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 9e5e97a31..0661e0c71 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,15 +1,14 @@ -FROM golang:1.23-bullseye +FROM golang:1.25-bookworm RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install --no-install-recommends\ - gettext-base=0.21-4 \ - iptables=1.8.7-1 \ - libgl1-mesa-dev=20.3.5-1 \ - xorg-dev=1:7.7+22 \ - libayatana-appindicator3-dev=0.5.5-2+deb11u2 \ + gettext-base=0.21-12 \ + iptables=1.8.9-2 \ + libgl1-mesa-dev=22.3.6-1+deb12u1 \ + xorg-dev=1:7.7+23 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* \ - && go install -v golang.org/x/tools/gopls@v0.18.1 + && go install -v golang.org/x/tools/gopls@latest WORKDIR /app diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..a546f5f5e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.env +.env.* +*.pem +*.key +*.crt +*.p12 diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 000000000..31898182e --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,11 @@ +#!/bin/bash + +echo "Running pre-push hook..." +if ! make lint; then + echo "" + echo "Hint: To push without verification, run:" + echo " git push --no-verify" + exit 1 +fi + +echo "All checks passed!" 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 new file mode 100644 index 000000000..ee3e84df6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,26 @@ +blank_issues_enabled: false +contact_links: + - 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. + - name: Cloud Support + url: https://docs.netbird.io/help/report-bug-issues + about: Contact NetBird for Cloud support. + - name: Client / Connection Troubleshooting + url: https://docs.netbird.io/help/troubleshooting-client + about: See the client troubleshooting guide for common connectivity issues. + - name: Self-host Troubleshooting + url: https://docs.netbird.io/selfhosted/troubleshooting + 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..b78b1417a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,45 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + open-pull-requests-limit: 15 + 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: "daily" + open-pull-requests-limit: 15 + groups: + 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..8e68054bd 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -12,6 +12,7 @@ - [ ] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) +- [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > 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..88b98293d --- /dev/null +++ b/.github/workflows/agent-network-e2e.yml @@ -0,0 +1,80 @@ +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: "" + +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 }} + run: go test -tags e2e -timeout 40m -v ./e2e/... diff --git a/.github/workflows/check-license-dependencies.yml b/.github/workflows/check-license-dependencies.yml index d3da427b0..17c9fdc8d 100644 --- a/.github/workflows/check-license-dependencies.yml +++ b/.github/workflows/check-license-dependencies.yml @@ -2,40 +2,114 @@ name: Check License Dependencies on: push: - branches: [ main ] + branches: [main] + paths: + - "go.mod" + - "go.sum" + - ".github/workflows/check-license-dependencies.yml" pull_request: + paths: + - "go.mod" + - "go.sum" + - ".github/workflows/check-license-dependencies.yml" jobs: - check-dependencies: + check-internal-dependencies: + name: Check Internal AGPL Dependencies 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: | - echo "Checking for dependencies on management/, signal/, and relay/ packages..." + - name: Check for problematic license dependencies + run: | + echo "Checking for dependencies on management/, signal/, relay/, and proxy/ packages..." + echo "" - # Find all directories except the problematic ones and system dirs - FOUND_ISSUES=0 - find . -maxdepth 1 -type d -not -name "." -not -name "management" -not -name "signal" -not -name "relay" -not -name ".git*" | sort | while read dir; do - echo "=== Checking $dir ===" - # Search for problematic imports, excluding test files - RESULTS=$(grep -r "github.com/netbirdio/netbird/\(management\|signal\|relay\)" "$dir" --include="*.go" | grep -v "_test.go" | grep -v "test_" | grep -v "/test/" || true) - if [ ! -z "$RESULTS" ]; then - echo "❌ Found problematic dependencies:" - echo "$RESULTS" - FOUND_ISSUES=1 + # Find all directories except the problematic ones and system dirs + FOUND_ISSUES=0 + while IFS= read -r dir; do + echo "=== Checking $dir ===" + # Search for problematic imports, excluding test files + RESULTS=$(grep -r "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\)" "$dir" --include="*.go" 2>/dev/null | grep -v "_test.go" | grep -v "test_" | grep -v "/test/" | grep -v "tools/idp-migrate/" || true) + if [ -n "$RESULTS" ]; then + echo "❌ Found problematic dependencies:" + echo "$RESULTS" + FOUND_ISSUES=1 + else + echo "✓ No problematic dependencies found" + fi + done < <(find . -maxdepth 1 -type d -not -name "." -not -name "management" -not -name "signal" -not -name "relay" -not -name "proxy" -not -name "combined" -not -name ".git*" | sort) + + echo "" + if [ $FOUND_ISSUES -eq 1 ]; then + echo "❌ Found dependencies on management/, signal/, relay/, or proxy/ packages" + echo "These packages are licensed under AGPLv3 and must not be imported by BSD-licensed code" + exit 1 else - echo "✓ No problematic dependencies found" + echo "" + echo "✅ All internal license dependencies are clean" fi - done - if [ $FOUND_ISSUES -eq 1 ]; then + + check-external-licenses: + name: Check External GPL/AGPL Licenses + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - 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: Check for GPL/AGPL licensed dependencies + run: | + echo "Checking for GPL/AGPL/LGPL licensed dependencies..." echo "" - echo "❌ Found dependencies on management/, signal/, or relay/ packages" - echo "These packages will change license and should not be imported by client or shared code" - exit 1 - else - echo "" - echo "✅ All license dependencies are clean" - fi + + # 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" + 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 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..552ccef29 --- /dev/null +++ b/.github/workflows/frontend-ui.yml @@ -0,0 +1,98 @@ +name: UI Frontend + +on: + pull_request: + paths: + - "client/ui/frontend/**" + - "client/ui/i18n/**" + - "client/ui/**/*.go" + - ".github/workflows/frontend-ui.yml" + push: + branches: + - main + 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 4571ce753..420749a0e 100644 --- a/.github/workflows/golang-test-darwin.yml +++ b/.github/workflows/golang-test-darwin.yml @@ -15,16 +15,19 @@ jobs: name: "Client / Unit" runs-on: macos-latest steps: - - name: Install Go - uses: actions/setup-go@v5 - with: - go-version: "1.23.x" - cache: false - 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@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') }} @@ -42,5 +45,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 /management) + # 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 cdd0910a4..9c795e783 100644 --- a/.github/workflows/golang-test-freebsd.yml +++ b/.github/workflows/golang-test-freebsd.yml @@ -15,20 +15,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.23.12.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,15 +48,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 -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 ./signal/... - 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 ba36c013b..0af506bba 100644 --- a/.github/workflows/golang-test-linux.yml +++ b/.github/workflows/golang-test-linux.yml @@ -18,9 +18,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,18 +30,18 @@ jobs: - 'management/**' - name: Install Go - uses: actions/setup-go@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: - go-version: "1.23.x" + go-version-file: "go.mod" cache: false - 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 +53,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' @@ -97,31 +99,43 @@ jobs: working-directory: relay run: CGO_ENABLED=1 GOARCH=386 go build -o relay-386 . + - name: Build combined + if: steps.cache.outputs.cache-hit != 'true' + working-directory: combined + run: CGO_ENABLED=1 go build . + + - name: Build combined 386 + if: steps.cache.outputs.cache-hit != 'true' + working-directory: combined + run: CGO_ENABLED=1 GOARCH=386 go build -o combined-386 . + test: name: "Client / Unit" needs: [build-cache] strategy: fail-fast: false matrix: - arch: [ '386','amd64' ] + arch: ["386", "amd64"] runs-on: ubuntu-22.04 steps: - - name: Install Go - uses: actions/setup-go@v5 - with: - go-version: "1.23.x" - cache: false - - 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@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: "go.mod" + cache: false - 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 }} @@ -131,7 +145,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' @@ -144,21 +158,39 @@ 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) + # 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: Install Go - uses: actions/setup-go@v5 - with: - go-version: "1.23.x" - cache: false - - 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@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: "go.mod" + cache: false - name: Get Go environment id: go-env @@ -167,7 +199,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: | @@ -200,11 +232,11 @@ jobs: -e GOCACHE=${CONTAINER_GOCACHE} \ -e GOMODCACHE=${CONTAINER_GOMODCACHE} \ -e CONTAINER=${CONTAINER} \ - golang:1.23-alpine \ + golang:1.25-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 /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: @@ -220,14 +252,16 @@ jobs: raceFlag: "-race" runs-on: ubuntu-22.04 steps: - - name: Install Go - uses: actions/setup-go@v5 - with: - go-version: "1.23.x" - cache: false - - 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@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: "go.mod" + cache: false - name: Install dependencies if: steps.cache.outputs.cache-hit != 'true' @@ -236,10 +270,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 }} @@ -258,8 +292,73 @@ jobs: run: | CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ go test ${{ matrix.raceFlag }} \ - -exec 'sudo' \ - -timeout 10m ./relay/... ./shared/relay/... + -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"] + runs-on: ubuntu-22.04 + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Install Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: "go.mod" + cache: false + + - name: Install dependencies + run: sudo apt update && sudo apt install -y gcc-multilib g++-multilib libc6-dev-i386 + + - name: Get Go environment + run: | + echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV + echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV + + - name: Cache Go modules + uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 + with: + path: | + ${{ env.cache }} + ${{ env.modcache }} + key: ${{ runner.os }}-gotest-cache-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-gotest-cache- + + - name: Install modules + run: go mod tidy + + - name: check git status + run: git --no-pager diff --exit-code + + - name: Test + run: | + CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ + go test -timeout 10m -p 1 -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" @@ -267,17 +366,19 @@ jobs: strategy: fail-fast: false matrix: - arch: [ '386','amd64' ] + arch: ["386", "amd64"] runs-on: ubuntu-22.04 steps: - - name: Install Go - uses: actions/setup-go@v5 - with: - go-version: "1.23.x" - cache: false - - 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@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: "go.mod" + cache: false - name: Install dependencies if: steps.cache.outputs.cache-hit != 'true' @@ -286,10 +387,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 }} @@ -308,35 +409,45 @@ 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: Install Go - uses: actions/setup-go@v5 - with: - go-version: "1.23.x" - cache: false - - 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@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: "go.mod" + cache: false - 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 }} @@ -352,34 +463,49 @@ jobs: run: git --no-pager diff --exit-code - name: Login to Docker hub - if: matrix.store == 'mysql' && (github.repository == github.head.repo.full_name || !github.head_ref) - uses: docker/login-action@v1 + if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_TOKEN }} + - name: docker login for root user + if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref + env: + DOCKER_USER: ${{ secrets.DOCKER_USER }} + DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} + run: echo "$DOCKER_TOKEN" | sudo docker login --username "$DOCKER_USER" --password-stdin + - name: download mysql image if: matrix.store == 'mysql' run: docker pull mlsmaycon/warmed-mysql:8 - 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 @@ -408,22 +534,25 @@ jobs: -v $PWD/prometheus.yml:/etc/prometheus/prometheus.yml \ -p 9090:9090 \ prom/prometheus - - name: Install Go - uses: actions/setup-go@v5 - with: - go-version: "1.23.x" - cache: false - 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@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: "go.mod" + cache: false - 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 }} @@ -439,35 +568,39 @@ jobs: run: git --no-pager diff --exit-code - name: Login to Docker hub - if: matrix.store == 'mysql' && (github.repository == github.head.repo.full_name || !github.head_ref) - uses: docker/login-action@v1 + if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_TOKEN }} - - name: download mysql image - if: matrix.store == 'mysql' - run: docker pull mlsmaycon/warmed-mysql:8 + - name: docker login for root user + if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref + env: + DOCKER_USER: ${{ secrets.DOCKER_USER }} + DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} + run: echo "$DOCKER_TOKEN" | sudo docker login --username "$DOCKER_USER" --password-stdin - name: Test run: | 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 @@ -497,22 +630,24 @@ jobs: -p 9090:9090 \ prom/prometheus - - name: Install Go - uses: actions/setup-go@v5 - with: - go-version: "1.23.x" - cache: false - - 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@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: "go.mod" + cache: false - 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 }} @@ -528,55 +663,61 @@ jobs: run: git --no-pager diff --exit-code - name: Login to Docker hub - if: matrix.store == 'mysql' && (github.repository == github.head.repo.full_name || !github.head_ref) - uses: docker/login-action@v1 + if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_TOKEN }} - - name: download mysql image - if: matrix.store == 'mysql' - run: docker pull mlsmaycon/warmed-mysql:8 + - name: docker login for root user + if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref + env: + DOCKER_USER: ${{ secrets.DOCKER_USER }} + DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} + run: echo "$DOCKER_TOKEN" | sudo docker login --username "$DOCKER_USER" --password-stdin - name: Test run: | 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: Install Go - uses: actions/setup-go@v5 - with: - go-version: "1.23.x" - cache: false - - 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@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: "go.mod" + cache: false - 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 }} @@ -596,6 +737,14 @@ jobs: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \ NETBIRD_STORE_ENGINE=${{ matrix.store }} \ CI=true \ - go test -tags=integration \ + go test -tags=integration -coverprofile=coverage.txt \ -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' \ -timeout 20m ./management/server/http/... + + - 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 2083c0721..50a5ba4d6 100644 --- a/.github/workflows/golang-test-windows.yml +++ b/.github/workflows/golang-test-windows.yml @@ -18,13 +18,15 @@ 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: "1.23.x" + go-version-file: "go.mod" cache: false - name: Get Go environment @@ -33,7 +35,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 +46,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\' @@ -63,10 +64,22 @@ jobs: - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOMODCACHE=${{ env.cache }} - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }} - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy - - run: echo "files=$(go list ./... | ForEach-Object { $_ } | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' })" >> $env:GITHUB_ENV + - 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 -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui' } + $goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe" + $cmd = "$goExe test -tags `"devcert privileged`" -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1" + Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd - name: test - run: PsExec64 -s -w ${{ github.workspace }} cmd.exe /c "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe test -tags=devcert -timeout 10m -p 1 ${{ env.files }} > test-out.txt 2>&1" + run: PsExec64 -s -w ${{ github.workspace }} cmd.exe /c "${{ github.workspace }}\run-tests.cmd" - name: test output if: ${{ always() }} run: Get-Content test-out.txt diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 2845b05a5..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 - skip: go.mod,go.sum + 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,24 +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: "1.23.x" + 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@v4 + uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1 with: version: latest - args: --timeout=12m --out-format colored-line-number + skip-cache: true + skip-save-cache: true + cache-invalidation-interval: 0 + args: --timeout=20m diff --git a/.github/workflows/install-script-test.yml b/.github/workflows/install-script-test.yml index 22d002a48..1514caedc 100644 --- a/.github/workflows/install-script-test.yml +++ b/.github/workflows/install-script-test.yml @@ -22,7 +22,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 index c7d43695b..44e912c73 100644 --- a/.github/workflows/mobile-build-validation.yml +++ b/.github/workflows/mobile-build-validation.yml @@ -16,30 +16,32 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 - - name: Install Go - uses: actions/setup-go@v5 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - go-version: "1.23.x" + persist-credentials: false + - name: Install Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: "go.mod" - name: Setup Android SDK - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 with: cmdline-tools-version: 8512546 - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 with: java-version: "11" distribution: "adopt" - name: NDK Cache id: ndk-cache - uses: actions/cache@v4 + uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: /usr/local/lib/android/sdk/ndk key: ndk-cache-23.1.7779620 - name: Setup NDK run: /usr/local/lib/android/sdk/cmdline-tools/7.0/bin/sdkmanager --install "ndk;23.1.7779620" - name: install gomobile - run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20240404231514-09dbf07665ed + 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 @@ -52,13 +54,15 @@ jobs: runs-on: macos-latest steps: - name: Checkout repository - uses: actions/checkout@v4 - - name: Install Go - uses: actions/setup-go@v5 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - go-version: "1.23.x" + persist-credentials: false + - name: Install Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: "go.mod" - name: install gomobile - run: go install golang.org/x/mobile/cmd/gomobile@v0.0.0-20240404231514-09dbf07665ed + 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 diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml new file mode 100644 index 000000000..67d65356c --- /dev/null +++ b/.github/workflows/pr-title-check.yml @@ -0,0 +1,51 @@ +name: PR Title Check + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +jobs: + check-title: + runs-on: ubuntu-latest + steps: + - name: Validate PR title prefix + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const title = context.payload.pull_request.title; + const allowedTags = [ + 'management', + 'client', + 'signal', + 'proxy', + 'relay', + 'misc', + 'infrastructure', + 'self-hosted', + 'doc', + ]; + + const pattern = /^\[([^\]]+)\]\s+.+/; + const match = title.match(pattern); + + if (!match) { + core.setFailed( + `PR title must start with a tag in brackets.\n` + + `Example: [client] fix something\n` + + `Allowed tags: ${allowedTags.join(', ')}` + ); + return; + } + + const tags = match[1].split(',').map(t => t.trim().toLowerCase()); + + const invalid = tags.filter(t => !allowedTags.includes(t)); + if (invalid.length > 0) { + core.setFailed( + `Invalid tag(s): ${invalid.join(', ')}\n` + + `Allowed tags: ${allowedTags.join(', ')}` + ); + return; + } + + console.log(`Valid PR title tags: [${tags.join(', ')}]`); diff --git a/.github/workflows/proto-version-check.yml b/.github/workflows/proto-version-check.yml new file mode 100644 index 000000000..fd2c2c908 --- /dev/null +++ b/.github/workflows/proto-version-check.yml @@ -0,0 +1,111 @@ +name: Proto Version Check + +on: + pull_request: + paths: + - "**/*.pb.go" + +jobs: + check-proto-versions: + runs-on: ubuntu-latest + steps: + - name: Check for proto tool version changes + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + per_page: 100, + }); + + // 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; + } + + // 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.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` + + ` 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( + `Proto version strings changed in generated files.\n` + + `This usually means the wrong protoc or protoc-gen-go version was used.\n` + + `Regenerate with the matching tool versions.\n\n` + + details + ); + return; + } + + console.log('No proto version string changes detected'); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e9741f541..76a5b36ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,41 +9,169 @@ on: pull_request: env: - SIGN_PIPE_VER: "v0.0.23" - GORELEASER_VER: "v2.3.2" + 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 }} cancel-in-progress: true jobs: - release: + release_freebsd_port: + name: "FreeBSD Port / Build & Test" runs-on: ubuntu-22.04 - env: - flags: "" 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: + persist-credentials: false + + - name: Generate FreeBSD port diff + run: bash -x release_files/freebsd-port-diff.sh + + - name: Generate FreeBSD port issue body + run: bash -x release_files/freebsd-port-issue-body.sh + + - name: Check if diff was generated + id: check_diff + run: | + if ls netbird-*.diff 1> /dev/null 2>&1; then + echo "diff_exists=true" >> $GITHUB_OUTPUT + else + echo "diff_exists=false" >> $GITHUB_OUTPUT + echo "No diff file generated (port may already be up to date)" + fi + + - name: Extract version + if: steps.check_diff.outputs.diff_exists == 'true' + id: version + run: | + VERSION=$(ls netbird-*.diff | sed 's/netbird-\(.*\)\.diff/\1/') + echo "version=$VERSION" >> $GITHUB_OUTPUT + 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' + 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 + + # Install Go for building + 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" + + # Clone ports tree (shallow, only what we need) + git clone --depth 1 --filter=blob:none https://git.FreeBSD.org/ports.git /usr/ports + cd /usr/ports + + run: | + set -e -x + export PATH=$PATH:/usr/local/go/bin + + # Find the diff file + echo "Finding diff file..." + DIFF_FILE=$(find $PWD -name "netbird-*.diff" -type f 2>/dev/null | head -1) + echo "Found: $DIFF_FILE" + + if [[ -z "$DIFF_FILE" ]]; then + echo "ERROR: Could not find diff file" + find ~ -name "*.diff" -type f 2>/dev/null || true + exit 1 + fi + + # Apply the generated diff from /usr/ports (diff has a/security/netbird/... paths) + cd /usr/ports + patch -p1 -V none < "$DIFF_FILE" + + # 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 + with: + name: freebsd-port-files + path: | + ./netbird-*-issue.txt + ./netbird-*.diff + retention-days: 30 + + release: + 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: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 # It is required for GoReleaser to work properly + persist-credentials: false + + - name: Parse semver string + id: semver_parser + uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2 + + - name: Set snapshot flag + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} + run: | + echo "flags=--snapshot" >> $GITHUB_ENV + + - name: Set build vars + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + run: | + if [[ "x-${{ steps.semver_parser.outputs.prerelease }}" == "x-" && "x-${{ github.repository }}" == "x-netbirdio/netbird" ]]; then + echo "x-${{ github.repository }}" + echo "x-${{ steps.semver_parser.outputs.prerelease }}" + echo "SKIP_PUBLISH=false" >> $GITHUB_ENV + else + echo "x-${{ github.repository }}" + echo "x-${{ steps.semver_parser.outputs.prerelease }}" + fi + + 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: "1.23" + 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 @@ -53,21 +181,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' - uses: docker/login-action@v3 + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -75,14 +205,23 @@ jobs: - name: Install OS build dependencies run: sudo apt update && sudo apt install -y -q gcc-arm-linux-gnueabihf gcc-aarch64-linux-gnu + - name: Decode GPG signing key + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + env: + GPG_RPM_PRIVATE_KEY: ${{ secrets.GPG_RPM_PRIVATE_KEY }} + run: | + echo "$GPG_RPM_PRIVATE_KEY" | base64 -d > /tmp/gpg-rpm-signing-key.asc + echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV + - name: Install goversioninfo run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e - name: Generate windows syso amd64 - 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 - uses: goreleaser/goreleaser-action@v4 + id: goreleaser + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: version: ${{ env.GORELEASER_VER }} args: release --clean ${{ env.flags }} @@ -91,26 +230,115 @@ jobs: HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }} UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }} + GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }} + NFPM_NETBIRD_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }} + 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 ' + dnf install -y -q rpm-sign curl >/dev/null 2>&1 + curl -sSL https://pkgs.netbird.io/yum/repodata/repomd.xml.key -o /tmp/rpm-pub.key + rpm --import /tmp/rpm-pub.key + echo "=== Verifying RPM signatures ===" + for rpm_file in /dist/*amd64*.rpm; do + [ -f "$rpm_file" ] || continue + echo "--- $(basename $rpm_file) ---" + rpm -K "$rpm_file" + done + ' + - name: Clean up GPG key + if: always() + run: rm -f /tmp/gpg-rpm-signing-key.asc + - name: Tag and push images (amd64 only) + 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') + run: | + set -euo pipefail + + resolve_tags() { + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "pr-${{ github.event.pull_request.number }}" + else + echo "main sha-$(git rev-parse --short HEAD)" + fi + } + + 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%%:*}" + for tag in $(resolve_tags); do + dst="${img_name}:${tag}" + echo "Tagging ${src} -> ${dst}" + docker tag "$src" "$dst" + docker push "$dst" + image_refs+=("$dst") + done + } + + cat > /tmp/goreleaser-artifacts.json <<'JSON' + ${{ steps.goreleaser.outputs.artifacts }} + JSON + + # 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** @@ -118,28 +346,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: "1.23" + 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 @@ -154,8 +397,26 @@ 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 + env: + GPG_RPM_PRIVATE_KEY: ${{ secrets.GPG_RPM_PRIVATE_KEY }} + run: | + echo "$GPG_RPM_PRIVATE_KEY" | base64 -d > /tmp/gpg-rpm-signing-key.asc + echo "GPG_RPM_KEY_FILE=/tmp/gpg-rpm-signing-key.asc" >> $GITHUB_ENV - name: Install LLVM-MinGW for ARM64 cross-compilation run: | @@ -166,13 +427,19 @@ jobs: 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 + - 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 }} @@ -181,8 +448,28 @@ jobs: HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }} UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }} + GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }} + NFPM_NETBIRD_UI_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }} + 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 - uses: actions/upload-artifact@v4 + id: upload_release_ui + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1 with: name: release-ui path: dist/ @@ -190,20 +477,23 @@ jobs: 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@v4 + 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@v5 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: - go-version: "1.23" + 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 @@ -215,28 +505,290 @@ 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 - trigger_signer: + 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_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_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_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 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, 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..d99f88b54 100644 --- a/.github/workflows/sync-tag.yml +++ b/.github/workflows/sync-tag.yml @@ -3,21 +3,49 @@ name: sync tag on: push: tags: - - 'v*' + - "v*" concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} cancel-in-progress: true +# Receiving workflows (cloud sync-tag, mobile bump-netbird) expect the short +# tag form (e.g. v0.30.0), not refs/tags/v0.30.0 — github.ref_name, not github.ref. jobs: trigger_sync_tag: runs-on: ubuntu-latest steps: - name: Trigger release tag sync - uses: benc-uk/workflow-dispatch@v1 + uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2 with: workflow: sync-tag.yml ref: main repo: ${{ secrets.UPSTREAM_REPO }} token: ${{ secrets.NC_GITHUB_TOKEN }} - inputs: '{ "tag": "${{ github.ref_name }}" }' \ No newline at end of file + inputs: '{ "tag": "${{ github.ref_name }}" }' + + trigger_android_bump: + runs-on: ubuntu-latest + if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') + steps: + - name: Trigger android-client submodule bump + uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2 + with: + workflow: bump-netbird.yml + ref: main + repo: netbirdio/android-client + token: ${{ secrets.NC_GITHUB_TOKEN }} + 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 }}" }' diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml index 3855baba2..0a4f2e371 100644 --- a/.github/workflows/test-infrastructure-files.yml +++ b/.github/workflows/test-infrastructure-files.yml @@ -6,10 +6,10 @@ on: - main 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 +20,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - store: [ 'sqlite', 'postgres', 'mysql' ] + store: ["sqlite", "postgres", "mysql"] services: postgres: image: ${{ (matrix.store == 'postgres') && 'postgres' || '' }} @@ -67,22 +67,24 @@ jobs: - name: Install curl run: sudo apt-get install -y curl - - name: Install Go - uses: actions/setup-go@v5 + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - go-version: "1.23.x" + persist-credentials: false + + - name: Install Go + 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') }} restore-keys: | ${{ runner.os }}-go- - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup MySQL privileges if: matrix.store == 'mysql' run: | @@ -139,8 +141,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 +207,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 +216,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 +225,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 @@ -243,6 +245,7 @@ jobs: working-directory: infrastructure_files/artifacts run: | sleep 30 + docker compose logs 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 @@ -253,7 +256,9 @@ jobs: 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 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 e4ac799bc..e8a12cdaf 100644 --- a/.github/workflows/wasm-build-validation.yml +++ b/.github/workflows/wasm-build-validation.yml @@ -14,26 +14,29 @@ jobs: js_lint: name: "JS / Lint" runs-on: ubuntu-latest + env: + GOOS: js + GOARCH: wasm steps: - name: Checkout repository - uses: actions/checkout@v4 - - name: Install Go - uses: actions/setup-go@v5 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - go-version: "1.23.x" + persist-credentials: false + - name: Install Go + 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@d6238b002a20823d52840fda27e2d4891c5952dc + uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1 with: version: latest install-mode: binary skip-cache: true - skip-pkg-cache: true - skip-build-cache: true - - name: Run golangci-lint for WASM - run: | - GOOS=js GOARCH=wasm golangci-lint run --timeout=12m --out-format colored-line-number ./client/... + skip-save-cache: true + cache-invalidation-interval: 0 + working-directory: ./client continue-on-error: true js_build: @@ -41,11 +44,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 - - name: Install Go - uses: actions/setup-go@v5 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - go-version: "1.23.x" + persist-credentials: false + - name: Install Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: "go.mod" - name: Build Wasm client run: GOOS=js GOARCH=wasm go build -o netbird.wasm ./client/wasm/cmd env: @@ -60,8 +65,7 @@ jobs: echo "Size: ${SIZE} bytes (${SIZE_MB} MB)" - if [ ${SIZE} -gt 52428800 ]; then - echo "Wasm binary size (${SIZE_MB}MB) exceeds 50MB 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 e6c0c0aca..305f3cb50 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,9 @@ +.claude .idea .run *.iml dist/ +!proxy/web/dist/ bin/ .env conf.json @@ -31,3 +33,5 @@ infrastructure_files/setup-*.env .DS_Store vendor/ /netbird +client/netbird-electron/ +management/server/types/testdata/ diff --git a/.golangci.yaml b/.golangci.yaml index 461677c2e..e350b9de7 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -1,139 +1,139 @@ -run: - # Timeout for analysis, e.g. 30s, 5m. - # Default: 1m - timeout: 6m - -# This file contains only configs which differ from defaults. -# All possible options can be found here https://github.com/golangci/golangci-lint/blob/master/.golangci.reference.yml -linters-settings: - errcheck: - # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. - # Such cases aren't reported by default. - # Default: false - check-type-assertions: false - - gosec: - includes: - - G101 # Look for hard coded credentials - #- G102 # Bind to all interfaces - - G103 # Audit the use of unsafe block - - G104 # Audit errors not checked - - G106 # Audit the use of ssh.InsecureIgnoreHostKey - #- G107 # Url provided to HTTP request as taint input - - G108 # Profiling endpoint automatically exposed on /debug/pprof - - G109 # Potential Integer overflow made by strconv.Atoi result conversion to int16/32 - - G110 # Potential DoS vulnerability via decompression bomb - - G111 # Potential directory traversal - #- G112 # Potential slowloris attack - - G113 # Usage of Rat.SetString in math/big with an overflow (CVE-2022-23772) - #- G114 # Use of net/http serve function that has no support for setting timeouts - - G201 # SQL query construction using format string - - G202 # SQL query construction using string concatenation - - G203 # Use of unescaped data in HTML templates - #- G204 # Audit use of command execution - - G301 # Poor file permissions used when creating a directory - - G302 # Poor file permissions used with chmod - - G303 # Creating tempfile using a predictable path - - G304 # File path provided as taint input - - G305 # File traversal when extracting zip/tar archive - - G306 # Poor file permissions used when writing to a new file - - G307 # Poor file permissions used when creating a file with os.Create - #- G401 # Detect the usage of DES, RC4, MD5 or SHA1 - #- G402 # Look for bad TLS connection settings - - G403 # Ensure minimum RSA key length of 2048 bits - #- G404 # Insecure random number source (rand) - #- G501 # Import blocklist: crypto/md5 - - G502 # Import blocklist: crypto/des - - G503 # Import blocklist: crypto/rc4 - - G504 # Import blocklist: net/http/cgi - #- G505 # Import blocklist: crypto/sha1 - - G601 # Implicit memory aliasing of items from a range statement - - G602 # Slice access out of bounds - - gocritic: - disabled-checks: - - commentFormatting - - captLocal - - deprecatedComment - - govet: - # Enable all analyzers. - # Default: false - enable-all: false - enable: - - nilness - - revive: - rules: - - name: exported - severity: warning - disabled: false - arguments: - - "checkPrivateReceivers" - - "sayRepetitiveInsteadOfStutters" - tenv: - # The option `all` will run against whole test files (`_test.go`) regardless of method/function signatures. - # Otherwise, only methods that take `*testing.T`, `*testing.B`, and `testing.TB` as arguments are checked. - # Default: false - all: true - +version: "2" linters: - disable-all: true + default: none enable: - ## enabled by default - - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases - - gosimple # specializes in simplifying a code - - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string - - ineffassign # detects when assignments to existing variables are not used - - staticcheck # is a go vet on steroids, applying a ton of static analysis checks - - tenv # Tenv is analyzer that detects using os.Setenv instead of t.Setenv since Go1.17. - - typecheck # like the front-end of a Go compiler, parses and type-checks Go code - - unused # checks for unused constants, variables, functions and types - ## disable by default but the have interesting results so lets add them - - bodyclose # checks whether HTTP response body is closed successfully - - dupword # dupword checks for duplicate words in the source code - - durationcheck # durationcheck checks for two durations multiplied together - - forbidigo # forbidigo forbids identifiers - - gocritic # provides diagnostics that check for bugs, performance and style issues - - gosec # inspects source code for security problems - - mirror # mirror reports wrong mirror patterns of bytes/strings usage - - misspell # misspess finds commonly misspelled English words in comments - - nilerr # finds the code that returns nil even if it checks that the error is not nil - - nilnil # checks that there is no simultaneous return of nil error and an invalid value - - predeclared # predeclared finds code that shadows one of Go's predeclared identifiers - - revive # Fast, configurable, extensible, flexible, and beautiful linter for Go. Drop-in replacement of golint. - - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed - # - thelper # thelper detects Go test helpers without t.Helper() call and checks the consistency of test helpers. - - wastedassign # wastedassign finds wasted assignment statements + - bodyclose + - dupword + - durationcheck + - errcheck + - forbidigo + - gocritic + - gosec + - govet + - ineffassign + - mirror + - misspell + - nilerr + - nilnil + - predeclared + - revive + - sqlclosecheck + - staticcheck + - unused + - wastedassign + settings: + errcheck: + check-type-assertions: false + gocritic: + disabled-checks: + - commentFormatting + - captLocal + - deprecatedComment + gosec: + includes: + - G101 + - G103 + - G104 + - G106 + - G108 + - G109 + - G110 + - G111 + - G201 + - G202 + - G203 + - G301 + - G302 + - G303 + - G304 + - G305 + - G306 + - G307 + - G403 + - G502 + - G503 + - G504 + - G601 + - G602 + 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: + - name: exported + arguments: + - checkPrivateReceivers + - sayRepetitiveInsteadOfStutters + severity: warning + disabled: false + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - linters: + - forbidigo + path: management/cmd/root\.go + - linters: + - forbidigo + path: signal/cmd/root\.go + - linters: + - unused + path: sharedsock/filter\.go + - linters: + - unused + path: client/firewall/iptables/rule\.go + - linters: + - gosec + - mirror + path: test\.go + - linters: + - nilnil + path: mock\.go + - linters: + - staticcheck + text: grpc.DialContext is deprecated + - linters: + - staticcheck + text: grpc.WithBlock is deprecated + - linters: + - staticcheck + text: "QF1001" + - linters: + - staticcheck + text: "QF1008" + - 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$ + - examples$ issues: - # Maximum count of issues with the same text. - # Set to 0 to disable. - # Default: 3 max-same-issues: 5 - - exclude-rules: - # allow fmt - - path: management/cmd/root\.go - linters: forbidigo - - path: signal/cmd/root\.go - linters: forbidigo - - path: sharedsock/filter\.go - linters: - - unused - - path: client/firewall/iptables/rule\.go - linters: - - unused - - path: test\.go - linters: - - mirror - - gosec - - path: mock\.go - linters: - - nilnil - # Exclude specific deprecation warnings for grpc methods - - linters: - - staticcheck - text: "grpc.DialContext is deprecated" - - linters: - - staticcheck - text: "grpc.WithBlock is deprecated" +formatters: + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 952e946dc..8dd05a192 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,30 @@ 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 }}" + + - id: netbird-server + dir: combined + env: + - CGO_ENABLED=1 + - >- + {{- if eq .Runtime.Goos "linux" }} + {{- if eq .Arch "arm64"}}CC=aarch64-linux-gnu-gcc{{- end }} + {{- if eq .Arch "arm"}}CC=arm-linux-gnueabihf-gcc{{- end }} + {{- end }} + binary: netbird-server + goos: + - linux + goarch: + - amd64 + - arm64 + - arm + 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 }}" @@ -116,6 +146,46 @@ 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 }}" + + - id: netbird-proxy + dir: proxy/cmd/proxy + env: [CGO_ENABLED=0] + binary: netbird-proxy + goos: + - linux + goarch: + - amd64 + - arm64 + - arm + goarm: + - 7 + ldflags: + - -s -w -X main.Version={{.Version}} -X main.Commit={{.Commit}} -X main.BuildDate={{.CommitDate}} + mod_timestamp: "{{ .CommitTimestamp }}" + + - id: netbird-idp-migrate + dir: tools/idp-migrate + env: + - CGO_ENABLED=1 + - >- + {{- if eq .Runtime.Goos "linux" }} + {{- if eq .Arch "arm64"}}CC=aarch64-linux-gnu-gcc{{- end }} + {{- if eq .Arch "arm"}}CC=arm-linux-gnueabihf-gcc{{- end }} + {{- end }} + binary: netbird-idp-migrate + goos: + - linux + goarch: + - amd64 + - arm64 + - arm + 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 }}" @@ -132,18 +202,23 @@ archives: - netbird-wasm name_template: "{{ .ProjectName }}_{{ .Version }}" format: binary + - id: netbird-idp-migrate + builds: + - netbird-idp-migrate + name_template: "netbird-idp-migrate_{{ .Version }}_{{ .Os }}_{{ .Arch }}" nfpms: - maintainer: Netbird description: Netbird client. homepage: https://netbird.io/ - id: netbird-deb + license: BSD-3-Clause + vendor: NetBird + id: netbird_deb bindir: /usr/bin builds: - netbird formats: - deb - scripts: postinstall: "release_files/post_install.sh" preremove: "release_files/pre_remove.sh" @@ -151,533 +226,206 @@ nfpms: - maintainer: Netbird description: Netbird client. homepage: https://netbird.io/ - id: netbird-rpm + license: BSD-3-Clause + vendor: NetBird + id: netbird_rpm bindir: /usr/bin builds: - netbird formats: - rpm - scripts: postinstall: "release_files/post_install.sh" preremove: "release_files/pre_remove.sh" -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" + rpm: + signature: + key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}' +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" - - 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" -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: 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 brews: - ids: - default + skip_upload: "{{ .Env.SKIP_PUBLISH }}" repository: owner: netbirdio name: homebrew-tap @@ -694,16 +442,18 @@ brews: uploads: - name: debian + skip: "{{ .Env.SKIP_PUBLISH }}" ids: - - netbird-deb + - netbird_deb mode: archive target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=stable;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package= username: dev@wiretrustee.com method: PUT - name: yum + skip: "{{ .Env.SKIP_PUBLISH }}" ids: - - netbird-rpm + - netbird_rpm mode: archive target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }} username: dev@wiretrustee.com @@ -713,8 +463,14 @@ checksum: 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 release: 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 a243702ea..197fcd440 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 @@ -61,7 +71,9 @@ nfpms: - maintainer: Netbird description: Netbird client UI. homepage: https://netbird.io/ - id: netbird-ui-deb + license: BSD-3-Clause + vendor: NetBird + id: netbird_ui_deb package_name: netbird-ui builds: - netbird-ui @@ -70,9 +82,9 @@ 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 dependencies: - netbird @@ -80,7 +92,9 @@ nfpms: - maintainer: Netbird description: Netbird client UI. homepage: https://netbird.io/ - id: netbird-ui-rpm + license: BSD-3-Clause + vendor: NetBird + id: netbird_ui_rpm package_name: netbird-ui builds: - netbird-ui @@ -89,25 +103,31 @@ 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 dependencies: - netbird + 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 + - netbird_ui_deb mode: archive target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=stable;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package= username: dev@wiretrustee.com method: PUT - name: yum + skip: "{{ .Env.SKIP_PUBLISH }}" ids: - - netbird-ui-rpm + - netbird_ui_rpm mode: archive target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }} username: dev@wiretrustee.com diff --git a/.goreleaser_ui_darwin.yaml b/.goreleaser_ui_darwin.yaml index 0a0082075..96e15371a 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 @@ -20,8 +29,6 @@ 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: - - load_wgnt_from_rsrc universal_binaries: - id: netbird-ui-darwin diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c82cfc763..261083783 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,13 +8,14 @@ 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. ## Contents - [Contributing to NetBird](#contributing-to-netbird) - [Contents](#contents) - [Code of conduct](#code-of-conduct) + - [Discuss changes with the NetBird team first](#discuss-changes-with-the-netbird-team-first) - [Directory structure](#directory-structure) - [Development setup](#development-setup) - [Requirements](#requirements) @@ -33,6 +34,14 @@ Conduct which can be found in the file [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to community@netbird.io. +## Discuss changes with the NetBird team first + +Changes to the **public API**, **gRPC protocols**, **functionality behavior**, **CLI / service flags**, or **new features** should be discussed with the NetBird team before you start the work. These surfaces are part of NetBird's contract with operators, self-hosters, and downstream integrators, and changes to them have compatibility, security, and release-planning implications that benefit from an early conversation. + +Open an issue or reach out on [Slack](https://docs.netbird.io/slack-url) to talk through what you have in mind. We'll help shape the change, flag any constraints we know about, and confirm the direction so the PR review can focus on implementation rather than design. + +Typical bug fixes, internal refactors, documentation updates, and tests do not need pre-discussion — open the PR directly. + ## Directory structure The NetBird project monorepo is organized to maintain most of its individual dependencies code within their directories, except for a few auxiliary or shared packages. @@ -70,13 +79,21 @@ dependencies are installed. Here is a short guide on how that can be done. ### Requirements -#### Go 1.21 +#### Go 1.25 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.25 +- 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. @@ -136,6 +153,14 @@ checked out and set up: go mod tidy ``` +6. Configure Git hooks for automatic linting: + + ```bash + make setup-hooks + ``` + + This will configure Git to run linting automatically before each push, helping catch issues early. + ### Dev Container Support If you prefer using a dev container for development, NetBird now includes support for dev containers. @@ -197,6 +222,39 @@ 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 `--`: + +``` +task dev -- --daemon-addr=tcp://127.0.0.1:41731 +``` + +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: @@ -234,10 +292,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,8 +332,6 @@ go test -exec sudo ./... ``` > 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: - Keep functions as simple as possible, with a single purpose diff --git a/CONTRIBUTOR_LICENSE_AGREEMENT.md b/CONTRIBUTOR_LICENSE_AGREEMENT.md index 1fdd072c9..b0a6ee218 100644 --- a/CONTRIBUTOR_LICENSE_AGREEMENT.md +++ b/CONTRIBUTOR_LICENSE_AGREEMENT.md @@ -1,7 +1,7 @@ ## Contributor License Agreement This Contributor License Agreement (referred to as the "Agreement") is entered into by the individual -submitting this Agreement and NetBird GmbH, c/o Max-Beer-Straße 2-4 Münzstraße 12 10178 Berlin, Germany, +submitting this Agreement and NetBird GmbH, Brunnenstraße 196, 10119 Berlin, Germany, referred to as "NetBird" (collectively, the "Parties"). The Agreement outlines the terms and conditions under which NetBird may utilize software contributions provided by the Contributor for inclusion in its software development projects. By submitting this Agreement, the Contributor confirms their acceptance diff --git a/LICENSE b/LICENSE index 594691464..d922f155a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -This BSD‑3‑Clause license applies to all parts of the repository except for the directories management/, signal/ and relay/. +This BSD‑3‑Clause license applies to all parts of the repository except for the directories management/, signal/, relay/ and combined/. Those directories are licensed under the GNU Affero General Public License version 3.0 (AGPLv3). See the respective LICENSE files inside each directory. BSD 3-Clause License diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..0a4fad2f2 --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +.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 +$(GOLANGCI_LINT): + @echo "Installing golangci-lint..." + @mkdir -p ./bin + @GOBIN=$(shell pwd)/bin go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest + +# Lint only changed files (fast, for pre-push) +lint: $(GOLANGCI_LINT) + @echo "Running lint on changed files..." + @$(GOLANGCI_LINT) run --new-from-rev=origin/main --timeout=2m + +# Lint entire codebase (slow, matches CI) +lint-all: $(GOLANGCI_LINT) + @echo "Running lint on all files..." + @$(GOLANGCI_LINT) run --timeout=12m + +# Just install the linter +lint-install: $(GOLANGCI_LINT) + +# Setup git hooks for all developers +setup-hooks: + @git config core.hooksPath .githooks + @chmod +x .githooks/pre-push + @echo "✅ Git hooks configured! Pre-push will now run 'make lint'" + +# 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 2c5ee2ab6..40c6b9ed5 100644 --- a/README.md +++ b/README.md @@ -1,49 +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 -
- -
-
- - 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.** @@ -51,91 +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 -### NetBird on Lawrence Systems (Video) -[![Watch the video](https://img.youtube.com/vi/Kwrff6h0rEw/0.jpg)](https://www.youtube.com/watch?v=Kwrff6h0rEw) +### Self-host NetBird (video) + +[![Watch the video](https://img.youtube.com/vi/bZAgpT6nzaQ/0.jpg)](https://youtu.be/bZAgpT6nzaQ) ### Key features -| 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 ports: **3478**, **49152-65535**. -- **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-with-zitadel.sh | 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 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/agent-network/README.md b/agent-network/README.md new file mode 100644 index 000000000..1997ea299 --- /dev/null +++ b/agent-network/README.md @@ -0,0 +1,73 @@ +# 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** + +## 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. + +## Documentation + +Full documentation, architecture, and quickstart: +**https://docs.netbird.io/agent-network** diff --git a/client/Dockerfile b/client/Dockerfile index 5cd459357..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.22.2 +FROM alpine:3.24 # iproute2: busybox doesn't display ip rules properly RUN apk add --no-cache \ bash \ @@ -17,11 +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_ENTRYPOINT_SERVICE_TIMEOUT="5" \ - NB_ENTRYPOINT_LOGIN_TIMEOUT="5" + 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 5fa8de0a5..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,11 +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_ENTRYPOINT_SERVICE_TIMEOUT="5" \ - NB_ENTRYPOINT_LOGIN_TIMEOUT="1" + 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 d2d0c37f6..2266ff53d 100644 --- a/client/android/client.go +++ b/client/android/client.go @@ -4,22 +4,31 @@ package android import ( "context" + "fmt" "os" "slices" "sync" + "time" + + "golang.org/x/exp/maps" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/debug" "github.com/netbirdio/netbird/client/internal/dns" "github.com/netbirdio/netbird/client/internal/listener" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" + "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/system" "github.com/netbirdio/netbird/formatter" - "github.com/netbirdio/netbird/client/net" + "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 @@ -53,7 +62,6 @@ func init() { // Client struct manage the life circle of background service type Client struct { - cfgFile string tunAdapter device.TunAdapter iFaceDiscover IFaceDiscover recorder *peer.Status @@ -63,16 +71,38 @@ type Client struct { uiVersion string networkChangeListener listener.NetworkChangeListener + stateMu sync.RWMutex connectClient *internal.ConnectClient + config *profilemanager.Config + cacheDir string +} + +func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) { + c.stateMu.Lock() + defer c.stateMu.Unlock() + c.config = cfg + c.cacheDir = cacheDir + c.connectClient = cc +} + +func (c *Client) stateSnapshot() (*profilemanager.Config, string, *internal.ConnectClient) { + c.stateMu.RLock() + defer c.stateMu.RUnlock() + return c.config, c.cacheDir, c.connectClient +} + +func (c *Client) getConnectClient() *internal.ConnectClient { + c.stateMu.RLock() + defer c.stateMu.RUnlock() + return c.connectClient } // NewClient instantiate a new Client -func NewClient(cfgFile string, androidSDKVersion int, deviceName string, uiVersion string, tunAdapter TunAdapter, iFaceDiscover IFaceDiscover, networkChangeListener NetworkChangeListener) *Client { +func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAdapter TunAdapter, iFaceDiscover IFaceDiscover, networkChangeListener NetworkChangeListener) *Client { execWorkaround(androidSDKVersion) net.SetAndroidProtectSocketFn(tunAdapter.ProtectSocket) return &Client{ - cfgFile: cfgFile, deviceName: deviceName, uiVersion: uiVersion, tunAdapter: tunAdapter, @@ -84,10 +114,17 @@ func NewClient(cfgFile string, androidSDKVersion int, deviceName string, uiVersi } // Run start the internal client. It is a blocker function -func (c *Client) Run(urlOpener URLOpener, dns *DNSList, dnsReadyListener DnsReadyListener, envList *EnvList) error { +func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroidTV bool, dns *DNSList, dnsReadyListener DnsReadyListener, envList *EnvList) error { exportEnvList(envList) + + cfgFile := platformFiles.ConfigurationFilePath() + stateFile := platformFiles.StateFilePath() + cacheDir := platformFiles.CacheDir() + + log.Infof("Starting client with config: %s, state: %s", cfgFile, stateFile) + cfg, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ - ConfigPath: c.cfgFile, + ConfigPath: cfgFile, }) if err != nil { return err @@ -107,23 +144,31 @@ func (c *Client) Run(urlOpener URLOpener, dns *DNSList, dnsReadyListener DnsRead c.ctxCancelLock.Unlock() auth := NewAuthWithConfig(ctx, cfg) - err = auth.login(urlOpener) + err = auth.login(urlOpener, isAndroidTV) if err != nil { return err } // todo do not throw error in case of cancelled context ctx = internal.CtxInitState(ctx) - c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder) - return c.connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener) + connectClient := internal.NewConnectClient(ctx, cfg, c.recorder) + c.setState(cfg, cacheDir, connectClient) + return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir) } // RunWithoutLogin we apply this type of run function when the backed has been started without UI (i.e. after reboot). // In this case make no sense handle registration steps. -func (c *Client) RunWithoutLogin(dns *DNSList, dnsReadyListener DnsReadyListener, envList *EnvList) error { +func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsReadyListener DnsReadyListener, envList *EnvList) error { exportEnvList(envList) + + cfgFile := platformFiles.ConfigurationFilePath() + stateFile := platformFiles.StateFilePath() + cacheDir := platformFiles.CacheDir() + + log.Infof("Starting client without login with config: %s, state: %s", cfgFile, stateFile) + cfg, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ - ConfigPath: c.cfgFile, + ConfigPath: cfgFile, }) if err != nil { return err @@ -141,8 +186,9 @@ func (c *Client) RunWithoutLogin(dns *DNSList, dnsReadyListener DnsReadyListener // todo do not throw error in case of cancelled context ctx = internal.CtxInitState(ctx) - c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder) - return c.connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener) + connectClient := internal.NewConnectClient(ctx, cfg, c.recorder) + c.setState(cfg, cacheDir, connectClient) + return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir) } // Stop the internal client and free the resources @@ -156,6 +202,90 @@ func (c *Client) Stop() { c.ctxCancel() } +func (c *Client) RenewTun(fd int) error { + cc := c.getConnectClient() + if cc == nil { + return fmt.Errorf("engine not running") + } + + e := cc.Engine() + if e == nil { + return fmt.Errorf("engine not initialized") + } + + return e.RenewTun(fd) +} + +// DebugBundle generates a debug bundle, uploads it, and returns the upload key. +// It works both with and without a running engine. +func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) { + cfg, cacheDir, cc := c.stateSnapshot() + + // If the engine hasn't been started, load config from disk + if cfg == nil { + var err error + cfg, err = profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: platformFiles.ConfigurationFilePath(), + }) + if err != nil { + return "", fmt.Errorf("load config: %w", err) + } + cacheDir = platformFiles.CacheDir() + } + + deps := debug.GeneratorDependencies{ + InternalConfig: cfg, + StatusRecorder: c.recorder, + TempDir: cacheDir, + } + + if cc != nil { + resp, err := cc.GetLatestSyncResponse() + if err != nil { + log.Warnf("get latest sync response: %v", err) + } + deps.SyncResponse = resp + + if e := cc.Engine(); e != nil { + 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, + IncludeSystemInfo: true, + }, + ) + + path, err := bundleGenerator.Generate() + if err != nil { + return "", fmt.Errorf("generate debug bundle: %w", err) + } + defer func() { + if err := os.Remove(path); err != nil { + log.Errorf("failed to remove debug bundle file: %v", err) + } + }() + + uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path) + if err != nil { + return "", fmt.Errorf("upload debug bundle: %w", err) + } + + log.Infof("debug bundle uploaded with key %s", key) + return key, nil +} + // SetTraceLogLevel configure the logger to trace level func (c *Client) SetTraceLogLevel() { log.SetLevel(log.TraceLevel) @@ -174,9 +304,11 @@ func (c *Client) PeersList() *PeerInfoArray { peerInfos := make([]PeerInfo, len(fullStatus.Peers)) for n, p := range fullStatus.Peers { pi := PeerInfo{ - p.IP, - p.FQDN, - p.ConnStatus.String(), + IP: p.IP, + IPv6: p.IPv6, + FQDN: p.FQDN, + ConnStatus: int(p.ConnStatus), + Routes: PeerRoutes{routes: maps.Keys(p.GetRoutes())}, } peerInfos[n] = pi } @@ -184,12 +316,13 @@ func (c *Client) PeersList() *PeerInfoArray { } func (c *Client) Networks() *NetworkArray { - if c.connectClient == nil { + cc := c.getConnectClient() + if cc == nil { log.Error("not connected") return nil } - engine := c.connectClient.Engine() + engine := cc.Engine() if engine == nil { log.Error("could not get engine") return nil @@ -201,37 +334,90 @@ func (c *Client) Networks() *NetworkArray { return nil } + routeSelector := routeManager.GetRouteSelector() + if routeSelector == nil { + log.Error("could not get route selector") + return nil + } + + routesMap := routeManager.GetClientRoutesWithNetID() + v6Merged := route.V6ExitMergeSet(routesMap) + resolvedDomains := c.recorder.GetResolvedDomainsStates() + networkArray := &NetworkArray{ items: make([]Network, 0), } - for id, routes := range routeManager.GetClientRoutesWithNetID() { + for id, routes := range routesMap { if len(routes) == 0 { continue } - - r := routes[0] - netStr := r.Network.String() - if r.IsDynamic() { - netStr = r.Domains.SafeString() - } - - peer, 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: peer.FQDN, - Status: peer.ConnStatus.String(), + + 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() @@ -253,6 +439,69 @@ 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 { + return nil, fmt.Errorf("not connected") + } + + engine := client.Engine() + if engine == nil { + return nil, fmt.Errorf("engine is not running") + } + + manager := engine.GetRouteManager() + if manager == nil { + return nil, fmt.Errorf("could not get route manager") + } + + return manager, nil +} + +func (c *Client) SelectRoute(route string) error { + manager, err := c.getRouteManager() + if err != nil { + return err + } + + return c.toggleRoute(selectRouteCommand{route: route, manager: manager}) +} + +func (c *Client) DeselectRoute(route string) error { + manager, err := c.getRouteManager() + if err != nil { + return err + } + + return c.toggleRoute(deselectRouteCommand{route: route, manager: manager}) +} + +// getNetworkDomainsFromRoute extracts domains from a route and enriches each domain +// with its resolved IP addresses from the provided resolvedDomains map. +func (c *Client) getNetworkDomainsFromRoute(route *route.Route, resolvedDomains map[domain.Domain]peer.ResolvedDomainInfo) NetworkDomains { + domains := NetworkDomains{} + + for _, d := range route.Domains { + networkDomain := NetworkDomain{ + Address: d.SafeString(), + } + + if info, exists := resolvedDomains[d]; exists { + for _, prefix := range info.Prefixes { + networkDomain.addResolvedIP(prefix.Addr().String()) + } + } + + domains.Add(&networkDomain) + } + + return domains +} + func exportEnvList(list *EnvList) { if list == nil { return diff --git a/client/android/env_list.go b/client/android/env_list.go index 04122300a..d0e0a1e78 100644 --- a/client/android/env_list.go +++ b/client/android/env_list.go @@ -1,10 +1,19 @@ package android -import "github.com/netbirdio/netbird/client/internal/peer" +import ( + "github.com/netbirdio/netbird/client/internal/lazyconn" + "github.com/netbirdio/netbird/client/internal/peer" +) var ( - // EnvKeyNBForceRelay Exported for Android java client + // EnvKeyNBForceRelay Exported for Android java client to force relay connections EnvKeyNBForceRelay = peer.EnvKeyNBForceRelay + + // EnvKeyNBLazyConn Exported for Android java client to configure lazy connection + EnvKeyNBLazyConn = lazyconn.EnvLazyConn + + // EnvKeyNBInactivityThreshold Exported for Android java client to configure connection inactivity threshold + EnvKeyNBInactivityThreshold = lazyconn.EnvInactivityThreshold ) // EnvList wraps a Go map for export to Java diff --git a/client/android/login.go b/client/android/login.go index 16df24ba8..a9422cdbf 100644 --- a/client/android/login.go +++ b/client/android/login.go @@ -3,15 +3,7 @@ package android import ( "context" "fmt" - "time" - "github.com/cenkalti/backoff/v4" - log "github.com/sirupsen/logrus" - "google.golang.org/grpc/codes" - gstatus "google.golang.org/grpc/status" - - "github.com/netbirdio/netbird/client/cmd" - "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/system" @@ -32,7 +24,7 @@ type ErrListener interface { // URLOpener it is a callback interface. The Open function will be triggered if // the backend want to show an url for the user type URLOpener interface { - Open(string) + Open(url string, userCode string) OnLoginSuccess() } @@ -84,34 +76,21 @@ func (a *Auth) SaveConfigIfSSOSupported(listener SSOListener) { } func (a *Auth) saveConfigIfSSOSupported() (bool, error) { - supportsSSO := true - err := a.withBackOff(a.ctx, func() (err error) { - _, err = internal.GetPKCEAuthorizationFlowInfo(a.ctx, a.config.PrivateKey, a.config.ManagementURL, nil) - if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.NotFound || s.Code() == codes.Unimplemented) { - _, err = internal.GetDeviceAuthorizationFlowInfo(a.ctx, a.config.PrivateKey, a.config.ManagementURL) - s, ok := gstatus.FromError(err) - if !ok { - return err - } - if s.Code() == codes.NotFound || s.Code() == codes.Unimplemented { - supportsSSO = false - err = nil - } + authClient, err := auth.NewAuth(a.ctx, a.config.PrivateKey, a.config.ManagementURL, a.config) + if err != nil { + return false, fmt.Errorf("failed to create auth client: %v", err) + } + defer authClient.Close() - return err - } - - return err - }) + supportsSSO, err := authClient.IsSSOSupported(a.ctx) + if err != nil { + return false, fmt.Errorf("failed to check SSO support: %v", err) + } if !supportsSSO { return false, nil } - if err != nil { - return false, fmt.Errorf("backoff cycle failed: %v", err) - } - err = profilemanager.WriteOutConfig(a.cfgPath, a.config) return true, err } @@ -129,28 +108,26 @@ func (a *Auth) LoginWithSetupKeyAndSaveConfig(resultListener ErrListener, setupK } func (a *Auth) loginWithSetupKeyAndSaveConfig(setupKey string, deviceName string) error { + authClient, err := auth.NewAuth(a.ctx, a.config.PrivateKey, a.config.ManagementURL, a.config) + if err != nil { + return fmt.Errorf("failed to create auth client: %v", err) + } + defer authClient.Close() + //nolint ctxWithValues := context.WithValue(a.ctx, system.DeviceNameCtxKey, deviceName) - - err := a.withBackOff(a.ctx, func() error { - backoffErr := internal.Login(ctxWithValues, a.config, setupKey, "") - if s, ok := gstatus.FromError(backoffErr); ok && (s.Code() == codes.PermissionDenied) { - // we got an answer from management, exit backoff earlier - return backoff.Permanent(backoffErr) - } - return backoffErr - }) + err, _ = authClient.Login(ctxWithValues, setupKey, "") if err != nil { - return fmt.Errorf("backoff cycle failed: %v", err) + return fmt.Errorf("login failed: %v", err) } return profilemanager.WriteOutConfig(a.cfgPath, a.config) } // Login try register the client on the server -func (a *Auth) Login(resultListener ErrListener, urlOpener URLOpener) { +func (a *Auth) Login(resultListener ErrListener, urlOpener URLOpener, isAndroidTV bool) { go func() { - err := a.login(urlOpener) + err := a.login(urlOpener, isAndroidTV) if err != nil { resultListener.OnError(err) } else { @@ -159,50 +136,42 @@ func (a *Auth) Login(resultListener ErrListener, urlOpener URLOpener) { }() } -func (a *Auth) login(urlOpener URLOpener) error { - var needsLogin bool +func (a *Auth) login(urlOpener URLOpener, isAndroidTV bool) error { + authClient, err := auth.NewAuth(a.ctx, a.config.PrivateKey, a.config.ManagementURL, a.config) + if err != nil { + return fmt.Errorf("failed to create auth client: %v", err) + } + defer authClient.Close() // check if we need to generate JWT token - err := a.withBackOff(a.ctx, func() (err error) { - needsLogin, err = internal.IsLoginRequired(a.ctx, a.config) - return - }) + needsLogin, err := authClient.IsLoginRequired(a.ctx) if err != nil { - return fmt.Errorf("backoff cycle failed: %v", err) + return fmt.Errorf("failed to check login requirement: %v", err) } jwtToken := "" if needsLogin { - tokenInfo, err := a.foregroundGetTokenInfo(urlOpener) + tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV) if err != nil { return fmt.Errorf("interactive sso login failed: %v", err) } jwtToken = tokenInfo.GetTokenToUse() } - err = a.withBackOff(a.ctx, func() error { - err := internal.Login(a.ctx, a.config, "", jwtToken) - - if err == nil { - go urlOpener.OnLoginSuccess() - } - - if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.InvalidArgument || s.Code() == codes.PermissionDenied) { - return nil - } - return err - }) + err, _ = authClient.Login(a.ctx, "", jwtToken) if err != nil { - return fmt.Errorf("backoff cycle failed: %v", err) + return fmt.Errorf("login failed: %v", err) } + go urlOpener.OnLoginSuccess() + return nil } -func (a *Auth) foregroundGetTokenInfo(urlOpener URLOpener) (*auth.TokenInfo, error) { - oAuthFlow, err := auth.NewOAuthFlow(a.ctx, a.config, false, "") +func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener, isAndroidTV bool) (*auth.TokenInfo, error) { + oAuthFlow, err := authClient.GetOAuthFlow(a.ctx, isAndroidTV) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to get OAuth flow: %v", err) } flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO()) @@ -210,24 +179,12 @@ func (a *Auth) foregroundGetTokenInfo(urlOpener URLOpener) (*auth.TokenInfo, err return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err) } - go urlOpener.Open(flowInfo.VerificationURIComplete) + go urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode) - waitTimeout := time.Duration(flowInfo.ExpiresIn) * time.Second - waitCTX, cancel := context.WithTimeout(a.ctx, waitTimeout) - defer cancel() - tokenInfo, err := oAuthFlow.WaitToken(waitCTX, flowInfo) + tokenInfo, err := oAuthFlow.WaitToken(a.ctx, flowInfo) if err != nil { return nil, fmt.Errorf("waiting for browser login failed: %v", err) } return &tokenInfo, nil } - -func (a *Auth) withBackOff(ctx context.Context, bf func() error) error { - return backoff.RetryNotify( - bf, - backoff.WithContext(cmd.CLIBackOffSettings, ctx), - func(err error, duration time.Duration) { - log.Warnf("retrying Login to the Management service in %v due to error %v", duration, err) - }) -} diff --git a/client/android/network_domains.go b/client/android/network_domains.go new file mode 100644 index 000000000..a459bdc23 --- /dev/null +++ b/client/android/network_domains.go @@ -0,0 +1,56 @@ +//go:build android + +package android + +import "fmt" + +type ResolvedIPs struct { + resolvedIPs []string +} + +func (r *ResolvedIPs) Add(ipAddress string) { + r.resolvedIPs = append(r.resolvedIPs, ipAddress) +} + +func (r *ResolvedIPs) Get(i int) (string, error) { + if i < 0 || i >= len(r.resolvedIPs) { + return "", fmt.Errorf("%d is out of range", i) + } + return r.resolvedIPs[i], nil +} + +func (r *ResolvedIPs) Size() int { + return len(r.resolvedIPs) +} + +type NetworkDomain struct { + Address string + resolvedIPs ResolvedIPs +} + +func (d *NetworkDomain) addResolvedIP(resolvedIP string) { + d.resolvedIPs.Add(resolvedIP) +} + +func (d *NetworkDomain) GetResolvedIPs() *ResolvedIPs { + return &d.resolvedIPs +} + +type NetworkDomains struct { + domains []*NetworkDomain +} + +func (n *NetworkDomains) Add(domain *NetworkDomain) { + n.domains = append(n.domains, domain) +} + +func (n *NetworkDomains) Get(i int) (*NetworkDomain, error) { + if i < 0 || i >= len(n.domains) { + return nil, fmt.Errorf("%d is out of range", i) + } + return n.domains[i], nil +} + +func (n *NetworkDomains) Size() int { + return len(n.domains) +} diff --git a/client/android/networks.go b/client/android/networks.go index aa130420b..3c3a25939 100644 --- a/client/android/networks.go +++ b/client/android/networks.go @@ -3,10 +3,16 @@ package android type Network struct { - Name string - Network string - Peer string - Status string + Name string + Network string + Peer string + Status string + IsSelected bool + Domains NetworkDomains +} + +func (n Network) GetNetworkDomains() *NetworkDomains { + return &n.Domains } type NetworkArray struct { diff --git a/client/android/peer_notifier.go b/client/android/peer_notifier.go index 1f5564c72..c2595e574 100644 --- a/client/android/peer_notifier.go +++ b/client/android/peer_notifier.go @@ -1,10 +1,27 @@ +//go:build android + package android +import "github.com/netbirdio/netbird/client/internal/peer" + +// Connection status constants exported via gomobile. +const ( + ConnStatusIdle = int(peer.StatusIdle) + ConnStatusConnecting = int(peer.StatusConnecting) + ConnStatusConnected = int(peer.StatusConnected) +) + // PeerInfo describe information about the peers. It designed for the UI usage type PeerInfo struct { IP string + IPv6 string FQDN string - ConnStatus string // Todo replace to enum + ConnStatus int + Routes PeerRoutes +} + +func (p *PeerInfo) GetPeerRoutes() *PeerRoutes { + return &p.Routes } // PeerInfoArray is a wrapper of []PeerInfo diff --git a/client/android/peer_routes.go b/client/android/peer_routes.go new file mode 100644 index 000000000..bb46d609f --- /dev/null +++ b/client/android/peer_routes.go @@ -0,0 +1,20 @@ +//go:build android + +package android + +import "fmt" + +type PeerRoutes struct { + routes []string +} + +func (p *PeerRoutes) Get(i int) (string, error) { + if i < 0 || i >= len(p.routes) { + return "", fmt.Errorf("%d is out of range", i) + } + return p.routes[i], nil +} + +func (p *PeerRoutes) Size() int { + return len(p.routes) +} diff --git a/client/android/platform_files.go b/client/android/platform_files.go new file mode 100644 index 000000000..3be40c0bd --- /dev/null +++ b/client/android/platform_files.go @@ -0,0 +1,11 @@ +//go:build android + +package android + +// PlatformFiles groups paths to files used internally by the engine that can't be created/modified +// at their default locations due to android OS restrictions. +type PlatformFiles interface { + ConfigurationFilePath() string + StateFilePath() string + CacheDir() string +} diff --git a/client/android/preferences.go b/client/android/preferences.go index 9a5d6bb21..066477293 100644 --- a/client/android/preferences.go +++ b/client/android/preferences.go @@ -201,6 +201,94 @@ func (p *Preferences) SetServerSSHAllowed(allowed bool) { p.configInput.ServerSSHAllowed = &allowed } +// GetEnableSSHRoot reads SSH root login setting from config file +func (p *Preferences) GetEnableSSHRoot() (bool, error) { + if p.configInput.EnableSSHRoot != nil { + return *p.configInput.EnableSSHRoot, nil + } + + cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + if err != nil { + return false, err + } + if cfg.EnableSSHRoot == nil { + // Default to false for security on Android + return false, nil + } + return *cfg.EnableSSHRoot, err +} + +// SetEnableSSHRoot stores the given value and waits for commit +func (p *Preferences) SetEnableSSHRoot(enabled bool) { + p.configInput.EnableSSHRoot = &enabled +} + +// GetEnableSSHSFTP reads SSH SFTP setting from config file +func (p *Preferences) GetEnableSSHSFTP() (bool, error) { + if p.configInput.EnableSSHSFTP != nil { + return *p.configInput.EnableSSHSFTP, nil + } + + cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + if err != nil { + return false, err + } + if cfg.EnableSSHSFTP == nil { + // Default to false for security on Android + return false, nil + } + return *cfg.EnableSSHSFTP, err +} + +// SetEnableSSHSFTP stores the given value and waits for commit +func (p *Preferences) SetEnableSSHSFTP(enabled bool) { + p.configInput.EnableSSHSFTP = &enabled +} + +// GetEnableSSHLocalPortForwarding reads SSH local port forwarding setting from config file +func (p *Preferences) GetEnableSSHLocalPortForwarding() (bool, error) { + if p.configInput.EnableSSHLocalPortForwarding != nil { + return *p.configInput.EnableSSHLocalPortForwarding, nil + } + + cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + if err != nil { + return false, err + } + if cfg.EnableSSHLocalPortForwarding == nil { + // Default to false for security on Android + return false, nil + } + return *cfg.EnableSSHLocalPortForwarding, err +} + +// SetEnableSSHLocalPortForwarding stores the given value and waits for commit +func (p *Preferences) SetEnableSSHLocalPortForwarding(enabled bool) { + p.configInput.EnableSSHLocalPortForwarding = &enabled +} + +// GetEnableSSHRemotePortForwarding reads SSH remote port forwarding setting from config file +func (p *Preferences) GetEnableSSHRemotePortForwarding() (bool, error) { + if p.configInput.EnableSSHRemotePortForwarding != nil { + return *p.configInput.EnableSSHRemotePortForwarding, nil + } + + cfg, err := profilemanager.ReadConfig(p.configInput.ConfigPath) + if err != nil { + return false, err + } + if cfg.EnableSSHRemotePortForwarding == nil { + // Default to false for security on Android + return false, nil + } + return *cfg.EnableSSHRemotePortForwarding, err +} + +// SetEnableSSHRemotePortForwarding stores the given value and waits for commit +func (p *Preferences) SetEnableSSHRemotePortForwarding(enabled bool) { + p.configInput.EnableSSHRemotePortForwarding = &enabled +} + // GetBlockInbound reads block inbound setting from config file func (p *Preferences) GetBlockInbound() (bool, error) { if p.configInput.BlockInbound != nil { @@ -219,6 +307,24 @@ 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 +} + // Commit writes out the changes to the config file func (p *Preferences) Commit() error { _, err := profilemanager.UpdateOrCreateConfig(p.configInput) diff --git a/client/android/profile_manager.go b/client/android/profile_manager.go new file mode 100644 index 000000000..87c001396 --- /dev/null +++ b/client/android/profile_manager.go @@ -0,0 +1,259 @@ +//go:build android + +package android + +import ( + "fmt" + "os" + "path/filepath" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/profilemanager" +) + +const ( + // Android-specific config filename (different from desktop default.json) + defaultConfigFilename = "netbird.cfg" + // Subdirectory for non-default profiles (must match Java Preferences.java) + profilesSubdir = "profiles" + // Android uses a single user context per app (non-empty username required by ServiceManager) + androidUsername = "android" +) + +// Profile represents a profile for gomobile +type Profile struct { + ID string + Name string + IsActive bool +} + +// ProfileArray wraps profiles for gomobile compatibility +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 +func (p *ProfileArray) Get(i int) *Profile { + if i < 0 || i >= len(p.items) { + return nil + } + return p.items[i] +} + +/* + +/data/data/io.netbird.client/files/ ← configDir parameter +├── netbird.cfg ← Default profile config +├── state.json ← Default profile state +├── active_profile.json ← Active profile tracker (JSON with Name + Username) +└── profiles/ ← Subdirectory for non-default profiles + ├── work.json ← Legacy work profile config + ├── work.state.json ← Legacy work profile state + ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.json ← ID profile config + ├── 4c5f5c8198c3989cffb5b5394f5a7ae0.state.json ← ID profile state +*/ + +// ProfileManager manages profiles for Android +// It wraps the internal profilemanager to provide Android-specific behavior +type ProfileManager struct { + configDir string + serviceMgr *profilemanager.ServiceManager +} + +// NewProfileManager creates a new profile manager for Android +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, + } +} + +// ListProfiles returns all available profiles +func (pm *ProfileManager) ListProfiles() (*ProfileArray, error) { + // Use ServiceManager (looks in profiles/ directory, checks active_profile.json for IsActive) + internalProfiles, err := pm.serviceMgr.ListProfiles(androidUsername) + if err != nil { + return nil, fmt.Errorf("failed to list profiles: %w", err) + } + + // Convert internal profiles to Android Profile type + var profiles []*Profile + for _, p := range internalProfiles { + profiles = append(profiles, &Profile{ + ID: p.ID.String(), + Name: p.Name, + IsActive: p.IsActive, + }) + } + + return &ProfileArray{items: profiles}, nil +} + +// GetActiveProfile returns the currently active profile name +func (pm *ProfileManager) GetActiveProfile() (*Profile, error) { + // Use ServiceManager to stay consistent with ListProfiles + // ServiceManager uses active_profile.json + activeState, err := pm.serviceMgr.GetActiveProfileState() + if err != nil { + return nil, fmt.Errorf("failed to get active profile: %w", err) + } + + // ActiveProfileState only stores the ID (and username), not the display + // name. Resolve the ID to the full profile so callers get the real Name. + prof, err := pm.serviceMgr.ResolveProfile(activeState.ID.String(), androidUsername) + if err != nil { + return nil, fmt.Errorf("failed to resolve active profile %q: %w", activeState.ID, err) + } + return &Profile{ID: prof.ID.String(), Name: prof.Name, IsActive: true}, nil +} + +// SwitchProfile switches to a different profile +func (pm *ProfileManager) SwitchProfile(id string) error { + // Use ServiceManager to stay consistent with ListProfiles + // ServiceManager uses active_profile.json + err := pm.serviceMgr.SetActiveProfileState(&profilemanager.ActiveProfileState{ + ID: profilemanager.ID(id), + Username: androidUsername, + }) + if err != nil { + return fmt.Errorf("failed to switch profile: %w", err) + } + + log.Infof("switched to profile: %s", id) + return nil +} + +// AddProfile creates a new profile +func (pm *ProfileManager) AddProfile(profileName string) error { + // Use ServiceManager (creates profile in profiles/ directory) + profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername) + if err != nil { + return fmt.Errorf("failed to add profile: %w", err) + } + + log.Infof("created new profile: %s", profile.ID) + return nil +} + +// LogoutProfile logs out from a profile (clears authentication) +func (pm *ProfileManager) LogoutProfile(id string) error { + configPath, err := pm.getProfileConfigPath(id) + if err != nil { + return err + } + + if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { + return fmt.Errorf("id '%s' is not valid", id) + } + + // Check if profile exists + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return fmt.Errorf("profile '%s' does not exist", id) + } + + // Read current config using internal profilemanager + config, err := profilemanager.ReadConfig(configPath) + if err != nil { + return fmt.Errorf("failed to read profile config: %w", err) + } + + // Clear authentication by removing private key and SSH key + config.PrivateKey = "" + config.SSHKey = "" + + // Save config using internal profilemanager + if err := profilemanager.WriteOutConfig(configPath, config); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + log.Infof("logged out from profile: %s", id) + return nil +} + +// RemoveProfile deletes a profile +func (pm *ProfileManager) RemoveProfile(id string) error { + // Use ServiceManager (removes profile from profiles/ directory) + if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), androidUsername); err != nil { + return fmt.Errorf("failed to remove profile: %w", err) + } + + log.Infof("removed profile: %s", id) + return nil +} + +// getProfileConfigPath returns the config file path for a profile +// This is needed for Android-specific path handling (netbird.cfg for default profile) +func (pm *ProfileManager) getProfileConfigPath(id string) (string, error) { + if !profilemanager.IsValidProfileFilenameStem(profilemanager.ID(id)) { + return "", fmt.Errorf("id %q is not valid", id) + } + + if id == profilemanager.DefaultProfileName { + // Android uses netbird.cfg for default profile instead of default.json + // Default profile is stored in root configDir, not in profiles/ + return filepath.Join(pm.configDir, defaultConfigFilename), nil + } + + profilesDir := filepath.Join(pm.configDir, profilesSubdir) + return filepath.Join(profilesDir, id+".json"), nil +} + +// GetConfigPath returns the config file path for a given profile id +// Java should call this instead of constructing paths with Preferences.configFile() +func (pm *ProfileManager) GetConfigPath(id string) (string, error) { + return pm.getProfileConfigPath(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(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 +// Java should call this instead of Preferences.getActiveProfileName() + Preferences.configFile() +func (pm *ProfileManager) GetActiveConfigPath() (string, error) { + activeProfile, err := pm.GetActiveProfile() + if err != nil { + return "", fmt.Errorf("failed to get active profile: %w", err) + } + return pm.GetConfigPath(activeProfile.ID) +} + +// GetActiveStateFilePath returns the state file path for the currently active profile +// Java should call this instead of Preferences.getActiveProfileName() + Preferences.stateFile() +func (pm *ProfileManager) GetActiveStateFilePath() (string, error) { + activeProfile, err := pm.GetActiveProfile() + if err != nil { + return "", fmt.Errorf("failed to get active profile: %w", err) + } + return pm.GetStateFilePath(activeProfile.ID) +} diff --git a/client/android/route_command.go b/client/android/route_command.go new file mode 100644 index 000000000..5e7357335 --- /dev/null +++ b/client/android/route_command.go @@ -0,0 +1,70 @@ +//go:build android + +package android + +import ( + "fmt" + + log "github.com/sirupsen/logrus" + "golang.org/x/exp/maps" + + "github.com/netbirdio/netbird/client/internal/routemanager" + "github.com/netbirdio/netbird/route" +) + +func executeRouteToggle(id string, manager routemanager.Manager, + operationName string, + routeOperation func(routes []route.NetID, allRoutes []route.NetID) error) error { + netID := route.NetID(id) + routes := []route.NetID{netID} + + routesMap := manager.GetClientRoutesWithNetID() + routes = route.ExpandV6ExitPairs(routes, routesMap) + + log.Debugf("%s with ids: %v", operationName, routes) + + if err := routeOperation(routes, maps.Keys(routesMap)); err != nil { + log.Debugf("error when %s: %s", operationName, err) + return fmt.Errorf("error %s: %w", operationName, err) + } + + manager.TriggerSelection(manager.GetClientRoutes()) + + return nil +} + +type routeCommand interface { + toggleRoute() error +} + +type selectRouteCommand struct { + route string + manager routemanager.Manager +} + +func (s selectRouteCommand) toggleRoute() error { + routeSelector := s.manager.GetRouteSelector() + if routeSelector == nil { + return fmt.Errorf("no route selector available") + } + + routeOperation := func(routes []route.NetID, allRoutes []route.NetID) error { + return routeSelector.SelectRoutes(routes, true, allRoutes) + } + + return executeRouteToggle(s.route, s.manager, "selecting route", routeOperation) +} + +type deselectRouteCommand struct { + route string + manager routemanager.Manager +} + +func (d deselectRouteCommand) toggleRoute() error { + routeSelector := d.manager.GetRouteSelector() + if routeSelector == nil { + return fmt.Errorf("no route selector available") + } + + return executeRouteToggle(d.route, d.manager, "deselecting route", routeSelector.DeselectRoutes) +} diff --git a/client/anonymize/anonymize.go b/client/anonymize/anonymize.go index 89e653300..c140cef89 100644 --- a/client/anonymize/anonymize.go +++ b/client/anonymize/anonymize.go @@ -9,6 +9,7 @@ import ( "net/url" "regexp" "slices" + "strconv" "strings" ) @@ -26,8 +27,9 @@ type Anonymizer struct { } 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::") } func NewAnonymizer(startIPv4, startIPv6 netip.Addr) *Anonymizer { @@ -48,7 +50,7 @@ func (a *Anonymizer) AnonymizeIP(ip netip.Addr) netip.Addr { ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() || - ip.IsPrivate() || + (ip.Is4() && ip.IsPrivate()) || ip.IsUnspecified() || ip.IsMulticast() || isWellKnown(ip) || @@ -96,6 +98,11 @@ func (a *Anonymizer) isInAnonymizedRange(ip netip.Addr) bool { } 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 @@ -150,7 +157,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 +165,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) } diff --git a/client/anonymize/anonymize_test.go b/client/anonymize/anonymize_test.go index ff2e48869..852315fa1 100644 --- a/client/anonymize/anonymize_test.go +++ b/client/anonymize/anonymize_test.go @@ -13,7 +13,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 +26,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"}, } @@ -274,17 +274,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/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/debug.go b/client/cmd/debug.go index 430012a17..57e75f663 100644 --- a/client/cmd/debug.go +++ b/client/cmd/debug.go @@ -3,12 +3,15 @@ package cmd import ( "context" "fmt" + "os/user" "strings" "time" 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" @@ -16,9 +19,9 @@ import ( "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/server" - nbstatus "github.com/netbirdio/netbird/client/status" 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" @@ -84,6 +87,73 @@ 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 := user.Current() + 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 { conn, err := getClient(cmd) if err != nil { @@ -98,9 +168,9 @@ func debugBundle(cmd *cobra.Command, _ []string) error { client := proto.NewDaemonServiceClient(conn) request := &proto.DebugBundleRequest{ Anonymize: anonymizeFlag, - Status: getStatusOutput(cmd, anonymizeFlag), SystemInfo: systemInfoFlag, LogFileCount: logFileCount, + CliVersion: version.NetbirdVersion(), } if uploadBundleFlag { request.UploadURL = uploadBundleURLFlag @@ -136,6 +206,7 @@ func setLogLevel(cmd *cobra.Command, args []string) error { client := proto.NewDaemonServiceClient(conn) level := server.ParseLogLevel(args[0]) if level == proto.LogLevel_UNKNOWN { + //nolint return fmt.Errorf("unknown log level: %s. Available levels are: panic, fatal, error, warn, info, debug, trace\n", args[0]) } @@ -182,10 +253,11 @@ func runForDuration(cmd *cobra.Command, args []string) error { if stateWasDown { if _, err := client.Up(cmd.Context(), &proto.UpRequest{}); err != nil { - return fmt.Errorf("failed to up: %v", status.Convert(err).Message()) + cmd.PrintErrf("Failed to bring service up: %v\n", status.Convert(err).Message()) + } else { + cmd.Println("netbird up") + time.Sleep(time.Second * 10) } - cmd.Println("netbird up") - time.Sleep(time.Second * 10) } initialLevelTrace := initialLogLevel.GetLevel() >= proto.LogLevel_TRACE @@ -199,10 +271,13 @@ func runForDuration(cmd *cobra.Command, args []string) error { cmd.Println("Log level set to trace.") } + needsRestoreUp := false if _, err := client.Down(cmd.Context(), &proto.DownRequest{}); err != nil { - return fmt.Errorf("failed to down: %v", status.Convert(err).Message()) + cmd.PrintErrf("Failed to bring service down: %v\n", status.Convert(err).Message()) + } else { + needsRestoreUp = !stateWasDown + cmd.Println("netbird down") } - cmd.Println("netbird down") time.Sleep(1 * time.Second) @@ -210,33 +285,91 @@ func runForDuration(cmd *cobra.Command, args []string) error { if _, err := client.SetSyncResponsePersistence(cmd.Context(), &proto.SetSyncResponsePersistenceRequest{ Enabled: true, }); err != nil { - return fmt.Errorf("failed to enable sync response persistence: %v", status.Convert(err).Message()) + cmd.PrintErrf("Failed to enable sync response persistence: %v\n", status.Convert(err).Message()) } if _, err := client.Up(cmd.Context(), &proto.UpRequest{}); err != nil { - return fmt.Errorf("failed to up: %v", status.Convert(err).Message()) + cmd.PrintErrf("Failed to bring service up: %v\n", status.Convert(err).Message()) + } else { + needsRestoreUp = false + cmd.Println("netbird up") } - cmd.Println("netbird up") time.Sleep(3 * time.Second) - headerPostUp := fmt.Sprintf("----- NetBird post-up - Timestamp: %s", time.Now().Format(time.RFC3339)) - statusOutput := fmt.Sprintf("%s\n%s", headerPostUp, getStatusOutput(cmd, anonymizeFlag)) + cpuProfilingStarted := false + if _, err := client.StartCPUProfile(cmd.Context(), &proto.StartCPUProfileRequest{}); err != nil { + cmd.PrintErrf("Failed to start CPU profiling: %v\n", err) + } else { + cpuProfilingStarted = true + defer func() { + if cpuProfilingStarted { + if _, err := client.StopCPUProfile(cmd.Context(), &proto.StopCPUProfileRequest{}); err != nil { + cmd.PrintErrf("Failed to stop CPU profiling: %v\n", err) + } + } + }() + } + + 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) + } else { + cpuProfilingStarted = false + } + } + cmd.Println("Creating debug bundle...") - headerPreDown := fmt.Sprintf("----- NetBird pre-down - Timestamp: %s - Duration: %s", time.Now().Format(time.RFC3339), duration) - statusOutput = fmt.Sprintf("%s\n%s\n%s", statusOutput, headerPreDown, getStatusOutput(cmd, anonymizeFlag)) request := &proto.DebugBundleRequest{ Anonymize: anonymizeFlag, - Status: statusOutput, SystemInfo: systemInfoFlag, LogFileCount: logFileCount, + CliVersion: version.NetbirdVersion(), } if uploadBundleFlag { request.UploadURL = uploadBundleURLFlag @@ -246,18 +379,28 @@ func runForDuration(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to bundle debug: %v", status.Convert(err).Message()) } + if needsRestoreUp { + if _, err := client.Up(cmd.Context(), &proto.UpRequest{}); err != nil { + cmd.PrintErrf("Failed to restore service up state: %v\n", status.Convert(err).Message()) + } else { + cmd.Println("netbird up (restored)") + } + } + if stateWasDown { if _, err := client.Down(cmd.Context(), &proto.DownRequest{}); err != nil { - return fmt.Errorf("failed to down: %v", status.Convert(err).Message()) + cmd.PrintErrf("Failed to restore service down state: %v\n", status.Convert(err).Message()) + } else { + cmd.Println("netbird down") } - cmd.Println("netbird down") } if !initialLevelTrace { if _, err := client.SetLogLevel(cmd.Context(), &proto.SetLogLevelRequest{Level: initialLogLevel.GetLevel()}); err != nil { - return fmt.Errorf("failed to restore log level: %v", status.Convert(err).Message()) + cmd.PrintErrf("Failed to restore log level: %v\n", status.Convert(err).Message()) + } else { + cmd.Println("Log level restored to", initialLogLevel.GetLevel()) } - cmd.Println("Log level restored to", initialLogLevel.GetLevel()) } cmd.Printf("Local file:\n%s\n", resp.GetPath()) @@ -301,25 +444,6 @@ func setSyncResponsePersistence(cmd *cobra.Command, args []string) error { return nil } -func getStatusOutput(cmd *cobra.Command, anon bool) string { - var statusOutputString string - statusResp, err := getStatus(cmd.Context(), true) - if err != nil { - cmd.PrintErrf("Failed to get status: %v\n", err) - } else { - pm := profilemanager.NewProfileManager() - var profName string - if activeProf, err := pm.GetActiveProfile(); err == nil { - profName = activeProf.Name - } - - statusOutputString = nbstatus.ParseToFullDetailSummary( - nbstatus.ConvertToStatusOutputOverview(statusResp, anon, "", nil, nil, nil, "", profName), - ) - } - return statusOutputString -} - func waitForDurationOrCancel(ctx context.Context, duration time.Duration, cmd *cobra.Command) error { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() @@ -378,7 +502,9 @@ func generateDebugBundle(config *profilemanager.Config, recorder *peer.Status, c InternalConfig: config, StatusRecorder: recorder, SyncResponse: syncResponse, - LogFile: logFilePath, + LogPath: logFilePath, + CPUProfile: nil, + DaemonVersion: version.NetbirdVersion(), // acting as daemon }, debug.BundleConfig{ IncludeSystemInfo: true, @@ -403,4 +529,5 @@ func init() { 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().Bool("capture", false, "Capture packets during the debug duration and include in bundle") } diff --git a/client/cmd/expose.go b/client/cmd/expose.go new file mode 100644 index 000000000..c48a6adac --- /dev/null +++ b/client/cmd/expose.go @@ -0,0 +1,287 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/signal" + "regexp" + "strconv" + "strings" + "syscall" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/expose" + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/util" +) + +var pinRegexp = regexp.MustCompile(`^\d{6}$`) + +var ( + exposePin string + exposePassword string + exposeUserGroups []string + exposeDomain string + exposeNamePrefix string + exposeProtocol string + exposeExternalPort uint16 +) + +var exposeCmd = &cobra.Command{ + Use: "expose ", + Short: "Expose a local port via the NetBird reverse proxy", + Args: cobra.ExactArgs(1), + Example: ` netbird expose --with-password safe-pass 8080 + netbird expose --protocol tcp 5432 + netbird expose --protocol tcp --with-external-port 5433 5432 + netbird expose --protocol tls --with-custom-domain tls.example.com 4443`, + RunE: exposeFn, +} + +func init() { + exposeCmd.Flags().StringVar(&exposePin, "with-pin", "", "Protect the exposed service with a 6-digit PIN (e.g. --with-pin 123456)") + exposeCmd.Flags().StringVar(&exposePassword, "with-password", "", "Protect the exposed service with a password (e.g. --with-password my-secret)") + exposeCmd.Flags().StringSliceVar(&exposeUserGroups, "with-user-groups", nil, "Restrict access to specific user groups with SSO (e.g. --with-user-groups devops,Backend)") + exposeCmd.Flags().StringVar(&exposeDomain, "with-custom-domain", "", "Custom domain for the exposed service, must be configured to your account (e.g. --with-custom-domain myapp.example.com)") + exposeCmd.Flags().StringVar(&exposeNamePrefix, "with-name-prefix", "", "Prefix for the generated service name (e.g. --with-name-prefix my-app)") + exposeCmd.Flags().StringVar(&exposeProtocol, "protocol", "http", "Protocol to use: http, https, tcp, udp, or tls (e.g. --protocol tcp)") + exposeCmd.Flags().Uint16Var(&exposeExternalPort, "with-external-port", 0, "Public-facing external port on the proxy cluster (defaults to the target port for L4)") +} + +// isClusterProtocol returns true for L4/TLS protocols that reject HTTP-style auth flags. +func isClusterProtocol(protocol string) bool { + switch strings.ToLower(protocol) { + case "tcp", "udp", "tls": + return true + default: + return false + } +} + +// isPortBasedProtocol returns true for pure port-based protocols (TCP/UDP) +// where domain display doesn't apply. TLS uses SNI so it has a domain. +func isPortBasedProtocol(protocol string) bool { + switch strings.ToLower(protocol) { + case "tcp", "udp": + return true + default: + return false + } +} + +// extractPort returns the port portion of a URL like "tcp://host:12345", or +// falls back to the given default formatted as a string. +func extractPort(serviceURL string, fallback uint16) string { + u := serviceURL + if idx := strings.Index(u, "://"); idx != -1 { + u = u[idx+3:] + } + if i := strings.LastIndex(u, ":"); i != -1 { + if p := u[i+1:]; p != "" { + return p + } + } + return strconv.FormatUint(uint64(fallback), 10) +} + +// resolveExternalPort returns the effective external port, defaulting to the target port. +func resolveExternalPort(targetPort uint64) uint16 { + if exposeExternalPort != 0 { + return exposeExternalPort + } + return uint16(targetPort) +} + +func validateExposeFlags(cmd *cobra.Command, portStr string) (uint64, error) { + port, err := strconv.ParseUint(portStr, 10, 32) + if err != nil { + return 0, fmt.Errorf("invalid port number: %s", portStr) + } + if port == 0 || port > 65535 { + return 0, fmt.Errorf("invalid port number: must be between 1 and 65535") + } + + if !isProtocolValid(exposeProtocol) { + return 0, fmt.Errorf("unsupported protocol %q: must be http, https, tcp, udp, or tls", exposeProtocol) + } + + if isClusterProtocol(exposeProtocol) { + if exposePin != "" || exposePassword != "" || len(exposeUserGroups) > 0 { + return 0, fmt.Errorf("auth flags (--with-pin, --with-password, --with-user-groups) are not supported for %s protocol", exposeProtocol) + } + } else if cmd.Flags().Changed("with-external-port") { + return 0, fmt.Errorf("--with-external-port is not supported for %s protocol", exposeProtocol) + } + + if exposePin != "" && !pinRegexp.MatchString(exposePin) { + return 0, fmt.Errorf("invalid pin: must be exactly 6 digits") + } + + if cmd.Flags().Changed("with-password") && exposePassword == "" { + return 0, fmt.Errorf("password cannot be empty") + } + + if cmd.Flags().Changed("with-user-groups") && len(exposeUserGroups) == 0 { + return 0, fmt.Errorf("user groups cannot be empty") + } + + return port, nil +} + +func isProtocolValid(exposeProtocol string) bool { + switch strings.ToLower(exposeProtocol) { + case "http", "https", "tcp", "udp", "tls": + return true + default: + return false + } +} + +func exposeFn(cmd *cobra.Command, args []string) error { + SetFlagsFromEnvVars(rootCmd) + + if err := util.InitLog(logLevel, util.LogConsole); err != nil { + log.Errorf("failed initializing log %v", err) + return err + } + + cmd.Root().SilenceUsage = false + + port, err := validateExposeFlags(cmd, args[0]) + if err != nil { + return err + } + + cmd.Root().SilenceUsage = true + + ctx, cancel := context.WithCancel(cmd.Context()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + cancel() + }() + + conn, err := DialClientGRPCServer(ctx, daemonAddr) + if err != nil { + return fmt.Errorf("connect to daemon: %w", err) + } + defer func() { + if err := conn.Close(); err != nil { + log.Debugf("failed to close daemon connection: %v", err) + } + }() + + client := proto.NewDaemonServiceClient(conn) + + protocol, err := toExposeProtocol(exposeProtocol) + if err != nil { + return err + } + + req := &proto.ExposeServiceRequest{ + Port: uint32(port), + Protocol: protocol, + Pin: exposePin, + Password: exposePassword, + UserGroups: exposeUserGroups, + Domain: exposeDomain, + NamePrefix: exposeNamePrefix, + } + if isClusterProtocol(exposeProtocol) { + req.ListenPort = uint32(resolveExternalPort(port)) + } + + stream, err := client.ExposeService(ctx, req) + if err != nil { + return fmt.Errorf("expose service: %v", status.Convert(err).Message()) + } + + if err := handleExposeReady(cmd, stream, port); err != nil { + return err + } + + return waitForExposeEvents(cmd, ctx, stream) +} + +func toExposeProtocol(exposeProtocol string) (proto.ExposeProtocol, error) { + p, err := expose.ParseProtocolType(exposeProtocol) + if err != nil { + return 0, fmt.Errorf("invalid protocol: %w", err) + } + + switch p { + case expose.ProtocolHTTP: + return proto.ExposeProtocol_EXPOSE_HTTP, nil + case expose.ProtocolHTTPS: + return proto.ExposeProtocol_EXPOSE_HTTPS, nil + case expose.ProtocolTCP: + return proto.ExposeProtocol_EXPOSE_TCP, nil + case expose.ProtocolUDP: + return proto.ExposeProtocol_EXPOSE_UDP, nil + case expose.ProtocolTLS: + return proto.ExposeProtocol_EXPOSE_TLS, nil + default: + return 0, fmt.Errorf("unhandled protocol type: %d", p) + } +} + +func handleExposeReady(cmd *cobra.Command, stream proto.DaemonService_ExposeServiceClient, port uint64) error { + event, err := stream.Recv() + if err != nil { + return fmt.Errorf("receive expose event: %v", status.Convert(err).Message()) + } + + ready, ok := event.Event.(*proto.ExposeServiceEvent_Ready) + if !ok { + return fmt.Errorf("unexpected expose event: %T", event.Event) + } + printExposeReady(cmd, ready.Ready, port) + return nil +} + +func printExposeReady(cmd *cobra.Command, r *proto.ExposeServiceReady, port uint64) { + cmd.Println("Service exposed successfully!") + cmd.Printf(" Name: %s\n", r.ServiceName) + if r.ServiceUrl != "" { + cmd.Printf(" URL: %s\n", r.ServiceUrl) + } + if r.Domain != "" && !isPortBasedProtocol(exposeProtocol) { + cmd.Printf(" Domain: %s\n", r.Domain) + } + cmd.Printf(" Protocol: %s\n", exposeProtocol) + cmd.Printf(" Internal: %d\n", port) + if isClusterProtocol(exposeProtocol) { + cmd.Printf(" External: %s\n", extractPort(r.ServiceUrl, resolveExternalPort(port))) + } + if r.PortAutoAssigned && exposeExternalPort != 0 { + cmd.Printf("\n Note: requested port %d was reassigned\n", exposeExternalPort) + } + cmd.Println() + cmd.Println("Press Ctrl+C to stop exposing.") +} + +func waitForExposeEvents(cmd *cobra.Command, ctx context.Context, stream proto.DaemonService_ExposeServiceClient) error { + for { + _, err := stream.Recv() + if err != nil { + if ctx.Err() != nil { + cmd.Println("\nService stopped.") + //nolint:nilerr + return nil + } + if errors.Is(err, io.EOF) { + return fmt.Errorf("connection to daemon closed unexpectedly") + } + return fmt.Errorf("stream error: %w", err) + } + } +} diff --git a/client/cmd/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 b0c877faa..a53cb6d5f 100644 --- a/client/cmd/login.go +++ b/client/cmd/login.go @@ -4,30 +4,39 @@ import ( "context" "fmt" "os" - "os/exec" "os/user" "runtime" "strings" - "time" log "github.com/sirupsen/logrus" - "github.com/skratchdot/open-golang/open" "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" + 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{ @@ -62,6 +71,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); 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 { @@ -83,6 +102,7 @@ var loginCmd = &cobra.Command{ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey string, activeProf *profilemanager.Profile, username string, pm *profilemanager.ProfileManager) 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) @@ -96,17 +116,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(), 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 != "" { @@ -150,6 +172,65 @@ 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) 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) + + req := &proto.RequestExtendAuthSessionRequest{} + // Pre-fill the IdP login hint from the active profile so the user + // doesn't have to retype their email. Best-effort: we still proceed + // without a hint if the lookup fails. + pm := profilemanager.NewProfileManager() + if active, perr := pm.GetActiveProfile(); perr == nil { + if profState, sperr := pm.GetProfileState(active.ID); sperr == nil && profState.Email != "" { + req.Hint = &profState.Email + } + } + + 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 @@ -170,14 +251,13 @@ 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) error { + resolvedID, err := switchProfile(ctx, handle, username) if err != nil { return fmt.Errorf("switch profile on daemon: %v", err) } - err = pm.SwitchProfile(profileName) - if err != nil { + if err := pm.SwitchProfile(resolvedID); err != nil { return fmt.Errorf("switch profile: %v", err) } @@ -205,10 +285,15 @@ func switchProfileOnDaemon(ctx context.Context, pm *profilemanager.ProfileManage return 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 { - return fmt.Errorf("failed to connect to daemon error: %v\n"+ + //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) } @@ -216,15 +301,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 { @@ -248,7 +333,15 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, return fmt.Errorf("read config file %s: %v", configFilePath, err) } - 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) } @@ -257,7 +350,7 @@ func doForegroundLogin(ctx context.Context, cmd *cobra.Command, setupKey string, } 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 { @@ -276,63 +369,46 @@ 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 { - needsLogin := false - - err := WithBackOff(func() error { - err := internal.Login(ctx, config, "", "") - if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.InvalidArgument || s.Code() == codes.PermissionDenied) { - needsLogin = true - return nil - } - return err - }) +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("backoff cycle failed: %v", err) + return fmt.Errorf("failed to create auth client: %v", err) + } + defer authClient.Close() + + needsLogin, err := authClient.IsLoginRequired(ctx) + if err != nil { + return fmt.Errorf("check login required: %v", err) } 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) } jwtToken = tokenInfo.GetTokenToUse() } - var lastError error - - err = WithBackOff(func() error { - err := internal.Login(ctx, config, setupKey, jwtToken) - if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.InvalidArgument || s.Code() == codes.PermissionDenied) { - lastError = err - return nil - } - return err - }) - - if lastError != nil { - return fmt.Errorf("login failed: %v", lastError) - } - + err, _ = authClient.Login(ctx, setupKey, jwtToken) if err != nil { - return fmt.Errorf("backoff cycle failed: %v", err) + return fmt.Errorf("login failed: %v", err) } 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(), hint) + oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isUnixRunningDesktop(), false, hint) if err != nil { return nil, err } @@ -342,13 +418,9 @@ 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) - waitTimeout := time.Duration(flowInfo.ExpiresIn) * time.Second - waitCTX, c := context.WithTimeout(context.TODO(), waitTimeout) - defer c() - - tokenInfo, err := oAuthFlow.WaitToken(waitCTX, flowInfo) + tokenInfo, err := oAuthFlow.WaitToken(context.TODO(), flowInfo) if err != nil { return nil, fmt.Errorf("waiting for browser login failed: %v", err) } @@ -356,7 +428,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) @@ -370,24 +442,22 @@ 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 { - if err := openBrowser(verificationURIComplete); err != nil { + if err := util.OpenBrowser(verificationURIComplete); err != nil { cmd.Println("\nAlternatively, you may want to use a setup key, see:\n\n" + "https://docs.netbird.io/how-to/register-machines-using-setup-keys") } } } -// openBrowser opens the URL in a browser, respecting the BROWSER environment variable. -func openBrowser(url string) error { - if browser := os.Getenv("BROWSER"); browser != "" { - return exec.Command(browser, url).Start() - } - return open.Run(url) -} - // isUnixRunningDesktop checks if a Linux OS is running desktop environment func isUnixRunningDesktop() bool { if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { 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/pprof.go b/client/cmd/pprof.go index 37efd35f0..c041c6ea9 100644 --- a/client/cmd/pprof.go +++ b/client/cmd/pprof.go @@ -1,5 +1,4 @@ //go:build pprof -// +build pprof package cmd diff --git a/client/cmd/profile.go b/client/cmd/profile.go index d6e81760f..268034e70 100644 --- a/client/cmd/profile.go +++ b/client/cmd/profile.go @@ -2,11 +2,16 @@ 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 +19,8 @@ import ( "github.com/netbirdio/netbird/util" ) +var profileListShowID bool + var profileCmd = &cobra.Command{ Use: "profile", Short: "Manage NetBird client profiles", @@ -31,27 +38,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 +85,7 @@ func setupCmd(cmd *cobra.Command) error { return nil } + func listProfilesFunc(cmd *cobra.Command, _ []string) error { if err := setupCmd(cmd); err != nil { return err @@ -83,25 +104,33 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error { 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,6 +138,41 @@ func addProfileFunc(cmd *cobra.Command, args []string) error { return err } + currUser, err := user.Current() + 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) @@ -121,21 +185,43 @@ func addProfileFunc(cmd *cobra.Command, args []string) error { } 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 @@ -153,18 +239,17 @@ func removeProfileFunc(cmd *cobra.Command, args []string) error { } 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,7 +259,7 @@ func selectProfileFunc(cmd *cobra.Command, args []string) error { } profileManager := profilemanager.NewProfileManager() - profileName := args[0] + handle := args[0] currUser, err := user.Current() if err != nil { @@ -191,32 +276,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 +299,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 11e5228f1..f1ef32717 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -22,6 +22,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + daddr "github.com/netbirdio/netbird/client/internal/daemonaddr" "github.com/netbirdio/netbird/client/internal/profilemanager" ) @@ -35,7 +36,6 @@ const ( wireguardPortFlag = "wireguard-port" networkMonitorFlag = "network-monitor" disableAutoConnectFlag = "disable-auto-connect" - serverSSHAllowedFlag = "allow-server-ssh" extraIFaceBlackListFlag = "extra-iface-blacklist" dnsRouteIntervalFlag = "dns-router-interval" enableLazyConnectionFlag = "enable-lazy-connection" @@ -64,7 +64,6 @@ var ( customDNSAddress string rosenpassEnabled bool rosenpassPermissive bool - serverSSHAllowed bool interfaceName string wireguardPort uint16 networkMonitor bool @@ -72,24 +71,52 @@ var ( extraIFaceBlackList []string anonymizeFlag bool dnsRouteInterval time.Duration - lazyConnEnabled bool - mtu uint16 - profilesDisabled bool - updateSettingsDisabled 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 rootCmd = &cobra.Command{ Use: "netbird", Short: "", Long: "", SilenceUsage: true, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + SetFlagsFromEnvVars(cmd.Root()) + + // Don't resolve for service commands — they create the socket, not connect to it. + if !isServiceCmd(cmd) { + daemonAddr = daddr.ResolveUnixDaemonAddr(daemonAddr) + } + return nil + }, } ) -// 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() + } 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/" @@ -130,7 +157,7 @@ func init() { 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().StringVarP(&configPath, "config", "c", profilemanager.DefaultConfigPath, "Overrides the default profile file location") rootCmd.AddCommand(upCmd) rootCmd.AddCommand(downCmd) @@ -143,6 +170,7 @@ func init() { rootCmd.AddCommand(forwardingRulesCmd) rootCmd.AddCommand(debugCmd) rootCmd.AddCommand(profileCmd) + rootCmd.AddCommand(exposeCmd) networksCMD.AddCommand(routesListCmd) networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd) @@ -154,10 +182,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) @@ -176,9 +211,9 @@ 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(&serverSSHAllowed, serverSSHAllowedFlag, false, "Allow SSH server on peer. If enabled, the SSH server will be permitted") 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(&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") } @@ -385,11 +420,11 @@ func migrateToNetbird(oldPath, newPath string) bool { } func getClient(cmd *cobra.Command) (*grpc.ClientConn, error) { - SetFlagsFromEnvVars(rootCmd) cmd.SetOut(cmd.OutOrStdout()) conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr) if err != nil { + //nolint return nil, 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) @@ -397,3 +432,13 @@ func getClient(cmd *cobra.Command) (*grpc.ClientConn, error) { return conn, nil } + +// isServiceCmd returns true if cmd is the "service" command or a child of it. +func isServiceCmd(cmd *cobra.Command) bool { + for c := cmd; c != nil; c = c.Parent() { + if c.Name() == "service" { + return true + } + } + return false +} diff --git a/client/cmd/service.go b/client/cmd/service.go index e55465875..b0a56c71a 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,21 @@ 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 + jsonServ *http.Server + jsonServMu sync.Mutex serverInstance *server.Server serverInstanceMu sync.Mutex } @@ -41,13 +48,19 @@ func init() { defaultServiceName = "Netbird" } - serviceCmd.AddCommand(runCmd, startCmd, stopCmd, restartCmd, svcStatusCmd, installCmd, uninstallCmd, reconfigureCmd) + serviceCmd.AddCommand(runCmd, startCmd, stopCmd, restartCmd, svcStatusCmd, installCmd, uninstallCmd, reconfigureCmd, resetParamsCmd) serviceCmd.PersistentFlags().BoolVar(&profilesDisabled, "disable-profiles", false, "Disables profiles feature. If enabled, the client will not be able to change or edit any profile. To persist this setting, use: netbird service install --disable-profiles") serviceCmd.PersistentFlags().BoolVar(&updateSettingsDisabled, "disable-update-settings", false, "Disables update settings feature. If enabled, the client will not be able to change or edit any settings. To persist this setting, use: netbird service install --disable-update-settings") + serviceCmd.PersistentFlags().BoolVar(&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. ` + `You can specify a comma-separated list of KEY=VALUE pairs. ` + + `New keys are merged with previously saved env vars; existing keys are overwritten. ` + + `Use --service-env "" to clear all saved env vars. ` + `E.g. --service-env NB_LOG_LEVEL=debug,CUSTOM_VAR=value` installCmd.Flags().StringSliceVar(&serviceEnvVars, "service-env", nil, serviceEnvDesc) diff --git a/client/cmd/service_controller.go b/client/cmd/service_controller.go index 0545ce6b7..5ef13a0a6 100644 --- a/client/cmd/service_controller.go +++ b/client/cmd/service_controller.go @@ -5,9 +5,6 @@ package cmd import ( "context" "fmt" - "net" - "os" - "strings" "time" "github.com/kardianos/service" @@ -22,46 +19,61 @@ import ( "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 +} + 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]) - } - - listen, err := net.Listen(split[0], split[1]) + daemonListener, err := listenOnAddress(daemonAddr) if err != nil { return fmt.Errorf("listen daemon interface: %w", 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]) + var jsonListener *socketListener + if enableJSONSocket { + jsonListener, err = listenOnAddress(jsonSocket) + if err != nil { + _ = daemonListener.Close() + return fmt.Errorf("listen daemon JSON interface: %w", err) + } + } else { + removeStaleUnixSocketForAddress(jsonSocket) + } + + go func() { + defer daemonListener.Close() + if jsonListener != nil { + defer jsonListener.Close() + } + + if err := daemonListener.chmodUnixSocket("daemon"); err != nil { + log.Error(err) + return + } + if jsonListener != nil { + if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil { + log.Error(err) return } } - serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled) + serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled) if err := serverInstance.Start(); err != nil { log.Fatalf("failed to start daemon: %v", err) } @@ -71,8 +83,16 @@ func (p *program) Start(svc service.Service) error { p.serverInstance = serverInstance p.serverInstanceMu.Unlock() - log.Printf("started daemon server: %v", split[1]) - if err := p.serv.Serve(listen); err != nil { + if jsonListener != nil { + if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil { + log.Fatalf("failed to start daemon JSON server: %v", err) + } + } else { + log.Debug("daemon JSON socket disabled") + } + + log.Printf("started daemon server: %v", daemonListener.address) + if err := p.serv.Serve(daemonListener.Listener); err != nil { log.Errorf("failed to serve daemon requests: %v", err) } }() @@ -92,6 +112,20 @@ func (p *program) Stop(srv service.Service) error { p.cancel() + p.jsonServMu.Lock() + jsonServ := p.jsonServ + p.jsonServMu.Unlock() + 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,8 +136,8 @@ func (p *program) Stop(srv service.Service) error { } // Common setup for service control commands -func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel context.CancelFunc) (service.Service, error) { - SetFlagsFromEnvVars(rootCmd) +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) cmd.SetOut(cmd.OutOrStdout()) @@ -112,8 +146,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 +178,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 +195,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 +216,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 +234,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 +255,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 f6828d96a..ae2dfb9fa 100644 --- a/client/cmd/service_installer.go +++ b/client/cmd/service_installer.go @@ -59,6 +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 } @@ -98,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) @@ -119,6 +135,10 @@ var installCmd = &cobra.Command{ return err } + if err := loadAndApplyServiceParams(cmd); err != nil { + cmd.PrintErrf("Warning: failed to load saved service params: %v\n", err) + } + svcConfig, err := createServiceConfigForInstall() if err != nil { return err @@ -136,6 +156,10 @@ var installCmd = &cobra.Command{ return fmt.Errorf("install service: %w", err) } + if err := saveServiceParams(currentServiceParams()); err != nil { + cmd.PrintErrf("Warning: failed to save service params: %v\n", err) + } + cmd.Println("NetBird service has been installed") return nil }, @@ -187,6 +211,10 @@ This command will temporarily stop the service, update its configuration, and re return err } + if err := loadAndApplyServiceParams(cmd); err != nil { + cmd.PrintErrf("Warning: failed to load saved service params: %v\n", err) + } + wasRunning, err := isServiceRunning() if err != nil && !errors.Is(err, ErrGetServiceStatus) { return fmt.Errorf("check service status: %w", err) @@ -222,6 +250,10 @@ This command will temporarily stop the service, update its configuration, and re return fmt.Errorf("install service with new config: %w", err) } + if err := saveServiceParams(currentServiceParams()); err != nil { + cmd.PrintErrf("Warning: failed to save service params: %v\n", err) + } + if wasRunning { cmd.Println("Starting NetBird service...") if err := s.Start(); err != nil { diff --git a/client/cmd/service_json_gateway.go b/client/cmd/service_json_gateway.go new file mode 100644 index 000000000..29c1a6456 --- /dev/null +++ b/client/cmd/service_json_gateway.go @@ -0,0 +1,52 @@ +//go:build !ios && !android + +package cmd + +import ( + "context" + "errors" + "net" + "net/http" + "strings" + "time" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + log "github.com/sirupsen/logrus" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/netbirdio/netbird/client/proto" +) + +func grpcGatewayEndpoint(addr string) string { + return strings.TrimPrefix(addr, "tcp://") +} + +func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint string) error { + mux := runtime.NewServeMux() + opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())} + if err := proto.RegisterDaemonServiceHandlerFromEndpoint(p.ctx, mux, grpcGatewayEndpoint(daemonEndpoint), opts); err != nil { + return err + } + + jsonServer := &http.Server{ + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + BaseContext: func(net.Listener) context.Context { + return p.ctx + }, + } + + p.jsonServMu.Lock() + p.jsonServ = jsonServer + 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_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 new file mode 100644 index 000000000..f25087a69 --- /dev/null +++ b/client/cmd/service_params.go @@ -0,0 +1,235 @@ +//go:build !ios && !android + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/client/configs" + "github.com/netbirdio/netbird/util" +) + +const serviceParamsFile = "service.json" + +// serviceParams holds install-time service parameters that persist across +// uninstall/reinstall cycles. Saved to /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"` +} + +// serviceParamsPath returns the path to the service params file. +func serviceParamsPath() string { + return filepath.Join(configs.StateDir, serviceParamsFile) +} + +// loadServiceParams reads saved service parameters from disk. +// Returns nil with no error if the file does not exist. +func loadServiceParams() (*serviceParams, error) { + path := serviceParamsPath() + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil //nolint:nilnil + } + return nil, fmt.Errorf("read service params %s: %w", path, err) + } + + var params serviceParams + if err := json.Unmarshal(data, ¶ms); err != nil { + return nil, fmt.Errorf("parse service params %s: %w", path, err) + } + + return ¶ms, nil +} + +// saveServiceParams writes current service parameters to disk atomically +// with restricted permissions. +func saveServiceParams(params *serviceParams) error { + path := serviceParamsPath() + if err := util.WriteJsonWithRestrictedPermission(context.Background(), path, params); err != nil { + return fmt.Errorf("save service params: %w", err) + } + return nil +} + +// currentServiceParams captures the current state of all package-level +// variables into a serviceParams struct. +func currentServiceParams() *serviceParams { + params := &serviceParams{ + LogLevel: logLevel, + DaemonAddr: daemonAddr, + JSONSocket: jsonSocket, + ManagementURL: managementURL, + ConfigPath: configPath, + LogFiles: logFiles, + DisableProfiles: profilesDisabled, + DisableUpdateSettings: updateSettingsDisabled, + EnableCapture: captureEnabled, + DisableNetworks: networksDisabled, + EnableJSONSocket: enableJSONSocket, + } + + if len(serviceEnvVars) > 0 { + parsed, err := parseServiceEnvVars(serviceEnvVars) + if err == nil { + params.ServiceEnvVars = parsed + } + } + + return params +} + +// loadAndApplyServiceParams loads saved params from disk and applies them +// to any flags that were not explicitly set. +func loadAndApplyServiceParams(cmd *cobra.Command) error { + params, err := loadServiceParams() + if err != nil { + return err + } + applyServiceParams(cmd, params) + return nil +} + +// applyServiceParams merges saved parameters into package-level variables +// for any flag that was not explicitly set by the user (via CLI or env var). +// Flags that were Changed() are left untouched. +func applyServiceParams(cmd *cobra.Command, params *serviceParams) { + if params == nil { + return + } + + // For fields with non-empty defaults, 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 + } + + 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 + // that an explicit clear (--management-url "") persists across reinstalls. + if !rootCmd.PersistentFlags().Changed("management-url") { + managementURL = params.ManagementURL + } + + if !rootCmd.PersistentFlags().Changed("config") { + configPath = params.ConfigPath + } + + if !rootCmd.PersistentFlags().Changed("log-file") { + logFiles = params.LogFiles + } + + if !serviceCmd.PersistentFlags().Changed("disable-profiles") { + profilesDisabled = params.DisableProfiles + } + + if !serviceCmd.PersistentFlags().Changed("disable-update-settings") { + updateSettingsDisabled = params.DisableUpdateSettings + } + + if !serviceCmd.PersistentFlags().Changed("enable-capture") { + captureEnabled = params.EnableCapture + } + + if !serviceCmd.PersistentFlags().Changed("disable-networks") { + networksDisabled = params.DisableNetworks + } + + applyServiceEnvParams(cmd, params) +} + +// applyServiceEnvParams merges saved service environment variables. +// If --service-env was explicitly set with values, explicit values win on key +// conflict but saved keys not in the explicit set are carried over. +// If --service-env was explicitly set to empty, all saved env vars are cleared. +// If --service-env was not set, saved env vars are used entirely. +func applyServiceEnvParams(cmd *cobra.Command, params *serviceParams) { + if !cmd.Flags().Changed("service-env") { + if len(params.ServiceEnvVars) > 0 { + // No explicit env vars: rebuild serviceEnvVars from saved params. + serviceEnvVars = envMapToSlice(params.ServiceEnvVars) + } + return + } + + // Flag was explicitly set: parse what the user provided. + explicit, err := parseServiceEnvVars(serviceEnvVars) + if err != nil { + cmd.PrintErrf("Warning: parse explicit service env vars for merge: %v\n", err) + return + } + + // If the user passed an empty value (e.g. --service-env ""), clear all + // saved env vars rather than merging. + if len(explicit) == 0 { + serviceEnvVars = nil + return + } + + if len(params.ServiceEnvVars) == 0 { + return + } + + // Merge saved values underneath explicit ones. + merged := make(map[string]string, len(params.ServiceEnvVars)+len(explicit)) + maps.Copy(merged, params.ServiceEnvVars) + maps.Copy(merged, explicit) // explicit wins on conflict + serviceEnvVars = envMapToSlice(merged) +} + +var resetParamsCmd = &cobra.Command{ + Use: "reset-params", + Short: "Remove saved service install parameters", + Long: "Removes the saved service.json file so the next install uses default parameters.", + RunE: func(cmd *cobra.Command, args []string) error { + path := serviceParamsPath() + if err := os.Remove(path); err != nil { + if os.IsNotExist(err) { + cmd.Println("No saved service parameters found") + return nil + } + return fmt.Errorf("remove service params: %w", err) + } + cmd.Printf("Removed saved service parameters (%s)\n", path) + return nil + }, +} + +// envMapToSlice converts a map of env vars to a KEY=VALUE slice. +func envMapToSlice(m map[string]string) []string { + s := make([]string, 0, len(m)) + for k, v := range m { + s = append(s, k+"="+v) + } + return s +} diff --git a/client/cmd/service_params_test.go b/client/cmd/service_params_test.go new file mode 100644 index 000000000..94f98a0ce --- /dev/null +++ b/client/cmd/service_params_test.go @@ -0,0 +1,588 @@ +//go:build !ios && !android + +package cmd + +import ( + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/configs" +) + +func TestServiceParamsPath(t *testing.T) { + original := configs.StateDir + t.Cleanup(func() { configs.StateDir = original }) + + configs.StateDir = "/var/lib/netbird" + assert.Equal(t, filepath.Join("/var/lib/netbird", "service.json"), serviceParamsPath()) + + configs.StateDir = "/custom/state" + assert.Equal(t, filepath.Join("/custom/state", "service.json"), serviceParamsPath()) +} + +func TestSaveAndLoadServiceParams(t *testing.T) { + tmpDir := t.TempDir() + + original := configs.StateDir + t.Cleanup(func() { configs.StateDir = original }) + configs.StateDir = tmpDir + + params := &serviceParams{ + LogLevel: "debug", + DaemonAddr: "unix:///var/run/netbird.sock", + 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"}, + DisableProfiles: true, + DisableUpdateSettings: false, + ServiceEnvVars: map[string]string{"NB_LOG_FORMAT": "json", "CUSTOM": "val"}, + } + + err := saveServiceParams(params) + require.NoError(t, err) + + // Verify the file exists and is valid JSON. + data, err := os.ReadFile(filepath.Join(tmpDir, "service.json")) + require.NoError(t, err) + assert.True(t, json.Valid(data)) + + loaded, err := loadServiceParams() + require.NoError(t, err) + require.NotNil(t, loaded) + + assert.Equal(t, params.LogLevel, loaded.LogLevel) + assert.Equal(t, params.DaemonAddr, loaded.DaemonAddr) + assert.Equal(t, params.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) + assert.Equal(t, params.DisableProfiles, loaded.DisableProfiles) + assert.Equal(t, params.DisableUpdateSettings, loaded.DisableUpdateSettings) + assert.Equal(t, params.ServiceEnvVars, loaded.ServiceEnvVars) +} + +func TestLoadServiceParams_FileNotExists(t *testing.T) { + tmpDir := t.TempDir() + + original := configs.StateDir + t.Cleanup(func() { configs.StateDir = original }) + configs.StateDir = tmpDir + + params, err := loadServiceParams() + assert.NoError(t, err) + assert.Nil(t, params) +} + +func TestLoadServiceParams_InvalidJSON(t *testing.T) { + tmpDir := t.TempDir() + + original := configs.StateDir + t.Cleanup(func() { configs.StateDir = original }) + configs.StateDir = tmpDir + + err := os.WriteFile(filepath.Join(tmpDir, "service.json"), []byte("not json"), 0600) + require.NoError(t, err) + + params, err := loadServiceParams() + assert.Error(t, err) + assert.Nil(t, params) +} + +func TestCurrentServiceParams(t *testing.T) { + origLogLevel := logLevel + origDaemonAddr := daemonAddr + origJSONSocket := jsonSocket + origEnableJSONSocket := enableJSONSocket + origManagementURL := managementURL + origConfigPath := configPath + origLogFiles := logFiles + origProfilesDisabled := profilesDisabled + origUpdateSettingsDisabled := updateSettingsDisabled + origServiceEnvVars := serviceEnvVars + t.Cleanup(func() { + logLevel = origLogLevel + daemonAddr = origDaemonAddr + jsonSocket = origJSONSocket + enableJSONSocket = origEnableJSONSocket + managementURL = origManagementURL + configPath = origConfigPath + logFiles = origLogFiles + profilesDisabled = origProfilesDisabled + updateSettingsDisabled = origUpdateSettingsDisabled + serviceEnvVars = origServiceEnvVars + }) + + 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"} + profilesDisabled = true + updateSettingsDisabled = true + serviceEnvVars = []string{"FOO=bar", "BAZ=qux"} + + params := currentServiceParams() + + assert.Equal(t, "trace", params.LogLevel) + assert.Equal(t, "tcp://127.0.0.1:9999", params.DaemonAddr) + assert.Equal(t, "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) + assert.True(t, params.DisableProfiles) + assert.True(t, params.DisableUpdateSettings) + assert.Equal(t, map[string]string{"FOO": "bar", "BAZ": "qux"}, params.ServiceEnvVars) +} + +func TestApplyServiceParams_OnlyUnchangedFlags(t *testing.T) { + origLogLevel := logLevel + origDaemonAddr := daemonAddr + origJSONSocket := jsonSocket + origEnableJSONSocket := enableJSONSocket + origManagementURL := managementURL + origConfigPath := configPath + origLogFiles := logFiles + origProfilesDisabled := profilesDisabled + origUpdateSettingsDisabled := updateSettingsDisabled + origServiceEnvVars := serviceEnvVars + t.Cleanup(func() { + logLevel = origLogLevel + daemonAddr = origDaemonAddr + jsonSocket = origJSONSocket + enableJSONSocket = origEnableJSONSocket + managementURL = origManagementURL + configPath = origConfigPath + logFiles = origLogFiles + profilesDisabled = origProfilesDisabled + updateSettingsDisabled = origUpdateSettingsDisabled + serviceEnvVars = origServiceEnvVars + }) + + // Reset all flags to defaults. + logLevel = "info" + daemonAddr = "unix:///var/run/netbird.sock" + jsonSocket = defaultJSONSocket + enableJSONSocket = false + managementURL = "" + configPath = "/etc/netbird/config.json" + logFiles = []string{"/var/log/netbird/client.log"} + profilesDisabled = false + updateSettingsDisabled = false + serviceEnvVars = nil + + // Reset Changed state on all relevant flags. + rootCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { + f.Changed = false + }) + serviceCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { + f.Changed = false + }) + + // Simulate user explicitly setting --log-level via CLI. + logLevel = "warn" + require.NoError(t, rootCmd.PersistentFlags().Set("log-level", "warn")) + + saved := &serviceParams{ + LogLevel: "debug", + DaemonAddr: "tcp://127.0.0.1:5555", + JSONSocket: "tcp://127.0.0.1:8080", + EnableJSONSocket: true, + ManagementURL: "https://saved.example.com", + ConfigPath: "/saved/config.json", + LogFiles: []string{"/saved/client.log"}, + DisableProfiles: true, + DisableUpdateSettings: true, + ServiceEnvVars: map[string]string{"SAVED_KEY": "saved_val"}, + } + + cmd := &cobra.Command{} + cmd.Flags().StringSlice("service-env", nil, "") + applyServiceParams(cmd, saved) + + // log-level was Changed, so it should keep "warn", not use saved "debug". + assert.Equal(t, "warn", logLevel) + + // All other fields were not Changed, so they should use saved values. + assert.Equal(t, "tcp://127.0.0.1:5555", daemonAddr) + assert.Equal(t, "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) + assert.True(t, profilesDisabled) + assert.True(t, updateSettingsDisabled) + assert.Equal(t, []string{"SAVED_KEY=saved_val"}, serviceEnvVars) +} + +func TestApplyServiceParams_BooleanRevertToFalse(t *testing.T) { + origProfilesDisabled := profilesDisabled + origUpdateSettingsDisabled := updateSettingsDisabled + 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) { + f.Changed = false + }) + + // Saved params have both as false. + saved := &serviceParams{ + DisableProfiles: false, + DisableUpdateSettings: false, + } + + cmd := &cobra.Command{} + cmd.Flags().StringSlice("service-env", nil, "") + applyServiceParams(cmd, saved) + + assert.False(t, profilesDisabled, "saved false should override current true") + assert.False(t, updateSettingsDisabled, "saved false should override current true") + assert.False(t, enableJSONSocket, "saved false should override current true") +} + +func TestApplyServiceParams_ClearManagementURL(t *testing.T) { + origManagementURL := managementURL + t.Cleanup(func() { managementURL = origManagementURL }) + + managementURL = "https://leftover.example.com" + + // Simulate saved params where management URL was explicitly cleared. + saved := &serviceParams{ + LogLevel: "info", + DaemonAddr: "unix:///var/run/netbird.sock", + // ManagementURL intentionally empty: was cleared with --management-url "". + } + + rootCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { + f.Changed = false + }) + + cmd := &cobra.Command{} + cmd.Flags().StringSlice("service-env", nil, "") + applyServiceParams(cmd, saved) + + assert.Equal(t, "", managementURL, "saved empty management URL should clear the current value") +} + +func TestApplyServiceParams_NilParams(t *testing.T) { + origLogLevel := logLevel + t.Cleanup(func() { logLevel = origLogLevel }) + + logLevel = "info" + cmd := &cobra.Command{} + cmd.Flags().StringSlice("service-env", nil, "") + + // Should be a no-op. + applyServiceParams(cmd, nil) + assert.Equal(t, "info", logLevel) +} + +func TestApplyServiceEnvParams_MergeExplicitAndSaved(t *testing.T) { + origServiceEnvVars := serviceEnvVars + t.Cleanup(func() { serviceEnvVars = origServiceEnvVars }) + + // Set up a command with --service-env marked as Changed. + cmd := &cobra.Command{} + cmd.Flags().StringSlice("service-env", nil, "") + require.NoError(t, cmd.Flags().Set("service-env", "EXPLICIT=yes,OVERLAP=explicit")) + + serviceEnvVars = []string{"EXPLICIT=yes", "OVERLAP=explicit"} + + saved := &serviceParams{ + ServiceEnvVars: map[string]string{ + "SAVED": "val", + "OVERLAP": "saved", + }, + } + + applyServiceEnvParams(cmd, saved) + + // Parse result for easier assertion. + result, err := parseServiceEnvVars(serviceEnvVars) + require.NoError(t, err) + + assert.Equal(t, "yes", result["EXPLICIT"]) + assert.Equal(t, "val", result["SAVED"]) + // Explicit wins on conflict. + assert.Equal(t, "explicit", result["OVERLAP"]) +} + +func TestApplyServiceEnvParams_NotChanged(t *testing.T) { + origServiceEnvVars := serviceEnvVars + t.Cleanup(func() { serviceEnvVars = origServiceEnvVars }) + + serviceEnvVars = nil + + cmd := &cobra.Command{} + cmd.Flags().StringSlice("service-env", nil, "") + + saved := &serviceParams{ + ServiceEnvVars: map[string]string{"FROM_SAVED": "val"}, + } + + applyServiceEnvParams(cmd, saved) + + result, err := parseServiceEnvVars(serviceEnvVars) + require.NoError(t, err) + assert.Equal(t, map[string]string{"FROM_SAVED": "val"}, result) +} + +func TestApplyServiceEnvParams_ExplicitEmptyClears(t *testing.T) { + origServiceEnvVars := serviceEnvVars + t.Cleanup(func() { serviceEnvVars = origServiceEnvVars }) + + // Simulate --service-env "" which produces [""] in the slice. + serviceEnvVars = []string{""} + + cmd := &cobra.Command{} + cmd.Flags().StringSlice("service-env", nil, "") + require.NoError(t, cmd.Flags().Set("service-env", "")) + + saved := &serviceParams{ + ServiceEnvVars: map[string]string{"OLD_VAR": "should_be_cleared"}, + } + + applyServiceEnvParams(cmd, saved) + + assert.Nil(t, serviceEnvVars, "explicit empty --service-env should clear all saved env vars") +} + +func TestCurrentServiceParams_EmptyEnvVarsAfterParse(t *testing.T) { + origServiceEnvVars := serviceEnvVars + t.Cleanup(func() { serviceEnvVars = origServiceEnvVars }) + + // Simulate --service-env "" which produces [""] in the slice. + serviceEnvVars = []string{""} + + params := currentServiceParams() + + // After parsing, the empty string is skipped, resulting in an empty map. + // The map should still be set (not nil) so it overwrites saved values. + assert.NotNil(t, params.ServiceEnvVars, "empty env vars should produce empty map, not nil") + assert.Empty(t, params.ServiceEnvVars, "no valid env vars should be parsed from empty string") +} + +// TestServiceParams_FieldsCoveredInFunctions ensures that all serviceParams fields are +// referenced in both currentServiceParams() and applyServiceParams(). If a new field is +// added to serviceParams but not wired into these functions, this test fails. +func TestServiceParams_FieldsCoveredInFunctions(t *testing.T) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "service_params.go", nil, 0) + require.NoError(t, err) + + // Collect all JSON field names from the serviceParams struct. + structFields := extractStructJSONFields(t, file, "serviceParams") + require.NotEmpty(t, structFields, "failed to find serviceParams struct fields") + + // Collect field names referenced in currentServiceParams and applyServiceParams. + currentFields := extractFuncFieldRefs(t, file, "currentServiceParams", structFields) + applyFields := extractFuncFieldRefs(t, file, "applyServiceParams", structFields) + // applyServiceEnvParams handles ServiceEnvVars indirectly. + applyEnvFields := extractFuncFieldRefs(t, file, "applyServiceEnvParams", structFields) + for k, v := range applyEnvFields { + applyFields[k] = v + } + + for _, field := range structFields { + assert.Contains(t, currentFields, field, + "serviceParams field %q is not captured in currentServiceParams()", field) + assert.Contains(t, applyFields, field, + "serviceParams field %q is not restored in applyServiceParams()/applyServiceEnvParams()", field) + } +} + +// TestServiceParams_BuildArgsCoversAllFlags ensures that buildServiceArguments references +// all serviceParams fields that should become CLI args. ServiceEnvVars is excluded because +// it flows through newSVCConfig() EnvVars, not CLI args. +func TestServiceParams_BuildArgsCoversAllFlags(t *testing.T) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "service_params.go", nil, 0) + require.NoError(t, err) + + structFields := extractStructJSONFields(t, file, "serviceParams") + require.NotEmpty(t, structFields) + + installerFile, err := parser.ParseFile(fset, "service_installer.go", nil, 0) + require.NoError(t, err) + + // Fields that are handled outside of buildServiceArguments (env vars go through newSVCConfig). + fieldsNotInArgs := map[string]bool{ + "ServiceEnvVars": true, + } + + buildFields := extractFuncGlobalRefs(t, installerFile, "buildServiceArguments") + + // Forward: every struct field must appear in buildServiceArguments. + for _, field := range structFields { + if fieldsNotInArgs[field] { + continue + } + globalVar := fieldToGlobalVar(field) + assert.Contains(t, buildFields, globalVar, + "serviceParams field %q (global %q) is not referenced in buildServiceArguments()", field, globalVar) + } + + // Reverse: every service-related global used in buildServiceArguments must + // have a corresponding serviceParams field. This catches a developer adding + // a new flag to buildServiceArguments without adding it to the struct. + globalToField := make(map[string]string, len(structFields)) + for _, field := range structFields { + globalToField[fieldToGlobalVar(field)] = field + } + // Identifiers in buildServiceArguments that are not service params + // (builtins, boilerplate, loop variables). + nonParamGlobals := map[string]bool{ + "args": true, "append": true, "string": true, "_": true, + "logFile": true, // range variable over logFiles + } + for ref := range buildFields { + if nonParamGlobals[ref] { + continue + } + _, inStruct := globalToField[ref] + assert.True(t, inStruct, + "buildServiceArguments() references global %q which has no corresponding serviceParams field", ref) + } +} + +// extractStructJSONFields returns field names from a named struct type. +func extractStructJSONFields(t *testing.T, file *ast.File, structName string) []string { + t.Helper() + var fields []string + ast.Inspect(file, func(n ast.Node) bool { + ts, ok := n.(*ast.TypeSpec) + if !ok || ts.Name.Name != structName { + return true + } + st, ok := ts.Type.(*ast.StructType) + if !ok { + return false + } + for _, f := range st.Fields.List { + if len(f.Names) > 0 { + fields = append(fields, f.Names[0].Name) + } + } + return false + }) + return fields +} + +// extractFuncFieldRefs returns which of the given field names appear inside the +// named function, either as selector expressions (params.FieldName) or as +// composite literal keys (&serviceParams{FieldName: ...}). +func extractFuncFieldRefs(t *testing.T, file *ast.File, funcName string, fields []string) map[string]bool { + t.Helper() + fieldSet := make(map[string]bool, len(fields)) + for _, f := range fields { + fieldSet[f] = true + } + + found := make(map[string]bool) + fn := findFuncDecl(file, funcName) + require.NotNil(t, fn, "function %s not found", funcName) + + ast.Inspect(fn.Body, func(n ast.Node) bool { + switch v := n.(type) { + case *ast.SelectorExpr: + if fieldSet[v.Sel.Name] { + found[v.Sel.Name] = true + } + case *ast.KeyValueExpr: + if ident, ok := v.Key.(*ast.Ident); ok && fieldSet[ident.Name] { + found[ident.Name] = true + } + } + return true + }) + return found +} + +// extractFuncGlobalRefs returns all identifier names referenced in the named function body. +func extractFuncGlobalRefs(t *testing.T, file *ast.File, funcName string) map[string]bool { + t.Helper() + fn := findFuncDecl(file, funcName) + require.NotNil(t, fn, "function %s not found", funcName) + + refs := make(map[string]bool) + ast.Inspect(fn.Body, func(n ast.Node) bool { + if ident, ok := n.(*ast.Ident); ok { + refs[ident.Name] = true + } + return true + }) + return refs +} + +func findFuncDecl(file *ast.File, name string) *ast.FuncDecl { + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if ok && fn.Name.Name == name { + return fn + } + } + return nil +} + +// fieldToGlobalVar maps serviceParams field names to the package-level variable +// names used in buildServiceArguments and applyServiceParams. +func fieldToGlobalVar(field string) string { + m := map[string]string{ + "LogLevel": "logLevel", + "DaemonAddr": "daemonAddr", + "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 { + return v + } + // Default: lowercase first letter. + return strings.ToLower(field[:1]) + field[1:] +} + +func TestEnvMapToSlice(t *testing.T) { + m := map[string]string{"A": "1", "B": "2"} + s := envMapToSlice(m) + assert.Len(t, s, 2) + assert.Contains(t, s, "A=1") + assert.Contains(t, s, "B=2") +} + +func TestEnvMapToSlice_Empty(t *testing.T) { + s := envMapToSlice(map[string]string{}) + assert.Empty(t, s) +} 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..f825a4062 --- /dev/null +++ b/client/cmd/service_socket.go @@ -0,0 +1,111 @@ +//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 == "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]://[path|host:port] format: %q", addr) + } + + switch network { + case "unix", "tcp": + 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 6d75ca524..22eba206d 100644 --- a/client/cmd/service_test.go +++ b/client/cmd/service_test.go @@ -1,168 +1,30 @@ 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" ) -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 +// TestMain intercepts when this test binary is run as a daemon subprocess. +// On FreeBSD, the rc.d service script runs the binary via daemon(8) -r with +// "service run ..." arguments. Since the test binary can't handle cobra CLI +// args, it exits immediately, causing daemon -r to respawn rapidly until +// hitting the rate limit and exiting. This makes service restart unreliable. +// Blocking here keeps the subprocess alive until the init system sends SIGTERM. +func TestMain(m *testing.M) { + if len(os.Args) > 2 && os.Args[1] == "service" && os.Args[2] == "run" { + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGTERM, os.Interrupt) + <-sig + return } - - 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) - - 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) - }) + os.Exit(m.Run()) } // TestServiceEnvVars tests environment variable parsing diff --git a/client/cmd/signer/artifactkey.go b/client/cmd/signer/artifactkey.go new file mode 100644 index 000000000..ee12326db --- /dev/null +++ b/client/cmd/signer/artifactkey.go @@ -0,0 +1,176 @@ +package main + +import ( + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/client/internal/updater/reposign" +) + +var ( + bundlePubKeysRootPrivKeyFile string + bundlePubKeysPubKeyFiles []string + bundlePubKeysFile string + + createArtifactKeyRootPrivKeyFile string + createArtifactKeyPrivKeyFile string + createArtifactKeyPubKeyFile string + createArtifactKeyExpiration time.Duration +) + +var createArtifactKeyCmd = &cobra.Command{ + Use: "create-artifact-key", + Short: "Create a new artifact signing key", + Long: `Generate a new artifact signing key pair signed by the root private key. +The artifact key will be used to sign software artifacts/updates.`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if createArtifactKeyExpiration <= 0 { + return fmt.Errorf("--expiration must be a positive duration (e.g., 720h, 365d, 8760h)") + } + + if err := handleCreateArtifactKey(cmd, createArtifactKeyRootPrivKeyFile, createArtifactKeyPrivKeyFile, createArtifactKeyPubKeyFile, createArtifactKeyExpiration); err != nil { + return fmt.Errorf("failed to create artifact key: %w", err) + } + return nil + }, +} + +var bundlePubKeysCmd = &cobra.Command{ + Use: "bundle-pub-keys", + Short: "Bundle multiple artifact public keys into a signed package", + Long: `Bundle one or more artifact public keys into a signed package using the root private key. +This command is typically used to distribute or authorize a set of valid artifact signing keys.`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(bundlePubKeysPubKeyFiles) == 0 { + return fmt.Errorf("at least one --artifact-pub-key-file must be provided") + } + + if err := handleBundlePubKeys(cmd, bundlePubKeysRootPrivKeyFile, bundlePubKeysPubKeyFiles, bundlePubKeysFile); err != nil { + return fmt.Errorf("failed to bundle public keys: %w", err) + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(createArtifactKeyCmd) + + createArtifactKeyCmd.Flags().StringVar(&createArtifactKeyRootPrivKeyFile, "root-private-key-file", "", "Path to the root private key file used to sign the artifact key") + createArtifactKeyCmd.Flags().StringVar(&createArtifactKeyPrivKeyFile, "artifact-priv-key-file", "", "Path where the artifact private key will be saved") + createArtifactKeyCmd.Flags().StringVar(&createArtifactKeyPubKeyFile, "artifact-pub-key-file", "", "Path where the artifact public key will be saved") + createArtifactKeyCmd.Flags().DurationVar(&createArtifactKeyExpiration, "expiration", 0, "Expiration duration for the artifact key (e.g., 720h, 365d, 8760h)") + + if err := createArtifactKeyCmd.MarkFlagRequired("root-private-key-file"); err != nil { + panic(fmt.Errorf("mark root-private-key-file as required: %w", err)) + } + if err := createArtifactKeyCmd.MarkFlagRequired("artifact-priv-key-file"); err != nil { + panic(fmt.Errorf("mark artifact-priv-key-file as required: %w", err)) + } + if err := createArtifactKeyCmd.MarkFlagRequired("artifact-pub-key-file"); err != nil { + panic(fmt.Errorf("mark artifact-pub-key-file as required: %w", err)) + } + if err := createArtifactKeyCmd.MarkFlagRequired("expiration"); err != nil { + panic(fmt.Errorf("mark expiration as required: %w", err)) + } + + rootCmd.AddCommand(bundlePubKeysCmd) + + bundlePubKeysCmd.Flags().StringVar(&bundlePubKeysRootPrivKeyFile, "root-private-key-file", "", "Path to the root private key file used to sign the bundle") + bundlePubKeysCmd.Flags().StringArrayVar(&bundlePubKeysPubKeyFiles, "artifact-pub-key-file", nil, "Path(s) to the artifact public key files to include in the bundle (can be repeated)") + bundlePubKeysCmd.Flags().StringVar(&bundlePubKeysFile, "bundle-pub-key-file", "", "Path where the public keys will be saved") + + if err := bundlePubKeysCmd.MarkFlagRequired("root-private-key-file"); err != nil { + panic(fmt.Errorf("mark root-private-key-file as required: %w", err)) + } + if err := bundlePubKeysCmd.MarkFlagRequired("artifact-pub-key-file"); err != nil { + panic(fmt.Errorf("mark artifact-pub-key-file as required: %w", err)) + } + if err := bundlePubKeysCmd.MarkFlagRequired("bundle-pub-key-file"); err != nil { + panic(fmt.Errorf("mark bundle-pub-key-file as required: %w", err)) + } +} + +func handleCreateArtifactKey(cmd *cobra.Command, rootPrivKeyFile, artifactPrivKeyFile, artifactPubKeyFile string, expiration time.Duration) error { + cmd.Println("Creating new artifact signing key...") + + privKeyPEM, err := os.ReadFile(rootPrivKeyFile) + if err != nil { + return fmt.Errorf("read root private key file: %w", err) + } + + privateRootKey, err := reposign.ParseRootKey(privKeyPEM) + if err != nil { + return fmt.Errorf("failed to parse private root key: %w", err) + } + + artifactKey, privPEM, pubPEM, signature, err := reposign.GenerateArtifactKey(privateRootKey, expiration) + if err != nil { + return fmt.Errorf("generate artifact key: %w", err) + } + + if err := os.WriteFile(artifactPrivKeyFile, privPEM, 0o600); err != nil { + return fmt.Errorf("write private key file (%s): %w", artifactPrivKeyFile, err) + } + + if err := os.WriteFile(artifactPubKeyFile, pubPEM, 0o600); err != nil { + return fmt.Errorf("write public key file (%s): %w", artifactPubKeyFile, err) + } + + signatureFile := artifactPubKeyFile + ".sig" + if err := os.WriteFile(signatureFile, signature, 0o600); err != nil { + return fmt.Errorf("write signature file (%s): %w", signatureFile, err) + } + + cmd.Printf("✅ Artifact key created successfully.\n") + cmd.Printf("%s\n", artifactKey.String()) + return nil +} + +func handleBundlePubKeys(cmd *cobra.Command, rootPrivKeyFile string, artifactPubKeyFiles []string, bundlePubKeysFile string) error { + cmd.Println("📦 Bundling public keys into signed package...") + + privKeyPEM, err := os.ReadFile(rootPrivKeyFile) + if err != nil { + return fmt.Errorf("read root private key file: %w", err) + } + + privateRootKey, err := reposign.ParseRootKey(privKeyPEM) + if err != nil { + return fmt.Errorf("failed to parse private root key: %w", err) + } + + publicKeys := make([]reposign.PublicKey, 0, len(artifactPubKeyFiles)) + for _, pubFile := range artifactPubKeyFiles { + pubPem, err := os.ReadFile(pubFile) + if err != nil { + return fmt.Errorf("read public key file: %w", err) + } + + pk, err := reposign.ParseArtifactPubKey(pubPem) + if err != nil { + return fmt.Errorf("failed to parse artifact key: %w", err) + } + publicKeys = append(publicKeys, pk) + } + + parsedKeys, signature, err := reposign.BundleArtifactKeys(privateRootKey, publicKeys) + if err != nil { + return fmt.Errorf("bundle artifact keys: %w", err) + } + + if err := os.WriteFile(bundlePubKeysFile, parsedKeys, 0o600); err != nil { + return fmt.Errorf("write public keys file (%s): %w", bundlePubKeysFile, err) + } + + signatureFile := bundlePubKeysFile + ".sig" + if err := os.WriteFile(signatureFile, signature, 0o600); err != nil { + return fmt.Errorf("write signature file (%s): %w", signatureFile, err) + } + + cmd.Printf("✅ Bundle created with %d public keys.\n", len(artifactPubKeyFiles)) + return nil +} diff --git a/client/cmd/signer/artifactsign.go b/client/cmd/signer/artifactsign.go new file mode 100644 index 000000000..7c02323dc --- /dev/null +++ b/client/cmd/signer/artifactsign.go @@ -0,0 +1,276 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/client/internal/updater/reposign" +) + +const ( + envArtifactPrivateKey = "NB_ARTIFACT_PRIV_KEY" +) + +var ( + signArtifactPrivKeyFile string + signArtifactArtifactFile string + + verifyArtifactPubKeyFile string + verifyArtifactFile string + verifyArtifactSignatureFile string + + verifyArtifactKeyPubKeyFile string + verifyArtifactKeyRootPubKeyFile string + verifyArtifactKeySignatureFile string + verifyArtifactKeyRevocationFile string +) + +var signArtifactCmd = &cobra.Command{ + Use: "sign-artifact", + Short: "Sign an artifact using an artifact private key", + Long: `Sign a software artifact (e.g., update bundle or binary) using the artifact's private key. +This command produces a detached signature that can be verified using the corresponding artifact public key.`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if err := handleSignArtifact(cmd, signArtifactPrivKeyFile, signArtifactArtifactFile); err != nil { + return fmt.Errorf("failed to sign artifact: %w", err) + } + return nil + }, +} + +var verifyArtifactCmd = &cobra.Command{ + Use: "verify-artifact", + Short: "Verify an artifact signature using an artifact public key", + Long: `Verify a software artifact signature using the artifact's public key.`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if err := handleVerifyArtifact(cmd, verifyArtifactPubKeyFile, verifyArtifactFile, verifyArtifactSignatureFile); err != nil { + return fmt.Errorf("failed to verify artifact: %w", err) + } + return nil + }, +} + +var verifyArtifactKeyCmd = &cobra.Command{ + Use: "verify-artifact-key", + Short: "Verify an artifact public key was signed by a root key", + Long: `Verify that an artifact public key (or bundle) was properly signed by a root key. +This validates the chain of trust from the root key to the artifact key.`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if err := handleVerifyArtifactKey(cmd, verifyArtifactKeyPubKeyFile, verifyArtifactKeyRootPubKeyFile, verifyArtifactKeySignatureFile, verifyArtifactKeyRevocationFile); err != nil { + return fmt.Errorf("failed to verify artifact key: %w", err) + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(signArtifactCmd) + rootCmd.AddCommand(verifyArtifactCmd) + rootCmd.AddCommand(verifyArtifactKeyCmd) + + signArtifactCmd.Flags().StringVar(&signArtifactPrivKeyFile, "artifact-key-file", "", fmt.Sprintf("Path to the artifact private key file used for signing (or set %s env var)", envArtifactPrivateKey)) + signArtifactCmd.Flags().StringVar(&signArtifactArtifactFile, "artifact-file", "", "Path to the artifact to be signed") + + // artifact-file is required, but artifact-key-file can come from env var + if err := signArtifactCmd.MarkFlagRequired("artifact-file"); err != nil { + panic(fmt.Errorf("mark artifact-file as required: %w", err)) + } + + verifyArtifactCmd.Flags().StringVar(&verifyArtifactPubKeyFile, "artifact-public-key-file", "", "Path to the artifact public key file") + verifyArtifactCmd.Flags().StringVar(&verifyArtifactFile, "artifact-file", "", "Path to the artifact to be verified") + verifyArtifactCmd.Flags().StringVar(&verifyArtifactSignatureFile, "signature-file", "", "Path to the signature file") + + if err := verifyArtifactCmd.MarkFlagRequired("artifact-public-key-file"); err != nil { + panic(fmt.Errorf("mark artifact-public-key-file as required: %w", err)) + } + if err := verifyArtifactCmd.MarkFlagRequired("artifact-file"); err != nil { + panic(fmt.Errorf("mark artifact-file as required: %w", err)) + } + if err := verifyArtifactCmd.MarkFlagRequired("signature-file"); err != nil { + panic(fmt.Errorf("mark signature-file as required: %w", err)) + } + + verifyArtifactKeyCmd.Flags().StringVar(&verifyArtifactKeyPubKeyFile, "artifact-key-file", "", "Path to the artifact public key file or bundle") + verifyArtifactKeyCmd.Flags().StringVar(&verifyArtifactKeyRootPubKeyFile, "root-key-file", "", "Path to the root public key file or bundle") + verifyArtifactKeyCmd.Flags().StringVar(&verifyArtifactKeySignatureFile, "signature-file", "", "Path to the signature file") + verifyArtifactKeyCmd.Flags().StringVar(&verifyArtifactKeyRevocationFile, "revocation-file", "", "Path to the revocation list file (optional)") + + if err := verifyArtifactKeyCmd.MarkFlagRequired("artifact-key-file"); err != nil { + panic(fmt.Errorf("mark artifact-key-file as required: %w", err)) + } + if err := verifyArtifactKeyCmd.MarkFlagRequired("root-key-file"); err != nil { + panic(fmt.Errorf("mark root-key-file as required: %w", err)) + } + if err := verifyArtifactKeyCmd.MarkFlagRequired("signature-file"); err != nil { + panic(fmt.Errorf("mark signature-file as required: %w", err)) + } +} + +func handleSignArtifact(cmd *cobra.Command, privKeyFile, artifactFile string) error { + cmd.Println("🖋️ Signing artifact...") + + // Load private key from env var or file + var privKeyPEM []byte + var err error + + if envKey := os.Getenv(envArtifactPrivateKey); envKey != "" { + // Use key from environment variable + privKeyPEM = []byte(envKey) + } else if privKeyFile != "" { + // Fall back to file + privKeyPEM, err = os.ReadFile(privKeyFile) + if err != nil { + return fmt.Errorf("read private key file: %w", err) + } + } else { + return fmt.Errorf("artifact private key must be provided via %s environment variable or --artifact-key-file flag", envArtifactPrivateKey) + } + + privateKey, err := reposign.ParseArtifactKey(privKeyPEM) + if err != nil { + return fmt.Errorf("failed to parse artifact private key: %w", err) + } + + artifactData, err := os.ReadFile(artifactFile) + if err != nil { + return fmt.Errorf("read artifact file: %w", err) + } + + signature, err := reposign.SignData(privateKey, artifactData) + if err != nil { + return fmt.Errorf("sign artifact: %w", err) + } + + sigFile := artifactFile + ".sig" + if err := os.WriteFile(artifactFile+".sig", signature, 0o600); err != nil { + return fmt.Errorf("write signature file (%s): %w", sigFile, err) + } + + cmd.Printf("✅ Artifact signed successfully.\n") + cmd.Printf("Signature file: %s\n", sigFile) + return nil +} + +func handleVerifyArtifact(cmd *cobra.Command, pubKeyFile, artifactFile, signatureFile string) error { + cmd.Println("🔍 Verifying artifact...") + + // Read artifact public key + pubKeyPEM, err := os.ReadFile(pubKeyFile) + if err != nil { + return fmt.Errorf("read public key file: %w", err) + } + + publicKey, err := reposign.ParseArtifactPubKey(pubKeyPEM) + if err != nil { + return fmt.Errorf("failed to parse artifact public key: %w", err) + } + + // Read artifact data + artifactData, err := os.ReadFile(artifactFile) + if err != nil { + return fmt.Errorf("read artifact file: %w", err) + } + + // Read signature + sigBytes, err := os.ReadFile(signatureFile) + if err != nil { + return fmt.Errorf("read signature file: %w", err) + } + + signature, err := reposign.ParseSignature(sigBytes) + if err != nil { + return fmt.Errorf("failed to parse signature: %w", err) + } + + // Validate artifact + if err := reposign.ValidateArtifact([]reposign.PublicKey{publicKey}, artifactData, *signature); err != nil { + return fmt.Errorf("artifact verification failed: %w", err) + } + + cmd.Println("✅ Artifact signature is valid") + cmd.Printf("Artifact: %s\n", artifactFile) + cmd.Printf("Signed by key: %s\n", signature.KeyID) + cmd.Printf("Signature timestamp: %s\n", signature.Timestamp.Format("2006-01-02 15:04:05 MST")) + return nil +} + +func handleVerifyArtifactKey(cmd *cobra.Command, artifactKeyFile, rootKeyFile, signatureFile, revocationFile string) error { + cmd.Println("🔍 Verifying artifact key...") + + // Read artifact key data + artifactKeyData, err := os.ReadFile(artifactKeyFile) + if err != nil { + return fmt.Errorf("read artifact key file: %w", err) + } + + // Read root public key(s) + rootKeyData, err := os.ReadFile(rootKeyFile) + if err != nil { + return fmt.Errorf("read root key file: %w", err) + } + + rootPublicKeys, err := parseRootPublicKeys(rootKeyData) + if err != nil { + return fmt.Errorf("failed to parse root public key(s): %w", err) + } + + // Read signature + sigBytes, err := os.ReadFile(signatureFile) + if err != nil { + return fmt.Errorf("read signature file: %w", err) + } + + signature, err := reposign.ParseSignature(sigBytes) + if err != nil { + return fmt.Errorf("failed to parse signature: %w", err) + } + + // Read optional revocation list + var revocationList *reposign.RevocationList + if revocationFile != "" { + revData, err := os.ReadFile(revocationFile) + if err != nil { + return fmt.Errorf("read revocation file: %w", err) + } + + revocationList, err = reposign.ParseRevocationList(revData) + if err != nil { + return fmt.Errorf("failed to parse revocation list: %w", err) + } + } + + // Validate artifact key(s) + validKeys, err := reposign.ValidateArtifactKeys(rootPublicKeys, artifactKeyData, *signature, revocationList) + if err != nil { + return fmt.Errorf("artifact key verification failed: %w", err) + } + + cmd.Println("✅ Artifact key(s) verified successfully") + cmd.Printf("Signed by root key: %s\n", signature.KeyID) + cmd.Printf("Signature timestamp: %s\n", signature.Timestamp.Format("2006-01-02 15:04:05 MST")) + cmd.Printf("\nValid artifact keys (%d):\n", len(validKeys)) + for i, key := range validKeys { + cmd.Printf(" [%d] Key ID: %s\n", i+1, key.Metadata.ID) + cmd.Printf(" Created: %s\n", key.Metadata.CreatedAt.Format("2006-01-02 15:04:05 MST")) + if !key.Metadata.ExpiresAt.IsZero() { + cmd.Printf(" Expires: %s\n", key.Metadata.ExpiresAt.Format("2006-01-02 15:04:05 MST")) + } else { + cmd.Printf(" Expires: Never\n") + } + } + return nil +} + +// parseRootPublicKeys parses a root public key from PEM data +func parseRootPublicKeys(data []byte) ([]reposign.PublicKey, error) { + key, err := reposign.ParseRootPublicKey(data) + if err != nil { + return nil, err + } + return []reposign.PublicKey{key}, nil +} diff --git a/client/cmd/signer/main.go b/client/cmd/signer/main.go new file mode 100644 index 000000000..407093d07 --- /dev/null +++ b/client/cmd/signer/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "signer", + Short: "A CLI tool for managing cryptographic keys and artifacts", + Long: `signer is a command-line tool that helps you manage +root keys, artifact keys, and revocation lists securely.`, +} + +func main() { + if err := rootCmd.Execute(); err != nil { + rootCmd.Println(err) + os.Exit(1) + } +} diff --git a/client/cmd/signer/revocation.go b/client/cmd/signer/revocation.go new file mode 100644 index 000000000..5ff636dcb --- /dev/null +++ b/client/cmd/signer/revocation.go @@ -0,0 +1,220 @@ +package main + +import ( + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/client/internal/updater/reposign" +) + +const ( + defaultRevocationListExpiration = 365 * 24 * time.Hour // 1 year +) + +var ( + keyID string + revocationListFile string + privateRootKeyFile string + publicRootKeyFile string + signatureFile string + expirationDuration time.Duration +) + +var createRevocationListCmd = &cobra.Command{ + Use: "create-revocation-list", + Short: "Create a new revocation list signed by the private root key", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return handleCreateRevocationList(cmd, revocationListFile, privateRootKeyFile) + }, +} + +var extendRevocationListCmd = &cobra.Command{ + Use: "extend-revocation-list", + Short: "Extend an existing revocation list with a given key ID", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return handleExtendRevocationList(cmd, keyID, revocationListFile, privateRootKeyFile) + }, +} + +var verifyRevocationListCmd = &cobra.Command{ + Use: "verify-revocation-list", + Short: "Verify a revocation list signature using the public root key", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return handleVerifyRevocationList(cmd, revocationListFile, signatureFile, publicRootKeyFile) + }, +} + +func init() { + rootCmd.AddCommand(createRevocationListCmd) + rootCmd.AddCommand(extendRevocationListCmd) + rootCmd.AddCommand(verifyRevocationListCmd) + + createRevocationListCmd.Flags().StringVar(&revocationListFile, "revocation-list-file", "", "Path to the existing revocation list file") + createRevocationListCmd.Flags().StringVar(&privateRootKeyFile, "private-root-key", "", "Path to the private root key PEM file") + createRevocationListCmd.Flags().DurationVar(&expirationDuration, "expiration", defaultRevocationListExpiration, "Expiration duration for the revocation list (e.g., 8760h for 1 year)") + if err := createRevocationListCmd.MarkFlagRequired("revocation-list-file"); err != nil { + panic(err) + } + if err := createRevocationListCmd.MarkFlagRequired("private-root-key"); err != nil { + panic(err) + } + + extendRevocationListCmd.Flags().StringVar(&keyID, "key-id", "", "ID of the key to extend the revocation list for") + extendRevocationListCmd.Flags().StringVar(&revocationListFile, "revocation-list-file", "", "Path to the existing revocation list file") + extendRevocationListCmd.Flags().StringVar(&privateRootKeyFile, "private-root-key", "", "Path to the private root key PEM file") + extendRevocationListCmd.Flags().DurationVar(&expirationDuration, "expiration", defaultRevocationListExpiration, "Expiration duration for the revocation list (e.g., 8760h for 1 year)") + if err := extendRevocationListCmd.MarkFlagRequired("key-id"); err != nil { + panic(err) + } + if err := extendRevocationListCmd.MarkFlagRequired("revocation-list-file"); err != nil { + panic(err) + } + if err := extendRevocationListCmd.MarkFlagRequired("private-root-key"); err != nil { + panic(err) + } + + verifyRevocationListCmd.Flags().StringVar(&revocationListFile, "revocation-list-file", "", "Path to the revocation list file") + verifyRevocationListCmd.Flags().StringVar(&signatureFile, "signature-file", "", "Path to the signature file") + verifyRevocationListCmd.Flags().StringVar(&publicRootKeyFile, "public-root-key", "", "Path to the public root key PEM file") + if err := verifyRevocationListCmd.MarkFlagRequired("revocation-list-file"); err != nil { + panic(err) + } + if err := verifyRevocationListCmd.MarkFlagRequired("signature-file"); err != nil { + panic(err) + } + if err := verifyRevocationListCmd.MarkFlagRequired("public-root-key"); err != nil { + panic(err) + } +} + +func handleCreateRevocationList(cmd *cobra.Command, revocationListFile string, privateRootKeyFile string) error { + privKeyPEM, err := os.ReadFile(privateRootKeyFile) + if err != nil { + return fmt.Errorf("failed to read private root key file: %w", err) + } + + privateRootKey, err := reposign.ParseRootKey(privKeyPEM) + if err != nil { + return fmt.Errorf("failed to parse private root key: %w", err) + } + + rlBytes, sigBytes, err := reposign.CreateRevocationList(*privateRootKey, expirationDuration) + if err != nil { + return fmt.Errorf("failed to create revocation list: %w", err) + } + + if err := writeOutputFiles(revocationListFile, revocationListFile+".sig", rlBytes, sigBytes); err != nil { + return fmt.Errorf("failed to write output files: %w", err) + } + + cmd.Println("✅ Revocation list created successfully") + return nil +} + +func handleExtendRevocationList(cmd *cobra.Command, keyID, revocationListFile, privateRootKeyFile string) error { + privKeyPEM, err := os.ReadFile(privateRootKeyFile) + if err != nil { + return fmt.Errorf("failed to read private root key file: %w", err) + } + + privateRootKey, err := reposign.ParseRootKey(privKeyPEM) + if err != nil { + return fmt.Errorf("failed to parse private root key: %w", err) + } + + rlBytes, err := os.ReadFile(revocationListFile) + if err != nil { + return fmt.Errorf("failed to read revocation list file: %w", err) + } + + rl, err := reposign.ParseRevocationList(rlBytes) + if err != nil { + return fmt.Errorf("failed to parse revocation list: %w", err) + } + + kid, err := reposign.ParseKeyID(keyID) + if err != nil { + return fmt.Errorf("invalid key ID: %w", err) + } + + newRLBytes, sigBytes, err := reposign.ExtendRevocationList(*privateRootKey, *rl, kid, expirationDuration) + if err != nil { + return fmt.Errorf("failed to extend revocation list: %w", err) + } + + if err := writeOutputFiles(revocationListFile, revocationListFile+".sig", newRLBytes, sigBytes); err != nil { + return fmt.Errorf("failed to write output files: %w", err) + } + + cmd.Println("✅ Revocation list extended successfully") + return nil +} + +func handleVerifyRevocationList(cmd *cobra.Command, revocationListFile, signatureFile, publicRootKeyFile string) error { + // Read revocation list file + rlBytes, err := os.ReadFile(revocationListFile) + if err != nil { + return fmt.Errorf("failed to read revocation list file: %w", err) + } + + // Read signature file + sigBytes, err := os.ReadFile(signatureFile) + if err != nil { + return fmt.Errorf("failed to read signature file: %w", err) + } + + // Read public root key file + pubKeyPEM, err := os.ReadFile(publicRootKeyFile) + if err != nil { + return fmt.Errorf("failed to read public root key file: %w", err) + } + + // Parse public root key + publicKey, err := reposign.ParseRootPublicKey(pubKeyPEM) + if err != nil { + return fmt.Errorf("failed to parse public root key: %w", err) + } + + // Parse signature + signature, err := reposign.ParseSignature(sigBytes) + if err != nil { + return fmt.Errorf("failed to parse signature: %w", err) + } + + // Validate revocation list + rl, err := reposign.ValidateRevocationList([]reposign.PublicKey{publicKey}, rlBytes, *signature) + if err != nil { + return fmt.Errorf("failed to validate revocation list: %w", err) + } + + // Display results + cmd.Println("✅ Revocation list signature is valid") + cmd.Printf("Last Updated: %s\n", rl.LastUpdated.Format(time.RFC3339)) + cmd.Printf("Expires At: %s\n", rl.ExpiresAt.Format(time.RFC3339)) + cmd.Printf("Number of revoked keys: %d\n", len(rl.Revoked)) + + if len(rl.Revoked) > 0 { + cmd.Println("\nRevoked Keys:") + for keyID, revokedTime := range rl.Revoked { + cmd.Printf(" - %s (revoked at: %s)\n", keyID, revokedTime.Format(time.RFC3339)) + } + } + + return nil +} + +func writeOutputFiles(rlPath, sigPath string, rlBytes, sigBytes []byte) error { + if err := os.WriteFile(rlPath, rlBytes, 0o600); err != nil { + return fmt.Errorf("failed to write revocation list file: %w", err) + } + if err := os.WriteFile(sigPath, sigBytes, 0o600); err != nil { + return fmt.Errorf("failed to write signature file: %w", err) + } + return nil +} diff --git a/client/cmd/signer/rootkey.go b/client/cmd/signer/rootkey.go new file mode 100644 index 000000000..eae0da84d --- /dev/null +++ b/client/cmd/signer/rootkey.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/client/internal/updater/reposign" +) + +var ( + privKeyFile string + pubKeyFile string + rootExpiration time.Duration +) + +var createRootKeyCmd = &cobra.Command{ + Use: "create-root-key", + Short: "Create a new root key pair", + Long: `Create a new root key pair and specify an expiration time for it.`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + // Validate expiration + if rootExpiration <= 0 { + return fmt.Errorf("--expiration must be a positive duration (e.g., 720h, 365d, 8760h)") + } + + // Run main logic + if err := handleGenerateRootKey(cmd, privKeyFile, pubKeyFile, rootExpiration); err != nil { + return fmt.Errorf("failed to generate root key: %w", err) + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(createRootKeyCmd) + createRootKeyCmd.Flags().StringVar(&privKeyFile, "priv-key-file", "", "Path to output private key file") + createRootKeyCmd.Flags().StringVar(&pubKeyFile, "pub-key-file", "", "Path to output public key file") + createRootKeyCmd.Flags().DurationVar(&rootExpiration, "expiration", 0, "Expiration time for the root key (e.g., 720h,)") + + if err := createRootKeyCmd.MarkFlagRequired("priv-key-file"); err != nil { + panic(err) + } + if err := createRootKeyCmd.MarkFlagRequired("pub-key-file"); err != nil { + panic(err) + } + if err := createRootKeyCmd.MarkFlagRequired("expiration"); err != nil { + panic(err) + } +} + +func handleGenerateRootKey(cmd *cobra.Command, privKeyFile, pubKeyFile string, expiration time.Duration) error { + rk, privPEM, pubPEM, err := reposign.GenerateRootKey(expiration) + if err != nil { + return fmt.Errorf("generate root key: %w", err) + } + + // Write private key + if err := os.WriteFile(privKeyFile, privPEM, 0o600); err != nil { + return fmt.Errorf("write private key file (%s): %w", privKeyFile, err) + } + + // Write public key + if err := os.WriteFile(pubKeyFile, pubPEM, 0o600); err != nil { + return fmt.Errorf("write public key file (%s): %w", pubKeyFile, err) + } + + cmd.Printf("%s\n\n", rk.String()) + cmd.Printf("✅ Root key pair generated successfully.\n") + return nil +} diff --git a/client/cmd/ssh.go b/client/cmd/ssh.go index 5358ddacb..d6e052e08 100644 --- a/client/cmd/ssh.go +++ b/client/cmd/ssh.go @@ -3,125 +3,886 @@ package cmd import ( "context" "errors" + "flag" "fmt" + "net" "os" "os/signal" + "os/user" + "slices" + "strconv" "strings" "syscall" + log "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "golang.org/x/crypto/ssh" "github.com/netbirdio/netbird/client/internal" - "github.com/netbirdio/netbird/client/internal/profilemanager" - nbssh "github.com/netbirdio/netbird/client/ssh" + sshclient "github.com/netbirdio/netbird/client/ssh/client" + "github.com/netbirdio/netbird/client/ssh/detection" + sshproxy "github.com/netbirdio/netbird/client/ssh/proxy" + sshserver "github.com/netbirdio/netbird/client/ssh/server" "github.com/netbirdio/netbird/util" ) -var ( - port int - userName = "root" - host string +const ( + sshUsernameDesc = "SSH username" + hostArgumentRequired = "host argument required" + + serverSSHAllowedFlag = "allow-server-ssh" + enableSSHRootFlag = "enable-ssh-root" + enableSSHSFTPFlag = "enable-ssh-sftp" + enableSSHLocalPortForwardFlag = "enable-ssh-local-port-forwarding" + enableSSHRemotePortForwardFlag = "enable-ssh-remote-port-forwarding" + disableSSHAuthFlag = "disable-ssh-auth" + sshJWTCacheTTLFlag = "ssh-jwt-cache-ttl" ) -var sshCmd = &cobra.Command{ - Use: "ssh [user@]host", - Args: func(cmd *cobra.Command, args []string) error { - if len(args) < 1 { - return errors.New("requires a host argument") - } +var ( + port int + username string + host string + command string + localForwards []string + remoteForwards []string + strictHostKeyChecking bool + knownHostsFile string + identityFile string + skipCachedToken bool + requestPTY bool + sshNoBrowser bool +) - split := strings.Split(args[0], "@") - if len(split) == 2 { - userName = split[0] - host = split[1] - } else { - host = args[0] - } +var ( + serverSSHAllowed bool + enableSSHRoot bool + enableSSHSFTP bool + enableSSHLocalPortForward bool + enableSSHRemotePortForward bool + disableSSHAuth bool + sshJWTCacheTTL int +) - return nil - }, - Short: "Connect to a remote SSH server", - RunE: func(cmd *cobra.Command, args []string) error { - SetFlagsFromEnvVars(rootCmd) - SetFlagsFromEnvVars(cmd) +func init() { + upCmd.PersistentFlags().BoolVar(&serverSSHAllowed, serverSSHAllowedFlag, false, "Allow SSH server on peer") + upCmd.PersistentFlags().BoolVar(&enableSSHRoot, enableSSHRootFlag, false, "Enable root login for SSH server") + upCmd.PersistentFlags().BoolVar(&enableSSHSFTP, enableSSHSFTPFlag, false, "Enable SFTP subsystem for SSH server") + upCmd.PersistentFlags().BoolVar(&enableSSHLocalPortForward, enableSSHLocalPortForwardFlag, false, "Enable local port forwarding for SSH server") + upCmd.PersistentFlags().BoolVar(&enableSSHRemotePortForward, enableSSHRemotePortForwardFlag, false, "Enable remote port forwarding for SSH server") + upCmd.PersistentFlags().BoolVar(&disableSSHAuth, disableSSHAuthFlag, false, "Disable SSH authentication") + upCmd.PersistentFlags().IntVar(&sshJWTCacheTTL, sshJWTCacheTTLFlag, 0, "SSH JWT token cache TTL in seconds (0=disabled)") - cmd.SetOut(cmd.OutOrStdout()) + sshCmd.PersistentFlags().IntVarP(&port, "port", "p", sshserver.DefaultSSHPort, "Remote SSH port") + sshCmd.PersistentFlags().StringVarP(&username, "user", "u", "", sshUsernameDesc) + sshCmd.PersistentFlags().StringVar(&username, "login", "", sshUsernameDesc+" (alias for --user)") + sshCmd.PersistentFlags().BoolVarP(&requestPTY, "tty", "t", false, "Force pseudo-terminal allocation") + sshCmd.PersistentFlags().BoolVar(&strictHostKeyChecking, "strict-host-key-checking", true, "Enable strict host key checking (default: true)") + sshCmd.PersistentFlags().StringVarP(&knownHostsFile, "known-hosts", "o", "", "Path to known_hosts file (default: ~/.ssh/known_hosts)") + sshCmd.PersistentFlags().StringVarP(&identityFile, "identity", "i", "", "Path to SSH private key file (deprecated)") + _ = sshCmd.PersistentFlags().MarkDeprecated("identity", "this flag is no longer used") + sshCmd.PersistentFlags().BoolVar(&skipCachedToken, "no-cache", false, "Skip cached JWT token and force fresh authentication") + sshCmd.PersistentFlags().BoolVar(&sshNoBrowser, noBrowserFlag, false, noBrowserDesc) - err := util.InitLog(logLevel, util.LogConsole) - if err != nil { - return fmt.Errorf("failed initializing log %v", err) - } + sshCmd.PersistentFlags().StringArrayP("L", "L", []string{}, "Local port forwarding [bind_address:]port:host:hostport") + sshCmd.PersistentFlags().StringArrayP("R", "R", []string{}, "Remote port forwarding [bind_address:]port:host:hostport") - if !util.IsAdmin() { - cmd.Printf("error: you must have Administrator privileges to run this command\n") - return nil - } - - ctx := internal.CtxInitState(cmd.Context()) - - sm := profilemanager.NewServiceManager(configPath) - activeProf, err := sm.GetActiveProfileState() - if err != nil { - return fmt.Errorf("get active profile: %v", err) - } - profPath, err := activeProf.FilePath() - if err != nil { - return fmt.Errorf("get active profile path: %v", err) - } - - config, err := profilemanager.ReadConfig(profPath) - if err != nil { - return fmt.Errorf("read profile config: %v", err) - } - - sig := make(chan os.Signal, 1) - signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT) - sshctx, cancel := context.WithCancel(ctx) - - go func() { - // blocking - if err := runSSH(sshctx, host, []byte(config.SSHKey), cmd); err != nil { - cmd.Printf("Error: %v\n", err) - os.Exit(1) - } - cancel() - }() - - select { - case <-sig: - cancel() - case <-sshctx.Done(): - } - - return nil - }, + sshCmd.AddCommand(sshSftpCmd) + sshCmd.AddCommand(sshProxyCmd) + sshCmd.AddCommand(sshDetectCmd) } -func runSSH(ctx context.Context, addr string, pemKey []byte, cmd *cobra.Command) error { - c, err := nbssh.DialWithKey(fmt.Sprintf("%s:%d", addr, port), userName, pemKey) - if err != nil { - cmd.Printf("Error: %v\n", err) - cmd.Printf("Couldn't connect. Please check the connection status or if the ssh server is enabled on the other peer" + - "\nYou can verify the connection by running:\n\n" + - " netbird status\n\n") - return err - } - go func() { - <-ctx.Done() - err = c.Close() - if err != nil { - return +var sshCmd = &cobra.Command{ + Use: "ssh [flags] [user@]host [command]", + Short: "Connect to a NetBird peer via SSH", + Long: `Connect to a NetBird peer using SSH with support for port forwarding. + +Port Forwarding: + -L [bind_address:]port:host:hostport Local port forwarding + -L [bind_address:]port:/path/to/socket Local port forwarding to Unix socket + -R [bind_address:]port:host:hostport Remote port forwarding + -R [bind_address:]port:/path/to/socket Remote port forwarding to Unix socket + +SSH Options: + -p, --port int Remote SSH port (default 22) + -u, --user string SSH username + --login string SSH username (alias for --user) + -t, --tty Force pseudo-terminal allocation + --strict-host-key-checking Enable strict host key checking (default: true) + -o, --known-hosts string Path to known_hosts file + +Examples: + netbird ssh peer-hostname + netbird ssh root@peer-hostname + netbird ssh --login root peer-hostname + netbird ssh peer-hostname ls -la + netbird ssh peer-hostname whoami + netbird ssh -t peer-hostname tmux # Force PTY for tmux/screen + netbird ssh -t peer-hostname sudo -i # Force PTY for interactive sudo + netbird ssh -L 8080:localhost:80 peer-hostname # Local port forwarding + netbird ssh -R 9090:localhost:3000 peer-hostname # Remote port forwarding + netbird ssh -L "*:8080:localhost:80" peer-hostname # Bind to all interfaces + netbird ssh -L 8080:/tmp/socket peer-hostname # Unix socket forwarding`, + DisableFlagParsing: true, + Args: validateSSHArgsWithoutFlagParsing, + RunE: sshFn, + Aliases: []string{"ssh"}, +} + +func sshFn(cmd *cobra.Command, args []string) error { + for _, arg := range args { + if arg == "-h" || arg == "--help" { + return cmd.Help() } + } + + SetFlagsFromEnvVars(rootCmd) + SetFlagsFromEnvVars(cmd) + + cmd.SetOut(cmd.OutOrStdout()) + + logOutput := "console" + if firstLogFile := util.FindFirstLogPath(logFiles); firstLogFile != "" && firstLogFile != defaultLogFile { + logOutput = firstLogFile + } + if err := util.InitLog(logLevel, logOutput); err != nil { + return fmt.Errorf("init log: %w", err) + } + + ctx := internal.CtxInitState(cmd.Context()) + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT) + sshctx, cancel := context.WithCancel(ctx) + + errCh := make(chan error, 1) + go func() { + if err := runSSH(sshctx, host, cmd); err != nil { + errCh <- err + } + cancel() }() - err = c.OpenTerminal() - if err != nil { + select { + case <-sig: + cancel() + <-sshctx.Done() + return nil + case err := <-errCh: return err + case <-sshctx.Done(): } return nil } -func init() { - sshCmd.PersistentFlags().IntVarP(&port, "port", "p", nbssh.DefaultSSHPort, "Sets remote SSH port. Defaults to "+fmt.Sprint(nbssh.DefaultSSHPort)) +// getEnvOrDefault checks for environment variables with WT_ and NB_ prefixes +func getEnvOrDefault(flagName, defaultValue string) string { + if envValue := os.Getenv("WT_" + flagName); envValue != "" { + return envValue + } + if envValue := os.Getenv("NB_" + flagName); envValue != "" { + return envValue + } + return defaultValue +} + +// getBoolEnvOrDefault checks for boolean environment variables with WT_ and NB_ prefixes +func getBoolEnvOrDefault(flagName string, defaultValue bool) bool { + if envValue := os.Getenv("WT_" + flagName); envValue != "" { + if parsed, err := strconv.ParseBool(envValue); err == nil { + return parsed + } + } + if envValue := os.Getenv("NB_" + flagName); envValue != "" { + if parsed, err := strconv.ParseBool(envValue); err == nil { + return parsed + } + } + return defaultValue +} + +// resetSSHGlobals sets SSH globals to their default values +func resetSSHGlobals() { + port = sshserver.DefaultSSHPort + username = "" + host = "" + command = "" + localForwards = nil + remoteForwards = nil + strictHostKeyChecking = true + knownHostsFile = "" + identityFile = "" + sshNoBrowser = false +} + +// parseCustomSSHFlags extracts -L, -R flags and returns filtered args +func parseCustomSSHFlags(args []string) ([]string, []string, []string) { + var localForwardFlags []string + var remoteForwardFlags []string + var filteredArgs []string + + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case strings.HasPrefix(arg, "-L"): + localForwardFlags, i = parseForwardFlag(arg, args, i, localForwardFlags) + case strings.HasPrefix(arg, "-R"): + remoteForwardFlags, i = parseForwardFlag(arg, args, i, remoteForwardFlags) + default: + filteredArgs = append(filteredArgs, arg) + } + } + + return filteredArgs, localForwardFlags, remoteForwardFlags +} + +func parseForwardFlag(arg string, args []string, i int, flags []string) ([]string, int) { + if arg == "-L" || arg == "-R" { + if i+1 < len(args) { + flags = append(flags, args[i+1]) + i++ + } + } else if len(arg) > 2 { + flags = append(flags, arg[2:]) + } + return flags, i +} + +// extractGlobalFlags parses global flags that were passed before 'ssh' command +func extractGlobalFlags(args []string) { + sshPos := findSSHCommandPosition(args) + if sshPos == -1 { + return + } + + globalArgs := args[:sshPos] + parseGlobalArgs(globalArgs) +} + +// findSSHCommandPosition locates the 'ssh' command in the argument list +func findSSHCommandPosition(args []string) int { + for i, arg := range args { + if arg == "ssh" { + return i + } + } + return -1 +} + +const ( + configFlag = "config" + logLevelFlag = "log-level" + logFileFlag = "log-file" +) + +// parseGlobalArgs processes the global arguments and sets the corresponding variables +func parseGlobalArgs(globalArgs []string) { + flagHandlers := map[string]func(string){ + configFlag: func(value string) { configPath = value }, + logLevelFlag: func(value string) { logLevel = value }, + logFileFlag: func(value string) { + if !slices.Contains(logFiles, value) { + logFiles = append(logFiles, value) + } + }, + } + + shortFlags := map[string]string{ + "c": configFlag, + "l": logLevelFlag, + } + + for i := 0; i < len(globalArgs); i++ { + arg := globalArgs[i] + + if handled, nextIndex := parseFlag(arg, globalArgs, i, flagHandlers, shortFlags); handled { + i = nextIndex + } + } +} + +// parseFlag handles generic flag parsing for both long and short forms +func parseFlag(arg string, args []string, currentIndex int, flagHandlers map[string]func(string), shortFlags map[string]string) (bool, int) { + if parsedValue, found := parseEqualsFormat(arg, flagHandlers, shortFlags); found { + flagHandlers[parsedValue.flagName](parsedValue.value) + return true, currentIndex + } + + if parsedValue, found := parseSpacedFormat(arg, args, currentIndex, flagHandlers, shortFlags); found { + flagHandlers[parsedValue.flagName](parsedValue.value) + return true, currentIndex + 1 + } + + return false, currentIndex +} + +type parsedFlag struct { + flagName string + value string +} + +// parseEqualsFormat handles --flag=value and -f=value formats +func parseEqualsFormat(arg string, flagHandlers map[string]func(string), shortFlags map[string]string) (parsedFlag, bool) { + if !strings.Contains(arg, "=") { + return parsedFlag{}, false + } + + parts := strings.SplitN(arg, "=", 2) + if len(parts) != 2 { + return parsedFlag{}, false + } + + if strings.HasPrefix(parts[0], "--") { + flagName := strings.TrimPrefix(parts[0], "--") + if _, exists := flagHandlers[flagName]; exists { + return parsedFlag{flagName: flagName, value: parts[1]}, true + } + } + + if strings.HasPrefix(parts[0], "-") && len(parts[0]) == 2 { + shortFlag := strings.TrimPrefix(parts[0], "-") + if longFlag, exists := shortFlags[shortFlag]; exists { + if _, exists := flagHandlers[longFlag]; exists { + return parsedFlag{flagName: longFlag, value: parts[1]}, true + } + } + } + + return parsedFlag{}, false +} + +// parseSpacedFormat handles --flag value and -f value formats +func parseSpacedFormat(arg string, args []string, currentIndex int, flagHandlers map[string]func(string), shortFlags map[string]string) (parsedFlag, bool) { + if currentIndex+1 >= len(args) { + return parsedFlag{}, false + } + + if strings.HasPrefix(arg, "--") { + flagName := strings.TrimPrefix(arg, "--") + if _, exists := flagHandlers[flagName]; exists { + return parsedFlag{flagName: flagName, value: args[currentIndex+1]}, true + } + } + + if strings.HasPrefix(arg, "-") && len(arg) == 2 { + shortFlag := strings.TrimPrefix(arg, "-") + if longFlag, exists := shortFlags[shortFlag]; exists { + if _, exists := flagHandlers[longFlag]; exists { + return parsedFlag{flagName: longFlag, value: args[currentIndex+1]}, true + } + } + } + + return parsedFlag{}, false +} + +// createSSHFlagSet creates and configures the flag set for SSH command parsing +// sshFlags contains all SSH-related flags and parameters +type sshFlags struct { + Port int + Username string + Login string + RequestPTY bool + StrictHostKeyChecking bool + KnownHostsFile string + IdentityFile string + SkipCachedToken bool + NoBrowser bool + ConfigPath string + LogLevel string + LocalForwards []string + RemoteForwards []string + Host string + Command string +} + +func createSSHFlagSet() (*flag.FlagSet, *sshFlags) { + defaultConfigPath := getEnvOrDefault("CONFIG", configPath) + defaultLogLevel := getEnvOrDefault("LOG_LEVEL", logLevel) + defaultNoBrowser := getBoolEnvOrDefault("NO_BROWSER", false) + + fs := flag.NewFlagSet("ssh-flags", flag.ContinueOnError) + fs.SetOutput(nil) + + flags := &sshFlags{} + + fs.IntVar(&flags.Port, "p", sshserver.DefaultSSHPort, "SSH port") + fs.IntVar(&flags.Port, "port", sshserver.DefaultSSHPort, "SSH port") + fs.StringVar(&flags.Username, "u", "", sshUsernameDesc) + fs.StringVar(&flags.Username, "user", "", sshUsernameDesc) + fs.StringVar(&flags.Login, "login", "", sshUsernameDesc+" (alias for --user)") + fs.BoolVar(&flags.RequestPTY, "t", false, "Force pseudo-terminal allocation") + fs.BoolVar(&flags.RequestPTY, "tty", false, "Force pseudo-terminal allocation") + + fs.BoolVar(&flags.StrictHostKeyChecking, "strict-host-key-checking", true, "Enable strict host key checking") + fs.StringVar(&flags.KnownHostsFile, "o", "", "Path to known_hosts file") + fs.StringVar(&flags.KnownHostsFile, "known-hosts", "", "Path to known_hosts file") + fs.StringVar(&flags.IdentityFile, "i", "", "Path to SSH private key file") + fs.StringVar(&flags.IdentityFile, "identity", "", "Path to SSH private key file") + fs.BoolVar(&flags.SkipCachedToken, "no-cache", false, "Skip cached JWT token and force fresh authentication") + fs.BoolVar(&flags.NoBrowser, "no-browser", defaultNoBrowser, noBrowserDesc) + + fs.StringVar(&flags.ConfigPath, "c", defaultConfigPath, "Netbird config file location") + fs.StringVar(&flags.ConfigPath, "config", defaultConfigPath, "Netbird config file location") + fs.StringVar(&flags.LogLevel, "l", defaultLogLevel, "sets Netbird log level") + fs.StringVar(&flags.LogLevel, "log-level", defaultLogLevel, "sets Netbird log level") + + return fs, flags +} + +func validateSSHArgsWithoutFlagParsing(_ *cobra.Command, args []string) error { + if len(args) < 1 { + return errors.New(hostArgumentRequired) + } + + resetSSHGlobals() + + if len(os.Args) > 2 { + extractGlobalFlags(os.Args[1:]) + } + + filteredArgs, localForwardFlags, remoteForwardFlags := parseCustomSSHFlags(args) + + fs, flags := createSSHFlagSet() + + if err := fs.Parse(filteredArgs); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + + remaining := fs.Args() + if len(remaining) < 1 { + return errors.New(hostArgumentRequired) + } + + port = flags.Port + if flags.Username != "" { + username = flags.Username + } else if flags.Login != "" { + username = flags.Login + } + + requestPTY = flags.RequestPTY + strictHostKeyChecking = flags.StrictHostKeyChecking + knownHostsFile = flags.KnownHostsFile + identityFile = flags.IdentityFile + skipCachedToken = flags.SkipCachedToken + sshNoBrowser = flags.NoBrowser + + if flags.ConfigPath != getEnvOrDefault("CONFIG", configPath) { + configPath = flags.ConfigPath + } + if flags.LogLevel != getEnvOrDefault("LOG_LEVEL", logLevel) { + logLevel = flags.LogLevel + } + + localForwards = localForwardFlags + remoteForwards = remoteForwardFlags + + return parseHostnameAndCommand(remaining) +} + +func parseHostnameAndCommand(args []string) error { + if len(args) < 1 { + return errors.New(hostArgumentRequired) + } + + arg := args[0] + if strings.Contains(arg, "@") { + parts := strings.SplitN(arg, "@", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return errors.New("invalid user@host format") + } + if username == "" { + username = parts[0] + } + host = parts[1] + } else { + host = arg + } + + if username == "" { + if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" { + username = sudoUser + } else if currentUser, err := user.Current(); err == nil { + username = currentUser.Username + } else { + username = "root" + } + } + + // Everything after hostname becomes the command + if len(args) > 1 { + command = strings.Join(args[1:], " ") + } + + return nil +} + +func runSSH(ctx context.Context, addr string, cmd *cobra.Command) error { + target := net.JoinHostPort(strings.Trim(addr, "[]"), strconv.Itoa(port)) + c, err := sshclient.Dial(ctx, target, username, sshclient.DialOptions{ + KnownHostsFile: knownHostsFile, + IdentityFile: identityFile, + DaemonAddr: daemonAddr, + SkipCachedToken: skipCachedToken, + InsecureSkipVerify: !strictHostKeyChecking, + NoBrowser: sshNoBrowser, + }) + + if err != nil { + cmd.Printf("Failed to connect to %s@%s\n", username, target) + cmd.Printf("\nTroubleshooting steps:\n") + cmd.Printf(" 1. Check peer connectivity: netbird status -d\n") + cmd.Printf(" 2. Verify SSH server is enabled on the peer\n") + cmd.Printf(" 3. Ensure correct hostname/IP is used\n") + return fmt.Errorf("dial %s: %w", target, err) + } + + sshCtx, cancel := context.WithCancel(ctx) + defer cancel() + + go func() { + <-sshCtx.Done() + if err := c.Close(); err != nil { + cmd.Printf("Error closing SSH connection: %v\n", err) + } + }() + + if err := startPortForwarding(sshCtx, c, cmd); err != nil { + return fmt.Errorf("start port forwarding: %w", err) + } + + if command != "" { + return executeSSHCommand(sshCtx, c, command) + } + return openSSHTerminal(sshCtx, c) +} + +// executeSSHCommand executes a command over SSH. +func executeSSHCommand(ctx context.Context, c *sshclient.Client, command string) error { + var err error + if requestPTY { + err = c.ExecuteCommandWithPTY(ctx, command) + } else { + err = c.ExecuteCommandWithIO(ctx, command) + } + + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil + } + + var exitErr *ssh.ExitError + if errors.As(err, &exitErr) { + os.Exit(exitErr.ExitStatus()) + } + + var exitMissingErr *ssh.ExitMissingError + if errors.As(err, &exitMissingErr) { + log.Debugf("Remote command exited without exit status: %v", err) + return nil + } + + return fmt.Errorf("execute command: %w", err) + } + return nil +} + +// openSSHTerminal opens an interactive SSH terminal. +func openSSHTerminal(ctx context.Context, c *sshclient.Client) error { + if err := c.OpenTerminal(ctx); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil + } + + var exitMissingErr *ssh.ExitMissingError + if errors.As(err, &exitMissingErr) { + log.Debugf("Remote terminal exited without exit status: %v", err) + return nil + } + + return fmt.Errorf("open terminal: %w", err) + } + return nil +} + +// startPortForwarding starts local and remote port forwarding based on command line flags +func startPortForwarding(ctx context.Context, c *sshclient.Client, cmd *cobra.Command) error { + for _, forward := range localForwards { + if err := parseAndStartLocalForward(ctx, c, forward, cmd); err != nil { + return fmt.Errorf("local port forward %s: %w", forward, err) + } + } + + for _, forward := range remoteForwards { + if err := parseAndStartRemoteForward(ctx, c, forward, cmd); err != nil { + return fmt.Errorf("remote port forward %s: %w", forward, err) + } + } + + return nil +} + +// parseAndStartLocalForward parses and starts a local port forward (-L) +func parseAndStartLocalForward(ctx context.Context, c *sshclient.Client, forward string, cmd *cobra.Command) error { + localAddr, remoteAddr, err := parsePortForwardSpec(forward) + if err != nil { + return err + } + + if err := validateDestinationPort(remoteAddr); err != nil { + return fmt.Errorf("invalid remote address: %w", err) + } + + log.Debugf("Local port forwarding: %s -> %s", localAddr, remoteAddr) + + go func() { + if err := c.LocalPortForward(ctx, localAddr, remoteAddr); err != nil && !errors.Is(err, context.Canceled) { + cmd.Printf("Local port forward error: %v\n", err) + } + }() + + return nil +} + +// parseAndStartRemoteForward parses and starts a remote port forward (-R) +func parseAndStartRemoteForward(ctx context.Context, c *sshclient.Client, forward string, cmd *cobra.Command) error { + remoteAddr, localAddr, err := parsePortForwardSpec(forward) + if err != nil { + return err + } + + if err := validateDestinationPort(localAddr); err != nil { + return fmt.Errorf("invalid local address: %w", err) + } + + log.Debugf("Remote port forwarding: %s -> %s", remoteAddr, localAddr) + + go func() { + if err := c.RemotePortForward(ctx, remoteAddr, localAddr); err != nil && !errors.Is(err, context.Canceled) { + cmd.Printf("Remote port forward error: %v\n", err) + } + }() + + return nil +} + +// validateDestinationPort checks that the destination address has a valid port. +// Port 0 is only valid for bind addresses (where the OS picks an available port), +// not for destination addresses where we need to connect. +func validateDestinationPort(addr string) error { + if strings.HasPrefix(addr, "/") || strings.HasPrefix(addr, "./") { + return nil + } + + _, portStr, err := net.SplitHostPort(addr) + if err != nil { + return fmt.Errorf("parse address %s: %w", addr, err) + } + + port, err := strconv.Atoi(portStr) + if err != nil { + return fmt.Errorf("invalid port %s: %w", portStr, err) + } + + if port == 0 { + return fmt.Errorf("port 0 is not valid for destination address") + } + + if port < 0 || port > 65535 { + return fmt.Errorf("port %d out of range (1-65535)", port) + } + + return nil +} + +// parsePortForwardSpec parses port forward specifications like "8080:localhost:80" or "[::1]:8080:localhost:80". +// Also supports Unix sockets like "8080:/tmp/socket" or "127.0.0.1:8080:/tmp/socket". +func parsePortForwardSpec(spec string) (string, string, error) { + // Support formats: + // port:host:hostport -> localhost:port -> host:hostport + // host:port:host:hostport -> host:port -> host:hostport + // [host]:port:host:hostport -> [host]:port -> host:hostport + // port:unix_socket_path -> localhost:port -> unix_socket_path + // host:port:unix_socket_path -> host:port -> unix_socket_path + + if strings.HasPrefix(spec, "[") && strings.Contains(spec, "]:") { + return parseIPv6ForwardSpec(spec) + } + + parts := strings.Split(spec, ":") + if len(parts) < 2 { + return "", "", fmt.Errorf("invalid port forward specification: %s (expected format: [local_host:]local_port:remote_target)", spec) + } + + switch len(parts) { + case 2: + return parseTwoPartForwardSpec(parts, spec) + case 3: + return parseThreePartForwardSpec(parts) + case 4: + return parseFourPartForwardSpec(parts) + default: + return "", "", fmt.Errorf("invalid port forward specification: %s", spec) + } +} + +// parseTwoPartForwardSpec handles "port:unix_socket" format. +func parseTwoPartForwardSpec(parts []string, spec string) (string, string, error) { + if isUnixSocket(parts[1]) { + localAddr := "localhost:" + parts[0] + remoteAddr := parts[1] + return localAddr, remoteAddr, nil + } + return "", "", fmt.Errorf("invalid port forward specification: %s (expected format: [local_host:]local_port:remote_host:remote_port or [local_host:]local_port:unix_socket)", spec) +} + +// parseThreePartForwardSpec handles "port:host:hostport" or "host:port:unix_socket" formats. +func parseThreePartForwardSpec(parts []string) (string, string, error) { + if isUnixSocket(parts[2]) { + localHost := normalizeLocalHost(parts[0]) + localAddr := localHost + ":" + parts[1] + remoteAddr := parts[2] + return localAddr, remoteAddr, nil + } + localAddr := "localhost:" + parts[0] + remoteAddr := parts[1] + ":" + parts[2] + return localAddr, remoteAddr, nil +} + +// parseFourPartForwardSpec handles "host:port:host:hostport" format. +func parseFourPartForwardSpec(parts []string) (string, string, error) { + localHost := normalizeLocalHost(parts[0]) + localAddr := localHost + ":" + parts[1] + remoteAddr := parts[2] + ":" + parts[3] + return localAddr, remoteAddr, nil +} + +// parseIPv6ForwardSpec handles "[host]:port:host:hostport" format. +func parseIPv6ForwardSpec(spec string) (string, string, error) { + idx := strings.Index(spec, "]:") + if idx == -1 { + return "", "", fmt.Errorf("invalid IPv6 port forward specification: %s", spec) + } + + ipv6Host := spec[:idx+1] + remaining := spec[idx+2:] + + parts := strings.Split(remaining, ":") + if len(parts) != 3 { + return "", "", fmt.Errorf("invalid IPv6 port forward specification: %s (expected [ipv6]:port:host:hostport)", spec) + } + + localAddr := ipv6Host + ":" + parts[0] + remoteAddr := parts[1] + ":" + parts[2] + return localAddr, remoteAddr, nil +} + +// isUnixSocket checks if a path is a Unix socket path. +func isUnixSocket(path string) bool { + return strings.HasPrefix(path, "/") || strings.HasPrefix(path, "./") +} + +// normalizeLocalHost converts "*" to "" for binding to all interfaces (dual-stack). +func normalizeLocalHost(host string) string { + if host == "*" { + return "" + } + return host +} + +var sshProxyCmd = &cobra.Command{ + Use: "proxy ", + Short: "Internal SSH proxy for native SSH client integration", + Long: "Internal command used by SSH ProxyCommand to handle JWT authentication", + Hidden: true, + Args: cobra.ExactArgs(2), + RunE: sshProxyFn, +} + +func sshProxyFn(cmd *cobra.Command, args []string) error { + logOutput := "console" + if firstLogFile := util.FindFirstLogPath(logFiles); firstLogFile != "" && firstLogFile != defaultLogFile { + logOutput = firstLogFile + } + + proxyLogLevel := getEnvOrDefault("LOG_LEVEL", logLevel) + if err := util.InitLog(proxyLogLevel, logOutput); err != nil { + return fmt.Errorf("init log: %w", err) + } + + host := args[0] + portStr := args[1] + + port, err := strconv.Atoi(portStr) + if err != nil { + return fmt.Errorf("invalid port: %s", portStr) + } + + // Check env var for browser setting since this command is invoked via SSH ProxyCommand + // where command-line flags cannot be passed. Default is to open browser. + noBrowser := getBoolEnvOrDefault("NO_BROWSER", false) + var browserOpener func(string) error + if !noBrowser { + browserOpener = util.OpenBrowser + } + + proxy, err := sshproxy.New(daemonAddr, host, port, cmd.ErrOrStderr(), browserOpener) + if err != nil { + return fmt.Errorf("create SSH proxy: %w", err) + } + defer func() { + if err := proxy.Close(); err != nil { + log.Debugf("close SSH proxy: %v", err) + } + }() + + if err := proxy.Connect(cmd.Context()); err != nil { + return fmt.Errorf("SSH proxy: %w", err) + } + + return nil +} + +var sshDetectCmd = &cobra.Command{ + Use: "detect ", + Short: "Detect if a host is running NetBird SSH", + Long: "Internal command used by SSH Match exec to detect NetBird SSH servers. Exit codes: 0=JWT, 1=no-JWT, 2=regular SSH", + Hidden: true, + Args: cobra.ExactArgs(2), + RunE: sshDetectFn, +} + +func sshDetectFn(cmd *cobra.Command, args []string) error { + detectLogLevel := getEnvOrDefault("LOG_LEVEL", logLevel) + if err := util.InitLog(detectLogLevel, "console"); err != nil { + os.Exit(detection.ServerTypeRegular.ExitCode()) + } + + host := args[0] + portStr := args[1] + + port, err := strconv.Atoi(portStr) + if err != nil { + log.Debugf("invalid port %q: %v", portStr, err) + os.Exit(detection.ServerTypeRegular.ExitCode()) + } + + ctx, cancel := context.WithTimeout(cmd.Context(), detection.DefaultTimeout) + + dialer := &net.Dialer{} + serverType, err := detection.DetectSSHServerType(ctx, dialer, host, port) + if err != nil { + log.Debugf("SSH server detection failed: %v", err) + cancel() + os.Exit(detection.ServerTypeRegular.ExitCode()) + } + + cancel() + os.Exit(serverType.ExitCode()) + return nil } diff --git a/client/cmd/ssh_exec_unix.go b/client/cmd/ssh_exec_unix.go new file mode 100644 index 000000000..2412f072c --- /dev/null +++ b/client/cmd/ssh_exec_unix.go @@ -0,0 +1,74 @@ +//go:build unix + +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + sshserver "github.com/netbirdio/netbird/client/ssh/server" +) + +var ( + sshExecUID uint32 + sshExecGID uint32 + sshExecGroups []uint + sshExecWorkingDir string + sshExecShell string + sshExecCommand string + sshExecPTY bool +) + +// sshExecCmd represents the hidden ssh exec subcommand for privilege dropping +var sshExecCmd = &cobra.Command{ + Use: "exec", + Short: "Internal SSH execution with privilege dropping (hidden)", + Hidden: true, + RunE: runSSHExec, +} + +func init() { + sshExecCmd.Flags().Uint32Var(&sshExecUID, "uid", 0, "Target user ID") + sshExecCmd.Flags().Uint32Var(&sshExecGID, "gid", 0, "Target group ID") + sshExecCmd.Flags().UintSliceVar(&sshExecGroups, "groups", nil, "Supplementary group IDs (can be repeated)") + sshExecCmd.Flags().StringVar(&sshExecWorkingDir, "working-dir", "", "Working directory") + sshExecCmd.Flags().StringVar(&sshExecShell, "shell", "/bin/sh", "Shell to execute") + sshExecCmd.Flags().BoolVar(&sshExecPTY, "pty", false, "Request PTY (will fail as executor doesn't support PTY)") + sshExecCmd.Flags().StringVar(&sshExecCommand, "cmd", "", "Command to execute") + + if err := sshExecCmd.MarkFlagRequired("uid"); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "failed to mark uid flag as required: %v\n", err) + os.Exit(1) + } + if err := sshExecCmd.MarkFlagRequired("gid"); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "failed to mark gid flag as required: %v\n", err) + os.Exit(1) + } + + sshCmd.AddCommand(sshExecCmd) +} + +// runSSHExec handles the SSH exec subcommand execution. +func runSSHExec(cmd *cobra.Command, _ []string) error { + privilegeDropper := sshserver.NewPrivilegeDropper() + + var groups []uint32 + for _, groupInt := range sshExecGroups { + groups = append(groups, uint32(groupInt)) + } + + config := sshserver.ExecutorConfig{ + UID: sshExecUID, + GID: sshExecGID, + Groups: groups, + WorkingDir: sshExecWorkingDir, + Shell: sshExecShell, + Command: sshExecCommand, + PTY: sshExecPTY, + } + + privilegeDropper.ExecuteWithPrivilegeDrop(cmd.Context(), config) + return nil +} diff --git a/client/cmd/ssh_sftp_unix.go b/client/cmd/ssh_sftp_unix.go new file mode 100644 index 000000000..c06aab017 --- /dev/null +++ b/client/cmd/ssh_sftp_unix.go @@ -0,0 +1,94 @@ +//go:build unix + +package cmd + +import ( + "errors" + "io" + "os" + + "github.com/pkg/sftp" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + sshserver "github.com/netbirdio/netbird/client/ssh/server" +) + +var ( + sftpUID uint32 + sftpGID uint32 + sftpGroupsInt []uint + sftpWorkingDir string +) + +var sshSftpCmd = &cobra.Command{ + Use: "sftp", + Short: "SFTP server with privilege dropping (internal use)", + Hidden: true, + RunE: sftpMain, +} + +func init() { + sshSftpCmd.Flags().Uint32Var(&sftpUID, "uid", 0, "Target user ID") + sshSftpCmd.Flags().Uint32Var(&sftpGID, "gid", 0, "Target group ID") + sshSftpCmd.Flags().UintSliceVar(&sftpGroupsInt, "groups", nil, "Supplementary group IDs (can be repeated)") + sshSftpCmd.Flags().StringVar(&sftpWorkingDir, "working-dir", "", "Working directory") +} + +func sftpMain(cmd *cobra.Command, _ []string) error { + privilegeDropper := sshserver.NewPrivilegeDropper() + + var groups []uint32 + for _, groupInt := range sftpGroupsInt { + groups = append(groups, uint32(groupInt)) + } + + config := sshserver.ExecutorConfig{ + UID: sftpUID, + GID: sftpGID, + Groups: groups, + WorkingDir: sftpWorkingDir, + Shell: "", + Command: "", + } + + log.Tracef("dropping privileges for SFTP to UID=%d, GID=%d, groups=%v", config.UID, config.GID, config.Groups) + + if err := privilegeDropper.DropPrivileges(config.UID, config.GID, config.Groups); err != nil { + cmd.PrintErrf("privilege drop failed: %v\n", err) + os.Exit(sshserver.ExitCodePrivilegeDropFail) + } + + if config.WorkingDir != "" { + if err := os.Chdir(config.WorkingDir); err != nil { + cmd.PrintErrf("failed to change to working directory %s: %v\n", config.WorkingDir, err) + } + } + + sftpServer, err := sftp.NewServer(struct { + io.Reader + io.WriteCloser + }{ + Reader: os.Stdin, + WriteCloser: os.Stdout, + }) + if err != nil { + cmd.PrintErrf("SFTP server creation failed: %v\n", err) + os.Exit(sshserver.ExitCodeShellExecFail) + } + + log.Tracef("starting SFTP server with dropped privileges") + if err := sftpServer.Serve(); err != nil && !errors.Is(err, io.EOF) { + cmd.PrintErrf("SFTP server error: %v\n", err) + if closeErr := sftpServer.Close(); closeErr != nil { + cmd.PrintErrf("SFTP server close error: %v\n", closeErr) + } + os.Exit(sshserver.ExitCodeShellExecFail) + } + + if closeErr := sftpServer.Close(); closeErr != nil { + cmd.PrintErrf("SFTP server close error: %v\n", closeErr) + } + os.Exit(sshserver.ExitCodeSuccess) + return nil +} diff --git a/client/cmd/ssh_sftp_windows.go b/client/cmd/ssh_sftp_windows.go new file mode 100644 index 000000000..ffd2d1148 --- /dev/null +++ b/client/cmd/ssh_sftp_windows.go @@ -0,0 +1,94 @@ +//go:build windows + +package cmd + +import ( + "errors" + "fmt" + "io" + "os" + "os/user" + "strings" + + "github.com/pkg/sftp" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + sshserver "github.com/netbirdio/netbird/client/ssh/server" +) + +var ( + sftpWorkingDir string + windowsUsername string + windowsDomain string +) + +var sshSftpCmd = &cobra.Command{ + Use: "sftp", + Short: "SFTP server with user switching for Windows (internal use)", + Hidden: true, + RunE: sftpMain, +} + +func init() { + sshSftpCmd.Flags().StringVar(&sftpWorkingDir, "working-dir", "", "Working directory") + sshSftpCmd.Flags().StringVar(&windowsUsername, "windows-username", "", "Windows username for user switching") + sshSftpCmd.Flags().StringVar(&windowsDomain, "windows-domain", "", "Windows domain for user switching") +} + +func sftpMain(cmd *cobra.Command, _ []string) error { + return sftpMainDirect(cmd) +} + +func sftpMainDirect(cmd *cobra.Command) error { + currentUser, err := user.Current() + if err != nil { + cmd.PrintErrf("failed to get current user: %v\n", err) + os.Exit(sshserver.ExitCodeValidationFail) + } + + if windowsUsername != "" { + expectedUsername := windowsUsername + if windowsDomain != "" { + expectedUsername = fmt.Sprintf(`%s\%s`, windowsDomain, windowsUsername) + } + if !strings.EqualFold(currentUser.Username, expectedUsername) && !strings.EqualFold(currentUser.Username, windowsUsername) { + cmd.PrintErrf("user switching failed\n") + os.Exit(sshserver.ExitCodeValidationFail) + } + } + + log.Debugf("SFTP process running as: %s (UID: %s, Name: %s)", currentUser.Username, currentUser.Uid, currentUser.Name) + + if sftpWorkingDir != "" { + if err := os.Chdir(sftpWorkingDir); err != nil { + cmd.PrintErrf("failed to change to working directory %s: %v\n", sftpWorkingDir, err) + } + } + + sftpServer, err := sftp.NewServer(struct { + io.Reader + io.WriteCloser + }{ + Reader: os.Stdin, + WriteCloser: os.Stdout, + }) + if err != nil { + cmd.PrintErrf("SFTP server creation failed: %v\n", err) + os.Exit(sshserver.ExitCodeShellExecFail) + } + + log.Debugf("starting SFTP server") + exitCode := sshserver.ExitCodeSuccess + if err := sftpServer.Serve(); err != nil && !errors.Is(err, io.EOF) { + cmd.PrintErrf("SFTP server error: %v\n", err) + exitCode = sshserver.ExitCodeShellExecFail + } + + if err := sftpServer.Close(); err != nil { + log.Debugf("SFTP server close error: %v", err) + } + + os.Exit(exitCode) + return nil +} diff --git a/client/cmd/ssh_test.go b/client/cmd/ssh_test.go new file mode 100644 index 000000000..16ffadb90 --- /dev/null +++ b/client/cmd/ssh_test.go @@ -0,0 +1,717 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSSHCommand_FlagParsing(t *testing.T) { + tests := []struct { + name string + args []string + expectedHost string + expectedUser string + expectedPort int + expectedCmd string + expectError bool + }{ + { + name: "basic host", + args: []string{"hostname"}, + expectedHost: "hostname", + expectedUser: "", + expectedPort: 22, + expectedCmd: "", + }, + { + name: "user@host format", + args: []string{"user@hostname"}, + expectedHost: "hostname", + expectedUser: "user", + expectedPort: 22, + expectedCmd: "", + }, + { + name: "host with command", + args: []string{"hostname", "echo", "hello"}, + expectedHost: "hostname", + expectedUser: "", + expectedPort: 22, + expectedCmd: "echo hello", + }, + { + name: "command with flags should be preserved", + args: []string{"hostname", "ls", "-la", "/tmp"}, + expectedHost: "hostname", + expectedUser: "", + expectedPort: 22, + expectedCmd: "ls -la /tmp", + }, + { + name: "double dash separator", + args: []string{"hostname", "--", "ls", "-la"}, + expectedHost: "hostname", + expectedUser: "", + expectedPort: 22, + expectedCmd: "-- ls -la", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset global variables + host = "" + username = "" + port = 22 + command = "" + + // Mock command for testing + cmd := sshCmd + cmd.SetArgs(tt.args) + + err := validateSSHArgsWithoutFlagParsing(cmd, tt.args) + + if tt.expectError { + assert.Error(t, err) + return + } + + require.NoError(t, err, "SSH args validation should succeed for valid input") + assert.Equal(t, tt.expectedHost, host, "host mismatch") + if tt.expectedUser != "" { + assert.Equal(t, tt.expectedUser, username, "username mismatch") + } + assert.Equal(t, tt.expectedPort, port, "port mismatch") + assert.Equal(t, tt.expectedCmd, command, "command mismatch") + }) + } +} + +func TestSSHCommand_FlagConflictPrevention(t *testing.T) { + // Test that SSH flags don't conflict with command flags + tests := []struct { + name string + args []string + expectedCmd string + description string + }{ + { + name: "ls with -la flags", + args: []string{"hostname", "ls", "-la"}, + expectedCmd: "ls -la", + description: "ls flags should be passed to remote command", + }, + { + name: "grep with -r flag", + args: []string{"hostname", "grep", "-r", "pattern", "/path"}, + expectedCmd: "grep -r pattern /path", + description: "grep flags should be passed to remote command", + }, + { + name: "ps with aux flags", + args: []string{"hostname", "ps", "aux"}, + expectedCmd: "ps aux", + description: "ps flags should be passed to remote command", + }, + { + name: "command with double dash", + args: []string{"hostname", "--", "ls", "-la"}, + expectedCmd: "-- ls -la", + description: "double dash should be preserved in command", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset global variables + host = "" + username = "" + port = 22 + command = "" + + cmd := sshCmd + err := validateSSHArgsWithoutFlagParsing(cmd, tt.args) + require.NoError(t, err, "SSH args validation should succeed for valid input") + + assert.Equal(t, tt.expectedCmd, command, tt.description) + }) + } +} + +func TestSSHCommand_NonInteractiveExecution(t *testing.T) { + // Test that commands with arguments should execute the command and exit, + // not drop to an interactive shell + tests := []struct { + name string + args []string + expectedCmd string + shouldExit bool + description string + }{ + { + name: "ls command should execute and exit", + args: []string{"hostname", "ls"}, + expectedCmd: "ls", + shouldExit: true, + description: "ls command should execute and exit, not drop to shell", + }, + { + name: "ls with flags should execute and exit", + args: []string{"hostname", "ls", "-la"}, + expectedCmd: "ls -la", + shouldExit: true, + description: "ls with flags should execute and exit, not drop to shell", + }, + { + name: "pwd command should execute and exit", + args: []string{"hostname", "pwd"}, + expectedCmd: "pwd", + shouldExit: true, + description: "pwd command should execute and exit, not drop to shell", + }, + { + name: "echo command should execute and exit", + args: []string{"hostname", "echo", "hello"}, + expectedCmd: "echo hello", + shouldExit: true, + description: "echo command should execute and exit, not drop to shell", + }, + { + name: "no command should open shell", + args: []string{"hostname"}, + expectedCmd: "", + shouldExit: false, + description: "no command should open interactive shell", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset global variables + host = "" + username = "" + port = 22 + command = "" + + cmd := sshCmd + err := validateSSHArgsWithoutFlagParsing(cmd, tt.args) + require.NoError(t, err, "SSH args validation should succeed for valid input") + + assert.Equal(t, tt.expectedCmd, command, tt.description) + + // When command is present, it should execute the command and exit + // When command is empty, it should open interactive shell + hasCommand := command != "" + assert.Equal(t, tt.shouldExit, hasCommand, "Command presence should match expected behavior") + }) + } +} + +func TestSSHCommand_FlagHandling(t *testing.T) { + // Test that flags after hostname are not parsed by netbird but passed to SSH command + tests := []struct { + name string + args []string + expectedHost string + expectedCmd string + expectError bool + description string + }{ + { + name: "ls with -la flag should not be parsed by netbird", + args: []string{"debian2", "ls", "-la"}, + expectedHost: "debian2", + expectedCmd: "ls -la", + expectError: false, + description: "ls -la should be passed as SSH command, not parsed as netbird flags", + }, + { + name: "command with netbird-like flags should be passed through", + args: []string{"hostname", "echo", "--help"}, + expectedHost: "hostname", + expectedCmd: "echo --help", + expectError: false, + description: "--help should be passed to echo, not parsed by netbird", + }, + { + name: "command with -p flag should not conflict with SSH port flag", + args: []string{"hostname", "ps", "-p", "1234"}, + expectedHost: "hostname", + expectedCmd: "ps -p 1234", + expectError: false, + description: "ps -p should be passed to ps command, not parsed as port", + }, + { + name: "tar with flags should be passed through", + args: []string{"hostname", "tar", "-czf", "backup.tar.gz", "/home"}, + expectedHost: "hostname", + expectedCmd: "tar -czf backup.tar.gz /home", + expectError: false, + description: "tar flags should be passed to tar command", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset global variables + host = "" + username = "" + port = 22 + command = "" + + cmd := sshCmd + err := validateSSHArgsWithoutFlagParsing(cmd, tt.args) + + if tt.expectError { + assert.Error(t, err) + return + } + + require.NoError(t, err, "SSH args validation should succeed for valid input") + assert.Equal(t, tt.expectedHost, host, "host mismatch") + assert.Equal(t, tt.expectedCmd, command, tt.description) + }) + } +} + +func TestSSHCommand_RegressionFlagParsing(t *testing.T) { + // Regression test for the specific issue: "sudo ./netbird ssh debian2 ls -la" + // should not parse -la as netbird flags but pass them to the SSH command + tests := []struct { + name string + args []string + expectedHost string + expectedCmd string + expectError bool + description string + }{ + { + name: "original issue: ls -la should be preserved", + args: []string{"debian2", "ls", "-la"}, + expectedHost: "debian2", + expectedCmd: "ls -la", + expectError: false, + description: "The original failing case should now work", + }, + { + name: "ls -l should be preserved", + args: []string{"hostname", "ls", "-l"}, + expectedHost: "hostname", + expectedCmd: "ls -l", + expectError: false, + description: "Single letter flags should be preserved", + }, + { + name: "SSH port flag should work", + args: []string{"-p", "2222", "hostname", "ls", "-la"}, + expectedHost: "hostname", + expectedCmd: "ls -la", + expectError: false, + description: "SSH -p flag should be parsed, command flags preserved", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset global variables + host = "" + username = "" + port = 22 + command = "" + + cmd := sshCmd + err := validateSSHArgsWithoutFlagParsing(cmd, tt.args) + + if tt.expectError { + assert.Error(t, err) + return + } + + require.NoError(t, err, "SSH args validation should succeed for valid input") + assert.Equal(t, tt.expectedHost, host, "host mismatch") + assert.Equal(t, tt.expectedCmd, command, tt.description) + + // Check port for the test case with -p flag + if len(tt.args) > 0 && tt.args[0] == "-p" { + assert.Equal(t, 2222, port, "port should be parsed from -p flag") + } + }) + } +} + +func TestSSHCommand_PortForwardingFlagParsing(t *testing.T) { + tests := []struct { + name string + args []string + expectedHost string + expectedLocal []string + expectedRemote []string + expectError bool + description string + }{ + { + name: "local port forwarding -L", + args: []string{"-L", "8080:localhost:80", "hostname"}, + expectedHost: "hostname", + expectedLocal: []string{"8080:localhost:80"}, + expectedRemote: []string{}, + expectError: false, + description: "Single -L flag should be parsed correctly", + }, + { + name: "remote port forwarding -R", + args: []string{"-R", "8080:localhost:80", "hostname"}, + expectedHost: "hostname", + expectedLocal: []string{}, + expectedRemote: []string{"8080:localhost:80"}, + expectError: false, + description: "Single -R flag should be parsed correctly", + }, + { + name: "multiple local port forwards", + args: []string{"-L", "8080:localhost:80", "-L", "9090:localhost:443", "hostname"}, + expectedHost: "hostname", + expectedLocal: []string{"8080:localhost:80", "9090:localhost:443"}, + expectedRemote: []string{}, + expectError: false, + description: "Multiple -L flags should be parsed correctly", + }, + { + name: "multiple remote port forwards", + args: []string{"-R", "8080:localhost:80", "-R", "9090:localhost:443", "hostname"}, + expectedHost: "hostname", + expectedLocal: []string{}, + expectedRemote: []string{"8080:localhost:80", "9090:localhost:443"}, + expectError: false, + description: "Multiple -R flags should be parsed correctly", + }, + { + name: "mixed local and remote forwards", + args: []string{"-L", "8080:localhost:80", "-R", "9090:localhost:443", "hostname"}, + expectedHost: "hostname", + expectedLocal: []string{"8080:localhost:80"}, + expectedRemote: []string{"9090:localhost:443"}, + expectError: false, + description: "Mixed -L and -R flags should be parsed correctly", + }, + { + name: "port forwarding with bind address", + args: []string{"-L", "127.0.0.1:8080:localhost:80", "hostname"}, + expectedHost: "hostname", + expectedLocal: []string{"127.0.0.1:8080:localhost:80"}, + expectedRemote: []string{}, + expectError: false, + description: "Port forwarding with bind address should work", + }, + { + name: "port forwarding with command", + args: []string{"-L", "8080:localhost:80", "hostname", "ls", "-la"}, + expectedHost: "hostname", + expectedLocal: []string{"8080:localhost:80"}, + expectedRemote: []string{}, + expectError: false, + description: "Port forwarding with command should work", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset global variables + host = "" + username = "" + port = 22 + command = "" + localForwards = nil + remoteForwards = nil + + cmd := sshCmd + err := validateSSHArgsWithoutFlagParsing(cmd, tt.args) + + if tt.expectError { + assert.Error(t, err) + return + } + + require.NoError(t, err, "SSH args validation should succeed for valid input") + assert.Equal(t, tt.expectedHost, host, "host mismatch") + // Handle nil vs empty slice comparison + if len(tt.expectedLocal) == 0 { + assert.True(t, len(localForwards) == 0, tt.description+" - local forwards should be empty") + } else { + assert.Equal(t, tt.expectedLocal, localForwards, tt.description+" - local forwards") + } + if len(tt.expectedRemote) == 0 { + assert.True(t, len(remoteForwards) == 0, tt.description+" - remote forwards should be empty") + } else { + assert.Equal(t, tt.expectedRemote, remoteForwards, tt.description+" - remote forwards") + } + }) + } +} + +func TestParsePortForward(t *testing.T) { + tests := []struct { + name string + spec string + expectedLocal string + expectedRemote string + expectError bool + description string + }{ + { + name: "simple port forward", + spec: "8080:localhost:80", + expectedLocal: "localhost:8080", + expectedRemote: "localhost:80", + expectError: false, + description: "Simple port:host:port format should work", + }, + { + name: "port forward with bind address", + spec: "127.0.0.1:8080:localhost:80", + expectedLocal: "127.0.0.1:8080", + expectedRemote: "localhost:80", + expectError: false, + description: "bind_address:port:host:port format should work", + }, + { + name: "port forward to different host", + spec: "8080:example.com:443", + expectedLocal: "localhost:8080", + expectedRemote: "example.com:443", + expectError: false, + description: "Forwarding to different host should work", + }, + { + name: "port forward with IPv6 (needs bracket support)", + spec: "::1:8080:localhost:80", + expectError: true, + description: "IPv6 without brackets fails as expected (feature to implement)", + }, + { + name: "invalid format - too few parts", + spec: "8080:localhost", + expectError: true, + description: "Invalid format with too few parts should fail", + }, + { + name: "invalid format - too many parts", + spec: "127.0.0.1:8080:localhost:80:extra", + expectError: true, + description: "Invalid format with too many parts should fail", + }, + { + name: "empty spec", + spec: "", + expectError: true, + description: "Empty spec should fail", + }, + { + name: "unix socket local forward", + spec: "8080:/tmp/socket", + expectedLocal: "localhost:8080", + expectedRemote: "/tmp/socket", + expectError: false, + description: "Unix socket forwarding should work", + }, + { + name: "unix socket with bind address", + spec: "127.0.0.1:8080:/tmp/socket", + expectedLocal: "127.0.0.1:8080", + expectedRemote: "/tmp/socket", + expectError: false, + description: "Unix socket with bind address should work", + }, + { + name: "wildcard bind all interfaces", + spec: "*:8080:localhost:80", + expectedLocal: ":8080", + expectedRemote: "localhost:80", + expectError: false, + description: "Wildcard * should bind to all interfaces (dual-stack)", + }, + { + name: "wildcard for port only", + spec: "8080:*:80", + expectedLocal: "localhost:8080", + expectedRemote: "*:80", + expectError: false, + description: "Wildcard in remote host should be preserved", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + localAddr, remoteAddr, err := parsePortForwardSpec(tt.spec) + + if tt.expectError { + assert.Error(t, err, tt.description) + return + } + + require.NoError(t, err, tt.description) + assert.Equal(t, tt.expectedLocal, localAddr, tt.description+" - local address") + assert.Equal(t, tt.expectedRemote, remoteAddr, tt.description+" - remote address") + }) + } +} + +func TestSSHCommand_IntegrationPortForwarding(t *testing.T) { + // Integration test for port forwarding with the actual SSH command implementation + tests := []struct { + name string + args []string + expectedHost string + expectedLocal []string + expectedRemote []string + expectedCmd string + description string + }{ + { + name: "local forward with command", + args: []string{"-L", "8080:localhost:80", "hostname", "echo", "test"}, + expectedHost: "hostname", + expectedLocal: []string{"8080:localhost:80"}, + expectedRemote: []string{}, + expectedCmd: "echo test", + description: "Local forwarding should work with commands", + }, + { + name: "remote forward with command", + args: []string{"-R", "8080:localhost:80", "hostname", "ls", "-la"}, + expectedHost: "hostname", + expectedLocal: []string{}, + expectedRemote: []string{"8080:localhost:80"}, + expectedCmd: "ls -la", + description: "Remote forwarding should work with commands", + }, + { + name: "multiple forwards with user and command", + args: []string{"-L", "8080:localhost:80", "-R", "9090:localhost:443", "user@hostname", "ps", "aux"}, + expectedHost: "hostname", + expectedLocal: []string{"8080:localhost:80"}, + expectedRemote: []string{"9090:localhost:443"}, + expectedCmd: "ps aux", + description: "Complex case with multiple forwards, user, and command", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset global variables + host = "" + username = "" + port = 22 + command = "" + localForwards = nil + remoteForwards = nil + + cmd := sshCmd + err := validateSSHArgsWithoutFlagParsing(cmd, tt.args) + require.NoError(t, err, "SSH args validation should succeed for valid input") + + assert.Equal(t, tt.expectedHost, host, "host mismatch") + // Handle nil vs empty slice comparison + if len(tt.expectedLocal) == 0 { + assert.True(t, len(localForwards) == 0, tt.description+" - local forwards should be empty") + } else { + assert.Equal(t, tt.expectedLocal, localForwards, tt.description+" - local forwards") + } + if len(tt.expectedRemote) == 0 { + assert.True(t, len(remoteForwards) == 0, tt.description+" - remote forwards should be empty") + } else { + assert.Equal(t, tt.expectedRemote, remoteForwards, tt.description+" - remote forwards") + } + assert.Equal(t, tt.expectedCmd, command, tt.description+" - command") + }) + } +} + +func TestSSHCommand_ParameterIsolation(t *testing.T) { + tests := []struct { + name string + args []string + expectedCmd string + }{ + { + name: "cmd flag passed as command", + args: []string{"hostname", "--cmd", "echo test"}, + expectedCmd: "--cmd echo test", + }, + { + name: "uid flag passed as command", + args: []string{"hostname", "--uid", "1000"}, + expectedCmd: "--uid 1000", + }, + { + name: "shell flag passed as command", + args: []string{"hostname", "--shell", "/bin/bash"}, + expectedCmd: "--shell /bin/bash", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + host = "" + username = "" + port = 22 + command = "" + + err := validateSSHArgsWithoutFlagParsing(sshCmd, tt.args) + require.NoError(t, err) + + assert.Equal(t, "hostname", host) + assert.Equal(t, tt.expectedCmd, command) + }) + } +} + +func TestSSHCommand_InvalidFlagRejection(t *testing.T) { + // Test that invalid flags are properly rejected and not misinterpreted as hostnames + tests := []struct { + name string + args []string + description string + }{ + { + name: "invalid long flag before hostname", + args: []string{"--invalid-flag", "hostname"}, + description: "Invalid flag should return parse error, not treat flag as hostname", + }, + { + name: "invalid short flag before hostname", + args: []string{"-x", "hostname"}, + description: "Invalid short flag should return parse error", + }, + { + name: "invalid flag with value before hostname", + args: []string{"--invalid-option=value", "hostname"}, + description: "Invalid flag with value should return parse error", + }, + { + name: "typo in known flag", + args: []string{"--por", "2222", "hostname"}, + description: "Typo in flag name should return parse error (not silently ignored)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset global variables + host = "" + username = "" + port = 22 + command = "" + + err := validateSSHArgsWithoutFlagParsing(sshCmd, tt.args) + + // Should return an error for invalid flags + assert.Error(t, err, tt.description) + + // Should not have set host to the invalid flag + assert.NotEqual(t, tt.args[0], host, "Invalid flag should not be interpreted as hostname") + }) + } +} diff --git a/client/cmd/status.go b/client/cmd/status.go index 6e57ceb89..c4057ed82 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 @@ -28,6 +29,7 @@ var ( ipsFilterMap map[string]struct{} prefixNamesFilterMap map[string]struct{} connectionTypeFilter string + checkFlag string ) var statusCmd = &cobra.Command{ @@ -41,14 +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().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 { @@ -56,6 +60,10 @@ func statusFunc(cmd *cobra.Command, args []string) error { cmd.SetOut(cmd.OutOrStdout()) + if checkFlag != "" { + return runHealthCheck(cmd) + } + err := parseFilters() if err != nil { return err @@ -68,15 +76,17 @@ func statusFunc(cmd *cobra.Command, args []string) error { ctx := internal.CtxInitState(cmd.Context()) - resp, err := getStatus(ctx, false) + resp, err := getStatus(ctx, true, false) if err != nil { return err } status := resp.GetStatus() - if status == string(internal.StatusNeedsLogin) || status == string(internal.StatusLoginFailed) || - status == string(internal.StatusSessionExpired) { + needsAuth := status == string(internal.StatusNeedsLogin) || status == string(internal.StatusLoginFailed) || + status == string(internal.StatusSessionExpired) + + if needsAuth && !jsonFlag && !yamlFlag { cmd.Printf("Daemon status: %s\n\n"+ "Run UP command to log in with SSO (interactive login):\n\n"+ " netbird up \n\n"+ @@ -93,23 +103,46 @@ 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 } - var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp, anonymizeFlag, statusFilter, prefixNamesFilter, prefixNamesFilterMap, ipsFilterMap, connectionTypeFilter, profName) + // 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() + } + + var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{ + Anonymize: anonymizeFlag, + DaemonVersion: resp.GetDaemonVersion(), + DaemonStatus: nbstatus.ParseDaemonStatus(status), + StatusFilter: statusFilter, + PrefixNamesFilter: prefixNamesFilter, + PrefixNamesFilterMap: prefixNamesFilterMap, + IPsFilter: ipsFilterMap, + ConnectionTypeFilter: connectionTypeFilter, + ProfileName: profName, + SessionExpiresAt: sessionExpiresAt, + }) var statusOutputString string switch { case detailFlag: - statusOutputString = nbstatus.ParseToFullDetailSummary(outputInformationHolder) + statusOutputString = outputInformationHolder.FullDetailSummary() case jsonFlag: - statusOutputString, err = nbstatus.ParseToJSON(outputInformationHolder) + statusOutputString, err = outputInformationHolder.JSON() case yamlFlag: - statusOutputString, err = nbstatus.ParseToYAML(outputInformationHolder) + statusOutputString, err = outputInformationHolder.YAML() default: - statusOutputString = nbstatus.ParseGeneralSummary(outputInformationHolder, false, false, false) + statusOutputString = outputInformationHolder.GeneralSummary(false, false, false, false) } if err != nil { @@ -121,16 +154,17 @@ func statusFunc(cmd *cobra.Command, args []string) error { return nil } -func getStatus(ctx context.Context, shouldRunProbes bool) (*proto.StatusResponse, error) { +func getStatus(ctx context.Context, fullPeerStatus bool, shouldRunProbes bool) (*proto.StatusResponse, error) { conn, err := DialClientGRPCServer(ctx, daemonAddr) if err != nil { + //nolint return nil, 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() - resp, err := proto.NewDaemonServiceClient(conn).Status(ctx, &proto.StatusRequest{GetFullPeerStatus: true, ShouldRunProbes: shouldRunProbes}) + resp, err := proto.NewDaemonServiceClient(conn).Status(ctx, &proto.StatusRequest{GetFullPeerStatus: fullPeerStatus, ShouldRunProbes: shouldRunProbes}) if err != nil { return nil, fmt.Errorf("status failed: %v", status.Convert(err).Message()) } @@ -138,6 +172,25 @@ func getStatus(ctx context.Context, shouldRunProbes bool) (*proto.StatusResponse 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": @@ -184,6 +237,83 @@ func enableDetailFlagWhenFilterFlag() { } } +func runHealthCheck(cmd *cobra.Command) error { + check := strings.ToLower(checkFlag) + switch check { + case "live", "ready", "startup": + default: + return fmt.Errorf("unknown check %q, must be one of: live, ready, startup", checkFlag) + } + + if err := util.InitLog(logLevel, util.LogConsole); err != nil { + return fmt.Errorf("init log: %w", err) + } + + ctx := internal.CtxInitState(cmd.Context()) + + isStartup := check == "startup" + resp, err := getStatus(ctx, isStartup, false) + if err != nil { + return err + } + + switch check { + case "live": + return nil + case "ready": + return checkReadiness(resp) + case "startup": + return checkStartup(resp) + default: + return nil + } +} + +func checkReadiness(resp *proto.StatusResponse) error { + daemonStatus := internal.StatusType(resp.GetStatus()) + switch daemonStatus { + case internal.StatusIdle, internal.StatusConnecting, internal.StatusConnected: + return nil + case internal.StatusNeedsLogin, internal.StatusLoginFailed, internal.StatusSessionExpired: + return fmt.Errorf("readiness check: daemon status is %s", daemonStatus) + default: + return fmt.Errorf("readiness check: unexpected daemon status %q", daemonStatus) + } +} + +func checkStartup(resp *proto.StatusResponse) error { + fullStatus := resp.GetFullStatus() + if fullStatus == nil { + return fmt.Errorf("startup check: no full status available") + } + + if !fullStatus.GetManagementState().GetConnected() { + return fmt.Errorf("startup check: management not connected") + } + + if !fullStatus.GetSignalState().GetConnected() { + return fmt.Errorf("startup check: signal not connected") + } + + var relayCount, relaysConnected int + for _, r := range fullStatus.GetRelays() { + uri := r.GetURI() + if !strings.HasPrefix(uri, "rel://") && !strings.HasPrefix(uri, "rels://") { + continue + } + relayCount++ + if r.GetAvailable() { + relaysConnected++ + } + } + + if relayCount > 0 && relaysConnected == 0 { + return fmt.Errorf("startup check: no relay servers available (0/%d connected)", relayCount) + } + + return nil +} + func parseInterfaceIP(interfaceIP string) string { ip, _, err := net.ParseCIDR(interfaceIP) if err != nil { 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 78bb0476b..205327ef5 100644 --- a/client/cmd/testutil_test.go +++ b/client/cmd/testutil_test.go @@ -11,10 +11,16 @@ import ( "go.opentelemetry.io/otel" "google.golang.org/grpc" - "github.com/netbirdio/management-integrations/integrations" + "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" + + nbcache "github.com/netbirdio/netbird/management/server/cache" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" + "github.com/netbirdio/netbird/management/internals/modules/peers" + "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" clientProto "github.com/netbirdio/netbird/client/proto" client "github.com/netbirdio/netbird/client/server" @@ -23,8 +29,6 @@ import ( "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/groups" "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" - "github.com/netbirdio/netbird/management/server/peers" - "github.com/netbirdio/netbird/management/server/peers/ephemeral/manager" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" "github.com/netbirdio/netbird/management/server/store" @@ -88,9 +92,6 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp t.Cleanup(cleanUp) eventStore := &activity.InMemoryEventStore{} - if err != nil { - return nil, nil - } ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) @@ -99,9 +100,18 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp peersmanager := peers.NewManager(store, permissionsManagerMock) settingsManagerMock := settings.NewMockManager(ctrl) - iv, _ := integrations.NewIntegratedValidator(context.Background(), peersmanager, settingsManagerMock, eventStore) + jobManager := job.NewJobManager(nil, store, peersmanager) - metrics, err := telemetry.NewDefaultAppMetrics(context.Background()) + ctx := context.Background() + + cacheStore, err := nbcache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100) + if err != nil { + t.Fatal(err) + } + + iv, _ := validator.NewIntegratedValidator(ctx, peersmanager, settingsManagerMock, eventStore, cacheStore) + + metrics, err := telemetry.NewDefaultAppMetrics(ctx) require.NoError(t, err) settingsMockManager := settings.NewMockManager(ctrl) @@ -112,18 +122,20 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp Return(&types.Settings{}, nil). AnyTimes() - ctx := context.Background() updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock()) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config) - accountManager, err := mgmt.BuildManager(context.Background(), store, networkMapController, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false) + accountManager, err := mgmt.BuildManager(ctx, config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore) if err != nil { t.Fatal(err) } - secretsManager := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager) - mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, updateManager, secretsManager, nil, &manager.EphemeralManager{}, nil, &mgmt.MockIntegratedValidator{}, networkMapController) + secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager) + if err != nil { + t.Fatal(err) + } + mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &mgmt.MockIntegratedValidator{}, networkMapController, nil, nil) if err != nil { t.Fatal(err) } @@ -148,7 +160,7 @@ func startClientDaemon( s := grpc.NewServer() server := client.New(ctx, - "", "", 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 80175f7be..2c5194dee 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -21,7 +21,9 @@ import ( "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" + 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,6 +41,9 @@ 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." ) @@ -48,6 +53,7 @@ var ( dnsLabels []string dnsLabelsValidated domain.List noBrowser bool + showQR bool profileName string configPath string @@ -80,6 +86,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. ") @@ -123,16 +130,9 @@ func upFunc(cmd *cobra.Command, args []string) error { var profileSwitched bool // switch profile if provided if profileName != "" { - err = switchProfile(cmd.Context(), profileName, username.Username) - if err != nil { + if err := switchOrCreateProfile(cmd.Context(), pm, profileName, username.Username); 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 } @@ -147,6 +147,52 @@ 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. +func switchOrCreateProfile(ctx context.Context, pm *profilemanager.ProfileManager, handle, username string) error { + resolvedID, err := switchProfile(ctx, handle, username) + if err != nil { + st, ok := gstatus.FromError(err) + if !ok || st.Code() != codes.NotFound { + return 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 fmt.Errorf("create profile: %w", createErr) + } + return err + } + } + + if err := pm.SwitchProfile(resolvedID); err != nil { + return err + } + return 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 != "" { @@ -185,7 +231,25 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr _, _ = 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) } @@ -200,7 +264,7 @@ func runInForegroundMode(ctx context.Context, cmd *cobra.Command, activeProf *pr connectClient := internal.NewConnectClient(ctx, config, r) SetupDebugHandler(ctx, config, r, connectClient, "") - return connectClient.Run(nil) + return connectClient.Run(nil, util.FindFirstLogPath(logFiles)) } func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager.ProfileManager, activeProf *profilemanager.Profile, profileSwitched bool) error { @@ -216,6 +280,7 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager 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) @@ -255,10 +320,10 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager } // 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) } @@ -283,10 +348,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 != "" { @@ -323,7 +389,7 @@ 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) @@ -355,6 +421,25 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro 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 + } if cmd.Flag(interfaceNameFlag).Changed { if err := parseInterfaceName(interfaceName); err != nil { log.Errorf("parse interface name: %v", err) @@ -410,8 +495,8 @@ 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 } return &req @@ -439,6 +524,30 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil ic.ServerSSHAllowed = &serverSSHAllowed } + if cmd.Flag(enableSSHRootFlag).Changed { + ic.EnableSSHRoot = &enableSSHRoot + } + + if cmd.Flag(enableSSHSFTPFlag).Changed { + ic.EnableSSHSFTP = &enableSSHSFTP + } + + if cmd.Flag(enableSSHLocalPortForwardFlag).Changed { + ic.EnableSSHLocalPortForwarding = &enableSSHLocalPortForward + } + + if cmd.Flag(enableSSHRemotePortForwardFlag).Changed { + ic.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward + } + + if cmd.Flag(disableSSHAuthFlag).Changed { + ic.DisableSSHAuth = &disableSSHAuth + } + + if cmd.Flag(sshJWTCacheTTLFlag).Changed { + ic.SSHJWTCacheTTL = &sshJWTCacheTTL + } + if cmd.Flag(interfaceNameFlag).Changed { if err := parseInterfaceName(interfaceName); err != nil { return nil, err @@ -503,9 +612,10 @@ 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 } + return &ic, nil } @@ -539,6 +649,31 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte 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 + } + if cmd.Flag(disableAutoConnectFlag).Changed { loginRequest.DisableAutoConnect = &autoConnectDisabled } @@ -592,9 +727,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 } 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/update.go b/client/cmd/update.go new file mode 100644 index 000000000..dc49b02c3 --- /dev/null +++ b/client/cmd/update.go @@ -0,0 +1,13 @@ +//go:build !windows && !darwin + +package cmd + +import ( + "github.com/spf13/cobra" +) + +var updateCmd *cobra.Command + +func isUpdateBinary() bool { + return false +} diff --git a/client/cmd/update_supported.go b/client/cmd/update_supported.go new file mode 100644 index 000000000..0b197f4c5 --- /dev/null +++ b/client/cmd/update_supported.go @@ -0,0 +1,75 @@ +//go:build windows || darwin + +package cmd + +import ( + "context" + "os" + "path/filepath" + "strings" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/client/internal/updater/installer" + "github.com/netbirdio/netbird/util" +) + +var ( + updateCmd = &cobra.Command{ + Use: "update", + Short: "Update the NetBird client application", + RunE: updateFunc, + } + + tempDirFlag string + installerFile string + serviceDirFlag string + dryRunFlag bool +) + +func init() { + updateCmd.Flags().StringVar(&tempDirFlag, "temp-dir", "", "temporary dir") + updateCmd.Flags().StringVar(&installerFile, "installer-file", "", "installer file") + updateCmd.Flags().StringVar(&serviceDirFlag, "service-dir", "", "service directory") + updateCmd.Flags().BoolVar(&dryRunFlag, "dry-run", false, "dry run the update process without making any changes") +} + +// isUpdateBinary checks if the current executable is named "update" or "update.exe" +func isUpdateBinary() bool { + // Remove extension for cross-platform compatibility + execPath, err := os.Executable() + if err != nil { + return false + } + baseName := filepath.Base(execPath) + name := strings.TrimSuffix(baseName, filepath.Ext(baseName)) + + return name == installer.UpdaterBinaryNameWithoutExtension() +} + +func updateFunc(cmd *cobra.Command, args []string) error { + if err := setupLogToFile(tempDirFlag); err != nil { + return err + } + + log.Infof("updater started: %s", serviceDirFlag) + updater := installer.NewWithDir(tempDirFlag) + if err := updater.Setup(context.Background(), dryRunFlag, installerFile, serviceDirFlag); err != nil { + log.Errorf("failed to update application: %v", err) + return err + } + return nil +} + +func setupLogToFile(dir string) error { + logFile := filepath.Join(dir, installer.LogFile) + + if _, err := os.Stat(logFile); err == nil { + if err := os.Remove(logFile); err != nil { + log.Errorf("failed to remove existing log file: %v\n", err) + } + } + + return util.InitLog(logLevel, util.LogConsole, logFile) +} 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/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 e918235ed..99a6b8229 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -12,18 +12,36 @@ 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" "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/internal" + "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/system" + "github.com/netbirdio/netbird/shared/management/domain" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/util/capture" ) -var ErrClientAlreadyStarted = errors.New("client already started") -var ErrClientNotStarted = errors.New("client not started") -var ErrConfigNotInitialized = errors.New("config not initialized") +var ( + ErrClientAlreadyStarted = errors.New("client already started") + ErrClientNotStarted = errors.New("client not started") + ErrEngineNotStarted = errors.New("engine not started") + ErrConfigNotInitialized = errors.New("config not initialized") +) + +const ( + // PeerStatusConnected indicates the peer is in connected state. + PeerStatusConnected = peer.StatusConnected +) + +// PeerConnStatus is a peer's connection status. +type PeerConnStatus = peer.ConnStatus // Client manages a netbird embedded client instance. type Client struct { @@ -34,6 +52,7 @@ type Client struct { setupKey string jwtToken string connect *internal.ConnectClient + recorder *peer.Status } // Options configures a new Client. @@ -48,7 +67,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 @@ -62,6 +81,46 @@ 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 + // 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 + // WireguardPort is the port for the tunnel interface. Use 0 for a random port. + WireguardPort *int + // 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. + // Set to a higher value (e.g. 1400) if carrying QUIC or other protocols that require larger datagrams. + MTU *uint16 + // DNSLabels defines additional DNS labels configured in the peer. + DNSLabels []string + // 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 @@ -93,6 +152,12 @@ func New(opts Options) (*Client, error) { return nil, err } + if opts.MTU != nil { + if err := iface.ValidateMTU(*opts.MTU); err != nil { + return nil, fmt.Errorf("invalid MTU: %w", err) + } + } + if opts.LogOutput != nil { logrus.SetOutput(opts.LogOutput) } @@ -121,15 +186,26 @@ func New(opts Options) (*Client, error) { } } + var err error + var parsedLabels domain.List + if parsedLabels, err = domain.FromStringList(opts.DNSLabels); err != nil { + return nil, fmt.Errorf("invalid dns labels: %w", err) + } + t := true var config *profilemanager.Config - var err error input := profilemanager.ConfigInput{ ConfigPath: opts.ConfigPath, ManagementURL: opts.ManagementURL, PreSharedKey: &opts.PreSharedKey, DisableServerRoutes: &t, DisableClientRoutes: &opts.DisableClientRoutes, + DisableIPv6: &opts.DisableIPv6, + BlockInbound: &opts.BlockInbound, + BlockLANAccess: &opts.BlockLANAccess, + WireguardPort: opts.WireguardPort, + MTU: opts.MTU, + DNSLabels: parsedLabels, } if opts.ConfigPath != "" { config, err = profilemanager.UpdateOrCreateConfig(input) @@ -144,11 +220,19 @@ func New(opts Options) (*Client, error) { config.PrivateKey = opts.PrivateKey } + 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, jwtToken: opts.JWTToken, config: config, + recorder: peer.NewRecorder(config.ManagementURL.String()), }, nil } @@ -157,32 +241,50 @@ func New(opts Options) (*Client, error) { func (c *Client) Start(startCtx context.Context) error { c.mu.Lock() defer c.mu.Unlock() - if c.cancel != nil { + if c.connect != nil { return ErrClientAlreadyStarted } - ctx := internal.CtxInitState(context.Background()) + ctx, cancel := context.WithCancel(internal.CtxInitState(context.Background())) + defer func() { + if c.connect == nil { + cancel() + } + }() + // nolint:staticcheck ctx = context.WithValue(ctx, system.DeviceNameCtxKey, c.deviceName) - if err := internal.Login(ctx, c.config, c.setupKey, c.jwtToken); err != nil { + + authClient, err := auth.NewAuth(ctx, c.config.PrivateKey, c.config.ManagementURL, c.config) + if err != nil { + return fmt.Errorf("create auth client: %w", err) + } + defer authClient.Close() + + if err, _ := authClient.Login(ctx, c.setupKey, c.jwtToken); err != nil { return fmt.Errorf("login: %w", err) } - - recorder := peer.NewRecorder(c.config.ManagementURL.String()) - client := internal.NewConnectClient(ctx, c.config, recorder) + client := internal.NewConnectClient(ctx, c.config, c.recorder) + client.SetSyncResponsePersistence(true) // either startup error (permanent backoff err) or nil err (successful engine up) // TODO: make after-startup backoff err available run := make(chan struct{}) clientErr := make(chan error, 1) go func() { - if err := client.Run(run); err != nil { + if err := client.Run(run, ""); err != nil { clientErr <- err } }() 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()) } @@ -193,6 +295,7 @@ func (c *Client) Start(startCtx context.Context) error { } c.connect = client + c.cancel = cancel return nil } @@ -207,17 +310,23 @@ func (c *Client) Stop(ctx context.Context) error { return ErrClientNotStarted } + if c.cancel != nil { + c.cancel() + c.cancel = nil + } + done := make(chan error, 1) + connect := c.connect go func() { - done <- c.connect.Stop() + done <- connect.Stop() }() select { case <-ctx.Done(): - c.cancel = nil + c.connect = nil return ctx.Err() case err := <-done: - c.cancel = nil + c.connect = nil if err != nil { return fmt.Errorf("stop: %w", err) } @@ -238,17 +347,9 @@ func (c *Client) GetConfig() (profilemanager.Config, error) { // Dial dials a network address in the netbird network. // Not applicable if the userspace networking mode is disabled. func (c *Client) Dial(ctx context.Context, network, address string) (net.Conn, error) { - c.mu.Lock() - connect := c.connect - if connect == nil { - c.mu.Unlock() - return nil, ErrClientNotStarted - } - c.mu.Unlock() - - engine := connect.Engine() - if engine == nil { - return nil, errors.New("engine not started") + engine, err := c.getEngine() + if err != nil { + return nil, err } nsnet, err := engine.GetNet() @@ -259,6 +360,11 @@ func (c *Client) Dial(ctx context.Context, network, address string) (net.Conn, e return nsnet.DialContext(ctx, network, address) } +// DialContext dials a network address in the netbird network with context +func (c *Client) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + return c.Dial(ctx, network, address) +} + // ListenTCP listens on the given address in the netbird network. // Not applicable if the userspace networking mode is disabled. func (c *Client) ListenTCP(address string) (net.Listener, error) { @@ -271,7 +377,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 { @@ -292,7 +398,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 { @@ -314,18 +420,204 @@ func (c *Client) NewHTTPClient() *http.Client { } } -func (c *Client) getNet() (*wgnetstack.Net, netip.Addr, error) { +// Expose exposes a local service via the NetBird reverse proxy, making it accessible through a public URL. +// It returns an ExposeSession. Call Wait on the session to keep it alive. +func (c *Client) Expose(ctx context.Context, req ExposeRequest) (*ExposeSession, error) { + engine, err := c.getEngine() + if err != nil { + return nil, err + } + + mgr := engine.GetExposeManager() + if mgr == nil { + return nil, fmt.Errorf("expose manager not available") + } + + resp, err := mgr.Expose(ctx, req) + if err != nil { + return nil, fmt.Errorf("expose: %w", err) + } + + return &ExposeSession{ + Domain: resp.Domain, + ServiceName: resp.ServiceName, + ServiceURL: resp.ServiceURL, + mgr: mgr, + }, nil +} + +// 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() connect := c.connect - if connect == nil { - c.mu.Unlock() - return nil, netip.Addr{}, errors.New("client not started") - } c.mu.Unlock() + if connect != nil { + engine := connect.Engine() + if engine != nil { + _ = engine.RunHealthProbes(context.Background(), false) + } + } + + return c.recorder.GetFullStatus(), nil +} + +// GetLatestSyncResponse returns the latest sync response from the management server. +func (c *Client) GetLatestSyncResponse() (*mgmProto.SyncResponse, error) { + engine, err := c.getEngine() + if err != nil { + return nil, err + } + + syncResp, err := engine.GetLatestSyncResponse() + if err != nil { + return nil, fmt.Errorf("get sync response: %w", err) + } + + return syncResp, nil +} + +// SetLogLevel sets the logging level for the client and its components. +func (c *Client) SetLogLevel(levelStr string) error { + level, err := logrus.ParseLevel(levelStr) + if err != nil { + return fmt.Errorf("parse log level: %w", err) + } + + logrus.SetLevel(level) + + c.mu.Lock() + connect := c.connect + c.mu.Unlock() + + if connect != nil { + connect.SetLogLevel(level) + } + + return nil +} + +// VerifySSHHostKey verifies an SSH host key against stored peer keys. +// Returns nil if the key matches, ErrPeerNotFound if peer is not in network, +// ErrNoStoredKey if peer has no stored key, or an error for verification failures. +func (c *Client) VerifySSHHostKey(peerAddress string, key []byte) error { + engine, err := c.getEngine() + if err != nil { + return err + } + + storedKey, found := engine.GetPeerSSHKey(peerAddress) + if !found { + return sshcommon.ErrPeerNotFound + } + + return sshcommon.VerifyHostKey(storedKey, key, peerAddress) +} + +// 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 + } + + 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. +// Returns ErrClientNotStarted if the client is not started. +// Returns ErrEngineNotStarted if the engine is not available. +func (c *Client) getEngine() (*internal.Engine, error) { + c.mu.Lock() + connect := c.connect + c.mu.Unlock() + + if connect == nil { + return nil, ErrClientNotStarted + } + engine := connect.Engine() if engine == nil { - return nil, netip.Addr{}, errors.New("engine not started") + return nil, ErrEngineNotStarted + } + + return engine, nil +} + +func (c *Client) getNet() (*wgnetstack.Net, netip.Addr, error) { + engine, err := c.getEngine() + if err != nil { + return nil, netip.Addr{}, err } addr, err := engine.Address() diff --git a/client/embed/embed_test.go b/client/embed/embed_test.go new file mode 100644 index 000000000..a2f438975 --- /dev/null +++ b/client/embed/embed_test.go @@ -0,0 +1,168 @@ +package embed + +import ( + "context" + "net" + "testing" + "time" + + "github.com/golang/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/server/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, permissionsManager) + 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) + 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/embed/expose.go b/client/embed/expose.go new file mode 100644 index 000000000..825bb90ee --- /dev/null +++ b/client/embed/expose.go @@ -0,0 +1,45 @@ +package embed + +import ( + "context" + "errors" + + "github.com/netbirdio/netbird/client/internal/expose" +) + +const ( + // ExposeProtocolHTTP exposes the service as HTTP. + ExposeProtocolHTTP = expose.ProtocolHTTP + // ExposeProtocolHTTPS exposes the service as HTTPS. + ExposeProtocolHTTPS = expose.ProtocolHTTPS + // ExposeProtocolTCP exposes the service as TCP. + ExposeProtocolTCP = expose.ProtocolTCP + // ExposeProtocolUDP exposes the service as UDP. + ExposeProtocolUDP = expose.ProtocolUDP + // ExposeProtocolTLS exposes the service as TLS. + ExposeProtocolTLS = expose.ProtocolTLS +) + +// ExposeRequest is a request to expose a local service via the NetBird reverse proxy. +type ExposeRequest = expose.Request + +// ExposeProtocolType represents the protocol used for exposing a service. +type ExposeProtocolType = expose.ProtocolType + +// ExposeSession represents an active expose session. Use Wait to block until the session ends. +type ExposeSession struct { + Domain string + ServiceName string + ServiceURL string + + mgr *expose.Manager +} + +// Wait blocks while keeping the expose session alive. +// It returns when ctx is cancelled or a keep-alive error occurs, then terminates the session. +func (s *ExposeSession) Wait(ctx context.Context) error { + if s == nil || s.mgr == nil { + return errors.New("expose session is not initialized") + } + return s.mgr.KeepAlive(ctx, s.Domain) +} diff --git a/client/firewall/create_linux.go b/client/firewall/create_linux.go index 12dcaee8a..d916ebad4 100644 --- a/client/firewall/create_linux.go +++ b/client/firewall/create_linux.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "os" + "strconv" "github.com/coreos/go-iptables/iptables" "github.com/google/nftables" @@ -35,20 +36,34 @@ const SKIP_NFTABLES_ENV = "NB_SKIP_NFTABLES_CHECK" type FWType int func NewFirewall(iface IFaceMapper, stateManager *statemanager.Manager, flowLogger nftypes.FlowLogger, disableServerRoutes bool, mtu uint16) (firewall.Manager, error) { - // on the linux system we try to user nftables or iptables - // in any case, because we need to allow netbird interface traffic - // so we use AllowNetbird traffic from these firewall managers - // for the userspace packet filtering firewall + // We run in userspace mode and force userspace firewall was requested. We don't attempt native firewall. + if iface.IsUserspaceBind() && forceUserspaceFirewall() { + log.Info("forcing userspace firewall") + return createUserspaceFirewall(iface, nil, disableServerRoutes, flowLogger, mtu) + } + + // Use native firewall for either kernel or userspace, the interface appears identical to netfilter fm, err := createNativeFirewall(iface, stateManager, disableServerRoutes, mtu) + // Kernel cannot fall back to anything else, need to return error if !iface.IsUserspaceBind() { return fm, err } + // Fall back to the userspace packet filter if native is unavailable if err != nil { log.Warnf("failed to create native firewall: %v. Proceeding with userspace", err) + return createUserspaceFirewall(iface, nil, disableServerRoutes, flowLogger, mtu) } - return createUserspaceFirewall(iface, fm, disableServerRoutes, flowLogger, mtu) + + // Native firewall handles packet filtering, but the userspace WireGuard bind + // needs a device filter for DNS interception hooks. Install a minimal + // hooks-only filter that passes all traffic through to the kernel firewall. + if err := iface.SetFilter(&uspfilter.HooksFilter{}); err != nil { + log.Warnf("failed to set hooks filter, DNS via memory hooks will not work: %v", err) + } + + return fm, nil } func createNativeFirewall(iface IFaceMapper, stateManager *statemanager.Manager, routes bool, mtu uint16) (firewall.Manager, error) { @@ -160,3 +175,17 @@ func isIptablesClientAvailable(client *iptables.IPTables) bool { _, err := client.ListChains("filter") return err == nil } + +func forceUserspaceFirewall() bool { + val := os.Getenv(EnvForceUserspaceFirewall) + if val == "" { + return false + } + + force, err := strconv.ParseBool(val) + if err != nil { + log.Warnf("failed to parse %s: %v", EnvForceUserspaceFirewall, err) + return false + } + return force +} diff --git a/client/firewall/firewalld/firewalld.go b/client/firewall/firewalld/firewalld.go new file mode 100644 index 000000000..188ea61dd --- /dev/null +++ b/client/firewall/firewalld/firewalld.go @@ -0,0 +1,11 @@ +// Package firewalld integrates with the firewalld daemon so NetBird can place +// its wg interface into firewalld's "trusted" zone. This is required because +// firewalld's nftables chains are created with NFT_CHAIN_OWNER on recent +// versions, which returns EPERM to any other process that tries to insert +// rules into them. The workaround mirrors what Tailscale does: let firewalld +// itself add the accept rules to its own chains by trusting the interface. +package firewalld + +// TrustedZone is the firewalld zone name used for interfaces whose traffic +// should bypass firewalld filtering. +const TrustedZone = "trusted" diff --git a/client/firewall/firewalld/firewalld_linux.go b/client/firewall/firewalld/firewalld_linux.go new file mode 100644 index 000000000..924a04b0a --- /dev/null +++ b/client/firewall/firewalld/firewalld_linux.go @@ -0,0 +1,260 @@ +//go:build linux + +package firewalld + +import ( + "context" + "errors" + "fmt" + "os/exec" + "strings" + "sync" + "time" + + "github.com/godbus/dbus/v5" + log "github.com/sirupsen/logrus" +) + +const ( + dbusDest = "org.fedoraproject.FirewallD1" + dbusPath = "/org/fedoraproject/FirewallD1" + dbusRootIface = "org.fedoraproject.FirewallD1" + dbusZoneIface = "org.fedoraproject.FirewallD1.zone" + + errZoneAlreadySet = "ZONE_ALREADY_SET" + errAlreadyEnabled = "ALREADY_ENABLED" + errUnknownIface = "UNKNOWN_INTERFACE" + errNotEnabled = "NOT_ENABLED" + + // callTimeout bounds each individual DBus or firewall-cmd invocation. + // A fresh context is created for each call so a slow DBus probe can't + // exhaust the deadline before the firewall-cmd fallback gets to run. + callTimeout = 3 * time.Second +) + +var ( + errDBusUnavailable = errors.New("firewalld dbus unavailable") + + // trustLogOnce ensures the "added to trusted zone" message is logged at + // Info level only for the first successful add per process; repeat adds + // from other init paths are quieter. + trustLogOnce sync.Once + + parentCtxMu sync.RWMutex + parentCtx context.Context = context.Background() +) + +// SetParentContext installs a parent context whose cancellation aborts any +// in-flight TrustInterface call. It does not affect UntrustInterface, which +// always uses a fresh Background-rooted timeout so cleanup can still run +// during engine shutdown when the engine context is already cancelled. +func SetParentContext(ctx context.Context) { + parentCtxMu.Lock() + parentCtx = ctx + parentCtxMu.Unlock() +} + +func getParentContext() context.Context { + parentCtxMu.RLock() + defer parentCtxMu.RUnlock() + return parentCtx +} + +// TrustInterface places iface into firewalld's trusted zone if firewalld is +// running. It is idempotent and best-effort: errors are returned so callers +// can log, but a non-running firewalld is not an error. Only the first +// successful call per process logs at Info. Respects the parent context set +// via SetParentContext so startup-time cancellation unblocks it. +func TrustInterface(iface string) error { + parent := getParentContext() + if !isRunning(parent) { + return nil + } + if err := addTrusted(parent, iface); err != nil { + return fmt.Errorf("add %s to firewalld trusted zone: %w", iface, err) + } + trustLogOnce.Do(func() { + log.Infof("added %s to firewalld trusted zone", iface) + }) + log.Debugf("firewalld: ensured %s is in trusted zone", iface) + return nil +} + +// UntrustInterface removes iface from firewalld's trusted zone if firewalld +// is running. Idempotent. Uses a Background-rooted timeout so it still runs +// during shutdown after the engine context has been cancelled. +func UntrustInterface(iface string) error { + if !isRunning(context.Background()) { + return nil + } + if err := removeTrusted(context.Background(), iface); err != nil { + return fmt.Errorf("remove %s from firewalld trusted zone: %w", iface, err) + } + return nil +} + +func newCallContext(parent context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(parent, callTimeout) +} + +func isRunning(parent context.Context) bool { + ctx, cancel := newCallContext(parent) + ok, err := isRunningDBus(ctx) + cancel() + if err == nil { + return ok + } + if errors.Is(err, errDBusUnavailable) || errors.Is(err, context.DeadlineExceeded) { + ctx, cancel = newCallContext(parent) + defer cancel() + return isRunningCLI(ctx) + } + return false +} + +func addTrusted(parent context.Context, iface string) error { + ctx, cancel := newCallContext(parent) + err := addDBus(ctx, iface) + cancel() + if err == nil { + return nil + } + if !errors.Is(err, errDBusUnavailable) { + log.Debugf("firewalld: dbus add failed, falling back to firewall-cmd: %v", err) + } + ctx, cancel = newCallContext(parent) + defer cancel() + return addCLI(ctx, iface) +} + +func removeTrusted(parent context.Context, iface string) error { + ctx, cancel := newCallContext(parent) + err := removeDBus(ctx, iface) + cancel() + if err == nil { + return nil + } + if !errors.Is(err, errDBusUnavailable) { + log.Debugf("firewalld: dbus remove failed, falling back to firewall-cmd: %v", err) + } + ctx, cancel = newCallContext(parent) + defer cancel() + return removeCLI(ctx, iface) +} + +func isRunningDBus(ctx context.Context) (bool, error) { + conn, err := dbus.SystemBus() + if err != nil { + return false, fmt.Errorf("%w: %v", errDBusUnavailable, err) + } + obj := conn.Object(dbusDest, dbusPath) + + var zone string + if err := obj.CallWithContext(ctx, dbusRootIface+".getDefaultZone", 0).Store(&zone); err != nil { + return false, fmt.Errorf("firewalld getDefaultZone: %w", err) + } + return true, nil +} + +func isRunningCLI(ctx context.Context) bool { + if _, err := exec.LookPath("firewall-cmd"); err != nil { + return false + } + return exec.CommandContext(ctx, "firewall-cmd", "--state").Run() == nil +} + +func addDBus(ctx context.Context, iface string) error { + conn, err := dbus.SystemBus() + if err != nil { + return fmt.Errorf("%w: %v", errDBusUnavailable, err) + } + obj := conn.Object(dbusDest, dbusPath) + + call := obj.CallWithContext(ctx, dbusZoneIface+".addInterface", 0, TrustedZone, iface) + if call.Err == nil { + return nil + } + + if dbusErrContains(call.Err, errAlreadyEnabled) { + return nil + } + + if dbusErrContains(call.Err, errZoneAlreadySet) { + move := obj.CallWithContext(ctx, dbusZoneIface+".changeZoneOfInterface", 0, TrustedZone, iface) + if move.Err != nil { + return fmt.Errorf("firewalld changeZoneOfInterface: %w", move.Err) + } + return nil + } + + return fmt.Errorf("firewalld addInterface: %w", call.Err) +} + +func removeDBus(ctx context.Context, iface string) error { + conn, err := dbus.SystemBus() + if err != nil { + return fmt.Errorf("%w: %v", errDBusUnavailable, err) + } + obj := conn.Object(dbusDest, dbusPath) + + call := obj.CallWithContext(ctx, dbusZoneIface+".removeInterface", 0, TrustedZone, iface) + if call.Err == nil { + return nil + } + + if dbusErrContains(call.Err, errUnknownIface) || dbusErrContains(call.Err, errNotEnabled) { + return nil + } + + return fmt.Errorf("firewalld removeInterface: %w", call.Err) +} + +func addCLI(ctx context.Context, iface string) error { + if _, err := exec.LookPath("firewall-cmd"); err != nil { + return fmt.Errorf("firewall-cmd not available: %w", err) + } + + // --change-interface (no --permanent) binds the interface for the + // current runtime only; we do not want membership to persist across + // reboots because netbird re-asserts it on every startup. + out, err := exec.CommandContext(ctx, + "firewall-cmd", "--zone="+TrustedZone, "--change-interface="+iface, + ).CombinedOutput() + if err != nil { + return fmt.Errorf("firewall-cmd change-interface: %w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func removeCLI(ctx context.Context, iface string) error { + if _, err := exec.LookPath("firewall-cmd"); err != nil { + return fmt.Errorf("firewall-cmd not available: %w", err) + } + + out, err := exec.CommandContext(ctx, + "firewall-cmd", "--zone="+TrustedZone, "--remove-interface="+iface, + ).CombinedOutput() + if err != nil { + msg := strings.TrimSpace(string(out)) + if strings.Contains(msg, errUnknownIface) || strings.Contains(msg, errNotEnabled) { + return nil + } + return fmt.Errorf("firewall-cmd remove-interface: %w: %s", err, msg) + } + return nil +} + +func dbusErrContains(err error, code string) bool { + if err == nil { + return false + } + var de dbus.Error + if errors.As(err, &de) { + for _, b := range de.Body { + if s, ok := b.(string); ok && strings.Contains(s, code) { + return true + } + } + } + return strings.Contains(err.Error(), code) +} diff --git a/client/firewall/firewalld/firewalld_linux_test.go b/client/firewall/firewalld/firewalld_linux_test.go new file mode 100644 index 000000000..d812745fc --- /dev/null +++ b/client/firewall/firewalld/firewalld_linux_test.go @@ -0,0 +1,49 @@ +//go:build linux + +package firewalld + +import ( + "errors" + "testing" + + "github.com/godbus/dbus/v5" +) + +func TestDBusErrContains(t *testing.T) { + tests := []struct { + name string + err error + code string + want bool + }{ + {"nil error", nil, errZoneAlreadySet, false}, + {"plain error match", errors.New("ZONE_ALREADY_SET: wt0"), errZoneAlreadySet, true}, + {"plain error miss", errors.New("something else"), errZoneAlreadySet, false}, + { + "dbus.Error body match", + dbus.Error{Name: "org.fedoraproject.FirewallD1.Exception", Body: []any{"ZONE_ALREADY_SET: wt0"}}, + errZoneAlreadySet, + true, + }, + { + "dbus.Error body miss", + dbus.Error{Name: "org.fedoraproject.FirewallD1.Exception", Body: []any{"INVALID_INTERFACE"}}, + errAlreadyEnabled, + false, + }, + { + "dbus.Error non-string body falls back to Error()", + dbus.Error{Name: "x", Body: []any{123}}, + "x", + true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := dbusErrContains(tc.err, tc.code) + if got != tc.want { + t.Fatalf("dbusErrContains(%v, %q) = %v; want %v", tc.err, tc.code, got, tc.want) + } + }) + } +} diff --git a/client/firewall/firewalld/firewalld_other.go b/client/firewall/firewalld/firewalld_other.go new file mode 100644 index 000000000..cfa28221d --- /dev/null +++ b/client/firewall/firewalld/firewalld_other.go @@ -0,0 +1,25 @@ +//go:build !linux + +package firewalld + +import "context" + +// SetParentContext is a no-op on non-Linux platforms because firewalld only +// runs on Linux. +func SetParentContext(context.Context) { + // intentionally empty: firewalld is a Linux-only daemon +} + +// TrustInterface is a no-op on non-Linux platforms because firewalld only +// runs on Linux. +func TrustInterface(string) error { + // intentionally empty: firewalld is a Linux-only daemon + return nil +} + +// UntrustInterface is a no-op on non-Linux platforms because firewalld only +// runs on Linux. +func UntrustInterface(string) error { + // intentionally empty: firewalld is a Linux-only daemon + return nil +} diff --git a/client/firewall/iface.go b/client/firewall/iface.go index b83c5f912..491f03269 100644 --- a/client/firewall/iface.go +++ b/client/firewall/iface.go @@ -7,6 +7,12 @@ import ( "github.com/netbirdio/netbird/client/iface/wgaddr" ) +// EnvForceUserspaceFirewall forces the use of the userspace packet filter even when +// native iptables/nftables is available. This only applies when the WireGuard interface +// runs in userspace mode. When set, peer ACLs are handled by USPFilter instead of +// kernel netfilter rules. +const EnvForceUserspaceFirewall = "NB_FORCE_USERSPACE_FIREWALL" + // IFaceMapper defines subset methods of interface required for manager type IFaceMapper interface { Name() string diff --git a/client/firewall/iptables/acl_linux.go b/client/firewall/iptables/acl_linux.go index d78372c9e..4b4cebf9c 100644 --- a/client/firewall/iptables/acl_linux.go +++ b/client/firewall/iptables/acl_linux.go @@ -1,13 +1,15 @@ package iptables import ( + "errors" "fmt" + "maps" "net" "slices" "github.com/coreos/go-iptables/iptables" "github.com/google/uuid" - "github.com/nadoo/ipset" + ipset "github.com/lrh3321/ipset-go" log "github.com/sirupsen/logrus" firewall "github.com/netbirdio/netbird/client/firewall/manager" @@ -20,6 +22,10 @@ const ( // rules chains contains the effective ACL rules chainNameInputRules = "NETBIRD-ACL-INPUT" + + // mangleFwdKey is the entries map key for mangle FORWARD guard rules that prevent + // external DNAT from bypassing ACL rules. + mangleFwdKey = "MANGLE-FORWARD" ) type aclEntries map[string][][]string @@ -35,24 +41,20 @@ type aclManager struct { entries aclEntries optionalEntries map[string][]entry ipsetStore *ipsetStore + v6 bool stateManager *statemanager.Manager } func newAclManager(iptablesClient *iptables.IPTables, wgIface iFaceMapper) (*aclManager, error) { - m := &aclManager{ + return &aclManager{ iptablesClient: iptablesClient, wgIface: wgIface, entries: make(map[string][][]string), optionalEntries: make(map[string][]entry), ipsetStore: newIpsetStore(), - } - - if err := ipset.Init(); err != nil { - return nil, fmt.Errorf("init ipset: %w", err) - } - - return m, nil + v6: iptablesClient.Proto() == iptables.ProtocolIPv6, + }, nil } func (m *aclManager) init(stateManager *statemanager.Manager) error { @@ -86,7 +88,11 @@ func (m *aclManager) AddPeerFiltering( chain := chainNameInputRules ipsetName = transformIPsetName(ipsetName, sPort, dPort, action) - specs := filterRuleSpecs(ip, string(protocol), sPort, dPort, action, ipsetName) + if m.v6 && ipsetName != "" { + ipsetName += "-v6" + } + proto := protoForFamily(protocol, m.v6) + specs := filterRuleSpecs(ip, proto, sPort, dPort, action, ipsetName) mangleSpecs := slices.Clone(specs) mangleSpecs = append(mangleSpecs, @@ -98,8 +104,8 @@ func (m *aclManager) AddPeerFiltering( specs = append(specs, "-j", actionToStr(action)) if ipsetName != "" { if ipList, ipsetExists := m.ipsetStore.ipset(ipsetName); ipsetExists { - if err := ipset.Add(ipsetName, ip.String()); err != nil { - return nil, fmt.Errorf("failed to add IP to ipset: %w", err) + 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. @@ -110,17 +116,22 @@ func (m *aclManager) AddPeerFiltering( ip: ip.String(), chain: chain, specs: specs, + v6: m.v6, }}, nil } - if err := ipset.Flush(ipsetName); err != nil { - log.Errorf("flush ipset %s before use it: %s", ipsetName, err) + 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 := ipset.Create(ipsetName); err != nil { - return nil, fmt.Errorf("failed to create ipset: %w", err) + if err := m.createIPSet(ipsetName); err != nil { + return nil, fmt.Errorf("create ipset: %w", err) } - if err := ipset.Add(ipsetName, ip.String()); err != nil { - return nil, fmt.Errorf("failed to add IP to 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()) @@ -158,6 +169,7 @@ func (m *aclManager) AddPeerFiltering( ipsetName: ipsetName, ip: ip.String(), chain: chain, + v6: m.v6, } m.updateState() @@ -172,11 +184,16 @@ func (m *aclManager) DeletePeerRule(rule firewall.Rule) error { 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 { - if err := ipset.Del(r.ipsetName, r.ip); err != nil { - return fmt.Errorf("failed to delete ip from ipset: %w", err) + 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) } @@ -190,10 +207,7 @@ func (m *aclManager) DeletePeerRule(rule firewall.Rule) error { // 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) - - if err := ipset.Destroy(r.ipsetName); err != nil { - log.Errorf("delete empty ipset: %v", err) - } + shouldDestroyIpset = true } if err := m.iptablesClient.Delete(tableName, r.chain, r.specs...); err != nil { @@ -206,6 +220,16 @@ func (m *aclManager) DeletePeerRule(rule firewall.Rule) error { } } + 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 @@ -263,12 +287,26 @@ func (m *aclManager) cleanChains() error { } } - for _, ipsetName := range m.ipsetStore.ipsetNames() { - if err := ipset.Flush(ipsetName); err != nil { - log.Errorf("flush ipset %q during reset: %v", ipsetName, 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) } - if err := ipset.Destroy(ipsetName); err != nil { - log.Errorf("delete ipset %q during reset: %v", ipsetName, 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) } @@ -284,6 +322,10 @@ func (m *aclManager) createDefaultChains() error { } for chainName, rules := range m.entries { + // mangle FORWARD guard rules are handled separately below + if chainName == mangleFwdKey { + continue + } for _, rule := range rules { if err := m.iptablesClient.InsertUnique(tableName, chainName, 1, rule...); err != nil { log.Debugf("failed to create input chain jump rule: %s", err) @@ -303,6 +345,13 @@ func (m *aclManager) createDefaultChains() error { } clear(m.optionalEntries) + // Insert mangle FORWARD guard rules to prevent external DNAT bypass. + for _, rule := range m.entries[mangleFwdKey] { + if err := m.iptablesClient.AppendUnique(tableMangle, chainFORWARD, rule...); err != nil { + log.Errorf("failed to add mangle FORWARD guard rule: %v", err) + } + } + return nil } @@ -324,6 +373,22 @@ func (m *aclManager) seedInitialEntries() { m.appendToEntries("FORWARD", []string{"-o", m.wgIface.Name(), "-j", chainRTFWDOUT}) m.appendToEntries("FORWARD", []string{"-i", m.wgIface.Name(), "-j", chainRTFWDIN}) + + // Mangle FORWARD guard: when external DNAT redirects traffic from the wg interface, it + // traverses FORWARD instead of INPUT, bypassing ACL rules. ACCEPT rules in filter FORWARD + // can be inserted above ours. Mangle runs before filter, so these guard rules enforce the + // ACL mark check where it cannot be overridden. + m.appendToEntries(mangleFwdKey, []string{ + "-i", m.wgIface.Name(), + "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", + "-j", "ACCEPT", + }) + m.appendToEntries(mangleFwdKey, []string{ + "-i", m.wgIface.Name(), + "-m", "conntrack", "--ctstate", "DNAT", + "-m", "mark", "!", "--mark", fmt.Sprintf("%#x", nbnet.PreroutingFwmarkRedirected), + "-j", "DROP", + }) } func (m *aclManager) seedInitialOptionalEntries() { @@ -357,8 +422,18 @@ func (m *aclManager) updateState() { currentState.Lock() defer currentState.Unlock() - currentState.ACLEntries = m.entries - currentState.ACLIPsetStore = m.ipsetStore + // Clone the maps so the persisted state holds a private snapshot. The + // live maps keep being mutated by subsequent rule operations while the + // state manager marshals the state from its periodic-save goroutine. + // Sharing them by reference races the two and aborts the process with a + // concurrent map iteration and write. + if m.v6 { + currentState.ACLEntries6 = maps.Clone(m.entries) + currentState.ACLIPsetStore6 = m.ipsetStore.clone() + } else { + currentState.ACLEntries = maps.Clone(m.entries) + currentState.ACLIPsetStore = m.ipsetStore.clone() + } if err := m.stateManager.UpdateState(currentState); err != nil { log.Errorf("failed to update state: %v", err) @@ -366,16 +441,22 @@ func (m *aclManager) updateState() { } // 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) { - matchByIP := true - // don't use IP matching if IP is ip 0.0.0.0 - if ip.String() == "0.0.0.0" { - matchByIP = false +// protoForFamily translates ICMP to ICMPv6 for ip6tables. +// ip6tables requires "ipv6-icmp" (or "icmpv6") instead of "icmp". +func protoForFamily(protocol firewall.Protocol, v6 bool) string { + if v6 && protocol == firewall.ProtocolICMP { + return "ipv6-icmp" } + return string(protocol) +} + +func filterRuleSpecs(ip net.IP, protocol string, sPort, dPort *firewall.Port, action firewall.Action, ipsetName string) (specs []string) { + // don't use IP matching if IP is 0.0.0.0 + matchByIP := !ip.IsUnspecified() if matchByIP { if ipsetName != "" { - specs = append(specs, "-m", "set", "--set", ipsetName, "src") + specs = append(specs, "-m", "set", "--match-set", ipsetName, "src") } else { specs = append(specs, "-s", ip.String()) } @@ -416,3 +497,64 @@ func transformIPsetName(ipsetName string, sPort, dPort *firewall.Port, action fi return ipsetName + actionSuffix } } + +func (m *aclManager) createIPSet(name string) error { + opts := ipset.CreateOptions{ + Replace: true, + } + if m.v6 { + opts.Family = ipset.FamilyIPV6 + } + + if err := ipset.Create(name, ipset.TypeHashNet, opts); err != nil { + return fmt.Errorf("create ipset %s: %w", name, err) + } + + log.Debugf("created ipset %s with type hash:net", name) + return nil +} + +func (m *aclManager) addToIPSet(name string, ip net.IP) error { + cidr := uint8(32) + if ip.To4() == nil { + cidr = 128 + } + + entry := &ipset.Entry{ + IP: ip, + CIDR: cidr, + Replace: true, + } + + if err := ipset.Add(name, entry); err != nil { + return fmt.Errorf("add IP to ipset %s: %w", name, err) + } + + return nil +} + +func (m *aclManager) delFromIPSet(name string, ip net.IP) error { + cidr := uint8(32) + if ip.To4() == nil { + cidr = 128 + } + + entry := &ipset.Entry{ + IP: ip, + CIDR: cidr, + } + + if err := ipset.Del(name, entry); err != nil { + return fmt.Errorf("delete IP from ipset %s: %w", name, err) + } + + return nil +} + +func (m *aclManager) flushIPSet(name string) error { + return ipset.Flush(name) +} + +func (m *aclManager) destroyIPSet(name string) error { + return ipset.Destroy(name) +} diff --git a/client/firewall/iptables/manager_linux.go b/client/firewall/iptables/manager_linux.go index 2563a9052..696537dd8 100644 --- a/client/firewall/iptables/manager_linux.go +++ b/client/firewall/iptables/manager_linux.go @@ -12,27 +12,37 @@ import ( log "github.com/sirupsen/logrus" nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/client/firewall/firewalld" firewall "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/statemanager" ) +type resetter interface { + Reset() error +} + // Manager of iptables firewall type Manager struct { mutex sync.Mutex wgIface iFaceMapper - ipv4Client *iptables.IPTables - aclMgr *aclManager - router *router + ipv4Client *iptables.IPTables + aclMgr *aclManager + router *router + rawSupported bool + + // IPv6 counterparts, nil when no v6 overlay + ipv6Client *iptables.IPTables + aclMgr6 *aclManager + router6 *router } // iFaceMapper defines subset methods of interface required for manager type iFaceMapper interface { Name() string Address() wgaddr.Address - IsUserspaceBind() bool } // Create iptables firewall manager @@ -57,16 +67,49 @@ func Create(wgIface iFaceMapper, mtu uint16) (*Manager, error) { 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) + } + m.ipv6Client = ip6Client + + m.router6, err = newRouter(ip6Client, wgIface, mtu) + if err != nil { + return fmt.Errorf("create v6 router: %w", err) + } + + // Share the same IP forwarding state with the v4 router, since + // EnableIPForwarding controls both v4 and v6 sysctls. + m.router6.ipFwdState = m.router.ipFwdState + + m.aclMgr6, err = newAclManager(ip6Client, wgIface) + if err != nil { + return fmt.Errorf("create v6 acl manager: %w", err) + } + + 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(), - UserspaceBind: m.wgIface.IsUserspaceBind(), - MTU: m.router.mtu, + NameStr: m.wgIface.Name(), + WGAddress: m.wgIface.Address(), + MTU: m.router.mtu, }, } stateManager.RegisterState(state) @@ -74,13 +117,18 @@ 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.initChains(stateManager); err != nil { + return err } - if err := m.aclMgr.init(stateManager); err != nil { - // TODO: cleanup router - return fmt.Errorf("acl manager init: %w", err) + if err := m.initNoTrackChain(); err != nil { + log.Warnf("raw table not available, notrack rules will be disabled: %v", err) + } + + // Trust after all fatal init steps so a later failure doesn't leave the + // interface in firewalld's trusted zone without a corresponding Close. + if err := firewalld.TrustInterface(m.wgIface.Name()); err != nil { + log.Warnf("failed to trust interface in firewalld: %v", err) } // persist early to ensure cleanup of chains @@ -93,6 +141,41 @@ func (m *Manager) Init(stateManager *statemanager.Manager) error { return nil } +// initChains initializes router and ACL chains for both address families, +// rolling back on failure. +func (m *Manager) initChains(stateManager *statemanager.Manager) error { + type initStep struct { + name string + init func(*statemanager.Manager) error + mgr resetter + } + + steps := []initStep{ + {"router", m.router.init, m.router}, + {"acl manager", m.aclMgr.init, m.aclMgr}, + } + if m.hasIPv6() { + steps = append(steps, + initStep{"v6 router", m.router6.init, m.router6}, + initStep{"v6 acl manager", m.aclMgr6.init, m.aclMgr6}, + ) + } + + var initialized []initStep + for _, s := range steps { + if err := s.init(stateManager); err != nil { + for i := len(initialized) - 1; i >= 0; i-- { + if rerr := initialized[i].mgr.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 +} + // AddPeerFiltering adds a rule to the firewall // // Comment will be ignored because some system this feature is not supported @@ -108,7 +191,13 @@ func (m *Manager) AddPeerFiltering( m.mutex.Lock() defer m.mutex.Unlock() - return m.aclMgr.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName) + if ip.To4() != nil { + return m.aclMgr.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName) + } + if !m.hasIPv6() { + return nil, fmt.Errorf("add peer filtering for %s: %w", ip, firewall.ErrIPv6NotInitialized) + } + return m.aclMgr6.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName) } func (m *Manager) AddRouteFiltering( @@ -122,25 +211,48 @@ func (m *Manager) AddRouteFiltering( 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 isIPv6RouteRule(sources, destination) { + if !m.hasIPv6() { + return nil, fmt.Errorf("add route filtering: %w", firewall.ErrIPv6NotInitialized) + } + return m.router6.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action) } return m.router.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action) } +func isIPv6RouteRule(sources []netip.Prefix, destination firewall.Network) bool { + if destination.IsPrefix() { + return destination.Prefix.Addr().Is6() + } + return len(sources) > 0 && sources[0].Addr().Is6() +} + // DeletePeerRule from the firewall by rule definition func (m *Manager) DeletePeerRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() + if m.hasIPv6() && isIPv6IptRule(rule) { + return m.aclMgr6.DeletePeerRule(rule) + } return m.aclMgr.DeletePeerRule(rule) } +func isIPv6IptRule(rule firewall.Rule) bool { + r, ok := rule.(*Rule) + return ok && r.v6 +} + +// DeleteRouteRule deletes a routing rule. +// Route rules are keyed by content hash. Check v4 first, try v6 if not found. func (m *Manager) DeleteRouteRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() + if m.hasIPv6() && !m.router.hasRule(rule.ID()) { + return m.router6.DeleteRouteRule(rule) + } return m.router.DeleteRouteRule(rule) } @@ -156,18 +268,65 @@ 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.router6.AddNatRule(pair) + } + + if err := m.router.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.router6.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.router6.RemoveNatRule(pair) + } + + var merr *multierror.Error + + if err := m.router.RemoveNatRule(pair); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove v4 NAT rule: %w", err)) + } + + if m.hasIPv6() && pair.Dynamic { + v6Pair := firewall.ToV6NatPair(pair) + if err := m.router6.RemoveNatRule(v6Pair); err != nil { + 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) + if err := firewall.SetLegacyManagement(m.router, isLegacy); err != nil { + return err + } + if m.hasIPv6() { + return firewall.SetLegacyManagement(m.router6, isLegacy) + } + return nil } // Reset firewall to the default state @@ -177,6 +336,19 @@ func (m *Manager) Close(stateManager *statemanager.Manager) error { var merr *multierror.Error + if err := m.cleanupNoTrackChain(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("cleanup notrack chain: %w", err)) + } + + if m.hasIPv6() { + if err := m.aclMgr6.Reset(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("reset v6 acl manager: %w", err)) + } + if err := m.router6.Reset(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("reset v6 router: %w", err)) + } + } + if err := m.aclMgr.Reset(); err != nil { merr = multierror.Append(merr, fmt.Errorf("reset acl manager: %w", err)) } @@ -184,6 +356,12 @@ func (m *Manager) Close(stateManager *statemanager.Manager) error { merr = multierror.Append(merr, fmt.Errorf("reset router: %w", err)) } + // Appending to merr intentionally blocks DeleteState below so ShutdownState + // stays persisted and the crash-recovery path retries firewalld cleanup. + if err := firewalld.UntrustInterface(m.wgIface.Name()); err != nil { + merr = multierror.Append(merr, err) + } + // attempt to delete state only if all other operations succeeded if merr == nil { if err := stateManager.DeleteState(&ShutdownState{}); err != nil { @@ -194,25 +372,25 @@ func (m *Manager) Close(stateManager *statemanager.Manager) error { return nberrors.FormatErrorOrNil(merr) } -// AllowNetbird allows netbird interface traffic +// AllowNetbird allows netbird interface traffic. +// This is called when USPFilter wraps the native firewall, adding blanket accept +// rules so that packet filtering is handled in userspace instead of by netfilter. func (m *Manager) AllowNetbird() error { - if !m.wgIface.IsUserspaceBind() { - return nil + var merr *multierror.Error + if _, err := m.AddPeerFiltering(nil, net.IP{0, 0, 0, 0}, firewall.ProtocolALL, nil, nil, firewall.ActionAccept, ""); err != nil { + merr = multierror.Append(merr, fmt.Errorf("allow netbird v4 interface traffic: %w", err)) + } + if m.hasIPv6() { + if _, err := m.AddPeerFiltering(nil, net.IPv6zero, firewall.ProtocolALL, nil, nil, firewall.ActionAccept, ""); err != nil { + merr = multierror.Append(merr, fmt.Errorf("allow netbird v6 interface traffic: %w", err)) + } } - _, 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 + + return nberrors.FormatErrorOrNil(merr) } // Flush doesn't need to be implemented for this manager @@ -242,6 +420,12 @@ func (m *Manager) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) m.mutex.Lock() defer m.mutex.Unlock() + if rule.TranslatedAddress.Is6() { + if !m.hasIPv6() { + return nil, fmt.Errorf("add DNAT rule: %w", firewall.ErrIPv6NotInitialized) + } + return m.router6.AddDNATRule(rule) + } return m.router.AddDNATRule(rule) } @@ -250,6 +434,9 @@ func (m *Manager) DeleteDNATRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() + if m.hasIPv6() && !m.router.hasRule(rule.ID()+dnatSuffix) { + return m.router6.DeleteDNATRule(rule) + } return m.router.DeleteDNATRule(rule) } @@ -258,23 +445,210 @@ 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.router.UpdateSet(set, v4Prefixes); err != nil { + return err + } + + if m.hasIPv6() && len(v6Prefixes) > 0 { + if err := m.router6.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.router6.AddInboundDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.router.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.router6.RemoveInboundDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.router.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, originalPort, translatedPort uint16) error { + m.mutex.Lock() + defer m.mutex.Unlock() + + if localAddr.Is6() { + if !m.hasIPv6() { + return fmt.Errorf("add output DNAT: %w", firewall.ErrIPv6NotInitialized) + } + return m.router6.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.router.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort) +} + +// RemoveOutputDNAT removes an OUTPUT chain DNAT rule. +func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + m.mutex.Lock() + defer m.mutex.Unlock() + + if localAddr.Is6() { + if !m.hasIPv6() { + return fmt.Errorf("remove output DNAT: %w", firewall.ErrIPv6NotInitialized) + } + return m.router6.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.router.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) +} + +const ( + chainNameRaw = "NETBIRD-RAW" + chainOUTPUT = "OUTPUT" + tableRaw = "raw" +) + +// SetupEBPFProxyNoTrack creates notrack rules for eBPF proxy loopback traffic. +// This prevents conntrack from tracking WireGuard proxy traffic on loopback, which +// can interfere with MASQUERADE rules (e.g., from container runtimes like Podman/netavark). +// +// Traffic flows that need NOTRACK: +// +// 1. Egress: WireGuard -> fake endpoint (before eBPF rewrite) +// src=127.0.0.1:wgPort -> dst=127.0.0.1:fakePort +// Matched by: sport=wgPort +// +// 2. Egress: Proxy -> WireGuard (via raw socket) +// src=127.0.0.1:fakePort -> dst=127.0.0.1:wgPort +// Matched by: dport=wgPort +// +// 3. Ingress: Packets to WireGuard +// dst=127.0.0.1:wgPort +// Matched by: dport=wgPort +// +// 4. Ingress: Packets to proxy (after eBPF rewrite) +// dst=127.0.0.1:proxyPort +// Matched by: dport=proxyPort +// +// Rules are cleaned up when the firewall manager is closed. +func (m *Manager) SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error { + m.mutex.Lock() + defer m.mutex.Unlock() + + if !m.rawSupported { + return fmt.Errorf("raw table not available") + } + + wgPortStr := fmt.Sprintf("%d", wgPort) + proxyPortStr := fmt.Sprintf("%d", proxyPort) + + // Egress rules: match outgoing loopback UDP packets + outputRuleSport := []string{"-o", "lo", "-s", "127.0.0.1", "-d", "127.0.0.1", "-p", "udp", "--sport", wgPortStr, "-j", "NOTRACK"} + if err := m.ipv4Client.AppendUnique(tableRaw, chainNameRaw, outputRuleSport...); err != nil { + return fmt.Errorf("add output sport notrack rule: %w", err) + } + + outputRuleDport := []string{"-o", "lo", "-s", "127.0.0.1", "-d", "127.0.0.1", "-p", "udp", "--dport", wgPortStr, "-j", "NOTRACK"} + if err := m.ipv4Client.AppendUnique(tableRaw, chainNameRaw, outputRuleDport...); err != nil { + return fmt.Errorf("add output dport notrack rule: %w", err) + } + + // Ingress rules: match incoming loopback UDP packets + preroutingRuleWg := []string{"-i", "lo", "-s", "127.0.0.1", "-d", "127.0.0.1", "-p", "udp", "--dport", wgPortStr, "-j", "NOTRACK"} + if err := m.ipv4Client.AppendUnique(tableRaw, chainNameRaw, preroutingRuleWg...); err != nil { + return fmt.Errorf("add prerouting wg notrack rule: %w", err) + } + + preroutingRuleProxy := []string{"-i", "lo", "-s", "127.0.0.1", "-d", "127.0.0.1", "-p", "udp", "--dport", proxyPortStr, "-j", "NOTRACK"} + if err := m.ipv4Client.AppendUnique(tableRaw, chainNameRaw, preroutingRuleProxy...); err != nil { + return fmt.Errorf("add prerouting proxy notrack rule: %w", err) + } + + log.Debugf("set up ebpf proxy notrack rules for ports %d,%d", proxyPort, wgPort) + return nil +} + +func (m *Manager) initNoTrackChain() error { + if err := m.cleanupNoTrackChain(); err != nil { + log.Debugf("cleanup notrack chain: %v", err) + } + + if err := m.ipv4Client.NewChain(tableRaw, chainNameRaw); err != nil { + return fmt.Errorf("create chain: %w", err) + } + + jumpRule := []string{"-j", chainNameRaw} + + 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 { + log.Debugf("delete output jump rule: %v", delErr) + } + if delErr := m.ipv4Client.DeleteChain(tableRaw, chainNameRaw); delErr != nil { + log.Debugf("delete orphan chain: %v", delErr) + } + return fmt.Errorf("add prerouting jump rule: %w", err) + } + + m.rawSupported = true + return nil +} + +func (m *Manager) cleanupNoTrackChain() error { + exists, err := m.ipv4Client.ChainExists(tableRaw, chainNameRaw) + if err != nil { + if !m.rawSupported { + return nil + } + return fmt.Errorf("check chain exists: %w", err) + } + if !exists { + return nil + } + + jumpRule := []string{"-j", chainNameRaw} + + 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 { + return fmt.Errorf("remove prerouting jump rule: %w", err) + } + + if err := m.ipv4Client.ClearAndDeleteChain(tableRaw, chainNameRaw); err != nil { + return fmt.Errorf("clear and delete chain: %w", err) + } + + m.rawSupported = false + return nil } func getConntrackEstablished() []string { diff --git a/client/firewall/iptables/manager_linux_test.go b/client/firewall/iptables/manager_linux_test.go index 6b5401e2b..7b0989f6c 100644 --- a/client/firewall/iptables/manager_linux_test.go +++ b/client/firewall/iptables/manager_linux_test.go @@ -1,3 +1,5 @@ +//go:build privileged + package iptables import ( @@ -47,8 +49,6 @@ func (i *iFaceMock) Address() wgaddr.Address { panic("AddressFunc is not set") } -func (i *iFaceMock) IsUserspaceBind() bool { return false } - func TestIptablesManager(t *testing.T) { ipv4Client, err := iptables.NewWithProtocol(iptables.ProtocolIPv4) require.NoError(t, err) @@ -161,7 +161,7 @@ func TestIptablesManagerDenyRules(t *testing.T) { t.Logf(" [%d] %s", i, rule) } - var denyRuleIndex, acceptRuleIndex int = -1, -1 + 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) diff --git a/client/firewall/iptables/router_linux.go b/client/firewall/iptables/router_linux.go index 305b0bf28..42d305f5c 100644 --- a/client/firewall/iptables/router_linux.go +++ b/client/firewall/iptables/router_linux.go @@ -4,13 +4,14 @@ package iptables import ( "fmt" + "maps" "net/netip" "strconv" "strings" "github.com/coreos/go-iptables/iptables" "github.com/hashicorp/go-multierror" - "github.com/nadoo/ipset" + ipset "github.com/lrh3321/ipset-go" log "github.com/sirupsen/logrus" nberrors "github.com/netbirdio/netbird/client/errors" @@ -36,6 +37,7 @@ const ( chainRTFWDOUT = "NETBIRD-RT-FWD-OUT" chainRTPRE = "NETBIRD-RT-PRE" chainRTRDR = "NETBIRD-RT-RDR" + chainNATOutput = "NETBIRD-NAT-OUTPUT" chainRTMSSCLAMP = "NETBIRD-RT-MSSCLAMP" routingFinalForwardJump = "ACCEPT" routingFinalNatJump = "MASQUERADE" @@ -43,6 +45,7 @@ const ( jumpManglePre = "jump-mangle-pre" jumpNatPre = "jump-nat-pre" jumpNatPost = "jump-nat-post" + jumpNatOutput = "jump-nat-output" jumpMSSClamp = "jump-mss-clamp" markManglePre = "mark-mangle-pre" markManglePost = "mark-mangle-post" @@ -52,8 +55,10 @@ const ( snatSuffix = "_snat" fwdSuffix = "_fwd" - // ipTCPHeaderMinSize represents minimum IP (20) + TCP (20) header size for MSS calculation - ipTCPHeaderMinSize = 40 + // 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 { @@ -84,6 +89,7 @@ type router struct { wgIface iFaceMapper legacyManagement bool mtu uint16 + v6 bool stateManager *statemanager.Manager ipFwdState *ipfwdstate.IPForwardingState @@ -95,6 +101,7 @@ func newRouter(iptablesClient *iptables.IPTables, wgIface iFaceMapper, mtu uint1 rules: make(map[string][]string), wgIface: wgIface, mtu: mtu, + v6: iptablesClient.Proto() == iptables.ProtocolIPv6, ipFwdState: ipfwdstate.NewIPForwardingState(), } @@ -107,10 +114,6 @@ func newRouter(iptablesClient *iptables.IPTables, wgIface iFaceMapper, mtu uint1 }, ) - if err := ipset.Init(); err != nil { - return nil, fmt.Errorf("init ipset: %w", err) - } - return r, nil } @@ -188,6 +191,11 @@ func (r *router) AddRouteFiltering( return ruleKey, nil } +func (r *router) hasRule(id string) bool { + _, ok := r.rules[id] + return ok +} + func (r *router) DeleteRouteRule(rule firewall.Rule) error { ruleKey := rule.ID() @@ -232,12 +240,12 @@ func (r *router) findSets(rule []string) []string { } func (r *router) createIpSet(setName string, sources []netip.Prefix) error { - if err := ipset.Create(setName, ipset.OptTimeout(0)); err != nil { + if err := r.createIPSet(setName); err != nil { return fmt.Errorf("create set %s: %w", setName, err) } for _, prefix := range sources { - if err := ipset.AddPrefix(setName, prefix); err != nil { + if err := r.addPrefixToIPSet(setName, prefix); err != nil { return fmt.Errorf("add element to set %s: %w", setName, err) } } @@ -246,7 +254,7 @@ func (r *router) createIpSet(setName string, sources []netip.Prefix) error { } func (r *router) deleteIpSet(setName string) error { - if err := ipset.Destroy(setName); err != nil { + if err := r.destroyIPSet(setName); err != nil { return fmt.Errorf("destroy set %s: %w", setName, err) } @@ -391,6 +399,18 @@ func (r *router) cleanUpDefaultForwardRules() error { } log.Debug("flushing routing related tables") + + // Remove jump rules from built-in chains before deleting custom chains, + // otherwise the chain deletion fails with "device or resource busy". + if ok, err := r.iptablesClient.ChainExists(tableNat, chainNATOutput); err != nil { + return fmt.Errorf("check chain %s: %w", chainNATOutput, err) + } else if ok { + jumpRule := []string{"-j", chainNATOutput} + if err := r.iptablesClient.Delete(tableNat, "OUTPUT", jumpRule...); err != nil { + log.Debugf("clean OUTPUT jump rule: %v", err) + } + } + for _, chainInfo := range []struct { chain string table string @@ -400,6 +420,7 @@ func (r *router) cleanUpDefaultForwardRules() error { {chainRTPRE, tableMangle}, {chainRTNAT, tableNat}, {chainRTRDR, tableNat}, + {chainNATOutput, tableNat}, {chainRTMSSCLAMP, tableMangle}, } { ok, err := r.iptablesClient.ChainExists(chainInfo.table, chainInfo.chain) @@ -427,6 +448,12 @@ func (r *router) createContainers() error { {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) } @@ -533,9 +560,12 @@ func (r *router) addPostroutingRules() error { } // addMSSClampingRules adds MSS clamping rules to prevent fragmentation for forwarded traffic. -// TODO: Add IPv6 support func (r *router) addMSSClampingRules() error { - mss := r.mtu - ipTCPHeaderMinSize + overhead := uint16(ipv4TCPHeaderSize) + if r.v6 { + overhead = ipv6TCPHeaderSize + } + mss := r.mtu - overhead // Add jump rule from FORWARD chain in mangle table to our custom chain jumpRule := []string{ @@ -720,8 +750,19 @@ func (r *router) updateState() { currentState.Lock() defer currentState.Unlock() - currentState.RouteRules = r.rules - currentState.RouteIPsetCounter = r.ipsetCounter + // Clone the rule map so the persisted state holds a private snapshot. The + // live map keeps being mutated by subsequent rule operations while the + // state manager marshals the state from its periodic-save goroutine. + // Sharing it by reference races the two and aborts the process with a + // concurrent map iteration and write. The ipset counter guards itself + // during marshaling, so it can be shared directly. + if r.v6 { + currentState.RouteRules6 = maps.Clone(r.rules) + currentState.RouteIPsetCounter6 = r.ipsetCounter + } else { + currentState.RouteRules = maps.Clone(r.rules) + currentState.RouteIPsetCounter = r.ipsetCounter + } if err := r.stateManager.UpdateState(currentState); err != nil { log.Errorf("failed to update state: %v", err) @@ -849,7 +890,7 @@ func (r *router) DeleteDNATRule(rule firewall.Rule) error { } if fwdRule, exists := r.rules[ruleKey+fwdSuffix]; exists { - if err := r.iptablesClient.Delete(tableFilter, chainRTFWDIN, fwdRule...); err != nil { + if err := r.iptablesClient.Delete(tableFilter, chainRTFWDOUT, fwdRule...); err != nil { merr = multierror.Append(merr, fmt.Errorf("delete forward rule: %w", err)) } delete(r.rules, ruleKey+fwdSuffix) @@ -876,7 +917,7 @@ func (r *router) genRouteRuleSpec(params routeFilteringRuleParams, sources []net rule = append(rule, destExp...) if params.Proto != firewall.ProtocolALL { - rule = append(rule, "-p", strings.ToLower(string(params.Proto))) + rule = append(rule, "-p", strings.ToLower(protoForFamily(params.Proto, r.v6))) rule = append(rule, applyPort("--sport", params.SPort)...) rule = append(rule, applyPort("--dport", params.DPort)...) } @@ -893,11 +934,12 @@ func (r *router) applyNetwork(flag string, network firewall.Network, prefixes [] } if network.IsSet() { - if _, err := r.ipsetCounter.Increment(network.Set.HashedName(), prefixes); err != nil { + 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, network.Set.HashedName(), direction}, nil + return []string{"-m", "set", matchSet, name, direction}, nil } if network.IsPrefix() { return []string{flag, network.Prefix.String()}, nil @@ -908,27 +950,23 @@ func (r *router) applyNetwork(flag string, network firewall.Network, prefixes [] } func (r *router) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { + name := r.ipsetName(set.HashedName()) 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 := ipset.AddPrefix(set.HashedName(), prefix); err != nil { - merr = multierror.Append(merr, fmt.Errorf("increment ipset counter: %w", err)) + 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", set.HashedName(), prefixes) + log.Debugf("updated set %s with prefixes %v", name, prefixes) } return nberrors.FormatErrorOrNil(merr) } // AddInboundDNAT adds an inbound DNAT rule redirecting traffic from NetBird peers to local services. -func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { - ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort) +func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) if _, exists := r.rules[ruleID]; exists { return nil @@ -936,12 +974,12 @@ func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol dnatRule := []string{ "-i", r.wgIface.Name(), - "-p", strings.ToLower(string(protocol)), - "--dport", strconv.Itoa(int(sourcePort)), + "-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(targetPort)), + "--to-destination", ":" + strconv.Itoa(int(translatedPort)), } ruleInfo := ruleInfo{ @@ -960,8 +998,8 @@ func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol } // 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) +func (r *router) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) if dnatRule, exists := r.rules[ruleID]; exists { if err := r.iptablesClient.Delete(tableNat, chainRTRDR, dnatRule...); err != nil { @@ -974,6 +1012,81 @@ func (r *router) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Proto return nil } +// ensureNATOutputChain lazily creates the OUTPUT NAT chain and jump rule on first use. +func (r *router) ensureNATOutputChain() error { + if _, exists := r.rules[jumpNatOutput]; exists { + return nil + } + + chainExists, err := r.iptablesClient.ChainExists(tableNat, chainNATOutput) + if err != nil { + return fmt.Errorf("check chain %s: %w", chainNATOutput, err) + } + if !chainExists { + if err := r.iptablesClient.NewChain(tableNat, chainNATOutput); err != nil { + return fmt.Errorf("create chain %s: %w", chainNATOutput, err) + } + } + + jumpRule := []string{"-j", chainNATOutput} + if err := r.iptablesClient.Insert(tableNat, "OUTPUT", 1, jumpRule...); err != nil { + if !chainExists { + if delErr := r.iptablesClient.ClearAndDeleteChain(tableNat, chainNATOutput); delErr != nil { + log.Warnf("failed to rollback chain %s: %v", chainNATOutput, delErr) + } + } + return fmt.Errorf("add OUTPUT jump rule: %w", err) + } + r.rules[jumpNatOutput] = jumpRule + + r.updateState() + return nil +} + +// AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic. +func (r *router) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) + + if _, exists := r.rules[ruleID]; exists { + return nil + } + + if err := r.ensureNATOutputChain(); err != nil { + return err + } + + dnatRule := []string{ + "-p", strings.ToLower(protoForFamily(protocol, localAddr.Is6())), + "--dport", strconv.Itoa(int(originalPort)), + "-d", localAddr.String(), + "-j", "DNAT", + "--to-destination", ":" + strconv.Itoa(int(translatedPort)), + } + + if err := r.iptablesClient.Append(tableNat, chainNATOutput, dnatRule...); err != nil { + return fmt.Errorf("add output DNAT rule: %w", err) + } + r.rules[ruleID] = dnatRule + + r.updateState() + return nil +} + +// RemoveOutputDNAT removes an OUTPUT chain DNAT rule. +func (r *router) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) + + if dnatRule, exists := r.rules[ruleID]; exists { + if err := r.iptablesClient.Delete(tableNat, chainNATOutput, dnatRule...); err != nil { + return fmt.Errorf("delete output DNAT rule: %w", err) + } + delete(r.rules, ruleID) + } + + r.updateState() + return nil +} + func applyPort(flag string, port *firewall.Port) []string { if port == nil { return nil @@ -993,3 +1106,49 @@ func applyPort(flag string, port *firewall.Port) []string { return []string{flag, strconv.Itoa(int(port.Values[0]))} } + +// ipsetName returns the ipset name, suffixed with "-v6" for the v6 router +// to avoid collisions since ipsets are global in the kernel. +func (r *router) ipsetName(name string) string { + if r.v6 { + return name + "-v6" + } + return name +} + +func (r *router) createIPSet(name string) error { + opts := ipset.CreateOptions{ + Replace: true, + } + if r.v6 { + opts.Family = ipset.FamilyIPV6 + } + + if err := ipset.Create(name, ipset.TypeHashNet, opts); err != nil { + return fmt.Errorf("create ipset %s: %w", name, err) + } + + log.Debugf("created ipset %s with type hash:net", name) + return nil +} + +func (r *router) addPrefixToIPSet(name string, prefix netip.Prefix) error { + addr := prefix.Addr() + ip := addr.AsSlice() + + entry := &ipset.Entry{ + IP: ip, + CIDR: uint8(prefix.Bits()), + Replace: true, + } + + if err := ipset.Add(name, entry); err != nil { + return fmt.Errorf("add prefix to ipset %s: %w", name, err) + } + + return nil +} + +func (r *router) destroyIPSet(name string) error { + return ipset.Destroy(name) +} diff --git a/client/firewall/iptables/router_linux_test.go b/client/firewall/iptables/router_linux_test.go index 6707573be..9ca6b9f7e 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 diff --git a/client/firewall/iptables/rule.go b/client/firewall/iptables/rule.go index aa4d2d079..4f4eab167 100644 --- a/client/firewall/iptables/rule.go +++ b/client/firewall/iptables/rule.go @@ -9,6 +9,7 @@ type Rule struct { mangleSpecs []string ip string chain string + v6 bool } // GetRuleID returns the rule id diff --git a/client/firewall/iptables/rulestore_linux.go b/client/firewall/iptables/rulestore_linux.go index 004c512a4..a6d36540e 100644 --- a/client/firewall/iptables/rulestore_linux.go +++ b/client/firewall/iptables/rulestore_linux.go @@ -1,6 +1,9 @@ package iptables -import "encoding/json" +import ( + "encoding/json" + "maps" +) type ipList struct { ips map[string]struct{} @@ -19,6 +22,14 @@ func (s *ipList) addIP(ip string) { s.ips[ip] = struct{}{} } +// clone returns a deep copy of the ipList with its own ips map. +func (s *ipList) clone() *ipList { + if s == nil { + return nil + } + return &ipList{ips: maps.Clone(s.ips)} +} + // MarshalJSON implements json.Marshaler func (s *ipList) MarshalJSON() ([]byte, error) { return json.Marshal(struct { @@ -55,6 +66,19 @@ func newIpsetStore() *ipsetStore { } } +// clone returns a deep copy of the ipsetStore with its own ipsets map and +// independent ipList entries. +func (s *ipsetStore) clone() *ipsetStore { + if s == nil { + return nil + } + cloned := &ipsetStore{ipsets: make(map[string]*ipList, len(s.ipsets))} + for name, list := range s.ipsets { + cloned.ipsets[name] = list.clone() + } + return cloned +} + func (s *ipsetStore) ipset(ipsetName string) (*ipList, bool) { r, ok := s.ipsets[ipsetName] return r, ok diff --git a/client/firewall/iptables/state_linux.go b/client/firewall/iptables/state_linux.go index c88774c1f..f4be37d01 100644 --- a/client/firewall/iptables/state_linux.go +++ b/client/firewall/iptables/state_linux.go @@ -4,15 +4,16 @@ import ( "fmt" "sync" + log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/wgaddr" ) type InterfaceState struct { - NameStr string `json:"name"` - WGAddress wgaddr.Address `json:"wg_address"` - UserspaceBind bool `json:"userspace_bind"` - MTU uint16 `json:"mtu"` + NameStr string `json:"name"` + WGAddress wgaddr.Address `json:"wg_address"` + MTU uint16 `json:"mtu"` } func (i *InterfaceState) Name() string { @@ -23,10 +24,6 @@ func (i *InterfaceState) Address() wgaddr.Address { return i.WGAddress } -func (i *InterfaceState) IsUserspaceBind() bool { - return i.UserspaceBind -} - type ShutdownState struct { sync.Mutex @@ -37,6 +34,12 @@ type ShutdownState struct { ACLEntries aclEntries `json:"acl_entries,omitempty"` ACLIPsetStore *ipsetStore `json:"acl_ipset_store,omitempty"` + + // IPv6 counterparts + RouteRules6 routeRules `json:"route_rules_v6,omitempty"` + RouteIPsetCounter6 *ipsetCounter `json:"route_ipset_counter_v6,omitempty"` + ACLEntries6 aclEntries `json:"acl_entries_v6,omitempty"` + ACLIPsetStore6 *ipsetStore `json:"acl_ipset_store_v6,omitempty"` } func (s *ShutdownState) Name() string { @@ -67,6 +70,28 @@ func (s *ShutdownState) Cleanup() error { 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.router6.rules = s.RouteRules6 + } + if s.RouteIPsetCounter6 != nil { + ipt.router6.ipsetCounter.LoadData(s.RouteIPsetCounter6) + } + if s.ACLEntries6 != nil { + ipt.aclMgr6.entries = s.ACLEntries6 + } + if s.ACLIPsetStore6 != nil { + ipt.aclMgr6.ipsetStore = s.ACLIPsetStore6 + } + } + if err := ipt.Close(nil); err != nil { return fmt.Errorf("reset iptables manager: %w", err) } diff --git a/client/firewall/manager/firewall.go b/client/firewall/manager/firewall.go index 72e6a5c68..149c6db83 100644 --- a/client/firewall/manager/firewall.go +++ b/client/firewall/manager/firewall.go @@ -1,6 +1,7 @@ package manager import ( + "errors" "fmt" "net" "net/netip" @@ -11,6 +12,10 @@ 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") + const ( ForwardingFormatPrefix = "netbird-fwd-" ForwardingFormat = "netbird-fwd-%s-%t" @@ -164,10 +169,20 @@ 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. + AddOutputDNAT(localAddr netip.Addr, protocol Protocol, originalPort, translatedPort uint16) error + + // RemoveOutputDNAT removes an OUTPUT chain DNAT rule. + 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 { 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/nftables/acl_linux.go b/client/firewall/nftables/acl_linux.go index a9d066e2f..a3609b4e6 100644 --- a/client/firewall/nftables/acl_linux.go +++ b/client/firewall/nftables/acl_linux.go @@ -33,15 +33,12 @@ const ( 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 + af addrFamily workTable *nftables.Table chainInputRules *nftables.Chain @@ -67,6 +64,7 @@ func newAclManager(table *nftables.Table, wgIface iFaceMapper, routingFwChainNam wgIface: wgIface, workTable: table, routingFwChainName: routingFwChainName, + af: familyForAddr(table.Family == nftables.TableFamilyIPv4), ipsetStore: newIpsetStore(), rules: make(map[string]*Rule), @@ -145,7 +143,7 @@ func (m *AclManager) DeletePeerRule(rule firewall.Rule) error { } if _, ok := ips[r.ip.String()]; ok { - err := m.sConn.SetDeleteElements(r.nftSet, []nftables.SetElement{{Key: r.ip.To4()}}) + err := m.sConn.SetDeleteElements(r.nftSet, []nftables.SetElement{{Key: ipToBytes(r.ip, m.af)}}) if err != nil { log.Errorf("delete elements for set %q: %v", r.nftSet.Name, err) } @@ -254,11 +252,11 @@ func (m *AclManager) addIOFiltering( expressions = append(expressions, &expr.Payload{ DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, - Offset: uint32(9), + Offset: m.af.protoOffset, Len: uint32(1), }) - protoData, err := protoToInt(proto) + protoData, err := m.af.protoNum(proto) if err != nil { return nil, fmt.Errorf("convert protocol to number: %v", err) } @@ -270,19 +268,16 @@ func (m *AclManager) addIOFiltering( }) } - rawIP := ip.To4() + rawIP := ipToBytes(ip, m.af) // check if rawIP contains zeroed IPv4 0.0.0.0 value // in that case not add IP match expression into the rule definition - if !bytes.HasPrefix(anyIP, rawIP) { - // source address position - addrOffset := uint32(12) - + if slices.ContainsFunc(rawIP, func(v byte) bool { return v != 0 }) { expressions = append(expressions, &expr.Payload{ DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, - Offset: addrOffset, - Len: 4, + Offset: m.af.srcAddrOffset, + Len: m.af.addrLen, }, ) // add individual IP for match if no ipset defined @@ -587,7 +582,7 @@ func (m *AclManager) addJumpRule(chain *nftables.Chain, to string, ifaceKey expr func (m *AclManager) addIpToSet(ipsetName string, ip net.IP) (*nftables.Set, error) { ipset, err := m.rConn.GetSetByName(m.workTable, ipsetName) - rawIP := ip.To4() + rawIP := ipToBytes(ip, m.af) if err != nil { if ipset, err = m.createSet(m.workTable, ipsetName); err != nil { return nil, fmt.Errorf("get set name: %v", err) @@ -619,7 +614,7 @@ func (m *AclManager) createSet(table *nftables.Table, name string) (*nftables.Se Name: name, Table: table, Dynamic: true, - KeyType: nftables.TypeIPAddr, + KeyType: m.af.setKeyType, } if err := m.rConn.AddSet(ipset, nil); err != nil { @@ -707,15 +702,10 @@ func ifname(n string) []byte { 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 +// ipToBytes converts net.IP to the correct byte length for the address family. +func ipToBytes(ip net.IP, af addrFamily) []byte { + if af.addrLen == 4 { + return ip.To4() } - - return 0, fmt.Errorf("unsupported protocol: %s", protocol) + return ip.To16() } 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/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/manager_linux.go b/client/firewall/nftables/manager_linux.go index bd19f1067..fdc7c2f3c 100644 --- a/client/firewall/nftables/manager_linux.go +++ b/client/firewall/nftables/manager_linux.go @@ -11,8 +11,12 @@ 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" + nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/client/firewall/firewalld" firewall "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/statemanager" @@ -39,7 +43,6 @@ func getTableName() string { type iFaceMapper interface { Name() string Address() wgaddr.Address - IsUserspaceBind() bool } // Manager of iptables firewall @@ -50,6 +53,15 @@ type Manager struct { router *router aclManager *AclManager + + // IPv6 counterparts, nil when no v6 overlay + router6 *router + aclManager6 *AclManager + + notrackOutputChain *nftables.Chain + notrackPreroutingChain *nftables.Chain + + extMonitor *externalChainMonitor } // Create nftables firewall manager @@ -59,7 +71,8 @@ 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) @@ -72,50 +85,170 @@ func Create(wgIface iFaceMapper, mtu uint16) (*Manager, error) { return nil, fmt.Errorf("create acl manager: %w", err) } + if wgIface.Address().HasIPv6() { + if err := m.createIPv6Components(tableName, wgIface, mtu); err != nil { + return nil, fmt.Errorf("create IPv6 firewall: %w", err) + } + } + + m.extMonitor = newExternalChainMonitor(m) + return m, nil } +func (m *Manager) createIPv6Components(tableName string, wgIface iFaceMapper, mtu uint16) error { + workTable6 := &nftables.Table{Name: tableName, Family: nftables.TableFamilyIPv6} + + var err error + m.router6, err = newRouter(workTable6, wgIface, mtu) + if err != nil { + return fmt.Errorf("create v6 router: %w", err) + } + + // Share the same IP forwarding state with the v4 router, since + // EnableIPForwarding controls both v4 and v6 sysctls. + m.router6.ipFwdState = m.router.ipFwdState + + m.aclManager6, err = newAclManager(workTable6, wgIface, chainNameRoutingFw) + if err != nil { + return fmt.Errorf("create v6 acl manager: %w", err) + } + + return nil +} + +// hasIPv6 reports whether the manager has IPv6 components initialized. +func (m *Manager) hasIPv6() bool { + return m.router6 != 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.router6.init(workTable6); err != nil { + return fmt.Errorf("v6 router init: %w", err) + } + + if err := m.aclManager6.init(workTable6); err != nil { + return fmt.Errorf("v6 acl manager init: %w", err) + } + + 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). +func (m *Manager) reconcileExternalChains() error { + m.mutex.Lock() + defer m.mutex.Unlock() + + var merr *multierror.Error + if m.router != nil { + if err := m.router.acceptExternalChainsRules(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("v4: %w", err)) + } + } + if m.hasIPv6() { + if err := m.router6.acceptExternalChainsRules(); 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) } + defer func() { + if err != nil { + m.rollbackInit() + } + }() + if err := m.router.init(workTable); err != nil { return fmt.Errorf("router 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(), - UserspaceBind: m.wgIface.IsUserspaceBind(), - MTU: m.router.mtu, + NameStr: m.wgIface.Name(), + WGAddress: m.wgIface.Address(), + MTU: m.router.mtu, }, }); err != nil { log.Errorf("failed to update state: %v", err) } - // persist early go func() { if err := stateManager.PersistState(context.Background()); err != nil { log.Errorf("failed to persist state: %v", err) } }() +} - return nil +// rollbackInit performs best-effort cleanup of already-initialized state when Init fails partway through. +func (m *Manager) rollbackInit() { + if err := m.router.Reset(); err != nil { + log.Warnf("rollback router: %v", err) + } + if m.hasIPv6() { + if err := m.router6.Reset(); err != nil { + log.Warnf("rollback v6 router: %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) + } } // AddPeerFiltering rule to the firewall @@ -134,12 +267,14 @@ func (m *Manager) AddPeerFiltering( m.mutex.Lock() defer m.mutex.Unlock() - rawIP := ip.To4() - if rawIP == nil { - return nil, fmt.Errorf("unsupported IP version: %s", ip.String()) + if ip.To4() != nil { + return m.aclManager.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName) } - return m.aclManager.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName) + if !m.hasIPv6() { + return nil, fmt.Errorf("add peer filtering for %s: %w", ip, firewall.ErrIPv6NotInitialized) + } + return m.aclManager6.AddPeerFiltering(id, ip, proto, sPort, dPort, action, ipsetName) } func (m *Manager) AddRouteFiltering( @@ -153,8 +288,11 @@ func (m *Manager) AddRouteFiltering( 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 isIPv6RouteRule(sources, destination) { + if !m.hasIPv6() { + return nil, fmt.Errorf("add route filtering: %w", firewall.ErrIPv6NotInitialized) + } + return m.router6.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action) } return m.router.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action) @@ -165,15 +303,66 @@ func (m *Manager) DeletePeerRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() + if m.hasIPv6() && isIPv6Rule(rule) { + return m.aclManager6.DeletePeerRule(rule) + } return m.aclManager.DeletePeerRule(rule) } -// DeleteRouteRule deletes a routing rule +func isIPv6Rule(rule firewall.Rule) bool { + r, ok := rule.(*Rule) + return ok && r.nftRule != nil && r.nftRule.Table != nil && r.nftRule.Table.Family == nftables.TableFamilyIPv6 +} + +// isIPv6RouteRule determines whether a route rule belongs to the v6 table. +// For static routes, the destination prefix determines the family. For dynamic +// routes (DomainSet), the sources determine the family since management +// duplicates dynamic rules per family. +func isIPv6RouteRule(sources []netip.Prefix, destination firewall.Network) bool { + if destination.IsPrefix() { + return destination.Prefix.Addr().Is6() + } + return len(sources) > 0 && sources[0].Addr().Is6() +} + +// DeleteRouteRule deletes a routing rule. Route rules live in exactly one +// router; the cached maps are normally authoritative, so the kernel is only +// consulted when neither map knows about the rule. func (m *Manager) DeleteRouteRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.DeleteRouteRule(rule) + id := rule.ID() + r, err := m.routerForRuleID(id, (*router).hasRule) + if err != nil { + return err + } + return r.DeleteRouteRule(rule) +} + +// routerForRuleID picks the router holding the rule with the given id, using +// the supplied lookup. If the cached maps disagree (or both miss), it refreshes +// from the kernel once and re-checks before falling back to the v4 router. +func (m *Manager) routerForRuleID(id string, has func(*router, string) bool) (*router, error) { + if has(m.router, id) { + return m.router, nil + } + if m.hasIPv6() && has(m.router6, id) { + return m.router6, nil + } + if !m.hasIPv6() { + return m.router, nil + } + if err := m.router.refreshRulesMap(); err != nil { + return nil, fmt.Errorf("refresh v4 rules: %w", err) + } + if err := m.router6.refreshRulesMap(); err != nil { + return nil, fmt.Errorf("refresh v6 rules: %w", err) + } + if has(m.router6, id) && !has(m.router, id) { + return m.router6, nil + } + return m.router, nil } func (m *Manager) IsServerRouteSupported() bool { @@ -188,62 +377,136 @@ 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.router6.AddNatRule(pair) + } + + if err := m.router.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.router6.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 -func (m *Manager) AllowNetbird() error { - if !m.wgIface.IsUserspaceBind() { - return nil + if pair.Destination.IsPrefix() && pair.Destination.Prefix.Addr().Is6() { + if !m.hasIPv6() { + return nil + } + return m.router6.RemoveNatRule(pair) } + var merr *multierror.Error + + if err := m.router.RemoveNatRule(pair); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove v4 NAT rule: %w", err)) + } + + if m.hasIPv6() && pair.Dynamic { + v6Pair := firewall.ToV6NatPair(pair) + if err := m.router6.RemoveNatRule(v6Pair); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove v6 NAT rule: %w", err)) + } + } + + return nberrors.FormatErrorOrNil(merr) +} + +// AllowNetbird allows netbird interface traffic. +// This is called when USPFilter wraps the native firewall, adding blanket accept +// rules so that packet filtering is handled in userspace instead of by netfilter. +// +// TODO: In USP mode this only adds ACCEPT to the netbird table's own chains, +// which doesn't override DROP rules in external tables (e.g. firewalld). +// Should add passthrough rules to external chains (like the native mode router's +// addExternalChainsRules does) for both the netbird table family and inet tables. +// The netbird table itself is fine (routing chains already exist there), but +// non-netbird tables with INPUT/FORWARD hooks can still DROP our WG traffic. +func (m *Manager) AllowNetbird() error { m.mutex.Lock() defer m.mutex.Unlock() if err := m.aclManager.createDefaultAllowRules(); err != nil { return fmt.Errorf("create default allow rules: %w", err) } + if m.hasIPv6() { + if err := m.aclManager6.createDefaultAllowRules(); err != nil { + return fmt.Errorf("create v6 default allow rules: %w", err) + } + } if err := m.rConn.Flush(); err != nil { return fmt.Errorf("flush allow input netbird rules: %w", err) } + if err := firewalld.TrustInterface(m.wgIface.Name()); err != nil { + log.Warnf("failed to trust interface in firewalld: %v", err) + } + return nil } // SetLegacyManagement sets the route manager to use legacy management func (m *Manager) SetLegacyManagement(isLegacy bool) error { - return firewall.SetLegacyManagement(m.router, isLegacy) + if err := firewall.SetLegacyManagement(m.router, isLegacy); err != nil { + return err + } + if m.hasIPv6() { + return firewall.SetLegacyManagement(m.router6, 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() + var merr *multierror.Error + if err := m.router.Reset(); err != nil { - return fmt.Errorf("reset router: %v", err) + merr = multierror.Append(merr, fmt.Errorf("reset router: %v", err)) + } + + if m.hasIPv6() { + if err := m.router6.Reset(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("reset v6 router: %v", err)) + } } if err := m.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 { @@ -288,7 +551,21 @@ func (m *Manager) Flush() error { m.mutex.Lock() defer m.mutex.Unlock() - return m.aclManager.Flush() + if err := m.aclManager.Flush(); err != nil { + return err + } + + if m.hasIPv6() { + if err := m.aclManager6.Flush(); err != nil { + return fmt.Errorf("flush v6 acl: %w", err) + } + } + + if err := m.refreshNoTrackChains(); err != nil { + log.Errorf("failed to refresh notrack chains: %v", err) + } + + return nil } // AddDNATRule adds a DNAT rule @@ -296,6 +573,12 @@ func (m *Manager) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) m.mutex.Lock() defer m.mutex.Unlock() + if rule.TranslatedAddress.Is6() { + if !m.hasIPv6() { + return nil, fmt.Errorf("add DNAT rule: %w", firewall.ErrIPv6NotInitialized) + } + return m.router6.AddDNATRule(rule) + } return m.router.AddDNATRule(rule) } @@ -304,7 +587,11 @@ func (m *Manager) DeleteDNATRule(rule firewall.Rule) error { m.mutex.Lock() defer m.mutex.Unlock() - return m.router.DeleteDNATRule(rule) + r, err := m.routerForRuleID(rule.ID(), (*router).hasDNATRule) + if err != nil { + return err + } + return r.DeleteDNATRule(rule) } // UpdateSet updates the set with the given prefixes @@ -312,27 +599,260 @@ 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.router.UpdateSet(set, v4Prefixes); err != nil { + return err + } + + if m.hasIPv6() && len(v6Prefixes) > 0 { + if err := m.router6.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.router6.AddInboundDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.router.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.router6.RemoveInboundDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.router.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, originalPort, translatedPort uint16) error { + m.mutex.Lock() + defer m.mutex.Unlock() + + if localAddr.Is6() { + if !m.hasIPv6() { + return fmt.Errorf("add output DNAT: %w", firewall.ErrIPv6NotInitialized) + } + return m.router6.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.router.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort) +} + +// RemoveOutputDNAT removes an OUTPUT chain DNAT rule. +func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + m.mutex.Lock() + defer m.mutex.Unlock() + + if localAddr.Is6() { + if !m.hasIPv6() { + return fmt.Errorf("remove output DNAT: %w", firewall.ErrIPv6NotInitialized) + } + return m.router6.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) + } + return m.router.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) +} + +const ( + chainNameRawOutput = "netbird-raw-out" + chainNameRawPrerouting = "netbird-raw-pre" +) + +// SetupEBPFProxyNoTrack creates notrack rules for eBPF proxy loopback traffic. +// This prevents conntrack from tracking WireGuard proxy traffic on loopback, which +// can interfere with MASQUERADE rules (e.g., from container runtimes like Podman/netavark). +// +// Traffic flows that need NOTRACK: +// +// 1. Egress: WireGuard -> fake endpoint (before eBPF rewrite) +// src=127.0.0.1:wgPort -> dst=127.0.0.1:fakePort +// Matched by: sport=wgPort +// +// 2. Egress: Proxy -> WireGuard (via raw socket) +// src=127.0.0.1:fakePort -> dst=127.0.0.1:wgPort +// Matched by: dport=wgPort +// +// 3. Ingress: Packets to WireGuard +// dst=127.0.0.1:wgPort +// Matched by: dport=wgPort +// +// 4. Ingress: Packets to proxy (after eBPF rewrite) +// dst=127.0.0.1:proxyPort +// Matched by: dport=proxyPort +// +// Rules are cleaned up when the firewall manager is closed. +func (m *Manager) SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error { + m.mutex.Lock() + defer m.mutex.Unlock() + + if m.notrackOutputChain == nil || m.notrackPreroutingChain == nil { + return fmt.Errorf("notrack chains not initialized") + } + + proxyPortBytes := binaryutil.BigEndian.PutUint16(proxyPort) + wgPortBytes := binaryutil.BigEndian.PutUint16(wgPort) + loopback := []byte{127, 0, 0, 1} + + // Egress rules: match outgoing loopback UDP packets + m.rConn.AddRule(&nftables.Rule{ + Table: m.notrackOutputChain.Table, + Chain: m.notrackOutputChain, + Exprs: []expr.Any{ + &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifname("lo")}, + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, // saddr + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, // daddr + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_UDP}}, + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 0, Len: 2}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: wgPortBytes}, // sport=wgPort + &expr.Counter{}, + &expr.Notrack{}, + }, + }) + m.rConn.AddRule(&nftables.Rule{ + Table: m.notrackOutputChain.Table, + Chain: m.notrackOutputChain, + Exprs: []expr.Any{ + &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifname("lo")}, + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, // saddr + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, // daddr + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_UDP}}, + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: wgPortBytes}, // dport=wgPort + &expr.Counter{}, + &expr.Notrack{}, + }, + }) + + // Ingress rules: match incoming loopback UDP packets + m.rConn.AddRule(&nftables.Rule{ + Table: m.notrackPreroutingChain.Table, + Chain: m.notrackPreroutingChain, + Exprs: []expr.Any{ + &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifname("lo")}, + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, // saddr + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, // daddr + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_UDP}}, + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: wgPortBytes}, // dport=wgPort + &expr.Counter{}, + &expr.Notrack{}, + }, + }) + m.rConn.AddRule(&nftables.Rule{ + Table: m.notrackPreroutingChain.Table, + Chain: m.notrackPreroutingChain, + Exprs: []expr.Any{ + &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifname("lo")}, + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, // saddr + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4}, // daddr + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: loopback}, + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_UDP}}, + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: proxyPortBytes}, // dport=proxyPort + &expr.Counter{}, + &expr.Notrack{}, + }, + }) + + if err := m.rConn.Flush(); err != nil { + return fmt.Errorf("flush notrack rules: %w", err) + } + + log.Debugf("set up ebpf proxy notrack rules for ports %d,%d", proxyPort, wgPort) + return nil +} + +func (m *Manager) initNoTrackChains(table *nftables.Table) error { + m.notrackOutputChain = m.rConn.AddChain(&nftables.Chain{ + Name: chainNameRawOutput, + Table: table, + Type: nftables.ChainTypeFilter, + Hooknum: nftables.ChainHookOutput, + Priority: nftables.ChainPriorityRaw, + }) + + m.notrackPreroutingChain = m.rConn.AddChain(&nftables.Chain{ + Name: chainNameRawPrerouting, + Table: table, + Type: nftables.ChainTypeFilter, + Hooknum: nftables.ChainHookPrerouting, + Priority: nftables.ChainPriorityRaw, + }) + + if err := m.rConn.Flush(); err != nil { + return fmt.Errorf("flush chain creation: %w", err) + } + + return nil +} + +func (m *Manager) refreshNoTrackChains() error { + chains, err := m.rConn.ListChainsOfTableFamily(nftables.TableFamilyIPv4) + if err != nil { + return fmt.Errorf("list chains: %w", err) + } + + tableName := getTableName() + for _, c := range chains { + if c.Table.Name != tableName { + continue + } + switch c.Name { + case chainNameRawOutput: + m.notrackOutputChain = c + case chainNameRawPrerouting: + m.notrackPreroutingChain = c + } + } + + return nil } 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) } @@ -344,7 +864,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 } diff --git a/client/firewall/nftables/manager_linux_test.go b/client/firewall/nftables/manager_linux_test.go index adec802c8..4eb466281 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 ( @@ -52,8 +54,6 @@ func (i *iFaceMock) Address() wgaddr.Address { panic("AddressFunc is not set") } -func (i *iFaceMock) IsUserspaceBind() bool { return false } - func TestNftablesManager(t *testing.T) { // just check on the local interface @@ -198,7 +198,7 @@ func TestNftablesManagerRuleOrder(t *testing.T) { t.Logf("Found %d rules in nftables chain", len(rules)) // Find the accept and deny rules and verify deny comes before accept - var acceptRuleIndex, denyRuleIndex int = -1, -1 + var acceptRuleIndex, denyRuleIndex = -1, -1 for i, rule := range rules { hasAcceptHTTPSet := false hasDenyHTTPSet := false @@ -208,11 +208,13 @@ func TestNftablesManagerRuleOrder(t *testing.T) { for _, e := range rule.Exprs { // Check for set lookup if lookup, ok := e.(*expr.Lookup); ok { - if lookup.SetName == "accept-http" { + switch lookup.SetName { + case "accept-http": hasAcceptHTTPSet = true - } else if lookup.SetName == "deny-http" { + case "deny-http": hasDenyHTTPSet = true } + } // Check for port 80 if cmp, ok := e.(*expr.Cmp); ok { @@ -222,9 +224,10 @@ func TestNftablesManagerRuleOrder(t *testing.T) { } // Check for verdict if verdict, ok := e.(*expr.Verdict); ok { - if verdict.Kind == expr.VerdictAccept { + switch verdict.Kind { + case expr.VerdictAccept: action = "ACCEPT" - } else if verdict.Kind == expr.VerdictDrop { + case expr.VerdictDrop: action = "DROP" } } @@ -382,6 +385,225 @@ 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.AddPeerFiltering(nil, ip.AsSlice(), fw.ProtocolTCP, nil, &fw.Port{Values: []uint16{80}}, fw.ActionAccept, "") + require.NoError(t, err, "add v6 peer filtering rule") + + _, err = manager.AddRouteFiltering( + 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") + } + + if _, err := exec.LookPath("iptables-save"); err != nil { + t.Skipf("iptables-save not available on this system: %v", err) + } + + // First ensure iptables-nft tables exist by running iptables-save + stdout, stderr := runIptablesSave(t) + verifyIptablesOutput(t, stdout, stderr) + + manager, err := Create(ifaceMock, iface.DefaultMTU) + require.NoError(t, err, "failed to create manager") + require.NoError(t, manager.Init(nil)) + + t.Cleanup(func() { + err := manager.Close(nil) + require.NoError(t, err, "failed to reset manager state") + + // Verify iptables output after reset + stdout, stderr := runIptablesSave(t) + verifyIptablesOutput(t, stdout, stderr) + }) + + const octet2Count = 25 + const octet3Count = 255 + prefixes := make([]netip.Prefix, 0, (octet2Count-1)*(octet3Count-1)) + for i := 1; i < octet2Count; i++ { + for j := 1; j < octet3Count; j++ { + addr := netip.AddrFrom4([4]byte{192, byte(j), byte(i), 0}) + prefixes = append(prefixes, netip.PrefixFrom(addr, 24)) + } + } + _, err = manager.AddRouteFiltering( + nil, + prefixes, + fw.Network{Prefix: netip.MustParsePrefix("10.2.0.0/24")}, + fw.ProtocolTCP, + nil, + &fw.Port{Values: []uint16{443}}, + fw.ActionAccept, + ) + require.NoError(t, err, "failed to add route filtering rule") + + stdout, stderr = runIptablesSave(t) + verifyIptablesOutput(t, stdout, stderr) +} + +func TestNftablesManagerCompatibilityWithIptablesForEmptyPrefixes(t *testing.T) { + if check() != NFTABLES { + t.Skip("nftables not supported on this system") + } + + if _, err := exec.LookPath("iptables-save"); err != nil { + t.Skipf("iptables-save not available on this system: %v", err) + } + + // First ensure iptables-nft tables exist by running iptables-save + stdout, stderr := runIptablesSave(t) + verifyIptablesOutput(t, stdout, stderr) + + manager, err := Create(ifaceMock, iface.DefaultMTU) + require.NoError(t, err, "failed to create manager") + require.NoError(t, manager.Init(nil)) + + t.Cleanup(func() { + err := manager.Close(nil) + require.NoError(t, err, "failed to reset manager state") + + // Verify iptables output after reset + stdout, stderr := runIptablesSave(t) + verifyIptablesOutput(t, stdout, stderr) + }) + + _, err = manager.AddRouteFiltering( + nil, + []netip.Prefix{}, + fw.Network{Prefix: netip.MustParsePrefix("10.2.0.0/24")}, + fw.ProtocolTCP, + nil, + &fw.Port{Values: []uint16{443}}, + fw.ActionAccept, + ) + require.NoError(t, err, "failed to add route filtering rule") + stdout, stderr = runIptablesSave(t) verifyIptablesOutput(t, stdout, stderr) } diff --git a/client/firewall/nftables/router_linux.go b/client/firewall/nftables/router_linux.go index 6192c92aa..4214455a9 100644 --- a/client/firewall/nftables/router_linux.go +++ b/client/firewall/nftables/router_linux.go @@ -19,6 +19,7 @@ import ( "golang.org/x/sys/unix" nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/client/firewall/firewalld" firewall "github.com/netbirdio/netbird/client/firewall/manager" nbid "github.com/netbirdio/netbird/client/internal/acl/id" "github.com/netbirdio/netbird/client/internal/routemanager/ipfwdstate" @@ -27,14 +28,21 @@ import ( ) const ( - tableNat = "nat" + 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" @@ -42,11 +50,15 @@ const ( dnatSuffix = "_dnat" snatSuffix = "_snat" - // ipTCPHeaderMinSize represents minimum IP (20) + TCP (20) header size for MSS calculation - ipTCPHeaderMinSize = 40 -) + // 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 -const refreshRulesMapError = "refresh rules map: %w" + // maxPrefixesSet 1638 prefixes start to fail, taking some margin + maxPrefixesSet = 1500 + refreshRulesMapError = "refresh rules map: %w" +) var ( errFilterTableNotFound = fmt.Errorf("'filter' table not found") @@ -66,6 +78,7 @@ type router struct { rules map[string]*nftables.Rule ipsetCounter *refcounter.Counter[string, setInput, *nftables.Set] + af addrFamily wgIface iFaceMapper ipFwdState *ipfwdstate.IPForwardingState legacyManagement bool @@ -78,6 +91,7 @@ func newRouter(workTable *nftables.Table, wgIface iFaceMapper, mtu uint16) (*rou workTable: workTable, chains: make(map[string]*nftables.Chain), rules: make(map[string]*nftables.Rule), + af: familyForAddr(workTable.Family == nftables.TableFamilyIPv4), wgIface: wgIface, ipFwdState: ipfwdstate.NewIPForwardingState(), mtu: mtu, @@ -91,11 +105,7 @@ func newRouter(workTable *nftables.Table, wgIface iFaceMapper, mtu uint16) (*rou var err error r.filterTable, err = r.loadFilterTable() if err != nil { - if errors.Is(err, errFilterTableNotFound) { - log.Warnf("table 'filter' not found for forward rules") - } else { - return nil, fmt.Errorf("load filter table: %w", err) - } + log.Debugf("ip filter table not found: %v", err) } return r, nil @@ -130,6 +140,10 @@ func (r *router) Reset() error { merr = multierror.Append(merr, fmt.Errorf("remove accept filter rules: %w", err)) } + if err := firewalld.UntrustInterface(r.wgIface.Name()); err != nil { + merr = multierror.Append(merr, err) + } + if err := r.removeNatPreroutingRules(); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove filter prerouting rules: %w", err)) } @@ -140,7 +154,7 @@ func (r *router) Reset() error { func (r *router) removeNatPreroutingRules() error { table := &nftables.Table{ Name: tableNat, - Family: nftables.TableFamilyIPv4, + Family: r.af.tableFamily, } chain := &nftables.Chain{ Name: chainNameNatPrerouting, @@ -173,9 +187,9 @@ func (r *router) removeNatPreroutingRules() error { } func (r *router) loadFilterTable() (*nftables.Table, error) { - tables, err := r.conn.ListTablesOfFamily(nftables.TableFamilyIPv4) + tables, err := r.conn.ListTablesOfFamily(r.af.tableFamily) if err != nil { - return nil, fmt.Errorf("unable to list tables: %v", err) + return nil, fmt.Errorf("list tables: %w", err) } for _, table := range tables { @@ -187,14 +201,39 @@ func (r *router) loadFilterTable() (*nftables.Table, error) { 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, }) - insertReturnTrafficRule(r.conn, r.workTable, r.chains[chainNameRoutingFw]) - prio := *nftables.ChainPriorityNATSource - 1 r.chains[chainNameRoutingNat] = r.conn.AddChain(&nftables.Chain{ Name: chainNameRoutingNat, @@ -236,9 +275,12 @@ func (r *router) createContainers() error { Type: nftables.ChainTypeFilter, }) - // Add the single NAT rule that matches on mark - if err := r.addPostroutingRules(); err != nil { - return fmt.Errorf("add single nat rule: %v", err) + 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 { @@ -249,12 +291,12 @@ func (r *router) createContainers() error { log.Errorf("failed to add accept rules for the forward chain: %s", err) } - if err := r.refreshRulesMap(); err != nil { - log.Errorf("failed to clean up rules from 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.conn.Flush(); err != nil { - return fmt.Errorf("initialize tables: %v", err) + if err := r.refreshRulesMap(); err != nil { + log.Errorf("failed to refresh rules: %s", err) } return nil @@ -381,7 +423,7 @@ func (r *router) AddRouteFiltering( // Handle protocol if proto != firewall.ProtocolALL { - protoNum, err := protoToInt(proto) + protoNum, err := r.af.protoNum(proto) if err != nil { return nil, fmt.Errorf("convert protocol to number: %w", err) } @@ -441,7 +483,24 @@ func (r *router) getIpSet(set firewall.Set, prefixes []netip.Prefix, isSource bo return nil, fmt.Errorf("create or get ipset: %w", err) } - return getIpSetExprs(ref, isSource) + return r.getIpSetExprs(ref, isSource) +} + +func (r *router) iptablesProto() iptables.Protocol { + if r.af.tableFamily == nftables.TableFamilyIPv6 { + return iptables.ProtocolIPv6 + } + return iptables.ProtocolIPv4 +} + +func (r *router) hasRule(id string) bool { + _, ok := r.rules[id] + return ok +} + +func (r *router) hasDNATRule(id string) bool { + _, ok := r.rules[id+dnatSuffix] + return ok } func (r *router) DeleteRouteRule(rule firewall.Rule) error { @@ -457,7 +516,12 @@ func (r *router) DeleteRouteRule(rule firewall.Rule) error { } if nftRule.Handle == 0 { - return fmt.Errorf("route rule %s has no handle", ruleKey) + log.Warnf("route rule %s has no handle, removing stale entry", ruleKey) + if err := r.decrementSetCounter(nftRule); err != nil { + log.Warnf("decrement set counter for stale rule %s: %v", ruleKey, err) + } + delete(r.rules, ruleKey) + return nil } if err := r.deleteNftRule(nftRule, ruleKey); err != nil { @@ -485,40 +549,53 @@ func (r *router) createIpSet(setName string, input setInput) (*nftables.Set, err Table: r.workTable, // required for prefixes Interval: true, - KeyType: nftables.TypeIPAddr, + KeyType: r.af.setKeyType, } - elements := convertPrefixesToSet(prefixes) - if err := r.conn.AddSet(nfset, elements); err != nil { - return nil, fmt.Errorf("error adding elements to set %s: %w", setName, err) - } + 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)) - log.Printf("Created new ipset: %s with %d elements", setName, len(elements)/2) + 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 { +func (r *router) 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}, + // the nft tool also adds a zero-address IntervalEnd element, see https://github.com/google/nftables/issues/247 + // nftables.SetElement{Key: make([]byte, r.af.addrLen), IntervalEnd: true}, nftables.SetElement{Key: firstIP.AsSlice()}, nftables.SetElement{Key: lastIP.AsSlice(), IntervalEnd: true}, ) @@ -528,10 +605,20 @@ func convertPrefixesToSet(prefixes []netip.Prefix) []nftables.SetElement { // 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 + masked := prefix.Masked() + if masked.Addr().Is4() { + hostMask := ^uint32(0) >> masked.Bits() + lastIP := uint32FromNetipAddr(masked.Addr()) | hostMask + return netip.AddrFrom4(uint32ToBytes(lastIP)) + } - 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. @@ -615,13 +702,32 @@ func (r *router) AddNatRule(pair firewall.RouterPair) error { } if err := r.conn.Flush(); err != nil { - // TODO: rollback ipset counter - return fmt.Errorf("insert rules for %s: %v", pair.Destination, err) + r.rollbackRules(pair) + return fmt.Errorf("insert rules for %s: %w", pair.Destination, err) } return nil } +// rollbackRules cleans up unflushed rules and their set counters after a flush failure. +func (r *router) rollbackRules(pair firewall.RouterPair) { + keys := []string{ + firewall.GenKey(firewall.ForwardingFormat, pair), + firewall.GenKey(firewall.PreroutingFormat, pair), + firewall.GenKey(firewall.PreroutingFormat, firewall.GetInversePair(pair)), + } + for _, key := range keys { + rule, ok := r.rules[key] + if !ok { + continue + } + if err := r.decrementSetCounter(rule); err != nil { + log.Warnf("rollback set counter for %s: %v", key, err) + } + delete(r.rules, key) + } +} + // addNatRule inserts a nftables rule to the conn client flush queue func (r *router) addNatRule(pair firewall.RouterPair) error { sourceExp, err := r.applyNetwork(pair.Source, nil, true) @@ -695,7 +801,7 @@ func (r *router) addNatRule(pair firewall.RouterPair) error { } // addPostroutingRules adds the masquerade rules -func (r *router) addPostroutingRules() error { +func (r *router) addPostroutingRules() { // First masquerade rule for traffic coming in from WireGuard interface exprs := []expr.Any{ // Match on the first fwmark @@ -761,14 +867,19 @@ func (r *router) addPostroutingRules() error { Chain: r.chains[chainNameRoutingNat], Exprs: exprs2, }) - - 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 + 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{ @@ -839,7 +950,7 @@ func (r *router) addMSSClampingRules() error { Exprs: exprsOut, }) - return nil + return r.conn.Flush() } // addLegacyRouteRule adds a legacy routing rule for mgmt servers pre route acls @@ -885,18 +996,30 @@ func (r *router) addLegacyRouteRule(pair firewall.RouterPair) error { func (r *router) removeLegacyRouteRule(pair firewall.RouterPair) error { ruleKey := firewall.GenKey(firewall.ForwardingFormat, pair) - if rule, exists := r.rules[ruleKey]; exists { - if err := r.conn.DelRule(rule); err != nil { - return fmt.Errorf("remove legacy forwarding rule %s -> %s: %v", pair.Source, pair.Destination, err) - } - - log.Debugf("removed legacy forwarding rule %s -> %s", pair.Source, pair.Destination) - - delete(r.rules, ruleKey) + rule, exists := r.rules[ruleKey] + if !exists { + return nil + } + if rule.Handle == 0 { + log.Warnf("legacy forwarding rule %s has no handle, removing stale entry", ruleKey) if err := r.decrementSetCounter(rule); err != nil { - return fmt.Errorf("decrement set counter: %w", err) + log.Warnf("decrement set counter for stale rule %s: %v", ruleKey, err) } + delete(r.rules, ruleKey) + return nil + } + + if err := r.conn.DelRule(rule); err != nil { + return fmt.Errorf("remove legacy forwarding rule %s -> %s: %w", pair.Source, pair.Destination, err) + } + + log.Debugf("removed legacy forwarding rule %s -> %s", pair.Source, pair.Destination) + + delete(r.rules, ruleKey) + + if err := r.decrementSetCounter(rule); err != nil { + return fmt.Errorf("decrement set counter: %w", err) } return nil @@ -939,8 +1062,21 @@ func (r *router) RemoveAllLegacyRouteRules() error { // 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 { - log.Debugf("table 'filter' not found for forward rules, skipping accept rules") return nil } @@ -950,17 +1086,22 @@ func (r *router) acceptForwardRules() error { 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() + // 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 { - // filter table exists but iptables is not log.Warnf("Will use nftables to manipulate the filter table because iptables is not available: %v", err) fw = "nftables" - return r.acceptFilterRulesNftables() + return r.acceptFilterRulesNftables(r.filterTable) } - return r.acceptFilterRulesIptables(ipt) + if err := r.acceptFilterRulesIptables(ipt); err != nil { + log.Warnf("iptables failed (table may be incompatible), falling back to nftables: %v", err) + fw = "nftables" + return r.acceptFilterRulesNftables(r.filterTable) + } + return nil } func (r *router) acceptFilterRulesIptables(ipt *iptables.IPTables) error { @@ -968,7 +1109,7 @@ func (r *router) acceptFilterRulesIptables(ipt *iptables.IPTables) error { for _, rule := range r.getAcceptForwardRules() { if err := ipt.Insert("filter", chainNameForward, 1, rule...); err != nil { - merr = multierror.Append(err, fmt.Errorf("add iptables forward rule: %v", err)) + merr = multierror.Append(merr, fmt.Errorf("add iptables forward rule: %v", err)) } else { log.Debugf("added iptables forward rule: %v", rule) } @@ -976,7 +1117,7 @@ func (r *router) acceptFilterRulesIptables(ipt *iptables.IPTables) error { inputRule := r.getAcceptInputRule() if err := ipt.Insert("filter", chainNameInput, 1, inputRule...); err != nil { - merr = multierror.Append(err, fmt.Errorf("add iptables input rule: %v", err)) + merr = multierror.Append(merr, fmt.Errorf("add iptables input rule: %v", err)) } else { log.Debugf("added iptables input rule: %v", inputRule) } @@ -996,103 +1137,199 @@ func (r *router) getAcceptInputRule() []string { return []string{"-i", r.wgIface.Name(), "-j", "ACCEPT"} } -func (r *router) acceptFilterRulesNftables() error { +// 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()) - iifRule := &nftables.Rule{ - Table: r.filterTable, - Chain: &nftables.Chain{ - Name: chainNameForward, - Table: r.filterTable, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookForward, - Priority: nftables.ChainPriorityFilter, - }, + forwardChain := &nftables.Chain{ + Name: chainNameForward, + Table: table, + Type: nftables.ChainTypeFilter, + Hooknum: nftables.ChainHookForward, + Priority: nftables.ChainPriorityFilter, + } + r.insertForwardAcceptRules(forwardChain, intf) + + inputChain := &nftables.Chain{ + Name: chainNameInput, + Table: table, + Type: nftables.ChainTypeFilter, + Hooknum: nftables.ChainHookInput, + Priority: nftables.ChainPriorityFilter, + } + r.insertInputAcceptRule(inputChain, intf) + + return r.conn.Flush() +} + +// acceptExternalChainsRules adds accept rules to external chains (non-netbird, non-iptables tables). +// It dynamically finds chains at call time to handle chains that may have been created after startup. +func (r *router) acceptExternalChainsRules() error { + chains := r.findExternalChains() + if len(chains) == 0 { + return nil + } + + intf := ifname(r.wgIface.Name()) + for _, chain := range chains { + r.applyExternalChainAccept(chain, intf) + } + + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("flush external chain rules: %w", err) + } + return nil +} + +func (r *router) applyExternalChainAccept(chain *nftables.Chain, intf []byte) { + if chain.Hooknum == nil { + log.Debugf("skipping external chain %s/%s: hooknum is nil", chain.Table.Name, chain.Name) + return + } + + log.Debugf("adding accept rules to external %s chain: %s %s/%s", + hookName(chain.Hooknum), familyName(chain.Table.Family), chain.Table.Name, chain.Name) + + switch *chain.Hooknum { + case *nftables.ChainHookForward: + r.insertForwardAcceptRules(chain, intf) + case *nftables.ChainHookInput: + r.insertInputAcceptRule(chain, intf) + } +} + +func (r *router) insertForwardAcceptRules(chain *nftables.Chain, intf []byte) { + existing, err := r.existingNetbirdRulesInChain(chain) + if err != nil { + log.Warnf("skip forward accept rules in %s/%s: %v", chain.Table.Name, chain.Name, err) + return + } + r.insertForwardIifRule(chain, intf, existing) + r.insertForwardOifEstablishedRule(chain, intf, existing) +} + +func (r *router) insertForwardIifRule(chain *nftables.Chain, intf []byte, existing map[string]bool) { + if existing[userDataAcceptForwardRuleIif] { + return + } + r.conn.InsertRule(&nftables.Rule{ + Table: chain.Table, + Chain: chain, Exprs: []expr.Any{ &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: intf, - }, + &expr.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{ +func (r *router) insertForwardOifEstablishedRule(chain *nftables.Chain, intf []byte, existing map[string]bool) { + if existing[userDataAcceptForwardRuleOif] { + return + } + exprs := []expr.Any{ &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: intf, - }, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: intf}, } - - oifRule := &nftables.Rule{ - Table: r.filterTable, - Chain: &nftables.Chain{ - Name: chainNameForward, - Table: r.filterTable, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookForward, - Priority: nftables.ChainPriorityFilter, - }, - Exprs: append(oifExprs, getEstablishedExprs(2)...), + r.conn.InsertRule(&nftables.Rule{ + Table: chain.Table, + Chain: chain, + Exprs: append(exprs, getEstablishedExprs(2)...), UserData: []byte(userDataAcceptForwardRuleOif), - } - r.conn.InsertRule(oifRule) + }) +} - inputRule := &nftables.Rule{ - Table: r.filterTable, - Chain: &nftables.Chain{ - Name: chainNameInput, - Table: r.filterTable, - Type: nftables.ChainTypeFilter, - Hooknum: nftables.ChainHookInput, - Priority: nftables.ChainPriorityFilter, - }, +func (r *router) insertInputAcceptRule(chain *nftables.Chain, intf []byte) { + existing, err := r.existingNetbirdRulesInChain(chain) + if err != nil { + log.Warnf("skip input accept rule in %s/%s: %v", chain.Table.Name, chain.Name, err) + return + } + if existing[userDataAcceptInputRule] { + return + } + r.conn.InsertRule(&nftables.Rule{ + Table: chain.Table, + Chain: chain, Exprs: []expr.Any{ &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, - &expr.Cmp{ - Op: expr.CmpOpEq, - Register: 1, - Data: intf, - }, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: intf}, &expr.Counter{}, &expr.Verdict{Kind: expr.VerdictAccept}, }, UserData: []byte(userDataAcceptInputRule), - } - r.conn.InsertRule(inputRule) + }) +} - return nil +// existingNetbirdRulesInChain returns the set of netbird-owned UserData tags present in a chain; callers must bail on error since InsertRule is additive. +func (r *router) existingNetbirdRulesInChain(chain *nftables.Chain) (map[string]bool, error) { + rules, err := r.conn.GetRules(chain.Table, chain) + if err != nil { + return nil, fmt.Errorf("list rules: %w", err) + } + present := map[string]bool{} + for _, rule := range rules { + if !isNetbirdAcceptRuleTag(rule.UserData) { + continue + } + present[string(rule.UserData)] = true + } + return present, nil +} + +func isNetbirdAcceptRuleTag(userData []byte) bool { + switch string(userData) { + case userDataAcceptForwardRuleIif, + userDataAcceptForwardRuleOif, + userDataAcceptInputRule: + return true + } + return false } func (r *router) removeAcceptFilterRules() error { + var merr *multierror.Error + + if err := r.removeFilterTableRules(); err != nil { + merr = multierror.Append(merr, err) + } + + if err := r.removeExternalChainsRules(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove external chain rules: %w", err)) + } + + return nberrors.FormatErrorOrNil(merr) +} + +func (r *router) removeFilterTableRules() error { if r.filterTable == nil { return nil } - ipt, err := iptables.New() + 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) - return r.removeAcceptFilterRulesNftables() + log.Debugf("iptables not available, using nftables to remove filter rules: %v", err) + return r.removeAcceptRulesFromTable(r.filterTable) } - return r.removeAcceptFilterRulesIptables(ipt) + if err := r.removeAcceptFilterRulesIptables(ipt); err != nil { + log.Debugf("iptables removal failed (table may be incompatible), falling back to nftables: %v", err) + return r.removeAcceptRulesFromTable(r.filterTable) + } + return nil } -func (r *router) removeAcceptFilterRulesNftables() error { - chains, err := r.conn.ListChainsOfTableFamily(nftables.TableFamilyIPv4) +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 != r.filterTable.Name { + if chain.Table.Name != table.Name { continue } @@ -1100,27 +1337,108 @@ func (r *router) removeAcceptFilterRulesNftables() error { continue } - rules, err := r.conn.GetRules(r.filterTable, chain) + if err := r.removeAcceptRulesFromChain(table, chain); err != nil { + return err + } + } + + return r.conn.Flush() +} + +func (r *router) removeAcceptRulesFromChain(table *nftables.Table, chain *nftables.Chain) error { + rules, err := r.conn.GetRules(table, chain) + if err != nil { + return fmt.Errorf("get rules from %s/%s: %v", table.Name, chain.Name, err) + } + + for _, rule := range rules { + if bytes.Equal(rule.UserData, []byte(userDataAcceptForwardRuleIif)) || + bytes.Equal(rule.UserData, []byte(userDataAcceptForwardRuleOif)) || + bytes.Equal(rule.UserData, []byte(userDataAcceptInputRule)) { + if err := r.conn.DelRule(rule); err != nil { + return fmt.Errorf("delete rule from %s/%s: %v", table.Name, chain.Name, err) + } + } + } + return nil +} + +// removeExternalChainsRules removes our accept rules from all external chains. +// This is deterministic - it scans for chains at removal time rather than relying on saved state, +// ensuring cleanup works even after a crash or if chains changed. +func (r *router) removeExternalChainsRules() error { + chains := r.findExternalChains() + if len(chains) == 0 { + return nil + } + + for _, chain := range chains { + if err := r.removeAcceptRulesFromChain(chain.Table, chain); err != nil { + log.Warnf("remove rules from external chain %s/%s: %v", chain.Table.Name, chain.Name, err) + } + } + + return r.conn.Flush() +} + +// findExternalChains scans for chains from non-netbird tables that have FORWARD or INPUT hooks. +// This is used both at startup (to know where to add rules) and at cleanup (to ensure deterministic removal). +func (r *router) findExternalChains() []*nftables.Chain { + var chains []*nftables.Chain + + families := []nftables.TableFamily{r.af.tableFamily, nftables.TableFamilyINet} + + for _, family := range families { + allChains, err := r.conn.ListChainsOfTableFamily(family) if err != nil { - return fmt.Errorf("get rules: %v", err) + log.Debugf("list chains for family %d: %v", family, err) + continue } - 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: %v", err) - } + for _, chain := range allChains { + if r.isExternalChain(chain) { + chains = append(chains, chain) } } } - if err := r.conn.Flush(); err != nil { - return fmt.Errorf(flushError, err) + return chains +} + +func (r *router) isExternalChain(chain *nftables.Chain) bool { + if r.workTable != nil && chain.Table.Name == r.workTable.Name { + return false } - return nil + // Skip firewalld-owned chains. Firewalld creates its chains with the + // NFT_CHAIN_OWNER flag, so inserting rules into them returns EPERM. + // We delegate acceptance to firewalld by trusting the interface instead. + if chain.Table.Name == firewalldTableName { + return false + } + + // Skip iptables/ip6tables-managed tables (adding nft-native rules breaks iptables-save compat) + if (chain.Table.Family == nftables.TableFamilyIPv4 || chain.Table.Family == nftables.TableFamilyIPv6) && isIptablesTable(chain.Table.Name) { + return false + } + + if chain.Type != nftables.ChainTypeFilter { + return false + } + + if chain.Hooknum == nil { + return false + } + + return *chain.Hooknum == *nftables.ChainHookForward || *chain.Hooknum == *nftables.ChainHookInput +} + +func isIptablesTable(name string) bool { + switch name { + case tableNameFilter, tableNat, tableMangle, tableRaw, tableSecurity: + return true + } + return false } func (r *router) removeAcceptFilterRulesIptables(ipt *iptables.IPTables) error { @@ -1128,13 +1446,13 @@ func (r *router) removeAcceptFilterRulesIptables(ipt *iptables.IPTables) error { for _, rule := range r.getAcceptForwardRules() { if err := ipt.DeleteIfExists("filter", chainNameForward, rule...); err != nil { - merr = multierror.Append(err, fmt.Errorf("remove iptables forward rule: %v", err)) + 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(err, fmt.Errorf("remove iptables input rule: %v", err)) + merr = multierror.Append(merr, fmt.Errorf("remove iptables input rule: %v", err)) } return nberrors.FormatErrorOrNil(merr) @@ -1146,65 +1464,89 @@ func (r *router) RemoveNatRule(pair firewall.RouterPair) error { return fmt.Errorf(refreshRulesMapError, err) } + var merr *multierror.Error + if pair.Masquerade { if err := r.removeNatRule(pair); err != nil { - return fmt.Errorf("remove prerouting rule: %w", err) + merr = multierror.Append(merr, fmt.Errorf("remove prerouting rule: %w", err)) } if err := r.removeNatRule(firewall.GetInversePair(pair)); err != nil { - return fmt.Errorf("remove inverse prerouting rule: %w", err) + merr = multierror.Append(merr, fmt.Errorf("remove inverse prerouting rule: %w", err)) } } if err := r.removeLegacyRouteRule(pair); err != nil { - return fmt.Errorf("remove legacy routing rule: %w", err) + merr = multierror.Append(merr, fmt.Errorf("remove legacy routing rule: %w", err)) } + // Set counters are decremented in the sub-methods above before flush. If flush fails, + // counters will be off until the next successful removal or refresh cycle. if err := r.conn.Flush(); err != nil { - // TODO: rollback set counter - return fmt.Errorf("remove nat rules rule %s: %v", pair.Destination, err) + merr = multierror.Append(merr, fmt.Errorf("flush remove nat rules %s: %w", pair.Destination, err)) } - return nil + return nberrors.FormatErrorOrNil(merr) } func (r *router) removeNatRule(pair firewall.RouterPair) error { ruleKey := firewall.GenKey(firewall.PreroutingFormat, pair) - if rule, exists := r.rules[ruleKey]; exists { - if err := r.conn.DelRule(rule); err != nil { - return fmt.Errorf("remove prerouting rule %s -> %s: %v", pair.Source, pair.Destination, err) - } - - log.Debugf("removed prerouting rule %s -> %s", pair.Source, pair.Destination) - - delete(r.rules, ruleKey) - - if err := r.decrementSetCounter(rule); err != nil { - return fmt.Errorf("decrement set counter: %w", err) - } - } else { + rule, exists := r.rules[ruleKey] + if !exists { log.Debugf("prerouting rule %s not found", ruleKey) + return nil + } + + if rule.Handle == 0 { + log.Warnf("prerouting rule %s has no handle, removing stale entry", ruleKey) + if err := r.decrementSetCounter(rule); err != nil { + log.Warnf("decrement set counter for stale rule %s: %v", ruleKey, err) + } + delete(r.rules, ruleKey) + return nil + } + + if err := r.conn.DelRule(rule); err != nil { + return fmt.Errorf("remove prerouting rule %s -> %s: %w", pair.Source, pair.Destination, err) + } + + log.Debugf("removed prerouting rule %s -> %s", pair.Source, pair.Destination) + + delete(r.rules, ruleKey) + + if err := r.decrementSetCounter(rule); err != nil { + return fmt.Errorf("decrement set counter: %w", err) } return nil } -// refreshRulesMap refreshes the rule map with the latest rules. this is useful to avoid -// duplicates and to get missing attributes that we don't have when adding new rules +// refreshRulesMap rebuilds the rule map from the kernel. This removes stale entries +// (e.g. from failed flushes) and updates handles for all existing rules. func (r *router) refreshRulesMap() error { + var merr *multierror.Error + newRules := make(map[string]*nftables.Rule) for _, chain := range r.chains { rules, err := r.conn.GetRules(chain.Table, chain) if err != nil { - return fmt.Errorf(" unable to list rules: %v", err) + merr = multierror.Append(merr, fmt.Errorf("list rules for chain %s: %w", chain.Name, err)) + // preserve existing entries for this chain since we can't verify their state + for k, v := range r.rules { + if v.Chain != nil && v.Chain.Name == chain.Name { + newRules[k] = v + } + } + continue } for _, rule := range rules { if len(rule.UserData) > 0 { - r.rules[string(rule.UserData)] = rule + newRules[string(rule.UserData)] = rule } } } - return nil + r.rules = newRules + return nberrors.FormatErrorOrNil(merr) } func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { @@ -1217,7 +1559,7 @@ func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) { return rule, nil } - protoNum, err := protoToInt(rule.Protocol) + protoNum, err := r.af.protoNum(rule.Protocol) if err != nil { return nil, fmt.Errorf("convert protocol to number: %w", err) } @@ -1280,7 +1622,7 @@ func (r *router) addDnatRedirect(rule firewall.ForwardRule, protoNum uint8, rule dnatExprs = append(dnatExprs, &expr.NAT{ Type: expr.NATTypeDestNAT, - Family: uint32(nftables.TableFamilyIPv4), + Family: uint32(r.af.tableFamily), RegAddrMin: 1, RegProtoMin: regProtoMin, RegProtoMax: regProtoMax, @@ -1373,14 +1715,15 @@ func (r *router) addXTablesRedirect(dnatExprs []expr.Any, ruleKey string, rule f }, ) + natTable := &nftables.Table{ + Name: tableNat, + Family: r.af.tableFamily, + } dnatRule := &nftables.Rule{ - Table: &nftables.Table{ - Name: tableNat, - Family: nftables.TableFamilyIPv4, - }, + Table: natTable, Chain: &nftables.Chain{ Name: chainNameNatPrerouting, - Table: r.filterTable, + Table: natTable, Type: nftables.ChainTypeNAT, Hooknum: nftables.ChainHookPrerouting, Priority: nftables.ChainPriorityNATDest, @@ -1411,8 +1754,8 @@ func (r *router) addDnatMasq(rule firewall.ForwardRule, protoNum uint8, ruleKey &expr.Payload{ DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, - Offset: 16, - Len: 4, + Offset: r.af.dstAddrOffset, + Len: r.af.addrLen, }, &expr.Cmp{ Op: expr.CmpOpEq, @@ -1446,20 +1789,34 @@ func (r *router) DeleteDNATRule(rule firewall.Rule) error { } var merr *multierror.Error + var needsFlush bool + if dnatRule, exists := r.rules[ruleKey+dnatSuffix]; exists { - if err := r.conn.DelRule(dnatRule); err != nil { + if dnatRule.Handle == 0 { + log.Warnf("dnat rule %s has no handle, removing stale entry", ruleKey+dnatSuffix) + delete(r.rules, ruleKey+dnatSuffix) + } else if err := r.conn.DelRule(dnatRule); err != nil { merr = multierror.Append(merr, fmt.Errorf("delete dnat rule: %w", err)) + } else { + needsFlush = true } } if masqRule, exists := r.rules[ruleKey+snatSuffix]; exists { - if err := r.conn.DelRule(masqRule); err != nil { + if masqRule.Handle == 0 { + log.Warnf("snat rule %s has no handle, removing stale entry", ruleKey+snatSuffix) + delete(r.rules, ruleKey+snatSuffix) + } else if err := r.conn.DelRule(masqRule); err != nil { merr = multierror.Append(merr, fmt.Errorf("delete snat rule: %w", err)) + } else { + needsFlush = true } } - if err := r.conn.Flush(); err != nil { - merr = multierror.Append(merr, fmt.Errorf(flushError, err)) + if needsFlush { + if err := r.conn.Flush(); err != nil { + merr = multierror.Append(merr, fmt.Errorf(flushError, err)) + } } if merr == nil { @@ -1476,7 +1833,7 @@ func (r *router) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { return fmt.Errorf("get set %s: %w", set.HashedName(), err) } - elements := convertPrefixesToSet(prefixes) + elements := r.convertPrefixesToSet(prefixes) if err := r.conn.SetAddElements(nfset, elements); err != nil { return fmt.Errorf("add elements to set %s: %w", set.HashedName(), err) } @@ -1491,14 +1848,14 @@ func (r *router) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error { } // 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) +func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) if _, exists := r.rules[ruleID]; exists { return nil } - protoNum, err := protoToInt(protocol) + protoNum, err := r.af.protoNum(protocol) if err != nil { return fmt.Errorf("convert protocol to number: %w", err) } @@ -1525,11 +1882,15 @@ func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol &expr.Cmp{ Op: expr.CmpOpEq, Register: 3, - Data: binaryutil.BigEndian.PutUint16(sourcePort), + Data: binaryutil.BigEndian.PutUint16(originalPort), }, } - exprs = append(exprs, applyPrefix(netip.PrefixFrom(localAddr, 32), false)...) + bits := 32 + if localAddr.Is6() { + bits = 128 + } + exprs = append(exprs, r.applyPrefix(netip.PrefixFrom(localAddr, bits), false)...) exprs = append(exprs, &expr.Immediate{ @@ -1538,11 +1899,11 @@ func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol }, &expr.Immediate{ Register: 2, - Data: binaryutil.BigEndian.PutUint16(targetPort), + Data: binaryutil.BigEndian.PutUint16(translatedPort), }, &expr.NAT{ Type: expr.NATTypeDestNAT, - Family: uint32(nftables.TableFamilyIPv4), + Family: uint32(r.af.tableFamily), RegAddrMin: 1, RegProtoMin: 2, RegProtoMax: 0, @@ -1567,23 +1928,160 @@ func (r *router) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol } // RemoveInboundDNAT removes an inbound DNAT rule. -func (r *router) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, sourcePort, targetPort uint16) error { +func (r *router) RemoveInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { if err := r.refreshRulesMap(); err != nil { return fmt.Errorf(refreshRulesMapError, err) } - ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, sourcePort, targetPort) + ruleID := fmt.Sprintf("inbound-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) - if rule, exists := r.rules[ruleID]; exists { - if err := r.conn.DelRule(rule); err != nil { - return fmt.Errorf("delete inbound DNAT rule %s: %w", ruleID, err) - } - if err := r.conn.Flush(); err != nil { - return fmt.Errorf("flush delete inbound DNAT rule: %w", err) - } - delete(r.rules, ruleID) + rule, exists := r.rules[ruleID] + if !exists { + return nil } + if rule.Handle == 0 { + log.Warnf("inbound DNAT rule %s has no handle, removing stale entry", ruleID) + delete(r.rules, ruleID) + return nil + } + + if err := r.conn.DelRule(rule); err != nil { + return fmt.Errorf("delete inbound DNAT rule %s: %w", ruleID, err) + } + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("flush delete inbound DNAT rule: %w", err) + } + delete(r.rules, ruleID) + + return nil +} + +// ensureNATOutputChain lazily creates the OUTPUT NAT chain on first use. +func (r *router) ensureNATOutputChain() error { + if _, exists := r.chains[chainNameNATOutput]; exists { + return nil + } + + r.chains[chainNameNATOutput] = r.conn.AddChain(&nftables.Chain{ + Name: chainNameNATOutput, + Table: r.workTable, + Hooknum: nftables.ChainHookOutput, + Priority: nftables.ChainPriorityNATDest, + Type: nftables.ChainTypeNAT, + }) + + if err := r.conn.Flush(); err != nil { + delete(r.chains, chainNameNATOutput) + return fmt.Errorf("create NAT output chain: %w", err) + } + return nil +} + +// AddOutputDNAT adds an OUTPUT chain DNAT rule for locally-generated traffic. +func (r *router) AddOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) + + if _, exists := r.rules[ruleID]; exists { + return nil + } + + if err := r.ensureNATOutputChain(); err != nil { + return err + } + + protoNum, err := r.af.protoNum(protocol) + if err != nil { + return fmt.Errorf("convert protocol to number: %w", err) + } + + exprs := []expr.Any{ + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: []byte{protoNum}, + }, + &expr.Payload{ + DestRegister: 2, + Base: expr.PayloadBaseTransportHeader, + Offset: 2, + Len: 2, + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 2, + Data: binaryutil.BigEndian.PutUint16(originalPort), + }, + } + + bits := 32 + if localAddr.Is6() { + bits = 128 + } + exprs = append(exprs, r.applyPrefix(netip.PrefixFrom(localAddr, bits), false)...) + + exprs = append(exprs, + &expr.Immediate{ + Register: 1, + Data: localAddr.AsSlice(), + }, + &expr.Immediate{ + Register: 2, + Data: binaryutil.BigEndian.PutUint16(translatedPort), + }, + &expr.NAT{ + Type: expr.NATTypeDestNAT, + Family: uint32(r.af.tableFamily), + RegAddrMin: 1, + RegProtoMin: 2, + }, + ) + + dnatRule := &nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameNATOutput], + Exprs: exprs, + UserData: []byte(ruleID), + } + r.conn.AddRule(dnatRule) + + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("add output DNAT rule: %w", err) + } + + r.rules[ruleID] = dnatRule + + return nil +} + +// RemoveOutputDNAT removes an OUTPUT chain DNAT rule. +func (r *router) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + if err := r.refreshRulesMap(); err != nil { + return fmt.Errorf(refreshRulesMapError, err) + } + + ruleID := fmt.Sprintf("output-dnat-%s-%s-%d-%d", localAddr.String(), protocol, originalPort, translatedPort) + + rule, exists := r.rules[ruleID] + if !exists { + return nil + } + + if rule.Handle == 0 { + log.Warnf("output DNAT rule %s has no handle, removing stale entry", ruleID) + delete(r.rules, ruleID) + return nil + } + + if err := r.conn.DelRule(rule); err != nil { + return fmt.Errorf("delete output DNAT rule %s: %w", ruleID, err) + } + if err := r.conn.Flush(); err != nil { + return fmt.Errorf("flush delete output DNAT rule: %w", err) + } + delete(r.rules, ruleID) + return nil } @@ -1602,45 +2100,44 @@ func (r *router) applyNetwork( } if network.IsPrefix() { - return applyPrefix(network.Prefix, isSource), nil + return r.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) +func (r *router) applyPrefix(prefix netip.Prefix, isSource bool) []expr.Any { + // dst offset by default + offset := r.af.dstAddrOffset if isSource { // src offset - offset = 12 + offset = r.af.srcAddrOffset } ones := prefix.Bits() - // 0.0.0.0/0 doesn't need extra expressions + // unspecified address (/0) doesn't need extra expressions if ones == 0 { return nil } - mask := net.CIDRMask(ones, 32) + mask := net.CIDRMask(ones, r.af.totalBits) + xor := make([]byte, r.af.addrLen) return []expr.Any{ &expr.Payload{ DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: offset, - Len: 4, + Len: r.af.addrLen, }, - // netmask &expr.Bitwise{ DestRegister: 1, SourceRegister: 1, - Len: 4, + Len: r.af.addrLen, Mask: mask, - Xor: []byte{0, 0, 0, 0}, + Xor: xor, }, - // net address &expr.Cmp{ Op: expr.CmpOpEq, Register: 1, @@ -1723,13 +2220,12 @@ func getCtNewExprs() []expr.Any { } } -func getIpSetExprs(ref refcounter.Ref[*nftables.Set], isSource bool) ([]expr.Any, error) { - - // dst offset - offset := uint32(16) +func (r *router) getIpSetExprs(ref refcounter.Ref[*nftables.Set], isSource bool) ([]expr.Any, error) { + // dst offset by default + offset := r.af.dstAddrOffset if isSource { // src offset - offset = 12 + offset = r.af.srcAddrOffset } return []expr.Any{ @@ -1737,7 +2233,7 @@ func getIpSetExprs(ref refcounter.Ref[*nftables.Set], isSource bool) ([]expr.Any DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: offset, - Len: 4, + Len: r.af.addrLen, }, &expr.Lookup{ SourceRegister: 1, diff --git a/client/firewall/nftables/router_linux_test.go b/client/firewall/nftables/router_linux_test.go index 3531b014b..2fc664d51 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 @@ -18,6 +18,7 @@ import ( firewall "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/firewall/test" "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/internal/acl/id" ) const ( @@ -89,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 := &router{af: afIPv4} + sourceExp := testRouter.applyPrefix(testCase.InputPair.Source.Prefix, true) + destExp := testRouter.applyPrefix(testCase.InputPair.Destination.Prefix, false) // Combine all expressions in the correct order // nolint:gocritic @@ -507,6 +509,136 @@ func TestNftablesCreateIpSet(t *testing.T) { } } +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, err := newRouter(workTable, ifaceMock, iface.DefaultMTU) + require.NoError(t, err, "Failed to create router") + require.NoError(t, r.init(workTable)) + defer func() { + require.NoError(t, r.Reset(), "Failed to reset router") + }() + + 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() @@ -626,7 +758,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: @@ -719,3 +851,189 @@ func deleteWorkTable() { } } } + +func TestRouter_RefreshRulesMap_RemovesStaleEntries(t *testing.T) { + if check() != NFTABLES { + t.Skip("nftables not supported on this system") + } + + workTable, err := createWorkTable() + require.NoError(t, err) + defer deleteWorkTable() + + r, err := newRouter(workTable, ifaceMock, iface.DefaultMTU) + require.NoError(t, err) + require.NoError(t, r.init(workTable)) + defer func() { require.NoError(t, r.Reset()) }() + + // Add a real rule to the kernel + ruleKey, err := r.AddRouteFiltering( + nil, + []netip.Prefix{netip.MustParsePrefix("192.168.1.0/24")}, + firewall.Network{Prefix: netip.MustParsePrefix("10.0.0.0/24")}, + firewall.ProtocolTCP, + nil, + &firewall.Port{Values: []uint16{80}}, + firewall.ActionAccept, + ) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, r.DeleteRouteRule(ruleKey)) + }) + + // Inject a stale entry with Handle=0 (simulates store-before-flush failure) + staleKey := "stale-rule-that-does-not-exist" + r.rules[staleKey] = &nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameRoutingFw], + Handle: 0, + UserData: []byte(staleKey), + } + + require.Contains(t, r.rules, staleKey, "stale entry should be in map before refresh") + + err = r.refreshRulesMap() + require.NoError(t, err) + + assert.NotContains(t, r.rules, staleKey, "stale entry should be removed after refresh") + + realRule, ok := r.rules[ruleKey.ID()] + assert.True(t, ok, "real rule should still exist after refresh") + assert.NotZero(t, realRule.Handle, "real rule should have a valid handle") +} + +func TestRouter_DeleteRouteRule_StaleHandle(t *testing.T) { + if check() != NFTABLES { + t.Skip("nftables not supported on this system") + } + + workTable, err := createWorkTable() + require.NoError(t, err) + defer deleteWorkTable() + + r, err := newRouter(workTable, ifaceMock, iface.DefaultMTU) + require.NoError(t, err) + require.NoError(t, r.init(workTable)) + defer func() { require.NoError(t, r.Reset()) }() + + // Inject a stale entry with Handle=0 + staleKey := "stale-route-rule" + r.rules[staleKey] = &nftables.Rule{ + Table: r.workTable, + Chain: r.chains[chainNameRoutingFw], + Handle: 0, + UserData: []byte(staleKey), + } + + // DeleteRouteRule should not return an error for stale handles + err = r.DeleteRouteRule(id.RuleID(staleKey)) + assert.NoError(t, err, "deleting a stale rule should not error") + assert.NotContains(t, r.rules, staleKey, "stale entry should be cleaned up") +} + +func TestRouter_AddNatRule_WithStaleEntry(t *testing.T) { + if check() != NFTABLES { + t.Skip("nftables not supported on this system") + } + + manager, err := Create(ifaceMock, iface.DefaultMTU) + require.NoError(t, err) + require.NoError(t, manager.Init(nil)) + t.Cleanup(func() { + require.NoError(t, manager.Close(nil)) + }) + + pair := firewall.RouterPair{ + ID: "staletest", + Source: firewall.Network{Prefix: netip.MustParsePrefix("100.100.100.1/32")}, + Destination: firewall.Network{Prefix: netip.MustParsePrefix("100.100.200.0/24")}, + Masquerade: true, + } + + rtr := manager.router + + // First add succeeds + err = rtr.AddNatRule(pair) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, rtr.RemoveNatRule(pair)) + }) + + // Corrupt the handle to simulate stale state + natRuleKey := firewall.GenKey(firewall.PreroutingFormat, pair) + if rule, exists := rtr.rules[natRuleKey]; exists { + rule.Handle = 0 + } + inverseKey := firewall.GenKey(firewall.PreroutingFormat, firewall.GetInversePair(pair)) + if rule, exists := rtr.rules[inverseKey]; exists { + rule.Handle = 0 + } + + // Adding the same rule again should succeed despite stale handles + err = rtr.AddNatRule(pair) + assert.NoError(t, err, "AddNatRule should succeed even with stale entries") + + // Verify rules exist in kernel + rules, err := rtr.conn.GetRules(rtr.workTable, rtr.chains[chainNameManglePrerouting]) + require.NoError(t, err) + + found := 0 + for _, rule := range rules { + if len(rule.UserData) > 0 && string(rule.UserData) == natRuleKey { + found++ + } + } + assert.Equal(t, 1, found, "NAT rule should exist in kernel") +} + +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 := &router{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/state_linux.go b/client/firewall/nftables/state_linux.go index 48b7b3741..462ad2556 100644 --- a/client/firewall/nftables/state_linux.go +++ b/client/firewall/nftables/state_linux.go @@ -8,10 +8,9 @@ import ( ) type InterfaceState struct { - NameStr string `json:"name"` - WGAddress wgaddr.Address `json:"wg_address"` - UserspaceBind bool `json:"userspace_bind"` - MTU uint16 `json:"mtu"` + NameStr string `json:"name"` + WGAddress wgaddr.Address `json:"wg_address"` + MTU uint16 `json:"mtu"` } func (i *InterfaceState) Name() string { @@ -22,10 +21,6 @@ func (i *InterfaceState) Address() wgaddr.Address { return i.WGAddress } -func (i *InterfaceState) IsUserspaceBind() bool { - return i.UserspaceBind -} - type ShutdownState struct { InterfaceState *InterfaceState `json:"interface_state,omitempty"` } diff --git a/client/firewall/uspfilter/allow_netbird.go b/client/firewall/uspfilter/allow_netbird.go index 22e6fca1f..b120cdf12 100644 --- a/client/firewall/uspfilter/allow_netbird.go +++ b/client/firewall/uspfilter/allow_netbird.go @@ -3,12 +3,9 @@ package uspfilter import ( - "context" - "net/netip" - "time" - log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/client/firewall/firewalld" "github.com/netbirdio/netbird/client/internal/statemanager" ) @@ -17,37 +14,14 @@ func (m *Manager) Close(stateManager *statemanager.Manager) error { m.mutex.Lock() defer m.mutex.Unlock() - m.outgoingRules = make(map[netip.Addr]RuleSet) - m.incomingDenyRules = make(map[netip.Addr]RuleSet) - m.incomingRules = make(map[netip.Addr]RuleSet) - - if m.udpTracker != nil { - m.udpTracker.Close() - } - - if m.icmpTracker != nil { - m.icmpTracker.Close() - } - - if m.tcpTracker != nil { - m.tcpTracker.Close() - } - - if fwder := m.forwarder.Load(); fwder != nil { - fwder.Stop() - } - - if m.logger != nil { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - if err := m.logger.Stop(ctx); err != nil { - log.Errorf("failed to shutdown logger: %v", err) - } - } + m.resetState() if m.nativeFirewall != nil { return m.nativeFirewall.Close(stateManager) } + if err := firewalld.UntrustInterface(m.wgIface.Name()); err != nil { + log.Warnf("failed to untrust interface in firewalld: %v", err) + } return nil } @@ -56,5 +30,8 @@ func (m *Manager) AllowNetbird() error { if m.nativeFirewall != nil { return m.nativeFirewall.AllowNetbird() } + if err := firewalld.TrustInterface(m.wgIface.Name()); err != nil { + log.Warnf("failed to trust interface in firewalld: %v", err) + } return nil } diff --git a/client/firewall/uspfilter/allow_netbird_windows.go b/client/firewall/uspfilter/allow_netbird_windows.go index 8a56b0862..10a2b9116 100644 --- a/client/firewall/uspfilter/allow_netbird_windows.go +++ b/client/firewall/uspfilter/allow_netbird_windows.go @@ -1,15 +1,14 @@ package uspfilter import ( - "context" "fmt" - "net/netip" "os/exec" "syscall" - "time" + "github.com/hashicorp/go-multierror" log "github.com/sirupsen/logrus" + nberrors "github.com/netbirdio/netbird/client/errors" "github.com/netbirdio/netbird/client/internal/statemanager" ) @@ -26,47 +25,26 @@ func (m *Manager) Close(*statemanager.Manager) error { m.mutex.Lock() defer m.mutex.Unlock() - m.outgoingRules = make(map[netip.Addr]RuleSet) - m.incomingDenyRules = make(map[netip.Addr]RuleSet) - m.incomingRules = make(map[netip.Addr]RuleSet) - - if m.udpTracker != nil { - m.udpTracker.Close() - } - - if m.icmpTracker != nil { - m.icmpTracker.Close() - } - - if m.tcpTracker != nil { - m.tcpTracker.Close() - } - - if fwder := m.forwarder.Load(); fwder != nil { - fwder.Stop() - } - - if m.logger != nil { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - if err := m.logger.Stop(ctx); err != nil { - log.Errorf("failed to shutdown logger: %v", err) - } - } + m.resetState() if !isWindowsFirewallReachable() { return nil } - if !isFirewallRuleActive(firewallRuleName) { - return nil + var merr *multierror.Error + if isFirewallRuleActive(firewallRuleName) { + if err := manageFirewallRule(firewallRuleName, deleteRule); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove windows firewall rule: %w", err)) + } } - if err := manageFirewallRule(firewallRuleName, deleteRule); err != nil { - return fmt.Errorf("couldn't remove windows firewall: %w", err) + if isFirewallRuleActive(firewallRuleName + "-v6") { + if err := manageFirewallRule(firewallRuleName+"-v6", deleteRule); err != nil { + merr = multierror.Append(merr, fmt.Errorf("remove windows v6 firewall rule: %w", err)) + } } - return nil + return nberrors.FormatErrorOrNil(merr) } // AllowNetbird allows netbird interface traffic @@ -75,17 +53,33 @@ func (m *Manager) AllowNetbird() error { return nil } - if isFirewallRuleActive(firewallRuleName) { - return nil + if !isFirewallRuleActive(firewallRuleName) { + if err := manageFirewallRule(firewallRuleName, + addRule, + "dir=in", + "enable=yes", + "action=allow", + "profile=any", + "localip="+m.wgIface.Address().IP.String(), + ); err != nil { + return err + } } - return manageFirewallRule(firewallRuleName, - addRule, - "dir=in", - "enable=yes", - "action=allow", - "profile=any", - "localip="+m.wgIface.Address().IP.String(), - ) + + if v6 := m.wgIface.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 } func manageFirewallRule(ruleName string, action action, extraArgs ...string) error { diff --git a/client/firewall/uspfilter/common/hooks.go b/client/firewall/uspfilter/common/hooks.go new file mode 100644 index 000000000..dadd800dd --- /dev/null +++ b/client/firewall/uspfilter/common/hooks.go @@ -0,0 +1,37 @@ +package common + +import ( + "net/netip" + "sync/atomic" +) + +// PacketHook stores a registered hook for a specific IP:port. +type PacketHook struct { + IP netip.Addr + Port uint16 + Fn func([]byte) bool +} + +// HookMatches checks if a packet's destination matches the hook and invokes it. +func HookMatches(h *PacketHook, dstIP netip.Addr, dport uint16, packetData []byte) bool { + if h == nil { + return false + } + if h.IP == dstIP && h.Port == dport { + return h.Fn(packetData) + } + return false +} + +// SetHook atomically stores a hook, handling nil removal. +func SetHook(ptr *atomic.Pointer[PacketHook], ip netip.Addr, dPort uint16, hook func([]byte) bool) { + if hook == nil { + ptr.Store(nil) + return + } + ptr.Store(&PacketHook{ + IP: ip, + Port: dPort, + Fn: hook, + }) +} diff --git a/client/firewall/uspfilter/common/iface.go b/client/firewall/uspfilter/common/iface.go index 7296953db..9c06eb3f7 100644 --- a/client/firewall/uspfilter/common/iface.go +++ b/client/firewall/uspfilter/common/iface.go @@ -9,6 +9,7 @@ import ( // IFaceMapper defines subset methods of interface required for manager type IFaceMapper interface { + Name() string SetFilter(device.PacketFilter) error Address() wgaddr.Address GetWGDevice() *wgdevice.Device 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 8d64412e0..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 @@ -115,6 +136,17 @@ func (t *TCPConnTrack) IsTombstone() bool { return t.tombstone.Load() } +// IsSupersededBy returns true if this connection should be replaced by a new one +// carrying the given flags. Tombstoned connections are always superseded; TIME-WAIT +// connections are superseded by a pure SYN (a new connection attempt for the same +// four-tuple, as contemplated by RFC 1122 §4.2.2.13 and RFC 6191). +func (t *TCPConnTrack) IsSupersededBy(flags uint8) bool { + if t.tombstone.Load() { + return true + } + return flags&TCPSyn != 0 && flags&TCPAck == 0 && TCPState(t.state.Load()) == TCPStateTimeWait +} + // SetTombstone safely marks the connection for deletion func (t *TCPConnTrack) SetTombstone() { t.tombstone.Store(true) @@ -122,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 @@ -144,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) @@ -169,7 +209,7 @@ func (t *TCPTracker) updateIfExists(srcIP, dstIP netip.Addr, srcPort, dstPort ui conn, exists := t.connections[key] t.mutex.RUnlock() - if exists { + if exists && !conn.IsSupersededBy(flags) { t.updateState(key, conn, flags, direction, size) return key, uint16(conn.DNATOrigPort.Load()), true } @@ -198,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{ @@ -214,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{ @@ -241,16 +332,23 @@ func (t *TCPTracker) IsValidInbound(srcIP, dstIP netip.Addr, srcPort, dstPort ui conn, exists := t.connections[key] t.mutex.RUnlock() - if !exists || conn.IsTombstone() { + if !exists || conn.IsSupersededBy(flags) { + return false + } + + // 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 } @@ -259,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 @@ -438,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..a6d3d0c7b --- /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/tcp_test.go b/client/firewall/uspfilter/conntrack/tcp_test.go index bb440f70a..f46c5c1ab 100644 --- a/client/firewall/uspfilter/conntrack/tcp_test.go +++ b/client/firewall/uspfilter/conntrack/tcp_test.go @@ -485,6 +485,261 @@ func TestTCPAbnormalSequences(t *testing.T) { }) } +// TestTCPPortReuseTombstone verifies that a new connection on a port with a +// tombstoned (closed) conntrack entry is properly tracked. Without the fix, +// updateIfExists treats tombstoned entries as live, causing track() to skip +// creating a new connection. The subsequent SYN-ACK then fails IsValidInbound +// because the entry is tombstoned, and the response packet gets dropped by ACL. +func TestTCPPortReuseTombstone(t *testing.T) { + srcIP := netip.MustParseAddr("100.64.0.1") + dstIP := netip.MustParseAddr("100.64.0.2") + srcPort := uint16(12345) + dstPort := uint16(80) + + t.Run("Outbound port reuse after graceful close", func(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + // Establish and gracefully close a connection (server-initiated close) + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + + // Server sends FIN + valid := tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0) + require.True(t, valid) + + // Client sends FIN-ACK + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) + + // Server sends final ACK + valid = tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0) + require.True(t, valid) + + // Connection should be tombstoned + conn := tracker.connections[key] + require.NotNil(t, conn, "old connection should still be in map") + require.True(t, conn.IsTombstone(), "old connection should be tombstoned") + + // Now reuse the same port for a new connection + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPSyn, 100) + + // The old tombstoned entry should be replaced with a new one + newConn := tracker.connections[key] + require.NotNil(t, newConn, "new connection should exist") + require.False(t, newConn.IsTombstone(), "new connection should not be tombstoned") + require.Equal(t, TCPStateSynSent, newConn.GetState()) + + // SYN-ACK for the new connection should be valid + valid = tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPSyn|TCPAck, 100) + require.True(t, valid, "SYN-ACK for new connection on reused port should be accepted") + require.Equal(t, TCPStateEstablished, newConn.GetState()) + + // Data transfer should work + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPAck, 100) + valid = tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPPush|TCPAck, 500) + require.True(t, valid, "data should be allowed on new connection") + }) + + t.Run("Outbound port reuse after RST", func(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + // Establish and RST a connection + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + valid := tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPRst|TCPAck, 0) + require.True(t, valid) + + conn := tracker.connections[key] + require.True(t, conn.IsTombstone(), "RST connection should be tombstoned") + + // Reuse the same port + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPSyn, 100) + + newConn := tracker.connections[key] + require.NotNil(t, newConn) + require.False(t, newConn.IsTombstone()) + require.Equal(t, TCPStateSynSent, newConn.GetState()) + + valid = tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPSyn|TCPAck, 100) + require.True(t, valid, "SYN-ACK should be accepted after RST tombstone") + }) + + t.Run("Inbound port reuse after close", func(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + clientIP := srcIP + serverIP := dstIP + clientPort := srcPort + serverPort := dstPort + key := ConnKey{SrcIP: clientIP, DstIP: serverIP, SrcPort: clientPort, DstPort: serverPort} + + // Inbound connection: client SYN → server SYN-ACK → client ACK + tracker.TrackInbound(clientIP, serverIP, clientPort, serverPort, TCPSyn, nil, 100, 0) + tracker.TrackOutbound(serverIP, clientIP, serverPort, clientPort, TCPSyn|TCPAck, 100) + tracker.TrackInbound(clientIP, serverIP, clientPort, serverPort, TCPAck, nil, 100, 0) + + conn := tracker.connections[key] + require.Equal(t, TCPStateEstablished, conn.GetState()) + + // Server-initiated close to reach Closed/tombstoned: + // Server FIN (opposite dir) → CloseWait + tracker.TrackOutbound(serverIP, clientIP, serverPort, clientPort, TCPFin|TCPAck, 100) + require.Equal(t, TCPStateCloseWait, conn.GetState()) + // Client FIN-ACK (same dir as conn) → LastAck + tracker.TrackInbound(clientIP, serverIP, clientPort, serverPort, TCPFin|TCPAck, nil, 100, 0) + require.Equal(t, TCPStateLastAck, conn.GetState()) + // Server final ACK (opposite dir) → Closed → tombstoned + tracker.TrackOutbound(serverIP, clientIP, serverPort, clientPort, TCPAck, 100) + + require.True(t, conn.IsTombstone()) + + // New inbound connection on same ports + tracker.TrackInbound(clientIP, serverIP, clientPort, serverPort, TCPSyn, nil, 100, 0) + + newConn := tracker.connections[key] + require.NotNil(t, newConn) + require.False(t, newConn.IsTombstone()) + require.Equal(t, TCPStateSynReceived, newConn.GetState()) + + // Complete handshake: server SYN-ACK, then client ACK + tracker.TrackOutbound(serverIP, clientIP, serverPort, clientPort, TCPSyn|TCPAck, 100) + tracker.TrackInbound(clientIP, serverIP, clientPort, serverPort, TCPAck, nil, 100, 0) + require.Equal(t, TCPStateEstablished, newConn.GetState()) + }) + + t.Run("Late ACK on tombstoned connection is harmless", func(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + // Establish and close via passive close (server-initiated FIN → Closed → tombstoned) + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0) // CloseWait + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) // LastAck + tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0) // Closed + + conn := tracker.connections[key] + require.True(t, conn.IsTombstone()) + + // Late ACK should be rejected (tombstoned) + valid := tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0) + require.False(t, valid, "late ACK on tombstoned connection should be rejected") + + // Late outbound ACK should not create a new connection (not a SYN) + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPAck, 0) + require.True(t, tracker.connections[key].IsTombstone(), "late outbound ACK should not replace tombstoned entry") + }) +} + +func TestTCPPortReuseTimeWait(t *testing.T) { + srcIP := netip.MustParseAddr("100.64.0.1") + dstIP := netip.MustParseAddr("100.64.0.2") + srcPort := uint16(12345) + dstPort := uint16(80) + + t.Run("Outbound port reuse during TIME-WAIT (active close)", func(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + // Establish connection + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + + // Active close: client (outbound initiator) sends FIN first + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) + conn := tracker.connections[key] + require.Equal(t, TCPStateFinWait1, conn.GetState()) + + // Server ACKs the FIN + valid := tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0) + require.True(t, valid) + require.Equal(t, TCPStateFinWait2, conn.GetState()) + + // Server sends its own FIN + valid = tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0) + require.True(t, valid) + require.Equal(t, TCPStateTimeWait, conn.GetState()) + + // Client sends final ACK (TIME-WAIT stays, not tombstoned) + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPAck, 0) + require.False(t, conn.IsTombstone(), "TIME-WAIT should not be tombstoned") + + // New outbound SYN on the same port (port reuse during TIME-WAIT) + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPSyn, 100) + + // Per RFC 1122/6191, new SYN during TIME-WAIT should start a new connection + newConn := tracker.connections[key] + require.NotNil(t, newConn, "new connection should exist") + require.False(t, newConn.IsTombstone(), "new connection should not be tombstoned") + require.Equal(t, TCPStateSynSent, newConn.GetState(), "new connection should be in SYN-SENT") + + // SYN-ACK for new connection should be valid + valid = tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPSyn|TCPAck, 100) + require.True(t, valid, "SYN-ACK for new connection should be accepted") + require.Equal(t, TCPStateEstablished, newConn.GetState()) + }) + + t.Run("Inbound SYN during TIME-WAIT falls through to normal tracking", func(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + // Establish outbound connection and close via active close → TIME-WAIT + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) + tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0) + tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0) + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPAck, 0) + + conn := tracker.connections[key] + require.Equal(t, TCPStateTimeWait, conn.GetState()) + + // Inbound SYN on same ports during TIME-WAIT: IsValidInbound returns false + // so the filter falls through to ACL check + TrackInbound (which creates + // a new connection via track() → updateIfExists skips TIME-WAIT for SYN) + valid := tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPSyn, 0) + require.False(t, valid, "inbound SYN during TIME-WAIT should fail conntrack validation") + + // Simulate what the filter does next: TrackInbound via the normal path + tracker.TrackInbound(dstIP, srcIP, dstPort, srcPort, TCPSyn, nil, 100, 0) + + // The new inbound connection uses the inverted key (dst→src becomes src→dst in track) + invertedKey := ConnKey{SrcIP: dstIP, DstIP: srcIP, SrcPort: dstPort, DstPort: srcPort} + newConn := tracker.connections[invertedKey] + require.NotNil(t, newConn, "new inbound connection should be tracked") + require.Equal(t, TCPStateSynReceived, newConn.GetState()) + require.False(t, newConn.IsTombstone()) + }) + + t.Run("Late retransmit during TIME-WAIT still allowed", func(t *testing.T) { + tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger) + defer tracker.Close() + + key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort} + + // Establish and active close → TIME-WAIT + establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort) + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) + tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0) + tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0) + tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPAck, 0) + + conn := tracker.connections[key] + require.Equal(t, TCPStateTimeWait, conn.GetState()) + + // Late ACK retransmits during TIME-WAIT should still be accepted + valid := tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0) + require.True(t, valid, "retransmitted ACK during TIME-WAIT should be accepted") + }) +} + func TestTCPTimeoutHandling(t *testing.T) { // Create tracker with a very short timeout for testing shortTimeout := 100 * time.Millisecond diff --git a/client/firewall/uspfilter/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 990630ee4..7376e59ca 100644 --- a/client/firewall/uspfilter/filter.go +++ b/client/firewall/uspfilter/filter.go @@ -1,6 +1,7 @@ package uspfilter import ( + "context" "encoding/binary" "errors" "fmt" @@ -12,29 +13,41 @@ import ( "strings" "sync" "sync/atomic" + "time" "github.com/google/gopacket" "github.com/google/gopacket/layers" "github.com/google/uuid" + "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" "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/netstack" + nbid "github.com/netbirdio/netbird/client/internal/acl/id" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" "github.com/netbirdio/netbird/client/internal/statemanager" ) const ( - layerTypeAll = 0 + 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 +type serviceKey struct { + protocol gopacket.LayerType + port uint16 +} + const ( // EnvDisableConntrack disables the stateful filter, replies to outbound traffic won't be allowed. EnvDisableConntrack = "NB_DISABLE_CONNTRACK" @@ -59,12 +72,6 @@ const ( var errNatNotSupported = errors.New("nat not supported with userspace firewall") -// serviceKey represents a protocol/port combination for netstack service registry -type serviceKey struct { - protocol gopacket.LayerType - port uint16 -} - // RuleSet is a set of rules grouped by a string key type RuleSet map[string]PeerRule @@ -89,6 +96,7 @@ type Manager struct { incomingDenyRules map[netip.Addr]RuleSet incomingRules map[netip.Addr]RuleSet routeRules RouteRules + routeRulesMap map[nbid.RuleID]*RouteRule decoders sync.Pool wgIface common.IFaceMapper nativeFirewall firewall.Manager @@ -110,14 +118,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 @@ -132,9 +142,14 @@ 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] + tcpHookOut atomic.Pointer[common.PacketHook] } // decoder for packages @@ -147,11 +162,63 @@ type decoder struct { icmp4 layers.ICMPv4 icmp6 layers.ICMPv6 decoded []gopacket.LayerType - parser *gopacket.DecodingLayerParser + parser4 *gopacket.DecodingLayerParser + parser6 *gopacket.DecodingLayerParser dnatOrigPort uint16 } +// 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) + } +} + +// 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 + } + + // 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 + } + + if err := l4.DecodeFromBytes(payload, gopacket.NilDecodeFeedback); err != nil { + return false + } + d.decoded = append(d.decoded, layerType) + return true +} + // 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) @@ -209,11 +276,17 @@ 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 }, }, @@ -229,6 +302,7 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe flowLogger: flowLogger, netstack: netstack.IsEnabled(), localForwarding: enableLocalForwarding, + routeRulesMap: make(map[nbid.RuleID]*RouteRule), dnatMappings: make(map[netip.Addr]netip.Addr), portDNATRules: []portDNATRule{}, netstackServices: make(map[serviceKey]struct{}), @@ -238,11 +312,18 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe if !disableMSSClamping { m.mssClampEnabled = true - m.mssClampValue = mtu - ipTCPHeaderMinSize + if mtu > ipv4TCPHeaderMinSize { + m.mssClampValueIPv4 = mtu - ipv4TCPHeaderMinSize + } + if mtu > ipv6TCPHeaderMinSize { + m.mssClampValueIPv6 = mtu - ipv6TCPHeaderMinSize + } } if err := m.localipmanager.UpdateLocalIPs(iface); err != nil { return nil, fmt.Errorf("update local IPs: %w", err) } + m.fragments = newFragmentTracker(m.logger) + if disableConntrack { log.Info("conntrack is disabled") } else { @@ -256,21 +337,31 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe } } if err := iface.SetFilter(m); err != nil { + m.fragments.Close() return nil, fmt.Errorf("set filter: %w", err) } return m, nil } -func (m *Manager) blockInvalidRouted(iface common.IFaceMapper) (firewall.Rule, error) { - wgPrefix, err := netip.ParsePrefix(iface.Address().Network.String()) - if err != nil { - return nil, fmt.Errorf("parse wireguard network: %w", err) - } +// blockInvalidRouted installs drop rules for traffic to the wg overlay that +// arrives via the routing path. v4 and v6 are independent: a v6 install +// failure leaves v4 protection in place (and vice versa) so the returned +// slice always contains whatever was successfully installed, even on error. +// Callers must persist the slice so DisableRouting can clean partial state. +func (m *Manager) blockInvalidRouted(iface common.IFaceMapper) ([]firewall.Rule, error) { + 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.addRouteFiltering( nil, - []netip.Prefix{netip.PrefixFrom(netip.IPv4Unspecified(), 0)}, + sources, firewall.Network{Prefix: wgPrefix}, firewall.ProtocolALL, nil, @@ -278,12 +369,30 @@ 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.addRouteFiltering( + 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 { @@ -344,6 +453,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 { @@ -365,6 +487,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 @@ -439,19 +566,7 @@ func (m *Manager) AddPeerFiltering( r.sPort = sPort r.dPort = dPort - switch proto { - case firewall.ProtocolTCP: - r.protoLayer = layers.LayerTypeTCP - case firewall.ProtocolUDP: - r.protoLayer = layers.LayerTypeUDP - case firewall.ProtocolICMP: - r.protoLayer = layers.LayerTypeICMPv4 - if r.ipLayer == layers.LayerTypeIPv6 { - r.protoLayer = layers.LayerTypeICMPv6 - } - case firewall.ProtocolALL: - r.protoLayer = layerTypeAll - } + r.protoLayer = protoToLayer(proto, r.ipLayer) m.mutex.Lock() var targetMap map[netip.Addr]RuleSet @@ -495,17 +610,22 @@ func (m *Manager) addRouteFiltering( return m.nativeFirewall.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action) } - ruleID := uuid.New().String() + ruleKey := nbid.GenerateRouteRuleKey(sources, destination, proto, sPort, dPort, action) + + if existingRule, ok := m.routeRulesMap[ruleKey]; ok { + return existingRule, nil + } + rule := RouteRule{ // TODO: consolidate these IDs - id: ruleID, - mgmtId: id, - sources: sources, - dstSet: destination.Set, - proto: proto, - srcPort: sPort, - dstPort: dPort, - action: action, + id: string(ruleKey), + mgmtId: id, + sources: sources, + dstSet: destination.Set, + protoLayer: protoToLayer(proto, ipLayerFromPrefix(destination.Prefix)), + srcPort: sPort, + dstPort: dPort, + action: action, } if destination.IsPrefix() { rule.destinations = []netip.Prefix{destination.Prefix} @@ -513,6 +633,7 @@ func (m *Manager) addRouteFiltering( m.routeRules = append(m.routeRules, &rule) m.routeRules.Sort() + m.routeRulesMap[ruleKey] = &rule return &rule, nil } @@ -529,15 +650,20 @@ func (m *Manager) deleteRouteRule(rule firewall.Rule) error { return m.nativeFirewall.DeleteRouteRule(rule) } - ruleID := rule.ID() + ruleKey := nbid.RuleID(rule.ID()) + if _, ok := m.routeRulesMap[ruleKey]; !ok { + return fmt.Errorf("route rule not found: %s", ruleKey) + } + idx := slices.IndexFunc(m.routeRules, func(r *RouteRule) bool { - return r.id == ruleID + return r.id == string(ruleKey) }) if idx < 0 { - return fmt.Errorf("route rule not found: %s", ruleID) + return fmt.Errorf("route rule not found in slice: %s", ruleKey) } m.routeRules = slices.Delete(m.routeRules, idx, idx+1) + delete(m.routeRulesMap, ruleKey) return nil } @@ -584,6 +710,55 @@ func (m *Manager) SetLegacyManagement(isLegacy bool) error { // Flush doesn't need to be implemented for this manager func (m *Manager) Flush() error { return nil } +// resetState clears all firewall rules and closes connection trackers. +// Must be called with m.mutex held. +func (m *Manager) resetState() { + clear(m.outgoingRules) + clear(m.incomingDenyRules) + clear(m.incomingRules) + clear(m.routeRulesMap) + m.routeRules = m.routeRules[:0] + m.udpHookOut.Store(nil) + m.tcpHookOut.Store(nil) + + if m.udpTracker != nil { + m.udpTracker.Close() + } + + if m.icmpTracker != nil { + m.icmpTracker.Close() + } + + if m.tcpTracker != nil { + m.tcpTracker.Close() + } + + if m.fragments != nil { + m.fragments.Close() + } + + if fwder := m.forwarder.Load(); fwder != nil { + fwder.SetCapture(nil) + fwder.Stop() + } + + if m.logger != nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := m.logger.Stop(ctx); err != nil { + log.Errorf("failed to shutdown logger: %v", err) + } + } +} + +// SetupEBPFProxyNoTrack creates notrack rules for eBPF proxy loopback traffic. +func (m *Manager) SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error { + if m.nativeFirewall == nil { + return nil + } + return m.nativeFirewall.SetupEBPFProxyNoTrack(proxyPort, wgPort) +} + // 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 { @@ -606,11 +781,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()) @@ -649,7 +820,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 } @@ -659,7 +830,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 } @@ -669,6 +842,9 @@ func (m *Manager) filterOutbound(packetData []byte, size int) bool { return true } case layers.LayerTypeTCP: + if m.tcpHooksDrop(uint16(d.tcp.DstPort), dstIP, packetData) { + return true + } // Clamp MSS on all TCP SYN packets, including those from local IPs. // SNATed routed traffic may appear as local IP but still requires clamping. if m.mssClampEnabled { @@ -730,12 +906,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 } @@ -746,20 +942,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 @@ -774,7 +967,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 @@ -784,18 +977,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 uint32 = pseudoSum + sum := pseudoSum for i := 0; i < tcpLength-1; i += 2 { sum += uint32(tcpLayer[i])<<8 | uint32(tcpLayer[i+1]) } @@ -833,6 +1040,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) } } @@ -846,44 +1056,20 @@ 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 } -// udpHooksDrop checks if any UDP hooks should drop the packet func (m *Manager) udpHooksDrop(dport uint16, dstIP netip.Addr, packetData []byte) bool { - m.mutex.RLock() - defer m.mutex.RUnlock() + return common.HookMatches(m.udpHookOut.Load(), dstIP, dport, packetData) +} - // Check specific destination IP first - if rules, exists := m.outgoingRules[dstIP]; exists { - for _, rule := range rules { - if rule.udpHook != nil && portsMatch(rule.dPort, dport) { - return rule.udpHook(packetData) - } - } - } - - // Check IPv4 unspecified address - if rules, exists := m.outgoingRules[netip.IPv4Unspecified()]; exists { - for _, rule := range rules { - if rule.udpHook != nil && portsMatch(rule.dPort, dport) { - return rule.udpHook(packetData) - } - } - } - - // Check IPv6 unspecified address - if rules, exists := m.outgoingRules[netip.IPv6Unspecified()]; exists { - for _, rule := range rules { - if rule.udpHook != nil && portsMatch(rule.dPort, dport) { - return rule.udpHook(packetData) - } - } - } - - return false +func (m *Manager) tcpHooksDrop(dport uint16, dstIP netip.Addr, packetData []byte) bool { + return common.HookMatches(m.tcpHookOut.Load(), dstIP, dport, packetData) } // filterInbound implements filtering logic for incoming packets. @@ -903,17 +1089,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 } @@ -922,7 +1115,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 } @@ -940,31 +1133,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 } @@ -999,8 +1387,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 } @@ -1010,28 +1400,13 @@ func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP netip.Addr, packe return false } - proto, pnum := getProtocolFromPacket(d) + protoLayer := d.decoded[1] srcPort, dstPort := getPortsFromPacket(d) - ruleID, pass := m.routeACLsPass(srcIP, dstIP, proto, srcPort, dstPort) + ruleID, pass := m.routeACLsPass(srcIP, dstIP, protoLayer, srcPort, dstPort) if !pass { - m.logger.Trace6("Dropping routed 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 routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d", + d, srcIP, dstIP, ruleID, size) return true } @@ -1052,16 +1427,77 @@ func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP netip.Addr, packe return true } -func getProtocolFromPacket(d *decoder) (firewall.Protocol, nftypes.Protocol) { +// 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: + return layers.LayerTypeTCP + case firewall.ProtocolUDP: + return layers.LayerTypeUDP + case firewall.ProtocolICMP: + if ipLayer == layers.LayerTypeIPv6 { + return layers.LayerTypeICMPv6 + } + return layers.LayerTypeICMPv4 + case firewall.ProtocolALL: + return layerTypeAll + } + return 0 +} + +func getProtocolFromPacket(d *decoder) nftypes.Protocol { switch d.decoded[1] { case layers.LayerTypeTCP: - return firewall.ProtocolTCP, nftypes.TCP + return nftypes.TCP case layers.LayerTypeUDP: - return firewall.ProtocolUDP, nftypes.UDP - case layers.LayerTypeICMPv4, layers.LayerTypeICMPv6: - return firewall.ProtocolICMP, nftypes.ICMP + return nftypes.UDP + case layers.LayerTypeICMPv4: + return nftypes.ICMP + case layers.LayerTypeICMPv6: + return nftypes.ICMPv6 default: - return firewall.ProtocolALL, nftypes.ProtocolUnknown + return nftypes.ProtocolUnknown } } @@ -1080,8 +1516,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 } @@ -1093,10 +1531,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 + } } } @@ -1134,21 +1583,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) { @@ -1205,7 +1668,7 @@ func validateRule(ip netip.Addr, packetData []byte, rules map[string]PeerRule, d return rule.mgmtId, rule.drop, true } - if payloadLayer != rule.protoLayer { + if !protoLayerMatches(rule.protoLayer, payloadLayer) { continue } @@ -1215,12 +1678,6 @@ func validateRule(ip netip.Addr, packetData []byte, rules map[string]PeerRule, d return rule.mgmtId, rule.drop, true } case layers.LayerTypeUDP: - // if rule has UDP hook (and if we are here we match this rule) - // we ignore rule.drop and call this hook - if rule.udpHook != nil { - return rule.mgmtId, rule.udpHook(packetData), true - } - if portsMatch(rule.sPort, uint16(d.udp.SrcPort)) && portsMatch(rule.dPort, uint16(d.udp.DstPort)) { return rule.mgmtId, rule.drop, true } @@ -1233,19 +1690,29 @@ func validateRule(ip netip.Addr, packetData []byte, rules map[string]PeerRule, d } // routeACLsPass returns true if the packet is allowed by the route ACLs -func (m *Manager) routeACLsPass(srcIP, dstIP netip.Addr, proto firewall.Protocol, srcPort, dstPort uint16) ([]byte, bool) { +func (m *Manager) routeACLsPass(srcIP, dstIP netip.Addr, protoLayer gopacket.LayerType, srcPort, dstPort uint16) ([]byte, bool) { m.mutex.RLock() defer m.mutex.RUnlock() for _, rule := range m.routeRules { - if matches := m.ruleMatches(rule, srcIP, dstIP, proto, srcPort, dstPort); matches { + if matches := m.ruleMatches(rule, srcIP, dstIP, protoLayer, srcPort, dstPort); matches { return rule.mgmtId, rule.action == firewall.ActionAccept } } return nil, false } -func (m *Manager) ruleMatches(rule *RouteRule, srcAddr, dstAddr netip.Addr, proto firewall.Protocol, srcPort, dstPort uint16) bool { +func (m *Manager) ruleMatches(rule *RouteRule, srcAddr, dstAddr netip.Addr, protoLayer gopacket.LayerType, srcPort, dstPort uint16) bool { + if rule.protoLayer != layerTypeAll && !protoLayerMatches(rule.protoLayer, protoLayer) { + return false + } + + if protoLayer == layers.LayerTypeTCP || protoLayer == layers.LayerTypeUDP { + if !portsMatch(rule.srcPort, srcPort) || !portsMatch(rule.dstPort, dstPort) { + return false + } + } + destMatched := false for _, dst := range rule.destinations { if dst.Contains(dstAddr) { @@ -1264,82 +1731,18 @@ func (m *Manager) ruleMatches(rule *RouteRule, srcAddr, dstAddr netip.Addr, prot break } } - if !sourceMatched { - return false - } - if rule.proto != firewall.ProtocolALL && rule.proto != proto { - return false - } - - if proto == firewall.ProtocolTCP || proto == firewall.ProtocolUDP { - if !portsMatch(rule.srcPort, srcPort) || !portsMatch(rule.dstPort, dstPort) { - return false - } - } - - return true + return sourceMatched } -// AddUDPPacketHook calls hook when UDP packet from given direction matched -// -// Hook function returns flag which indicates should be the matched package dropped or not -func (m *Manager) AddUDPPacketHook(in bool, ip netip.Addr, dPort uint16, hook func(packet []byte) bool) string { - r := PeerRule{ - id: uuid.New().String(), - ip: ip, - protoLayer: layers.LayerTypeUDP, - dPort: &firewall.Port{Values: []uint16{dPort}}, - ipLayer: layers.LayerTypeIPv6, - udpHook: hook, - } - - if ip.Is4() { - r.ipLayer = layers.LayerTypeIPv4 - } - - m.mutex.Lock() - if in { - // Incoming UDP hooks are stored in allow rules map - if _, ok := m.incomingRules[r.ip]; !ok { - m.incomingRules[r.ip] = make(map[string]PeerRule) - } - m.incomingRules[r.ip][r.id] = r - } else { - if _, ok := m.outgoingRules[r.ip]; !ok { - m.outgoingRules[r.ip] = make(map[string]PeerRule) - } - m.outgoingRules[r.ip][r.id] = r - } - m.mutex.Unlock() - - return r.id +// SetUDPPacketHook sets the outbound UDP packet hook. Pass nil hook to remove. +func (m *Manager) SetUDPPacketHook(ip netip.Addr, dPort uint16, hook func(packet []byte) bool) { + common.SetHook(&m.udpHookOut, ip, dPort, hook) } -// RemovePacketHook removes packet hook by given ID -func (m *Manager) RemovePacketHook(hookID string) error { - m.mutex.Lock() - defer m.mutex.Unlock() - - // Check incoming hooks (stored in allow rules) - for _, arr := range m.incomingRules { - for _, r := range arr { - if r.id == hookID { - delete(arr, r.id) - return nil - } - } - } - // Check outgoing hooks - for _, arr := range m.outgoingRules { - for _, r := range arr { - if r.id == hookID { - delete(arr, r.id) - return nil - } - } - } - return fmt.Errorf("hook with given id not found") +// SetTCPPacketHook sets the outbound TCP packet hook. Pass nil hook to remove. +func (m *Manager) SetTCPPacketHook(ip netip.Addr, dPort uint16, hook func(packet []byte) bool) { + common.SetHook(&m.tcpHookOut, ip, dPort, hook) } // SetLogLevel sets the log level for the firewall manager @@ -1361,13 +1764,14 @@ func (m *Manager) EnableRouting() error { return nil } - rule, err := m.blockInvalidRouted(m.wgIface) + rules, err := m.blockInvalidRouted(m.wgIface) + // Persist whatever was installed even on partial failure, so DisableRouting + // can clean it up later. + m.blockRules = rules if err != nil { return fmt.Errorf("block invalid routed: %w", err) } - m.blockRule = rule - return nil } @@ -1383,9 +1787,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() @@ -1393,14 +1804,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 @@ -1454,7 +1858,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 5a2d0410f..4dccb0f65 100644 --- a/client/firewall/uspfilter/filter_bench_test.go +++ b/client/firewall/uspfilter/filter_bench_test.go @@ -955,7 +955,7 @@ func BenchmarkRouteACLs(b *testing.B) { for _, tc := range cases { srcIP := netip.MustParseAddr(tc.srcIP) dstIP := netip.MustParseAddr(tc.dstIP) - manager.routeACLsPass(srcIP, dstIP, tc.proto, 0, tc.dstPort) + manager.routeACLsPass(srcIP, dstIP, protoToLayer(tc.proto, layers.LayerTypeIPv4), 0, tc.dstPort) } } } @@ -1023,7 +1023,8 @@ func BenchmarkMSSClamping(b *testing.B) { }() 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") @@ -1088,7 +1089,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") @@ -1141,7 +1143,8 @@ func BenchmarkMSSClampingMemory(b *testing.B) { }() 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 eb5aa3343..a64c83138 100644 --- a/client/firewall/uspfilter/filter_filter_test.go +++ b/client/firewall/uspfilter/filter_filter_test.go @@ -539,53 +539,236 @@ 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(ifaceMock, false, flowLogger, 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 matches ICMPv6 via protoLayerMatches", + srcIP: "fd00::1", + dstIP: "fd00::100", + proto: fw.ProtocolICMP, + ruleIP: "0.0.0.0", + ruleProto: fw.ProtocolICMP, + ruleAction: fw.ActionAccept, + shouldBeBlocked: false, + }, + } + + 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.AddPeerFiltering(nil, net.ParseIP(tc.ruleIP), fw.ProtocolALL, nil, nil, fw.ActionAccept, "") + require.NoError(t, err) + t.Cleanup(func() { + for _, rule := range rules { + require.NoError(t, manager.DeletePeerRule(rule)) + } + }) + } + + rules, err := manager.AddPeerFiltering(nil, net.ParseIP(tc.ruleIP), tc.ruleProto, nil, tc.ruleDstPort, tc.ruleAction, "") + require.NoError(t, err) + require.NotEmpty(t, rules) + t.Cleanup(func() { + for _, rule := range rules { + require.NoError(t, manager.DeletePeerRule(rule)) + } + }) + + 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) @@ -1259,7 +1442,7 @@ func TestRouteACLFiltering(t *testing.T) { // testing routeACLsPass only and not FilterInbound, as routed packets are dropped after being passed // to the forwarder - _, isAllowed := manager.routeACLsPass(srcIP, dstIP, tc.proto, tc.srcPort, tc.dstPort) + _, isAllowed := manager.routeACLsPass(srcIP, dstIP, protoToLayer(tc.proto, layers.LayerTypeIPv4), tc.srcPort, tc.dstPort) require.Equal(t, tc.shouldPass, isAllowed) }) } @@ -1445,7 +1628,7 @@ func TestRouteACLOrder(t *testing.T) { srcIP := netip.MustParseAddr(p.srcIP) dstIP := netip.MustParseAddr(p.dstIP) - _, isAllowed := manager.routeACLsPass(srcIP, dstIP, p.proto, p.srcPort, p.dstPort) + _, isAllowed := manager.routeACLsPass(srcIP, dstIP, protoToLayer(p.proto, layers.LayerTypeIPv4), p.srcPort, p.dstPort) require.Equal(t, p.shouldPass, isAllowed, "packet %d failed", i) } }) @@ -1488,13 +1671,113 @@ func TestRouteACLSet(t *testing.T) { dstIP := netip.MustParseAddr("192.168.1.100") // Check that traffic is dropped (empty set shouldn't match anything) - _, isAllowed := manager.routeACLsPass(srcIP, dstIP, fw.ProtocolTCP, 12345, 80) + _, isAllowed := manager.routeACLsPass(srcIP, dstIP, protoToLayer(fw.ProtocolTCP, layers.LayerTypeIPv4), 12345, 80) require.False(t, isAllowed, "Empty set should not allow any traffic") err = manager.UpdateSet(set, []netip.Prefix{netip.MustParsePrefix("192.168.1.0/24")}) require.NoError(t, err) // Now the packet should be allowed - _, isAllowed = manager.routeACLsPass(srcIP, dstIP, fw.ProtocolTCP, 12345, 80) + _, 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.AddRouteFiltering( + 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.AddRouteFiltering( + 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 new file mode 100644 index 000000000..449554d8b --- /dev/null +++ b/client/firewall/uspfilter/filter_routeacl_test.go @@ -0,0 +1,376 @@ +package uspfilter + +import ( + "net/netip" + "testing" + + "github.com/golang/mock/gomock" + "github.com/google/gopacket/layers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + wgdevice "golang.zx2c4.com/wireguard/device" + + fw "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/mocks" + "github.com/netbirdio/netbird/client/iface/wgaddr" +) + +// TestAddRouteFilteringReturnsExistingRule verifies that adding the same route +// filtering rule twice returns the same rule ID (idempotent behavior). +func TestAddRouteFilteringReturnsExistingRule(t *testing.T) { + manager := setupTestManager(t) + + sources := []netip.Prefix{ + netip.MustParsePrefix("100.64.1.0/24"), + netip.MustParsePrefix("100.64.2.0/24"), + } + destination := fw.Network{Prefix: netip.MustParsePrefix("192.168.1.0/24")} + + // Add rule first time + rule1, err := manager.AddRouteFiltering( + []byte("policy-1"), + sources, + destination, + fw.ProtocolTCP, + nil, + &fw.Port{Values: []uint16{443}}, + fw.ActionAccept, + ) + require.NoError(t, err) + require.NotNil(t, rule1) + + // Add the same rule again + rule2, err := manager.AddRouteFiltering( + []byte("policy-1"), + sources, + destination, + fw.ProtocolTCP, + nil, + &fw.Port{Values: []uint16{443}}, + fw.ActionAccept, + ) + require.NoError(t, err) + require.NotNil(t, rule2) + + // These should be the same (idempotent) like nftables/iptables implementations + assert.Equal(t, rule1.ID(), rule2.ID(), + "Adding the same rule twice should return the same rule ID (idempotent)") + + manager.mutex.RLock() + ruleCount := len(manager.routeRules) + manager.mutex.RUnlock() + + assert.Equal(t, 2, ruleCount, + "Should have exactly 2 rules (1 user rule + 1 block rule)") +} + +// TestAddRouteFilteringDifferentRulesGetDifferentIDs verifies that rules with +// different parameters get distinct IDs. +func TestAddRouteFilteringDifferentRulesGetDifferentIDs(t *testing.T) { + manager := setupTestManager(t) + + sources := []netip.Prefix{netip.MustParsePrefix("100.64.1.0/24")} + + // Add first rule + rule1, err := manager.AddRouteFiltering( + []byte("policy-1"), + sources, + fw.Network{Prefix: netip.MustParsePrefix("192.168.1.0/24")}, + fw.ProtocolTCP, + nil, + &fw.Port{Values: []uint16{443}}, + fw.ActionAccept, + ) + require.NoError(t, err) + + // Add different rule (different destination) + rule2, err := manager.AddRouteFiltering( + []byte("policy-2"), + sources, + fw.Network{Prefix: netip.MustParsePrefix("192.168.2.0/24")}, // Different! + fw.ProtocolTCP, + nil, + &fw.Port{Values: []uint16{443}}, + fw.ActionAccept, + ) + require.NoError(t, err) + + assert.NotEqual(t, rule1.ID(), rule2.ID(), + "Different rules should have different IDs") + + manager.mutex.RLock() + ruleCount := len(manager.routeRules) + manager.mutex.RUnlock() + + assert.Equal(t, 3, ruleCount, "Should have 3 rules (2 user rules + 1 block rule)") +} + +// TestRouteRuleUpdateDoesNotCauseGap verifies that re-adding the same route +// rule during a network map update does not disrupt existing traffic. +func TestRouteRuleUpdateDoesNotCauseGap(t *testing.T) { + manager := setupTestManager(t) + + sources := []netip.Prefix{netip.MustParsePrefix("100.64.1.0/24")} + destination := fw.Network{Prefix: netip.MustParsePrefix("192.168.1.0/24")} + + rule1, err := manager.AddRouteFiltering( + []byte("policy-1"), + sources, + destination, + fw.ProtocolTCP, + nil, + nil, + fw.ActionAccept, + ) + require.NoError(t, err) + + srcIP := netip.MustParseAddr("100.64.1.5") + dstIP := netip.MustParseAddr("192.168.1.10") + _, pass := manager.routeACLsPass(srcIP, dstIP, layers.LayerTypeTCP, 12345, 443) + require.True(t, pass, "Traffic should pass with rule in place") + + // Re-add same rule (simulates network map update) + rule2, err := manager.AddRouteFiltering( + []byte("policy-1"), + sources, + destination, + fw.ProtocolTCP, + nil, + nil, + fw.ActionAccept, + ) + require.NoError(t, err) + + // Idempotent IDs mean rule1.ID() == rule2.ID(), so the ACL manager + // won't delete rule1 during cleanup. If IDs differed, deleting rule1 + // would remove the only matching rule and cause a traffic gap. + if rule1.ID() != rule2.ID() { + err = manager.DeleteRouteRule(rule1) + require.NoError(t, err) + } + + _, passAfter := manager.routeACLsPass(srcIP, dstIP, layers.LayerTypeTCP, 12345, 443) + assert.True(t, passAfter, + "Traffic should still pass after rule update - no gap should occur") +} + +// TestBlockInvalidRoutedIdempotent verifies that blockInvalidRouted creates +// exactly one drop rule for the WireGuard network prefix, and calling it again +// returns the same rule without duplicating. +func TestBlockInvalidRoutedIdempotent(t *testing.T) { + ctrl := gomock.NewController(t) + dev := mocks.NewMockDevice(ctrl) + dev.EXPECT().MTU().Return(1500, nil).AnyTimes() + + wgNet := netip.MustParsePrefix("100.64.0.1/16") + + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: wgNet.Addr(), + Network: wgNet, + } + }, + GetDeviceFunc: func() *device.FilteredDevice { + return &device.FilteredDevice{Device: dev} + }, + GetWGDeviceFunc: func() *wgdevice.Device { + return &wgdevice.Device{} + }, + } + + manager, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, manager.Close(nil)) + }) + + // Call blockInvalidRouted directly multiple times + rules1, err := manager.blockInvalidRouted(ifaceMock) + require.NoError(t, err) + require.NotEmpty(t, rules1) + + rules2, err := manager.blockInvalidRouted(ifaceMock) + require.NoError(t, err) + require.NotEmpty(t, rules2) + + rules3, err := manager.blockInvalidRouted(ifaceMock) + require.NoError(t, err) + require.NotEmpty(t, rules3) + + // 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() + ruleCount := len(manager.routeRules) + manager.mutex.RUnlock() + + assert.Equal(t, 1, ruleCount, "Should have exactly 1 block rule after 3 calls") + + // Verify the rule blocks traffic to the WG network + srcIP := netip.MustParseAddr("10.0.0.1") + dstIP := netip.MustParseAddr("100.64.0.50") + _, pass := manager.routeACLsPass(srcIP, dstIP, layers.LayerTypeTCP, 12345, 80) + assert.False(t, pass, "Block rule should deny traffic to WG prefix") +} + +// TestBlockRuleNotAccumulatedOnRepeatedEnableRouting verifies that calling +// EnableRouting multiple times (as happens on each route update) does not +// accumulate duplicate block rules in the routeRules slice. +func TestBlockRuleNotAccumulatedOnRepeatedEnableRouting(t *testing.T) { + ctrl := gomock.NewController(t) + dev := mocks.NewMockDevice(ctrl) + dev.EXPECT().MTU().Return(1500, nil).AnyTimes() + + wgNet := netip.MustParsePrefix("100.64.0.1/16") + + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: wgNet.Addr(), + Network: wgNet, + } + }, + GetDeviceFunc: func() *device.FilteredDevice { + return &device.FilteredDevice{Device: dev} + }, + GetWGDeviceFunc: func() *wgdevice.Device { + return &wgdevice.Device{} + }, + } + + manager, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, manager.Close(nil)) + }) + + // Call EnableRouting multiple times (simulating repeated route updates) + for i := 0; i < 5; i++ { + require.NoError(t, manager.EnableRouting()) + } + + manager.mutex.RLock() + ruleCount := len(manager.routeRules) + manager.mutex.RUnlock() + + assert.Equal(t, 1, ruleCount, + "Repeated EnableRouting should not accumulate block rules") +} + +// TestRouteRuleCountStableAcrossUpdates verifies that adding the same route +// rule multiple times does not create duplicate entries. +func TestRouteRuleCountStableAcrossUpdates(t *testing.T) { + manager := setupTestManager(t) + + sources := []netip.Prefix{netip.MustParsePrefix("100.64.1.0/24")} + destination := fw.Network{Prefix: netip.MustParsePrefix("192.168.1.0/24")} + + // Simulate 5 network map updates with the same route rule + for i := 0; i < 5; i++ { + rule, err := manager.AddRouteFiltering( + []byte("policy-1"), + sources, + destination, + fw.ProtocolTCP, + nil, + &fw.Port{Values: []uint16{443}}, + fw.ActionAccept, + ) + require.NoError(t, err) + require.NotNil(t, rule) + } + + manager.mutex.RLock() + ruleCount := len(manager.routeRules) + manager.mutex.RUnlock() + + assert.Equal(t, 2, ruleCount, + "Should have exactly 2 rules (1 user rule + 1 block rule) after 5 updates") +} + +// TestDeleteRouteRuleAfterIdempotentAdd verifies that deleting a route rule +// after adding it multiple times works correctly. +func TestDeleteRouteRuleAfterIdempotentAdd(t *testing.T) { + manager := setupTestManager(t) + + sources := []netip.Prefix{netip.MustParsePrefix("100.64.1.0/24")} + destination := fw.Network{Prefix: netip.MustParsePrefix("192.168.1.0/24")} + + // Add same rule twice + rule1, err := manager.AddRouteFiltering( + []byte("policy-1"), + sources, + destination, + fw.ProtocolTCP, + nil, + nil, + fw.ActionAccept, + ) + require.NoError(t, err) + + rule2, err := manager.AddRouteFiltering( + []byte("policy-1"), + sources, + destination, + fw.ProtocolTCP, + nil, + nil, + fw.ActionAccept, + ) + require.NoError(t, err) + + require.Equal(t, rule1.ID(), rule2.ID(), "Should return same rule ID") + + // Delete using first reference + err = manager.DeleteRouteRule(rule1) + require.NoError(t, err) + + // Verify traffic no longer passes + srcIP := netip.MustParseAddr("100.64.1.5") + dstIP := netip.MustParseAddr("192.168.1.10") + _, pass := manager.routeACLsPass(srcIP, dstIP, layers.LayerTypeTCP, 12345, 443) + assert.False(t, pass, "Traffic should not pass after rule deletion") +} + +func setupTestManager(t *testing.T) *Manager { + t.Helper() + + ctrl := gomock.NewController(t) + dev := mocks.NewMockDevice(ctrl) + dev.EXPECT().MTU().Return(1500, nil).AnyTimes() + + wgNet := netip.MustParsePrefix("100.64.0.1/16") + + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + AddressFunc: func() wgaddr.Address { + return wgaddr.Address{ + IP: wgNet.Addr(), + Network: wgNet, + } + }, + GetDeviceFunc: func() *device.FilteredDevice { + return &device.FilteredDevice{Device: dev} + }, + GetWGDeviceFunc: func() *wgdevice.Device { + return &wgdevice.Device{} + }, + } + + manager, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU) + require.NoError(t, err) + require.NoError(t, manager.EnableRouting()) + + t.Cleanup(func() { + require.NoError(t, manager.Close(nil)) + }) + + return manager +} diff --git a/client/firewall/uspfilter/filter_test.go b/client/firewall/uspfilter/filter_test.go index c56a078fc..f19c4bb56 100644 --- a/client/firewall/uspfilter/filter_test.go +++ b/client/firewall/uspfilter/filter_test.go @@ -12,6 +12,7 @@ import ( "github.com/google/gopacket" "github.com/google/gopacket/layers" "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" wgdevice "golang.zx2c4.com/wireguard/device" @@ -22,6 +23,7 @@ import ( "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/internal/netflow" + nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" "github.com/netbirdio/netbird/shared/management/domain" ) @@ -29,12 +31,20 @@ var logger = log.NewFromLogrus(logrus.StandardLogger()) var flowLogger = netflow.NewManager(nil, []byte{}, nil).GetLogger() type IFaceMock struct { + NameFunc func() string SetFilterFunc func(device.PacketFilter) error AddressFunc func() wgaddr.Address GetWGDeviceFunc func() *wgdevice.Device GetDeviceFunc func() *device.FilteredDevice } +func (i *IFaceMock) Name() string { + if i.NameFunc == nil { + return "wgtest" + } + return i.NameFunc() +} + func (i *IFaceMock) GetWGDevice() *wgdevice.Device { if i.GetWGDeviceFunc == nil { return nil @@ -185,81 +195,204 @@ func TestManagerDeleteRule(t *testing.T) { } } -func TestAddUDPPacketHook(t *testing.T) { - tests := []struct { - name string - in bool - expDir fw.RuleDirection - ip netip.Addr - dPort uint16 - hook func([]byte) bool - expectedID string - }{ - { - name: "Test Outgoing UDP Packet Hook", - in: false, - expDir: fw.RuleDirectionOUT, - ip: netip.MustParseAddr("10.168.0.1"), - dPort: 8000, - hook: func([]byte) bool { return true }, - }, - { - name: "Test Incoming UDP Packet Hook", - in: true, - expDir: fw.RuleDirectionIN, - ip: netip.MustParseAddr("::1"), - dPort: 9000, - hook: func([]byte) bool { return false }, - }, +func TestSetUDPPacketHook(t *testing.T) { + manager, err := Create(&IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, false, flowLogger, nbiface.DefaultMTU) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, manager.Close(nil)) }) + + var called bool + manager.SetUDPPacketHook(netip.MustParseAddr("10.168.0.1"), 8000, func([]byte) bool { + called = true + return true + }) + + h := manager.udpHookOut.Load() + require.NotNil(t, h) + assert.Equal(t, netip.MustParseAddr("10.168.0.1"), h.IP) + assert.Equal(t, uint16(8000), h.Port) + assert.True(t, h.Fn(nil)) + assert.True(t, called) + + manager.SetUDPPacketHook(netip.MustParseAddr("10.168.0.1"), 8000, nil) + assert.Nil(t, manager.udpHookOut.Load()) +} + +func TestSetTCPPacketHook(t *testing.T) { + manager, err := Create(&IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + }, false, flowLogger, nbiface.DefaultMTU) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, manager.Close(nil)) }) + + var called bool + manager.SetTCPPacketHook(netip.MustParseAddr("10.168.0.1"), 53, func([]byte) bool { + called = true + return true + }) + + h := manager.tcpHookOut.Load() + require.NotNil(t, h) + assert.Equal(t, netip.MustParseAddr("10.168.0.1"), h.IP) + assert.Equal(t, uint16(53), h.Port) + assert.True(t, h.Fn(nil)) + assert.True(t, called) + + manager.SetTCPPacketHook(netip.MustParseAddr("10.168.0.1"), 53, nil) + assert.Nil(t, manager.tcpHookOut.Load()) +} + +// TestPeerRuleLifecycleDenyRules verifies that deny rules are correctly added +// to the deny map and can be cleanly deleted without leaving orphans. +func TestPeerRuleLifecycleDenyRules(t *testing.T) { + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - manager, err := Create(&IFaceMock{ - SetFilterFunc: func(device.PacketFilter) error { return nil }, - }, false, flowLogger, nbiface.DefaultMTU) - require.NoError(t, err) + m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + require.NoError(t, err) + defer func() { + require.NoError(t, m.Close(nil)) + }() - manager.AddUDPPacketHook(tt.in, tt.ip, tt.dPort, tt.hook) + ip := net.ParseIP("192.168.1.1") + addr := netip.MustParseAddr("192.168.1.1") - var addedRule PeerRule - if tt.in { - // Incoming UDP hooks are stored in allow rules map - if len(manager.incomingRules[tt.ip]) != 1 { - t.Errorf("expected 1 incoming rule, got %d", len(manager.incomingRules[tt.ip])) - return - } - for _, rule := range manager.incomingRules[tt.ip] { - addedRule = rule - } - } else { - if len(manager.outgoingRules[tt.ip]) != 1 { - t.Errorf("expected 1 outgoing rule, got %d", len(manager.outgoingRules[tt.ip])) - return - } - for _, rule := range manager.outgoingRules[tt.ip] { - addedRule = rule - } - } + // Add multiple deny rules for different ports + rule1, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil, + &fw.Port{Values: []uint16{22}}, fw.ActionDrop, "") + require.NoError(t, err) - if tt.ip.Compare(addedRule.ip) != 0 { - t.Errorf("expected ip %s, got %s", tt.ip, addedRule.ip) - return - } - if tt.dPort != addedRule.dPort.Values[0] { - t.Errorf("expected dPort %d, got %d", tt.dPort, addedRule.dPort.Values[0]) - return - } - if layers.LayerTypeUDP != addedRule.protoLayer { - t.Errorf("expected protoLayer %s, got %s", layers.LayerTypeUDP, addedRule.protoLayer) - return - } - if addedRule.udpHook == nil { - t.Errorf("expected udpHook to be set") - return - } - }) + rule2, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil, + &fw.Port{Values: []uint16{80}}, fw.ActionDrop, "") + require.NoError(t, err) + + m.mutex.RLock() + denyCount := len(m.incomingDenyRules[addr]) + m.mutex.RUnlock() + require.Equal(t, 2, denyCount, "Should have exactly 2 deny rules") + + // Delete the first deny rule + err = m.DeletePeerRule(rule1[0]) + require.NoError(t, err) + + m.mutex.RLock() + denyCount = len(m.incomingDenyRules[addr]) + m.mutex.RUnlock() + require.Equal(t, 1, denyCount, "Should have 1 deny rule after deleting first") + + // Delete the second deny rule + err = m.DeletePeerRule(rule2[0]) + require.NoError(t, err) + + m.mutex.RLock() + _, exists := m.incomingDenyRules[addr] + m.mutex.RUnlock() + require.False(t, exists, "Deny rules IP entry should be cleaned up when empty") +} + +// TestPeerRuleAddAndDeleteDontLeak verifies that repeatedly adding and deleting +// peer rules (simulating network map updates) does not leak rules in the maps. +func TestPeerRuleAddAndDeleteDontLeak(t *testing.T) { + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, } + + m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + require.NoError(t, err) + defer func() { + require.NoError(t, m.Close(nil)) + }() + + ip := net.ParseIP("192.168.1.1") + addr := netip.MustParseAddr("192.168.1.1") + + // Simulate 10 network map updates: add rule, delete old, add new + for i := 0; i < 10; i++ { + // Add a deny rule + rules, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil, + &fw.Port{Values: []uint16{22}}, fw.ActionDrop, "") + require.NoError(t, err) + + // Add an allow rule + allowRules, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil, + &fw.Port{Values: []uint16{80}}, fw.ActionAccept, "") + require.NoError(t, err) + + // Delete them (simulating ACL manager cleanup) + for _, r := range rules { + require.NoError(t, m.DeletePeerRule(r)) + } + for _, r := range allowRules { + require.NoError(t, m.DeletePeerRule(r)) + } + } + + m.mutex.RLock() + denyCount := len(m.incomingDenyRules[addr]) + allowCount := len(m.incomingRules[addr]) + m.mutex.RUnlock() + + require.Equal(t, 0, denyCount, "No deny rules should remain after cleanup") + require.Equal(t, 0, allowCount, "No allow rules should remain after cleanup") +} + +// TestMixedAllowDenyRulesSameIP verifies that allow and deny rules for the same +// IP are stored in separate maps and don't interfere with each other. +func TestMixedAllowDenyRulesSameIP(t *testing.T) { + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + } + + m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + require.NoError(t, err) + defer func() { + require.NoError(t, m.Close(nil)) + }() + + ip := net.ParseIP("192.168.1.1") + + // Add allow rule for port 80 + allowRule, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil, + &fw.Port{Values: []uint16{80}}, fw.ActionAccept, "") + require.NoError(t, err) + + // Add deny rule for port 22 + denyRule, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil, + &fw.Port{Values: []uint16{22}}, fw.ActionDrop, "") + require.NoError(t, err) + + addr := netip.MustParseAddr("192.168.1.1") + m.mutex.RLock() + allowCount := len(m.incomingRules[addr]) + denyCount := len(m.incomingDenyRules[addr]) + m.mutex.RUnlock() + + require.Equal(t, 1, allowCount, "Should have 1 allow rule") + require.Equal(t, 1, denyCount, "Should have 1 deny rule") + + // Delete allow rule should not affect deny rule + err = m.DeletePeerRule(allowRule[0]) + require.NoError(t, err) + + m.mutex.RLock() + denyCountAfter := len(m.incomingDenyRules[addr]) + m.mutex.RUnlock() + + require.Equal(t, 1, denyCountAfter, "Deny rule should still exist after deleting allow rule") + + // Delete deny rule + err = m.DeletePeerRule(denyRule[0]) + require.NoError(t, err) + + m.mutex.RLock() + _, denyExists := m.incomingDenyRules[addr] + _, allowExists := m.incomingRules[addr] + m.mutex.RUnlock() + + require.False(t, denyExists, "Deny rules should be empty") + require.False(t, allowExists, "Allow rules should be empty") } func TestManagerReset(t *testing.T) { @@ -377,39 +510,12 @@ func TestRemovePacketHook(t *testing.T) { require.NoError(t, manager.Close(nil)) }() - // Add a UDP packet hook - hookFunc := func(data []byte) bool { return true } - hookID := manager.AddUDPPacketHook(false, netip.MustParseAddr("192.168.0.1"), 8080, hookFunc) + manager.SetUDPPacketHook(netip.MustParseAddr("192.168.0.1"), 8080, func([]byte) bool { return true }) - // Assert the hook is added by finding it in the manager's outgoing rules - found := false - for _, arr := range manager.outgoingRules { - for _, rule := range arr { - if rule.id == hookID { - found = true - break - } - } - } + require.NotNil(t, manager.udpHookOut.Load(), "hook should be registered") - if !found { - t.Fatalf("The hook was not added properly.") - } - - // Now remove the packet hook - err = manager.RemovePacketHook(hookID) - if err != nil { - t.Fatalf("Failed to remove hook: %s", err) - } - - // Assert the hook is removed by checking it in the manager's outgoing rules - for _, arr := range manager.outgoingRules { - for _, rule := range arr { - if rule.id == hookID { - t.Fatalf("The hook was not removed properly.") - } - } - } + manager.SetUDPPacketHook(netip.MustParseAddr("192.168.0.1"), 8080, nil) + assert.Nil(t, manager.udpHookOut.Load(), "hook should be removed") } func TestProcessOutgoingHooks(t *testing.T) { @@ -429,18 +535,22 @@ 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 }, } hookCalled := false - hookID := manager.AddUDPPacketHook( - false, + manager.SetUDPPacketHook( netip.MustParseAddr("100.10.0.100"), 53, func([]byte) bool { @@ -448,7 +558,6 @@ func TestProcessOutgoingHooks(t *testing.T) { return true }, ) - require.NotEmpty(t, hookID) // Create test UDP packet ipv4 := &layers.IPv4{ @@ -534,11 +643,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 }, } @@ -766,9 +880,9 @@ func TestUpdateSetMerge(t *testing.T) { dstIP2 := netip.MustParseAddr("192.168.1.100") dstIP3 := netip.MustParseAddr("172.16.0.100") - _, isAllowed1 := manager.routeACLsPass(srcIP, dstIP1, fw.ProtocolTCP, 12345, 80) - _, isAllowed2 := manager.routeACLsPass(srcIP, dstIP2, fw.ProtocolTCP, 12345, 80) - _, isAllowed3 := manager.routeACLsPass(srcIP, dstIP3, fw.ProtocolTCP, 12345, 80) + _, isAllowed1 := manager.routeACLsPass(srcIP, dstIP1, protoToLayer(fw.ProtocolTCP, layers.LayerTypeIPv4), 12345, 80) + _, isAllowed2 := manager.routeACLsPass(srcIP, dstIP2, protoToLayer(fw.ProtocolTCP, layers.LayerTypeIPv4), 12345, 80) + _, isAllowed3 := manager.routeACLsPass(srcIP, dstIP3, protoToLayer(fw.ProtocolTCP, layers.LayerTypeIPv4), 12345, 80) require.True(t, isAllowed1, "Traffic to 10.0.0.100 should be allowed") require.True(t, isAllowed2, "Traffic to 192.168.1.100 should be allowed") @@ -783,8 +897,8 @@ func TestUpdateSetMerge(t *testing.T) { require.NoError(t, err) // Check that all original prefixes are still included - _, isAllowed1 = manager.routeACLsPass(srcIP, dstIP1, fw.ProtocolTCP, 12345, 80) - _, isAllowed2 = manager.routeACLsPass(srcIP, dstIP2, fw.ProtocolTCP, 12345, 80) + _, isAllowed1 = manager.routeACLsPass(srcIP, dstIP1, protoToLayer(fw.ProtocolTCP, layers.LayerTypeIPv4), 12345, 80) + _, isAllowed2 = manager.routeACLsPass(srcIP, dstIP2, protoToLayer(fw.ProtocolTCP, layers.LayerTypeIPv4), 12345, 80) require.True(t, isAllowed1, "Traffic to 10.0.0.100 should still be allowed after update") require.True(t, isAllowed2, "Traffic to 192.168.1.100 should still be allowed after update") @@ -792,8 +906,8 @@ func TestUpdateSetMerge(t *testing.T) { dstIP4 := netip.MustParseAddr("172.16.1.100") dstIP5 := netip.MustParseAddr("10.1.0.50") - _, isAllowed4 := manager.routeACLsPass(srcIP, dstIP4, fw.ProtocolTCP, 12345, 80) - _, isAllowed5 := manager.routeACLsPass(srcIP, dstIP5, fw.ProtocolTCP, 12345, 80) + _, isAllowed4 := manager.routeACLsPass(srcIP, dstIP4, protoToLayer(fw.ProtocolTCP, layers.LayerTypeIPv4), 12345, 80) + _, isAllowed5 := manager.routeACLsPass(srcIP, dstIP5, protoToLayer(fw.ProtocolTCP, layers.LayerTypeIPv4), 12345, 80) require.True(t, isAllowed4, "Traffic to new prefix 172.16.0.0/16 should be allowed") require.True(t, isAllowed5, "Traffic to new prefix 10.1.0.0/24 should be allowed") @@ -921,7 +1035,7 @@ func TestUpdateSetDeduplication(t *testing.T) { srcIP := netip.MustParseAddr("100.10.0.1") for _, tc := range testCases { - _, isAllowed := manager.routeACLsPass(srcIP, tc.dstIP, fw.ProtocolTCP, 12345, 80) + _, isAllowed := manager.routeACLsPass(srcIP, tc.dstIP, protoToLayer(fw.ProtocolTCP, layers.LayerTypeIPv4), 12345, 80) require.Equal(t, tc.expected, isAllowed, tc.desc) } } @@ -944,8 +1058,8 @@ func TestMSSClamping(t *testing.T) { }() 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) @@ -963,7 +1077,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) { @@ -987,7 +1101,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) { @@ -1114,3 +1228,181 @@ func generateTCPPacketWithFlags(tb testing.TB, srcIP, dstIP net.IP, srcPort, dst return buf.Bytes() } + +func TestShouldForward(t *testing.T) { + // Set up test addresses + wgIP := netip.MustParseAddr("100.10.0.1") + otherIP := netip.MustParseAddr("100.10.0.2") + + // Create test manager with mock interface + ifaceMock := &IFaceMock{ + SetFilterFunc: func(device.PacketFilter) error { return nil }, + } + // Set the mock to return our test WG IP + ifaceMock.AddressFunc = func() wgaddr.Address { + return wgaddr.Address{IP: wgIP, Network: netip.PrefixFrom(wgIP, 24)} + } + + manager, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU) + require.NoError(t, err) + defer func() { + require.NoError(t, manager.Close(nil)) + }() + + // Helper to create decoder with TCP packet + createTCPDecoder := func(dstPort uint16) *decoder { + ipv4 := &layers.IPv4{ + Version: 4, + Protocol: layers.IPProtocolTCP, + SrcIP: net.ParseIP("192.168.1.100"), + DstIP: wgIP.AsSlice(), + } + tcp := &layers.TCP{ + SrcPort: 54321, + DstPort: layers.TCPPort(dstPort), + } + + err := tcp.SetNetworkLayerForChecksum(ipv4) + require.NoError(t, err) + + buf := gopacket.NewSerializeBuffer() + opts := gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true} + err = gopacket.SerializeLayers(buf, opts, ipv4, tcp, gopacket.Payload("test")) + require.NoError(t, err) + + d := &decoder{ + decoded: []gopacket.LayerType{}, + } + d.parser4 = gopacket.NewDecodingLayerParser( + layers.LayerTypeIPv4, + &d.eth, &d.ip4, &d.ip6, &d.icmp4, &d.icmp6, &d.tcp, &d.udp, + ) + 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(buf.Bytes()) + require.NoError(t, err) + + return d + } + + tests := []struct { + name string + localForwarding bool + netstack bool + dstIP netip.Addr + serviceRegistered bool + servicePort uint16 + expected bool + description string + }{ + { + name: "no local forwarding", + localForwarding: false, + netstack: true, + dstIP: wgIP, + expected: false, + description: "should never forward when local forwarding disabled", + }, + { + name: "traffic to other local interface", + localForwarding: true, + netstack: false, + dstIP: otherIP, + expected: true, + description: "should forward traffic to our other local interfaces (not NetBird IP)", + }, + { + name: "traffic to NetBird IP, no netstack", + localForwarding: true, + netstack: false, + dstIP: wgIP, + expected: false, + description: "should send to netstack listeners (final return false path)", + }, + { + name: "traffic to our IP, netstack mode, no service", + localForwarding: true, + netstack: true, + dstIP: wgIP, + expected: true, + description: "should forward when in netstack mode with no matching service", + }, + { + name: "traffic to our IP, netstack mode, with service", + localForwarding: true, + netstack: true, + dstIP: wgIP, + serviceRegistered: true, + servicePort: 22, + expected: false, + description: "should send to netstack listeners when service is registered", + }, + } + + // 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(ifaceMock, false, flowLogger, 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 + manager.localForwarding = tt.localForwarding + manager.netstack = tt.netstack + + // Register service if needed + if tt.serviceRegistered { + manager.RegisterNetstackService(nftypes.TCP, tt.servicePort) + defer manager.UnregisterNetstackService(nftypes.TCP, tt.servicePort) + } + + // Create decoder for the test + decoder := createTCPDecoder(tt.servicePort) + if !tt.serviceRegistered { + decoder = createTCPDecoder(8080) // Use non-registered port + } + + // Test the method + result := manager.shouldForward(decoder, tt.dstIP) + require.Equal(t, tt.expected, result, tt.description) + }) + } +} diff --git a/client/firewall/uspfilter/forwarder/endpoint.go b/client/firewall/uspfilter/forwarder/endpoint.go index f91291ea8..fab776f2a 100644 --- a/client/firewall/uspfilter/forwarder/endpoint.go +++ b/client/firewall/uspfilter/forwarder/endpoint.go @@ -1,7 +1,9 @@ package forwarder import ( - "fmt" + "net" + "strconv" + "sync/atomic" wgdevice "golang.zx2c4.com/wireguard/device" "gvisor.dev/gvisor/pkg/tcpip" @@ -11,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 uint32 + mtu atomic.Uint32 + capture atomic.Pointer[PacketCapture] } func (e *endpoint) Attach(dispatcher stack.NetworkDispatcher) { @@ -28,7 +37,7 @@ func (e *endpoint) IsAttached() bool { } func (e *endpoint) MTU() uint32 { - return e.mtu + return e.mtu.Load() } func (e *endpoint) Capabilities() stack.LinkEndpointCapabilities { @@ -46,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++ } @@ -82,9 +102,27 @@ func (e *endpoint) ParseHeader(*stack.PacketBuffer) bool { return true } +func (e *endpoint) Close() { + // Endpoint cleanup - nothing to do as device is managed externally +} + +func (e *endpoint) SetLinkAddress(tcpip.LinkAddress) { + // Link address is not used for this endpoint type +} + +func (e *endpoint) SetMTU(mtu uint32) { + e.mtu.Store(mtu) +} + +func (e *endpoint) SetOnCloseAction(func()) { + // No action needed on close +} + 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 00cb3f1df..28320ad88 100644 --- a/client/firewall/uspfilter/forwarder/forwarder.go +++ b/client/firewall/uspfilter/forwarder/forwarder.go @@ -5,14 +5,18 @@ import ( "fmt" "net" "net/netip" + "os" "runtime" + "strconv" "sync" + "time" log "github.com/sirupsen/logrus" "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" @@ -29,29 +33,42 @@ 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" ) 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 + 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) { 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, }) @@ -60,8 +77,8 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow endpoint := &endpoint{ logger: logger, device: iface.GetWGDevice(), - mtu: uint32(mtu), } + endpoint.mtu.Store(uint32(mtu)) if err := s.CreateNIC(nicID, endpoint); err != nil { return nil, fmt.Errorf("create NIC: %v", err) @@ -70,7 +87,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(), }, } @@ -79,6 +96,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}), @@ -87,6 +117,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) } @@ -95,23 +133,23 @@ 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()) f := &Forwarder{ - logger: logger, - flowLogger: flowLogger, - stack: s, - endpoint: endpoint, - udpForwarder: newUDPForwarder(mtu, logger, flowLogger), - ctx: ctx, - cancel: cancel, - netstack: netstack, - ip: tcpip.AddrFromSlice(iface.Address().IP.AsSlice()), + logger: logger, + flowLogger: flowLogger, + stack: s, + endpoint: endpoint, + udpForwarder: newUDPForwarder(mtu, logger, flowLogger), + ctx: ctx, + cancel: cancel, + netstack: netstack, + ip: tcpip.AddrFrom4(iface.Address().IP.As4()), + ipv6: addrFromNetipAddr(iface.Address().IPv6), + pingSemaphore: make(chan struct{}, 3), } receiveWindow := defaultReceiveWindow @@ -121,21 +159,60 @@ 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() log.Debugf("forwarder: Initialization complete with NIC %d", nicID) 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{ @@ -144,11 +221,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() @@ -161,11 +387,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) { @@ -198,3 +427,79 @@ func buildKey(srcIP, dstIP netip.Addr, srcPort, dstPort uint16) conntrack.ConnKe DstPort: dstPort, } } + +// 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, network, addr) + if err != nil { + logger.Debug1("forwarder: no raw %s socket access, will use ping binary fallback", network) + return false + } + + if err := conn.Close(); err != nil { + logger.Debug2("forwarder: failed to close %s capability test socket: %v", network, err) + } + + 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 939c04789..94a50570f 100644 --- a/client/firewall/uspfilter/forwarder/icmp.go +++ b/client/firewall/uspfilter/forwarder/icmp.go @@ -2,42 +2,118 @@ package forwarder import ( "context" + "fmt" "net" "net/netip" + "os/exec" + "runtime" "time" "github.com/google/uuid" "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" ) // handleICMP handles ICMP packets from the network stack -func (f *Forwarder) handleICMP(id stack.TransportEndpointID, pkt stack.PacketBufferPtr) bool { +func (f *Forwarder) handleICMP(id stack.TransportEndpointID, pkt *stack.PacketBuffer) bool { icmpHdr := header.ICMPv4(pkt.TransportHeader().View().AsSlice()) - icmpType := uint8(icmpHdr.Type()) - icmpCode := uint8(icmpHdr.Code()) - - if header.ICMPv4Type(icmpType) == header.ICMPv4EchoReply { - // dont process our own replies - return true - } flowID := uuid.New() - f.sendICMPEvent(nftypes.TypeStart, flowID, id, icmpType, icmpCode, 0, 0) + f.sendICMPEvent(nftypes.TypeStart, flowID, id, uint8(icmpHdr.Type()), uint8(icmpHdr.Code()), 0, 0) - ctx, cancel := context.WithTimeout(f.ctx, 5*time.Second) + // For Echo Requests, send and wait for response + if icmpHdr.Type() == header.ICMPv4Echo { + return f.handleICMPEcho(flowID, id, pkt, uint8(icmpHdr.Type()), uint8(icmpHdr.Code())) + } + + // For other ICMP types (Time Exceeded, Destination Unreachable, etc), forward without waiting + if !f.hasRawICMPAccess { + f.logger.Debug2("forwarder: Cannot handle ICMP 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()), false, 100*time.Millisecond) + if err != nil { + f.logger.Error2("forwarder: Failed to forward ICMP packet for %v: %v", epID(id), err) + return true + } + if err := conn.Close(); err != nil { + f.logger.Debug1("forwarder: Failed to close ICMP socket: %v", err) + } + + return true +} + +// handleICMPEcho handles ICMP echo requests asynchronously with rate limiting. +func (f *Forwarder) handleICMPEcho(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.hasRawICMPAccess { + f.handleICMPViaSocket(flowID, id, icmpType, icmpCode, icmpData, rxBytes, false) + } else { + f.handleICMPViaPing(flowID, id, icmpType, icmpCode, icmpData, rxBytes) + } + }() + default: + f.logger.Debug3("forwarder: ICMP rate limit exceeded for %v type %v code %v", + epID(id), icmpType, icmpCode) + } + return true +} + +// 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, v6 bool, timeout time.Duration) (net.PacketConn, error) { + ctx, cancel := context.WithTimeout(f.ctx, timeout) defer cancel() - lc := net.ListenConfig{} - // TODO: support non-root - conn, err := lc.ListenPacket(ctx, "ip4:icmp", "0.0.0.0") - if err != nil { - f.logger.Error2("forwarder: Failed to create ICMP socket for %v: %v", epID(id), err) + network, listenAddr := "ip4:icmp", "0.0.0.0" + if v6 { + network, listenAddr = "ip6:ipv6-icmp", "::" + } - // This will make netstack reply on behalf of the original destination, that's ok for now - return false + lc := net.ListenConfig{} + 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.AsSlice()} + + if _, err = conn.WriteTo(payload, dst); err != nil { + if closeErr := conn.Close(); closeErr != nil { + f.logger.Debug1("forwarder: Failed to close ICMP socket: %v", closeErr) + } + return nil, fmt.Errorf("write ICMP packet: %w", err) + } + + 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 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, v6, 5*time.Second) + if err != nil { + f.logger.Error2("forwarder: Failed to send ICMP packet for %v: %v", epID(id), err) + return } defer func() { if err := conn.Close(); err != nil { @@ -45,38 +121,28 @@ func (f *Forwarder) handleICMP(id stack.TransportEndpointID, pkt stack.PacketBuf } }() - dstIP := f.determineDialAddr(id.LocalAddress) - dst := &net.IPAddr{IP: dstIP} + txBytes := f.handleEchoResponse(conn, id, v6) + rtt := time.Since(sendTime).Round(10 * time.Microsecond) - fullPacket := stack.PayloadSince(pkt.TransportHeader()) - payload := fullPacket.AsSlice() - - if _, err = conn.WriteTo(payload, dst); err != nil { - f.logger.Error2("forwarder: Failed to write ICMP packet for %v: %v", epID(id), err) - return true + 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.logger.Trace3("forwarder: Forwarded ICMP packet %v type %v code %v", - epID(id), icmpHdr.Type(), icmpHdr.Code()) - - // For Echo Requests, send and handle response - if header.ICMPv4Type(icmpType) == header.ICMPv4Echo { - rxBytes := pkt.Size() - txBytes := f.handleEchoResponse(icmpHdr, conn, id) - f.sendICMPEvent(nftypes.TypeEnd, flowID, id, icmpType, icmpCode, uint64(rxBytes), uint64(txBytes)) - } - - // For other ICMP types (Time Exceeded, Destination Unreachable, etc) do nothing - return true + f.sendICMPEvent(nftypes.TypeEnd, flowID, id, icmpType, icmpCode, uint64(rxBytes), uint64(txBytes)) } -func (f *Forwarder) handleEchoResponse(icmpHdr header.ICMPv4, 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 } - response := make([]byte, f.endpoint.mtu) + response := make([]byte, f.endpoint.mtu.Load()) n, _, err := conn.ReadFrom(response) if err != nil { if !isTimeout(err) { @@ -85,31 +151,20 @@ func (f *Forwarder) handleEchoResponse(icmpHdr header.ICMPv4, conn net.PacketCon return 0 } - ipHdr := make([]byte, header.IPv4MinimumSize) - ip := header.IPv4(ipHdr) - ip.Encode(&header.IPv4Fields{ - TotalLength: uint16(header.IPv4MinimumSize + n), - TTL: 64, - Protocol: uint8(header.ICMPv4ProtocolNumber), - SrcAddr: id.LocalAddress, - DstAddr: id.RemoteAddress, - }) - ip.SetChecksum(^ip.CalculateChecksum()) - - fullPacket := make([]byte, 0, len(ipHdr)+n) - fullPacket = append(fullPacket, ipHdr...) - fullPacket = append(fullPacket, response[:n]...) - - if err := f.InjectIncomingPacket(fullPacket); err != nil { - f.logger.Error1("forwarder: Failed to inject ICMP response: %v", err) - - 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]) } - f.logger.Trace3("forwarder: Forwarded ICMP echo reply for %v type %v code %v", - epID(id), icmpHdr.Type(), icmpHdr.Code()) - - return len(fullPacket) + return f.injectICMPReply(id, response[:n]) } // sendICMPEvent stores flow events for ICMP packets @@ -122,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, @@ -152,3 +211,245 @@ func (f *Forwarder) sendICMPEvent(typ nftypes.Type, flowID uuid.UUID, id stack.T f.flowLogger.StoreEvent(fields) } + +// handleICMPViaPing handles ICMP echo requests by executing the system ping binary. +// This is used as a fallback when raw socket access is not available. +func (f *Forwarder) handleICMPViaPing(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: Ping binary failed for %v type %v code %v: %v", epID(id), + icmpType, icmpCode, err) + return + } + rtt := time.Since(pingStart).Round(10 * time.Microsecond) + + 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) + + 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. +// 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, pingBin, "-c", "1", "-W", timeoutStr, "-q", target.String()) + case "darwin", "ios": + bin := pingBin + if isV6 { + bin = ping6Bin + } + return exec.CommandContext(ctx, bin, "-c", "1", "-t", timeoutStr, "-q", target.String()) + case "freebsd": + return exec.CommandContext(ctx, pingBin, "-c", "1", "-t", timeoutStr, target.String()) + case "openbsd", "netbsd": + bin := pingBin + if isV6 { + bin = ping6Bin + } + return exec.CommandContext(ctx, bin, "-c", "1", "-w", timeoutStr, target.String()) + case "windows": + return exec.CommandContext(ctx, pingBin, "-n", "1", "-w", fmt.Sprintf("%d", timeoutSec*1000), target.String()) + default: + return exec.CommandContext(ctx, pingBin, "-c", "1", target.String()) + } +} + +// synthesizeEchoReply creates an ICMP echo reply from raw ICMP data and injects it back into the network stack. +// Returns the size of the injected packet. +func (f *Forwarder) synthesizeEchoReply(id stack.TransportEndpointID, icmpData []byte) int { + replyICMP := make([]byte, len(icmpData)) + copy(replyICMP, icmpData) + + replyICMPHdr := header.ICMPv4(replyICMP) + replyICMPHdr.SetType(header.ICMPv4EchoReply) + replyICMPHdr.SetChecksum(0) + replyICMPHdr.SetChecksum(header.ICMPv4Checksum(replyICMPHdr, 0)) + + return f.injectICMPReply(id, replyICMP) +} + +// injectICMPReply wraps an ICMP payload in an IP header and injects it into the network stack. +// Returns the total size of the injected packet, or 0 if injection failed. +func (f *Forwarder) injectICMPReply(id stack.TransportEndpointID, icmpPayload []byte) int { + ipHdr := make([]byte, header.IPv4MinimumSize) + ip := header.IPv4(ipHdr) + ip.Encode(&header.IPv4Fields{ + TotalLength: uint16(header.IPv4MinimumSize + len(icmpPayload)), + TTL: 64, + Protocol: uint8(header.ICMPv4ProtocolNumber), + SrcAddr: id.LocalAddress, + DstAddr: id.RemoteAddress, + }) + ip.SetChecksum(^ip.CalculateChecksum()) + + fullPacket := make([]byte, 0, len(ipHdr)+len(icmpPayload)) + fullPacket = append(fullPacket, ipHdr...) + fullPacket = append(fullPacket, icmpPayload...) + + // Bypass netstack and send directly to peer to avoid looping through our ICMP handler + if err := f.endpoint.device.CreateOutboundPacket(fullPacket, id.RemoteAddress.AsSlice()); err != nil { + f.logger.Error1("forwarder: Failed to send ICMP reply to peer: %v", err) + 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 55743d975..d840ef06b 100644 --- a/client/firewall/uspfilter/forwarder/udp.go +++ b/client/firewall/uspfilter/forwarder/udp.go @@ -4,8 +4,9 @@ import ( "context" "errors" "fmt" + "io" "net" - "net/netip" + "strconv" "sync" "sync/atomic" "time" @@ -124,17 +125,19 @@ 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)) + } } } } } // handleUDP is called by the UDP forwarder for new packets -func (f *Forwarder) handleUDP(r *udp.ForwarderRequest) { +func (f *Forwarder) handleUDP(r *udp.ForwarderRequest) bool { if f.ctx.Err() != nil { f.logger.Trace("forwarder: context done, dropping UDP packet") - return + return false } id := r.ID() @@ -143,8 +146,10 @@ func (f *Forwarder) handleUDP(r *udp.ForwarderRequest) { _, exists := f.udpForwarder.conns[id] f.udpForwarder.RUnlock() if exists { - f.logger.Trace1("forwarder: existing UDP connection for %v", epID(id)) - return + if f.logger.Enabled(nblog.LevelTrace) { + f.logger.Trace1("forwarder: existing UDP connection for %v", epID(id)) + } + return true } flowID := uuid.New() @@ -157,12 +162,12 @@ func (f *Forwarder) handleUDP(r *udp.ForwarderRequest) { } }() - 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) // TODO: Send ICMP error message - return + return false } // Create wait queue for blocking syscalls @@ -173,10 +178,10 @@ func (f *Forwarder) handleUDP(r *udp.ForwarderRequest) { if err := outConn.Close(); err != nil { f.logger.Debug2("forwarder: UDP outConn close error for %v: %v", epID(id), err) } - return + return false } - inConn := gonet.NewUDPConn(f.stack, &wq, ep) + inConn := gonet.NewUDPConn(&wq, ep) connCtx, connCancel := context.WithCancel(f.ctx) pConn := &udpPacketConn{ @@ -199,15 +204,18 @@ func (f *Forwarder) handleUDP(r *udp.ForwarderRequest) { if err := outConn.Close(); err != nil { f.logger.Debug2("forwarder: UDP outConn close error for %v: %v", epID(id), err) } - return + return true } f.udpForwarder.conns[id] = pConn 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 } func (f *Forwarder) proxyUDP(ctx context.Context, pConn *udpPacketConn, id stack.TransportEndpointID, ep tcpip.Endpoint) { @@ -263,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) @@ -274,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, @@ -348,7 +357,7 @@ func (c *udpPacketConn) copy(ctx context.Context, dst net.Conn, src net.Conn, bu } func isClosedError(err error) bool { - return errors.Is(err, net.ErrClosed) || errors.Is(err, context.Canceled) + return errors.Is(err, net.ErrClosed) || errors.Is(err, context.Canceled) || errors.Is(err, io.EOF) } func isTimeout(err error) bool { 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..6960e4dda --- /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(ifaceMock, false, flowLogger, 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.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), 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.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), 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.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), 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.AddPeerFiltering(nil, net.ParseIP(fragTestSrc), 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.AddRouteFiltering( + []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.AddPeerFiltering(nil, net.ParseIP(fragTestSrcV6), 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.AddPeerFiltering(nil, net.ParseIP(fragTestSrcV6), 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 new file mode 100644 index 000000000..f3adf5f8b --- /dev/null +++ b/client/firewall/uspfilter/hooks_filter.go @@ -0,0 +1,89 @@ +package uspfilter + +import ( + "encoding/binary" + "net/netip" + "sync/atomic" + + "github.com/netbirdio/netbird/client/firewall/uspfilter/common" + "github.com/netbirdio/netbird/client/iface/device" +) + +const ( + ipv4HeaderMinLen = 20 + ipv4ProtoOffset = 9 + ipv4FlagsOffset = 6 + ipProtoUDP = 17 + ipProtoTCP = 6 + ipv4FragOffMask = 0x1fff + // dstPortOffset is the offset of the destination port within a UDP or TCP header. + dstPortOffset = 2 +) + +// HooksFilter is a minimal packet filter that only handles outbound DNS hooks. +// It is installed on the WireGuard interface when the userspace bind is active +// but a full firewall filter (Manager) is not needed because a native kernel +// firewall (nftables/iptables) handles packet filtering. +type HooksFilter struct { + udpHook atomic.Pointer[common.PacketHook] + tcpHook atomic.Pointer[common.PacketHook] +} + +var _ device.PacketFilter = (*HooksFilter)(nil) + +// FilterOutbound checks outbound packets for DNS hook matches. +// Only IPv4 packets matching the registered hook IP:port are intercepted. +// IPv6 and non-IP packets pass through unconditionally. +func (f *HooksFilter) FilterOutbound(packetData []byte, _ int) bool { + if len(packetData) < ipv4HeaderMinLen { + return false + } + + // Only process IPv4 packets, let everything else pass through. + if packetData[0]>>4 != 4 { + return false + } + + ihl := int(packetData[0]&0x0f) * 4 + if ihl < ipv4HeaderMinLen || len(packetData) < ihl+4 { + return false + } + + // Skip non-first fragments: they don't carry L4 headers. + flagsAndOffset := binary.BigEndian.Uint16(packetData[ipv4FlagsOffset : ipv4FlagsOffset+2]) + if flagsAndOffset&ipv4FragOffMask != 0 { + return false + } + + dstIP, ok := netip.AddrFromSlice(packetData[ipv4DstOffset : ipv4DstOffset+4]) + if !ok { + return false + } + + proto := packetData[ipv4ProtoOffset] + dstPort := binary.BigEndian.Uint16(packetData[ihl+dstPortOffset : ihl+dstPortOffset+2]) + + switch proto { + case ipProtoUDP: + return common.HookMatches(f.udpHook.Load(), dstIP, dstPort, packetData) + case ipProtoTCP: + return common.HookMatches(f.tcpHook.Load(), dstIP, dstPort, packetData) + default: + return false + } +} + +// FilterInbound allows all inbound packets (native firewall handles filtering). +func (f *HooksFilter) FilterInbound([]byte, int) bool { + return false +} + +// SetUDPPacketHook registers the UDP packet hook. +func (f *HooksFilter) SetUDPPacketHook(ip netip.Addr, dPort uint16, hook func([]byte) bool) { + common.SetHook(&f.udpHook, ip, dPort, hook) +} + +// SetTCPPacketHook registers the TCP packet hook. +func (f *HooksFilter) SetTCPPacketHook(ip netip.Addr, dPort uint16, hook func([]byte) bool) { + common.SetHook(&f.tcpHook, ip, dPort, hook) +} diff --git a/client/firewall/uspfilter/localip.go b/client/firewall/uspfilter/localip.go index 7f6b52c71..b35be56c6 100644 --- a/client/firewall/uspfilter/localip.go +++ b/client/firewall/uspfilter/localip.go @@ -4,89 +4,32 @@ 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,18 +47,19 @@ 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) } } +// UpdateLocalIPs rebuilds the local IP snapshot and swaps it in atomically. func (m *localIPManager) UpdateLocalIPs(iface common.IFaceMapper) (err error) { defer func() { if r := recover(); r != nil { @@ -123,19 +67,20 @@ func (m *localIPManager) UpdateLocalIPs(iface common.IFaceMapper) (err error) { } }() - 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++ { - 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) } } @@ -143,26 +88,27 @@ func (m *localIPManager) UpdateLocalIPs(iface common.IFaceMapper) (err error) { if err != nil { log.Warnf("failed to get interfaces: %v", err) } else { + // TODO: filter out down interfaces (net.FlagUp). Also handle the reverse + // case where an interface comes up between refreshes. for _, intf := range interfaces { - m.processInterface(intf, &newIPv4Bitmap, ipv4Set, &ipv4Addresses) + 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 45ac912cd..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 139f702f2..03e7d4809 100644 --- a/client/firewall/uspfilter/log/log.go +++ b/client/firewall/uspfilter/log/log.go @@ -5,6 +5,8 @@ import ( "context" "fmt" "io" + "os" + "strconv" "sync" "sync/atomic" "time" @@ -16,9 +18,18 @@ const ( maxBatchSize = 1024 * 16 maxMessageSize = 1024 * 2 defaultFlushInterval = 2 * time.Second - logChannelSize = 1000 + defaultLogChanSize = 1000 ) +func getLogChannelSize() int { + if v := os.Getenv("NB_USPFILTER_LOG_BUFFER"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return n + } + } + return defaultLogChanSize +} + type Level uint32 const ( @@ -42,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 @@ -69,7 +81,7 @@ type Logger struct { func NewFromLogrus(logrusLogger *log.Logger) *Logger { l := &Logger{ output: logrusLogger.Out, - msgChannel: make(chan logMessage, logChannelSize), + msgChannel: make(chan logMessage, getLogChannelSize()), shutdown: make(chan struct{}), bufPool: sync.Pool{ New: func() any { @@ -96,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 { @@ -144,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: } } @@ -153,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: } } @@ -162,7 +190,16 @@ 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: + } + } +} + +func (l *Logger) Warn4(format string, arg1, arg2, arg3, arg4 any) { + if l.level.Load() >= uint32(LevelWarn) { + select { + case l.msgChannel <- logMessage{level: LevelWarn, argCount: 4, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4}: default: } } @@ -171,7 +208,7 @@ func (l *Logger) Warn3(format string, arg1, arg2, arg3 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: } } @@ -180,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: } } @@ -189,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: } } @@ -207,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: } } @@ -216,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: } } @@ -225,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: } } @@ -234,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: } } @@ -243,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: } } @@ -253,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: } } @@ -266,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 13567872e..5d51c1538 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) @@ -358,9 +458,9 @@ func incrementalUpdate(oldChecksum uint16, oldBytes, newBytes []byte) uint16 { // Fast path for IPv4 addresses (4 bytes) - most common case if len(oldBytes) == 4 && len(newBytes) == 4 { sum += uint32(^binary.BigEndian.Uint16(oldBytes[0:2])) - sum += uint32(^binary.BigEndian.Uint16(oldBytes[2:4])) + sum += uint32(^binary.BigEndian.Uint16(oldBytes[2:4])) //nolint:gosec // length checked above sum += uint32(binary.BigEndian.Uint16(newBytes[0:2])) - sum += uint32(binary.BigEndian.Uint16(newBytes[2:4])) + sum += uint32(binary.BigEndian.Uint16(newBytes[2:4])) //nolint:gosec // length checked above } else { // Fallback for other lengths for i := 0; i < len(oldBytes)-1; i += 2 { @@ -403,14 +503,14 @@ func (m *Manager) DeleteDNATRule(rule firewall.Rule) error { } // 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,7 +521,8 @@ func (m *Manager) addPortRedirection(targetIP netip.Addr, protocol gopacket.Laye } // 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 { +// TODO: also delegate to nativeFirewall when available for kernel WG mode +func (m *Manager) AddInboundDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { var layerType gopacket.LayerType switch protocol { case firewall.ProtocolTCP: @@ -432,16 +533,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 { @@ -452,7 +553,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: @@ -463,7 +564,23 @@ 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, originalPort, translatedPort uint16) error { + if m.nativeFirewall == nil { + return fmt.Errorf("output DNAT not supported without native firewall") + } + return m.nativeFirewall.AddOutputDNAT(localAddr, protocol, originalPort, translatedPort) +} + +// RemoveOutputDNAT delegates to the native firewall if available. +func (m *Manager) RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error { + if m.nativeFirewall == nil { + return nil + } + return m.nativeFirewall.RemoveOutputDNAT(localAddr, protocol, originalPort, translatedPort) } // translateInboundPortDNAT applies port-specific DNAT translation to inbound packets. @@ -504,7 +621,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 @@ -515,12 +634,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") } @@ -546,12 +665,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..1e15c8c0c 100644 --- a/client/firewall/uspfilter/nat_bench_test.go +++ b/client/firewall/uspfilter/nat_bench_test.go @@ -342,12 +342,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 +376,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++ { diff --git a/client/firewall/uspfilter/nat_stateful_test.go b/client/firewall/uspfilter/nat_stateful_test.go new file mode 100644 index 000000000..21c6da06e --- /dev/null +++ b/client/firewall/uspfilter/nat_stateful_test.go @@ -0,0 +1,85 @@ +package uspfilter + +import ( + "net/netip" + "testing" + + "github.com/google/gopacket/layers" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/client/iface/device" +) + +// 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) + require.NoError(t, err) + defer func() { + require.NoError(t, manager.Close(nil)) + }() + + // Define peer IPs + peerA := netip.MustParseAddr("100.10.0.50") + peerB := netip.MustParseAddr("100.10.0.51") + + // Add SSH port redirection rule for peer B (the target) + err = manager.addPortRedirection(peerB, layers.LayerTypeTCP, 22, 22022) + require.NoError(t, err) + + // Scenario: Peer A connects to Peer B on port 22 (should get NAT) + packetAtoB := generateDNATTestPacket(t, peerA, peerB, layers.IPProtocolTCP, 54321, 22) + d := parsePacket(t, packetAtoB) + translatedAtoB := manager.translateInboundPortDNAT(packetAtoB, d, peerA, peerB) + require.True(t, translatedAtoB, "Peer A to Peer B should be translated (NAT applied)") + + // Verify port was translated to 22022 + d = parsePacket(t, packetAtoB) + require.Equal(t, uint16(22022), uint16(d.tcp.DstPort), "Port should be rewritten to 22022") + + // Scenario: Return traffic from Peer B to Peer A should NOT be translated + // (prevents double NAT - original port stored in conntrack) + returnPacket := generateDNATTestPacket(t, peerB, peerA, layers.IPProtocolTCP, 22022, 54321) + d2 := parsePacket(t, returnPacket) + translatedReturn := manager.translateInboundPortDNAT(returnPacket, d2, peerB, peerA) + require.False(t, translatedReturn, "Return traffic from same IP should not be translated") +} + +// 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) + require.NoError(t, err) + defer func() { + require.NoError(t, manager.Close(nil)) + }() + + // Define peer IPs + peerA := netip.MustParseAddr("100.10.0.50") + peerB := netip.MustParseAddr("100.10.0.51") + + // Add SSH port redirection rules for both peers + err = manager.addPortRedirection(peerA, layers.LayerTypeTCP, 22, 22022) + require.NoError(t, err) + err = manager.addPortRedirection(peerB, layers.LayerTypeTCP, 22, 22022) + require.NoError(t, err) + + // Test traffic to peer B gets translated + packetToB := generateDNATTestPacket(t, peerA, peerB, layers.IPProtocolTCP, 54321, 22) + d1 := parsePacket(t, packetToB) + translatedToB := manager.translateInboundPortDNAT(packetToB, d1, peerA, peerB) + require.True(t, translatedToB, "Traffic to peer B should be translated") + d1 = parsePacket(t, packetToB) + require.Equal(t, uint16(22022), uint16(d1.tcp.DstPort), "Port should be 22022") + + // Test traffic to peer A gets translated + packetToA := generateDNATTestPacket(t, peerB, peerA, layers.IPProtocolTCP, 54322, 22) + d2 := parsePacket(t, packetToA) + translatedToA := manager.translateInboundPortDNAT(packetToA, d2, peerB, peerA) + require.True(t, translatedToA, "Traffic to peer A should be translated") + d2 = parsePacket(t, packetToA) + require.Equal(t, uint16(22022), uint16(d2.tcp.DstPort), "Port should be 22022") +} diff --git a/client/firewall/uspfilter/nat_test.go b/client/firewall/uspfilter/nat_test.go index 400d61020..4598c3901 100644 --- a/client/firewall/uspfilter/nat_test.go +++ b/client/firewall/uspfilter/nat_test.go @@ -86,13 +86,18 @@ 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 } @@ -234,9 +239,10 @@ func TestInboundPortDNATNegative(t *testing.T) { require.False(t, translated, "Packet should NOT be translated for %s", tc.name) d = parsePacket(t, packet) - if tc.protocol == layers.IPProtocolTCP { + switch tc.protocol { + case layers.IPProtocolTCP: require.Equal(t, tc.dstPort, uint16(d.tcp.DstPort), "Port should remain unchanged") - } else if tc.protocol == layers.IPProtocolUDP { + case layers.IPProtocolUDP: require.Equal(t, tc.dstPort, uint16(d.udp.DstPort), "Port should remain unchanged") } }) diff --git a/client/firewall/uspfilter/rule.go b/client/firewall/uspfilter/rule.go index b765c72e9..4f135cbd6 100644 --- a/client/firewall/uspfilter/rule.go +++ b/client/firewall/uspfilter/rule.go @@ -19,8 +19,6 @@ type PeerRule struct { sPort *firewall.Port dPort *firewall.Port drop bool - - udpHook func([]byte) bool } // ID returns the rule id @@ -34,7 +32,7 @@ type RouteRule struct { sources []netip.Prefix dstSet firewall.Set destinations []netip.Prefix - proto firewall.Protocol + protoLayer gopacket.LayerType srcPort *firewall.Port dstPort *firewall.Port action firewall.Action diff --git a/client/firewall/uspfilter/tracer.go b/client/firewall/uspfilter/tracer.go index c46a6581d..696489e95 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 } @@ -256,6 +297,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 +362,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 } @@ -379,9 +429,9 @@ func (m *Manager) handleNativeRouter(trace *PacketTrace) *PacketTrace { } func (m *Manager) handleRouteACLs(trace *PacketTrace, d *decoder, srcIP, dstIP netip.Addr) *PacketTrace { - proto, _ := getProtocolFromPacket(d) + protoLayer := d.decoded[1] srcPort, dstPort := getPortsFromPacket(d) - id, allowed := m.routeACLsPass(srcIP, dstIP, proto, srcPort, dstPort) + id, allowed := m.routeACLsPass(srcIP, dstIP, protoLayer, srcPort, dstPort) strId := string(id) if id == nil { @@ -395,7 +445,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 +465,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 +484,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 +494,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 +559,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 +589,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 d9f9f1aa8..657f96fc0 100644 --- a/client/firewall/uspfilter/tracer_test.go +++ b/client/firewall/uspfilter/tracer_test.go @@ -399,21 +399,17 @@ func TestTracePacket(t *testing.T) { { name: "UDPTraffic_WithHook", setup: func(m *Manager) { - hookFunc := func([]byte) bool { - return true - } - m.AddUDPPacketHook(true, netip.MustParseAddr("1.1.1.1"), 53, hookFunc) + m.SetUDPPacketHook(netip.MustParseAddr("100.10.255.254"), 53, func([]byte) bool { + return true // drop (intercepted by hook) + }) }, packetBuilder: func() *PacketBuilder { - return createPacketBuilder("1.1.1.1", "100.10.0.100", "udp", 12345, 53, fw.RuleDirectionIN) + return createPacketBuilder("100.10.0.100", "100.10.255.254", "udp", 12345, 53, fw.RuleDirectionOUT) }, expectedStages: []PacketStage{ StageReceived, - StageInboundPortDNAT, - StageInbound1to1NAT, - StageConntrack, - StageRouting, - StagePeerACL, + StageOutbound1to1NAT, + StageOutboundPortReverse, StageCompleted, }, expectedAllow: false, diff --git a/client/grpc/dialer.go b/client/grpc/dialer.go index 7763f2417..9a6bc0670 100644 --- a/client/grpc/dialer.go +++ b/client/grpc/dialer.go @@ -4,7 +4,6 @@ import ( "context" "crypto/tls" "crypto/x509" - "errors" "fmt" "runtime" "time" @@ -12,7 +11,6 @@ import ( "github.com/cenkalti/backoff/v4" log "github.com/sirupsen/logrus" "google.golang.org/grpc" - "google.golang.org/grpc/connectivity" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/keepalive" @@ -20,9 +18,6 @@ import ( "github.com/netbirdio/netbird/util/embeddedroots" ) -// ErrConnectionShutdown indicates that the connection entered shutdown state before becoming ready -var ErrConnectionShutdown = errors.New("connection shutdown before ready") - // Backoff returns a backoff configuration for gRPC calls func Backoff(ctx context.Context) backoff.BackOff { b := backoff.NewExponentialBackOff() @@ -31,29 +26,9 @@ func Backoff(ctx context.Context) backoff.BackOff { return backoff.WithContext(b, ctx) } -// waitForConnectionReady blocks until the connection becomes ready or fails. -// Returns an error if the connection times out, is cancelled, or enters shutdown state. -func waitForConnectionReady(ctx context.Context, conn *grpc.ClientConn) error { - conn.Connect() - - state := conn.GetState() - for state != connectivity.Ready && state != connectivity.Shutdown { - if !conn.WaitForStateChange(ctx, state) { - return fmt.Errorf("wait state change from %s: %w", state, ctx.Err()) - } - state = conn.GetState() - } - - if state == connectivity.Shutdown { - return ErrConnectionShutdown - } - - return nil -} - // CreateConnection creates a gRPC client connection with the appropriate transport options. // The component parameter specifies the WebSocket proxy component path (e.g., "/management", "/signal"). -func CreateConnection(ctx context.Context, addr string, tlsEnabled bool, component string) (*grpc.ClientConn, error) { +func CreateConnection(ctx context.Context, addr string, tlsEnabled bool, component string, extraOpts ...grpc.DialOption) (*grpc.ClientConn, error) { transportOption := grpc.WithTransportCredentials(insecure.NewCredentials()) // for js, the outer websocket layer takes care of tls if tlsEnabled && runtime.GOOS != "js" { @@ -68,25 +43,23 @@ func CreateConnection(ctx context.Context, addr string, tlsEnabled bool, compone })) } - conn, err := grpc.NewClient( - addr, + connCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + opts := []grpc.DialOption{ transportOption, WithCustomDialer(tlsEnabled, component), + grpc.WithBlock(), grpc.WithKeepaliveParams(keepalive.ClientParameters{ Time: 30 * time.Second, Timeout: 10 * time.Second, }), - ) - if err != nil { - return nil, fmt.Errorf("new client: %w", err) } + opts = append(opts, extraOpts...) - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - if err := waitForConnectionReady(ctx, conn); err != nil { - _ = conn.Close() - return nil, err + conn, err := grpc.DialContext(connCtx, addr, opts...) + if err != nil { + return nil, fmt.Errorf("dial context: %w", err) } return conn, nil diff --git a/client/iface/bind/dual_stack_conn.go b/client/iface/bind/dual_stack_conn.go new file mode 100644 index 000000000..061016ecc --- /dev/null +++ b/client/iface/bind/dual_stack_conn.go @@ -0,0 +1,169 @@ +package bind + +import ( + "errors" + "net" + "sync" + "time" + + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + + nberrors "github.com/netbirdio/netbird/client/errors" +) + +var ( + errNoIPv4Conn = errors.New("no IPv4 connection available") + errNoIPv6Conn = errors.New("no IPv6 connection available") + errInvalidAddr = errors.New("invalid address type") +) + +// DualStackPacketConn wraps IPv4 and IPv6 UDP connections and routes writes +// to the appropriate connection based on the destination address. +// ReadFrom is not used in the hot path - ICEBind receives packets via +// BatchReader.ReadBatch() directly. This is only used by udpMux for sending. +type DualStackPacketConn struct { + ipv4Conn net.PacketConn + ipv6Conn net.PacketConn + + readFromWarn sync.Once +} + +// NewDualStackPacketConn creates a new dual-stack packet connection. +func NewDualStackPacketConn(ipv4Conn, ipv6Conn net.PacketConn) *DualStackPacketConn { + return &DualStackPacketConn{ + ipv4Conn: ipv4Conn, + ipv6Conn: ipv6Conn, + } +} + +// ReadFrom reads from the available connection (preferring IPv4). +// NOTE: This method is NOT used in the data path. ICEBind receives packets via +// BatchReader.ReadBatch() directly for both IPv4 and IPv6, which is much more efficient. +// This implementation exists only to satisfy the net.PacketConn interface for the udpMux, +// but the udpMux only uses WriteTo() for sending STUN responses - it never calls ReadFrom() +// because STUN packets are filtered and forwarded via HandleSTUNMessage() from the receive path. +func (d *DualStackPacketConn) ReadFrom(b []byte) (n int, addr net.Addr, err error) { + d.readFromWarn.Do(func() { + log.Warn("DualStackPacketConn.ReadFrom called - this is unexpected and may indicate an inefficient code path") + }) + + if d.ipv4Conn != nil { + return d.ipv4Conn.ReadFrom(b) + } + if d.ipv6Conn != nil { + return d.ipv6Conn.ReadFrom(b) + } + return 0, nil, net.ErrClosed +} + +// WriteTo writes to the appropriate connection based on the address type. +func (d *DualStackPacketConn) WriteTo(b []byte, addr net.Addr) (n int, err error) { + udpAddr, ok := addr.(*net.UDPAddr) + if !ok { + return 0, &net.OpError{ + Op: "write", + Net: "udp", + Addr: addr, + Err: errInvalidAddr, + } + } + + if udpAddr.IP.To4() == nil { + if d.ipv6Conn != nil { + return d.ipv6Conn.WriteTo(b, addr) + } + return 0, &net.OpError{ + Op: "write", + Net: "udp6", + Addr: addr, + Err: errNoIPv6Conn, + } + } + + if d.ipv4Conn != nil { + return d.ipv4Conn.WriteTo(b, addr) + } + return 0, &net.OpError{ + Op: "write", + Net: "udp4", + Addr: addr, + Err: errNoIPv4Conn, + } +} + +// Close closes both connections. +func (d *DualStackPacketConn) Close() error { + var result *multierror.Error + if d.ipv4Conn != nil { + if err := d.ipv4Conn.Close(); err != nil { + result = multierror.Append(result, err) + } + } + if d.ipv6Conn != nil { + if err := d.ipv6Conn.Close(); err != nil { + result = multierror.Append(result, err) + } + } + return nberrors.FormatErrorOrNil(result) +} + +// LocalAddr returns the local address of the IPv4 connection if available, +// otherwise the IPv6 connection. +func (d *DualStackPacketConn) LocalAddr() net.Addr { + if d.ipv4Conn != nil { + return d.ipv4Conn.LocalAddr() + } + if d.ipv6Conn != nil { + return d.ipv6Conn.LocalAddr() + } + return nil +} + +// SetDeadline sets the deadline for both connections. +func (d *DualStackPacketConn) SetDeadline(t time.Time) error { + var result *multierror.Error + if d.ipv4Conn != nil { + if err := d.ipv4Conn.SetDeadline(t); err != nil { + result = multierror.Append(result, err) + } + } + if d.ipv6Conn != nil { + if err := d.ipv6Conn.SetDeadline(t); err != nil { + result = multierror.Append(result, err) + } + } + return nberrors.FormatErrorOrNil(result) +} + +// SetReadDeadline sets the read deadline for both connections. +func (d *DualStackPacketConn) SetReadDeadline(t time.Time) error { + var result *multierror.Error + if d.ipv4Conn != nil { + if err := d.ipv4Conn.SetReadDeadline(t); err != nil { + result = multierror.Append(result, err) + } + } + if d.ipv6Conn != nil { + if err := d.ipv6Conn.SetReadDeadline(t); err != nil { + result = multierror.Append(result, err) + } + } + return nberrors.FormatErrorOrNil(result) +} + +// SetWriteDeadline sets the write deadline for both connections. +func (d *DualStackPacketConn) SetWriteDeadline(t time.Time) error { + var result *multierror.Error + if d.ipv4Conn != nil { + if err := d.ipv4Conn.SetWriteDeadline(t); err != nil { + result = multierror.Append(result, err) + } + } + if d.ipv6Conn != nil { + if err := d.ipv6Conn.SetWriteDeadline(t); err != nil { + result = multierror.Append(result, err) + } + } + return nberrors.FormatErrorOrNil(result) +} diff --git a/client/iface/bind/dual_stack_conn_bench_test.go b/client/iface/bind/dual_stack_conn_bench_test.go new file mode 100644 index 000000000..940c44966 --- /dev/null +++ b/client/iface/bind/dual_stack_conn_bench_test.go @@ -0,0 +1,119 @@ +package bind + +import ( + "net" + "testing" +) + +var ( + ipv4Addr = &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 12345} + ipv6Addr = &net.UDPAddr{IP: net.ParseIP("::1"), Port: 12345} + payload = make([]byte, 1200) +) + +func BenchmarkWriteTo_DirectUDPConn(b *testing.B) { + conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) + if err != nil { + b.Fatal(err) + } + defer conn.Close() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = conn.WriteTo(payload, ipv4Addr) + } +} + +func BenchmarkWriteTo_DualStack_IPv4Only(b *testing.B) { + conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) + if err != nil { + b.Fatal(err) + } + defer conn.Close() + + ds := NewDualStackPacketConn(conn, nil) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = ds.WriteTo(payload, ipv4Addr) + } +} + +func BenchmarkWriteTo_DualStack_IPv6Only(b *testing.B) { + conn, err := net.ListenUDP("udp6", &net.UDPAddr{IP: net.IPv6zero, Port: 0}) + if err != nil { + b.Skipf("IPv6 not available: %v", err) + } + defer conn.Close() + + ds := NewDualStackPacketConn(nil, conn) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = ds.WriteTo(payload, ipv6Addr) + } +} + +func BenchmarkWriteTo_DualStack_Both_IPv4Traffic(b *testing.B) { + conn4, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) + if err != nil { + b.Fatal(err) + } + defer conn4.Close() + + conn6, err := net.ListenUDP("udp6", &net.UDPAddr{IP: net.IPv6zero, Port: 0}) + if err != nil { + b.Skipf("IPv6 not available: %v", err) + } + defer conn6.Close() + + ds := NewDualStackPacketConn(conn4, conn6) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = ds.WriteTo(payload, ipv4Addr) + } +} + +func BenchmarkWriteTo_DualStack_Both_IPv6Traffic(b *testing.B) { + conn4, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) + if err != nil { + b.Fatal(err) + } + defer conn4.Close() + + conn6, err := net.ListenUDP("udp6", &net.UDPAddr{IP: net.IPv6zero, Port: 0}) + if err != nil { + b.Skipf("IPv6 not available: %v", err) + } + defer conn6.Close() + + ds := NewDualStackPacketConn(conn4, conn6) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = ds.WriteTo(payload, ipv6Addr) + } +} + +func BenchmarkWriteTo_DualStack_Both_MixedTraffic(b *testing.B) { + conn4, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) + if err != nil { + b.Fatal(err) + } + defer conn4.Close() + + conn6, err := net.ListenUDP("udp6", &net.UDPAddr{IP: net.IPv6zero, Port: 0}) + if err != nil { + b.Skipf("IPv6 not available: %v", err) + } + defer conn6.Close() + + ds := NewDualStackPacketConn(conn4, conn6) + addrs := []net.Addr{ipv4Addr, ipv6Addr} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = ds.WriteTo(payload, addrs[i&1]) + } +} diff --git a/client/iface/bind/dual_stack_conn_test.go b/client/iface/bind/dual_stack_conn_test.go new file mode 100644 index 000000000..3007d907f --- /dev/null +++ b/client/iface/bind/dual_stack_conn_test.go @@ -0,0 +1,191 @@ +package bind + +import ( + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDualStackPacketConn_RoutesWritesToCorrectSocket(t *testing.T) { + ipv4Conn := &mockPacketConn{network: "udp4"} + ipv6Conn := &mockPacketConn{network: "udp6"} + dualStack := NewDualStackPacketConn(ipv4Conn, ipv6Conn) + + tests := []struct { + name string + addr *net.UDPAddr + wantSocket string + }{ + { + name: "IPv4 address", + addr: &net.UDPAddr{IP: net.ParseIP("192.168.1.1"), Port: 1234}, + wantSocket: "udp4", + }, + { + name: "IPv6 address", + addr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 1234}, + wantSocket: "udp6", + }, + { + name: "IPv4-mapped IPv6 goes to IPv4", + addr: &net.UDPAddr{IP: net.ParseIP("::ffff:192.168.1.1"), Port: 1234}, + wantSocket: "udp4", + }, + { + name: "IPv4 loopback", + addr: &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 1234}, + wantSocket: "udp4", + }, + { + name: "IPv6 loopback", + addr: &net.UDPAddr{IP: net.ParseIP("::1"), Port: 1234}, + wantSocket: "udp6", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ipv4Conn.writeCount = 0 + ipv6Conn.writeCount = 0 + + n, err := dualStack.WriteTo([]byte("test"), tt.addr) + require.NoError(t, err) + assert.Equal(t, 4, n) + + if tt.wantSocket == "udp4" { + assert.Equal(t, 1, ipv4Conn.writeCount, "expected write to IPv4") + assert.Equal(t, 0, ipv6Conn.writeCount, "expected no write to IPv6") + } else { + assert.Equal(t, 0, ipv4Conn.writeCount, "expected no write to IPv4") + assert.Equal(t, 1, ipv6Conn.writeCount, "expected write to IPv6") + } + }) + } +} + +func TestDualStackPacketConn_IPv4OnlyRejectsIPv6(t *testing.T) { + dualStack := NewDualStackPacketConn(&mockPacketConn{network: "udp4"}, nil) + + // IPv4 works + _, err := dualStack.WriteTo([]byte("test"), &net.UDPAddr{IP: net.ParseIP("192.168.1.1"), Port: 1234}) + require.NoError(t, err) + + // IPv6 fails + _, err = dualStack.WriteTo([]byte("test"), &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 1234}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no IPv6 connection") +} + +func TestDualStackPacketConn_IPv6OnlyRejectsIPv4(t *testing.T) { + dualStack := NewDualStackPacketConn(nil, &mockPacketConn{network: "udp6"}) + + // IPv6 works + _, err := dualStack.WriteTo([]byte("test"), &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 1234}) + require.NoError(t, err) + + // IPv4 fails + _, err = dualStack.WriteTo([]byte("test"), &net.UDPAddr{IP: net.ParseIP("192.168.1.1"), Port: 1234}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no IPv4 connection") +} + +// TestDualStackPacketConn_ReadFromIsNotUsedInHotPath documents that ReadFrom +// only reads from one socket (IPv4 preferred). This is fine because the actual +// receive path uses wireguard-go's BatchReader directly, not ReadFrom. +func TestDualStackPacketConn_ReadFromIsNotUsedInHotPath(t *testing.T) { + ipv4Conn := &mockPacketConn{ + network: "udp4", + readData: []byte("from ipv4"), + readAddr: &net.UDPAddr{IP: net.ParseIP("192.168.1.1"), Port: 1234}, + } + ipv6Conn := &mockPacketConn{ + network: "udp6", + readData: []byte("from ipv6"), + readAddr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 1234}, + } + + dualStack := NewDualStackPacketConn(ipv4Conn, ipv6Conn) + + buf := make([]byte, 100) + n, addr, err := dualStack.ReadFrom(buf) + + require.NoError(t, err) + // reads from IPv4 (preferred) - this is expected behavior + assert.Equal(t, "from ipv4", string(buf[:n])) + assert.Equal(t, "192.168.1.1", addr.(*net.UDPAddr).IP.String()) +} + +func TestDualStackPacketConn_LocalAddrPrefersIPv4(t *testing.T) { + ipv4Addr := &net.UDPAddr{IP: net.ParseIP("0.0.0.0"), Port: 51820} + ipv6Addr := &net.UDPAddr{IP: net.ParseIP("::"), Port: 51820} + + tests := []struct { + name string + ipv4 net.PacketConn + ipv6 net.PacketConn + wantAddr net.Addr + }{ + { + name: "both available returns IPv4", + ipv4: &mockPacketConn{localAddr: ipv4Addr}, + ipv6: &mockPacketConn{localAddr: ipv6Addr}, + wantAddr: ipv4Addr, + }, + { + name: "IPv4 only", + ipv4: &mockPacketConn{localAddr: ipv4Addr}, + ipv6: nil, + wantAddr: ipv4Addr, + }, + { + name: "IPv6 only", + ipv4: nil, + ipv6: &mockPacketConn{localAddr: ipv6Addr}, + wantAddr: ipv6Addr, + }, + { + name: "neither returns nil", + ipv4: nil, + ipv6: nil, + wantAddr: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dualStack := NewDualStackPacketConn(tt.ipv4, tt.ipv6) + assert.Equal(t, tt.wantAddr, dualStack.LocalAddr()) + }) + } +} + +// mock + +type mockPacketConn struct { + network string + writeCount int + readData []byte + readAddr net.Addr + localAddr net.Addr +} + +func (m *mockPacketConn) ReadFrom(b []byte) (n int, addr net.Addr, err error) { + if m.readData != nil { + return copy(b, m.readData), m.readAddr, nil + } + return 0, nil, nil +} + +func (m *mockPacketConn) WriteTo(b []byte, addr net.Addr) (n int, err error) { + m.writeCount++ + return len(b), nil +} + +func (m *mockPacketConn) Close() error { return nil } +func (m *mockPacketConn) LocalAddr() net.Addr { return m.localAddr } +func (m *mockPacketConn) SetDeadline(t time.Time) error { return nil } +func (m *mockPacketConn) SetReadDeadline(t time.Time) error { return nil } +func (m *mockPacketConn) SetWriteDeadline(t time.Time) error { return nil } diff --git a/client/iface/bind/ice_bind.go b/client/iface/bind/ice_bind.go index dfb22ecde..156450c61 100644 --- a/client/iface/bind/ice_bind.go +++ b/client/iface/bind/ice_bind.go @@ -14,7 +14,6 @@ import ( "github.com/pion/stun/v3" "github.com/pion/transport/v3" log "github.com/sirupsen/logrus" - "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" wgConn "golang.zx2c4.com/wireguard/conn" @@ -27,8 +26,8 @@ type receiverCreator struct { iceBind *ICEBind } -func (rc receiverCreator) CreateIPv4ReceiverFn(pc *ipv4.PacketConn, conn *net.UDPConn, rxOffload bool, msgPool *sync.Pool) wgConn.ReceiveFunc { - return rc.iceBind.createIPv4ReceiverFn(pc, conn, rxOffload, msgPool) +func (rc receiverCreator) CreateReceiverFn(pc wgConn.BatchReader, conn *net.UDPConn, rxOffload bool, msgPool *sync.Pool) wgConn.ReceiveFunc { + return rc.iceBind.createReceiverFn(pc, conn, rxOffload, msgPool) } // ICEBind is a bind implementation with two main features: @@ -42,7 +41,6 @@ type ICEBind struct { *wgConn.StdNetBind transportNet transport.Net - filterFn udpmux.FilterFn address wgaddr.Address mtu uint16 @@ -58,14 +56,15 @@ type ICEBind struct { muUDPMux sync.Mutex udpMux *udpmux.UniversalUDPMuxDefault + ipv4Conn *net.UDPConn + 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), @@ -103,6 +102,12 @@ func (s *ICEBind) Close() error { close(s.closedChan) + s.muUDPMux.Lock() + s.ipv4Conn = nil + s.ipv6Conn = nil + s.udpMux = nil + s.muUDPMux.Unlock() + return s.StdNetBind.Close() } @@ -160,19 +165,18 @@ func (b *ICEBind) Send(bufs [][]byte, ep wgConn.Endpoint) error { return nil } -func (s *ICEBind) createIPv4ReceiverFn(pc *ipv4.PacketConn, conn *net.UDPConn, rxOffload bool, msgsPool *sync.Pool) wgConn.ReceiveFunc { +func (s *ICEBind) createReceiverFn(pc wgConn.BatchReader, conn *net.UDPConn, rxOffload bool, msgsPool *sync.Pool) wgConn.ReceiveFunc { s.muUDPMux.Lock() defer s.muUDPMux.Unlock() - s.udpMux = udpmux.NewUniversalUDPMuxDefault( - udpmux.UniversalUDPMuxParams{ - UDPConn: nbnet.WrapPacketConn(conn), - Net: s.transportNet, - FilterFn: s.filterFn, - WGAddress: s.address, - MTU: s.mtu, - }, - ) + // Detect IPv4 vs IPv6 from connection's local address + if localAddr := conn.LocalAddr().(*net.UDPAddr); localAddr.IP.To4() != nil { + s.ipv4Conn = conn + } else { + s.ipv6Conn = conn + } + s.createOrUpdateMux() + return func(bufs [][]byte, sizes []int, eps []wgConn.Endpoint) (n int, err error) { msgs := getMessages(msgsPool) for i := range bufs { @@ -180,12 +184,13 @@ func (s *ICEBind) createIPv4ReceiverFn(pc *ipv4.PacketConn, conn *net.UDPConn, r (*msgs)[i].OOB = (*msgs)[i].OOB[:cap((*msgs)[i].OOB)] } defer putMessages(msgs, msgsPool) + var numMsgs int if runtime.GOOS == "linux" || runtime.GOOS == "android" { if rxOffload { readAt := len(*msgs) - (wgConn.IdealBatchSize / wgConn.UdpSegmentMaxDatagrams) - //nolint - numMsgs, err = pc.ReadBatch((*msgs)[readAt:], 0) + //nolint:staticcheck + _, err = pc.ReadBatch((*msgs)[readAt:], 0) if err != nil { return 0, err } @@ -207,12 +212,12 @@ func (s *ICEBind) createIPv4ReceiverFn(pc *ipv4.PacketConn, conn *net.UDPConn, r } numMsgs = 1 } + for i := 0; i < numMsgs; i++ { msg := &(*msgs)[i] // todo: handle err - ok, _ := s.filterOutStunMessages(msg.Buffers, msg.N, msg.Addr) - if ok { + if ok, _ := s.filterOutStunMessages(msg.Buffers, msg.N, msg.Addr); ok { continue } sizes[i] = msg.N @@ -233,6 +238,37 @@ func (s *ICEBind) createIPv4ReceiverFn(pc *ipv4.PacketConn, conn *net.UDPConn, r } } +// createOrUpdateMux creates or updates the UDP mux with the available connections. +// Must be called with muUDPMux held. +func (s *ICEBind) createOrUpdateMux() { + var muxConn net.PacketConn + + switch { + case s.ipv4Conn != nil && s.ipv6Conn != nil: + muxConn = NewDualStackPacketConn( + nbnet.WrapPacketConn(s.ipv4Conn), + nbnet.WrapPacketConn(s.ipv6Conn), + ) + case s.ipv4Conn != nil: + muxConn = nbnet.WrapPacketConn(s.ipv4Conn) + case s.ipv6Conn != nil: + muxConn = nbnet.WrapPacketConn(s.ipv6Conn) + default: + return + } + + // Don't close the old mux - it doesn't own the underlying connections. + // The sockets are managed by WireGuard's StdNetBind, not by us. + s.udpMux = udpmux.NewUniversalUDPMuxDefault( + udpmux.UniversalUDPMuxParams{ + UDPConn: muxConn, + Net: s.transportNet, + WGAddress: s.address, + MTU: s.mtu, + }, + ) +} + func (s *ICEBind) filterOutStunMessages(buffers [][]byte, n int, addr net.Addr) (bool, error) { for i := range buffers { if !stun.IsMessage(buffers[i]) { @@ -245,9 +281,14 @@ func (s *ICEBind) filterOutStunMessages(buffers [][]byte, n int, addr net.Addr) return true, err } - muxErr := s.udpMux.HandleSTUNMessage(msg, addr) - if muxErr != nil { - log.Warnf("failed to handle STUN packet") + s.muUDPMux.Lock() + mux := s.udpMux + s.muUDPMux.Unlock() + + if mux != nil { + if muxErr := mux.HandleSTUNMessage(msg, addr); muxErr != nil { + log.Warnf("failed to handle STUN packet: %v", muxErr) + } } buffers[i] = []byte{} diff --git a/client/iface/bind/ice_bind_test.go b/client/iface/bind/ice_bind_test.go new file mode 100644 index 000000000..0b8db7640 --- /dev/null +++ b/client/iface/bind/ice_bind_test.go @@ -0,0 +1,328 @@ +package bind + +import ( + "fmt" + "net" + "net/netip" + "sync" + "testing" + "time" + + "github.com/pion/transport/v3/stdnet" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" + + "github.com/netbirdio/netbird/client/iface/wgaddr" +) + +func TestICEBind_CreatesReceiverForBothIPv4AndIPv6(t *testing.T) { + iceBind := setupICEBind(t) + + ipv4Conn, ipv6Conn := createDualStackConns(t) + defer ipv4Conn.Close() + defer ipv6Conn.Close() + + rc := receiverCreator{iceBind} + pool := createMsgPool() + + // Simulate wireguard-go calling CreateReceiverFn for IPv4 + ipv4RecvFn := rc.CreateReceiverFn(ipv4.NewPacketConn(ipv4Conn), ipv4Conn, false, pool) + require.NotNil(t, ipv4RecvFn) + + iceBind.muUDPMux.Lock() + assert.NotNil(t, iceBind.ipv4Conn, "should store IPv4 connection") + assert.Nil(t, iceBind.ipv6Conn, "IPv6 not added yet") + assert.NotNil(t, iceBind.udpMux, "mux should be created after first connection") + iceBind.muUDPMux.Unlock() + + // Simulate wireguard-go calling CreateReceiverFn for IPv6 + ipv6RecvFn := rc.CreateReceiverFn(ipv6.NewPacketConn(ipv6Conn), ipv6Conn, false, pool) + require.NotNil(t, ipv6RecvFn) + + iceBind.muUDPMux.Lock() + assert.NotNil(t, iceBind.ipv4Conn, "should still have IPv4 connection") + assert.NotNil(t, iceBind.ipv6Conn, "should now have IPv6 connection") + assert.NotNil(t, iceBind.udpMux, "mux should still exist") + iceBind.muUDPMux.Unlock() + + mux, err := iceBind.GetICEMux() + require.NoError(t, err) + require.NotNil(t, mux) +} + +func TestICEBind_WorksWithIPv4Only(t *testing.T) { + iceBind := setupICEBind(t) + + ipv4Conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) + require.NoError(t, err) + defer ipv4Conn.Close() + + rc := receiverCreator{iceBind} + recvFn := rc.CreateReceiverFn(ipv4.NewPacketConn(ipv4Conn), ipv4Conn, false, createMsgPool()) + require.NotNil(t, recvFn) + + iceBind.muUDPMux.Lock() + assert.NotNil(t, iceBind.ipv4Conn) + assert.Nil(t, iceBind.ipv6Conn) + assert.NotNil(t, iceBind.udpMux) + iceBind.muUDPMux.Unlock() + + mux, err := iceBind.GetICEMux() + require.NoError(t, err) + require.NotNil(t, mux) +} + +func TestICEBind_WorksWithIPv6Only(t *testing.T) { + iceBind := setupICEBind(t) + + ipv6Conn, err := net.ListenUDP("udp6", &net.UDPAddr{IP: net.IPv6zero, Port: 0}) + if err != nil { + t.Skipf("IPv6 not available: %v", err) + } + defer ipv6Conn.Close() + + rc := receiverCreator{iceBind} + recvFn := rc.CreateReceiverFn(ipv6.NewPacketConn(ipv6Conn), ipv6Conn, false, createMsgPool()) + require.NotNil(t, recvFn) + + iceBind.muUDPMux.Lock() + assert.Nil(t, iceBind.ipv4Conn) + assert.NotNil(t, iceBind.ipv6Conn) + assert.NotNil(t, iceBind.udpMux) + iceBind.muUDPMux.Unlock() + + mux, err := iceBind.GetICEMux() + require.NoError(t, err) + require.NotNil(t, mux) +} + +// TestICEBind_SendsToIPv4AndIPv6PeersSimultaneously verifies that we can communicate +// with peers on different address families through the same DualStackPacketConn. +func TestICEBind_SendsToIPv4AndIPv6PeersSimultaneously(t *testing.T) { + // two "remote peers" listening on different address families + ipv4Peer := listenUDP(t, "udp4", "127.0.0.1:0") + defer ipv4Peer.Close() + + ipv6Peer, err := net.ListenUDP("udp6", &net.UDPAddr{IP: net.IPv6loopback, Port: 0}) + if err != nil { + t.Skipf("IPv6 not available: %v", err) + } + defer ipv6Peer.Close() + + // our local dual-stack connection + ipv4Local := listenUDP(t, "udp4", "127.0.0.1:0") + defer ipv4Local.Close() + + ipv6Local := listenUDP(t, "udp6", "[::1]:0") + defer ipv6Local.Close() + + dualStack := NewDualStackPacketConn(ipv4Local, ipv6Local) + + // send to both peers + _, err = dualStack.WriteTo([]byte("to-ipv4"), ipv4Peer.LocalAddr()) + require.NoError(t, err) + + _, err = dualStack.WriteTo([]byte("to-ipv6"), ipv6Peer.LocalAddr()) + require.NoError(t, err) + + // verify IPv4 peer got its packet from the IPv4 socket + buf := make([]byte, 100) + _ = ipv4Peer.SetReadDeadline(time.Now().Add(time.Second)) + n, addr, err := ipv4Peer.ReadFrom(buf) + require.NoError(t, err) + assert.Equal(t, "to-ipv4", string(buf[:n])) + assert.Equal(t, ipv4Local.LocalAddr().(*net.UDPAddr).Port, addr.(*net.UDPAddr).Port) + + // verify IPv6 peer got its packet from the IPv6 socket + _ = ipv6Peer.SetReadDeadline(time.Now().Add(time.Second)) + n, addr, err = ipv6Peer.ReadFrom(buf) + require.NoError(t, err) + assert.Equal(t, "to-ipv6", string(buf[:n])) + assert.Equal(t, ipv6Local.LocalAddr().(*net.UDPAddr).Port, addr.(*net.UDPAddr).Port) +} + +// TestICEBind_HandlesConcurrentMixedTraffic sends packets concurrently to both IPv4 +// and IPv6 peers. Verifies no packets get misrouted (IPv4 peer only gets v4- packets, +// IPv6 peer only gets v6- packets). Some packet loss is acceptable for UDP. +func TestICEBind_HandlesConcurrentMixedTraffic(t *testing.T) { + ipv4Peer := listenUDP(t, "udp4", "127.0.0.1:0") + defer ipv4Peer.Close() + + ipv6Peer, err := net.ListenUDP("udp6", &net.UDPAddr{IP: net.IPv6loopback, Port: 0}) + if err != nil { + t.Skipf("IPv6 not available: %v", err) + } + defer ipv6Peer.Close() + + ipv4Local := listenUDP(t, "udp4", "127.0.0.1:0") + defer ipv4Local.Close() + + ipv6Local := listenUDP(t, "udp6", "[::1]:0") + defer ipv6Local.Close() + + dualStack := NewDualStackPacketConn(ipv4Local, ipv6Local) + + const packetsPerFamily = 500 + + ipv4Received := make(chan string, packetsPerFamily) + ipv6Received := make(chan string, packetsPerFamily) + + startGate := make(chan struct{}) + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + buf := make([]byte, 100) + for i := 0; i < packetsPerFamily; i++ { + n, _, err := ipv4Peer.ReadFrom(buf) + if err != nil { + return + } + ipv4Received <- string(buf[:n]) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + buf := make([]byte, 100) + for i := 0; i < packetsPerFamily; i++ { + n, _, err := ipv6Peer.ReadFrom(buf) + if err != nil { + return + } + ipv6Received <- string(buf[:n]) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + <-startGate + for i := 0; i < packetsPerFamily; i++ { + _, _ = dualStack.WriteTo([]byte(fmt.Sprintf("v4-%04d", i)), ipv4Peer.LocalAddr()) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + <-startGate + for i := 0; i < packetsPerFamily; i++ { + _, _ = dualStack.WriteTo([]byte(fmt.Sprintf("v6-%04d", i)), ipv6Peer.LocalAddr()) + } + }() + + close(startGate) + + time.AfterFunc(5*time.Second, func() { + _ = ipv4Peer.SetReadDeadline(time.Now()) + _ = ipv6Peer.SetReadDeadline(time.Now()) + }) + + wg.Wait() + close(ipv4Received) + close(ipv6Received) + + ipv4Count := 0 + for pkt := range ipv4Received { + require.True(t, len(pkt) >= 3 && pkt[:3] == "v4-", "IPv4 peer got misrouted packet: %s", pkt) + ipv4Count++ + } + + ipv6Count := 0 + for pkt := range ipv6Received { + require.True(t, len(pkt) >= 3 && pkt[:3] == "v6-", "IPv6 peer got misrouted packet: %s", pkt) + ipv6Count++ + } + + // Allow some UDP packet loss under load (e.g. FreeBSD/QEMU runners). The + // routing-correctness checks above are the real assertions; the counts + // are a sanity bound to catch a totally silent path. + minDelivered := packetsPerFamily * 80 / 100 + assert.GreaterOrEqual(t, ipv4Count, minDelivered, "IPv4 delivery below threshold") + assert.GreaterOrEqual(t, ipv6Count, minDelivered, "IPv6 delivery below threshold") +} + +func TestICEBind_DetectsAddressFamilyFromConnection(t *testing.T) { + tests := []struct { + name string + network string + addr string + wantIPv4 bool + }{ + {"IPv4 any", "udp4", "0.0.0.0:0", true}, + {"IPv4 loopback", "udp4", "127.0.0.1:0", true}, + {"IPv6 any", "udp6", "[::]:0", false}, + {"IPv6 loopback", "udp6", "[::1]:0", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + addr, err := net.ResolveUDPAddr(tt.network, tt.addr) + require.NoError(t, err) + + conn, err := net.ListenUDP(tt.network, addr) + if err != nil { + t.Skipf("%s not available: %v", tt.network, err) + } + defer conn.Close() + + localAddr := conn.LocalAddr().(*net.UDPAddr) + isIPv4 := localAddr.IP.To4() != nil + assert.Equal(t, tt.wantIPv4, isIPv4) + }) + } +} + +// helpers + +func setupICEBind(t *testing.T) *ICEBind { + t.Helper() + transportNet, err := stdnet.NewNet() + require.NoError(t, err) + + address := wgaddr.Address{ + IP: netip.MustParseAddr("100.64.0.1"), + Network: netip.MustParsePrefix("100.64.0.0/10"), + } + return NewICEBind(transportNet, address, 1280) +} + +func createDualStackConns(t *testing.T) (*net.UDPConn, *net.UDPConn) { + t.Helper() + ipv4Conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) + require.NoError(t, err) + + ipv6Conn, err := net.ListenUDP("udp6", &net.UDPAddr{IP: net.IPv6zero, Port: 0}) + if err != nil { + ipv4Conn.Close() + t.Skipf("IPv6 not available: %v", err) + } + return ipv4Conn, ipv6Conn +} + +func createMsgPool() *sync.Pool { + return &sync.Pool{ + New: func() any { + msgs := make([]ipv6.Message, 1) + for i := range msgs { + msgs[i].Buffers = make(net.Buffers, 1) + msgs[i].OOB = make([]byte, 0, 40) + } + return &msgs + }, + } +} + +func listenUDP(t *testing.T, network, addr string) *net.UDPConn { + t.Helper() + udpAddr, err := net.ResolveUDPAddr(network, addr) + require.NoError(t, err) + conn, err := net.ListenUDP(network, udpAddr) + require.NoError(t, err) + return conn +} diff --git a/client/iface/configurer/common.go b/client/iface/configurer/common.go index 088cff69d..10162d703 100644 --- a/client/iface/configurer/common.go +++ b/client/iface/configurer/common.go @@ -3,8 +3,22 @@ package configurer import ( "net" "net/netip" + + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" ) +// buildPresharedKeyConfig creates a wgtypes.Config for setting a preshared key on a peer. +// This is a shared helper used by both kernel and userspace configurers. +func buildPresharedKeyConfig(peerKey wgtypes.Key, psk wgtypes.Key, updateOnly bool) wgtypes.Config { + return wgtypes.Config{ + Peers: []wgtypes.PeerConfig{{ + PublicKey: peerKey, + PresharedKey: &psk, + UpdateOnly: updateOnly, + }}, + } +} + func prefixesToIPNets(prefixes []netip.Prefix) []net.IPNet { ipNets := make([]net.IPNet, len(prefixes)) for i, prefix := range prefixes { diff --git a/client/iface/configurer/kernel_unix.go b/client/iface/configurer/kernel_unix.go index 96b286175..da69c2a35 100644 --- a/client/iface/configurer/kernel_unix.go +++ b/client/iface/configurer/kernel_unix.go @@ -15,16 +15,17 @@ import ( "github.com/netbirdio/netbird/monotime" ) -var zeroKey wgtypes.Key - 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 { @@ -48,6 +49,18 @@ func (c *KernelConfigurer) ConfigureInterface(privateKey string, port int) error return nil } +// SetPresharedKey sets the preshared key for a peer. +// If updateOnly is true, only updates the existing peer; if false, creates or updates. +func (c *KernelConfigurer) SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error { + parsedPeerKey, err := wgtypes.ParseKey(peerKey) + if err != nil { + return err + } + + cfg := buildPresharedKeyConfig(parsedPeerKey, psk, updateOnly) + return c.configure(cfg) +} + func (c *KernelConfigurer) UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error { peerKeyParsed, err := wgtypes.ParseKey(peerKey) if err != nil { @@ -236,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) } @@ -279,7 +286,7 @@ func (c *KernelConfigurer) FullStats() (*Stats, error) { TxBytes: p.TransmitBytes, RxBytes: p.ReceiveBytes, LastHandshake: p.LastHandshakeTime, - PresharedKey: p.PresharedKey != zeroKey, + PresharedKey: [32]byte(p.PresharedKey), } if p.Endpoint != nil { peer.Endpoint = *p.Endpoint @@ -290,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 { @@ -316,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/uapi.go b/client/iface/configurer/uapi.go index f85c7852a..d9bd9bfab 100644 --- a/client/iface/configurer/uapi.go +++ b/client/iface/configurer/uapi.go @@ -5,20 +5,18 @@ package configurer import ( "net" - log "github.com/sirupsen/logrus" "golang.zx2c4.com/wireguard/ipc" ) func openUAPI(deviceName string) (net.Listener, error) { uapiSock, err := ipc.UAPIOpen(deviceName) if err != nil { - log.Errorf("failed to open uapi socket: %v", err) return nil, err } listener, err := ipc.UAPIListen(deviceName, uapiSock) if err != nil { - log.Errorf("failed to listen on uapi socket: %v", err) + _ = uapiSock.Close() return nil, err } diff --git a/client/iface/configurer/usp.go b/client/iface/configurer/usp.go index bc875b73c..0a25c55bc 100644 --- a/client/iface/configurer/usp.go +++ b/client/iface/configurer/usp.go @@ -22,17 +22,16 @@ import ( ) const ( - privateKey = "private_key" - ipcKeyLastHandshakeTimeSec = "last_handshake_time_sec" - ipcKeyLastHandshakeTimeNsec = "last_handshake_time_nsec" - ipcKeyTxBytes = "tx_bytes" - ipcKeyRxBytes = "rx_bytes" - allowedIP = "allowed_ip" - endpoint = "endpoint" - fwmark = "fwmark" - listenPort = "listen_port" - publicKey = "public_key" - presharedKey = "preshared_key" + privateKey = "private_key" + ipcKeyLastHandshakeTimeSec = "last_handshake_time_sec" + ipcKeyTxBytes = "tx_bytes" + ipcKeyRxBytes = "rx_bytes" + allowedIP = "allowed_ip" + endpoint = "endpoint" + fwmark = "fwmark" + listenPort = "listen_port" + publicKey = "public_key" + presharedKey = "preshared_key" ) var ErrAllowedIPNotFound = fmt.Errorf("allowed IP not found") @@ -41,6 +40,7 @@ type WGUSPConfigurer struct { device *device.Device deviceName string activityRecorder *bind.ActivityRecorder + statsCache *statsCache uapiListener net.Listener } @@ -51,10 +51,21 @@ 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 { + 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 { log.Debugf("adding Wireguard private key") key, err := wgtypes.ParseKey(privateKey) @@ -72,6 +83,18 @@ func (c *WGUSPConfigurer) ConfigureInterface(privateKey string, port int) error return c.device.IpcSet(toWgUserspaceString(config)) } +// SetPresharedKey sets the preshared key for a peer. +// If updateOnly is true, only updates the existing peer; if false, creates or updates. +func (c *WGUSPConfigurer) SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error { + parsedPeerKey, err := wgtypes.ParseKey(peerKey) + if err != nil { + return err + } + + cfg := buildPresharedKeyConfig(parsedPeerKey, psk, updateOnly) + return c.device.IpcSet(toWgUserspaceString(cfg)) +} + func (c *WGUSPConfigurer) UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error { peerKeyParsed, err := wgtypes.ParseKey(peerKey) if err != nil { @@ -100,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 @@ -329,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) @@ -422,23 +449,19 @@ func toWgUserspaceString(wgCfg wgtypes.Config) string { hexKey := hex.EncodeToString(p.PublicKey[:]) sb.WriteString(fmt.Sprintf("public_key=%s\n", hexKey)) + if p.Remove { + sb.WriteString("remove=true\n") + } + + if p.UpdateOnly { + sb.WriteString("update_only=true\n") + } + if p.PresharedKey != nil { preSharedHexKey := hex.EncodeToString(p.PresharedKey[:]) sb.WriteString(fmt.Sprintf("preshared_key=%s\n", preSharedHexKey)) } - if p.Remove { - sb.WriteString("remove=true") - } - - if p.ReplaceAllowedIPs { - sb.WriteString("replace_allowed_ips=true\n") - } - - for _, aip := range p.AllowedIPs { - sb.WriteString(fmt.Sprintf("allowed_ip=%s\n", aip.String())) - } - if p.Endpoint != nil { sb.WriteString(fmt.Sprintf("endpoint=%s\n", p.Endpoint.String())) } @@ -446,6 +469,14 @@ func toWgUserspaceString(wgCfg wgtypes.Config) string { if p.PersistentKeepaliveInterval != nil { sb.WriteString(fmt.Sprintf("persistent_keepalive_interval=%d\n", int(p.PersistentKeepaliveInterval.Seconds()))) } + + if p.ReplaceAllowedIPs { + sb.WriteString("replace_allowed_ips=true\n") + } + + for _, aip := range p.AllowedIPs { + sb.WriteString(fmt.Sprintf("allowed_ip=%s\n", aip.String())) + } } return sb.String() } @@ -543,7 +574,7 @@ func parseStatus(deviceName, ipcStr string) (*Stats, error) { continue } - host, portStr, err := net.SplitHostPort(strings.Trim(val, "[]")) + host, portStr, err := net.SplitHostPort(val) if err != nil { log.Errorf("failed to parse endpoint: %v", err) continue @@ -599,7 +630,9 @@ func parseStatus(deviceName, ipcStr string) (*Stats, error) { continue } if val != "" && val != "0000000000000000000000000000000000000000000000000000000000000000" { - currentPeer.PresharedKey = true + if pskKey, err := hexToWireguardKey(val); err == nil { + currentPeer.PresharedKey = [32]byte(pskKey) + } } } } diff --git a/client/iface/configurer/wgshow.go b/client/iface/configurer/wgshow.go index 604264026..4a5c31160 100644 --- a/client/iface/configurer/wgshow.go +++ b/client/iface/configurer/wgshow.go @@ -12,7 +12,7 @@ type Peer struct { TxBytes int64 RxBytes int64 LastHandshake time.Time - PresharedKey bool + PresharedKey [32]byte } type Stats struct { 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 48346fc0f..cbe88c10c 100644 --- a/client/iface/device/device_android.go +++ b/client/iface/device/device_android.go @@ -3,6 +3,7 @@ package device import ( + "fmt" "strings" log "github.com/sirupsen/logrus" @@ -19,11 +20,12 @@ import ( // WGTunDevice ignore the WGTunDevice interface on Android because the creation of the tun device is different on this platform type WGTunDevice struct { - address wgaddr.Address - port int - key string - mtu uint16 - iceBind *bind.ICEBind + address wgaddr.Address + port int + key string + mtu uint16 + iceBind *bind.ICEBind + // todo: review if we can eliminate the TunAdapter tunAdapter TunAdapter disableDNS bool @@ -32,17 +34,19 @@ type WGTunDevice struct { filteredDevice *FilteredDevice udpMux *udpmux.UniversalUDPMuxDefault configurer WGConfigurer + renewableTun *RenewableTUN } func NewTunDevice(address wgaddr.Address, port int, key string, mtu uint16, iceBind *bind.ICEBind, tunAdapter TunAdapter, disableDNS bool) *WGTunDevice { return &WGTunDevice{ - address: address, - port: port, - key: key, - mtu: mtu, - iceBind: iceBind, - tunAdapter: tunAdapter, - disableDNS: disableDNS, + address: address, + port: port, + key: key, + mtu: mtu, + iceBind: iceBind, + tunAdapter: tunAdapter, + disableDNS: disableDNS, + renewableTun: NewRenewableTUN(), } } @@ -59,20 +63,23 @@ 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) + fd, err := t.tunAdapter.ConfigureInterface(t.address.String(), t.address.IPv6String(), int(t.mtu), dns, searchDomainsToString, routesString) if err != nil { log.Errorf("failed to create Android interface: %s", err) return nil, err } - tunDevice, name, err := tun.CreateUnmonitoredTUNFromFD(fd) + unmonitoredTUN, name, err := tun.CreateUnmonitoredTUNFromFD(fd) if err != nil { _ = unix.Close(fd) log.Errorf("failed to create Android interface: %s", err) return nil, err } + + t.renewableTun.AddDevice(unmonitoredTUN) + t.name = name - t.filteredDevice = newDeviceFilter(tunDevice) + t.filteredDevice = newDeviceFilter(t.renewableTun) log.Debugf("attaching to interface %v", name) t.device = device.NewDevice(t.filteredDevice, t.iceBind, device.NewLogger(wgLogLevel(), "[netbird] ")) @@ -104,6 +111,23 @@ func (t *WGTunDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) { return udpMux, nil } +func (t *WGTunDevice) RenewTun(fd int) error { + if t.device == nil { + return fmt.Errorf("device not initialized") + } + + unmonitoredTUN, _, err := tun.CreateUnmonitoredTUNFromFD(fd) + if err != nil { + _ = unix.Close(fd) + log.Errorf("failed to renew Android interface: %s", err) + return err + } + + t.renewableTun.AddDevice(unmonitoredTUN) + + return nil +} + func (t *WGTunDevice) UpdateAddr(addr wgaddr.Address) error { // todo implement return nil 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 015f71ff4..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" ) @@ -15,22 +19,38 @@ type PacketFilter interface { // FilterInbound filter incoming packets from external sources to host FilterInbound(packetData []byte, size int) bool - // AddUDPPacketHook calls hook when UDP packet from given direction matched - // - // Hook function returns flag which indicates should be the matched package dropped or not. - // Hook function receives raw network packet data as argument. - AddUDPPacketHook(in bool, ip netip.Addr, dPort uint16, hook func(packet []byte) bool) string + // SetUDPPacketHook registers a hook for outbound UDP packets matching the given IP and port. + // Hook function returns true if the packet should be dropped. + // Only one UDP hook is supported; calling again replaces the previous hook. + // Pass nil hook to remove. + SetUDPPacketHook(ip netip.Addr, dPort uint16, hook func(packet []byte) bool) - // RemovePacketHook removes hook by ID - RemovePacketHook(hookID string) error + // SetTCPPacketHook registers a hook for outbound TCP packets matching the given IP and port. + // Hook function returns true if the packet should be dropped. + // Only one TCP hook is supported; calling again replaces the previous hook. + // Pass nil hook to remove. + 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 + 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 @@ -40,25 +60,44 @@ func newDeviceFilter(device tun.Device) *FilteredDevice { } } +// Close closes the underlying tun device exactly once. +// wireguard-go's netTun.Close() panics on double-close due to a bare close(channel), +// and multiple code paths can trigger Close on the same device. +func (d *FilteredDevice) Close() error { + var err error + d.closeOnce.Do(func() { + err = d.Device.Close() + }) + if err != nil { + return err + } + return nil +} + // Read wraps read method with filtering feature func (d *FilteredDevice) Read(bufs [][]byte, sizes []int, offset int) (n int, err error) { - if n, err = d.Device.Read(bufs, sizes, offset); err != nil { + 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) } } @@ -67,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 @@ -95,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..0d86c9323 100644 --- a/client/iface/device/device_filter_test.go +++ b/client/iface/device/device_filter_test.go @@ -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 f96edf992..8368c8dce 100644 --- a/client/iface/device/device_ios.go +++ b/client/iface/device/device_ios.go @@ -1,9 +1,7 @@ -//go:build ios -// +build ios - package device import ( + "fmt" "os" log "github.com/sirupsen/logrus" @@ -45,10 +43,31 @@ func NewTunDevice(name string, address wgaddr.Address, port int, key string, mtu } } +// ErrInvalidTunnelFD is returned when the tunnel file descriptor is invalid (0). +// This typically means the Swift code couldn't find the utun control socket. +var ErrInvalidTunnelFD = fmt.Errorf("invalid tunnel file descriptor: fd is 0 (Swift failed to locate utun socket)") + func (t *TunDevice) Create() (WGConfigurer, error) { log.Infof("create tun interface") - dupTunFd, err := unix.Dup(t.tunFd) + var tunDevice tun.Device + var err error + + // Validate the tunnel file descriptor. + // On iOS/tvOS, the FD must be provided by the NEPacketTunnelProvider. + // A value of 0 means the Swift code couldn't find the utun control socket + // (the low-level APIs like ctl_info, sockaddr_ctl may not be exposed in + // tvOS SDK headers). This is a hard error - there's no viable fallback + // since tun.CreateTUN() cannot work within the iOS/tvOS sandbox. + if t.tunFd == 0 { + log.Errorf("Tunnel file descriptor is 0 - Swift code failed to locate the utun control socket. " + + "On tvOS, ensure the NEPacketTunnelProvider is properly configured and the tunnel is started.") + return nil, ErrInvalidTunnelFD + } + + // Normal iOS/tvOS path: use the provided file descriptor from NEPacketTunnelProvider + var dupTunFd int + dupTunFd, err = unix.Dup(t.tunFd) if err != nil { log.Errorf("Unable to dup tun fd: %v", err) return nil, err @@ -60,7 +79,7 @@ func (t *TunDevice) Create() (WGConfigurer, error) { _ = unix.Close(dupTunFd) return nil, err } - tunDevice, err := tun.CreateTUNFromFile(os.NewFile(uintptr(dupTunFd), "/dev/tun"), 0) + tunDevice, err = tun.CreateTUNFromFile(os.NewFile(uintptr(dupTunFd), "/dev/tun"), 0) if err != nil { log.Errorf("Unable to create new tun device from fd: %v", err) _ = unix.Close(dupTunFd) @@ -132,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 40d8fdac8..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 { @@ -79,10 +84,12 @@ func (t *TunNetstackDevice) create() (WGConfigurer, error) { device.NewLogger(wgLogLevel(), "[netbird] "), ) - t.configurer = configurer.NewUSPConfigurer(t.device, t.name, t.bind.ActivityRecorder()) + t.configurer = configurer.NewUSPConfigurerNoUAPI(t.device, t.name, t.bind.ActivityRecorder()) err = t.configurer.ConfigureInterface(t.key, t.port) if err != nil { - _ = tunIface.Close() + if cErr := tunIface.Close(); cErr != nil { + log.Debugf("failed to close tun device: %v", cErr) + } return nil, fmt.Errorf("error configuring interface: %s", err) } diff --git a/client/iface/device/device_netstack_android.go b/client/iface/device/device_netstack_android.go index 45ae8ba7d..f1a77d40a 100644 --- a/client/iface/device/device_netstack_android.go +++ b/client/iface/device/device_netstack_android.go @@ -2,6 +2,13 @@ package device +import "fmt" + func (t *TunNetstackDevice) Create(routes []string, dns string, searchDomains []string) (WGConfigurer, error) { return t.create() } + +func (t *TunNetstackDevice) RenewTun(fd int) error { + // Doesn't make sense in Android for Netstack. + return fmt.Errorf("this function has not been implemented in Netstack for Android") +} 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/interface.go b/client/iface/device/interface.go index db53d9c3a..7bab7b757 100644 --- a/client/iface/device/interface.go +++ b/client/iface/device/interface.go @@ -17,6 +17,7 @@ type WGConfigurer interface { RemovePeer(peerKey string) error AddAllowedIP(peerKey string, allowedIP netip.Prefix) error RemoveAllowedIP(peerKey string, allowedIP netip.Prefix) error + SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error Close() GetStats() (map[string]configurer.WGStats, error) FullStats() (*configurer.Stats, error) 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/renewable_tun.go b/client/iface/device/renewable_tun.go new file mode 100644 index 000000000..a501eebbb --- /dev/null +++ b/client/iface/device/renewable_tun.go @@ -0,0 +1,309 @@ +//go:build android + +package device + +import ( + "io" + "os" + "sync" + "sync/atomic" + "time" + + log "github.com/sirupsen/logrus" + "golang.zx2c4.com/wireguard/tun" +) + +// closeAwareDevice wraps a tun.Device along with a flag +// indicating whether its Close method was called. +// +// It also redirects tun.Device's Events() to a separate goroutine +// and closes it when Close is called. +// +// The WaitGroup and CloseOnce fields are used to ensure that the +// goroutine is awaited and closed only once. +type closeAwareDevice struct { + isClosed atomic.Bool + tun.Device + closeEventCh chan struct{} + wg sync.WaitGroup + closeOnce sync.Once +} + +func newClosableDevice(tunDevice tun.Device) *closeAwareDevice { + return &closeAwareDevice{ + Device: tunDevice, + isClosed: atomic.Bool{}, + closeEventCh: make(chan struct{}), + } +} + +// redirectEvents redirects the Events() method of the underlying tun.Device +// to the given channel (RenewableTUN's events channel). +func (c *closeAwareDevice) redirectEvents(out chan tun.Event) { + c.wg.Add(1) + go func() { + defer c.wg.Done() + for { + select { + case ev, ok := <-c.Device.Events(): + if !ok { + return + } + + if ev == tun.EventDown { + continue + } + + select { + case out <- ev: + case <-c.closeEventCh: + return + } + case <-c.closeEventCh: + return + } + } + }() +} + +// Close calls the underlying Device's Close method +// after setting isClosed to true. +func (c *closeAwareDevice) Close() (err error) { + c.closeOnce.Do(func() { + c.isClosed.Store(true) + close(c.closeEventCh) + err = c.Device.Close() + c.wg.Wait() + }) + + return err +} + +func (c *closeAwareDevice) IsClosed() bool { + return c.isClosed.Load() +} + +type RenewableTUN struct { + devices []*closeAwareDevice + mu sync.Mutex + cond *sync.Cond + events chan tun.Event + closed atomic.Bool +} + +func NewRenewableTUN() *RenewableTUN { + r := &RenewableTUN{ + devices: make([]*closeAwareDevice, 0), + mu: sync.Mutex{}, + events: make(chan tun.Event, 16), + } + r.cond = sync.NewCond(&r.mu) + return r +} + +func (r *RenewableTUN) File() *os.File { + for { + dev := r.peekLast() + if dev == nil { + if !r.waitForDevice() { + return nil + } + continue + } + + file := dev.File() + + if dev.IsClosed() { + time.Sleep(1 * time.Millisecond) + continue + } + + return file + } +} + +// Read reads from an underlying tun.Device kept in the r.devices slice. +// If no device is available, it waits for one to be added via AddDevice(). +// +// On error, it retries reading from the newest device instead of returning the error +// if the device is closed; if not, it propagates the error. +func (r *RenewableTUN) Read(bufs [][]byte, sizes []int, offset int) (n int, err error) { + for { + dev := r.peekLast() + if dev == nil { + // wait until AddDevice() signals a new device via cond.Broadcast() + if !r.waitForDevice() { // returns false if the renewable TUN itself is closed + return 0, io.EOF + } + continue + } + + n, err = dev.Read(bufs, sizes, offset) + if err == nil { + return n, nil + } + + // swap in progress; retry on the newest instead of returning the error + if dev.IsClosed() { + time.Sleep(1 * time.Millisecond) + continue + } + return n, err // propagate non-swap error + } +} + +// Write writes to underlying tun.Device kept in the r.devices slice. +// If no device is available, it waits for one to be added via AddDevice(). +// +// On error, it retries writing to the newest device instead of returning the error +// if the device is closed; if not, it propagates the error. +func (r *RenewableTUN) Write(bufs [][]byte, offset int) (int, error) { + for { + dev := r.peekLast() + if dev == nil { + if !r.waitForDevice() { + return 0, io.EOF + } + continue + } + + n, err := dev.Write(bufs, offset) + if err == nil { + return n, nil + } + + if dev.IsClosed() { + time.Sleep(1 * time.Millisecond) + continue + } + + return n, err + } +} + +func (r *RenewableTUN) MTU() (int, error) { + for { + dev := r.peekLast() + if dev == nil { + if !r.waitForDevice() { + return 0, io.EOF + } + continue + } + mtu, err := dev.MTU() + if err == nil { + return mtu, nil + } + if dev.IsClosed() { + time.Sleep(1 * time.Millisecond) + continue + } + return 0, err + } +} + +func (r *RenewableTUN) Name() (string, error) { + for { + dev := r.peekLast() + if dev == nil { + if !r.waitForDevice() { + return "", io.EOF + } + continue + } + name, err := dev.Name() + if err == nil { + return name, nil + } + if dev.IsClosed() { + time.Sleep(1 * time.Millisecond) + continue + } + return "", err + } +} + +// Events returns a channel that is fed events from the underlying tun.Device's events channel +// once it is added. +func (r *RenewableTUN) Events() <-chan tun.Event { + return r.events +} + +func (r *RenewableTUN) Close() error { + // Attempts to set the RenewableTUN closed flag to true. + // If it's already true, returns immediately. + if !r.closed.CompareAndSwap(false, true) { + return nil // already closed: idempotent + } + r.mu.Lock() + devices := r.devices + r.devices = nil + r.cond.Broadcast() + r.mu.Unlock() + + var lastErr error + + log.Debugf("closing %d devices", len(devices)) + for _, device := range devices { + if err := device.Close(); err != nil { + log.Debugf("error closing a device: %v", err) + lastErr = err + } + } + + close(r.events) + return lastErr +} + +func (r *RenewableTUN) BatchSize() int { + return 1 +} + +func (r *RenewableTUN) AddDevice(device tun.Device) { + r.mu.Lock() + if r.closed.Load() { + r.mu.Unlock() + _ = device.Close() + return + } + + var toClose *closeAwareDevice + if len(r.devices) > 0 { + toClose = r.devices[len(r.devices)-1] + } + + cad := newClosableDevice(device) + cad.redirectEvents(r.events) + + r.devices = []*closeAwareDevice{cad} + r.cond.Broadcast() + + r.mu.Unlock() + + if toClose != nil { + if err := toClose.Close(); err != nil { + log.Debugf("error closing last device: %v", err) + } + } +} + +func (r *RenewableTUN) waitForDevice() bool { + r.mu.Lock() + defer r.mu.Unlock() + + for len(r.devices) == 0 && !r.closed.Load() { + r.cond.Wait() + } + return !r.closed.Load() +} + +func (r *RenewableTUN) peekLast() *closeAwareDevice { + r.mu.Lock() + defer r.mu.Unlock() + + if len(r.devices) == 0 { + return nil + } + + return r.devices[len(r.devices)-1] +} 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/device_android.go b/client/iface/device_android.go index cdfcea48d..3899bf426 100644 --- a/client/iface/device_android.go +++ b/client/iface/device_android.go @@ -21,5 +21,6 @@ type WGTunDevice interface { FilteredDevice() *device.FilteredDevice Device() *wgdevice.Device GetNet() *netstack.Net + RenewTun(fd int) error GetICEBind() device.EndpointManager } diff --git a/client/iface/iface.go b/client/iface/iface.go index 07235a995..247f421a2 100644 --- a/client/iface/iface.go +++ b/client/iface/iface.go @@ -18,6 +18,7 @@ import ( "github.com/netbirdio/netbird/client/errors" "github.com/netbirdio/netbird/client/iface/configurer" "github.com/netbirdio/netbird/client/iface/device" + nbnetstack "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/iface/wgaddr" "github.com/netbirdio/netbird/client/iface/wgproxy" @@ -50,18 +51,18 @@ func ValidateMTU(mtu uint16) error { type wgProxyFactory interface { GetProxy() wgproxy.Proxy + GetProxyPort() uint16 Free() error } 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 } @@ -80,6 +81,12 @@ func (w *WGIface) GetProxy() wgproxy.Proxy { return w.wgProxyFactory.GetProxy() } +// GetProxyPort returns the proxy port used by the WireGuard proxy. +// Returns 0 if no proxy port is used (e.g., for userspace WireGuard). +func (w *WGIface) GetProxyPort() uint16 { + return w.wgProxyFactory.GetProxyPort() +} + // GetBind returns the EndpointManager userspace bind mode. func (w *WGIface) GetBind() device.EndpointManager { w.mu.Lock() @@ -133,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 @@ -209,7 +211,6 @@ func (w *WGIface) RemoveAllowedIP(peerKey string, allowedIP netip.Prefix) error // Close closes the tunnel interface func (w *WGIface) Close() error { w.mu.Lock() - defer w.mu.Unlock() var result *multierror.Error @@ -217,10 +218,22 @@ func (w *WGIface) Close() error { result = multierror.Append(result, fmt.Errorf("failed to free WireGuard proxy: %w", err)) } - if err := w.tun.Close(); err != nil { + // Release w.mu before calling w.tun.Close(): the underlying + // wireguard-go device.Close() waits for its send/receive goroutines + // to drain. Some of those goroutines re-enter WGIface methods that + // take w.mu (e.g. the packet filter DNS hook calls GetDevice()), so + // holding the mutex here would deadlock the shutdown path. + tun := w.tun + w.mu.Unlock() + + if err := tun.Close(); err != nil { result = multierror.Append(result, fmt.Errorf("failed to close wireguard interface %s: %w", w.Name(), err)) } + if nbnetstack.IsEnabled() { + return errors.FormatErrorOrNil(result) + } + if err := w.waitUntilRemoved(); err != nil { log.Warnf("failed to remove WireGuard interface %s: %v", w.Name(), err) if err := w.Destroy(); err != nil { @@ -297,6 +310,19 @@ func (w *WGIface) FullStats() (*configurer.Stats, error) { return w.configurer.FullStats() } +// SetPresharedKey sets or updates the preshared key for a peer. +// If updateOnly is true, only updates existing peer; if false, creates or updates. +func (w *WGIface) SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error { + w.mu.Lock() + defer w.mu.Unlock() + + if w.configurer == nil { + return ErrIfaceNotFound + } + + return w.configurer.SetPresharedKey(peerKey, psk, updateOnly) +} + func (w *WGIface) waitUntilRemoved() error { maxWaitTime := 5 * time.Second timeout := time.NewTimer(maxWaitTime) diff --git a/client/iface/iface_close_test.go b/client/iface/iface_close_test.go new file mode 100644 index 000000000..246027f90 --- /dev/null +++ b/client/iface/iface_close_test.go @@ -0,0 +1,113 @@ +//go:build !android + +package iface + +import ( + "errors" + "sync" + "testing" + "time" + + wgdevice "golang.zx2c4.com/wireguard/device" + "golang.zx2c4.com/wireguard/tun/netstack" + + "github.com/netbirdio/netbird/client/iface/device" + "github.com/netbirdio/netbird/client/iface/udpmux" + "github.com/netbirdio/netbird/client/iface/wgaddr" + "github.com/netbirdio/netbird/client/iface/wgproxy" +) + +// fakeTunDevice implements WGTunDevice and lets the test control when +// Close() returns. It mimics the wireguard-go shutdown path, which blocks +// until its goroutines drain. Some of those goroutines (e.g. the packet +// filter DNS hook in client/internal/dns) call back into WGIface, so if +// WGIface.Close() held w.mu across tun.Close() the shutdown would +// deadlock. +type fakeTunDevice struct { + closeStarted chan struct{} + unblockClose chan struct{} +} + +func (f *fakeTunDevice) Create() (device.WGConfigurer, error) { + return nil, errors.New("not implemented") +} +func (f *fakeTunDevice) Up() (*udpmux.UniversalUDPMuxDefault, error) { + return nil, errors.New("not implemented") +} +func (f *fakeTunDevice) UpdateAddr(wgaddr.Address) error { return nil } +func (f *fakeTunDevice) WgAddress() wgaddr.Address { return wgaddr.Address{} } +func (f *fakeTunDevice) MTU() uint16 { return DefaultMTU } +func (f *fakeTunDevice) DeviceName() string { return "nb-close-test" } +func (f *fakeTunDevice) FilteredDevice() *device.FilteredDevice { return nil } +func (f *fakeTunDevice) Device() *wgdevice.Device { return nil } +func (f *fakeTunDevice) GetNet() *netstack.Net { return nil } +func (f *fakeTunDevice) GetICEBind() device.EndpointManager { return nil } + +func (f *fakeTunDevice) Close() error { + close(f.closeStarted) + <-f.unblockClose + return nil +} + +type fakeProxyFactory struct{} + +func (fakeProxyFactory) GetProxy() wgproxy.Proxy { return nil } +func (fakeProxyFactory) GetProxyPort() uint16 { return 0 } +func (fakeProxyFactory) Free() error { return nil } + +// TestWGIface_CloseReleasesMutexBeforeTunClose guards against a deadlock +// that surfaces as a macOS test-timeout in +// TestDNSPermanent_updateUpstream: WGIface.Close() used to hold w.mu +// while waiting for the wireguard-go device goroutines to finish, and +// one of those goroutines (the DNS filter hook) calls back into +// WGIface.GetDevice() which needs the same mutex. The fix is to drop +// the lock before tun.Close() returns control. +func TestWGIface_CloseReleasesMutexBeforeTunClose(t *testing.T) { + tun := &fakeTunDevice{ + closeStarted: make(chan struct{}), + unblockClose: make(chan struct{}), + } + w := &WGIface{ + tun: tun, + wgProxyFactory: fakeProxyFactory{}, + } + + closeDone := make(chan error, 1) + go func() { + closeDone <- w.Close() + }() + + select { + case <-tun.closeStarted: + case <-time.After(2 * time.Second): + close(tun.unblockClose) + t.Fatal("tun.Close() was never invoked") + } + + // Simulate the WireGuard read goroutine calling back into WGIface + // via the packet filter's DNS hook. If Close() still held w.mu + // during tun.Close(), this would block until the test timeout. + getDeviceDone := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + _ = w.GetDevice() + close(getDeviceDone) + }() + + select { + case <-getDeviceDone: + case <-time.After(2 * time.Second): + close(tun.unblockClose) + wg.Wait() + t.Fatal("GetDevice() deadlocked while WGIface.Close was closing the tun") + } + + close(tun.unblockClose) + select { + case <-closeDone: + case <-time.After(2 * time.Second): + t.Fatal("WGIface.Close() never returned after the tun was unblocked") + } +} diff --git a/client/iface/iface_create.go b/client/iface/iface_create.go index 5e17c6d41..13ae9393c 100644 --- a/client/iface/iface_create.go +++ b/client/iface/iface_create.go @@ -24,3 +24,7 @@ func (w *WGIface) Create() error { func (w *WGIface) CreateOnAndroid([]string, string, []string) error { return fmt.Errorf("this function has not implemented on non mobile") } + +func (w *WGIface) RenewTun(fd int) error { + return fmt.Errorf("this function has not been implemented on non-android") +} diff --git a/client/iface/iface_create_android.go b/client/iface/iface_create_android.go index 373a9c95a..d2d9eb70e 100644 --- a/client/iface/iface_create_android.go +++ b/client/iface/iface_create_android.go @@ -6,6 +6,7 @@ import ( // CreateOnAndroid creates a new Wireguard interface, sets a given IP and brings it up. // Will reuse an existing one. +// todo: review does this function really necessary or can we merge it with iOS func (w *WGIface) CreateOnAndroid(routes []string, dns string, searchDomains []string) error { w.mu.Lock() defer w.mu.Unlock() @@ -22,3 +23,9 @@ func (w *WGIface) CreateOnAndroid(routes []string, dns string, searchDomains []s func (w *WGIface) Create() error { return fmt.Errorf("this function has not implemented on this platform") } + +func (w *WGIface) RenewTun(fd int) error { + w.mu.Lock() + defer w.mu.Unlock() + return w.tun.RenewTun(fd) +} diff --git a/client/iface/iface_create_darwin.go b/client/iface/iface_create_darwin.go index 1d91bce54..0b7cd36ef 100644 --- a/client/iface/iface_create_darwin.go +++ b/client/iface/iface_create_darwin.go @@ -39,3 +39,7 @@ func (w *WGIface) Create() error { func (w *WGIface) CreateOnAndroid([]string, string, []string) error { return fmt.Errorf("this function has not implemented on this platform") } + +func (w *WGIface) RenewTun(fd int) error { + return fmt.Errorf("this function has not been implemented on this platform") +} 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 e890b30f3..89c8cd16e 100644 --- a/client/iface/iface_test.go +++ b/client/iface/iface_test.go @@ -1,6 +1,9 @@ +//go:build privileged + package iface import ( + "context" "fmt" "net" "net/netip" @@ -9,13 +12,14 @@ import ( "time" "github.com/google/uuid" - "github.com/pion/transport/v3/stdnet" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "golang.zx2c4.com/wireguard/wgctrl" "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" ) // keep darwin compatibility @@ -40,14 +44,14 @@ func TestWGIface_UpdateAddr(t *testing.T) { ifaceName := fmt.Sprintf("utun%d", WgIntNumber+4) addr := "100.64.0.1/8" wgPort := 33100 - newNet, err := stdnet.NewNet() + newNet, err := stdnet.NewNet(context.Background(), nil) if err != nil { t.Fatal(err) } opts := WGIFaceOpts{ IFaceName: ifaceName, - Address: addr, + Address: wgaddr.MustParseWGAddress(addr), WGPort: wgPort, WGPrivKey: key, MTU: DefaultMTU, @@ -83,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) } @@ -123,13 +127,13 @@ func getIfaceAddrs(ifaceName string) ([]net.Addr, error) { func Test_CreateInterface(t *testing.T) { ifaceName := fmt.Sprintf("utun%d", WgIntNumber+1) wgIP := "10.99.99.1/32" - newNet, err := stdnet.NewNet() + newNet, err := stdnet.NewNet(context.Background(), nil) if err != nil { t.Fatal(err) } opts := WGIFaceOpts{ IFaceName: ifaceName, - Address: wgIP, + Address: wgaddr.MustParseWGAddress(wgIP), WGPort: 33100, WGPrivKey: key, MTU: DefaultMTU, @@ -166,14 +170,14 @@ func Test_Close(t *testing.T) { ifaceName := fmt.Sprintf("utun%d", WgIntNumber+2) wgIP := "10.99.99.2/32" wgPort := 33100 - newNet, err := stdnet.NewNet() + newNet, err := stdnet.NewNet(context.Background(), nil) if err != nil { t.Fatal(err) } opts := WGIFaceOpts{ IFaceName: ifaceName, - Address: wgIP, + Address: wgaddr.MustParseWGAddress(wgIP), WGPort: wgPort, WGPrivKey: key, MTU: DefaultMTU, @@ -211,14 +215,14 @@ func TestRecreation(t *testing.T) { ifaceName := fmt.Sprintf("utun%d", WgIntNumber+2) wgIP := "10.99.99.2/32" wgPort := 33100 - newNet, err := stdnet.NewNet() + newNet, err := stdnet.NewNet(context.Background(), nil) if err != nil { t.Fatal(err) } opts := WGIFaceOpts{ IFaceName: ifaceName, - Address: wgIP, + Address: wgaddr.MustParseWGAddress(wgIP), WGPort: wgPort, WGPrivKey: key, MTU: DefaultMTU, @@ -284,13 +288,13 @@ func Test_ConfigureInterface(t *testing.T) { ifaceName := fmt.Sprintf("utun%d", WgIntNumber+3) wgIP := "10.99.99.5/30" wgPort := 33100 - newNet, err := stdnet.NewNet() + newNet, err := stdnet.NewNet(context.Background(), nil) if err != nil { t.Fatal(err) } opts := WGIFaceOpts{ IFaceName: ifaceName, - Address: wgIP, + Address: wgaddr.MustParseWGAddress(wgIP), WGPort: wgPort, WGPrivKey: key, MTU: DefaultMTU, @@ -339,14 +343,14 @@ func Test_ConfigureInterface(t *testing.T) { func Test_UpdatePeer(t *testing.T) { ifaceName := fmt.Sprintf("utun%d", WgIntNumber+4) wgIP := "10.99.99.9/30" - newNet, err := stdnet.NewNet() + newNet, err := stdnet.NewNet(context.Background(), nil) if err != nil { t.Fatal(err) } opts := WGIFaceOpts{ IFaceName: ifaceName, - Address: wgIP, + Address: wgaddr.MustParseWGAddress(wgIP), WGPort: 33100, WGPrivKey: key, MTU: DefaultMTU, @@ -409,14 +413,14 @@ func Test_UpdatePeer(t *testing.T) { func Test_RemovePeer(t *testing.T) { ifaceName := fmt.Sprintf("utun%d", WgIntNumber+4) wgIP := "10.99.99.13/30" - newNet, err := stdnet.NewNet() + newNet, err := stdnet.NewNet(context.Background(), nil) if err != nil { t.Fatal(err) } opts := WGIFaceOpts{ IFaceName: ifaceName, - Address: wgIP, + Address: wgaddr.MustParseWGAddress(wgIP), WGPort: 33100, WGPrivKey: key, MTU: DefaultMTU, @@ -460,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() @@ -471,7 +477,7 @@ func Test_ConnectPeers(t *testing.T) { peer2wgPort := 33200 keepAlive := 1 * time.Second - newNet, err := stdnet.NewNet() + newNet, err := stdnet.NewNet(context.Background(), nil) if err != nil { t.Fatal(err) } @@ -481,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, @@ -501,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) } @@ -514,14 +516,14 @@ func Test_ConnectPeers(t *testing.T) { guid = fmt.Sprintf("{%s}", uuid.New().String()) device.CustomWindowsGUIDString = strings.ToLower(guid) - newNet, err = stdnet.NewNet() + newNet, err = stdnet.NewNet(context.Background(), nil) if err != nil { t.Fatal(err) } optsPeer2 := WGIFaceOpts{ IFaceName: peer2ifaceName, - Address: peer2wgIP.String(), + Address: wgaddr.MustParseWGAddress(peer2wgIP.String()), WGPort: peer2wgPort, WGPrivKey: peer2Key.String(), MTU: DefaultMTU, @@ -542,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) } @@ -565,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) @@ -584,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: + } } } @@ -611,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 566068aa5..5ae98039c 100644 --- a/client/iface/mocks/filter.go +++ b/client/iface/mocks/filter.go @@ -34,18 +34,28 @@ func (m *MockPacketFilter) EXPECT() *MockPacketFilterMockRecorder { return m.recorder } -// AddUDPPacketHook mocks base method. -func (m *MockPacketFilter) AddUDPPacketHook(arg0 bool, arg1 netip.Addr, arg2 uint16, arg3 func([]byte) bool) string { +// SetUDPPacketHook mocks base method. +func (m *MockPacketFilter) SetUDPPacketHook(arg0 netip.Addr, arg1 uint16, arg2 func([]byte) bool) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "AddUDPPacketHook", arg0, arg1, arg2, arg3) - ret0, _ := ret[0].(string) - return ret0 + m.ctrl.Call(m, "SetUDPPacketHook", arg0, arg1, arg2) } -// AddUDPPacketHook indicates an expected call of AddUDPPacketHook. -func (mr *MockPacketFilterMockRecorder) AddUDPPacketHook(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { +// SetUDPPacketHook indicates an expected call of SetUDPPacketHook. +func (mr *MockPacketFilterMockRecorder) SetUDPPacketHook(arg0, arg1, arg2 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddUDPPacketHook", reflect.TypeOf((*MockPacketFilter)(nil).AddUDPPacketHook), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetUDPPacketHook", reflect.TypeOf((*MockPacketFilter)(nil).SetUDPPacketHook), arg0, arg1, arg2) +} + +// SetTCPPacketHook mocks base method. +func (m *MockPacketFilter) SetTCPPacketHook(arg0 netip.Addr, arg1 uint16, arg2 func([]byte) bool) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetTCPPacketHook", arg0, arg1, arg2) +} + +// SetTCPPacketHook indicates an expected call of SetTCPPacketHook. +func (mr *MockPacketFilterMockRecorder) SetTCPPacketHook(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTCPPacketHook", reflect.TypeOf((*MockPacketFilter)(nil).SetTCPPacketHook), arg0, arg1, arg2) } // FilterInbound mocks base method. @@ -75,17 +85,3 @@ func (mr *MockPacketFilterMockRecorder) FilterOutbound(arg0 interface{}, arg1 an mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FilterOutbound", reflect.TypeOf((*MockPacketFilter)(nil).FilterOutbound), arg0, arg1) } - -// RemovePacketHook mocks base method. -func (m *MockPacketFilter) RemovePacketHook(arg0 string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RemovePacketHook", arg0) - ret0, _ := ret[0].(error) - return ret0 -} - -// RemovePacketHook indicates an expected call of RemovePacketHook. -func (mr *MockPacketFilterMockRecorder) RemovePacketHook(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemovePacketHook", reflect.TypeOf((*MockPacketFilter)(nil).RemovePacketHook), arg0) -} diff --git a/client/iface/mocks/iface/mocks/filter.go b/client/iface/mocks/iface/mocks/filter.go deleted file mode 100644 index 291ab9ab5..000000000 --- a/client/iface/mocks/iface/mocks/filter.go +++ /dev/null @@ -1,87 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: github.com/netbirdio/netbird/client/iface (interfaces: PacketFilter) - -// Package mocks is a generated GoMock package. -package mocks - -import ( - net "net" - reflect "reflect" - - gomock "github.com/golang/mock/gomock" -) - -// MockPacketFilter is a mock of PacketFilter interface. -type MockPacketFilter struct { - ctrl *gomock.Controller - recorder *MockPacketFilterMockRecorder -} - -// MockPacketFilterMockRecorder is the mock recorder for MockPacketFilter. -type MockPacketFilterMockRecorder struct { - mock *MockPacketFilter -} - -// NewMockPacketFilter creates a new mock instance. -func NewMockPacketFilter(ctrl *gomock.Controller) *MockPacketFilter { - mock := &MockPacketFilter{ctrl: ctrl} - mock.recorder = &MockPacketFilterMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockPacketFilter) EXPECT() *MockPacketFilterMockRecorder { - return m.recorder -} - -// AddUDPPacketHook mocks base method. -func (m *MockPacketFilter) AddUDPPacketHook(arg0 bool, arg1 net.IP, arg2 uint16, arg3 func(*net.UDPAddr, []byte) bool) { - m.ctrl.T.Helper() - m.ctrl.Call(m, "AddUDPPacketHook", arg0, arg1, arg2, arg3) -} - -// AddUDPPacketHook indicates an expected call of AddUDPPacketHook. -func (mr *MockPacketFilterMockRecorder) AddUDPPacketHook(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddUDPPacketHook", reflect.TypeOf((*MockPacketFilter)(nil).AddUDPPacketHook), arg0, arg1, arg2, arg3) -} - -// FilterInbound mocks base method. -func (m *MockPacketFilter) FilterInbound(arg0 []byte) bool { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "FilterInbound", arg0) - ret0, _ := ret[0].(bool) - return ret0 -} - -// FilterInbound indicates an expected call of FilterInbound. -func (mr *MockPacketFilterMockRecorder) FilterInbound(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FilterInbound", reflect.TypeOf((*MockPacketFilter)(nil).FilterInbound), arg0) -} - -// FilterOutbound mocks base method. -func (m *MockPacketFilter) FilterOutbound(arg0 []byte) bool { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "FilterOutbound", arg0) - ret0, _ := ret[0].(bool) - return ret0 -} - -// FilterOutbound indicates an expected call of FilterOutbound. -func (mr *MockPacketFilterMockRecorder) FilterOutbound(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FilterOutbound", reflect.TypeOf((*MockPacketFilter)(nil).FilterOutbound), arg0) -} - -// SetNetwork mocks base method. -func (m *MockPacketFilter) SetNetwork(arg0 *net.IPNet) { - m.ctrl.T.Helper() - m.ctrl.Call(m, "SetNetwork", arg0) -} - -// SetNetwork indicates an expected call of SetNetwork. -func (mr *MockPacketFilterMockRecorder) SetNetwork(arg0 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetNetwork", reflect.TypeOf((*MockPacketFilter)(nil).SetNetwork), arg0) -} 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 b2506b50d..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 { @@ -66,7 +66,7 @@ func (t *NetStackTun) Create() (tun.Device, *netstack.Net, error) { } }() - return nsTunDev, tunNet, nil + return t.tundev, tunNet, nil } func (t *NetStackTun) Close() error { diff --git a/client/iface/udpmux/mux.go b/client/iface/udpmux/mux.go index 319724926..c5d2de4a5 100644 --- a/client/iface/udpmux/mux.go +++ b/client/iface/udpmux/mux.go @@ -1,6 +1,7 @@ package udpmux import ( + "context" "fmt" "io" "net" @@ -12,8 +13,9 @@ import ( "github.com/pion/logging" "github.com/pion/stun/v3" "github.com/pion/transport/v3" - "github.com/pion/transport/v3/stdnet" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/stdnet" ) /* @@ -199,7 +201,7 @@ func (m *SingleSocketUDPMux) updateLocalAddresses() { if len(networks) > 0 { if m.params.Net == nil { var err error - if m.params.Net, err = stdnet.NewNet(); err != nil { + if m.params.Net, err = stdnet.NewNet(context.Background(), nil); err != nil { m.params.Logger.Errorf("failed to get create network: %v", err) } } diff --git a/client/iface/udpmux/universal.go b/client/iface/udpmux/universal.go index 43bfedaaa..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,67 +121,18 @@ 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) + 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 0, fmt.Errorf("address %s is part of the NetBird network %s, refusing to write", addr, u.address) } 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) { - 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) - } - - 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 -} - // GetSharedConn returns the shared udp conn func (m *UniversalUDPMuxDefault) GetSharedConn() net.PacketConn { return m.params.UDPConn @@ -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..43d1ec9aa 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,60 @@ 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()) +} + +// 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_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 eb585d8a2..be690ed4f 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" @@ -114,21 +114,21 @@ func (p *ProxyBind) Pause() { } func (p *ProxyBind) RedirectAs(endpoint *net.UDPAddr) { + ep, err := addrToEndpoint(endpoint) + if err != nil { + log.Errorf("failed to start package redirection: %v", err) + return + } + p.pausedCond.L.Lock() p.paused = false - p.wgCurrentUsed = addrToEndpoint(endpoint) + p.wgCurrentUsed = ep p.pausedCond.Signal() p.pausedCond.L.Unlock() } -func addrToEndpoint(addr *net.UDPAddr) *bind.Endpoint { - ip, _ := netip.AddrFromSlice(addr.IP.To4()) - addrPort := netip.AddrPortFrom(ip, uint16(addr.Port)) - return &bind.Endpoint{AddrPort: addrPort} -} - func (p *ProxyBind) CloseConn() error { if p.cancel == nil { return fmt.Errorf("proxy not started") @@ -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,19 +201,39 @@ 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 } + +func addrToEndpoint(addr *net.UDPAddr) (*bind.Endpoint, error) { + if addr == nil { + return nil, fmt.Errorf("invalid address") + } + ip, ok := netip.AddrFromSlice(addr.IP) + if !ok { + return nil, fmt.Errorf("convert %s to netip.Addr", addr) + } + + addrPort := netip.AddrPortFrom(ip.Unmap(), uint16(addr.Port)) + return &bind.Endpoint{AddrPort: addrPort}, nil +} diff --git a/client/iface/wgproxy/ebpf/proxy.go b/client/iface/wgproxy/ebpf/proxy.go index 858143091..1b1a8ce1c 100644 --- a/client/iface/wgproxy/ebpf/proxy.go +++ b/client/iface/wgproxy/ebpf/proxy.go @@ -8,8 +8,6 @@ import ( "net" "sync" - "github.com/google/gopacket" - "github.com/google/gopacket/layers" "github.com/hashicorp/go-multierror" "github.com/pion/transport/v3" log "github.com/sirupsen/logrus" @@ -26,13 +24,10 @@ const ( loopbackAddr = "127.0.0.1" ) -var ( - localHostNetIP = net.ParseIP("127.0.0.1") -) - // WGEBPFProxy definition for proxy with EBPF support type WGEBPFProxy struct { localWGListenPort int + proxyPort int mtu uint16 ebpfManager ebpfMgr.Manager @@ -40,7 +35,8 @@ type WGEBPFProxy struct { turnConnMutex sync.Mutex lastUsedPort uint16 - rawConn net.PacketConn + rawConnIPv4 net.PacketConn + rawConnIPv6 net.PacketConn conn transport.UDPConn ctx context.Context @@ -62,23 +58,39 @@ func NewWGEBPFProxy(wgPort int, mtu uint16) *WGEBPFProxy { // Listen load ebpf program and listen the proxy func (p *WGEBPFProxy) Listen() error { pl := portLookup{} - wgPorxyPort, err := pl.searchFreePort() + proxyPort, err := pl.searchFreePort() + if err != nil { + return err + } + p.proxyPort = proxyPort + + // Prepare IPv4 raw socket (required) + p.rawConnIPv4, err = rawsocket.PrepareSenderRawSocketIPv4() if err != nil { return err } - p.rawConn, err = rawsocket.PrepareSenderRawSocket() + // Prepare IPv6 raw socket (optional) + p.rawConnIPv6, err = rawsocket.PrepareSenderRawSocketIPv6() if err != nil { - return err + log.Warnf("failed to prepare IPv6 raw socket, continuing with IPv4 only: %v", err) } - err = p.ebpfManager.LoadWgProxy(wgPorxyPort, p.localWGListenPort) + err = p.ebpfManager.LoadWgProxy(proxyPort, p.localWGListenPort) if err != nil { + if closeErr := p.rawConnIPv4.Close(); closeErr != nil { + log.Warnf("failed to close IPv4 raw socket: %v", closeErr) + } + if p.rawConnIPv6 != nil { + if closeErr := p.rawConnIPv6.Close(); closeErr != nil { + log.Warnf("failed to close IPv6 raw socket: %v", closeErr) + } + } return err } addr := net.UDPAddr{ - Port: wgPorxyPort, + Port: proxyPort, IP: net.ParseIP(loopbackAddr), } @@ -94,7 +106,7 @@ func (p *WGEBPFProxy) Listen() error { p.conn = conn go p.proxyToRemote() - log.Infof("local wg proxy listening on: %d", wgPorxyPort) + log.Infof("local wg proxy listening on: %d", proxyPort) return nil } @@ -135,12 +147,25 @@ func (p *WGEBPFProxy) Free() error { result = multierror.Append(result, err) } - if err := p.rawConn.Close(); err != nil { - result = multierror.Append(result, err) + if p.rawConnIPv4 != nil { + if err := p.rawConnIPv4.Close(); err != nil { + result = multierror.Append(result, err) + } + } + + if p.rawConnIPv6 != nil { + if err := p.rawConnIPv6.Close(); err != nil { + result = multierror.Append(result, err) + } } return nberrors.FormatErrorOrNil(result) } +// GetProxyPort returns the proxy listening port. +func (p *WGEBPFProxy) GetProxyPort() uint16 { + return uint16(p.proxyPort) +} + // proxyToRemote read messages from local WireGuard interface and forward it to remote conn // From this go routine has only one instance. func (p *WGEBPFProxy) proxyToRemote() { @@ -216,34 +241,3 @@ generatePort: } return p.lastUsedPort, nil } - -func (p *WGEBPFProxy) sendPkg(data []byte, endpointAddr *net.UDPAddr) error { - payload := gopacket.Payload(data) - ipH := &layers.IPv4{ - DstIP: localHostNetIP, - SrcIP: endpointAddr.IP, - Version: 4, - TTL: 64, - Protocol: layers.IPProtocolUDP, - } - udpH := &layers.UDP{ - SrcPort: layers.UDPPort(endpointAddr.Port), - DstPort: layers.UDPPort(p.localWGListenPort), - } - - err := udpH.SetNetworkLayerForChecksum(ipH) - if err != nil { - return fmt.Errorf("set network layer for checksum: %w", err) - } - - layerBuffer := gopacket.NewSerializeBuffer() - - err = gopacket.SerializeLayers(layerBuffer, gopacket.SerializeOptions{ComputeChecksums: true, FixLengths: true}, ipH, udpH, payload) - if err != nil { - return fmt.Errorf("serialize layers: %w", err) - } - if _, err = p.rawConn.WriteTo(layerBuffer.Bytes(), &net.IPAddr{IP: localHostNetIP}); err != nil { - return fmt.Errorf("write to raw conn: %w", err) - } - return nil -} diff --git a/client/iface/wgproxy/ebpf/wrapper.go b/client/iface/wgproxy/ebpf/wrapper.go index ff44d30c0..a6156a661 100644 --- a/client/iface/wgproxy/ebpf/wrapper.go +++ b/client/iface/wgproxy/ebpf/wrapper.go @@ -10,12 +10,89 @@ import ( "net" "sync" + "github.com/google/gopacket" + "github.com/google/gopacket/layers" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/client/iface/bufsize" "github.com/netbirdio/netbird/client/iface/wgproxy/listener" ) +var ( + errIPv6ConnNotAvailable = errors.New("IPv6 endpoint but rawConnIPv6 is not available") + errIPv4ConnNotAvailable = errors.New("IPv4 endpoint but rawConnIPv4 is not available") + + localHostNetIPv4 = net.ParseIP("127.0.0.1") + localHostNetIPv6 = net.ParseIP("::1") + + serializeOpts = gopacket.SerializeOptions{ + ComputeChecksums: true, + FixLengths: true, + } +) + +// PacketHeaders holds pre-created headers and buffers for efficient packet sending +type PacketHeaders struct { + ipH gopacket.SerializableLayer + udpH *layers.UDP + layerBuffer gopacket.SerializeBuffer + localHostAddr net.IP + isIPv4 bool +} + +func NewPacketHeaders(localWGListenPort int, endpoint *net.UDPAddr) (*PacketHeaders, error) { + var ipH gopacket.SerializableLayer + var networkLayer gopacket.NetworkLayer + var localHostAddr net.IP + var isIPv4 bool + + // Check if source address is IPv4 or IPv6 + if endpoint.IP.To4() != nil { + // IPv4 path + ipv4 := &layers.IPv4{ + DstIP: localHostNetIPv4, + SrcIP: endpoint.IP, + Version: 4, + TTL: 64, + Protocol: layers.IPProtocolUDP, + } + ipH = ipv4 + networkLayer = ipv4 + localHostAddr = localHostNetIPv4 + isIPv4 = true + } else { + // IPv6 path + ipv6 := &layers.IPv6{ + DstIP: localHostNetIPv6, + SrcIP: endpoint.IP, + Version: 6, + HopLimit: 64, + NextHeader: layers.IPProtocolUDP, + } + ipH = ipv6 + networkLayer = ipv6 + localHostAddr = localHostNetIPv6 + isIPv4 = false + } + + udpH := &layers.UDP{ + SrcPort: layers.UDPPort(endpoint.Port), + DstPort: layers.UDPPort(localWGListenPort), + } + + if err := udpH.SetNetworkLayerForChecksum(networkLayer); err != nil { + return nil, fmt.Errorf("set network layer for checksum: %w", err) + } + + return &PacketHeaders{ + ipH: ipH, + udpH: udpH, + layerBuffer: gopacket.NewSerializeBuffer(), + localHostAddr: localHostAddr, + isIPv4: isIPv4, + }, nil +} + // ProxyWrapper help to keep the remoteConn instance for net.Conn.Close function call type ProxyWrapper struct { wgeBPFProxy *WGEBPFProxy @@ -24,8 +101,10 @@ type ProxyWrapper struct { ctx context.Context cancel context.CancelFunc - wgRelayedEndpointAddr *net.UDPAddr - wgEndpointCurrentUsedAddr *net.UDPAddr + wgRelayedEndpointAddr *net.UDPAddr + headers *PacketHeaders + headerCurrentUsed *PacketHeaders + rawConn net.PacketConn paused bool pausedCond *sync.Cond @@ -41,15 +120,32 @@ func NewProxyWrapper(proxy *WGEBPFProxy) *ProxyWrapper { closeListener: listener.NewCloseListener(), } } -func (p *ProxyWrapper) AddTurnConn(ctx context.Context, endpoint *net.UDPAddr, remoteConn net.Conn) error { + +func (p *ProxyWrapper) AddTurnConn(ctx context.Context, _ *net.UDPAddr, remoteConn net.Conn) error { addr, err := p.wgeBPFProxy.AddTurnConn(remoteConn) if err != nil { return fmt.Errorf("add turn conn: %w", err) } + + headers, err := NewPacketHeaders(p.wgeBPFProxy.localWGListenPort, addr) + if err != nil { + return fmt.Errorf("create packet sender: %w", err) + } + + // Check if required raw connection is available + if !headers.isIPv4 && p.wgeBPFProxy.rawConnIPv6 == nil { + return errIPv6ConnNotAvailable + } + if headers.isIPv4 && p.wgeBPFProxy.rawConnIPv4 == nil { + return errIPv4ConnNotAvailable + } + p.remoteConn = remoteConn p.ctx, p.cancel = context.WithCancel(ctx) p.wgRelayedEndpointAddr = addr - return err + p.headers = headers + p.rawConn = p.selectRawConn(headers) + return nil } func (p *ProxyWrapper) EndpointAddr() *net.UDPAddr { @@ -68,7 +164,8 @@ func (p *ProxyWrapper) Work() { p.pausedCond.L.Lock() p.paused = false - p.wgEndpointCurrentUsedAddr = p.wgRelayedEndpointAddr + p.headerCurrentUsed = p.headers + p.rawConn = p.selectRawConn(p.headerCurrentUsed) if !p.isStarted { p.isStarted = true @@ -91,15 +188,48 @@ func (p *ProxyWrapper) Pause() { } func (p *ProxyWrapper) RedirectAs(endpoint *net.UDPAddr) { + if endpoint == nil || endpoint.IP == nil { + log.Errorf("failed to start package redirection, endpoint is nil") + return + } + + header, err := NewPacketHeaders(p.wgeBPFProxy.localWGListenPort, endpoint) + if err != nil { + log.Errorf("failed to create packet headers: %s", err) + return + } + + // Check if required raw connection is available + if !header.isIPv4 && p.wgeBPFProxy.rawConnIPv6 == nil { + log.Error(errIPv6ConnNotAvailable) + return + } + if header.isIPv4 && p.wgeBPFProxy.rawConnIPv4 == nil { + log.Error(errIPv4ConnNotAvailable) + return + } + p.pausedCond.L.Lock() p.paused = false - p.wgEndpointCurrentUsedAddr = endpoint + p.headerCurrentUsed = header + p.rawConn = p.selectRawConn(header) p.pausedCond.Signal() 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 { @@ -136,7 +266,7 @@ func (p *ProxyWrapper) proxyToLocal(ctx context.Context) { p.pausedCond.Wait() } - err = p.wgeBPFProxy.sendPkg(buf[:n], p.wgEndpointCurrentUsedAddr) + err = p.sendPkg(buf[:n], p.headerCurrentUsed) p.pausedCond.L.Unlock() if err != nil { @@ -162,3 +292,29 @@ func (p *ProxyWrapper) readFromRemote(ctx context.Context, buf []byte) (int, err } return n, nil } + +func (p *ProxyWrapper) sendPkg(data []byte, header *PacketHeaders) error { + defer func() { + if err := header.layerBuffer.Clear(); err != nil { + log.Errorf("failed to clear layer buffer: %s", err) + } + }() + + payload := gopacket.Payload(data) + + if err := gopacket.SerializeLayers(header.layerBuffer, serializeOpts, header.ipH, header.udpH, payload); err != nil { + return fmt.Errorf("serialize layers: %w", err) + } + + if _, err := p.rawConn.WriteTo(header.layerBuffer.Bytes(), &net.IPAddr{IP: header.localHostAddr}); err != nil { + return fmt.Errorf("write to raw conn: %w", err) + } + return nil +} + +func (p *ProxyWrapper) selectRawConn(header *PacketHeaders) net.PacketConn { + if header.isIPv4 { + return p.wgeBPFProxy.rawConnIPv4 + } + return p.wgeBPFProxy.rawConnIPv6 +} diff --git a/client/iface/wgproxy/factory_kernel.go b/client/iface/wgproxy/factory_kernel.go index ad2807546..7821df3de 100644 --- a/client/iface/wgproxy/factory_kernel.go +++ b/client/iface/wgproxy/factory_kernel.go @@ -3,12 +3,19 @@ package wgproxy import ( + "os" + "strconv" + log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/client/iface/wgproxy/ebpf" udpProxy "github.com/netbirdio/netbird/client/iface/wgproxy/udp" ) +const ( + envDisableEBPFWGProxy = "NB_DISABLE_EBPF_WG_PROXY" +) + type KernelFactory struct { wgPort int mtu uint16 @@ -22,6 +29,12 @@ func NewKernelFactory(wgPort int, mtu uint16) *KernelFactory { mtu: mtu, } + if isEBPFDisabled() { + log.Infof("WireGuard Proxy Factory will produce UDP proxy") + log.Infof("eBPF WireGuard proxy is disabled via %s environment variable", envDisableEBPFWGProxy) + return f + } + ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, mtu) if err := ebpfProxy.Listen(); err != nil { log.Infof("WireGuard Proxy Factory will produce UDP proxy") @@ -41,9 +54,30 @@ func (w *KernelFactory) GetProxy() Proxy { return ebpf.NewProxyWrapper(w.ebpfProxy) } +// GetProxyPort returns the eBPF proxy port, or 0 if eBPF is not active. +func (w *KernelFactory) GetProxyPort() uint16 { + if w.ebpfProxy == nil { + return 0 + } + return w.ebpfProxy.GetProxyPort() +} + func (w *KernelFactory) Free() error { if w.ebpfProxy == nil { return nil } return w.ebpfProxy.Free() } + +func isEBPFDisabled() bool { + val := os.Getenv(envDisableEBPFWGProxy) + if val == "" { + return false + } + disabled, err := strconv.ParseBool(val) + if err != nil { + log.Warnf("failed to parse %s: %v", envDisableEBPFWGProxy, err) + return false + } + return disabled +} diff --git a/client/iface/wgproxy/factory_usp.go b/client/iface/wgproxy/factory_usp.go index a1b1c34d7..bbd67e076 100644 --- a/client/iface/wgproxy/factory_usp.go +++ b/client/iface/wgproxy/factory_usp.go @@ -24,6 +24,11 @@ func (w *USPFactory) GetProxy() Proxy { return proxyBind.NewProxyBind(w.bind, w.mtu) } +// GetProxyPort returns 0 as userspace WireGuard doesn't use a separate proxy port. +func (w *USPFactory) GetProxyPort() uint16 { + return 0 +} + func (w *USPFactory) Free() error { return nil } diff --git a/client/iface/wgproxy/proxy.go b/client/iface/wgproxy/proxy.go index 3c8dfd30e..40346bc15 100644 --- a/client/iface/wgproxy/proxy.go +++ b/client/iface/wgproxy/proxy.go @@ -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/rawsocket/rawsocket.go b/client/iface/wgproxy/rawsocket/rawsocket.go index a11ac46d5..bc785b43a 100644 --- a/client/iface/wgproxy/rawsocket/rawsocket.go +++ b/client/iface/wgproxy/rawsocket/rawsocket.go @@ -8,43 +8,87 @@ import ( "os" "syscall" + log "github.com/sirupsen/logrus" + "golang.org/x/sys/unix" + nbnet "github.com/netbirdio/netbird/client/net" ) -func PrepareSenderRawSocket() (net.PacketConn, error) { +// PrepareSenderRawSocketIPv4 creates and configures a raw socket for sending IPv4 packets +func PrepareSenderRawSocketIPv4() (net.PacketConn, error) { + return prepareSenderRawSocket(syscall.AF_INET, true) +} + +// PrepareSenderRawSocketIPv6 creates and configures a raw socket for sending IPv6 packets +func PrepareSenderRawSocketIPv6() (net.PacketConn, error) { + return prepareSenderRawSocket(syscall.AF_INET6, false) +} + +func prepareSenderRawSocket(family int, isIPv4 bool) (net.PacketConn, error) { // Create a raw socket. - fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_RAW, syscall.IPPROTO_RAW) + fd, err := syscall.Socket(family, syscall.SOCK_RAW, syscall.IPPROTO_RAW) if err != nil { return nil, fmt.Errorf("creating raw socket failed: %w", err) } - // Set the IP_HDRINCL option on the socket to tell the kernel that headers are included in the packet. - err = syscall.SetsockoptInt(fd, syscall.IPPROTO_IP, syscall.IP_HDRINCL, 1) - if err != nil { - return nil, fmt.Errorf("setting IP_HDRINCL failed: %w", err) + // Set the header include option on the socket to tell the kernel that headers are included in the packet. + // For IPv4, we need to set IP_HDRINCL. For IPv6, we need to set IPV6_HDRINCL to accept application-provided IPv6 headers. + if isIPv4 { + err = syscall.SetsockoptInt(fd, syscall.IPPROTO_IP, unix.IP_HDRINCL, 1) + 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 IP_HDRINCL failed: %w", err) + } + } else { + err = syscall.SetsockoptInt(fd, syscall.IPPROTO_IPV6, unix.IPV6_HDRINCL, 1) + 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 IPV6_HDRINCL failed: %w", err) + } } // Bind the socket to the "lo" interface. err = syscall.SetsockoptString(fd, syscall.SOL_SOCKET, syscall.SO_BINDTODEVICE, "lo") if err != nil { + if closeErr := syscall.Close(fd); closeErr != nil { + log.Warnf("failed to close raw socket fd: %v", closeErr) + } 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) } // Convert the file descriptor to a PacketConn. file := os.NewFile(uintptr(fd), fmt.Sprintf("fd %d", fd)) if file == nil { + if closeErr := syscall.Close(fd); closeErr != nil { + log.Warnf("failed to close raw socket fd: %v", closeErr) + } return nil, fmt.Errorf("converting fd to file failed") } packetConn, err := net.FilePacketConn(file) if err != nil { + if closeErr := file.Close(); closeErr != nil { + log.Warnf("failed to close file: %v", closeErr) + } return nil, fmt.Errorf("converting file to packet conn failed: %w", err) } + // Close the original file to release the FD (net.FilePacketConn duplicates it) + if closeErr := file.Close(); closeErr != nil { + log.Warnf("failed to close file after creating packet conn: %v", closeErr) + } + return packetConn, nil } diff --git a/client/iface/wgproxy/redirect_test.go b/client/iface/wgproxy/redirect_test.go new file mode 100644 index 000000000..135970838 --- /dev/null +++ b/client/iface/wgproxy/redirect_test.go @@ -0,0 +1,353 @@ +//go:build linux && !android && privileged + +package wgproxy + +import ( + "context" + "net" + "testing" + "time" + + "github.com/netbirdio/netbird/client/iface/wgproxy/ebpf" + "github.com/netbirdio/netbird/client/iface/wgproxy/udp" +) + +// compareUDPAddr compares two UDP addresses, ignoring IPv6 zone IDs +// IPv6 link-local addresses include zone IDs (e.g., fe80::1%lo) which we should ignore +func compareUDPAddr(addr1, addr2 net.Addr) bool { + udpAddr1, ok1 := addr1.(*net.UDPAddr) + udpAddr2, ok2 := addr2.(*net.UDPAddr) + + if !ok1 || !ok2 { + return addr1.String() == addr2.String() + } + + // Compare IP and Port, ignoring zone + return udpAddr1.IP.Equal(udpAddr2.IP) && udpAddr1.Port == udpAddr2.Port +} + +// TestRedirectAs_UDP_IPv4 tests RedirectAs with UDP proxy using IPv4 addresses +func TestRedirectAs_UDP_IPv4(t *testing.T) { + wgPort := 51852 + proxy := udp.NewWGUDPProxy(wgPort, 1280) + + // 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_UDP_IPv6 tests RedirectAs with UDP proxy using IPv6 addresses +func TestRedirectAs_UDP_IPv6(t *testing.T) { + wgPort := 51853 + proxy := udp.NewWGUDPProxy(wgPort, 1280) + + // 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 is a helper function that tests the RedirectAs functionality +// It verifies that: +// 1. Initial traffic from relay connection works +// 2. After calling RedirectAs, packets appear to come from the p2p endpoint +// 3. Multiple packets are correctly redirected with the new source address +func testRedirectAs(t *testing.T, proxy Proxy, wgPort int, nbAddr, p2pEndpoint *net.UDPAddr) { + t.Helper() + + ctx := context.Background() + + // Create WireGuard listeners on both IPv4 and IPv6 to support both P2P connection types + // In reality, WireGuard binds to a port and receives from both IPv4 and IPv6 + wgListener4, err := net.ListenUDP("udp4", &net.UDPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: wgPort, + }) + if err != nil { + t.Fatalf("failed to create IPv4 WireGuard listener: %v", err) + } + defer wgListener4.Close() + + wgListener6, err := net.ListenUDP("udp6", &net.UDPAddr{ + IP: net.ParseIP("::1"), + Port: wgPort, + }) + if err != nil { + t.Fatalf("failed to create IPv6 WireGuard listener: %v", err) + } + defer wgListener6.Close() + + // Determine which listener to use based on the NetBird address IP version + // (this is where initial traffic will come from before RedirectAs is called) + var wgListener *net.UDPConn + if p2pEndpoint.IP.To4() == nil { + wgListener = wgListener6 + } else { + wgListener = wgListener4 + } + + // Create relay server and connection + relayServer, err := net.ListenUDP("udp", &net.UDPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: 0, // Random port + }) + if err != nil { + t.Fatalf("failed to create relay server: %v", err) + } + defer relayServer.Close() + + relayConn, err := net.Dial("udp", relayServer.LocalAddr().String()) + if err != nil { + t.Fatalf("failed to create relay connection: %v", err) + } + 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) + } + defer func() { + if err := proxy.CloseConn(); err != nil { + t.Errorf("failed to close proxy connection: %v", err) + } + }() + + // Start the proxy + proxy.Work() + + // Phase 1: Test initial relay traffic + msgFromRelay := []byte("hello from relay") + if _, err := relayServer.WriteTo(msgFromRelay, relayConn.LocalAddr()); err != nil { + t.Fatalf("failed to write to relay server: %v", err) + } + + // Set read deadline to avoid hanging + if err := wgListener4.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatalf("failed to set read deadline: %v", err) + } + + buf := make([]byte, 1024) + n, _, err := wgListener4.ReadFrom(buf) + if err != nil { + t.Fatalf("failed to read from WireGuard listener: %v", err) + } + + if n != len(msgFromRelay) { + t.Errorf("expected %d bytes, got %d", len(msgFromRelay), n) + } + + if string(buf[:n]) != string(msgFromRelay) { + t.Errorf("expected message %q, got %q", msgFromRelay, buf[:n]) + } + + // Phase 2: Redirect to p2p endpoint + proxy.RedirectAs(p2pEndpoint) + + // Give the proxy a moment to process the redirect + time.Sleep(100 * time.Millisecond) + + // Phase 3: Test redirected traffic + redirectedMessages := [][]byte{ + []byte("redirected message 1"), + []byte("redirected message 2"), + []byte("redirected message 3"), + } + + for i, msg := range redirectedMessages { + if _, err := relayServer.WriteTo(msg, relayConn.LocalAddr()); err != nil { + t.Fatalf("failed to write redirected message %d: %v", i+1, err) + } + + if err := wgListener.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatalf("failed to set read deadline: %v", err) + } + + n, srcAddr, err := wgListener.ReadFrom(buf) + if err != nil { + t.Fatalf("failed to read redirected message %d: %v", i+1, err) + } + + // Verify message content + if string(buf[:n]) != string(msg) { + t.Errorf("message %d: expected %q, got %q", i+1, msg, buf[:n]) + } + + // Verify source address matches p2p endpoint (this is the key test) + // Use compareUDPAddr to ignore IPv6 zone IDs + if !compareUDPAddr(srcAddr, p2pEndpoint) { + t.Errorf("message %d: expected source address %s, got %s", + i+1, p2pEndpoint.String(), srcAddr.String()) + } + } +} + +// 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 + 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) + + ctx := context.Background() + + // Create WireGuard listener + wgListener, err := net.ListenUDP("udp4", &net.UDPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: wgPort, + }) + if err != nil { + t.Fatalf("failed to create WireGuard listener: %v", err) + } + defer wgListener.Close() + + // Create relay server and connection + relayServer, err := net.ListenUDP("udp", &net.UDPAddr{ + IP: net.ParseIP("127.0.0.1"), + Port: 0, + }) + if err != nil { + t.Fatalf("failed to create relay server: %v", err) + } + defer relayServer.Close() + + relayConn, err := net.Dial("udp", relayServer.LocalAddr().String()) + if err != nil { + t.Fatalf("failed to create relay connection: %v", err) + } + defer relayConn.Close() + + nbAddr := &net.UDPAddr{ + IP: net.ParseIP("100.108.111.177"), + Port: 38746, + } + + if err := proxy.AddTurnConn(ctx, nbAddr, relayConn); err != nil { + t.Fatalf("failed to add TURN connection: %v", err) + } + defer func() { + if err := proxy.CloseConn(); err != nil { + t.Errorf("failed to close proxy connection: %v", err) + } + }() + + proxy.Work() + + // Test switching between multiple endpoints - using addresses in local subnet + endpoints := []*net.UDPAddr{ + {IP: net.ParseIP("192.168.0.100"), Port: 51820}, + {IP: net.ParseIP("192.168.0.101"), Port: 51821}, + {IP: net.ParseIP("192.168.0.102"), Port: 51822}, + } + + for i, endpoint := range endpoints { + proxy.RedirectAs(endpoint) + time.Sleep(100 * time.Millisecond) + + msg := []byte("test message") + if _, err := relayServer.WriteTo(msg, relayConn.LocalAddr()); err != nil { + t.Fatalf("failed to write message for endpoint %d: %v", i, err) + } + + buf := make([]byte, 1024) + if err := wgListener.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatalf("failed to set read deadline: %v", err) + } + + n, srcAddr, err := wgListener.ReadFrom(buf) + if err != nil { + t.Fatalf("failed to read message for endpoint %d: %v", i, err) + } + + if string(buf[:n]) != string(msg) { + t.Errorf("endpoint %d: expected message %q, got %q", i, msg, buf[:n]) + } + + if !compareUDPAddr(srcAddr, endpoint) { + t.Errorf("endpoint %d: expected source %s, got %s", + i, endpoint.String(), srcAddr.String()) + } + } +} diff --git a/client/iface/wgproxy/udp/proxy.go b/client/iface/wgproxy/udp/proxy.go index 4ef2f19c4..783843aba 100644 --- a/client/iface/wgproxy/udp/proxy.go +++ b/client/iface/wgproxy/udp/proxy.go @@ -56,7 +56,7 @@ func NewWGUDPProxy(wgPort int, mtu uint16) *WGUDPProxy { // 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, endpoint *net.UDPAddr, remoteConn net.Conn) error { +func (p *WGUDPProxy) AddTurnConn(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/iface/wgproxy/udp/rawsocket.go b/client/iface/wgproxy/udp/rawsocket.go index fdc911463..cc099d9df 100644 --- a/client/iface/wgproxy/udp/rawsocket.go +++ b/client/iface/wgproxy/udp/rawsocket.go @@ -19,37 +19,56 @@ var ( FixLengths: true, } - localHostNetIPAddr = &net.IPAddr{ + localHostNetIPAddrV4 = &net.IPAddr{ IP: net.ParseIP("127.0.0.1"), } + localHostNetIPAddrV6 = &net.IPAddr{ + IP: net.ParseIP("::1"), + } ) type SrcFaker struct { srcAddr *net.UDPAddr - rawSocket net.PacketConn - ipH gopacket.SerializableLayer - udpH gopacket.SerializableLayer - layerBuffer gopacket.SerializeBuffer + rawSocket net.PacketConn + ipH gopacket.SerializableLayer + udpH gopacket.SerializableLayer + layerBuffer gopacket.SerializeBuffer + localHostAddr *net.IPAddr } func NewSrcFaker(dstPort int, srcAddr *net.UDPAddr) (*SrcFaker, error) { - rawSocket, err := rawsocket.PrepareSenderRawSocket() + // Create only the raw socket for the address family we need + var rawSocket net.PacketConn + var err error + var localHostAddr *net.IPAddr + + if srcAddr.IP.To4() != nil { + rawSocket, err = rawsocket.PrepareSenderRawSocketIPv4() + localHostAddr = localHostNetIPAddrV4 + } else { + rawSocket, err = rawsocket.PrepareSenderRawSocketIPv6() + localHostAddr = localHostNetIPAddrV6 + } if err != nil { return nil, err } ipH, udpH, err := prepareHeaders(dstPort, srcAddr) if err != nil { + if closeErr := rawSocket.Close(); closeErr != nil { + log.Warnf("failed to close raw socket: %v", closeErr) + } return nil, err } f := &SrcFaker{ - srcAddr: srcAddr, - rawSocket: rawSocket, - ipH: ipH, - udpH: udpH, - layerBuffer: gopacket.NewSerializeBuffer(), + srcAddr: srcAddr, + rawSocket: rawSocket, + ipH: ipH, + udpH: udpH, + layerBuffer: gopacket.NewSerializeBuffer(), + localHostAddr: localHostAddr, } return f, nil @@ -72,7 +91,7 @@ func (f *SrcFaker) SendPkg(data []byte) (int, error) { if err != nil { return 0, fmt.Errorf("serialize layers: %w", err) } - n, err := f.rawSocket.WriteTo(f.layerBuffer.Bytes(), localHostNetIPAddr) + n, err := f.rawSocket.WriteTo(f.layerBuffer.Bytes(), f.localHostAddr) if err != nil { return 0, fmt.Errorf("write to raw conn: %w", err) } @@ -80,19 +99,40 @@ func (f *SrcFaker) SendPkg(data []byte) (int, error) { } func prepareHeaders(dstPort int, srcAddr *net.UDPAddr) (gopacket.SerializableLayer, gopacket.SerializableLayer, error) { - ipH := &layers.IPv4{ - DstIP: net.ParseIP("127.0.0.1"), - SrcIP: srcAddr.IP, - Version: 4, - TTL: 64, - Protocol: layers.IPProtocolUDP, + var ipH gopacket.SerializableLayer + var networkLayer gopacket.NetworkLayer + + // Check if source IP is IPv4 or IPv6 + if srcAddr.IP.To4() != nil { + // IPv4 + ipv4 := &layers.IPv4{ + DstIP: localHostNetIPAddrV4.IP, + SrcIP: srcAddr.IP, + Version: 4, + TTL: 64, + Protocol: layers.IPProtocolUDP, + } + ipH = ipv4 + networkLayer = ipv4 + } else { + // IPv6 + ipv6 := &layers.IPv6{ + DstIP: localHostNetIPAddrV6.IP, + SrcIP: srcAddr.IP, + Version: 6, + HopLimit: 64, + NextHeader: layers.IPProtocolUDP, + } + ipH = ipv6 + networkLayer = ipv6 } + udpH := &layers.UDP{ SrcPort: layers.UDPPort(srcAddr.Port), DstPort: layers.UDPPort(dstPort), // dst is the localhost WireGuard port } - err := udpH.SetNetworkLayerForChecksum(ipH) + err := udpH.SetNetworkLayerForChecksum(networkLayer) if err != nil { return nil, nil, fmt.Errorf("set network layer for checksum: %w", err) } diff --git a/client/installer.nsis b/client/installer.nsis index 96d60a785..71699071b 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" @@ -79,8 +79,6 @@ ShowInstDetails Show !insertmacro MUI_PAGE_DIRECTORY -Page custom AutostartPage AutostartPageLeave - !insertmacro MUI_PAGE_INSTFILES !insertmacro MUI_PAGE_FINISH @@ -97,40 +95,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 +171,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 +193,10 @@ ${If} $R0 != "" ${EndIf} FunctionEnd + +Function un.onInit +SetRegView 64 +FunctionEnd ###################################################################### Section -MainProgram ${INSTALL_TYPE} @@ -228,6 +211,7 @@ Section -MainProgram !else File /r "..\\dist\\netbird_windows_amd64\\" !endif + File "..\\client\\ui\\assets\\netbird.png" SectionEnd ###################################################################### @@ -244,15 +228,12 @@ 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} +; Autostart is owned by the UI's per-user setting (HKCU\...\Run via Wails), +; not the installer. Drop the machine-wide entry older installers wrote so the +; toggle is the single source of truth. HKCU is left untouched -- it may hold +; the user's own toggle state, which must survive upgrades. +DetailPrint "Removing installer-managed autostart registry entry if present..." +DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" EnVar::SetHKLM EnVar::AddValueEx "path" "$INSTDIR" @@ -262,6 +243,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,9 +299,14 @@ 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..." +; Remove autostart registry entries +DetailPrint "Removing autostart registry entries if they exist..." +; Legacy machine-wide entry written by older installers. +DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" +; Per-user entry the UI toggle writes via Wails (value name is the lowercase +; app-name slug). Uninstall removes the app, so drop it too. DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}" +DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "netbird" ; Handle data deletion based on checkbox DetailPrint "Checking if user requested data deletion..." @@ -306,9 +329,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 +344,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/manager.go b/client/internal/acl/manager.go index 965decc73..f05c01177 100644 --- a/client/internal/acl/manager.go +++ b/client/internal/acl/manager.go @@ -5,21 +5,21 @@ import ( "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" firewall "github.com/netbirdio/netbird/client/firewall/manager" "github.com/netbirdio/netbird/client/internal/acl/id" - "github.com/netbirdio/netbird/client/ssh" "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") @@ -31,11 +31,13 @@ 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 + ipsetCounter int + peerRulesPairs map[id.RuleID][]firewall.Rule + routeRules map[id.RuleID]struct{} + previousConfigHash uint64 + hasAppliedConfig bool + mutex sync.Mutex } func NewDefaultManager(fm firewall.Manager) *DefaultManager { @@ -58,6 +60,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 @@ -71,34 +90,54 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout d.applyPeerACLs(networkMap) - if err := d.applyRouteACLs(networkMap.RoutesFirewallRules, dnsRouteFeatureFlag); err != nil { - log.Errorf("Failed to apply route ACLs: %v", err) + routeErr := d.applyRouteACLs(networkMap.RoutesFirewallRules, dnsRouteFeatureFlag) + if routeErr != nil { + log.Errorf("Failed to apply route ACLs: %v", routeErr) } - if err := d.firewall.Flush(); err != nil { - log.Error("failed to flush firewall rules: ", err) + 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 && routeErr == nil && flushErr == nil { + d.previousConfigHash = hash + d.hasAppliedConfig = true + } else { + d.hasAppliedConfig = false + } +} + +// 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) { rules := networkMap.FirewallRules - enableSSH := networkMap.PeerConfig != nil && - networkMap.PeerConfig.SshConfig != nil && - networkMap.PeerConfig.SshConfig.SshEnabled - - // If SSH enabled, add default firewall rule which accepts connection to any peer - // in the network by SSH (TCP port defined by ssh.DefaultSSHPort). - if enableSSH { - rules = append(rules, &mgmProto.FirewallRule{ - PeerIP: "0.0.0.0", - Direction: mgmProto.RuleDirection_IN, - Action: mgmProto.RuleAction_ACCEPT, - Protocol: mgmProto.RuleProtocol_TCP, - Port: strconv.Itoa(ssh.DefaultSSHPort), - }) - } - // if we got empty rules list but management not set networkMap.FirewallRulesIsEmpty flag // we have old version of management without rules handling, we should allow all traffic if len(networkMap.FirewallRules) == 0 && !networkMap.FirewallRulesIsEmpty { @@ -122,6 +161,10 @@ func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) { newRulePairs := make(map[id.RuleID][]firewall.Rule) ipsetByRuleSelectors := make(map[string]string) + // TODO: deny rules should be fatal: if a deny rule fails to apply, we must + // roll back all allow rules to avoid a fail-open where allowed traffic bypasses + // the missing deny. Currently we accumulate errors and continue. + var merr *multierror.Error for _, r := range rules { // if this rule is member of rule selection with more than DefaultIPsCountForSet // it's IP address can be used in the ipset for firewall manager which supports it @@ -134,9 +177,8 @@ func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) { } 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 + merr = multierror.Append(merr, fmt.Errorf("apply firewall rule: %w", err)) + continue } if len(rulePair) > 0 { d.peerRulesPairs[pairID] = rulePair @@ -144,6 +186,10 @@ func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) { } } + if merr != nil { + log.Errorf("failed to apply %d peer ACL rule(s): %v", merr.Len(), nberrors.FormatErrorOrNil(merr)) + } + for pairID, rules := range d.peerRulesPairs { if _, ok := newRulePairs[pairID]; !ok { for _, rule := range rules { @@ -233,9 +279,9 @@ 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") + ip, err := extractRuleIP(r) + if err != nil { + return "", nil, err } protocol, err := convertToFirewallProtocol(r.Protocol) @@ -306,13 +352,13 @@ func portInfoEmpty(portInfo *mgmProto.PortInfo) bool { func (d *DefaultManager) addInRules( id []byte, - ip net.IP, + ip netip.Addr, 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) + rule, err := d.firewall.AddPeerFiltering(id, ip.AsSlice(), protocol, nil, port, action, ipsetName) if err != nil { return nil, fmt.Errorf("add firewall rule: %w", err) } @@ -322,7 +368,7 @@ func (d *DefaultManager) addInRules( func (d *DefaultManager) addOutRules( id []byte, - ip net.IP, + ip netip.Addr, protocol firewall.Protocol, port *firewall.Port, action firewall.Action, @@ -332,7 +378,7 @@ func (d *DefaultManager) addOutRules( return nil, nil } - rule, err := d.firewall.AddPeerFiltering(id, ip, protocol, port, nil, action, ipsetName) + rule, err := d.firewall.AddPeerFiltering(id, ip.AsSlice(), protocol, port, nil, action, ipsetName) if err != nil { return nil, fmt.Errorf("add firewall rule: %w", err) } @@ -340,9 +386,9 @@ func (d *DefaultManager) addOutRules( return rule, nil } -// getPeerRuleID() returns unique ID for the rule based on its parameters. +// getPeerRuleID returns unique ID for the rule based on its parameters. func (d *DefaultManager) getPeerRuleID( - ip net.IP, + ip netip.Addr, proto firewall.Protocol, direction int, port *firewall.Port, @@ -361,15 +407,24 @@ func (d *DefaultManager) getRuleGroupingSelector(rule *mgmProto.FirewallRule) st 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) - } +// extractRuleIP extracts the peer IP from a firewall rule. +// If sourcePrefixes is populated (new management), decode the first entry and use its address. +// Otherwise fall back to the deprecated PeerIP string field (old management). +func extractRuleIP(r *mgmProto.FirewallRule) (netip.Addr, error) { + if len(r.SourcePrefixes) > 0 { + addr, err := netiputil.DecodeAddr(r.SourcePrefixes[0]) + if err != nil { + return netip.Addr{}, fmt.Errorf("decode source prefix: %w", err) } + return addr.Unmap(), nil } + + //nolint:staticcheck // PeerIP used for backward compatibility with old management + addr, err := netip.ParseAddr(r.PeerIP) + if err != nil { + return netip.Addr{}, fmt.Errorf("invalid IP address, skipping firewall rule") + } + return addr.Unmap(), nil } func convertToFirewallProtocol(protocol mgmProto.RuleProtocol) (firewall.Protocol, error) { diff --git a/client/internal/acl/manager_test.go b/client/internal/acl/manager_test.go index 638245bf7..832edf8ca 100644 --- a/client/internal/acl/manager_test.go +++ b/client/internal/acl/manager_test.go @@ -1,6 +1,7 @@ package acl import ( + "fmt" "net/netip" "testing" @@ -19,6 +20,9 @@ import ( var flowLogger = netflow.NewManager(nil, []byte{}, nil).GetLogger() func TestDefaultManager(t *testing.T) { + t.Setenv("NB_WG_KERNEL_DISABLED", "true") + t.Setenv(firewall.EnvForceUserspaceFirewall, "true") + networkMap := &mgmProto.NetworkMap{ FirewallRules: []*mgmProto.FirewallRule{ { @@ -135,6 +139,7 @@ func TestDefaultManager(t *testing.T) { func TestDefaultManagerStateless(t *testing.T) { // stateless currently only in userspace, so we have to disable kernel t.Setenv("NB_WG_KERNEL_DISABLED", "true") + t.Setenv(firewall.EnvForceUserspaceFirewall, "true") t.Setenv("NB_DISABLE_CONNTRACK", "true") networkMap := &mgmProto.NetworkMap{ @@ -189,6 +194,215 @@ func TestDefaultManagerStateless(t *testing.T) { }) } +// TestDenyRulesNotAccumulatedOnRepeatedApply verifies that applying the same +// deny rules repeatedly does not accumulate duplicate rules in the uspfilter. +// This tests the full ACL manager -> uspfilter integration. +func TestDenyRulesNotAccumulatedOnRepeatedApply(t *testing.T) { + t.Setenv("NB_WG_KERNEL_DISABLED", "true") + t.Setenv(firewall.EnvForceUserspaceFirewall, "true") + + networkMap := &mgmProto.NetworkMap{ + FirewallRules: []*mgmProto.FirewallRule{ + { + PeerIP: "10.93.0.1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_DROP, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "22", + }, + { + PeerIP: "10.93.0.2", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_DROP, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "80", + }, + { + PeerIP: "10.93.0.3", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + }, + }, + FirewallRulesIsEmpty: false, + } + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + ifaceMock := mocks.NewMockIFaceMapper(ctrl) + ifaceMock.EXPECT().IsUserspaceBind().Return(true).AnyTimes() + ifaceMock.EXPECT().SetFilter(gomock.Any()) + network := netip.MustParsePrefix("172.0.0.1/32") + ifaceMock.EXPECT().Name().Return("lo").AnyTimes() + ifaceMock.EXPECT().Address().Return(wgaddr.Address{ + IP: network.Addr(), + Network: network, + }).AnyTimes() + ifaceMock.EXPECT().GetWGDevice().Return(nil).AnyTimes() + + fw, err := firewall.NewFirewall(ifaceMock, nil, flowLogger, false, iface.DefaultMTU) + require.NoError(t, err) + defer func() { + require.NoError(t, fw.Close(nil)) + }() + + acl := NewDefaultManager(fw) + + // Apply the same rules 5 times (simulating repeated network map updates) + for i := 0; i < 5; i++ { + acl.ApplyFiltering(networkMap, false) + } + + // The ACL manager should track exactly 3 rule pairs (2 deny + 1 accept inbound) + assert.Equal(t, 3, len(acl.peerRulesPairs), + "Should have exactly 3 rule pairs after 5 identical updates") +} + +// TestDenyRulesCleanedUpOnRemoval verifies that deny rules are properly cleaned +// up when they're removed from the network map in a subsequent update. +func TestDenyRulesCleanedUpOnRemoval(t *testing.T) { + t.Setenv("NB_WG_KERNEL_DISABLED", "true") + 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) + + // First update: add deny and accept rules + networkMap1 := &mgmProto.NetworkMap{ + FirewallRules: []*mgmProto.FirewallRule{ + { + PeerIP: "10.93.0.1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_DROP, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "22", + }, + { + PeerIP: "10.93.0.2", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + }, + }, + FirewallRulesIsEmpty: false, + } + + acl.ApplyFiltering(networkMap1, false) + assert.Equal(t, 2, len(acl.peerRulesPairs), "Should have 2 rules after first update") + + // Second update: remove the deny rule, keep only accept + networkMap2 := &mgmProto.NetworkMap{ + FirewallRules: []*mgmProto.FirewallRule{ + { + PeerIP: "10.93.0.2", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "443", + }, + }, + FirewallRulesIsEmpty: false, + } + + acl.ApplyFiltering(networkMap2, false) + assert.Equal(t, 1, len(acl.peerRulesPairs), + "Should have 1 rule after removing deny rule") + + // Third update: remove all rules + networkMap3 := &mgmProto.NetworkMap{ + FirewallRules: []*mgmProto.FirewallRule{}, + FirewallRulesIsEmpty: true, + } + + acl.ApplyFiltering(networkMap3, false) + assert.Equal(t, 0, len(acl.peerRulesPairs), + "Should have 0 rules after removing all rules") +} + +// TestRuleUpdateChangingAction verifies that when a rule's action changes from +// accept to deny (or vice versa), the old rule is properly removed and the new +// one added without leaking. +func TestRuleUpdateChangingAction(t *testing.T) { + t.Setenv("NB_WG_KERNEL_DISABLED", "true") + 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) + + // First update: accept rule + networkMap := &mgmProto.NetworkMap{ + FirewallRules: []*mgmProto.FirewallRule{ + { + PeerIP: "10.93.0.1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "22", + }, + }, + FirewallRulesIsEmpty: false, + } + acl.ApplyFiltering(networkMap, false) + assert.Equal(t, 1, len(acl.peerRulesPairs)) + + // Second update: change to deny (same IP/port/proto, different action) + networkMap.FirewallRules = []*mgmProto.FirewallRule{ + { + PeerIP: "10.93.0.1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_DROP, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "22", + }, + } + acl.ApplyFiltering(networkMap, false) + + // Should still have exactly 1 rule (the old accept removed, new deny added) + assert.Equal(t, 1, len(acl.peerRulesPairs), + "Changing action should result in exactly 1 rule, not 2") +} + func TestPortInfoEmpty(t *testing.T) { tests := []struct { name string @@ -273,39 +487,13 @@ func TestPortInfoEmpty(t *testing.T) { } } -func TestDefaultManagerEnableSSHRules(t *testing.T) { - networkMap := &mgmProto.NetworkMap{ - PeerConfig: &mgmProto.PeerConfig{ - SshConfig: &mgmProto.SSHConfig{ - SshEnabled: true, - }, - }, - RemotePeers: []*mgmProto.RemotePeerConfig{ - {AllowedIps: []string{"10.93.0.1"}}, - {AllowedIps: []string{"10.93.0.2"}}, - {AllowedIps: []string{"10.93.0.3"}}, - }, - FirewallRules: []*mgmProto.FirewallRule{ - { - PeerIP: "10.93.0.1", - Direction: mgmProto.RuleDirection_IN, - Action: mgmProto.RuleAction_ACCEPT, - Protocol: mgmProto.RuleProtocol_TCP, - }, - { - PeerIP: "10.93.0.2", - Direction: mgmProto.RuleDirection_IN, - Action: mgmProto.RuleAction_ACCEPT, - Protocol: mgmProto.RuleProtocol_TCP, - }, - { - PeerIP: "10.93.0.3", - Direction: mgmProto.RuleDirection_OUT, - Action: mgmProto.RuleAction_ACCEPT, - Protocol: mgmProto.RuleProtocol_UDP, - }, - }, - } +// 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() @@ -314,7 +502,6 @@ func TestDefaultManagerEnableSSHRules(t *testing.T) { 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(), @@ -325,17 +512,123 @@ func TestDefaultManagerEnableSSHRules(t *testing.T) { fw, err := firewall.NewFirewall(ifaceMock, nil, flowLogger, false, iface.DefaultMTU) require.NoError(t, err) defer func() { - err = fw.Close(nil) - require.NoError(t, err) + require.NoError(t, fw.Close(nil)) }() acl := NewDefaultManager(fw) - acl.ApplyFiltering(networkMap, false) - - expectedRules := 3 - if fw.IsStateful() { - expectedRules = 3 // 2 inbound rules + SSH rule + networkMap := &mgmProto.NetworkMap{ + FirewallRules: []*mgmProto.FirewallRule{ + { + PeerIP: "10.93.0.1", + Direction: mgmProto.RuleDirection_IN, + Action: mgmProto.RuleAction_ACCEPT, + Protocol: mgmProto.RuleProtocol_TCP, + Port: "22", + }, + }, + FirewallRulesIsEmpty: false, } - assert.Equal(t, expectedRules, len(acl.peerRulesPairs)) + + 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), + 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/auth/auth.go b/client/internal/auth/auth.go new file mode 100644 index 000000000..153727a6c --- /dev/null +++ b/client/internal/auth/auth.go @@ -0,0 +1,518 @@ +package auth + +import ( + "context" + "net/url" + "strings" + "sync" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/google/uuid" + log "github.com/sirupsen/logrus" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/ssh" + "github.com/netbirdio/netbird/client/system" + mgm "github.com/netbirdio/netbird/shared/management/client" + "github.com/netbirdio/netbird/shared/management/client/common" + 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 { + mutex sync.RWMutex + client *mgm.GrpcClient + config *profilemanager.Config + privateKey wgtypes.Key + mgmURL *url.URL + mgmTLSEnabled bool +} + +// NewAuth creates a new Auth instance that manages authentication flows +// It establishes a connection to the management server that will be reused for all operations +// The connection is automatically recreated with backoff if it becomes disconnected +func NewAuth(ctx context.Context, privateKey string, mgmURL *url.URL, config *profilemanager.Config) (*Auth, error) { + // Validate WireGuard private key + myPrivateKey, err := wgtypes.ParseKey(privateKey) + if err != nil { + return nil, err + } + + // Determine TLS setting based on URL scheme + mgmTLSEnabled := mgmURL.Scheme == "https" + + log.Debugf("connecting to Management Service %s", mgmURL.String()) + mgmClient, err := mgm.NewClient(ctx, mgmURL.Host, myPrivateKey, mgmTLSEnabled) + if err != nil { + log.Errorf("failed connecting to Management Service %s: %v", mgmURL.String(), err) + return nil, err + } + + log.Debugf("connected to the Management service %s", mgmURL.String()) + + return &Auth{ + client: mgmClient, + config: config, + privateKey: myPrivateKey, + mgmURL: mgmURL, + mgmTLSEnabled: mgmTLSEnabled, + }, nil +} + +// Close closes the management client connection +func (a *Auth) Close() error { + a.mutex.Lock() + defer a.mutex.Unlock() + + if a.client == nil { + return nil + } + return a.client.Close() +} + +// IsSSOSupported checks if the management server supports SSO by attempting to retrieve auth flow configurations. +// Returns true if either PKCE or Device authorization flow is supported, false otherwise. +// This function encapsulates the SSO detection logic to avoid exposing gRPC error codes to upper layers. +// Automatically retries with backoff and reconnection on connection errors. +func (a *Auth) IsSSOSupported(ctx context.Context) (bool, error) { + var supportsSSO bool + + err := a.withRetry(ctx, func(client *mgm.GrpcClient) error { + // Try PKCE flow first + _, err := a.getPKCEFlow(client) + if err == nil { + supportsSSO = true + return nil + } + + // Check if PKCE is not supported + if s, ok := status.FromError(err); ok && (s.Code() == codes.NotFound || s.Code() == codes.Unimplemented) { + // PKCE not supported, try Device flow + _, err = a.getDeviceFlow(client) + if err == nil { + supportsSSO = true + return nil + } + + // Check if Device flow is also not supported + if s, ok := status.FromError(err); ok && (s.Code() == codes.NotFound || s.Code() == codes.Unimplemented) { + // Neither PKCE nor Device flow is supported + supportsSSO = false + return nil + } + + // Device flow check returned an error other than NotFound/Unimplemented + return err + } + + // PKCE flow check returned an error other than NotFound/Unimplemented + return err + }) + + return supportsSSO, err +} + +// 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) { + var flow OAuthFlow + var err error + + err = a.withRetry(ctx, func(client *mgm.GrpcClient) error { + if forceDeviceAuth { + flow, err = a.getDeviceFlow(client) + return err + } + + // Try PKCE flow first + flow, 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 + } + return err + } + return nil + }) + + return flow, err +} + +// IsLoginRequired checks if login is required by attempting to authenticate with the server +// Automatically retries with backoff and reconnection on connection errors. +func (a *Auth) IsLoginRequired(ctx context.Context) (bool, error) { + pubSSHKey, err := ssh.GeneratePublicKey([]byte(a.config.SSHKey)) + if err != nil { + return false, err + } + + var needsLogin bool + + err = a.withRetry(ctx, func(client *mgm.GrpcClient) error { + err := a.doMgmLogin(client, ctx, pubSSHKey) + if isLoginNeeded(err) { + needsLogin = true + return nil + } + needsLogin = false + return err + }) + + return needsLogin, err +} + +// Login attempts to log in or register the client with the management server +// Returns error and a boolean indicating if it's an authentication error (permission denied) that should stop retries. +// Automatically retries with backoff and reconnection on connection errors. +func (a *Auth) Login(ctx context.Context, setupKey string, jwtToken string) (error, bool) { + pubSSHKey, err := ssh.GeneratePublicKey([]byte(a.config.SSHKey)) + if err != nil { + return err, false + } + + var isAuthError bool + + err = a.withRetry(ctx, func(client *mgm.GrpcClient) error { + err := a.doMgmLogin(client, ctx, pubSSHKey) + if isRegistrationNeeded(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 + } + } else if err != nil { + isAuthError = isPermissionDenied(err) + return err + } + + isAuthError = false + return nil + }) + + return err, isAuthError +} + +// getPKCEFlow retrieves PKCE authorization flow configuration and creates a flow instance +func (a *Auth) getPKCEFlow(client *mgm.GrpcClient) (*PKCEAuthorizationFlow, error) { + protoFlow, err := client.GetPKCEAuthorizationFlow() + if err != nil { + if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound { + log.Warnf("server couldn't find pkce flow, contact admin: %v", err) + return nil, err + } + log.Errorf("failed to retrieve pkce flow: %v", err) + return nil, err + } + + protoConfig := protoFlow.GetProviderConfig() + config := &PKCEAuthProviderConfig{ + Audience: protoConfig.GetAudience(), + ClientID: protoConfig.GetClientID(), + ClientSecret: protoConfig.GetClientSecret(), //nolint:staticcheck + TokenEndpoint: protoConfig.GetTokenEndpoint(), + AuthorizationEndpoint: protoConfig.GetAuthorizationEndpoint(), + Scope: protoConfig.GetScope(), + RedirectURLs: protoConfig.GetRedirectURLs(), + UseIDToken: protoConfig.GetUseIDToken(), + ClientCertPair: a.config.ClientCertKeyPair, + DisablePromptLogin: protoConfig.GetDisablePromptLogin(), + LoginFlag: common.LoginFlag(protoConfig.GetLoginFlag()), + } + + if err := validatePKCEConfig(config); err != nil { + return nil, err + } + + flow, err := NewPKCEAuthorizationFlow(*config) + if err != nil { + return nil, err + } + + return flow, nil +} + +// getDeviceFlow retrieves device authorization flow configuration and creates a flow instance +func (a *Auth) getDeviceFlow(client *mgm.GrpcClient) (*DeviceAuthorizationFlow, error) { + protoFlow, err := client.GetDeviceAuthorizationFlow() + if err != nil { + if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound { + log.Warnf("server couldn't find device flow, contact admin: %v", err) + return nil, err + } + log.Errorf("failed to retrieve device flow: %v", err) + return nil, err + } + + protoConfig := protoFlow.GetProviderConfig() + config := &DeviceAuthProviderConfig{ + Audience: protoConfig.GetAudience(), + ClientID: protoConfig.GetClientID(), + ClientSecret: protoConfig.GetClientSecret(), //nolint:staticcheck + Domain: protoConfig.Domain, + TokenEndpoint: protoConfig.GetTokenEndpoint(), + DeviceAuthEndpoint: protoConfig.GetDeviceAuthEndpoint(), + Scope: protoConfig.GetScope(), + UseIDToken: protoConfig.GetUseIDToken(), + } + + // Keep compatibility with older management versions + if config.Scope == "" { + config.Scope = "openid" + } + + if err := validateDeviceAuthConfig(config); err != nil { + return nil, err + } + + flow, err := NewDeviceAuthorizationFlow(*config) + if err != nil { + return nil, err + } + + return flow, nil +} + +// doMgmLogin performs the actual login operation with the management service +func (a *Auth) doMgmLogin(client *mgm.GrpcClient, ctx context.Context, pubSSHKey []byte) error { + sysInfo := system.GetInfo(ctx) + a.setSystemInfoFlags(sysInfo) + _, err := client.Login(sysInfo, pubSSHKey, a.config.DNSLabels) + return err +} + +// registerPeer checks whether setupKey was provided via cmd line and if not then it prompts user to enter a key. +// Otherwise tries to register with the provided setupKey via command line. +func (a *Auth) registerPeer(client *mgm.GrpcClient, ctx context.Context, setupKey string, jwtToken string, pubSSHKey []byte) (*mgmProto.LoginResponse, error) { + validSetupKey, err := uuid.Parse(setupKey) + if err != nil && jwtToken == "" { + return nil, status.Errorf(codes.InvalidArgument, "invalid setup-key or no sso information provided, err: %v", err) + } + + log.Debugf("sending peer registration request to Management Service") + info := system.GetInfo(ctx) + a.setSystemInfoFlags(info) + loginResp, err := client.Register(validSetupKey.String(), jwtToken, info, pubSSHKey, a.config.DNSLabels) + if err != nil { + log.Errorf("failed registering peer %v", err) + return nil, err + } + + log.Infof("peer has been successfully registered on Management Service") + + return loginResp, nil +} + +// setSystemInfoFlags sets all configuration flags on the provided system info +func (a *Auth) setSystemInfoFlags(info *system.Info) { + info.SetFlags( + a.config.RosenpassEnabled, + a.config.RosenpassPermissive, + a.config.ServerSSHAllowed, + a.config.DisableClientRoutes, + a.config.DisableServerRoutes, + a.config.DisableDNS, + a.config.DisableFirewall, + a.config.BlockLANAccess, + a.config.BlockInbound, + a.config.DisableIPv6, + a.config.SyncMessageVersion, + a.config.EnableSSHRoot, + a.config.EnableSSHSFTP, + a.config.EnableSSHLocalPortForwarding, + a.config.EnableSSHRemotePortForwarding, + a.config.DisableSSHAuth, + ) +} + +// reconnect closes the current connection and creates a new one +// It checks if the brokenClient is still the current client before reconnecting +// to avoid multiple threads reconnecting unnecessarily +func (a *Auth) reconnect(ctx context.Context, brokenClient *mgm.GrpcClient) error { + a.mutex.Lock() + defer a.mutex.Unlock() + + // Double-check: if client has already been replaced by another thread, skip reconnection + if a.client != brokenClient { + log.Debugf("client already reconnected by another thread, skipping") + return nil + } + + // Create new connection FIRST, before closing the old one + // This ensures a.client is never nil, preventing panics in other threads + log.Debugf("reconnecting to Management Service %s", a.mgmURL.String()) + mgmClient, err := mgm.NewClient(ctx, a.mgmURL.Host, a.privateKey, a.mgmTLSEnabled) + if err != nil { + log.Errorf("failed reconnecting to Management Service %s: %v", a.mgmURL.String(), err) + // Keep the old client if reconnection fails + return err + } + + // Close old connection AFTER new one is successfully created + oldClient := a.client + a.client = mgmClient + + if oldClient != nil { + if err := oldClient.Close(); err != nil { + log.Debugf("error closing old connection: %v", err) + } + } + + log.Debugf("successfully reconnected to Management service %s", a.mgmURL.String()) + return nil +} + +// isConnectionError checks if the error is a connection-related error that should trigger reconnection +func isConnectionError(err error) bool { + if err == nil { + return false + } + s, ok := status.FromError(err) + if !ok { + return false + } + // These error codes indicate connection issues + return s.Code() == codes.Unavailable || + s.Code() == codes.DeadlineExceeded || + s.Code() == codes.Canceled || + s.Code() == codes.Internal +} + +// withRetry wraps an operation with exponential backoff retry logic +// It automatically reconnects on connection errors +func (a *Auth) withRetry(ctx context.Context, operation func(client *mgm.GrpcClient) error) error { + backoffSettings := &backoff.ExponentialBackOff{ + InitialInterval: 500 * time.Millisecond, + RandomizationFactor: 0.5, + Multiplier: 1.5, + MaxInterval: 10 * time.Second, + MaxElapsedTime: 2 * time.Minute, + Stop: backoff.Stop, + Clock: backoff.SystemClock, + } + backoffSettings.Reset() + + return backoff.RetryNotify( + func() error { + // Capture the client BEFORE the operation to ensure we track the correct client + a.mutex.RLock() + currentClient := a.client + a.mutex.RUnlock() + + if currentClient == nil { + return status.Errorf(codes.Unavailable, "client is not initialized") + } + + // Execute operation with the captured client + err := operation(currentClient) + if err == nil { + return nil + } + + // If it's a connection error, attempt reconnection using the client that was actually used + if isConnectionError(err) { + log.Warnf("connection error detected, attempting reconnection: %v", err) + + if reconnectErr := a.reconnect(ctx, currentClient); reconnectErr != nil { + log.Errorf("reconnection failed: %v", reconnectErr) + return reconnectErr + } + // Return the original error to trigger retry with the new connection + return err + } + + // For authentication errors, don't retry + if isAuthenticationError(err) { + return backoff.Permanent(err) + } + + return err + }, + backoff.WithContext(backoffSettings, ctx), + func(err error, duration time.Duration) { + log.Warnf("operation failed, retrying in %v: %v", duration, err) + }, + ) +} + +// isAuthenticationError checks if the error is an authentication-related error that should not be retried. +// Returns true if the error is InvalidArgument or PermissionDenied, indicating that retrying won't help. +func isAuthenticationError(err error) bool { + if err == nil { + return false + } + s, ok := status.FromError(err) + if !ok { + return false + } + return s.Code() == codes.InvalidArgument || s.Code() == codes.PermissionDenied +} + +// isPermissionDenied checks if the error is a PermissionDenied error. +// This is used to determine if early exit from backoff is needed (e.g., when the server responded but denied access). +func isPermissionDenied(err error) bool { + if err == nil { + return false + } + s, ok := status.FromError(err) + if !ok { + return false + } + return s.Code() == codes.PermissionDenied +} + +func isLoginNeeded(err error) bool { + return isAuthenticationError(err) +} + +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 8ca760742..9dec7cf53 100644 --- a/client/internal/auth/device_flow.go +++ b/client/internal/auth/device_flow.go @@ -15,7 +15,6 @@ import ( log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/util/embeddedroots" ) @@ -26,12 +25,56 @@ const ( var _ OAuthFlow = &DeviceAuthorizationFlow{} +// DeviceAuthProviderConfig has all attributes needed to initiate a device authorization flow +type DeviceAuthProviderConfig struct { + // ClientID An IDP application client id + ClientID string + // ClientSecret An IDP application client secret + ClientSecret string + // Domain An IDP API domain + // Deprecated. Use OIDCConfigEndpoint instead + Domain string + // Audience An Audience for to authorization validation + Audience string + // TokenEndpoint is the endpoint of an IDP manager where clients can obtain access token + TokenEndpoint string + // DeviceAuthEndpoint is the endpoint of an IDP manager where clients can obtain device authorization code + DeviceAuthEndpoint string + // Scopes provides the scopes to be included in the token request + Scope string + // UseIDToken indicates if the id token should be used for authentication + UseIDToken bool + // LoginHint is used to pre-fill the email/username field during authentication + LoginHint string +} + +// validateDeviceAuthConfig validates device authorization provider configuration +func validateDeviceAuthConfig(config *DeviceAuthProviderConfig) error { + errorMsgFormat := "invalid provider configuration received from management: %s value is empty. Contact your NetBird administrator" + + if config.Audience == "" { + return fmt.Errorf(errorMsgFormat, "Audience") + } + if config.ClientID == "" { + return fmt.Errorf(errorMsgFormat, "Client ID") + } + if config.TokenEndpoint == "" { + return fmt.Errorf(errorMsgFormat, "Token Endpoint") + } + if config.DeviceAuthEndpoint == "" { + return fmt.Errorf(errorMsgFormat, "Device Auth Endpoint") + } + if config.Scope == "" { + return fmt.Errorf(errorMsgFormat, "Device Auth Scopes") + } + return nil +} + // DeviceAuthorizationFlow implements the OAuthFlow interface, // for the Device Authorization Flow. type DeviceAuthorizationFlow struct { - providerConfig internal.DeviceAuthProviderConfig - - HTTPClient HTTPClient + providerConfig DeviceAuthProviderConfig + HTTPClient HTTPClient } // RequestDeviceCodePayload used for request device code payload for auth0 @@ -57,7 +100,7 @@ type TokenRequestResponse struct { } // NewDeviceAuthorizationFlow returns device authorization flow client -func NewDeviceAuthorizationFlow(config internal.DeviceAuthProviderConfig) (*DeviceAuthorizationFlow, error) { +func NewDeviceAuthorizationFlow(config DeviceAuthProviderConfig) (*DeviceAuthorizationFlow, error) { httpTransport := http.DefaultTransport.(*http.Transport).Clone() httpTransport.MaxIdleConns = 5 @@ -89,6 +132,11 @@ func (d *DeviceAuthorizationFlow) GetClientID(ctx context.Context) string { return d.providerConfig.ClientID } +// SetLoginHint sets the login hint for the device authorization flow +func (d *DeviceAuthorizationFlow) SetLoginHint(hint string) { + d.providerConfig.LoginHint = hint +} + // RequestAuthInfo requests a device code login flow information from Hosted func (d *DeviceAuthorizationFlow) RequestAuthInfo(ctx context.Context) (AuthFlowInfo, error) { form := url.Values{} @@ -199,16 +247,30 @@ func (d *DeviceAuthorizationFlow) requestToken(info AuthFlowInfo) (TokenRequestR } // WaitToken waits user's login and authorize the app. Once the user's authorize -// it retrieves the access token from Hosted's endpoint and validates it before returning +// it retrieves the access token from Hosted's endpoint and validates it before returning. +// The method creates a timeout context internally based on info.ExpiresIn. func (d *DeviceAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowInfo) (TokenInfo, error) { + // Create timeout context based on flow expiration + timeout := time.Duration(info.ExpiresIn) * time.Second + waitCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + interval := time.Duration(info.Interval) * time.Second 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 <-ctx.Done(): - return TokenInfo{}, ctx.Err() + 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) @@ -216,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 } @@ -235,11 +299,12 @@ 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) } + 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/device_flow_test.go b/client/internal/auth/device_flow_test.go index 466645ee9..6a433cb61 100644 --- a/client/internal/auth/device_flow_test.go +++ b/client/internal/auth/device_flow_test.go @@ -12,8 +12,6 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/require" - - "github.com/netbirdio/netbird/client/internal" ) type mockHTTPClient struct { @@ -115,18 +113,19 @@ func TestHosted_RequestDeviceCode(t *testing.T) { err: testCase.inputReqError, } - deviceFlow := &DeviceAuthorizationFlow{ - providerConfig: internal.DeviceAuthProviderConfig{ - Audience: expectedAudience, - ClientID: expectedClientID, - Scope: expectedScope, - TokenEndpoint: "test.hosted.com/token", - DeviceAuthEndpoint: "test.hosted.com/device/auth", - UseIDToken: false, - }, - HTTPClient: &httpClient, + config := DeviceAuthProviderConfig{ + Audience: expectedAudience, + ClientID: expectedClientID, + Scope: expectedScope, + TokenEndpoint: "test.hosted.com/token", + DeviceAuthEndpoint: "test.hosted.com/device/auth", + UseIDToken: false, } + deviceFlow, err := NewDeviceAuthorizationFlow(config) + require.NoError(t, err, "creating device flow should not fail") + deviceFlow.HTTPClient = &httpClient + authInfo, err := deviceFlow.RequestAuthInfo(context.TODO()) testCase.testingErrFunc(t, err, testCase.expectedErrorMSG) @@ -280,18 +279,19 @@ func TestHosted_WaitToken(t *testing.T) { countResBody: testCase.inputCountResBody, } - deviceFlow := DeviceAuthorizationFlow{ - providerConfig: internal.DeviceAuthProviderConfig{ - Audience: testCase.inputAudience, - ClientID: clientID, - TokenEndpoint: "test.hosted.com/token", - DeviceAuthEndpoint: "test.hosted.com/device/auth", - Scope: "openid", - UseIDToken: false, - }, - HTTPClient: &httpClient, + config := DeviceAuthProviderConfig{ + Audience: testCase.inputAudience, + ClientID: clientID, + TokenEndpoint: "test.hosted.com/token", + DeviceAuthEndpoint: "test.hosted.com/device/auth", + Scope: "openid", + UseIDToken: false, } + deviceFlow, err := NewDeviceAuthorizationFlow(config) + require.NoError(t, err, "creating device flow should not fail") + deviceFlow.HTTPClient = &httpClient + ctx, cancel := context.WithTimeout(context.TODO(), testCase.inputTimeout) defer cancel() tokenInfo, err := deviceFlow.WaitToken(ctx, testCase.inputInfo) diff --git a/client/internal/auth/oauth.go b/client/internal/auth/oauth.go index 9fbd6cf5f..a50a2ce6f 100644 --- a/client/internal/auth/oauth.go +++ b/client/internal/auth/oauth.go @@ -10,7 +10,6 @@ import ( "google.golang.org/grpc/codes" gstatus "google.golang.org/grpc/status" - "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/profilemanager" ) @@ -60,14 +59,19 @@ func (t TokenInfo) GetTokenToUse() string { return t.AccessToken } +func shouldUseDeviceFlow(force bool, isUnixDesktopClient bool) bool { + return force || (runtime.GOOS == "linux" || runtime.GOOS == "freebsd") && !isUnixDesktopClient +} + // NewOAuthFlow initializes and returns the appropriate OAuth flow based on the management configuration // // It starts by initializing the PKCE.If this process fails, it resorts to the Device Code Flow, // and if that also fails, the authentication process is deemed unsuccessful // // On Linux distros without desktop environment support, it only tries to initialize the Device Code Flow -func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, isUnixDesktopClient bool, hint string) (OAuthFlow, error) { - if (runtime.GOOS == "linux" || runtime.GOOS == "freebsd") && !isUnixDesktopClient { +// forceDeviceCodeFlow can be used to skip PKCE and go directly to Device Code Flow (e.g., for Android TV) +func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, isUnixDesktopClient bool, forceDeviceCodeFlow bool, hint string) (OAuthFlow, error) { + if shouldUseDeviceFlow(forceDeviceCodeFlow, isUnixDesktopClient) { return authenticateWithDeviceCodeFlow(ctx, config, hint) } @@ -82,19 +86,33 @@ func NewOAuthFlow(ctx context.Context, config *profilemanager.Config, isUnixDesk // authenticateWithPKCEFlow initializes the Proof Key for Code Exchange flow auth flow func authenticateWithPKCEFlow(ctx context.Context, config *profilemanager.Config, hint string) (OAuthFlow, error) { - pkceFlowInfo, err := internal.GetPKCEAuthorizationFlowInfo(ctx, config.PrivateKey, config.ManagementURL, config.ClientCertKeyPair) + authClient, err := NewAuth(ctx, config.PrivateKey, config.ManagementURL, config) + if err != nil { + return nil, fmt.Errorf("failed to create auth client: %v", err) + } + defer authClient.Close() + + pkceFlowInfo, err := authClient.getPKCEFlow(authClient.client) if err != nil { return nil, fmt.Errorf("getting pkce authorization flow info failed with error: %v", err) } - pkceFlowInfo.ProviderConfig.LoginHint = hint + if hint != "" { + pkceFlowInfo.SetLoginHint(hint) + } - return NewPKCEAuthorizationFlow(pkceFlowInfo.ProviderConfig) + return pkceFlowInfo, nil } // authenticateWithDeviceCodeFlow initializes the Device Code auth Flow func authenticateWithDeviceCodeFlow(ctx context.Context, config *profilemanager.Config, hint string) (OAuthFlow, error) { - deviceFlowInfo, err := internal.GetDeviceAuthorizationFlowInfo(ctx, config.PrivateKey, config.ManagementURL) + authClient, err := NewAuth(ctx, config.PrivateKey, config.ManagementURL, config) + if err != nil { + return nil, fmt.Errorf("failed to create auth client: %v", err) + } + defer authClient.Close() + + deviceFlowInfo, err := authClient.getDeviceFlow(authClient.client) if err != nil { switch s, ok := gstatus.FromError(err); { case ok && s.Code() == codes.NotFound: @@ -109,7 +127,9 @@ func authenticateWithDeviceCodeFlow(ctx context.Context, config *profilemanager. } } - deviceFlowInfo.ProviderConfig.LoginHint = hint + if hint != "" { + deviceFlowInfo.SetLoginHint(hint) + } - return NewDeviceAuthorizationFlow(deviceFlowInfo.ProviderConfig) + 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 738d3e34f..be64cc6a8 100644 --- a/client/internal/auth/pkce_flow.go +++ b/client/internal/auth/pkce_flow.go @@ -13,14 +13,15 @@ import ( "net" "net/http" "net/url" + "strconv" "strings" "time" log "github.com/sirupsen/logrus" "golang.org/x/oauth2" - "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/templates" + "github.com/netbirdio/netbird/shared/management/client/common" ) var _ OAuthFlow = &PKCEAuthorizationFlow{} @@ -33,22 +34,73 @@ const ( defaultPKCETimeoutSeconds = 300 ) +// PKCEAuthProviderConfig has all attributes needed to initiate PKCE authorization flow +type PKCEAuthProviderConfig struct { + // ClientID An IDP application client id + ClientID string + // ClientSecret An IDP application client secret + ClientSecret string + // Audience An Audience for to authorization validation + Audience string + // TokenEndpoint is the endpoint of an IDP manager where clients can obtain access token + TokenEndpoint string + // AuthorizationEndpoint is the endpoint of an IDP manager where clients can obtain authorization code + AuthorizationEndpoint string + // Scopes provides the scopes to be included in the token request + Scope string + // RedirectURL handles authorization code from IDP manager + RedirectURLs []string + // UseIDToken indicates if the id token should be used for authentication + UseIDToken bool + // ClientCertPair is used for mTLS authentication to the IDP + ClientCertPair *tls.Certificate + // DisablePromptLogin makes the PKCE flow to not prompt the user for login + DisablePromptLogin bool + // LoginFlag is used to configure the PKCE flow login behavior + LoginFlag common.LoginFlag + // LoginHint is used to pre-fill the email/username field during authentication + LoginHint string +} + +// validatePKCEConfig validates PKCE provider configuration +func validatePKCEConfig(config *PKCEAuthProviderConfig) error { + errorMsgFormat := "invalid provider configuration received from management: %s value is empty. Contact your NetBird administrator" + + if config.ClientID == "" { + return fmt.Errorf(errorMsgFormat, "Client ID") + } + if config.TokenEndpoint == "" { + return fmt.Errorf(errorMsgFormat, "Token Endpoint") + } + if config.AuthorizationEndpoint == "" { + return fmt.Errorf(errorMsgFormat, "Authorization Auth Endpoint") + } + if config.Scope == "" { + return fmt.Errorf(errorMsgFormat, "PKCE Auth Scopes") + } + if config.RedirectURLs == nil { + return fmt.Errorf(errorMsgFormat, "PKCE Redirect URLs") + } + return nil +} + // PKCEAuthorizationFlow implements the OAuthFlow interface for // the Authorization Code Flow with PKCE. type PKCEAuthorizationFlow struct { - providerConfig internal.PKCEAuthProviderConfig + providerConfig PKCEAuthProviderConfig state string codeVerifier string oAuthConfig *oauth2.Config } // NewPKCEAuthorizationFlow returns new PKCE authorization code flow. -func NewPKCEAuthorizationFlow(config internal.PKCEAuthProviderConfig) (*PKCEAuthorizationFlow, error) { +func NewPKCEAuthorizationFlow(config PKCEAuthProviderConfig) (*PKCEAuthorizationFlow, error) { var availableRedirectURL string - // find the first available redirect URL + excludedRanges := getSystemExcludedPortRanges() + for _, redirectURL := range config.RedirectURLs { - if !isRedirectURLPortUsed(redirectURL) { + if !isRedirectURLPortUsed(redirectURL, excludedRanges) { availableRedirectURL = redirectURL break } @@ -102,10 +154,10 @@ func (p *PKCEAuthorizationFlow) RequestAuthInfo(ctx context.Context) (AuthFlowIn oauth2.SetAuthURLParam("audience", p.providerConfig.Audience), } if !p.providerConfig.DisablePromptLogin { - if p.providerConfig.LoginFlag.IsPromptLogin() { + switch p.providerConfig.LoginFlag { + case common.LoginFlagPromptLogin: params = append(params, oauth2.SetAuthURLParam("prompt", "login")) - } - if p.providerConfig.LoginFlag.IsMaxAge0Login() { + case common.LoginFlagMaxAge0: params = append(params, oauth2.SetAuthURLParam("max_age", "0")) } } @@ -121,10 +173,23 @@ func (p *PKCEAuthorizationFlow) RequestAuthInfo(ctx context.Context) (AuthFlowIn }, nil } +// SetLoginHint sets the login hint for the PKCE authorization flow +func (p *PKCEAuthorizationFlow) SetLoginHint(hint string) { + p.providerConfig.LoginHint = hint +} + // WaitToken waits for the OAuth token in the PKCE Authorization Flow. // It starts an HTTP server to receive the OAuth token callback and waits for the token or an error. // Once the token is received, it is converted to TokenInfo and validated before returning. -func (p *PKCEAuthorizationFlow) WaitToken(ctx context.Context, _ AuthFlowInfo) (TokenInfo, error) { +// The method creates a timeout context internally based on info.ExpiresIn. +func (p *PKCEAuthorizationFlow) WaitToken(ctx context.Context, info AuthFlowInfo) (TokenInfo, error) { + // Create timeout context based on flow expiration + timeout := time.Duration(info.ExpiresIn) * time.Second + 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) @@ -135,7 +200,7 @@ func (p *PKCEAuthorizationFlow) WaitToken(ctx context.Context, _ AuthFlowInfo) ( server := &http.Server{Addr: fmt.Sprintf(":%s", parsedURL.Port())} defer func() { - shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := server.Shutdown(shutdownCtx); err != nil { @@ -146,8 +211,8 @@ func (p *PKCEAuthorizationFlow) WaitToken(ctx context.Context, _ AuthFlowInfo) ( go p.startServer(server, tokenChan, errChan) select { - case <-ctx.Done(): - return TokenInfo{}, ctx.Err() + case <-waitCtx.Done(): + return TokenInfo{}, waitCtx.Err() case token := <-tokenChan: return p.parseOAuthToken(token) case err := <-errChan: @@ -158,6 +223,7 @@ func (p *PKCEAuthorizationFlow) WaitToken(ctx context.Context, _ 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{ @@ -192,24 +258,34 @@ func (p *PKCEAuthorizationFlow) handleRequest(req *http.Request) (*oauth2.Token, if authError := query.Get(queryError); authError != "" { authErrorDesc := query.Get(queryErrorDesc) - return nil, fmt.Errorf("%s.%s", authError, authErrorDesc) + if authErrorDesc != "" { + return nil, fmt.Errorf("authentication failed: %s", authErrorDesc) + } + return nil, fmt.Errorf("authentication failed: %s", authError) } // Prevent timing attacks on the state if state := query.Get(queryState); subtle.ConstantTimeCompare([]byte(p.state), []byte(state)) == 0 { - return nil, fmt.Errorf("invalid state") + return nil, fmt.Errorf("authentication failed: Invalid state") } code := query.Get(queryCode) if code == "" { - return nil, fmt.Errorf("missing code") + 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) { @@ -230,8 +306,8 @@ func (p *PKCEAuthorizationFlow) parseOAuthToken(token *oauth2.Token) (TokenInfo, audience = p.providerConfig.ClientID } - if err := isValidAccessToken(tokenInfo.GetTokenToUse(), audience); err != nil { - return TokenInfo{}, fmt.Errorf("validate access token failed with error: %v", err) + if err := validateTokenAudience(tokenInfo.GetTokenToUse(), audience); err != nil { + return TokenInfo{}, fmt.Errorf("authentication failed: invalid access token - %w", err) } email, err := parseEmailFromIDToken(tokenInfo.IDToken) @@ -244,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 { @@ -279,15 +360,28 @@ func createCodeChallenge(codeVerifier string) string { return base64.RawURLEncoding.EncodeToString(sha2[:]) } -// isRedirectURLPortUsed checks if the port used in the redirect URL is in use. -func isRedirectURLPortUsed(redirectURL string) bool { +// isRedirectURLPortUsed checks if the port used in the redirect URL is in use or excluded on Windows. +func isRedirectURLPortUsed(redirectURL string, excludedRanges []excludedPortRange) bool { parsedURL, err := url.Parse(redirectURL) if err != nil { log.Errorf("failed to parse redirect URL: %v", err) return true } - addr := fmt.Sprintf(":%s", parsedURL.Port()) + port := parsedURL.Port() + + if isPortInExcludedRange(port, excludedRanges) { + log.Warnf("port %s is in Windows excluded port range, skipping", port) + return true + } + + // 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 @@ -301,6 +395,33 @@ func isRedirectURLPortUsed(redirectURL string) bool { return true } +// excludedPortRange represents a range of excluded ports. +type excludedPortRange struct { + start int + end int +} + +// isPortInExcludedRange checks if the given port is in any of the excluded ranges. +func isPortInExcludedRange(port string, excludedRanges []excludedPortRange) bool { + if len(excludedRanges) == 0 { + return false + } + + portNum, err := strconv.Atoi(port) + if err != nil { + log.Debugf("invalid port number %s: %v", port, err) + return false + } + + for _, r := range excludedRanges { + if portNum >= r.start && portNum <= r.end { + return true + } + } + + return false +} + func renderPKCEFlowTmpl(w http.ResponseWriter, authError error) { tmpl, err := template.New("pkce-auth-flow").Parse(templates.PKCEAuthMsgTmpl) if err != nil { diff --git a/client/internal/auth/pkce_flow_other.go b/client/internal/auth/pkce_flow_other.go new file mode 100644 index 000000000..96df41539 --- /dev/null +++ b/client/internal/auth/pkce_flow_other.go @@ -0,0 +1,8 @@ +//go:build !windows + +package auth + +// getSystemExcludedPortRanges returns nil on non-Windows platforms. +func getSystemExcludedPortRanges() []excludedPortRange { + return nil +} diff --git a/client/internal/auth/pkce_flow_test.go b/client/internal/auth/pkce_flow_test.go index b2347d12d..c487c13df 100644 --- a/client/internal/auth/pkce_flow_test.go +++ b/client/internal/auth/pkce_flow_test.go @@ -2,11 +2,13 @@ package auth import ( "context" + "fmt" + "net" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/netbirdio/netbird/client/internal" mgm "github.com/netbirdio/netbird/shared/management/client/common" ) @@ -20,28 +22,34 @@ func TestPromptLogin(t *testing.T) { name string loginFlag mgm.LoginFlag disablePromptLogin bool - expect string + expectContains []string }{ { - name: "Prompt login", - loginFlag: mgm.LoginFlagPrompt, - expect: promptLogin, + name: "Prompt login", + loginFlag: mgm.LoginFlagPromptLogin, + expectContains: []string{promptLogin}, }, { - name: "Max age 0 login", - loginFlag: mgm.LoginFlagMaxAge0, - expect: maxAge0, + name: "Max age 0", + loginFlag: mgm.LoginFlagMaxAge0, + expectContains: []string{maxAge0}, }, { name: "Disable prompt login", - loginFlag: mgm.LoginFlagPrompt, + loginFlag: mgm.LoginFlagPromptLogin, disablePromptLogin: true, + expectContains: []string{}, + }, + { + name: "None flag should not add parameters", + loginFlag: mgm.LoginFlagNone, + expectContains: []string{}, }, } for _, tc := range tt { t.Run(tc.name, func(t *testing.T) { - config := internal.PKCEAuthProviderConfig{ + config := PKCEAuthProviderConfig{ ClientID: "test-client-id", Audience: "test-audience", TokenEndpoint: "https://test-token-endpoint.com/token", @@ -50,6 +58,7 @@ func TestPromptLogin(t *testing.T) { RedirectURLs: []string{"http://127.0.0.1:33992/"}, UseIDToken: true, LoginFlag: tc.loginFlag, + DisablePromptLogin: tc.disablePromptLogin, } pkce, err := NewPKCEAuthorizationFlow(config) if err != nil { @@ -60,12 +69,153 @@ func TestPromptLogin(t *testing.T) { t.Fatalf("Failed to request auth info: %v", err) } - if !tc.disablePromptLogin { - require.Contains(t, authInfo.VerificationURIComplete, tc.expect) - } else { - require.Contains(t, authInfo.VerificationURIComplete, promptLogin) - require.NotContains(t, authInfo.VerificationURIComplete, maxAge0) + for _, expected := range tc.expectContains { + require.Contains(t, authInfo.VerificationURIComplete, expected) } }) } } + +func TestIsPortInExcludedRange(t *testing.T) { + tests := []struct { + name string + port string + excludedRanges []excludedPortRange + expectedBlocked bool + }{ + { + name: "Port in excluded range", + port: "8080", + excludedRanges: []excludedPortRange{{start: 8000, end: 8100}}, + expectedBlocked: true, + }, + { + name: "Port at start of range", + port: "8000", + excludedRanges: []excludedPortRange{{start: 8000, end: 8100}}, + expectedBlocked: true, + }, + { + name: "Port at end of range", + port: "8100", + excludedRanges: []excludedPortRange{{start: 8000, end: 8100}}, + expectedBlocked: true, + }, + { + name: "Port before range", + port: "7999", + excludedRanges: []excludedPortRange{{start: 8000, end: 8100}}, + expectedBlocked: false, + }, + { + name: "Port after range", + port: "8101", + excludedRanges: []excludedPortRange{{start: 8000, end: 8100}}, + expectedBlocked: false, + }, + { + name: "Empty excluded ranges", + port: "8080", + excludedRanges: []excludedPortRange{}, + expectedBlocked: false, + }, + { + name: "Nil excluded ranges", + port: "8080", + excludedRanges: nil, + expectedBlocked: false, + }, + { + name: "Multiple ranges - port in second range", + port: "9050", + excludedRanges: []excludedPortRange{ + {start: 8000, end: 8100}, + {start: 9000, end: 9100}, + }, + expectedBlocked: true, + }, + { + name: "Multiple ranges - port not in any range", + port: "8500", + excludedRanges: []excludedPortRange{ + {start: 8000, end: 8100}, + {start: 9000, end: 9100}, + }, + expectedBlocked: false, + }, + { + name: "Invalid port string", + port: "invalid", + excludedRanges: []excludedPortRange{{start: 8000, end: 8100}}, + expectedBlocked: false, + }, + { + name: "Empty port string", + port: "", + excludedRanges: []excludedPortRange{{start: 8000, end: 8100}}, + expectedBlocked: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isPortInExcludedRange(tt.port, tt.excludedRanges) + assert.Equal(t, tt.expectedBlocked, result, "Port exclusion check mismatch") + }) + } +} + +func TestIsRedirectURLPortUsed(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer func() { + _ = listener.Close() + }() + + usedPort := listener.Addr().(*net.TCPAddr).Port + + tests := []struct { + name string + redirectURL string + excludedRanges []excludedPortRange + expectedUsed bool + }{ + { + name: "Port in excluded range", + redirectURL: "http://127.0.0.1:8080/", + excludedRanges: []excludedPortRange{{start: 8000, end: 8100}}, + expectedUsed: true, + }, + { + name: "Port actually in use", + redirectURL: fmt.Sprintf("http://127.0.0.1:%d/", usedPort), + excludedRanges: nil, + expectedUsed: true, + }, + { + name: "Port not in use and not excluded", + redirectURL: "http://127.0.0.1:65432/", + excludedRanges: nil, + expectedUsed: false, + }, + { + name: "Invalid URL without port", + redirectURL: "not-a-valid-url", + excludedRanges: nil, + expectedUsed: false, + }, + { + name: "Port excluded even if not in use", + redirectURL: "http://127.0.0.1:8050/", + excludedRanges: []excludedPortRange{{start: 8000, end: 8100}}, + expectedUsed: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isRedirectURLPortUsed(tt.redirectURL, tt.excludedRanges) + assert.Equal(t, tt.expectedUsed, result, "Port usage check mismatch") + }) + } +} diff --git a/client/internal/auth/pkce_flow_windows.go b/client/internal/auth/pkce_flow_windows.go new file mode 100644 index 000000000..cf3f8718f --- /dev/null +++ b/client/internal/auth/pkce_flow_windows.go @@ -0,0 +1,86 @@ +//go:build windows + +package auth + +import ( + "bufio" + "fmt" + "os/exec" + "strconv" + "strings" + + log "github.com/sirupsen/logrus" +) + +// getSystemExcludedPortRanges retrieves the excluded port ranges from Windows using netsh. +func getSystemExcludedPortRanges() []excludedPortRange { + ranges, err := getExcludedPortRangesFromNetsh() + if err != nil { + log.Debugf("failed to get Windows excluded port ranges: %v", err) + return nil + } + + return ranges +} + +// getExcludedPortRangesFromNetsh retrieves excluded port ranges using netsh command. +func getExcludedPortRangesFromNetsh() ([]excludedPortRange, error) { + cmd := exec.Command("netsh", "interface", "ipv4", "show", "excludedportrange", "protocol=tcp") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("netsh command: %w", err) + } + + return parseExcludedPortRanges(string(output)) +} + +// parseExcludedPortRanges parses the output of the netsh command to extract port ranges. +func parseExcludedPortRanges(output string) ([]excludedPortRange, error) { + var ranges []excludedPortRange + scanner := bufio.NewScanner(strings.NewReader(output)) + + foundHeader := false + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + if strings.Contains(line, "Start Port") && strings.Contains(line, "End Port") { + foundHeader = true + continue + } + + if !foundHeader { + continue + } + + if strings.Contains(line, "----------") { + continue + } + + if line == "" { + continue + } + + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + + startPort, err := strconv.Atoi(fields[0]) + if err != nil { + continue + } + + endPort, err := strconv.Atoi(fields[1]) + if err != nil { + continue + } + + ranges = append(ranges, excludedPortRange{start: startPort, end: endPort}) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("scan output: %w", err) + } + + return ranges, nil +} diff --git a/client/internal/auth/pkce_flow_windows_test.go b/client/internal/auth/pkce_flow_windows_test.go new file mode 100644 index 000000000..125eb270a --- /dev/null +++ b/client/internal/auth/pkce_flow_windows_test.go @@ -0,0 +1,114 @@ +//go:build windows + +package auth + +import ( + "fmt" + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseExcludedPortRanges(t *testing.T) { + tests := []struct { + name string + netshOutput string + expectedRanges []excludedPortRange + expectError bool + }{ + { + name: "Valid netsh output with multiple ranges", + netshOutput: ` +Protocol tcp Dynamic Port Range +--------------------------------- +Start Port : 49152 +Number of Ports : 16384 + +Protocol tcp Excluded Port Ranges +--------------------------------- +Start Port End Port +---------- -------- + 5357 5357 * + 50000 50059 * +`, + expectedRanges: []excludedPortRange{ + {start: 5357, end: 5357}, + {start: 50000, end: 50059}, + }, + expectError: false, + }, + { + name: "Empty output", + netshOutput: ` +Protocol tcp Dynamic Port Range +--------------------------------- +Start Port : 49152 +Number of Ports : 16384 +`, + expectedRanges: nil, + expectError: false, + }, + { + name: "Single range", + netshOutput: ` +Protocol tcp Excluded Port Ranges +--------------------------------- +Start Port End Port +---------- -------- + 8080 8090 +`, + expectedRanges: []excludedPortRange{ + {start: 8080, end: 8090}, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ranges, err := parseExcludedPortRanges(tt.netshOutput) + + if tt.expectError { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectedRanges, ranges) + } + }) + } +} + +func TestNewPKCEAuthorizationFlow_WithActualExcludedPorts(t *testing.T) { + ranges := getSystemExcludedPortRanges() + t.Logf("Found %d excluded port ranges on this system", len(ranges)) + + listener1, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer func() { + _ = listener1.Close() + }() + usedPort1 := listener1.Addr().(*net.TCPAddr).Port + + availablePort := 65432 + + config := PKCEAuthProviderConfig{ + ClientID: "test-client-id", + Audience: "test-audience", + TokenEndpoint: "https://test-token-endpoint.com/token", + Scope: "openid email profile", + AuthorizationEndpoint: "https://test-auth-endpoint.com/authorize", + RedirectURLs: []string{ + fmt.Sprintf("http://127.0.0.1:%d/", usedPort1), + fmt.Sprintf("http://127.0.0.1:%d/", availablePort), + }, + UseIDToken: true, + } + + flow, err := NewPKCEAuthorizationFlow(config) + require.NoError(t, err) + require.NotNil(t, flow) + assert.Contains(t, flow.oAuthConfig.RedirectURL, fmt.Sprintf(":%d", availablePort), + "Should skip port in use and select available port") +} 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..7a591f60c 100644 --- a/client/internal/conn_mgr.go +++ b/client/internal/conn_mgr.go @@ -16,6 +16,16 @@ import ( "github.com/netbirdio/netbird/route" ) +// 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. // // The connection manager is responsible for: @@ -24,47 +34,69 @@ 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 + force lazyForce rosenpassEnabled 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 wg sync.WaitGroup lazyCtx context.Context lazyCtxCancel context.CancelFunc } +// SetRoutedIPsReconciler injects the callback used to re-apply a peer's routed allowed IPs when +// its lazy wake endpoint is (re)armed. Must be called before the lazy manager starts. +func (e *ConnMgr) SetRoutedIPsReconciler(fn func(peerKey string) error) { + e.reconcileRoutedIPs = fn +} + func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr { e := &ConnMgr{ peerStore: peerStore, statusRecorder: statusRecorder, iface: iface, + force: resolveLazyForce(engineConfig.LazyConnection), rosenpassEnabled: engineConfig.RosenpassEnabled, } - if engineConfig.LazyConnectionEnabled || lazyconn.IsLazyConnEnabledByEnv() { - e.enabledLocally = true - } 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. It starts the lazy connection manager when a +// local override forces it on; with no local override it waits for the management feature flag. 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") + switch e.force { + case lazyForceOff: + log.Infof("lazy connection manager is disabled by local override (%s or MDM policy)", lazyconn.EnvLazyConn) + e.statusRecorder.UpdateLazyConnection(false) + return + case lazyForceNone: + log.Infof("lazy connection manager is managed by the management feature flag") + e.statusRecorder.UpdateLazyConnection(false) return } if e.rosenpassEnabled { log.Warnf("rosenpass connection manager is enabled, lazy connection manager will not be started") + e.statusRecorder.UpdateLazyConnection(false) return } @@ -76,8 +108,8 @@ func (e *ConnMgr) Start(ctx context.Context) { // 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 { + // a local override (NB_LAZY_CONN or local config) takes precedence over management + if e.force != lazyForceNone { return nil } @@ -89,15 +121,17 @@ func (e *ConnMgr) UpdatedRemoteFeatureFlag(ctx context.Context, enabled bool) er if e.rosenpassEnabled { log.Infof("rosenpass connection manager is enabled, lazy connection manager will not be started") + e.statusRecorder.UpdateLazyConnection(false) return nil } - log.Warnf("lazy connection manager is enabled by management feature flag") + log.Infof("lazy connection manager is enabled by the management feature flag") e.initLazyManager(ctx) e.statusRecorder.UpdateLazyConnection(true) return e.addPeersToLazyConnManager() } else { if e.lazyConnMgr == nil { + e.statusRecorder.UpdateLazyConnection(false) return nil } log.Infof("lazy connection manager is disabled by management feature flag") @@ -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,22 @@ func (e *ConnMgr) Close() { e.lazyCtxCancel() e.wg.Wait() + + e.lazyConnMgrMu.Lock() e.lazyConnMgr = nil + e.lazyConnMgrMu.Unlock() } 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.wg.Add(1) go func() { @@ -298,7 +346,10 @@ func (e *ConnMgr) closeManager(ctx context.Context) { e.lazyCtxCancel() e.wg.Wait() + + e.lazyConnMgrMu.Lock() e.lazyConnMgr = nil + e.lazyConnMgrMu.Unlock() for _, peerID := range e.peerStore.PeersPubKey() { e.peerStore.PeerConnOpen(ctx, peerID) @@ -309,6 +360,25 @@ 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 == "" { diff --git a/client/internal/conn_mgr_test.go b/client/internal/conn_mgr_test.go new file mode 100644 index 000000000..e027fd4f2 --- /dev/null +++ b/client/internal/conn_mgr_test.go @@ -0,0 +1,106 @@ +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" +) + +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() +} diff --git a/client/internal/connect.go b/client/internal/connect.go index bb7c2b38b..f4d14aab2 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -6,28 +6,40 @@ 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/updater" + "github.com/netbirdio/netbird/client/internal/updater/installer" nbnet "github.com/netbirdio/netbird/client/net" cProto "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/ssh" + sshconfig "github.com/netbirdio/netbird/client/ssh/config" "github.com/netbirdio/netbird/client/system" mgm "github.com/netbirdio/netbird/shared/management/client" mgmProto "github.com/netbirdio/netbird/shared/management/proto" @@ -38,12 +50,23 @@ import ( "github.com/netbirdio/netbird/version" ) +// androidRunOverride is set on Android to inject mobile dependencies +// when using embed.Client (which calls Run() with empty MobileDependency). +var androidRunOverride func(c *ConnectClient, runningChan chan struct{}, logPath string) error + type ConnectClient struct { ctx context.Context + runCancel context.CancelFunc + runExited chan struct{} + runOnce sync.Once + runStarted atomic.Bool config *profilemanager.Config statusRecorder *peer.Status - engine *Engine - engineMutex sync.Mutex + + engine *Engine + engineMutex sync.Mutex + clientMetrics *metrics.ClientMetrics + updateManager *updater.Manager persistSyncResponse bool } @@ -52,19 +75,31 @@ func NewConnectClient( ctx context.Context, config *profilemanager.Config, statusRecorder *peer.Status, - ) *ConnectClient { + // Derive the run context here so Stop owns the cancel that unblocks the run + // loop. runCancel is set once at construction, so Stop can call it without + // racing the run loop's startup. Callers therefore need not cancel before Stop. + runCtx, runCancel := context.WithCancel(ctx) return &ConnectClient{ - ctx: ctx, + ctx: runCtx, + runCancel: runCancel, + runExited: make(chan struct{}), config: config, statusRecorder: statusRecorder, engineMutex: sync.Mutex{}, } } +func (c *ConnectClient) SetUpdateManager(um *updater.Manager) { + c.updateManager = um +} + // Run with main logic. -func (c *ConnectClient) Run(runningChan chan struct{}) error { - return c.run(MobileDependency{}, runningChan) +func (c *ConnectClient) Run(runningChan chan struct{}, logPath string) error { + if androidRunOverride != nil { + return androidRunOverride(c, runningChan, logPath) + } + return c.run(MobileDependency{}, runningChan, logPath) } // RunOnAndroid with main logic on mobile system @@ -74,6 +109,8 @@ func (c *ConnectClient) RunOnAndroid( networkChangeListener listener.NetworkChangeListener, dnsAddresses []netip.AddrPort, dnsReadyListener dns.ReadyListener, + stateFilePath string, + cacheDir string, ) error { // in case of non Android os these variables will be nil mobileDependency := MobileDependency{ @@ -82,8 +119,10 @@ func (c *ConnectClient) RunOnAndroid( NetworkChangeListener: networkChangeListener, HostDNSAddresses: dnsAddresses, DnsReadyListener: dnsReadyListener, + StateFilePath: stateFilePath, + TempDir: cacheDir, } - return c.run(mobileDependency, nil) + return c.run(mobileDependency, nil, "") } func (c *ConnectClient) RunOniOS( @@ -91,6 +130,8 @@ func (c *ConnectClient) RunOniOS( networkChangeListener listener.NetworkChangeListener, dnsManager dns.IosDnsManager, 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) @@ -100,11 +141,17 @@ func (c *ConnectClient) RunOniOS( NetworkChangeListener: networkChangeListener, DnsManager: dnsManager, StateFilePath: stateFilePath, + TempDir: cacheDir, } - return c.run(mobileDependency, nil) + return c.run(mobileDependency, nil, logFilePath) } -func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan struct{}) error { +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 @@ -121,10 +168,34 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan } }() + // Stop metrics push on exit + defer func() { + if c.clientMetrics != nil { + c.clientMetrics.StopPush() + } + }() + log.Infof("starting NetBird client version %s on %s/%s", version.NetbirdVersion(), runtime.GOOS, runtime.GOARCH) nbnet.Init() + // Initialize metrics once at startup (always active for debug bundles) + if c.clientMetrics == nil { + agentInfo := metrics.AgentInfo{ + DeploymentType: metrics.DeploymentTypeUnknown, + Version: version.NetbirdVersion(), + OS: runtime.GOOS, + Arch: runtime.GOARCH, + } + c.clientMetrics = metrics.NewClientMetrics(agentInfo) + log.Debugf("initialized client metrics") + + // Start metrics push if enabled (uses daemon context, persists across engine restarts) + if metrics.IsMetricsPushEnabled() { + c.clientMetrics.StartPush(c.ctx, metrics.PushConfigFromEnv()) + } + } + backOff := &backoff.ExponentialBackOff{ InitialInterval: time.Second, RandomizationFactor: 1, @@ -160,7 +231,36 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan return err } - defer c.statusRecorder.ClientStop() + var path string + if runtime.GOOS == "ios" || runtime.GOOS == "android" { + // On mobile, use the provided state file path directly + if !fileExists(mobileDependency.StateFilePath) { + if err := createFile(mobileDependency.StateFilePath); err != nil { + log.Errorf("failed to create state file: %v", err) + // we are not exiting as we can run without the state manager + } + } + path = mobileDependency.StateFilePath + } else { + sm := profilemanager.NewServiceManager("") + path = sm.GetStatePath() + } + stateManager := statemanager.New(path) + stateManager.RegisterState(&sshconfig.ShutdownState{}) + + if c.updateManager != nil { + c.updateManager.CheckUpdateSuccess(c.ctx) + } + + inst := installer.New() + if err := inst.CleanUpInstallerFiles(); err != nil { + log.Errorf("failed to clean up temporary installer file: %v", err) + } + + 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 { @@ -180,11 +280,30 @@ 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) 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) mgmClient.SetConnStateListener(mgmNotifier) + // Update metrics with actual deployment type after connection + deploymentType := metrics.DetermineDeploymentType(mgmClient.GetServerURL()) + agentInfo := metrics.AgentInfo{ + DeploymentType: deploymentType, + Version: version.NetbirdVersion(), + OS: runtime.GOOS, + Arch: runtime.GOARCH, + } + c.clientMetrics.UpdateAgentInfo(agentInfo, myPrivateKey.PublicKey().String()) + log.Debugf("connected to the Management service %s", c.config.ManagementURL.Host) defer func() { if err = mgmClient.Close(); err != nil { @@ -193,22 +312,29 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan }() // connect (just a connection, no stream yet) and login to Management Service to get an initial global Netbird config + loginStarted := time.Now() loginResp, err := loginToManagement(engineCtx, mgmClient, publicSSHKey, c.config) if err != nil { + c.clientMetrics.RecordLoginDuration(engineCtx, time.Since(loginStarted), false) 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) } + 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(), - KernelInterface: device.WireGuardModuleIsLoaded(), + KernelInterface: device.WireGuardModuleIsLoaded() && !netstack.IsEnabled(), FQDN: loginResp.GetPeerConfig().GetFqdn(), } c.statusRecorder.UpdateLocalPeerState(localPeerState) @@ -245,13 +371,23 @@ 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) + engineConfig, err := createEngineConfig(myPrivateKey, c.config, peerConfig, logPath) if err != nil { log.Error(err) 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) c.statusRecorder.SetRelayMgr(relayManager) @@ -271,35 +407,51 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan checks := loginResp.GetChecks() c.engineMutex.Lock() - c.engine = NewEngine(engineCtx, cancel, signalClient, mgmClient, relayManager, engineConfig, mobileDependency, c.statusRecorder, checks) - c.engine.SetSyncResponsePersistence(c.persistSyncResponse) + engine := NewEngine(engineCtx, cancel, engineConfig, EngineServices{ + SignalClient: signalClient, + MgmClient: mgmClient, + RelayManager: relayManager, + StatusRecorder: c.statusRecorder, + Checks: checks, + StateManager: stateManager, + UpdateManager: c.updateManager, + ClientMetrics: c.clientMetrics, + MetricsCtx: c.ctx, + }, mobileDependency) + engine.SetSyncResponsePersistence(c.persistSyncResponse) + c.engine = engine c.engineMutex.Unlock() - if err := c.engine.Start(loginResp.GetNetbirdConfig(), c.config.ManagementURL); err != nil { + if err := engine.Start(loginResp.GetNetbirdConfig(), c.config.ManagementURL); err != nil { log.Errorf("error while starting Netbird Connection Engine: %s", err) 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) if runningChan != nil { - close(runningChan) - runningChan = nil + select { + case <-runningChan: + default: + close(runningChan) + } } <-engineCtx.Done() c.engineMutex.Lock() - engine := c.engine c.engine = nil c.engineMutex.Unlock() - if engine != nil && engine.wgInterface != nil { - log.Infof("ensuring %s is removed, Netbird engine context cancelled", engine.wgInterface.Name()) - if err := engine.Stop(); err != nil { - log.Errorf("Failed to stop engine: %v", err) - } + 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) } c.statusRecorder.ClientTeardown() @@ -315,12 +467,16 @@ 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 { 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 } @@ -371,6 +527,19 @@ func (c *ConnectClient) GetLatestSyncResponse() (*mgmProto.SyncResponse, error) return syncResponse, nil } +// SetLogLevel sets the log level for the firewall manager if the engine is running. +func (c *ConnectClient) SetLogLevel(level log.Level) { + engine := c.Engine() + if engine == nil { + return + } + + fwManager := engine.GetFirewallManager() + if fwManager != nil { + fwManager.SetLogLevel(level) + } +} + // Status returns the current client status func (c *ConnectClient) Status() StatusType { if c == nil { @@ -385,11 +554,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 } @@ -410,26 +577,42 @@ func (c *ConnectClient) SetSyncResponsePersistence(enabled bool) { } // createEngineConfig converts configuration received from Management Service to EngineConfig -func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConfig *mgmProto.PeerConfig) (*EngineConfig, error) { +func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConfig *mgmProto.PeerConfig, logPath string) (*EngineConfig, error) { nm := false 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, - IFaceBlackList: config.IFaceBlackList, - DisableIPv6Discovery: config.DisableIPv6Discovery, - WgPrivateKey: key, - WgPort: config.WgPort, - NetworkMonitor: nm, - SSHKey: []byte(config.SSHKey), - NATExternalIPs: config.NATExternalIPs, - CustomDNSAddress: config.CustomDNSAddress, - RosenpassEnabled: config.RosenpassEnabled, - RosenpassPermissive: config.RosenpassPermissive, - ServerSSHAllowed: util.ReturnBoolWithDefaultTrue(config.ServerSSHAllowed), - DNSRouteInterval: config.DNSRouteInterval, + WgIfaceName: config.WgIface, + WgAddr: wgAddr, + IFaceBlackList: config.IFaceBlackList, + DisableIPv6Discovery: config.DisableIPv6Discovery, + WgPrivateKey: key, + WgPort: config.WgPort, + NetworkMonitor: nm, + SSHKey: []byte(config.SSHKey), + NATExternalIPs: config.NATExternalIPs, + CustomDNSAddress: config.CustomDNSAddress, + RosenpassEnabled: config.RosenpassEnabled, + RosenpassPermissive: config.RosenpassPermissive, + ServerSSHAllowed: util.ReturnBoolWithDefaultTrue(config.ServerSSHAllowed), + EnableSSHRoot: config.EnableSSHRoot, + EnableSSHSFTP: config.EnableSSHSFTP, + EnableSSHLocalPortForwarding: config.EnableSSHLocalPortForwarding, + EnableSSHRemotePortForwarding: config.EnableSSHRemotePortForwarding, + DisableSSHAuth: config.DisableSSHAuth, + DNSRouteInterval: config.DNSRouteInterval, DisableClientRoutes: config.DisableClientRoutes, DisableServerRoutes: config.DisableServerRoutes || config.BlockInbound, @@ -437,10 +620,15 @@ 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), + MTU: selectMTU(config.MTU, peerConfig.Mtu), + LogPath: logPath, + + ProfileConfig: config, } if config.PreSharedKey != "" { @@ -497,12 +685,6 @@ func connectToSignal(ctx context.Context, wtConfig *mgmProto.NetbirdConfig, ourP // loginToManagement creates Management ServiceDependencies client, establishes a connection, logs-in and gets a global Netbird config (signal, turn, stun hosts, etc) func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte, config *profilemanager.Config) (*mgmProto.LoginResponse, error) { - - serverPublicKey, err := client.GetServerPublicKey() - if err != nil { - return nil, gstatus.Errorf(codes.FailedPrecondition, "failed while getting Management Service public key: %s", err) - } - sysInfo := system.GetInfo(ctx) sysInfo.SetFlags( config.RosenpassEnabled, @@ -514,14 +696,15 @@ 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, ) - loginResp, err := client.Login(*serverPublicKey, sysInfo, pubSSHKey, config.DNSLabels) - if err != nil { - return nil, err - } - - return loginResp, nil + return client.Login(sysInfo, pubSSHKey, config.DNSLabels) } func statusRecorderToMgmConnStateNotifier(statusRecorder *peer.Status) mgm.ConnStateNotifier { diff --git a/client/internal/connect_android_default.go b/client/internal/connect_android_default.go new file mode 100644 index 000000000..b05e91fec --- /dev/null +++ b/client/internal/connect_android_default.go @@ -0,0 +1,77 @@ +//go:build android + +package internal + +import ( + "net/netip" + + "github.com/netbirdio/netbird/client/internal/dns" + "github.com/netbirdio/netbird/client/internal/listener" + "github.com/netbirdio/netbird/client/internal/stdnet" +) + +// noopIFaceDiscover is a stub ExternalIFaceDiscover for embed.Client on Android. +// It returns an empty interface list, which means ICE P2P candidates won't be +// discovered — connections will fall back to relay. Applications that need P2P +// should provide a real implementation via runOnAndroidEmbed that uses +// Android's ConnectivityManager to enumerate network interfaces. +type noopIFaceDiscover struct{} + +func (noopIFaceDiscover) IFaces() (string, error) { + // Return empty JSON array — no local interfaces advertised for ICE. + // This is intentional: without Android's ConnectivityManager, we cannot + // reliably enumerate interfaces (netlink is restricted on Android 11+). + // Relay connections still work; only P2P hole-punching is disabled. + return "[]", nil +} + +// noopNetworkChangeListener is a stub for embed.Client on Android. +// Network change events are ignored since the embed client manages its own +// reconnection logic via the engine's built-in retry mechanism. +type noopNetworkChangeListener struct{} + +func (noopNetworkChangeListener) OnNetworkChanged(string) { + // No-op: embed.Client relies on the engine's internal reconnection + // logic rather than OS-level network change notifications. +} + +func (noopNetworkChangeListener) SetInterfaceIP(string) { + // No-op: in netstack mode, the overlay IP is managed by the userspace + // 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. +type noopDnsReadyListener struct{} + +func (noopDnsReadyListener) OnReady() { + // No-op: embed.Client does not need DNS readiness notifications. + // System DNS is disabled in netstack mode. +} + +var _ stdnet.ExternalIFaceDiscover = noopIFaceDiscover{} +var _ listener.NetworkChangeListener = noopNetworkChangeListener{} +var _ dns.ReadyListener = noopDnsReadyListener{} + +func init() { + // Wire up the default override so embed.Client.Start() works on Android + // with netstack mode. Provides complete no-op stubs for all mobile + // dependencies so the engine's existing Android code paths work unchanged. + // Applications that need P2P ICE or real DNS should replace this by + // setting androidRunOverride before calling Start(). + androidRunOverride = func(c *ConnectClient, runningChan chan struct{}, logPath string) error { + return c.runOnAndroidEmbed( + noopIFaceDiscover{}, + noopNetworkChangeListener{}, + []netip.AddrPort{}, + noopDnsReadyListener{}, + runningChan, + logPath, + ) + } +} diff --git a/client/internal/connect_android_embed.go b/client/internal/connect_android_embed.go new file mode 100644 index 000000000..18f72e841 --- /dev/null +++ b/client/internal/connect_android_embed.go @@ -0,0 +1,32 @@ +//go:build android + +package internal + +import ( + "net/netip" + + "github.com/netbirdio/netbird/client/internal/dns" + "github.com/netbirdio/netbird/client/internal/listener" + "github.com/netbirdio/netbird/client/internal/stdnet" +) + +// runOnAndroidEmbed is like RunOnAndroid but accepts a runningChan +// so embed.Client.Start() can detect when the engine is ready. +// It provides complete MobileDependency so the engine's existing +// Android code paths work unchanged. +func (c *ConnectClient) runOnAndroidEmbed( + iFaceDiscover stdnet.ExternalIFaceDiscover, + networkChangeListener listener.NetworkChangeListener, + dnsAddresses []netip.AddrPort, + dnsReadyListener dns.ReadyListener, + runningChan chan struct{}, + logPath string, +) error { + mobileDependency := MobileDependency{ + IFaceDiscover: iFaceDiscover, + NetworkChangeListener: networkChangeListener, + HostDNSAddresses: dnsAddresses, + DnsReadyListener: dnsReadyListener, + } + return c.run(mobileDependency, runningChan, logPath) +} diff --git a/client/internal/daemonaddr/resolve.go b/client/internal/daemonaddr/resolve.go new file mode 100644 index 000000000..b445696ab --- /dev/null +++ b/client/internal/daemonaddr/resolve.go @@ -0,0 +1,60 @@ +//go:build !windows && !ios && !android + +package daemonaddr + +import ( + "os" + "path/filepath" + "strings" + + log "github.com/sirupsen/logrus" +) + +var scanDir = "/var/run/netbird" + +// setScanDir overrides the scan directory (used by tests). +func setScanDir(dir string) { + scanDir = dir +} + +// ResolveUnixDaemonAddr checks whether the default Unix socket exists and, if not, +// scans /var/run/netbird/ for a single .sock file to use instead. This handles the +// mismatch between the netbird@.service template (which places the socket under +// /var/run/netbird/.sock) and the CLI default (/var/run/netbird.sock). +func ResolveUnixDaemonAddr(addr string) string { + if !strings.HasPrefix(addr, "unix://") { + return addr + } + + sockPath := strings.TrimPrefix(addr, "unix://") + if _, err := os.Stat(sockPath); err == nil { + return addr + } + + entries, err := os.ReadDir(scanDir) + if err != nil { + return addr + } + + var found []string + for _, e := range entries { + if e.IsDir() { + continue + } + if strings.HasSuffix(e.Name(), ".sock") { + found = append(found, filepath.Join(scanDir, e.Name())) + } + } + + switch len(found) { + case 1: + resolved := "unix://" + found[0] + log.Debugf("Default daemon socket not found, using discovered socket: %s", resolved) + return resolved + case 0: + return addr + default: + log.Warnf("Default daemon socket not found and multiple sockets discovered in %s; pass --daemon-addr explicitly", scanDir) + return addr + } +} diff --git a/client/internal/daemonaddr/resolve_stub.go b/client/internal/daemonaddr/resolve_stub.go new file mode 100644 index 000000000..080b7171a --- /dev/null +++ b/client/internal/daemonaddr/resolve_stub.go @@ -0,0 +1,8 @@ +//go:build windows || ios || android + +package daemonaddr + +// ResolveUnixDaemonAddr is a no-op on platforms that don't use Unix sockets. +func ResolveUnixDaemonAddr(addr string) string { + return addr +} diff --git a/client/internal/daemonaddr/resolve_test.go b/client/internal/daemonaddr/resolve_test.go new file mode 100644 index 000000000..3df67708a --- /dev/null +++ b/client/internal/daemonaddr/resolve_test.go @@ -0,0 +1,121 @@ +//go:build !windows && !ios && !android + +package daemonaddr + +import ( + "os" + "path/filepath" + "testing" +) + +// createSockFile creates a regular file with a .sock extension. +// ResolveUnixDaemonAddr uses os.Stat (not net.Dial), so a regular file is +// sufficient and avoids Unix socket path-length limits on macOS. +func createSockFile(t *testing.T, path string) { + t.Helper() + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatalf("failed to create test sock file at %s: %v", path, err) + } +} + +func TestResolveUnixDaemonAddr_DefaultExists(t *testing.T) { + tmp := t.TempDir() + sock := filepath.Join(tmp, "netbird.sock") + createSockFile(t, sock) + + addr := "unix://" + sock + got := ResolveUnixDaemonAddr(addr) + if got != addr { + t.Errorf("expected %s, got %s", addr, got) + } +} + +func TestResolveUnixDaemonAddr_SingleDiscovered(t *testing.T) { + tmp := t.TempDir() + + // Default socket does not exist + defaultAddr := "unix://" + filepath.Join(tmp, "netbird.sock") + + // Create a scan dir with one socket + sd := filepath.Join(tmp, "netbird") + if err := os.MkdirAll(sd, 0o755); err != nil { + t.Fatal(err) + } + instanceSock := filepath.Join(sd, "main.sock") + createSockFile(t, instanceSock) + + origScanDir := scanDir + setScanDir(sd) + t.Cleanup(func() { setScanDir(origScanDir) }) + + got := ResolveUnixDaemonAddr(defaultAddr) + expected := "unix://" + instanceSock + if got != expected { + t.Errorf("expected %s, got %s", expected, got) + } +} + +func TestResolveUnixDaemonAddr_MultipleDiscovered(t *testing.T) { + tmp := t.TempDir() + + defaultAddr := "unix://" + filepath.Join(tmp, "netbird.sock") + + sd := filepath.Join(tmp, "netbird") + if err := os.MkdirAll(sd, 0o755); err != nil { + t.Fatal(err) + } + createSockFile(t, filepath.Join(sd, "main.sock")) + createSockFile(t, filepath.Join(sd, "other.sock")) + + origScanDir := scanDir + setScanDir(sd) + t.Cleanup(func() { setScanDir(origScanDir) }) + + got := ResolveUnixDaemonAddr(defaultAddr) + if got != defaultAddr { + t.Errorf("expected original %s, got %s", defaultAddr, got) + } +} + +func TestResolveUnixDaemonAddr_NoSocketsFound(t *testing.T) { + tmp := t.TempDir() + + defaultAddr := "unix://" + filepath.Join(tmp, "netbird.sock") + + sd := filepath.Join(tmp, "netbird") + if err := os.MkdirAll(sd, 0o755); err != nil { + t.Fatal(err) + } + + origScanDir := scanDir + setScanDir(sd) + t.Cleanup(func() { setScanDir(origScanDir) }) + + got := ResolveUnixDaemonAddr(defaultAddr) + if got != defaultAddr { + t.Errorf("expected original %s, got %s", defaultAddr, got) + } +} + +func TestResolveUnixDaemonAddr_NonUnixAddr(t *testing.T) { + addr := "tcp://127.0.0.1:41731" + got := ResolveUnixDaemonAddr(addr) + if got != addr { + t.Errorf("expected %s, got %s", addr, got) + } +} + +func TestResolveUnixDaemonAddr_ScanDirMissing(t *testing.T) { + tmp := t.TempDir() + + defaultAddr := "unix://" + filepath.Join(tmp, "netbird.sock") + + origScanDir := scanDir + setScanDir(filepath.Join(tmp, "nonexistent")) + t.Cleanup(func() { setScanDir(origScanDir) }) + + got := ResolveUnixDaemonAddr(defaultAddr) + if got != defaultAddr { + t.Errorf("expected original %s, got %s", defaultAddr, got) + } +} diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index fbec29ce3..2de1023e9 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -16,19 +16,22 @@ import ( "path/filepath" "runtime" "runtime/pprof" - "slices" "sort" "strings" "time" log "github.com/sirupsen/logrus" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "google.golang.org/protobuf/encoding/protojson" "github.com/netbirdio/netbird/client/anonymize" + "github.com/netbirdio/netbird/client/configs" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/internal/updater/installer" + nbstatus "github.com/netbirdio/netbird/client/status" mgmProto "github.com/netbirdio/netbird/shared/management/proto" - "github.com/netbirdio/netbird/util" + "github.com/netbirdio/netbird/shared/netiputil" ) const readmeContent = `Netbird debug bundle @@ -42,20 +45,28 @@ 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. 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. state.json: Anonymized client state dump containing netbird states for the active profile. +service_params.json: Sanitized service install parameters (service.json). Sensitive environment variable values are masked. Only present when service.json exists. +metrics.txt: Buffered client metrics in InfluxDB line protocol format. Only present when metrics collection is enabled. Peer identifiers are anonymized. mutex.prof: Mutex profiling information. goroutine.prof: Goroutine profiling information. block.prof: Block profiling information. heap.prof: Heap profiling information (snapshot of memory allocations). 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 @@ -109,6 +120,9 @@ go tool pprof -http=:8088 heap.prof This will open a web browser tab with the profiling information. +Stack Trace +The stack_trace.txt file contains a complete snapshot of all goroutine stack traces at the time the debug bundle was created. + Routes The routes.txt file contains detailed routing table information in a tabular format: @@ -154,22 +168,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: @@ -204,13 +229,25 @@ scutil_dns.txt (macOS only): const ( clientLogFile = "client.log" + uiLogFile = "gui-client.log" 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" ) +// MetricsExporter is an interface for exporting metrics +type MetricsExporter interface { + Export(w io.Writer) error +} + type BundleGenerator struct { anonymizer *anonymize.Anonymizer @@ -218,10 +255,18 @@ type BundleGenerator struct { internalConfig *profilemanager.Config statusRecorder *peer.Status syncResponse *mgmProto.SyncResponse - logFile string + logPath string + uiLogPath string + 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 - clientStatus string includeSystemInfo bool logFileCount uint32 @@ -230,7 +275,6 @@ type BundleGenerator struct { type BundleConfig struct { Anonymize bool - ClientStatus string IncludeSystemInfo bool LogFileCount uint32 } @@ -239,7 +283,16 @@ type GeneratorDependencies struct { InternalConfig *profilemanager.Config StatusRecorder *peer.Status SyncResponse *mgmProto.SyncResponse - LogFile string + LogPath string + UILogPath string // Absolute path to the desktop UI's gui-client.log, reported via RegisterUILog. Empty if no UI registered one. + TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used. + StatePath string // Path to the state file. If empty, the ServiceManager default path is used. + CPUProfile []byte + CapturePath string + RefreshStatus func() + ClientMetrics MetricsExporter + DaemonVersion string + CliVersion string } func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator { @@ -255,10 +308,18 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen internalConfig: deps.InternalConfig, statusRecorder: deps.StatusRecorder, syncResponse: deps.SyncResponse, - logFile: deps.LogFile, + logPath: deps.LogPath, + uiLogPath: deps.UILogPath, + 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, - clientStatus: cfg.ClientStatus, includeSystemInfo: cfg.IncludeSystemInfo, logFileCount: logFileCount, } @@ -266,7 +327,7 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen // Generate creates a debug bundle and returns the location. func (g *BundleGenerator) Generate() (resp string, err error) { - bundlePath, err := os.CreateTemp("", "netbird.debug.*.zip") + bundlePath, err := os.CreateTemp(g.tempDir, "netbird.debug.*.zip") if err != nil { return "", fmt.Errorf("create zip file: %w", err) } @@ -304,13 +365,6 @@ func (g *BundleGenerator) createArchive() error { return fmt.Errorf("add status: %w", err) } - if g.statusRecorder != nil { - status := g.statusRecorder.GetFullStatus() - seedFromStatus(g.anonymizer, &status) - } else { - log.Debugf("no status recorder available for seeding") - } - if err := g.addConfig(); err != nil { log.Errorf("failed to add config to debug bundle: %v", err) } @@ -327,6 +381,18 @@ func (g *BundleGenerator) createArchive() error { log.Errorf("failed to add profiles to debug bundle: %v", err) } + if err := g.addCPUProfile(); err != nil { + 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) + } + if err := g.addSyncResponse(); err != nil { return fmt.Errorf("add sync response: %w", err) } @@ -339,19 +405,28 @@ func (g *BundleGenerator) createArchive() error { log.Errorf("failed to add corrupted state files to debug bundle: %v", err) } + if err := g.addServiceParams(); err != nil { + log.Errorf("failed to add service params to debug bundle: %v", err) + } + + if err := g.addMetrics(); err != nil { + log.Errorf("failed to add metrics to debug bundle: %v", err) + } + if err := g.addWgShow(); err != nil { log.Errorf("failed to add wg show output: %v", err) } - if g.logFile != "" && !slices.Contains(util.SpecialLogs, g.logFile) { - if err := g.addLogfile(); err != nil { - log.Errorf("failed to add log file to debug bundle: %v", err) - if err := g.trySystemdLogFallback(); err != nil { - log.Errorf("failed to add systemd logs as fallback: %v", err) - } - } - } else if err := g.trySystemdLogFallback(); err != nil { - log.Errorf("failed to add systemd logs: %v", err) + if err := g.addPlatformLog(); err != nil { + 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) } return nil @@ -374,6 +449,10 @@ 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) } @@ -388,11 +467,34 @@ func (g *BundleGenerator) addReadme() error { } func (g *BundleGenerator) addStatus() error { - if status := g.clientStatus; status != "" { - statusReader := strings.NewReader(status) + if g.statusRecorder != nil { + pm := profilemanager.NewProfileManager() + var profName string + if activeProf, err := pm.GetActiveProfile(); err == nil { + profName = activeProf.Name + } + + if g.refreshStatus != nil { + g.refreshStatus() + } + + fullStatus := g.statusRecorder.GetFullStatus() + protoFullStatus := nbstatus.ToProtoFullStatus(fullStatus) + overview := nbstatus.ConvertToStatusOutputOverview(protoFullStatus, nbstatus.ConvertOptions{ + Anonymize: g.anonymize, + ProfileName: profName, + DaemonVersion: g.daemonVersion, + }) + overview.CliVersion = g.cliVersion + statusOutput := overview.FullDetailSummary() + + statusReader := strings.NewReader(statusOutput) if err := g.addFileToZip(statusReader, "status.txt"); err != nil { return fmt.Errorf("add status file to zip: %w", err) } + seedFromStatus(g.anonymizer, &fullStatus) + } else { + log.Debugf("no status recorder available for seeding") } return nil } @@ -430,6 +532,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) @@ -438,9 +548,96 @@ func (g *BundleGenerator) addConfig() error { return nil } +const ( + serviceParamsFile = "service.json" + serviceParamsBundle = "service_params.json" + maskedValue = "***" + envVarPrefix = "NB_" + jsonKeyManagementURL = "management_url" + jsonKeyServiceEnv = "service_env_vars" +) + +var sensitiveEnvSubstrings = []string{"key", "token", "secret", "password", "credential"} + +// addServiceParams reads the service.json file and adds a sanitized version to the bundle. +// Non-NB_ env vars and vars with sensitive names are masked. Other NB_ values are anonymized. +func (g *BundleGenerator) addServiceParams() error { + path := filepath.Join(configs.StateDir, serviceParamsFile) + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("read service params: %w", err) + } + + var params map[string]any + if err := json.Unmarshal(data, ¶ms); err != nil { + return fmt.Errorf("parse service params: %w", err) + } + + if g.anonymize { + if mgmtURL, ok := params[jsonKeyManagementURL].(string); ok && mgmtURL != "" { + params[jsonKeyManagementURL] = g.anonymizer.AnonymizeURI(mgmtURL) + } + } + + g.sanitizeServiceEnvVars(params) + + sanitizedData, err := json.MarshalIndent(params, "", " ") + if err != nil { + return fmt.Errorf("marshal sanitized service params: %w", err) + } + + if err := g.addFileToZip(bytes.NewReader(sanitizedData), serviceParamsBundle); err != nil { + return fmt.Errorf("add service params to zip: %w", err) + } + + return nil +} + +// sanitizeServiceEnvVars masks or anonymizes env var values in service params. +// Non-NB_ vars and vars with sensitive names (key, token, etc.) are fully masked. +// Other NB_ var values are passed through the anonymizer when anonymization is enabled. +func (g *BundleGenerator) sanitizeServiceEnvVars(params map[string]any) { + envVars, ok := params[jsonKeyServiceEnv].(map[string]any) + if !ok { + return + } + + sanitized := make(map[string]any, len(envVars)) + for k, v := range envVars { + val, _ := v.(string) + switch { + case !strings.HasPrefix(k, envVarPrefix) || isSensitiveEnvVar(k): + sanitized[k] = maskedValue + case g.anonymize: + sanitized[k] = g.anonymizer.AnonymizeString(val) + default: + sanitized[k] = val + } + } + params[jsonKeyServiceEnv] = sanitized +} + +// isSensitiveEnvVar returns true for env var names that may contain secrets. +func isSensitiveEnvVar(key string) bool { + lower := strings.ToLower(key) + for _, s := range sensitiveEnvSubstrings { + if strings.Contains(lower, s) { + return true + } + } + return false +} + func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder) { configContent.WriteString("NetBird Client Configuration:\n\n") + if key, err := wgtypes.ParseKey(g.internalConfig.PrivateKey); err == nil { + configContent.WriteString(fmt.Sprintf("PublicKey: %s\n", key.PublicKey().String())) + } configContent.WriteString(fmt.Sprintf("WgIface: %s\n", g.internalConfig.WgIface)) configContent.WriteString(fmt.Sprintf("WgPort: %d\n", g.internalConfig.WgPort)) if g.internalConfig.NetworkMonitor != nil { @@ -453,6 +650,24 @@ 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.EnableSSHRoot != nil { + configContent.WriteString(fmt.Sprintf("EnableSSHRoot: %v\n", *g.internalConfig.EnableSSHRoot)) + } + if g.internalConfig.EnableSSHSFTP != nil { + configContent.WriteString(fmt.Sprintf("EnableSSHSFTP: %v\n", *g.internalConfig.EnableSSHSFTP)) + } + if g.internalConfig.EnableSSHLocalPortForwarding != nil { + configContent.WriteString(fmt.Sprintf("EnableSSHLocalPortForwarding: %v\n", *g.internalConfig.EnableSSHLocalPortForwarding)) + } + 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)) @@ -460,6 +675,8 @@ 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("SyncMessageVersion: %v\n", g.internalConfig.SyncMessageVersion)) if g.internalConfig.DisableNotifications != nil { configContent.WriteString(fmt.Sprintf("DisableNotifications: %v\n", *g.internalConfig.DisableNotifications)) @@ -478,7 +695,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) { @@ -510,6 +728,54 @@ func (g *BundleGenerator) addProf() (err error) { return nil } +func (g *BundleGenerator) addCPUProfile() error { + if len(g.cpuProfile) == 0 { + return nil + } + + reader := bytes.NewReader(g.cpuProfile) + if err := g.addFileToZip(reader, "cpu.prof"); err != nil { + return fmt.Errorf("add CPU profile to zip: %w", err) + } + + 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) + + stackTrace := bytes.NewReader(buf[:n]) + if err := g.addFileToZip(stackTrace, "stack_trace.txt"); err != nil { + return fmt.Errorf("add stack trace file to zip: %w", err) + } + + return nil +} + func (g *BundleGenerator) addInterfaces() error { interfaces, err := net.Interfaces() if err != nil { @@ -565,6 +831,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) @@ -577,9 +845,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 } @@ -618,6 +910,29 @@ func (g *BundleGenerator) addStateFile() error { return nil } +func (g *BundleGenerator) addUpdateLogs() error { + inst := installer.New() + logFiles := inst.LogFiles() + if len(logFiles) == 0 { + return nil + } + + log.Infof("adding updater logs") + for _, logFile := range logFiles { + data, err := os.ReadFile(logFile) + if err != nil { + log.Warnf("failed to read update log file %s: %v", logFile, err) + continue + } + + baseName := filepath.Base(logFile) + 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) + } + } + return nil +} + func (g *BundleGenerator) addCorruptedStateFiles() error { sm := profilemanager.NewServiceManager("") pattern := sm.GetStatePath() @@ -649,19 +964,43 @@ func (g *BundleGenerator) addCorruptedStateFiles() error { return nil } +func (g *BundleGenerator) addMetrics() error { + if g.clientMetrics == nil { + log.Debugf("skipping metrics in debug bundle: no metrics collector") + return nil + } + + var buf bytes.Buffer + if err := g.clientMetrics.Export(&buf); err != nil { + return fmt.Errorf("export metrics: %w", err) + } + + if buf.Len() == 0 { + log.Debugf("skipping metrics.txt in debug bundle: no metrics data") + return nil + } + + if err := g.addFileToZip(&buf, "metrics.txt"); err != nil { + return fmt.Errorf("add metrics file to zip: %w", err) + } + + log.Debugf("added metrics to debug bundle") + return nil +} + func (g *BundleGenerator) addLogfile() error { - if g.logFile == "" { + if g.logPath == "" { log.Debugf("skipping empty log file in debug bundle") return nil } - logDir := filepath.Dir(g.logFile) + logDir := filepath.Dir(g.logPath) - if err := g.addSingleLogfile(g.logFile, clientLogFile); err != nil { + if err := g.addSingleLogfile(g.logPath, clientLogFile); err != nil { return fmt.Errorf("add client log file to zip: %w", err) } - g.addRotatedLogFiles(logDir) + g.addRotatedLogFiles(logDir, clientLogPrefix) stdErrLogPath := filepath.Join(logDir, errorLogFile) stdoutLogPath := filepath.Join(logDir, stdoutLogFile) @@ -681,6 +1020,25 @@ func (g *BundleGenerator) addLogfile() error { 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.uiLogPath, uiLogFile); err != nil { + return fmt.Errorf("add UI log file to zip: %w", err) + } + + g.addRotatedLogFiles(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) @@ -753,13 +1111,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(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) @@ -792,7 +1153,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(files[i], name) + } else { + err = g.addSingleLogfile(files[i], name) + } + if err != nil { log.Warnf("failed to add rotated log %s: %v", name, err) } } @@ -1034,6 +1400,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) @@ -1136,8 +1517,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_android.go b/client/internal/debug/debug_android.go new file mode 100644 index 000000000..a4e2b3e98 --- /dev/null +++ b/client/internal/debug/debug_android.go @@ -0,0 +1,41 @@ +//go:build android + +package debug + +import ( + "fmt" + "io" + "os/exec" + + log "github.com/sirupsen/logrus" +) + +func (g *BundleGenerator) addPlatformLog() error { + cmd := exec.Command("/system/bin/logcat", "-d") + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("logcat stdout pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start logcat: %w", err) + } + + var logReader io.Reader = stdout + if g.anonymize { + var pw *io.PipeWriter + logReader, pw = io.Pipe() + go anonymizeLog(stdout, pw, g.anonymizer) + } + + if err := g.addFileToZip(logReader, "logcat.txt"); err != nil { + return fmt.Errorf("add logcat to zip: %w", err) + } + + if err := cmd.Wait(); err != nil { + return fmt.Errorf("wait logcat: %w", err) + } + + log.Debug("added logcat output to debug bundle") + return nil +} diff --git a/client/internal/debug/debug_ios.go b/client/internal/debug/debug_ios.go new file mode 100644 index 000000000..a07c23dbd --- /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(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 39d796fda..40d864eda 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 @@ -507,15 +512,13 @@ func formatPayloadWithCmp(p *expr.Payload, cmp *expr.Cmp) string { if p.Base == expr.PayloadBaseNetworkHeader { switch p.Offset { case 12: - if p.Len == 4 { - return fmt.Sprintf("ip saddr %s %s", formatCmpOp(cmp.Op), formatIPBytes(cmp.Data)) - } else if p.Len == 2 { + switch p.Len { + case 4, 2: return fmt.Sprintf("ip saddr %s %s", formatCmpOp(cmp.Op), formatIPBytes(cmp.Data)) } case 16: - if p.Len == 4 { - return fmt.Sprintf("ip daddr %s %s", formatCmpOp(cmp.Op), formatIPBytes(cmp.Data)) - } else if p.Len == 2 { + switch p.Len { + case 4, 2: return fmt.Sprintf("ip daddr %s %s", formatCmpOp(cmp.Op), formatIPBytes(cmp.Data)) } } @@ -806,3 +809,91 @@ 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, "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..3749b3e9b --- /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(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 new file mode 100644 index 000000000..2dfca6ddc --- /dev/null +++ b/client/internal/debug/debug_nonandroid.go @@ -0,0 +1,25 @@ +//go:build !android && !ios + +package debug + +import ( + "slices" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/util" +) + +func (g *BundleGenerator) addPlatformLog() error { + if g.logPath != "" && !slices.Contains(util.SpecialLogs, g.logPath) { + if err := g.addLogfile(); err != nil { + log.Errorf("failed to add log file to debug bundle: %v", err) + if err := g.trySystemdLogFallback(); err != nil { + return err + } + } + } else if err := g.trySystemdLogFallback(); err != nil { + return err + } + return nil +} 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_test.go b/client/internal/debug/debug_test.go index 59837c328..7fe93a5c1 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -1,18 +1,37 @@ package debug import ( + "archive/zip" + "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"), @@ -163,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 @@ -267,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..."), }, @@ -345,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) @@ -420,6 +447,226 @@ func TestAnonymizeNetworkMap(t *testing.T) { } } +func TestIsSensitiveEnvVar(t *testing.T) { + tests := []struct { + key string + sensitive bool + }{ + {"NB_SETUP_KEY", true}, + {"NB_API_TOKEN", true}, + {"NB_CLIENT_SECRET", true}, + {"NB_PASSWORD", true}, + {"NB_CREDENTIAL", true}, + {"NB_LOG_LEVEL", false}, + {"NB_MANAGEMENT_URL", false}, + {"NB_HOSTNAME", false}, + {"HOME", false}, + {"PATH", false}, + } + for _, tt := range tests { + t.Run(tt.key, func(t *testing.T) { + assert.Equal(t, tt.sensitive, isSensitiveEnvVar(tt.key)) + }) + } +} + +func TestSanitizeServiceEnvVars(t *testing.T) { + tests := []struct { + name string + anonymize bool + input map[string]any + check func(t *testing.T, params map[string]any) + }{ + { + name: "no env vars key", + anonymize: false, + input: map[string]any{"management_url": "https://mgmt.example.com"}, + check: func(t *testing.T, params map[string]any) { + t.Helper() + assert.Equal(t, "https://mgmt.example.com", params["management_url"], "non-env fields should be untouched") + _, ok := params[jsonKeyServiceEnv] + assert.False(t, ok, "service_env_vars should not be added") + }, + }, + { + name: "non-NB vars are masked", + anonymize: false, + input: map[string]any{ + jsonKeyServiceEnv: map[string]any{ + "HOME": "/root", + "PATH": "/usr/bin", + "NB_LOG_LEVEL": "debug", + }, + }, + check: func(t *testing.T, params map[string]any) { + t.Helper() + env := params[jsonKeyServiceEnv].(map[string]any) + assert.Equal(t, maskedValue, env["HOME"], "non-NB_ var should be masked") + assert.Equal(t, maskedValue, env["PATH"], "non-NB_ var should be masked") + assert.Equal(t, "debug", env["NB_LOG_LEVEL"], "safe NB_ var should pass through") + }, + }, + { + name: "sensitive NB vars are masked", + anonymize: false, + input: map[string]any{ + jsonKeyServiceEnv: map[string]any{ + "NB_SETUP_KEY": "abc123", + "NB_API_TOKEN": "tok_xyz", + "NB_LOG_LEVEL": "info", + }, + }, + check: func(t *testing.T, params map[string]any) { + t.Helper() + env := params[jsonKeyServiceEnv].(map[string]any) + assert.Equal(t, maskedValue, env["NB_SETUP_KEY"], "sensitive NB_ var should be masked") + assert.Equal(t, maskedValue, env["NB_API_TOKEN"], "sensitive NB_ var should be masked") + assert.Equal(t, "info", env["NB_LOG_LEVEL"], "safe NB_ var should pass through") + }, + }, + { + name: "safe NB vars anonymized when anonymize is true", + anonymize: true, + input: map[string]any{ + jsonKeyServiceEnv: map[string]any{ + "NB_MANAGEMENT_URL": "https://mgmt.example.com:443", + "NB_LOG_LEVEL": "debug", + "NB_SETUP_KEY": "secret", + "SOME_OTHER": "val", + }, + }, + check: func(t *testing.T, params map[string]any) { + t.Helper() + env := params[jsonKeyServiceEnv].(map[string]any) + // Safe NB_ values should be anonymized (not the original, not masked) + mgmtVal := env["NB_MANAGEMENT_URL"].(string) + assert.NotEqual(t, "https://mgmt.example.com:443", mgmtVal, "should be anonymized") + assert.NotEqual(t, maskedValue, mgmtVal, "should not be masked") + + logVal := env["NB_LOG_LEVEL"].(string) + assert.NotEqual(t, maskedValue, logVal, "safe NB_ var should not be masked") + + // Sensitive and non-NB_ still masked + assert.Equal(t, maskedValue, env["NB_SETUP_KEY"]) + assert.Equal(t, maskedValue, env["SOME_OTHER"]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) + g := &BundleGenerator{ + anonymize: tt.anonymize, + anonymizer: anonymizer, + } + g.sanitizeServiceEnvVars(tt.input) + tt.check(t, tt.input) + }) + } +} + +func TestAddServiceParams(t *testing.T) { + t.Run("missing service.json returns nil", func(t *testing.T) { + g := &BundleGenerator{ + anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()), + } + + origStateDir := configs.StateDir + configs.StateDir = t.TempDir() + t.Cleanup(func() { configs.StateDir = origStateDir }) + + err := g.addServiceParams() + assert.NoError(t, err) + }) + + t.Run("management_url anonymized when anonymize is true", func(t *testing.T) { + dir := t.TempDir() + origStateDir := configs.StateDir + configs.StateDir = dir + t.Cleanup(func() { configs.StateDir = origStateDir }) + + input := map[string]any{ + jsonKeyManagementURL: "https://api.example.com:443", + jsonKeyServiceEnv: map[string]any{ + "NB_LOG_LEVEL": "trace", + }, + } + data, err := json.Marshal(input) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, serviceParamsFile), data, 0600)) + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + g := &BundleGenerator{ + anonymize: true, + anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()), + archive: zw, + } + + require.NoError(t, g.addServiceParams()) + require.NoError(t, zw.Close()) + + zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + require.NoError(t, err) + require.Len(t, zr.File, 1) + assert.Equal(t, serviceParamsBundle, zr.File[0].Name) + + rc, err := zr.File[0].Open() + require.NoError(t, err) + defer rc.Close() + + var result map[string]any + require.NoError(t, json.NewDecoder(rc).Decode(&result)) + + mgmt := result[jsonKeyManagementURL].(string) + assert.NotEqual(t, "https://api.example.com:443", mgmt, "management_url should be anonymized") + assert.NotEmpty(t, mgmt) + + env := result[jsonKeyServiceEnv].(map[string]any) + assert.NotEqual(t, maskedValue, env["NB_LOG_LEVEL"], "safe NB_ var should not be masked") + }) + + t.Run("management_url preserved when anonymize is false", func(t *testing.T) { + dir := t.TempDir() + origStateDir := configs.StateDir + configs.StateDir = dir + t.Cleanup(func() { configs.StateDir = origStateDir }) + + input := map[string]any{ + jsonKeyManagementURL: "https://api.example.com:443", + } + data, err := json.Marshal(input) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, serviceParamsFile), data, 0600)) + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + g := &BundleGenerator{ + anonymize: false, + anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()), + archive: zw, + } + + require.NoError(t, g.addServiceParams()) + require.NoError(t, zw.Close()) + + zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + require.NoError(t, err) + + rc, err := zr.File[0].Open() + require.NoError(t, err) + defer rc.Close() + + var result map[string]any + require.NoError(t, json.NewDecoder(rc).Decode(&result)) + + assert.Equal(t, "https://api.example.com:443", result[jsonKeyManagementURL], "management_url should be preserved") + }) +} + // Helper function to check if IP is in CGNAT range func isInCGNATRange(ip net.IP) bool { cgnat := net.IPNet{ @@ -430,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 @@ -467,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 } }` @@ -540,4 +799,163 @@ 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", + } + + 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, + 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", + 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) + + 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/upload.go b/client/internal/debug/upload.go new file mode 100644 index 000000000..cdf52409d --- /dev/null +++ b/client/internal/debug/upload.go @@ -0,0 +1,101 @@ +package debug + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + + "github.com/netbirdio/netbird/upload-server/types" +) + +const maxBundleUploadSize = 50 * 1024 * 1024 + +func UploadDebugBundle(ctx context.Context, url, managementURL, filePath string) (key string, err error) { + response, err := getUploadURL(ctx, url, managementURL) + if err != nil { + return "", err + } + + err = upload(ctx, filePath, response) + if err != nil { + return "", err + } + return response.Key, nil +} + +func upload(ctx context.Context, filePath string, response *types.GetURLResponse) error { + fileData, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("open file: %w", err) + } + + defer fileData.Close() + + stat, err := fileData.Stat() + if err != nil { + return fmt.Errorf("stat file: %w", err) + } + + if stat.Size() > maxBundleUploadSize { + return fmt.Errorf("file size exceeds maximum limit of %d bytes", maxBundleUploadSize) + } + + req, err := http.NewRequestWithContext(ctx, "PUT", response.URL, fileData) + if err != nil { + return fmt.Errorf("create PUT request: %w", err) + } + + req.ContentLength = stat.Size() + req.Header.Set("Content-Type", "application/octet-stream") + + putResp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("upload failed: %v", err) + } + defer putResp.Body.Close() + + if putResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(putResp.Body) + return fmt.Errorf("upload status %d: %s", putResp.StatusCode, string(body)) + } + 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) + 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) + if err != nil { + return nil, fmt.Errorf("get presigned URL: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("get presigned URL status %d: %s", resp.StatusCode, string(body)) + } + + urlBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response body: %w", err) + } + var response types.GetURLResponse + if err := json.Unmarshal(urlBytes, &response); err != nil { + return nil, fmt.Errorf("unmarshal response: %w", err) + } + return &response, nil +} + +func getURLHash(url string) string { + return fmt.Sprintf("%x", sha256.Sum256([]byte(url))) +} diff --git a/client/server/debug_test.go b/client/internal/debug/upload_test.go similarity index 53% rename from client/server/debug_test.go rename to client/internal/debug/upload_test.go index 53d9ac8ed..f224b8d3f 100644 --- a/client/server/debug_test.go +++ b/client/internal/debug/upload_test.go @@ -1,12 +1,14 @@ -package server +package debug import ( "context" "errors" + "net" "net/http" "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/require" @@ -19,8 +21,10 @@ func TestUpload(t *testing.T) { t.Skip("Skipping upload test on docker ci") } testDir := t.TempDir() - testURL := "http://localhost:8080" + addr := reserveLoopbackPort(t) + testURL := "http://" + addr t.Setenv("SERVER_URL", testURL) + t.Setenv("SERVER_ADDRESS", addr) t.Setenv("STORE_DIR", testDir) srv := server.NewServer() go func() { @@ -33,12 +37,13 @@ func TestUpload(t *testing.T) { t.Errorf("Failed to stop server: %v", err) } }) + waitForServer(t, addr) file := filepath.Join(t.TempDir(), "tmpfile") 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) require.NoError(t, err) id := getURLHash(testURL) require.Contains(t, key, id+"/") @@ -47,3 +52,30 @@ func TestUpload(t *testing.T) { require.NoError(t, err) require.Equal(t, fileContent, createdFileContent) } + +// reserveLoopbackPort binds an ephemeral port on loopback to learn a free +// address, then releases it so the server under test can rebind. The close/ +// rebind window is racy in theory; on loopback with a kernel-assigned port +// it's essentially never contended in practice. +func reserveLoopbackPort(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := l.Addr().String() + require.NoError(t, l.Close()) + return addr +} + +func waitForServer(t *testing.T, addr string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + c, err := net.DialTimeout("tcp", addr, 100*time.Millisecond) + if err == nil { + _ = c.Close() + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("server did not start listening on %s in time", addr) +} diff --git a/client/internal/debug/wgshow.go b/client/internal/debug/wgshow.go index 8233ca510..1e8a8a6cc 100644 --- a/client/internal/debug/wgshow.go +++ b/client/internal/debug/wgshow.go @@ -60,7 +60,7 @@ func (g *BundleGenerator) toWGShowFormat(s *configurer.Stats) string { } sb.WriteString(fmt.Sprintf(" latest handshake: %s\n", peer.LastHandshake.Format(time.RFC1123))) sb.WriteString(fmt.Sprintf(" transfer: %d B received, %d B sent\n", peer.RxBytes, peer.TxBytes)) - if peer.PresharedKey { + if peer.PresharedKey != [32]byte{} { sb.WriteString(" preshared key: (hidden)\n") } } diff --git a/client/internal/device_auth.go b/client/internal/device_auth.go deleted file mode 100644 index 7f7d06130..000000000 --- a/client/internal/device_auth.go +++ /dev/null @@ -1,136 +0,0 @@ -package internal - -import ( - "context" - "fmt" - "net/url" - - log "github.com/sirupsen/logrus" - "golang.zx2c4.com/wireguard/wgctrl/wgtypes" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - mgm "github.com/netbirdio/netbird/shared/management/client" -) - -// DeviceAuthorizationFlow represents Device Authorization Flow information -type DeviceAuthorizationFlow struct { - Provider string - ProviderConfig DeviceAuthProviderConfig -} - -// DeviceAuthProviderConfig has all attributes needed to initiate a device authorization flow -type DeviceAuthProviderConfig struct { - // ClientID An IDP application client id - ClientID string - // ClientSecret An IDP application client secret - ClientSecret string - // Domain An IDP API domain - // Deprecated. Use OIDCConfigEndpoint instead - Domain string - // Audience An Audience for to authorization validation - Audience string - // TokenEndpoint is the endpoint of an IDP manager where clients can obtain access token - TokenEndpoint string - // DeviceAuthEndpoint is the endpoint of an IDP manager where clients can obtain device authorization code - DeviceAuthEndpoint string - // Scopes provides the scopes to be included in the token request - Scope string - // UseIDToken indicates if the id token should be used for authentication - UseIDToken bool - // LoginHint is used to pre-fill the email/username field during authentication - LoginHint string -} - -// GetDeviceAuthorizationFlowInfo initialize a DeviceAuthorizationFlow instance and return with it -func GetDeviceAuthorizationFlowInfo(ctx context.Context, privateKey string, mgmURL *url.URL) (DeviceAuthorizationFlow, error) { - // validate our peer's Wireguard PRIVATE key - myPrivateKey, err := wgtypes.ParseKey(privateKey) - if err != nil { - log.Errorf("failed parsing Wireguard key %s: [%s]", privateKey, err.Error()) - return DeviceAuthorizationFlow{}, err - } - - var mgmTLSEnabled bool - if mgmURL.Scheme == "https" { - mgmTLSEnabled = true - } - - log.Debugf("connecting to Management Service %s", mgmURL.String()) - mgmClient, err := mgm.NewClient(ctx, mgmURL.Host, myPrivateKey, mgmTLSEnabled) - if err != nil { - log.Errorf("failed connecting to Management Service %s %v", mgmURL.String(), err) - return DeviceAuthorizationFlow{}, err - } - log.Debugf("connected to the Management service %s", mgmURL.String()) - - defer func() { - err = mgmClient.Close() - if err != nil { - log.Warnf("failed to close the Management service client %v", err) - } - }() - - serverKey, err := mgmClient.GetServerPublicKey() - if err != nil { - log.Errorf("failed while getting Management Service public key: %v", err) - return DeviceAuthorizationFlow{}, err - } - - protoDeviceAuthorizationFlow, err := mgmClient.GetDeviceAuthorizationFlow(*serverKey) - if err != nil { - if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound { - log.Warnf("server couldn't find device flow, contact admin: %v", err) - return DeviceAuthorizationFlow{}, err - } - log.Errorf("failed to retrieve device flow: %v", err) - return DeviceAuthorizationFlow{}, err - } - - deviceAuthorizationFlow := DeviceAuthorizationFlow{ - Provider: protoDeviceAuthorizationFlow.Provider.String(), - - ProviderConfig: DeviceAuthProviderConfig{ - Audience: protoDeviceAuthorizationFlow.GetProviderConfig().GetAudience(), - ClientID: protoDeviceAuthorizationFlow.GetProviderConfig().GetClientID(), - ClientSecret: protoDeviceAuthorizationFlow.GetProviderConfig().GetClientSecret(), - Domain: protoDeviceAuthorizationFlow.GetProviderConfig().Domain, - TokenEndpoint: protoDeviceAuthorizationFlow.GetProviderConfig().GetTokenEndpoint(), - DeviceAuthEndpoint: protoDeviceAuthorizationFlow.GetProviderConfig().GetDeviceAuthEndpoint(), - Scope: protoDeviceAuthorizationFlow.GetProviderConfig().GetScope(), - UseIDToken: protoDeviceAuthorizationFlow.GetProviderConfig().GetUseIDToken(), - }, - } - - // keep compatibility with older management versions - if deviceAuthorizationFlow.ProviderConfig.Scope == "" { - deviceAuthorizationFlow.ProviderConfig.Scope = "openid" - } - - err = isDeviceAuthProviderConfigValid(deviceAuthorizationFlow.ProviderConfig) - if err != nil { - return DeviceAuthorizationFlow{}, err - } - - return deviceAuthorizationFlow, nil -} - -func isDeviceAuthProviderConfigValid(config DeviceAuthProviderConfig) error { - errorMSGFormat := "invalid provider configuration received from management: %s value is empty. Contact your NetBird administrator" - if config.Audience == "" { - return fmt.Errorf(errorMSGFormat, "Audience") - } - if config.ClientID == "" { - return fmt.Errorf(errorMSGFormat, "Client ID") - } - if config.TokenEndpoint == "" { - return fmt.Errorf(errorMSGFormat, "Token Endpoint") - } - if config.DeviceAuthEndpoint == "" { - return fmt.Errorf(errorMSGFormat, "Device Auth Endpoint") - } - if config.Scope == "" { - return fmt.Errorf(errorMSGFormat, "Device Auth Scopes") - } - return nil -} diff --git a/client/internal/dns.go b/client/internal/dns.go index 5e604bec5..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,13 +102,16 @@ 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 for _, zone := range config.CustomZones { + if zone.NonAuthoritative { + continue + } for _, record := range zone.Records { - if record.Type != int(dns.TypeA) { + if record.Type != int(dns.TypeA) && record.Type != int(dns.TypeAAAA) { continue } @@ -106,8 +140,9 @@ func addReverseZone(config *nbdns.Config, network netip.Prefix) { records := collectPTRRecords(config, network) reverseZone := nbdns.CustomZone{ - Domain: zoneName, - Records: records, + Domain: zoneName, + Records: records, + SearchDomainDisabled: true, } config.CustomZones = append(config.CustomZones, reverseZone) diff --git a/client/internal/dns/file_parser_unix.go b/client/internal/dns/file_parser_unix.go index 8dacb4e51..50ba74c0c 100644 --- a/client/internal/dns/file_parser_unix.go +++ b/client/internal/dns/file_parser_unix.go @@ -13,6 +13,7 @@ import ( const ( defaultResolvConfPath = "/etc/resolv.conf" + nsswitchConfPath = "/etc/nsswitch.conf" ) type resolvConf struct { diff --git a/client/internal/dns/handler_chain.go b/client/internal/dns/handler_chain.go index 2e54bffd9..dc20146eb 100644 --- a/client/internal/dns/handler_chain.go +++ b/client/internal/dns/handler_chain.go @@ -1,19 +1,26 @@ package dns import ( + "context" "fmt" + "math" + "net" "slices" + "strconv" "strings" "sync" + "time" "github.com/miekg/dns" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/dns/resutil" ) const ( PriorityMgmtCache = 150 - PriorityLocal = 100 - PriorityDNSRoute = 75 + PriorityDNSRoute = 100 + PriorityLocal = 75 PriorityUpstream = 50 PriorityDefault = 1 PriorityFallback = -100 @@ -43,7 +50,23 @@ type HandlerChain struct { type ResponseWriterChain struct { dns.ResponseWriter origPattern string + requestID string shouldContinue bool + response *dns.Msg + meta map[string]string +} + +// RequestID returns the request ID for tracing +func (w *ResponseWriterChain) RequestID() string { + return w.requestID +} + +// SetMeta sets a metadata key-value pair for logging +func (w *ResponseWriterChain) SetMeta(key, value string) { + if w.meta == nil { + w.meta = make(map[string]string) + } + w.meta[key] = value } func (w *ResponseWriterChain) WriteMsg(m *dns.Msg) error { @@ -52,6 +75,10 @@ func (w *ResponseWriterChain) WriteMsg(m *dns.Msg) error { w.shouldContinue = true return nil } + w.response = m + if m.MsgHdr.Truncated { + w.SetMeta("truncated", "true") + } return w.ResponseWriter.WriteMsg(m) } @@ -101,6 +128,8 @@ func (c *HandlerChain) AddHandler(pattern string, handler dns.Handler, priority pos := c.findHandlerPosition(entry) c.handlers = append(c.handlers[:pos], append([]HandlerEntry{entry}, c.handlers[pos:]...)...) + + c.logHandlers() } // findHandlerPosition determines where to insert a new handler based on priority and specificity @@ -140,75 +169,177 @@ func (c *HandlerChain) removeEntry(pattern string, priority int) { for i := len(c.handlers) - 1; i >= 0; i-- { entry := c.handlers[i] if strings.EqualFold(entry.OrigPattern, pattern) && entry.Priority == priority { + log.Debugf("removing handler pattern: domain=%s priority=%d", entry.OrigPattern, priority) c.handlers = append(c.handlers[:i], c.handlers[i+1:]...) + c.logHandlers() break } } } +// logHandlers logs the current handler chain state. Caller must hold the lock. +func (c *HandlerChain) logHandlers() { + if !log.IsLevelEnabled(log.TraceLevel) { + return + } + + var b strings.Builder + b.WriteString("handler chain (" + strconv.Itoa(len(c.handlers)) + "):\n") + for _, h := range c.handlers { + b.WriteString(" - pattern: domain=" + h.Pattern + " original: domain=" + h.OrigPattern + + " wildcard=" + strconv.FormatBool(h.IsWildcard) + + " match_subdomain=" + strconv.FormatBool(h.MatchSubdomains) + + " priority=" + strconv.Itoa(h.Priority) + "\n") + } + log.Trace(strings.TrimSuffix(b.String(), "\n")) +} + func (c *HandlerChain) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { + c.dispatch(w, r, math.MaxInt) +} + +// dispatch routes a DNS request through the chain, skipping handlers with +// priority > maxPriority. Shared by ServeDNS and ResolveInternal. +func (c *HandlerChain) dispatch(w dns.ResponseWriter, r *dns.Msg, maxPriority int) { if len(r.Question) == 0 { return } - qname := strings.ToLower(r.Question[0].Name) + startTime := time.Now() + requestID := resutil.GenerateRequestID() + fields := log.Fields{ + "request_id": requestID, + "dns_id": fmt.Sprintf("%04x", r.Id), + } + if addr := w.RemoteAddr(); addr != nil { + fields["client"] = addr.String() + } + logger := log.WithFields(fields) + + question := r.Question[0] + qname := strings.ToLower(question.Name) c.mu.RLock() handlers := slices.Clone(c.handlers) c.mu.RUnlock() - if log.IsLevelEnabled(log.TraceLevel) { - var b strings.Builder - b.WriteString(fmt.Sprintf("DNS request domain=%s, handlers (%d):\n", qname, len(handlers))) - for _, h := range handlers { - b.WriteString(fmt.Sprintf(" - pattern: domain=%s original: domain=%s wildcard=%v match_subdomain=%v priority=%d\n", - h.Pattern, h.OrigPattern, h.IsWildcard, h.MatchSubdomains, h.Priority)) - } - log.Trace(strings.TrimSuffix(b.String(), "\n")) - } - // Try handlers in priority order for _, entry := range handlers { - matched := c.isHandlerMatch(qname, entry) - - if matched { - log.Tracef("handler matched: domain=%s -> pattern=%s wildcard=%v match_subdomain=%v priority=%d", - qname, entry.OrigPattern, entry.IsWildcard, entry.MatchSubdomains, entry.Priority) - - chainWriter := &ResponseWriterChain{ - ResponseWriter: w, - origPattern: entry.OrigPattern, - } - entry.Handler.ServeDNS(chainWriter, r) - - // If handler wants to continue, try next handler - if chainWriter.shouldContinue { - // Only log continue for non-management cache handlers to reduce noise - if entry.Priority != PriorityMgmtCache { - log.Tracef("handler requested continue to next handler for domain=%s", qname) - } - continue - } - return + if entry.Priority > maxPriority { + continue } + if !c.isHandlerMatch(qname, entry) { + continue + } + + handlerName := entry.OrigPattern + if s, ok := entry.Handler.(interface{ String() string }); ok { + handlerName = s.String() + } + + logger.Tracef("question: domain=%s type=%s class=%s -> handler=%s pattern=%s wildcard=%v match_subdomain=%v priority=%d", + qname, dns.TypeToString[question.Qtype], dns.ClassToString[question.Qclass], + handlerName, entry.OrigPattern, entry.IsWildcard, entry.MatchSubdomains, entry.Priority) + + chainWriter := &ResponseWriterChain{ + ResponseWriter: w, + origPattern: entry.OrigPattern, + requestID: requestID, + } + entry.Handler.ServeDNS(chainWriter, r) + + // If handler wants to continue, try next handler + if chainWriter.shouldContinue { + if entry.Priority != PriorityMgmtCache { + logger.Tracef("handler requested continue for domain=%s", qname) + } + continue + } + + c.logResponse(logger, chainWriter, qname, startTime) + return } // No handler matched or all handlers passed - log.Tracef("no handler found for domain=%s", qname) + logger.Tracef("no handler found for domain=%s type=%s class=%s", + qname, dns.TypeToString[question.Qtype], dns.ClassToString[question.Qclass]) resp := &dns.Msg{} resp.SetRcode(r, dns.RcodeRefused) if err := w.WriteMsg(resp); err != nil { - log.Errorf("failed to write DNS response: %v", err) + logger.Errorf("failed to write DNS response: %v", err) } } +func (c *HandlerChain) logResponse(logger *log.Entry, cw *ResponseWriterChain, qname string, startTime time.Time) { + if cw.response == nil { + return + } + + var meta string + for k, v := range cw.meta { + meta += " " + k + "=" + v + } + + logger.Tracef("response: domain=%s rcode=%s answers=%s size=%dB%s took=%s", + qname, dns.RcodeToString[cw.response.Rcode], resutil.FormatAnswers(cw.response.Answer), + cw.response.Len(), meta, time.Since(startTime)) +} + +// ResolveInternal runs an in-process DNS query against the chain, skipping any +// handler with priority > maxPriority. Used by internal callers (e.g. the mgmt +// cache refresher) that must bypass themselves to avoid loops. Honors ctx +// cancellation; on ctx.Done the dispatch goroutine is left to drain on its own +// (bounded by the invoked handler's internal timeout). +func (c *HandlerChain) ResolveInternal(ctx context.Context, r *dns.Msg, maxPriority int) (*dns.Msg, error) { + if len(r.Question) == 0 { + return nil, fmt.Errorf("empty question") + } + + base := &internalResponseWriter{} + done := make(chan struct{}) + go func() { + c.dispatch(base, r, maxPriority) + close(done) + }() + + select { + case <-done: + case <-ctx.Done(): + // Prefer a completed response if dispatch finished concurrently with cancellation. + select { + case <-done: + default: + return nil, fmt.Errorf("resolve %s: %w", strings.ToLower(r.Question[0].Name), ctx.Err()) + } + } + + if base.response == nil || base.response.Rcode == dns.RcodeRefused { + return nil, fmt.Errorf("no handler resolved %s at priority ≤ %d", + strings.ToLower(r.Question[0].Name), maxPriority) + } + return base.response, nil +} + +// HasRootHandlerAtOrBelow reports whether any "." handler is registered at +// priority ≤ maxPriority. +func (c *HandlerChain) HasRootHandlerAtOrBelow(maxPriority int) bool { + c.mu.RLock() + defer c.mu.RUnlock() + + for _, h := range c.handlers { + if h.Pattern == "." && h.Priority <= maxPriority { + return true + } + } + return false +} + func (c *HandlerChain) isHandlerMatch(qname string, entry HandlerEntry) bool { switch { 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 @@ -220,3 +351,36 @@ func (c *HandlerChain) isHandlerMatch(qname string, entry HandlerEntry) bool { } } } + +// internalResponseWriter captures a dns.Msg for in-process chain queries. +type internalResponseWriter struct { + response *dns.Msg +} + +func (w *internalResponseWriter) WriteMsg(m *dns.Msg) error { w.response = m; return nil } +func (w *internalResponseWriter) LocalAddr() net.Addr { return nil } +func (w *internalResponseWriter) RemoteAddr() net.Addr { return nil } + +// Write unpacks raw DNS bytes so handlers that call Write instead of WriteMsg +// still surface their answer to ResolveInternal. +func (w *internalResponseWriter) Write(p []byte) (int, error) { + msg := new(dns.Msg) + if err := msg.Unpack(p); err != nil { + return 0, err + } + w.response = msg + return len(p), nil +} + +func (w *internalResponseWriter) Close() error { return nil } +func (w *internalResponseWriter) TsigStatus() error { return nil } + +// TsigTimersOnly is part of dns.ResponseWriter. +func (w *internalResponseWriter) TsigTimersOnly(bool) { + // no-op: in-process queries carry no TSIG state. +} + +// Hijack is part of dns.ResponseWriter. +func (w *internalResponseWriter) Hijack() { + // no-op: in-process queries have no underlying connection to hand off. +} diff --git a/client/internal/dns/handler_chain_test.go b/client/internal/dns/handler_chain_test.go index 72c0004d5..b3db97ba3 100644 --- a/client/internal/dns/handler_chain_test.go +++ b/client/internal/dns/handler_chain_test.go @@ -1,11 +1,15 @@ package dns_test import ( + "context" + "net" "testing" + "time" "github.com/miekg/dns" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" nbdns "github.com/netbirdio/netbird/client/internal/dns" "github.com/netbirdio/netbird/client/internal/dns/test" @@ -112,6 +116,102 @@ func TestHandlerChain_ServeDNS_DomainMatching(t *testing.T) { matchSubdomains: false, shouldMatch: false, }, + { + name: "single letter TLD exact match", + handlerDomain: "example.x.", + queryDomain: "example.x.", + isWildcard: false, + matchSubdomains: false, + shouldMatch: true, + }, + { + name: "single letter TLD subdomain match", + handlerDomain: "example.x.", + queryDomain: "sub.example.x.", + isWildcard: false, + matchSubdomains: true, + shouldMatch: true, + }, + { + name: "single letter TLD wildcard match", + handlerDomain: "*.example.x.", + queryDomain: "sub.example.x.", + isWildcard: true, + matchSubdomains: false, + shouldMatch: true, + }, + { + name: "two letter domain labels", + handlerDomain: "a.b.", + queryDomain: "a.b.", + isWildcard: false, + matchSubdomains: false, + shouldMatch: true, + }, + { + name: "single character domain", + handlerDomain: "x.", + queryDomain: "x.", + isWildcard: false, + matchSubdomains: false, + shouldMatch: true, + }, + { + name: "single character domain with subdomain match", + handlerDomain: "x.", + queryDomain: "sub.x.", + isWildcard: false, + matchSubdomains: true, + shouldMatch: true, + }, + { + 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 { @@ -221,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 { @@ -994,3 +1107,163 @@ func TestHandlerChain_AddRemoveRoundtrip(t *testing.T) { }) } } + +// answeringHandler writes a fixed A record to ack the query. Used to verify +// which handler ResolveInternal dispatches to. +type answeringHandler struct { + name string + ip string +} + +func (h *answeringHandler) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { + resp := &dns.Msg{} + resp.SetReply(r) + resp.Answer = []dns.RR{&dns.A{ + Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, + A: net.ParseIP(h.ip).To4(), + }} + _ = w.WriteMsg(resp) +} + +func (h *answeringHandler) String() string { return h.name } + +func TestHandlerChain_ResolveInternal_SkipsAboveMaxPriority(t *testing.T) { + chain := nbdns.NewHandlerChain() + + high := &answeringHandler{name: "high", ip: "10.0.0.1"} + low := &answeringHandler{name: "low", ip: "10.0.0.2"} + + chain.AddHandler("example.com.", high, nbdns.PriorityMgmtCache) + chain.AddHandler("example.com.", low, nbdns.PriorityUpstream) + + r := new(dns.Msg) + r.SetQuestion("example.com.", dns.TypeA) + + resp, err := chain.ResolveInternal(context.Background(), r, nbdns.PriorityUpstream) + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, 1, len(resp.Answer)) + a, ok := resp.Answer[0].(*dns.A) + assert.True(t, ok) + assert.Equal(t, "10.0.0.2", a.A.String(), "should skip mgmtCache handler and resolve via upstream") +} + +func TestHandlerChain_ResolveInternal_ErrorWhenNoMatch(t *testing.T) { + chain := nbdns.NewHandlerChain() + high := &answeringHandler{name: "high", ip: "10.0.0.1"} + chain.AddHandler("example.com.", high, nbdns.PriorityMgmtCache) + + r := new(dns.Msg) + r.SetQuestion("example.com.", dns.TypeA) + + _, err := chain.ResolveInternal(context.Background(), r, nbdns.PriorityUpstream) + assert.Error(t, err, "no handler at or below maxPriority should error") +} + +// rawWriteHandler packs a response and calls ResponseWriter.Write directly +// (instead of WriteMsg), exercising the internalResponseWriter.Write path. +type rawWriteHandler struct { + ip string +} + +func (h *rawWriteHandler) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { + resp := &dns.Msg{} + resp.SetReply(r) + resp.Answer = []dns.RR{&dns.A{ + Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, + A: net.ParseIP(h.ip).To4(), + }} + packed, err := resp.Pack() + if err != nil { + return + } + _, _ = w.Write(packed) +} + +func TestHandlerChain_ResolveInternal_CapturesRawWrite(t *testing.T) { + chain := nbdns.NewHandlerChain() + chain.AddHandler("example.com.", &rawWriteHandler{ip: "10.0.0.3"}, nbdns.PriorityUpstream) + + r := new(dns.Msg) + r.SetQuestion("example.com.", dns.TypeA) + + resp, err := chain.ResolveInternal(context.Background(), r, nbdns.PriorityUpstream) + assert.NoError(t, err) + require.NotNil(t, resp) + require.Len(t, resp.Answer, 1) + a, ok := resp.Answer[0].(*dns.A) + require.True(t, ok) + assert.Equal(t, "10.0.0.3", a.A.String(), "handlers calling Write(packed) must still surface their answer") +} + +func TestHandlerChain_ResolveInternal_EmptyQuestion(t *testing.T) { + chain := nbdns.NewHandlerChain() + _, err := chain.ResolveInternal(context.Background(), new(dns.Msg), nbdns.PriorityUpstream) + assert.Error(t, err) +} + +// hangingHandler blocks indefinitely until closed, simulating a wedged upstream. +type hangingHandler struct { + block chan struct{} +} + +func (h *hangingHandler) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { + <-h.block + resp := &dns.Msg{} + resp.SetReply(r) + _ = w.WriteMsg(resp) +} + +func (h *hangingHandler) String() string { return "hangingHandler" } + +func TestHandlerChain_ResolveInternal_HonorsContextTimeout(t *testing.T) { + chain := nbdns.NewHandlerChain() + h := &hangingHandler{block: make(chan struct{})} + defer close(h.block) + + chain.AddHandler("example.com.", h, nbdns.PriorityUpstream) + + r := new(dns.Msg) + r.SetQuestion("example.com.", dns.TypeA) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + start := time.Now() + _, err := chain.ResolveInternal(ctx, r, nbdns.PriorityUpstream) + elapsed := time.Since(start) + + assert.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) + assert.Less(t, elapsed, 500*time.Millisecond, "ResolveInternal must return shortly after ctx deadline") +} + +func TestHandlerChain_HasRootHandlerAtOrBelow(t *testing.T) { + chain := nbdns.NewHandlerChain() + h := &answeringHandler{name: "h", ip: "10.0.0.1"} + + assert.False(t, chain.HasRootHandlerAtOrBelow(nbdns.PriorityUpstream), "empty chain") + + chain.AddHandler("example.com.", h, nbdns.PriorityUpstream) + assert.False(t, chain.HasRootHandlerAtOrBelow(nbdns.PriorityUpstream), "non-root handler does not count") + + chain.AddHandler(".", h, nbdns.PriorityMgmtCache) + assert.False(t, chain.HasRootHandlerAtOrBelow(nbdns.PriorityUpstream), "root handler above threshold excluded") + + chain.AddHandler(".", h, nbdns.PriorityDefault) + assert.True(t, chain.HasRootHandlerAtOrBelow(nbdns.PriorityUpstream), "root handler at PriorityDefault included") + + chain.RemoveHandler(".", nbdns.PriorityDefault) + assert.False(t, chain.HasRootHandlerAtOrBelow(nbdns.PriorityUpstream)) + + // Primary nsgroup case: root handler lands at PriorityUpstream. + chain.AddHandler(".", h, nbdns.PriorityUpstream) + assert.True(t, chain.HasRootHandlerAtOrBelow(nbdns.PriorityUpstream), "root at PriorityUpstream included") + chain.RemoveHandler(".", nbdns.PriorityUpstream) + + // Fallback case: original /etc/resolv.conf entries land at PriorityFallback. + chain.AddHandler(".", h, nbdns.PriorityFallback) + assert.True(t, chain.HasRootHandlerAtOrBelow(nbdns.PriorityUpstream), "root at PriorityFallback included") + chain.RemoveHandler(".", nbdns.PriorityFallback) + assert.False(t, chain.HasRootHandlerAtOrBelow(nbdns.PriorityUpstream)) +} diff --git a/client/internal/dns/host.go b/client/internal/dns/host.go index fa474afde..48eacef29 100644 --- a/client/internal/dns/host.go +++ b/client/internal/dns/host.go @@ -11,16 +11,15 @@ import ( nbdns "github.com/netbirdio/netbird/dns" ) -const ( - ipv4ReverseZone = ".in-addr.arpa." - ipv6ReverseZone = ".ip6.arpa." -) - type hostManager interface { applyDNSConfig(config HostDNSConfig, stateManager *statemanager.Manager) error 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 { @@ -110,10 +109,9 @@ func dnsConfigToHostDNSConfig(dnsConfig nbdns.Config, ip netip.Addr, port int) H } for _, customZone := range dnsConfig.CustomZones { - matchOnly := strings.HasSuffix(customZone.Domain, ipv4ReverseZone) || strings.HasSuffix(customZone.Domain, ipv6ReverseZone) config.Domains = append(config.Domains, DomainConfig{ Domain: strings.ToLower(dns.Fqdn(customZone.Domain)), - MatchOnly: matchOnly, + MatchOnly: customZone.SearchDomainDisabled, }) } @@ -137,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 71badf0d4..0f4eb6bf8 100644 --- a/client/internal/dns/host_darwin.go +++ b/client/internal/dns/host_darwin.go @@ -9,9 +9,13 @@ import ( "io" "net/netip" "os/exec" + "slices" "strconv" "strings" + "sync" + "github.com/hashicorp/go-multierror" + nberrors "github.com/netbirdio/netbird/client/errors" log "github.com/sirupsen/logrus" "golang.org/x/exp/maps" @@ -20,6 +24,7 @@ import ( const ( netbirdDNSStateKeyFormat = "State:/Network/Service/NetBird-%s/DNS" + netbirdDNSStateKeyIndexedFormat = "State:/Network/Service/NetBird-%s-%d/DNS" globalIPv4State = "State:/Network/Global/IPv4" primaryServiceStateKeyFormat = "State:/Network/Service/%s/DNS" keySupplementalMatchDomains = "SupplementalMatchDomains" @@ -33,11 +38,22 @@ const ( searchSuffix = "Search" matchSuffix = "Match" localSuffix = "Local" + + // maxDomainsPerResolverEntry is the max number of domains per scutil resolver key. + // scutil's d.add has maxArgs=101 (key + * + 99 values), so 99 is the hard cap. + maxDomainsPerResolverEntry = 50 + + // maxDomainBytesPerResolverEntry is the max total bytes of domain strings per key. + // scutil has an undocumented ~2048 byte value buffer; we stay well under it. + maxDomainBytesPerResolverEntry = 1500 ) type systemConfigurator struct { createdKeys map[string]struct{} systemDNSSettings SystemDNSSettings + + mu sync.RWMutex + origNameservers []netip.Addr } func newHostManager() (*systemConfigurator, error) { @@ -79,28 +95,23 @@ func (s *systemConfigurator) applyDNSConfig(config HostDNSConfig, stateManager * searchDomains = append(searchDomains, strings.TrimSuffix(""+dConf.Domain, ".")) } - matchKey := getKeyWithInput(netbirdDNSStateKeyFormat, matchSuffix) - var err error - if len(matchDomains) != 0 { - err = s.addMatchDomains(matchKey, strings.Join(matchDomains, " "), config.ServerIP, config.ServerPort) - } else { - log.Infof("removing match domains from the system") - err = s.removeKeyFromSystemConfig(matchKey) + if err := s.removeKeysContaining(matchSuffix); err != nil { + log.Warnf("failed to remove old match keys: %v", err) } - if err != nil { - return fmt.Errorf("add match domains: %w", err) + if len(matchDomains) != 0 { + if err := s.addBatchedDomains(matchSuffix, matchDomains, config.ServerIP, config.ServerPort, false); err != nil { + return fmt.Errorf("add match domains: %w", err) + } } s.updateState(stateManager) - searchKey := getKeyWithInput(netbirdDNSStateKeyFormat, searchSuffix) - if len(searchDomains) != 0 { - err = s.addSearchDomains(searchKey, strings.Join(searchDomains, " "), config.ServerIP, config.ServerPort) - } else { - log.Infof("removing search domains from the system") - err = s.removeKeyFromSystemConfig(searchKey) + if err := s.removeKeysContaining(searchSuffix); err != nil { + log.Warnf("failed to remove old search keys: %v", err) } - if err != nil { - return fmt.Errorf("add search domains: %w", err) + if len(searchDomains) != 0 { + if err := s.addBatchedDomains(searchSuffix, searchDomains, config.ServerIP, config.ServerPort, true); err != nil { + return fmt.Errorf("add search domains: %w", err) + } } s.updateState(stateManager) @@ -144,8 +155,7 @@ func (s *systemConfigurator) restoreHostDNS() error { func (s *systemConfigurator) getRemovableKeysWithDefaults() []string { if len(s.createdKeys) == 0 { - // return defaults for startup calls - return []string{getKeyWithInput(netbirdDNSStateKeyFormat, searchSuffix), getKeyWithInput(netbirdDNSStateKeyFormat, matchSuffix)} + return s.discoverExistingKeys() } keys := make([]string, 0, len(s.createdKeys)) @@ -155,6 +165,47 @@ func (s *systemConfigurator) getRemovableKeysWithDefaults() []string { return keys } +// discoverExistingKeys probes scutil for all NetBird DNS keys that may exist. +// This handles the case where createdKeys is empty (e.g., state file lost after unclean shutdown). +func (s *systemConfigurator) discoverExistingKeys() []string { + dnsKeys, err := getSystemDNSKeys() + if err != nil { + log.Errorf("failed to get system DNS keys: %v", err) + return nil + } + + var keys []string + + for _, suffix := range []string{searchSuffix, matchSuffix, localSuffix} { + key := getKeyWithInput(netbirdDNSStateKeyFormat, suffix) + if strings.Contains(dnsKeys, key) { + keys = append(keys, key) + } + } + + for _, suffix := range []string{searchSuffix, matchSuffix} { + for i := 0; ; i++ { + key := fmt.Sprintf(netbirdDNSStateKeyIndexedFormat, suffix, i) + if !strings.Contains(dnsKeys, key) { + break + } + keys = append(keys, key) + } + } + + return keys +} + +// getSystemDNSKeys gets all DNS keys +func getSystemDNSKeys() (string, error) { + command := "list .*DNS\nquit\n" + out, err := runSystemConfigCommand(command) + if err != nil { + return "", err + } + return string(out), nil +} + func (s *systemConfigurator) removeKeyFromSystemConfig(key string) error { line := buildRemoveKeyOperation(key) _, err := runSystemConfigCommand(wrapCommand(line)) @@ -179,12 +230,11 @@ func (s *systemConfigurator) addLocalDNS() error { return nil } - if err := s.addSearchDomains( - localKey, - strings.Join(s.systemDNSSettings.Domains, " "), s.systemDNSSettings.ServerIP, s.systemDNSSettings.ServerPort, - ); err != nil { - return fmt.Errorf("add search domains: %w", err) + domainsStr := strings.Join(s.systemDNSSettings.Domains, " ") + if err := s.addDNSState(localKey, domainsStr, s.systemDNSSettings.ServerIP, s.systemDNSSettings.ServerPort, true); err != nil { + return fmt.Errorf("add local dns state: %w", err) } + s.createdKeys[localKey] = struct{}{} return nil } @@ -218,6 +268,7 @@ func (s *systemConfigurator) getSystemDNSSettings() (SystemDNSSettings, error) { } var dnsSettings SystemDNSSettings + var serverAddresses []netip.Addr inSearchDomainsArray := false inServerAddressesArray := false @@ -244,9 +295,13 @@ func (s *systemConfigurator) getSystemDNSSettings() (SystemDNSSettings, error) { dnsSettings.Domains = append(dnsSettings.Domains, searchDomain) } else if inServerAddressesArray { address := strings.Split(line, " : ")[1] - if ip, err := netip.ParseAddr(address); err == nil && ip.Is4() { - dnsSettings.ServerIP = ip.Unmap() - inServerAddressesArray = false // Stop reading after finding the first IPv4 address + if ip, err := netip.ParseAddr(address); err == nil && !ip.IsUnspecified() { + ip = ip.Unmap() + serverAddresses = append(serverAddresses, ip) + // Prefer the first IPv4 server as ServerIP since our DNS listener is IPv4. + if !dnsSettings.ServerIP.IsValid() && ip.Is4() { + dnsSettings.ServerIP = ip + } } } } @@ -258,31 +313,90 @@ func (s *systemConfigurator) getSystemDNSSettings() (SystemDNSSettings, error) { // default to 53 port dnsSettings.ServerPort = DefaultPort + s.mu.Lock() + s.origNameservers = serverAddresses + s.mu.Unlock() + return dnsSettings, nil } -func (s *systemConfigurator) addSearchDomains(key, domains string, ip netip.Addr, port int) error { - err := s.addDNSState(key, domains, ip, port, true) - if err != nil { - return fmt.Errorf("add dns state: %w", err) - } - - log.Infof("added %d search domains to the state. Domain list: %s", len(strings.Split(domains, " ")), domains) - - s.createdKeys[key] = struct{}{} - - return nil +func (s *systemConfigurator) getOriginalNameservers() []netip.Addr { + s.mu.RLock() + defer s.mu.RUnlock() + return slices.Clone(s.origNameservers) } -func (s *systemConfigurator) addMatchDomains(key, domains string, dnsServer netip.Addr, port int) error { - err := s.addDNSState(key, domains, dnsServer, port, false) - if err != nil { - return fmt.Errorf("add dns state: %w", err) +// splitDomainsIntoBatches splits domains into batches respecting both element count and byte size limits. +func splitDomainsIntoBatches(domains []string) [][]string { + if len(domains) == 0 { + return nil } - log.Infof("added %d match domains to the state. Domain list: %s", len(strings.Split(domains, " ")), domains) + var batches [][]string + var current []string + currentBytes := 0 - s.createdKeys[key] = struct{}{} + for _, d := range domains { + domainLen := len(d) + newBytes := currentBytes + domainLen + if currentBytes > 0 { + newBytes++ // space separator + } + + if len(current) > 0 && (len(current) >= maxDomainsPerResolverEntry || newBytes > maxDomainBytesPerResolverEntry) { + batches = append(batches, current) + current = nil + currentBytes = 0 + } + + current = append(current, d) + if currentBytes > 0 { + currentBytes += 1 + domainLen + } else { + currentBytes = domainLen + } + } + + if len(current) > 0 { + batches = append(batches, current) + } + + return batches +} + +// removeKeysContaining removes all created keys that contain the given substring. +func (s *systemConfigurator) removeKeysContaining(suffix string) error { + var toRemove []string + for key := range s.createdKeys { + if strings.Contains(key, suffix) { + toRemove = append(toRemove, key) + } + } + var multiErr *multierror.Error + for _, key := range toRemove { + if err := s.removeKeyFromSystemConfig(key); err != nil { + multiErr = multierror.Append(multiErr, fmt.Errorf("couldn't remove key %s: %w", key, err)) + } + } + return nberrors.FormatErrorOrNil(multiErr) +} + +// addBatchedDomains splits domains into batches and creates indexed scutil keys for each batch. +func (s *systemConfigurator) addBatchedDomains(suffix string, domains []string, ip netip.Addr, port int, enableSearch bool) error { + batches := splitDomainsIntoBatches(domains) + + for i, batch := range batches { + key := fmt.Sprintf(netbirdDNSStateKeyIndexedFormat, suffix, i) + domainsStr := strings.Join(batch, " ") + + if err := s.addDNSState(key, domainsStr, ip, port, enableSearch); err != nil { + return fmt.Errorf("add dns state for batch %d: %w", i, err) + } + + s.createdKeys[key] = struct{}{} + } + + log.Infof("added %d %s domains across %d resolver entries", len(domains), suffix, len(batches)) return nil } @@ -345,7 +459,6 @@ func (s *systemConfigurator) flushDNSCache() error { if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("restart mDNSResponder: %w, output: %s", err, out) } - log.Info("flushed DNS cache") return nil } diff --git a/client/internal/dns/host_darwin_test.go b/client/internal/dns/host_darwin_test.go index c4efd17b0..94d020c39 100644 --- a/client/internal/dns/host_darwin_test.go +++ b/client/internal/dns/host_darwin_test.go @@ -3,7 +3,10 @@ package dns import ( + "bufio" + "bytes" "context" + "fmt" "net/netip" "os/exec" "path/filepath" @@ -49,17 +52,22 @@ func TestDarwinDNSUncleanShutdownCleanup(t *testing.T) { require.NoError(t, sm.PersistState(context.Background())) - searchKey := getKeyWithInput(netbirdDNSStateKeyFormat, searchSuffix) - matchKey := getKeyWithInput(netbirdDNSStateKeyFormat, matchSuffix) localKey := getKeyWithInput(netbirdDNSStateKeyFormat, localSuffix) + // Collect all created keys for cleanup verification + createdKeys := make([]string, 0, len(configurator.createdKeys)) + for key := range configurator.createdKeys { + createdKeys = append(createdKeys, key) + } + defer func() { - for _, key := range []string{searchKey, matchKey, localKey} { + for _, key := range createdKeys { _ = removeTestDNSKey(key) } + _ = removeTestDNSKey(localKey) }() - for _, key := range []string{searchKey, matchKey, localKey} { + for _, key := range createdKeys { exists, err := checkDNSKeyExists(key) require.NoError(t, err) if exists { @@ -83,13 +91,223 @@ func TestDarwinDNSUncleanShutdownCleanup(t *testing.T) { err = shutdownState.Cleanup() require.NoError(t, err) - for _, key := range []string{searchKey, matchKey, localKey} { + for _, key := range createdKeys { exists, err := checkDNSKeyExists(key) require.NoError(t, err) assert.False(t, exists, "Key %s should NOT exist after cleanup", key) } } +// generateShortDomains generates domains like a.com, b.com, ..., aa.com, ab.com, etc. +func generateShortDomains(count int) []string { + domains := make([]string, 0, count) + for i := range count { + label := "" + n := i + for { + label = string(rune('a'+n%26)) + label + n = n/26 - 1 + if n < 0 { + break + } + } + domains = append(domains, label+".com") + } + return domains +} + +// generateLongDomains generates domains like subdomain-000.department.organization-name.example.com +func generateLongDomains(count int) []string { + domains := make([]string, 0, count) + for i := range count { + domains = append(domains, fmt.Sprintf("subdomain-%03d.department.organization-name.example.com", i)) + } + return domains +} + +// readDomainsFromKey reads the SupplementalMatchDomains array back from scutil for a given key. +func readDomainsFromKey(t *testing.T, key string) []string { + t.Helper() + + cmd := exec.Command(scutilPath) + cmd.Stdin = strings.NewReader(fmt.Sprintf("open\nshow %s\nquit\n", key)) + out, err := cmd.Output() + require.NoError(t, err, "scutil show should succeed") + + var domains []string + inArray := false + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "SupplementalMatchDomains") && strings.Contains(line, "") { + inArray = true + continue + } + if inArray { + if line == "}" { + break + } + // lines look like: "0 : a.com" + parts := strings.SplitN(line, " : ", 2) + if len(parts) == 2 { + domains = append(domains, parts[1]) + } + } + } + require.NoError(t, scanner.Err()) + return domains +} + +func TestSplitDomainsIntoBatches(t *testing.T) { + tests := []struct { + name string + domains []string + expectedCount int + checkAllPresent bool + }{ + { + name: "empty", + domains: nil, + expectedCount: 0, + }, + { + name: "under_limit", + domains: generateShortDomains(10), + expectedCount: 1, + checkAllPresent: true, + }, + { + name: "at_element_limit", + domains: generateShortDomains(50), + expectedCount: 1, + checkAllPresent: true, + }, + { + name: "over_element_limit", + domains: generateShortDomains(51), + expectedCount: 2, + checkAllPresent: true, + }, + { + name: "triple_element_limit", + domains: generateShortDomains(150), + expectedCount: 3, + checkAllPresent: true, + }, + { + name: "long_domains_hit_byte_limit", + domains: generateLongDomains(50), + checkAllPresent: true, + }, + { + name: "500_short_domains", + domains: generateShortDomains(500), + expectedCount: 10, + checkAllPresent: true, + }, + { + name: "500_long_domains", + domains: generateLongDomains(500), + checkAllPresent: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + batches := splitDomainsIntoBatches(tc.domains) + + if tc.expectedCount > 0 { + assert.Len(t, batches, tc.expectedCount, "expected %d batches", tc.expectedCount) + } + + // Verify each batch respects limits + for i, batch := range batches { + assert.LessOrEqual(t, len(batch), maxDomainsPerResolverEntry, + "batch %d exceeds element limit", i) + + totalBytes := 0 + for j, d := range batch { + if j > 0 { + totalBytes++ + } + totalBytes += len(d) + } + assert.LessOrEqual(t, totalBytes, maxDomainBytesPerResolverEntry, + "batch %d exceeds byte limit (%d bytes)", i, totalBytes) + } + + if tc.checkAllPresent { + var all []string + for _, batch := range batches { + all = append(all, batch...) + } + assert.Equal(t, tc.domains, all, "all domains should be present in order") + } + }) + } +} + +// TestMatchDomainBatching writes increasing numbers of domains via the batching mechanism +// and verifies all domains are readable across multiple scutil keys. +func TestMatchDomainBatching(t *testing.T) { + if testing.Short() { + t.Skip("skipping scutil integration test in short mode") + } + + testCases := []struct { + name string + count int + generator func(int) []string + }{ + {"short_10", 10, generateShortDomains}, + {"short_50", 50, generateShortDomains}, + {"short_100", 100, generateShortDomains}, + {"short_200", 200, generateShortDomains}, + {"short_500", 500, generateShortDomains}, + {"long_10", 10, generateLongDomains}, + {"long_50", 50, generateLongDomains}, + {"long_100", 100, generateLongDomains}, + {"long_200", 200, generateLongDomains}, + {"long_500", 500, generateLongDomains}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + configurator := &systemConfigurator{ + createdKeys: make(map[string]struct{}), + } + + defer func() { + for key := range configurator.createdKeys { + _ = removeTestDNSKey(key) + } + }() + + domains := tc.generator(tc.count) + err := configurator.addBatchedDomains(matchSuffix, domains, netip.MustParseAddr("100.64.0.1"), 53, false) + require.NoError(t, err) + + batches := splitDomainsIntoBatches(domains) + t.Logf("wrote %d domains across %d batched keys", tc.count, len(batches)) + + // Read back all domains from all batched keys + var got []string + for i := range batches { + key := fmt.Sprintf(netbirdDNSStateKeyIndexedFormat, matchSuffix, i) + exists, err := checkDNSKeyExists(key) + require.NoError(t, err) + require.True(t, exists, "key %s should exist", key) + + got = append(got, readDomainsFromKey(t, key)...) + } + + t.Logf("read back %d/%d domains from %d keys", len(got), tc.count, len(batches)) + assert.Equal(t, tc.count, len(got), "all domains should be readable") + assert.Equal(t, domains, got, "domains should match in order") + }) + } +} + func checkDNSKeyExists(key string) (bool, error) { cmd := exec.Command(scutilPath) cmd.Stdin = strings.NewReader("show " + key + "\nquit\n") @@ -109,3 +327,169 @@ func removeTestDNSKey(key string) error { _, err := cmd.CombinedOutput() return err } + +func TestGetOriginalNameservers(t *testing.T) { + configurator := &systemConfigurator{ + createdKeys: make(map[string]struct{}), + origNameservers: []netip.Addr{ + netip.MustParseAddr("8.8.8.8"), + netip.MustParseAddr("1.1.1.1"), + }, + } + + servers := configurator.getOriginalNameservers() + assert.Len(t, servers, 2) + assert.Equal(t, netip.MustParseAddr("8.8.8.8"), servers[0]) + assert.Equal(t, netip.MustParseAddr("1.1.1.1"), servers[1]) +} + +func TestGetOriginalNameserversFromSystem(t *testing.T) { + configurator := &systemConfigurator{ + createdKeys: make(map[string]struct{}), + } + + _, err := configurator.getSystemDNSSettings() + require.NoError(t, err) + + servers := configurator.getOriginalNameservers() + + require.NotEmpty(t, servers, "expected at least one DNS server from system configuration") + + for _, server := range servers { + assert.True(t, server.IsValid(), "server address should be valid") + assert.False(t, server.IsUnspecified(), "server address should not be unspecified") + } + + t.Logf("found %d original nameservers: %v", len(servers), servers) +} + +func setupTestConfigurator(t *testing.T) (*systemConfigurator, *statemanager.Manager, func()) { + t.Helper() + + tmpDir := t.TempDir() + stateFile := filepath.Join(tmpDir, "state.json") + sm := statemanager.New(stateFile) + sm.RegisterState(&ShutdownState{}) + sm.Start() + + configurator := &systemConfigurator{ + createdKeys: make(map[string]struct{}), + } + + cleanup := func() { + _ = sm.Stop(context.Background()) + for key := range configurator.createdKeys { + _ = removeTestDNSKey(key) + } + // Also clean up old-format keys and local key in case they exist + _ = removeTestDNSKey(getKeyWithInput(netbirdDNSStateKeyFormat, searchSuffix)) + _ = removeTestDNSKey(getKeyWithInput(netbirdDNSStateKeyFormat, matchSuffix)) + _ = removeTestDNSKey(getKeyWithInput(netbirdDNSStateKeyFormat, localSuffix)) + } + + return configurator, sm, cleanup +} + +func TestOriginalNameserversNoTransition(t *testing.T) { + netbirdIP := netip.MustParseAddr("100.64.0.1") + + testCases := []struct { + name string + routeAll bool + }{ + {"routeall_false", false}, + {"routeall_true", true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + configurator, sm, cleanup := setupTestConfigurator(t) + defer cleanup() + + _, err := configurator.getSystemDNSSettings() + require.NoError(t, err) + initialServers := configurator.getOriginalNameservers() + t.Logf("Initial servers: %v", initialServers) + require.NotEmpty(t, initialServers) + + for _, srv := range initialServers { + require.NotEqual(t, netbirdIP, srv, "initial servers should not contain NetBird IP") + } + + config := HostDNSConfig{ + ServerIP: netbirdIP, + ServerPort: 53, + RouteAll: tc.routeAll, + Domains: []DomainConfig{{Domain: "example.com", MatchOnly: true}}, + } + + for i := 1; i <= 2; i++ { + err = configurator.applyDNSConfig(config, sm) + require.NoError(t, err) + + servers := configurator.getOriginalNameservers() + t.Logf("After apply %d (RouteAll=%v): %v", i, tc.routeAll, servers) + assert.Equal(t, initialServers, servers) + } + }) + } +} + +func TestOriginalNameserversRouteAllTransition(t *testing.T) { + netbirdIP := netip.MustParseAddr("100.64.0.1") + + testCases := []struct { + name string + initialRoute bool + }{ + {"start_with_routeall_false", false}, + {"start_with_routeall_true", true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + configurator, sm, cleanup := setupTestConfigurator(t) + defer cleanup() + + _, err := configurator.getSystemDNSSettings() + require.NoError(t, err) + initialServers := configurator.getOriginalNameservers() + t.Logf("Initial servers: %v", initialServers) + require.NotEmpty(t, initialServers) + + config := HostDNSConfig{ + ServerIP: netbirdIP, + ServerPort: 53, + RouteAll: tc.initialRoute, + Domains: []DomainConfig{{Domain: "example.com", MatchOnly: true}}, + } + + // First apply + err = configurator.applyDNSConfig(config, sm) + require.NoError(t, err) + servers := configurator.getOriginalNameservers() + t.Logf("After first apply (RouteAll=%v): %v", tc.initialRoute, servers) + assert.Equal(t, initialServers, servers) + + // Toggle RouteAll + config.RouteAll = !tc.initialRoute + err = configurator.applyDNSConfig(config, sm) + require.NoError(t, err) + servers = configurator.getOriginalNameservers() + t.Logf("After toggle (RouteAll=%v): %v", config.RouteAll, servers) + assert.Equal(t, initialServers, servers) + + // Toggle back + config.RouteAll = tc.initialRoute + err = configurator.applyDNSConfig(config, sm) + require.NoError(t, err) + servers = configurator.getOriginalNameservers() + t.Logf("After toggle back (RouteAll=%v): %v", config.RouteAll, servers) + assert.Equal(t, initialServers, servers) + + for _, srv := range servers { + assert.NotEqual(t, netbirdIP, srv, "servers should not contain NetBird IP") + } + }) + } +} diff --git a/client/internal/dns/host_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_unix.go b/client/internal/dns/host_unix.go index 422fed4e5..d7301d725 100644 --- a/client/internal/dns/host_unix.go +++ b/client/internal/dns/host_unix.go @@ -46,12 +46,12 @@ type restoreHostManager interface { } func newHostManager(wgInterface string) (hostManager, error) { - osManager, err := getOSDNSManagerType() + osManager, reason, err := getOSDNSManagerType() if err != nil { return nil, fmt.Errorf("get os dns manager type: %w", err) } - log.Infof("System DNS manager discovered: %s", osManager) + log.Infof("System DNS manager discovered: %s (%s)", osManager, reason) mgr, err := newHostManagerFromType(wgInterface, osManager) // need to explicitly return nil mgr on error to avoid returning a non-nil interface containing a nil value if err != nil { @@ -74,17 +74,49 @@ func newHostManagerFromType(wgInterface string, osManager osManagerType) (restor } } -func getOSDNSManagerType() (osManagerType, error) { +func getOSDNSManagerType() (osManagerType, string, error) { + resolved := isSystemdResolvedRunning() + nss := isLibnssResolveUsed() + stub := checkStub() + + // Prefer systemd-resolved whenever it owns libc resolution, regardless of + // who wrote /etc/resolv.conf. File-mode rewrites do not affect lookups + // that go through nss-resolve, and in foreign mode they can loop back + // through resolved as an upstream. + if resolved && (nss || stub) { + return systemdManager, fmt.Sprintf("systemd-resolved active (nss-resolve=%t, stub=%t)", nss, stub), nil + } + + mgr, reason, rejected, err := scanResolvConfHeader() + if err != nil { + return 0, "", err + } + if reason != "" { + return mgr, reason, nil + } + + fallback := fmt.Sprintf("no manager matched (resolved=%t, nss-resolve=%t, stub=%t)", resolved, nss, stub) + if len(rejected) > 0 { + fallback += "; rejected: " + strings.Join(rejected, ", ") + } + return fileManager, fallback, nil +} + +// scanResolvConfHeader walks /etc/resolv.conf header comments and returns the +// matching manager. If reason is empty the caller should pick file mode and +// use rejected for diagnostics. +func scanResolvConfHeader() (osManagerType, string, []string, error) { file, err := os.Open(defaultResolvConfPath) if err != nil { - return 0, fmt.Errorf("unable to open %s for checking owner, got error: %w", defaultResolvConfPath, err) + return 0, "", nil, fmt.Errorf("unable to open %s for checking owner, got error: %w", defaultResolvConfPath, err) } defer func() { - if err := file.Close(); err != nil { - log.Errorf("close file %s: %s", defaultResolvConfPath, err) + if cerr := file.Close(); cerr != nil { + log.Errorf("close file %s: %s", defaultResolvConfPath, cerr) } }() + var rejected []string scanner := bufio.NewScanner(file) for scanner.Scan() { text := scanner.Text() @@ -92,41 +124,48 @@ func getOSDNSManagerType() (osManagerType, error) { continue } if text[0] != '#' { - return fileManager, nil + break } - if strings.Contains(text, fileGeneratedResolvConfContentHeader) { - return netbirdManager, nil - } - if strings.Contains(text, "NetworkManager") && isDbusListenerRunning(networkManagerDest, networkManagerDbusObjectNode) && isNetworkManagerSupported() { - return networkManager, nil - } - if strings.Contains(text, "systemd-resolved") && isSystemdResolvedRunning() { - if checkStub() { - return systemdManager, nil - } else { - return fileManager, nil - } - } - if strings.Contains(text, "resolvconf") { - if isSystemdResolveConfMode() { - return systemdManager, nil - } - - return resolvConfManager, nil + if mgr, reason, rej := matchResolvConfHeader(text); reason != "" { + return mgr, reason, nil, nil + } else if rej != "" { + rejected = append(rejected, rej) } } if err := scanner.Err(); err != nil && err != io.EOF { - return 0, fmt.Errorf("scan: %w", err) + return 0, "", nil, fmt.Errorf("scan: %w", err) } - - return fileManager, nil + return 0, "", rejected, nil } -// checkStub checks if the stub resolver is disabled in systemd-resolved. If it is disabled, we fall back to file manager. +// matchResolvConfHeader inspects a single comment line. Returns either a +// definitive (manager, reason) or a non-empty rejected diagnostic. +func matchResolvConfHeader(text string) (osManagerType, string, string) { + if strings.Contains(text, fileGeneratedResolvConfContentHeader) { + return netbirdManager, "netbird-managed resolv.conf header detected", "" + } + if strings.Contains(text, "NetworkManager") { + if isDbusListenerRunning(networkManagerDest, networkManagerDbusObjectNode) && isNetworkManagerSupported() { + return networkManager, "NetworkManager header + supported version on dbus", "" + } + return 0, "", "NetworkManager header (no dbus or unsupported version)" + } + if strings.Contains(text, "resolvconf") { + if isSystemdResolveConfMode() { + return systemdManager, "resolvconf header in systemd-resolved compatibility mode", "" + } + return resolvConfManager, "resolvconf header detected", "" + } + return 0, "", "" +} + +// checkStub reports whether systemd-resolved's stub (127.0.0.53) is listed +// in /etc/resolv.conf. On parse failure we assume it is, to avoid dropping +// into file mode while resolved is active. func checkStub() bool { rConf, err := parseDefaultResolvConf() if err != nil { - log.Warnf("failed to parse resolv conf: %s", err) + log.Warnf("failed to parse resolv conf, assuming stub is active: %s", err) return true } @@ -139,3 +178,36 @@ func checkStub() bool { return false } + +// isLibnssResolveUsed reports whether nss-resolve is listed before dns on +// the hosts: line of /etc/nsswitch.conf. When it is, libc lookups are +// delegated to systemd-resolved regardless of /etc/resolv.conf. +func isLibnssResolveUsed() bool { + bs, err := os.ReadFile(nsswitchConfPath) + if err != nil { + log.Debugf("read %s: %v", nsswitchConfPath, err) + return false + } + return parseNsswitchResolveAhead(bs) +} + +func parseNsswitchResolveAhead(data []byte) bool { + for _, line := range strings.Split(string(data), "\n") { + if i := strings.IndexByte(line, '#'); i >= 0 { + line = line[:i] + } + fields := strings.Fields(line) + if len(fields) < 2 || fields[0] != "hosts:" { + continue + } + for _, module := range fields[1:] { + switch module { + case "dns": + return false + case "resolve": + return true + } + } + } + return false +} diff --git a/client/internal/dns/host_unix_test.go b/client/internal/dns/host_unix_test.go new file mode 100644 index 000000000..e936281d3 --- /dev/null +++ b/client/internal/dns/host_unix_test.go @@ -0,0 +1,76 @@ +//go:build (linux && !android) || freebsd + +package dns + +import "testing" + +func TestParseNsswitchResolveAhead(t *testing.T) { + tests := []struct { + name string + in string + want bool + }{ + { + name: "resolve before dns with action token", + in: "hosts: mymachines resolve [!UNAVAIL=return] files myhostname dns\n", + want: true, + }, + { + name: "dns before resolve", + in: "hosts: files mdns4_minimal [NOTFOUND=return] dns resolve\n", + want: false, + }, + { + name: "debian default with only dns", + in: "hosts: files mdns4_minimal [NOTFOUND=return] dns mymachines\n", + want: false, + }, + { + name: "neither resolve nor dns", + in: "hosts: files myhostname\n", + want: false, + }, + { + name: "no hosts line", + in: "passwd: files systemd\ngroup: files systemd\n", + want: false, + }, + { + name: "empty", + in: "", + want: false, + }, + { + name: "comments and blank lines ignored", + in: "# comment\n\n# another\nhosts: resolve dns\n", + want: true, + }, + { + name: "trailing inline comment", + in: "hosts: resolve [!UNAVAIL=return] dns # fallback\n", + want: true, + }, + { + name: "hosts token must be the first field", + in: " hosts: resolve dns\n", + want: true, + }, + { + name: "other db line mentioning resolve is ignored", + in: "networks: resolve\nhosts: dns\n", + want: false, + }, + { + name: "only resolve, no dns", + in: "hosts: files resolve\n", + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := parseNsswitchResolveAhead([]byte(tt.in)); got != tt.want { + t.Errorf("parseNsswitchResolveAhead() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/client/internal/dns/host_windows.go b/client/internal/dns/host_windows.go index 01b7edc48..4f6ece532 100644 --- a/client/internal/dns/host_windows.go +++ b/client/internal/dns/host_windows.go @@ -7,6 +7,7 @@ import ( "io" "net/netip" "os/exec" + "slices" "strings" "syscall" "time" @@ -42,9 +43,13 @@ const ( dnsPolicyConfigConfigOptionsKey = "ConfigOptions" dnsPolicyConfigConfigOptionsValue = 0x8 - interfaceConfigPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces` - interfaceConfigNameServerKey = "NameServer" - interfaceConfigSearchListKey = "SearchList" + nrptMaxDomainsPerRule = 50 + + interfaceConfigPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces` + interfaceConfigPathV6 = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces` + interfaceConfigNameServerKey = "NameServer" + interfaceConfigDhcpNameSrvKey = "DhcpNameServer" + interfaceConfigSearchListKey = "SearchList" // Network interface DNS registration settings disableDynamicUpdateKey = "DisableDynamicUpdate" @@ -65,10 +70,11 @@ const ( ) type registryConfigurator struct { - guid string - routingAll bool - gpo bool - nrptEntryCount int + guid string + routingAll bool + gpo bool + nrptEntryCount int + origNameservers []netip.Addr } func newHostManager(wgInterface WGIface) (*registryConfigurator, error) { @@ -92,6 +98,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) } @@ -99,6 +116,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 } @@ -198,10 +307,11 @@ func (r *registryConfigurator) applyDNSConfig(config HostDNSConfig, stateManager if len(matchDomains) != 0 { count, err := r.addDNSMatchPolicy(matchDomains, config.ServerIP) + // Update count even on error to ensure cleanup covers partially created rules + r.nrptEntryCount = count if err != nil { return fmt.Errorf("add dns match policy: %w", err) } - r.nrptEntryCount = count } else { r.nrptEntryCount = 0 } @@ -239,23 +349,33 @@ func (r *registryConfigurator) addDNSSetupForAll(ip netip.Addr) error { func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr) (int, error) { // if the gpo key is present, we need to put our DNS settings there, otherwise our config might be ignored // see https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpnrpt/8cc31cb9-20cb-4140-9e85-3e08703b4745 - for i, domain := range domains { - localPath := fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i) - gpoPath := fmt.Sprintf("%s-%d", gpoDnsPolicyConfigMatchPath, i) - singleDomain := []string{domain} + // We need to batch domains into chunks and create one NRPT rule per batch. + ruleIndex := 0 + for i := 0; i < len(domains); i += nrptMaxDomainsPerRule { + end := i + nrptMaxDomainsPerRule + if end > len(domains) { + end = len(domains) + } + batchDomains := domains[i:end] - if err := r.configureDNSPolicy(localPath, singleDomain, ip); err != nil { - return i, fmt.Errorf("configure DNS Local policy for domain %s: %w", domain, err) + localPath := fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, ruleIndex) + gpoPath := fmt.Sprintf("%s-%d", gpoDnsPolicyConfigMatchPath, ruleIndex) + + if err := r.configureDNSPolicy(localPath, batchDomains, ip); err != nil { + return ruleIndex, fmt.Errorf("configure DNS Local policy for rule %d: %w", ruleIndex, err) } + // Increment immediately so the caller's cleanup path knows about this rule + ruleIndex++ + if r.gpo { - if err := r.configureDNSPolicy(gpoPath, singleDomain, ip); err != nil { - return i, fmt.Errorf("configure gpo DNS policy: %w", err) + if err := r.configureDNSPolicy(gpoPath, batchDomains, ip); err != nil { + return ruleIndex, fmt.Errorf("configure gpo DNS policy for rule %d: %w", ruleIndex-1, err) } } - log.Debugf("added NRPT entry for domain: %s", domain) + log.Debugf("added NRPT rule %d with %d domains", ruleIndex-1, len(batchDomains)) } if r.gpo { @@ -264,8 +384,8 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr } } - log.Infof("added %d separate NRPT entries. Domain list: %s", len(domains), domains) - return len(domains), nil + log.Infof("added %d NRPT rules for %d domains", ruleIndex, len(domains)) + return ruleIndex, nil } func (r *registryConfigurator) configureDNSPolicy(policyPath string, domains []string, ip netip.Addr) error { diff --git a/client/internal/dns/host_windows_test.go b/client/internal/dns/host_windows_test.go index 19496bf5a..3cd2b1bd5 100644 --- a/client/internal/dns/host_windows_test.go +++ b/client/internal/dns/host_windows_test.go @@ -12,6 +12,7 @@ import ( // TestNRPTEntriesCleanupOnConfigChange tests that old NRPT entries are properly cleaned up // when the number of match domains decreases between configuration changes. +// With batching enabled (50 domains per rule), we need enough domains to create multiple rules. func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) { if testing.Short() { t.Skip("skipping registry integration test in short mode") @@ -37,51 +38,60 @@ func TestNRPTEntriesCleanupOnConfigChange(t *testing.T) { gpo: false, } - config5 := HostDNSConfig{ - ServerIP: testIP, - Domains: []DomainConfig{ - {Domain: "domain1.com", MatchOnly: true}, - {Domain: "domain2.com", MatchOnly: true}, - {Domain: "domain3.com", MatchOnly: true}, - {Domain: "domain4.com", MatchOnly: true}, - {Domain: "domain5.com", MatchOnly: true}, - }, + // Create 125 domains which will result in 3 NRPT rules (50+50+25) + domains125 := make([]DomainConfig, 125) + for i := 0; i < 125; i++ { + domains125[i] = DomainConfig{ + Domain: fmt.Sprintf("domain%d.com", i+1), + MatchOnly: true, + } } - err = cfg.applyDNSConfig(config5, nil) + config125 := HostDNSConfig{ + ServerIP: testIP, + Domains: domains125, + } + + err = cfg.applyDNSConfig(config125, nil) require.NoError(t, err) - // Verify all 5 entries exist - for i := 0; i < 5; i++ { + // Verify 3 NRPT rules exist + assert.Equal(t, 3, cfg.nrptEntryCount, "Should create 3 NRPT rules for 125 domains") + for i := 0; i < 3; i++ { exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i)) require.NoError(t, err) - assert.True(t, exists, "Entry %d should exist after first config", i) + assert.True(t, exists, "NRPT rule %d should exist after first config", i) } - config2 := HostDNSConfig{ + // Reduce to 75 domains which will result in 2 NRPT rules (50+25) + domains75 := make([]DomainConfig, 75) + for i := 0; i < 75; i++ { + domains75[i] = DomainConfig{ + Domain: fmt.Sprintf("domain%d.com", i+1), + MatchOnly: true, + } + } + + config75 := HostDNSConfig{ ServerIP: testIP, - Domains: []DomainConfig{ - {Domain: "domain1.com", MatchOnly: true}, - {Domain: "domain2.com", MatchOnly: true}, - }, + Domains: domains75, } - err = cfg.applyDNSConfig(config2, nil) + err = cfg.applyDNSConfig(config75, nil) require.NoError(t, err) - // Verify first 2 entries exist + // Verify first 2 NRPT rules exist + assert.Equal(t, 2, cfg.nrptEntryCount, "Should create 2 NRPT rules for 75 domains") for i := 0; i < 2; i++ { exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i)) require.NoError(t, err) - assert.True(t, exists, "Entry %d should exist after second config", i) + assert.True(t, exists, "NRPT rule %d should exist after second config", i) } - // Verify entries 2-4 are cleaned up - for i := 2; i < 5; i++ { - exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i)) - require.NoError(t, err) - assert.False(t, exists, "Entry %d should NOT exist after reducing to 2 domains", i) - } + // Verify rule 2 is cleaned up + exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, 2)) + require.NoError(t, err) + assert.False(t, exists, "NRPT rule 2 should NOT exist after reducing to 75 domains") } func registryKeyExists(path string) (bool, error) { @@ -97,6 +107,106 @@ func registryKeyExists(path string) (bool, error) { } func cleanupRegistryKeys(*testing.T) { - cfg := ®istryConfigurator{nrptEntryCount: 10} + // Clean up more entries to account for batching tests with many domains + cfg := ®istryConfigurator{nrptEntryCount: 20} _ = cfg.removeDNSMatchPolicies() } + +// TestNRPTDomainBatching verifies that domains are correctly batched into NRPT rules. +func TestNRPTDomainBatching(t *testing.T) { + if testing.Short() { + t.Skip("skipping registry integration test in short mode") + } + + defer cleanupRegistryKeys(t) + cleanupRegistryKeys(t) + + testIP := netip.MustParseAddr("100.64.0.1") + + // Create a test interface registry key so updateSearchDomains doesn't fail + testGUID := "{12345678-1234-1234-1234-123456789ABC}" + interfacePath := `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\` + testGUID + testKey, _, err := registry.CreateKey(registry.LOCAL_MACHINE, interfacePath, registry.SET_VALUE) + require.NoError(t, err, "Should create test interface registry key") + testKey.Close() + defer func() { + _ = registry.DeleteKey(registry.LOCAL_MACHINE, interfacePath) + }() + + cfg := ®istryConfigurator{ + guid: testGUID, + gpo: false, + } + + testCases := []struct { + name string + domainCount int + expectedRuleCount int + }{ + { + name: "Less than 50 domains (single rule)", + domainCount: 30, + expectedRuleCount: 1, + }, + { + name: "Exactly 50 domains (single rule)", + domainCount: 50, + expectedRuleCount: 1, + }, + { + name: "51 domains (two rules)", + domainCount: 51, + expectedRuleCount: 2, + }, + { + name: "100 domains (two rules)", + domainCount: 100, + expectedRuleCount: 2, + }, + { + name: "125 domains (three rules: 50+50+25)", + domainCount: 125, + expectedRuleCount: 3, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Clean up before each subtest + cleanupRegistryKeys(t) + + // Generate domains + domains := make([]DomainConfig, tc.domainCount) + for i := 0; i < tc.domainCount; i++ { + domains[i] = DomainConfig{ + Domain: fmt.Sprintf("domain%d.com", i+1), + MatchOnly: true, + } + } + + config := HostDNSConfig{ + ServerIP: testIP, + Domains: domains, + } + + err := cfg.applyDNSConfig(config, nil) + require.NoError(t, err) + + // Verify that exactly expectedRuleCount rules were created + assert.Equal(t, tc.expectedRuleCount, cfg.nrptEntryCount, + "Should create %d NRPT rules for %d domains", tc.expectedRuleCount, tc.domainCount) + + // Verify all expected rules exist + for i := 0; i < tc.expectedRuleCount; i++ { + exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, i)) + require.NoError(t, err) + assert.True(t, exists, "NRPT rule %d should exist", i) + } + + // Verify no extra rules were created + exists, err := registryKeyExists(fmt.Sprintf("%s-%d", dnsPolicyConfigMatchPath, tc.expectedRuleCount)) + require.NoError(t, err) + assert.False(t, exists, "No NRPT rule should exist at index %d", tc.expectedRuleCount) + }) + } +} diff --git a/client/internal/dns/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/local/local.go b/client/internal/dns/local/local.go index bac7875ec..fef35fd41 100644 --- a/client/internal/dns/local/local.go +++ b/client/internal/dns/local/local.go @@ -1,33 +1,133 @@ package local import ( + "context" + "errors" "fmt" + "net" + "net/netip" + "os" "slices" "strings" "sync" + "time" "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" nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/shared/management/domain" ) +const externalResolutionTimeout = 4 * time.Second + +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 domains map[domain.Domain]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 } func NewResolver() *Resolver { + ctx, cancel := context.WithCancel(context.Background()) return &Resolver{ - records: make(map[dns.Question][]dns.RR), - domains: make(map[domain.Domain]struct{}), + 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 } @@ -37,71 +137,174 @@ func (d *Resolver) String() string { return fmt.Sprintf("LocalResolver [%d records]", len(d.records)) } -func (d *Resolver) Stop() {} +func (d *Resolver) Stop() { + if d.cancel != nil { + d.cancel() + } + + d.mu.Lock() + defer d.mu.Unlock() + + clear(d.records) + clear(d.domains) + clear(d.zones) +} // ID returns the unique handler ID func (d *Resolver) ID() types.HandlerID { return "local-resolver" } -func (d *Resolver) ProbeAvailability() {} - // ServeDNS handles a DNS request func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { + logger := log.WithFields(log.Fields{ + "request_id": resutil.GetRequestID(w), + "dns_id": fmt.Sprintf("%04x", r.Id), + }) + if len(r.Question) == 0 { - log.Debugf("received local resolver request with no question") + logger.Debug("received local resolver request with no question") return } question := r.Question[0] question.Name = strings.ToLower(dns.Fqdn(question.Name)) - log.Tracef("received local question: domain=%s type=%v class=%v", r.Question[0].Name, question.Qtype, question.Qclass) - replyMessage := &dns.Msg{} replyMessage.SetReply(r) replyMessage.RecursionAvailable = true - // lookup all records matching the question - records := d.lookupRecords(question) - if len(records) > 0 { - replyMessage.Rcode = dns.RcodeSuccess - replyMessage.Answer = append(replyMessage.Answer, records...) - } else { - // Check if we have any records for this domain name with different types - if d.hasRecordsForDomain(domain.Domain(question.Name)) { - replyMessage.Rcode = dns.RcodeSuccess // NOERROR with 0 records - } else { - replyMessage.Rcode = dns.RcodeNameError // NXDOMAIN - } + 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) + + if replyMessage.Rcode == dns.RcodeNameError && d.shouldFallthrough(question.Name) { + d.continueToNext(logger, w, r) + return } if err := w.WriteMsg(replyMessage); err != nil { - log.Warnf("failed to write the local resolver response: %v", err) + logger.Warnf("failed to write the local resolver response: %v", err) + } +} + +// determineRcode returns the appropriate DNS response code. +// Per RFC 6604, CNAME chains should return the rcode of the final target resolution, +// even if CNAME records are included in the answer. +func (d *Resolver) determineRcode(question dns.Question, result lookupResult) int { + // Use the rcode from lookup - this properly handles CNAME chains where + // the target may be NXDOMAIN or SERVFAIL even though we have CNAME records + if result.rcode != 0 { + return result.rcode + } + + // No records found, but domain exists with different record types (NODATA) + if d.hasRecordsForDomain(domain.Domain(question.Name), question.Qtype) { + return dns.RcodeSuccess + } + + return dns.RcodeNameError +} + +// findZone finds the matching zone for a query name using reverse suffix lookup. +// Returns (nonAuthoritative, found). This is O(k) where k = number of labels in qname. +func (d *Resolver) findZone(qname string) (nonAuthoritative bool, found bool) { + qname = strings.ToLower(dns.Fqdn(qname)) + for { + if nonAuth, ok := d.zones[domain.Domain(qname)]; ok { + return nonAuth, true + } + // Move to parent domain + idx := strings.Index(qname, ".") + if idx == -1 || idx == len(qname)-1 { + return false, false + } + qname = qname[idx+1:] + } +} + +// shouldFallthrough checks if the query should fallthrough to the next handler. +// Returns true if the queried name belongs to a non-authoritative zone. +func (d *Resolver) shouldFallthrough(qname string) bool { + d.mu.RLock() + defer d.mu.RUnlock() + + nonAuth, found := d.findZone(qname) + return found && nonAuth +} + +func (d *Resolver) continueToNext(logger *log.Entry, w dns.ResponseWriter, r *dns.Msg) { + resp := &dns.Msg{} + resp.SetRcode(r, dns.RcodeNameError) + resp.MsgHdr.Zero = true + if err := w.WriteMsg(resp); err != nil { + logger.Warnf("failed to write continue signal: %v", err) } } // hasRecordsForDomain checks if any records exist for the given domain name regardless of type -func (d *Resolver) hasRecordsForDomain(domainName domain.Domain) bool { +func (d *Resolver) hasRecordsForDomain(domainName domain.Domain, qType uint16) bool { d.mu.RLock() defer d.mu.RUnlock() _, exists := d.domains[domainName] + if !exists && supportsWildcard(qType) { + testWild := transformDomainToWildcard(string(domainName)) + _, exists = d.domains[domain.Domain(testWild)] + } return exists } +// isInManagedZone checks if the given name falls within any of our managed zones. +// This is used to avoid unnecessary external resolution for CNAME targets that +// are within zones we manage - if we don't have a record for it, it doesn't exist. +// Caller must NOT hold the lock. +func (d *Resolver) isInManagedZone(name string) bool { + d.mu.RLock() + defer d.mu.RUnlock() + + _, found := d.findZone(name) + return found +} + +// lookupResult contains the result of a DNS lookup operation. +type lookupResult struct { + records []dns.RR + rcode int + hasExternalData bool +} + // lookupRecords fetches *all* DNS records matching the first question in r. -func (d *Resolver) lookupRecords(question dns.Question) []dns.RR { +func (d *Resolver) lookupRecords(logger *log.Entry, question dns.Question) lookupResult { d.mu.RLock() records, found := d.records[question] + usingWildcard := false + wildQuestion := transformToWildcard(question) + // RFC 4592 section 2.2.1: wildcard only matches if the name does NOT exist in the zone. + // If the domain exists with any record type, return NODATA instead of wildcard match. + if !found && supportsWildcard(question.Qtype) { + if _, domainExists := d.domains[domain.Domain(question.Name)]; !domainExists { + records, found = d.records[wildQuestion] + usingWildcard = found + } + } if !found { d.mu.RUnlock() // alternatively check if we have a cname if question.Qtype != dns.TypeCNAME { - question.Qtype = dns.TypeCNAME - return d.lookupRecords(question) + cnameQuestion := dns.Question{ + Name: question.Name, + Qtype: dns.TypeCNAME, + Qclass: question.Qclass, + } + return d.lookupCNAMEChain(logger, cnameQuestion, question.Qtype) } - return nil + return lookupResult{rcode: dns.RcodeNameError} } recordsCopy := slices.Clone(records) @@ -110,29 +313,336 @@ func (d *Resolver) lookupRecords(question dns.Question) []dns.RR { // if there's more than one record, rotate them (round-robin) if len(recordsCopy) > 1 { d.mu.Lock() - records = d.records[question] + q := question + if usingWildcard { + q = wildQuestion + } + records = d.records[q] if len(records) > 1 { first := records[0] records = append(records[1:], first) - d.records[question] = records + d.records[q] = records } d.mu.Unlock() } - return recordsCopy + if usingWildcard { + return responseFromWildRecords(question.Name, wildQuestion.Name, recordsCopy) + } + + return lookupResult{records: recordsCopy, rcode: dns.RcodeSuccess} } -func (d *Resolver) Update(update []nbdns.SimpleRecord) { +func transformToWildcard(question dns.Question) dns.Question { + wildQuestion := question + wildQuestion.Name = transformDomainToWildcard(wildQuestion.Name) + return wildQuestion +} + +func transformDomainToWildcard(domain string) string { + s := strings.Split(domain, ".") + s[0] = "*" + return strings.Join(s, ".") +} + +func supportsWildcard(queryType uint16) bool { + return queryType != dns.TypeNS && queryType != dns.TypeSOA +} + +func responseFromWildRecords(originalName, wildName string, wildRecords []dns.RR) lookupResult { + records := make([]dns.RR, len(wildRecords)) + for i, record := range wildRecords { + copiedRecord := dns.Copy(record) + copiedRecord.Header().Name = originalName + records[i] = copiedRecord + } + + return lookupResult{records: records, rcode: dns.RcodeSuccess} +} + +// lookupCNAMEChain follows a CNAME chain and returns the CNAME records along with +// the final resolved record of the requested type. This is required for musl libc +// compatibility, which expects the full answer chain rather than just the CNAME. +func (d *Resolver) lookupCNAMEChain(logger *log.Entry, cnameQuestion dns.Question, targetType uint16) lookupResult { + const maxDepth = 8 + var chain []dns.RR + + for range maxDepth { + cnameRecords := d.getRecords(cnameQuestion) + if len(cnameRecords) == 0 && supportsWildcard(targetType) { + wildQuestion := transformToWildcard(cnameQuestion) + if wildRecords := d.getRecords(wildQuestion); len(wildRecords) > 0 { + cnameRecords = responseFromWildRecords(cnameQuestion.Name, wildQuestion.Name, wildRecords).records + } + } + + if len(cnameRecords) == 0 { + break + } + + chain = append(chain, cnameRecords...) + + cname, ok := cnameRecords[0].(*dns.CNAME) + if !ok { + break + } + + targetName := strings.ToLower(cname.Target) + targetResult := d.resolveCNAMETarget(logger, targetName, targetType, cnameQuestion.Qclass) + + // keep following chain + if targetResult.rcode == -1 { + cnameQuestion = dns.Question{Name: targetName, Qtype: dns.TypeCNAME, Qclass: cnameQuestion.Qclass} + continue + } + + return d.buildChainResult(chain, targetResult) + } + + if len(chain) > 0 { + return lookupResult{records: chain, rcode: dns.RcodeSuccess} + } + return lookupResult{rcode: dns.RcodeSuccess} +} + +// buildChainResult combines CNAME chain records with the target resolution result. +// Per RFC 6604, the final rcode is propagated through the chain. +func (d *Resolver) buildChainResult(chain []dns.RR, target lookupResult) lookupResult { + records := chain + if len(target.records) > 0 { + records = append(records, target.records...) + } + + // preserve hasExternalData for SERVFAIL so caller knows the error came from upstream + if target.hasExternalData && target.rcode == dns.RcodeServerFailure { + return lookupResult{ + records: records, + rcode: dns.RcodeServerFailure, + hasExternalData: true, + } + } + + return lookupResult{ + records: records, + rcode: target.rcode, + hasExternalData: target.hasExternalData, + } +} + +// resolveCNAMETarget attempts to resolve a CNAME target name. +// Returns rcode=-1 to signal "keep following the chain". +func (d *Resolver) resolveCNAMETarget(logger *log.Entry, targetName string, targetType uint16, qclass uint16) lookupResult { + if records := d.getRecords(dns.Question{Name: targetName, Qtype: targetType, Qclass: qclass}); len(records) > 0 { + return lookupResult{records: records, rcode: dns.RcodeSuccess} + } + + // another CNAME, keep following + if d.hasRecord(dns.Question{Name: targetName, Qtype: dns.TypeCNAME, Qclass: qclass}) { + return lookupResult{rcode: -1} + } + + // domain exists locally but not this record type (NODATA) + if d.hasRecordsForDomain(domain.Domain(targetName), targetType) { + return lookupResult{rcode: dns.RcodeSuccess} + } + + // in our zone but doesn't exist (NXDOMAIN) + if d.isInManagedZone(targetName) { + return lookupResult{rcode: dns.RcodeNameError} + } + + return d.resolveExternal(logger, targetName, targetType) +} + +func (d *Resolver) getRecords(q dns.Question) []dns.RR { + d.mu.RLock() + defer d.mu.RUnlock() + return d.records[q] +} + +func (d *Resolver) hasRecord(q dns.Question) bool { + d.mu.RLock() + defer d.mu.RUnlock() + _, ok := d.records[q] + return ok +} + +// resolveExternal resolves a domain name using the system resolver. +// This is used to resolve CNAME targets that point outside our local zone, +// which is required for musl libc compatibility (musl expects complete answers). +func (d *Resolver) resolveExternal(logger *log.Entry, name string, qtype uint16) lookupResult { + network := resutil.NetworkForQtype(qtype) + if network == "" { + return lookupResult{rcode: dns.RcodeNotImplemented} + } + + resolver := d.resolver + if resolver == nil { + resolver = net.DefaultResolver + } + + ctx, cancel := context.WithTimeout(d.ctx, externalResolutionTimeout) + defer cancel() + + result := resutil.LookupIP(ctx, resolver, network, name, qtype) + if result.Err != nil { + d.logDNSError(logger, name, qtype, result.Err) + return lookupResult{rcode: result.Rcode, hasExternalData: true} + } + + return lookupResult{ + records: resutil.IPsToRRs(name, result.IPs, 60), + rcode: dns.RcodeSuccess, + hasExternalData: true, + } +} + +// logDNSError logs DNS resolution errors for debugging. +func (d *Resolver) logDNSError(logger *log.Entry, hostname string, qtype uint16, err error) { + qtypeName := dns.TypeToString[qtype] + + var dnsErr *net.DNSError + if !errors.As(err, &dnsErr) { + logger.Debugf("DNS resolution failed for %s type %s: %v", hostname, qtypeName, err) + return + } + + if dnsErr.IsNotFound { + logger.Tracef("DNS target not found: %s type %s", hostname, qtypeName) + return + } + + if dnsErr.Server != "" { + logger.Debugf("DNS resolution failed for %s type %s server=%s: %v", hostname, qtypeName, dnsErr.Server, err) + } else { + logger.Debugf("DNS resolution failed for %s type %s: %v", hostname, qtypeName, err) + } +} + +// 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) + clear(d.records) + clear(d.domains) + clear(d.zones) - for _, rec := range update { - if err := d.registerRecord(rec); err != nil { - log.Warnf("failed to register the record (%s): %v", rec, err) - continue + for _, zone := range customZones { + zoneDomain := domain.Domain(strings.ToLower(dns.Fqdn(zone.Domain))) + d.zones[zoneDomain] = zone.NonAuthoritative + + for _, rec := range zone.Records { + if err := d.registerRecord(rec); err != nil { + log.Warnf("failed to register the record (%s): %v", rec, err) + } } } } diff --git a/client/internal/dns/local/local_test.go b/client/internal/dns/local/local_test.go index 8b13b69ff..89e896c0a 100644 --- a/client/internal/dns/local/local_test.go +++ b/client/internal/dns/local/local_test.go @@ -1,8 +1,14 @@ package local import ( + "context" + "fmt" + "net" + "net/netip" "strings" + "sync" "testing" + "time" "github.com/miekg/dns" "github.com/stretchr/testify/assert" @@ -12,6 +18,33 @@ import ( nbdns "github.com/netbirdio/netbird/dns" ) +// mockResolver implements resolver for testing +type mockResolver struct { + lookupFunc func(ctx context.Context, network, host string) ([]netip.Addr, error) +} + +func (m *mockResolver) LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) { + if m.lookupFunc != nil { + return m.lookupFunc(ctx, network, host) + } + 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.", @@ -29,6 +62,24 @@ func TestLocalResolver_ServeDNS(t *testing.T) { RData: "www.netbird.io", } + wild := "wild.netbird.cloud." + + recordWild := nbdns.SimpleRecord{ + Name: "*." + wild, + Type: 1, + Class: nbdns.DefaultClass, + TTL: 300, + RData: "1.2.3.4", + } + + specificRecord := nbdns.SimpleRecord{ + Name: "existing." + wild, + Type: 1, + Class: nbdns.DefaultClass, + TTL: 300, + RData: "5.6.7.8", + } + testCases := []struct { name string inputRecord nbdns.SimpleRecord @@ -51,12 +102,23 @@ func TestLocalResolver_ServeDNS(t *testing.T) { inputMSG: new(dns.Msg).SetQuestion("not.found.com", dns.TypeA), responseShouldBeNil: true, }, + { + name: "Should Resolve A Wild Record", + inputRecord: recordWild, + inputMSG: new(dns.Msg).SetQuestion("test."+wild, dns.TypeA), + }, + { + name: "Should Resolve A more specific Record", + inputRecord: specificRecord, + inputMSG: new(dns.Msg).SetQuestion(specificRecord.Name, dns.TypeA), + }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { resolver := NewResolver() _ = resolver.RegisterRecord(testCase.inputRecord) + _ = resolver.RegisterRecord(recordWild) var responseMSG *dns.Msg responseWriter := &test.MockResponseWriter{ WriteMsgFunc: func(m *dns.Msg) error { @@ -75,7 +137,7 @@ func TestLocalResolver_ServeDNS(t *testing.T) { } answerString := responseMSG.Answer[0].String() - if !strings.Contains(answerString, testCase.inputRecord.Name) { + if !strings.Contains(answerString, testCase.inputMSG.Question[0].Name) { t.Fatalf("answer doesn't contain the same domain name: \nWant: %s\nGot:%s", testCase.name, answerString) } if !strings.Contains(answerString, dns.Type(testCase.inputRecord.Type).String()) { @@ -106,11 +168,11 @@ func TestLocalResolver_Update_StaleRecord(t *testing.T) { resolver := NewResolver() - update1 := []nbdns.SimpleRecord{record1} - update2 := []nbdns.SimpleRecord{record2} + zone1 := []nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{record1}}} + zone2 := []nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{record2}}} // Apply first update - resolver.Update(update1) + resolver.Update(zone1) // Verify first update resolver.mu.RLock() @@ -122,7 +184,7 @@ func TestLocalResolver_Update_StaleRecord(t *testing.T) { assert.Contains(t, rrSlice1[0].String(), record1.RData, "Record after first update should be %s", record1.RData) // Apply second update - resolver.Update(update2) + resolver.Update(zone2) // Verify second update resolver.mu.RLock() @@ -151,10 +213,10 @@ func TestLocalResolver_MultipleRecords_SameQuestion(t *testing.T) { Name: recordName, Type: int(recordType), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.2", } - update := []nbdns.SimpleRecord{record1, record2} + zones := []nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{record1, record2}}} // Apply update with both records - resolver.Update(update) + resolver.Update(zones) // Create question that matches both records question := dns.Question{ @@ -195,10 +257,10 @@ func TestLocalResolver_RecordRotation(t *testing.T) { Name: recordName, Type: int(recordType), Class: nbdns.DefaultClass, TTL: 300, RData: "192.168.1.3", } - update := []nbdns.SimpleRecord{record1, record2, record3} + zones := []nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{record1, record2, record3}}} // Apply update with all three records - resolver.Update(update) + resolver.Update(zones) msg := new(dns.Msg).SetQuestion(recordName, recordType) @@ -264,7 +326,7 @@ func TestLocalResolver_CaseInsensitiveMatching(t *testing.T) { } // Update resolver with the records - resolver.Update([]nbdns.SimpleRecord{lowerCaseRecord, mixedCaseRecord}) + resolver.Update([]nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{lowerCaseRecord, mixedCaseRecord}}}) testCases := []struct { name string @@ -379,7 +441,7 @@ func TestLocalResolver_CNAMEFallback(t *testing.T) { } // Update resolver with both records - resolver.Update([]nbdns.SimpleRecord{cnameRecord, targetRecord}) + resolver.Update([]nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{cnameRecord, targetRecord}}}) testCases := []struct { name string @@ -476,6 +538,20 @@ func TestLocalResolver_CNAMEFallback(t *testing.T) { // with 0 records instead of NXDOMAIN func TestLocalResolver_NoErrorWithDifferentRecordType(t *testing.T) { resolver := NewResolver() + // Mock external resolver for CNAME target resolution + resolver.resolver = &mockResolver{ + lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) { + if host == "target.example.com." { + if network == "ip4" { + return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil + } + if network == "ip6" { + return []netip.Addr{netip.MustParseAddr("2606:2800:220:1:248:1893:25c8:1946")}, nil + } + } + return nil, &net.DNSError{IsNotFound: true, Name: host} + }, + } recordA := nbdns.SimpleRecord{ Name: "example.netbird.cloud.", @@ -493,7 +569,7 @@ func TestLocalResolver_NoErrorWithDifferentRecordType(t *testing.T) { RData: "target.example.com.", } - resolver.Update([]nbdns.SimpleRecord{recordA, recordCNAME}) + resolver.Update([]nbdns.CustomZone{{Domain: "netbird.cloud.", Records: []nbdns.SimpleRecord{recordA, recordCNAME}}}) testCases := []struct { name string @@ -582,3 +658,2134 @@ func TestLocalResolver_NoErrorWithDifferentRecordType(t *testing.T) { }) } } + +// TestLocalResolver_CNAMEChainResolution tests comprehensive CNAME chain following +func TestLocalResolver_CNAMEChainResolution(t *testing.T) { + t.Run("simple internal CNAME chain", func(t *testing.T) { + resolver := NewResolver() + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "alias.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."}, + {Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "192.168.1.1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("alias.example.com.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 2) + + cname, ok := resp.Answer[0].(*dns.CNAME) + require.True(t, ok) + assert.Equal(t, "target.example.com.", cname.Target) + + a, ok := resp.Answer[1].(*dns.A) + require.True(t, ok) + assert.Equal(t, "192.168.1.1", a.A.String()) + }) + + t.Run("multi-hop CNAME chain", func(t *testing.T) { + resolver := NewResolver() + resolver.Update([]nbdns.CustomZone{{ + Domain: "test.", + Records: []nbdns.SimpleRecord{ + {Name: "hop1.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "hop2.test."}, + {Name: "hop2.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "hop3.test."}, + {Name: "hop3.test.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("hop1.test.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 3) + }) + + t.Run("CNAME to non-existent internal target returns only CNAME", func(t *testing.T) { + resolver := NewResolver() + resolver.Update([]nbdns.CustomZone{{ + Domain: "test.", + Records: []nbdns.SimpleRecord{ + {Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "nonexistent.test."}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Len(t, resp.Answer, 1) + _, ok := resp.Answer[0].(*dns.CNAME) + assert.True(t, ok) + }) +} + +// TestLocalResolver_CNAMEMaxDepth tests the maximum depth limit for CNAME chains +func TestLocalResolver_CNAMEMaxDepth(t *testing.T) { + t.Run("chain at max depth resolves", func(t *testing.T) { + resolver := NewResolver() + var records []nbdns.SimpleRecord + // Create chain of 7 CNAMEs (under max of 8) + for i := 1; i <= 7; i++ { + records = append(records, nbdns.SimpleRecord{ + Name: fmt.Sprintf("hop%d.test.", i), + Type: int(dns.TypeCNAME), + Class: nbdns.DefaultClass, + TTL: 300, + RData: fmt.Sprintf("hop%d.test.", i+1), + }) + } + records = append(records, nbdns.SimpleRecord{ + Name: "hop8.test.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.10.10", + }) + + resolver.Update([]nbdns.CustomZone{{Domain: "test.", Records: records}}) + + msg := new(dns.Msg).SetQuestion("hop1.test.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 8) + }) + + t.Run("chain exceeding max depth stops", func(t *testing.T) { + resolver := NewResolver() + var records []nbdns.SimpleRecord + // Create chain of 10 CNAMEs (exceeds max of 8) + for i := 1; i <= 10; i++ { + records = append(records, nbdns.SimpleRecord{ + Name: fmt.Sprintf("deep%d.test.", i), + Type: int(dns.TypeCNAME), + Class: nbdns.DefaultClass, + TTL: 300, + RData: fmt.Sprintf("deep%d.test.", i+1), + }) + } + records = append(records, nbdns.SimpleRecord{ + Name: "deep11.test.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.10.10", + }) + + resolver.Update([]nbdns.CustomZone{{Domain: "test.", Records: records}}) + + msg := new(dns.Msg).SetQuestion("deep1.test.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + // Should NOT have the final A record (chain too deep) + assert.LessOrEqual(t, len(resp.Answer), 8) + }) + + t.Run("circular CNAME is protected by max depth", func(t *testing.T) { + resolver := NewResolver() + resolver.Update([]nbdns.CustomZone{{ + Domain: "test.", + Records: []nbdns.SimpleRecord{ + {Name: "loop1.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "loop2.test."}, + {Name: "loop2.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "loop1.test."}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("loop1.test.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + assert.LessOrEqual(t, len(resp.Answer), 8) + }) +} + +// TestLocalResolver_ExternalCNAMEResolution tests CNAME resolution to external domains +func TestLocalResolver_ExternalCNAMEResolution(t *testing.T) { + t.Run("CNAME to external domain resolves via external resolver", func(t *testing.T) { + resolver := NewResolver() + resolver.resolver = &mockResolver{ + lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) { + if host == "external.example.com." && network == "ip4" { + return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil + } + return nil, nil + }, + } + + resolver.Update([]nbdns.CustomZone{{ + Domain: "test.", + Records: []nbdns.SimpleRecord{ + {Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Len(t, resp.Answer, 2, "Should have CNAME + A record") + + cname, ok := resp.Answer[0].(*dns.CNAME) + require.True(t, ok) + assert.Equal(t, "external.example.com.", cname.Target) + + a, ok := resp.Answer[1].(*dns.A) + require.True(t, ok) + assert.Equal(t, "93.184.216.34", a.A.String()) + }) + + t.Run("CNAME to external domain resolves IPv6", func(t *testing.T) { + resolver := NewResolver() + resolver.resolver = &mockResolver{ + lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) { + if host == "external.example.com." && network == "ip6" { + return []netip.Addr{netip.MustParseAddr("2606:2800:220:1:248:1893:25c8:1946")}, nil + } + return nil, nil + }, + } + + resolver.Update([]nbdns.CustomZone{{ + Domain: "test.", + Records: []nbdns.SimpleRecord{ + {Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeAAAA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Len(t, resp.Answer, 2, "Should have CNAME + AAAA record") + + cname, ok := resp.Answer[0].(*dns.CNAME) + require.True(t, ok) + assert.Equal(t, "external.example.com.", cname.Target) + + aaaa, ok := resp.Answer[1].(*dns.AAAA) + require.True(t, ok) + assert.Equal(t, "2606:2800:220:1:248:1893:25c8:1946", aaaa.AAAA.String()) + }) + + t.Run("concurrent external resolution", func(t *testing.T) { + resolver := NewResolver() + resolver.resolver = &mockResolver{ + lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) { + if host == "external.example.com." && network == "ip4" { + return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil + } + return nil, nil + }, + } + + resolver.Update([]nbdns.CustomZone{{ + Domain: "test.", + Records: []nbdns.SimpleRecord{ + {Name: "concurrent.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."}, + }, + }}) + + var wg sync.WaitGroup + results := make([]*dns.Msg, 10) + + for i := 0; i < 10; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + msg := new(dns.Msg).SetQuestion("concurrent.test.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + results[idx] = resp + }(i) + } + wg.Wait() + + for i, resp := range results { + require.NotNil(t, resp, "Response %d should not be nil", i) + require.Len(t, resp.Answer, 2, "Response %d should have CNAME + A", i) + } + }) +} + +// TestLocalResolver_ZoneManagement tests zone-aware CNAME resolution +func TestLocalResolver_ZoneManagement(t *testing.T) { + t.Run("Update sets zones correctly", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{ + {Domain: "example.com.", Records: []nbdns.SimpleRecord{ + {Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + }}, + {Domain: "test.local."}, + }) + + assert.True(t, resolver.isInManagedZone("host.example.com.")) + assert.True(t, resolver.isInManagedZone("other.example.com.")) + assert.True(t, resolver.isInManagedZone("sub.test.local.")) + assert.False(t, resolver.isInManagedZone("external.com.")) + }) + + t.Run("isInManagedZone case insensitive", func(t *testing.T) { + resolver := NewResolver() + resolver.Update([]nbdns.CustomZone{{Domain: "Example.COM."}}) + + assert.True(t, resolver.isInManagedZone("host.example.com.")) + assert.True(t, resolver.isInManagedZone("HOST.EXAMPLE.COM.")) + }) + + t.Run("Update clears zones", func(t *testing.T) { + resolver := NewResolver() + resolver.Update([]nbdns.CustomZone{{Domain: "example.com."}}) + assert.True(t, resolver.isInManagedZone("host.example.com.")) + + resolver.Update(nil) + assert.False(t, resolver.isInManagedZone("host.example.com.")) + }) +} + +// TestLocalResolver_CNAMEZoneAwareResolution tests CNAME resolution with zone awareness +func TestLocalResolver_CNAMEZoneAwareResolution(t *testing.T) { + t.Run("CNAME target in managed zone returns NXDOMAIN per RFC 6604", func(t *testing.T) { + resolver := NewResolver() + resolver.Update([]nbdns.CustomZone{{ + Domain: "myzone.test.", + Records: []nbdns.SimpleRecord{ + {Name: "alias.myzone.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "nonexistent.myzone.test."}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("alias.myzone.test.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + assert.Equal(t, dns.RcodeNameError, resp.Rcode, "Should return NXDOMAIN") + require.Len(t, resp.Answer, 1, "Should include CNAME in answer") + }) + + t.Run("CNAME to external domain skips zone check", func(t *testing.T) { + resolver := NewResolver() + resolver.resolver = &mockResolver{ + lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) { + if host == "external.other.com." && network == "ip4" { + return []netip.Addr{netip.MustParseAddr("203.0.113.1")}, nil + } + return nil, nil + }, + } + + resolver.Update([]nbdns.CustomZone{{ + Domain: "myzone.test.", + Records: []nbdns.SimpleRecord{ + {Name: "alias.myzone.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.other.com."}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("alias.myzone.test.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 2, "Should have CNAME + A from external resolution") + }) + + t.Run("CNAME target exists with different type returns NODATA not NXDOMAIN", func(t *testing.T) { + resolver := NewResolver() + // CNAME points to target that has A but no AAAA - query for AAAA should be NODATA + resolver.Update([]nbdns.CustomZone{{ + Domain: "myzone.test.", + Records: []nbdns.SimpleRecord{ + {Name: "alias.myzone.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.myzone.test."}, + {Name: "target.myzone.test.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "1.1.1.1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("alias.myzone.test.", dns.TypeAAAA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA (success), not NXDOMAIN") + require.Len(t, resp.Answer, 1, "Should have only CNAME, no AAAA") + _, ok := resp.Answer[0].(*dns.CNAME) + assert.True(t, ok, "Answer should be CNAME record") + }) + + t.Run("external CNAME target exists but no AAAA records (NODATA)", func(t *testing.T) { + resolver := NewResolver() + resolver.resolver = &mockResolver{ + lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) { + if host == "external.example.com." { + if network == "ip6" { + // No AAAA records + return nil, &net.DNSError{IsNotFound: true, Name: host} + } + if network == "ip4" { + // But A records exist - domain exists + return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil + } + } + return nil, &net.DNSError{IsNotFound: true, Name: host} + }, + } + + resolver.Update([]nbdns.CustomZone{{ + Domain: "test.", + Records: []nbdns.SimpleRecord{ + {Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeAAAA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA (success), not NXDOMAIN") + require.Len(t, resp.Answer, 1, "Should have only CNAME") + _, ok := resp.Answer[0].(*dns.CNAME) + assert.True(t, ok, "Answer should be CNAME record") + }) + + // Table-driven test for all external resolution outcomes + externalCases := []struct { + name string + lookupFunc func(context.Context, string, string) ([]netip.Addr, error) + expectedRcode int + expectedAnswer int + }{ + { + name: "external NXDOMAIN (both A and AAAA not found)", + lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) { + return nil, &net.DNSError{IsNotFound: true, Name: host} + }, + expectedRcode: dns.RcodeNameError, + expectedAnswer: 1, // CNAME only + }, + { + name: "external SERVFAIL (temporary error)", + lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) { + return nil, &net.DNSError{IsTemporary: true, Name: host} + }, + expectedRcode: dns.RcodeServerFailure, + expectedAnswer: 1, // CNAME only + }, + { + name: "external SERVFAIL (timeout)", + lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) { + return nil, &net.DNSError{IsTimeout: true, Name: host} + }, + expectedRcode: dns.RcodeServerFailure, + expectedAnswer: 1, // CNAME only + }, + { + name: "external SERVFAIL (generic error)", + lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) { + return nil, fmt.Errorf("connection refused") + }, + expectedRcode: dns.RcodeServerFailure, + expectedAnswer: 1, // CNAME only + }, + { + name: "external success with IPs", + lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) { + if network == "ip4" { + return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil + } + return nil, &net.DNSError{IsNotFound: true, Name: host} + }, + expectedRcode: dns.RcodeSuccess, + expectedAnswer: 2, // CNAME + A + }, + } + + for _, tc := range externalCases { + t.Run(tc.name, func(t *testing.T) { + resolver := NewResolver() + resolver.resolver = &mockResolver{lookupFunc: tc.lookupFunc} + + resolver.Update([]nbdns.CustomZone{{ + Domain: "test.", + Records: []nbdns.SimpleRecord{ + {Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + assert.Equal(t, tc.expectedRcode, resp.Rcode, "rcode mismatch") + assert.Len(t, resp.Answer, tc.expectedAnswer, "answer count mismatch") + if tc.expectedAnswer > 0 { + _, ok := resp.Answer[0].(*dns.CNAME) + assert.True(t, ok, "first answer should be CNAME") + } + }) + } +} + +// TestLocalResolver_Fallthrough verifies that non-authoritative zones +// trigger fallthrough (Zero bit set) when no records match +func TestLocalResolver_Fallthrough(t *testing.T) { + resolver := NewResolver() + + record := nbdns.SimpleRecord{ + Name: "existing.custom.zone.", + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: 300, + RData: "10.0.0.1", + } + + testCases := []struct { + name string + zones []nbdns.CustomZone + queryName string + expectFallthrough bool + expectRecord bool + }{ + { + name: "Authoritative zone returns NXDOMAIN without fallthrough", + zones: []nbdns.CustomZone{{ + Domain: "custom.zone.", + Records: []nbdns.SimpleRecord{record}, + }}, + queryName: "nonexistent.custom.zone.", + expectFallthrough: false, + expectRecord: false, + }, + { + name: "Non-authoritative zone triggers fallthrough", + zones: []nbdns.CustomZone{{ + Domain: "custom.zone.", + Records: []nbdns.SimpleRecord{record}, + NonAuthoritative: true, + }}, + queryName: "nonexistent.custom.zone.", + expectFallthrough: true, + expectRecord: false, + }, + { + name: "Record found in non-authoritative zone returns normally", + zones: []nbdns.CustomZone{{ + Domain: "custom.zone.", + Records: []nbdns.SimpleRecord{record}, + NonAuthoritative: true, + }}, + queryName: "existing.custom.zone.", + expectFallthrough: false, + expectRecord: true, + }, + { + name: "Record found in authoritative zone returns normally", + zones: []nbdns.CustomZone{{ + Domain: "custom.zone.", + Records: []nbdns.SimpleRecord{record}, + }}, + queryName: "existing.custom.zone.", + expectFallthrough: false, + expectRecord: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resolver.Update(tc.zones) + + var responseMSG *dns.Msg + responseWriter := &test.MockResponseWriter{ + WriteMsgFunc: func(m *dns.Msg) error { + responseMSG = m + return nil + }, + } + + msg := new(dns.Msg).SetQuestion(tc.queryName, dns.TypeA) + resolver.ServeDNS(responseWriter, msg) + + require.NotNil(t, responseMSG, "Should have received a response") + + if tc.expectFallthrough { + assert.True(t, responseMSG.MsgHdr.Zero, "Zero bit should be set for fallthrough") + assert.Equal(t, dns.RcodeNameError, responseMSG.Rcode, "Should return NXDOMAIN") + } else { + assert.False(t, responseMSG.MsgHdr.Zero, "Zero bit should not be set") + } + + if tc.expectRecord { + assert.Greater(t, len(responseMSG.Answer), 0, "Should have answer records") + assert.Equal(t, dns.RcodeSuccess, responseMSG.Rcode) + } + }) + } +} + +// TestLocalResolver_AuthoritativeFlag tests the AA flag behavior +func TestLocalResolver_AuthoritativeFlag(t *testing.T) { + t.Run("direct record lookup is authoritative", func(t *testing.T) { + resolver := NewResolver() + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("host.example.com.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + assert.True(t, resp.Authoritative) + }) + + t.Run("external resolution is not authoritative", func(t *testing.T) { + resolver := NewResolver() + resolver.resolver = &mockResolver{ + lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) { + if host == "external.example.com." && network == "ip4" { + return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil + } + return nil, nil + }, + } + + resolver.Update([]nbdns.CustomZone{{ + Domain: "test.", + Records: []nbdns.SimpleRecord{ + {Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Len(t, resp.Answer, 2) + assert.False(t, resp.Authoritative) + }) +} + +// TestLocalResolver_Stop tests cleanup on GracefullyStop +func TestLocalResolver_Stop(t *testing.T) { + t.Run("GracefullyStop clears all state", func(t *testing.T) { + resolver := NewResolver() + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + }, + }}) + + resolver.Stop() + + msg := new(dns.Msg).SetQuestion("host.example.com.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + assert.Len(t, resp.Answer, 0) + assert.False(t, resolver.isInManagedZone("host.example.com.")) + }) + + t.Run("GracefullyStop is safe to call multiple times", func(t *testing.T) { + resolver := NewResolver() + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + }, + }}) + + resolver.Stop() + resolver.Stop() + resolver.Stop() + }) + + t.Run("GracefullyStop cancels in-flight external resolution", func(t *testing.T) { + resolver := NewResolver() + + lookupStarted := make(chan struct{}) + lookupCtxCanceled := make(chan struct{}) + + resolver.resolver = &mockResolver{ + lookupFunc: func(ctx context.Context, network, host string) ([]netip.Addr, error) { + close(lookupStarted) + <-ctx.Done() + close(lookupCtxCanceled) + return nil, ctx.Err() + }, + } + + resolver.Update([]nbdns.CustomZone{{ + Domain: "test.", + Records: []nbdns.SimpleRecord{ + {Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."}, + }, + }}) + + done := make(chan struct{}) + go func() { + msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeA) + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { return nil }}, msg) + close(done) + }() + + <-lookupStarted + resolver.Stop() + + select { + case <-lookupCtxCanceled: + case <-time.After(time.Second): + t.Fatal("external lookup context was not canceled") + } + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("ServeDNS did not return after Stop") + } + }) +} + +// TestLocalResolver_FallthroughCaseInsensitive verifies case-insensitive domain matching for fallthrough +func TestLocalResolver_FallthroughCaseInsensitive(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "EXAMPLE.COM.", + Records: []nbdns.SimpleRecord{{Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "1.2.3.4"}}, + NonAuthoritative: true, + }}) + + var responseMSG *dns.Msg + responseWriter := &test.MockResponseWriter{ + WriteMsgFunc: func(m *dns.Msg) error { + responseMSG = m + return nil + }, + } + + msg := new(dns.Msg).SetQuestion("nonexistent.example.com.", dns.TypeA) + resolver.ServeDNS(responseWriter, msg) + + require.NotNil(t, responseMSG) + assert.True(t, responseMSG.MsgHdr.Zero, "Should fallthrough for non-authoritative zone with case-insensitive match") +} + +// TestLocalResolver_WildcardCNAME tests wildcard CNAME record handling for non-CNAME queries +func TestLocalResolver_WildcardCNAME(t *testing.T) { + t.Run("wildcard CNAME resolves A query with internal target", func(t *testing.T) { + resolver := NewResolver() + + // Configure wildcard CNAME pointing to internal A record + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."}, + {Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("foo.example.com.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should resolve via wildcard CNAME") + require.Len(t, resp.Answer, 2, "Should have CNAME + A record") + + // Verify CNAME has the original query name, not the wildcard + cname, ok := resp.Answer[0].(*dns.CNAME) + require.True(t, ok, "First answer should be CNAME") + assert.Equal(t, "foo.example.com.", cname.Hdr.Name, "CNAME owner should be rewritten to query name") + assert.Equal(t, "target.example.com.", cname.Target) + + // Verify A record + a, ok := resp.Answer[1].(*dns.A) + require.True(t, ok, "Second answer should be A record") + assert.Equal(t, "10.0.0.1", a.A.String()) + }) + + t.Run("wildcard CNAME resolves AAAA query with internal target", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."}, + {Name: "target.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("bar.example.com.", dns.TypeAAAA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should resolve via wildcard CNAME") + require.Len(t, resp.Answer, 2, "Should have CNAME + AAAA record") + + cname, ok := resp.Answer[0].(*dns.CNAME) + require.True(t, ok) + assert.Equal(t, "bar.example.com.", cname.Hdr.Name, "CNAME owner should be rewritten") + + aaaa, ok := resp.Answer[1].(*dns.AAAA) + require.True(t, ok) + assert.Equal(t, "2001:db8::1", aaaa.AAAA.String()) + }) + + t.Run("specific record takes precedence over wildcard CNAME", func(t *testing.T) { + resolver := NewResolver() + + // Both wildcard CNAME and specific A record exist + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."}, + {Name: "specific.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "192.168.1.1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("specific.example.com.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1, "Should return specific A record only") + + a, ok := resp.Answer[0].(*dns.A) + require.True(t, ok) + assert.Equal(t, "192.168.1.1", a.A.String()) + }) + + t.Run("specific CNAME takes precedence over wildcard CNAME", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "wildcard-target.example.com."}, + {Name: "specific.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "specific-target.example.com."}, + {Name: "specific-target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.1.1.1"}, + {Name: "wildcard-target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.2.2.2"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("specific.example.com.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.GreaterOrEqual(t, len(resp.Answer), 1) + + cname, ok := resp.Answer[0].(*dns.CNAME) + require.True(t, ok) + assert.Equal(t, "specific-target.example.com.", cname.Target, "Should use specific CNAME, not wildcard") + }) + + t.Run("wildcard CNAME to non-existent internal target returns NXDOMAIN with CNAME", func(t *testing.T) { + resolver := NewResolver() + + // Wildcard CNAME pointing to non-existent internal target + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "nonexistent.example.com."}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("foo.example.com.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + // Per RFC 6604, CNAME chains should return the rcode of the final target. + // When the wildcard CNAME target doesn't exist in the managed zone, this + // returns NXDOMAIN with the CNAME record included. + // Note: Current implementation returns NODATA (success) because the wildcard + // domain exists. This test documents the actual behavior. + if resp.Rcode == dns.RcodeNameError { + // RFC-compliant behavior: NXDOMAIN with CNAME + require.Len(t, resp.Answer, 1, "Should include the CNAME pointing to non-existent target") + cname, ok := resp.Answer[0].(*dns.CNAME) + require.True(t, ok) + assert.Equal(t, "foo.example.com.", cname.Hdr.Name, "CNAME owner should be rewritten") + assert.Equal(t, "nonexistent.example.com.", cname.Target) + } else { + // Current behavior: NODATA (success with CNAME but target not found) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Returns NODATA when wildcard exists but target doesn't") + } + }) + + t.Run("wildcard CNAME with multi-level subdomain", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."}, + {Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + }, + }}) + + // Query with multi-level subdomain - wildcard should only match first label + // Standard DNS wildcards only match a single label, so sub.domain.example.com + // should NOT match *.example.com - this tests current implementation behavior + msg := new(dns.Msg).SetQuestion("sub.domain.example.com.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + }) + + t.Run("wildcard CNAME NODATA when target has no matching type", func(t *testing.T) { + resolver := NewResolver() + + // Wildcard CNAME to target that only has A record, query for AAAA + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."}, + {Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("foo.example.com.", dns.TypeAAAA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA (success with no answer for AAAA)") + require.Len(t, resp.Answer, 1, "Should have only CNAME") + + cname, ok := resp.Answer[0].(*dns.CNAME) + require.True(t, ok) + assert.Equal(t, "foo.example.com.", cname.Hdr.Name) + }) + + t.Run("direct CNAME query for wildcard record", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."}, + }, + }}) + + // Direct CNAME query should also work via wildcard + msg := new(dns.Msg).SetQuestion("foo.example.com.", dns.TypeCNAME) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + + cname, ok := resp.Answer[0].(*dns.CNAME) + require.True(t, ok) + assert.Equal(t, "foo.example.com.", cname.Hdr.Name, "CNAME owner should be rewritten") + assert.Equal(t, "target.example.com.", cname.Target) + }) + + t.Run("wildcard CNAME case insensitive query", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."}, + {Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("FOO.EXAMPLE.COM.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Equal(t, dns.RcodeSuccess, resp.Rcode, "Wildcard CNAME should match case-insensitively") + require.Len(t, resp.Answer, 2) + }) + + t.Run("wildcard A and wildcard CNAME coexist - A takes precedence", func(t *testing.T) { + resolver := NewResolver() + + // Both wildcard A and wildcard CNAME exist + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("foo.example.com.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + + // A record should be returned, not CNAME + a, ok := resp.Answer[0].(*dns.A) + require.True(t, ok, "Wildcard A should take precedence over wildcard CNAME for A query") + assert.Equal(t, "10.0.0.1", a.A.String()) + }) + + t.Run("wildcard CNAME with chained CNAMEs", func(t *testing.T) { + resolver := NewResolver() + + // Wildcard CNAME -> another CNAME -> A record + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "hop1.example.com."}, + {Name: "hop1.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "final.example.com."}, + {Name: "final.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("anyhost.example.com.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 3, "Should have wildcard CNAME + hop1 CNAME + A record") + + // First should be the wildcard CNAME with rewritten name + cname1, ok := resp.Answer[0].(*dns.CNAME) + require.True(t, ok) + assert.Equal(t, "anyhost.example.com.", cname1.Hdr.Name) + assert.Equal(t, "hop1.example.com.", cname1.Target) + }) +} + +// TestLocalResolver_WildcardAandAAAA tests wildcard A and AAAA record handling +func TestLocalResolver_WildcardAandAAAA(t *testing.T) { + t.Run("wildcard A record resolves with owner name rewriting", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("anyhost.example.com.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + + a, ok := resp.Answer[0].(*dns.A) + require.True(t, ok) + assert.Equal(t, "anyhost.example.com.", a.Hdr.Name, "Owner name should be rewritten to query name") + assert.Equal(t, "10.0.0.1", a.A.String()) + }) + + t.Run("wildcard AAAA record resolves with owner name rewriting", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("anyhost.example.com.", dns.TypeAAAA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + + aaaa, ok := resp.Answer[0].(*dns.AAAA) + require.True(t, ok) + assert.Equal(t, "anyhost.example.com.", aaaa.Hdr.Name, "Owner name should be rewritten to query name") + assert.Equal(t, "2001:db8::1", aaaa.AAAA.String()) + }) + + t.Run("NODATA when querying AAAA but only wildcard A exists", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("anyhost.example.com.", dns.TypeAAAA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA (success with no answer)") + assert.Len(t, resp.Answer, 0, "Should have no AAAA answer") + }) + + t.Run("NODATA when querying A but only wildcard AAAA exists", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("anyhost.example.com.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA (success with no answer)") + assert.Len(t, resp.Answer, 0, "Should have no A answer") + }) + + t.Run("dual-stack wildcard returns both A and AAAA separately", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + {Name: "*.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"}, + }, + }}) + + // Query A + msgA := new(dns.Msg).SetQuestion("anyhost.example.com.", dns.TypeA) + var respA *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respA = m; return nil }}, msgA) + + require.NotNil(t, respA) + require.Equal(t, dns.RcodeSuccess, respA.Rcode) + require.Len(t, respA.Answer, 1) + a, ok := respA.Answer[0].(*dns.A) + require.True(t, ok) + assert.Equal(t, "10.0.0.1", a.A.String()) + + // Query AAAA + msgAAAA := new(dns.Msg).SetQuestion("anyhost.example.com.", dns.TypeAAAA) + var respAAAA *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respAAAA = m; return nil }}, msgAAAA) + + require.NotNil(t, respAAAA) + require.Equal(t, dns.RcodeSuccess, respAAAA.Rcode) + require.Len(t, respAAAA.Answer, 1) + aaaa, ok := respAAAA.Answer[0].(*dns.AAAA) + require.True(t, ok) + assert.Equal(t, "2001:db8::1", aaaa.AAAA.String()) + }) + + t.Run("specific A takes precedence over wildcard A", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + {Name: "specific.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "192.168.1.1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("specific.example.com.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + + a, ok := resp.Answer[0].(*dns.A) + require.True(t, ok) + assert.Equal(t, "192.168.1.1", a.A.String(), "Specific record should take precedence") + }) + + t.Run("specific AAAA takes precedence over wildcard AAAA", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"}, + {Name: "specific.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::2"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("specific.example.com.", dns.TypeAAAA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + + aaaa, ok := resp.Answer[0].(*dns.AAAA) + require.True(t, ok) + assert.Equal(t, "2001:db8::2", aaaa.AAAA.String(), "Specific record should take precedence") + }) + + t.Run("multiple wildcard A records round-robin", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.2"}, + {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.3"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("anyhost.example.com.", dns.TypeA) + + var firstIPs []string + for i := 0; i < 3; i++ { + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Len(t, resp.Answer, 3, "Should return all 3 A records") + + a, ok := resp.Answer[0].(*dns.A) + require.True(t, ok) + firstIPs = append(firstIPs, a.A.String()) + + // Verify owner name is rewritten for all records + for _, ans := range resp.Answer { + assert.Equal(t, "anyhost.example.com.", ans.Header().Name) + } + } + + // Verify rotation happened + assert.NotEqual(t, firstIPs[0], firstIPs[1], "First record should rotate") + assert.NotEqual(t, firstIPs[1], firstIPs[2], "Second rotation should differ") + }) + + t.Run("wildcard A case insensitive", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("ANYHOST.EXAMPLE.COM.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + }) + + t.Run("wildcard does not match multi-level subdomain", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + }, + }}) + + // *.example.com should NOT match sub.domain.example.com (standard DNS behavior) + msg := new(dns.Msg).SetQuestion("sub.domain.example.com.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + // This depends on implementation - standard DNS wildcards only match single label + // Current implementation replaces first label with *, so it WOULD match + // This test documents the current behavior + }) + + t.Run("wildcard with existing domain but different type returns NODATA", func(t *testing.T) { + resolver := NewResolver() + + // Specific A record exists, but query for TXT on wildcard domain + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("test.example.com.", dns.TypeTXT) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA for existing wildcard domain with different type") + assert.Len(t, resp.Answer, 0) + }) + + t.Run("mixed specific and wildcard returns correct records", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + {Name: "specific.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"}, + }, + }}) + + // Query A for specific - should use wildcard + msgA := new(dns.Msg).SetQuestion("specific.example.com.", dns.TypeA) + var respA *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respA = m; return nil }}, msgA) + + require.NotNil(t, respA) + // This could be NODATA since specific.example.com exists but has no A + // or could return wildcard A - depends on implementation + // The current behavior returns NODATA because specific domain exists + + // Query AAAA for specific - should use specific record + msgAAAA := new(dns.Msg).SetQuestion("specific.example.com.", dns.TypeAAAA) + var respAAAA *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respAAAA = m; return nil }}, msgAAAA) + + require.NotNil(t, respAAAA) + require.Equal(t, dns.RcodeSuccess, respAAAA.Rcode) + require.Len(t, respAAAA.Answer, 1) + aaaa, ok := respAAAA.Answer[0].(*dns.AAAA) + require.True(t, ok) + assert.Equal(t, "2001:db8::1", aaaa.AAAA.String()) + }) +} + +// TestLocalResolver_WildcardEdgeCases tests edge cases for wildcard record handling +func TestLocalResolver_WildcardEdgeCases(t *testing.T) { + t.Run("wildcard does not match NS queries", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("foo.example.com.", dns.TypeNS) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + assert.Equal(t, dns.RcodeNameError, resp.Rcode, "NS queries should not match wildcards") + assert.Len(t, resp.Answer, 0) + }) + + t.Run("wildcard does not match SOA queries", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("foo.example.com.", dns.TypeSOA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + assert.Equal(t, dns.RcodeNameError, resp.Rcode, "SOA queries should not match wildcards") + assert.Len(t, resp.Answer, 0) + }) + + t.Run("apex wildcard query", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + }, + }}) + + // Query for *.example.com directly (the wildcard itself) + msg := new(dns.Msg).SetQuestion("*.example.com.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + + a, ok := resp.Answer[0].(*dns.A) + require.True(t, ok) + assert.Equal(t, "10.0.0.1", a.A.String()) + }) + + t.Run("wildcard TXT record", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeTXT), Class: nbdns.DefaultClass, TTL: 300, RData: "v=spf1 -all"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("mail.example.com.", dns.TypeTXT) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + + txt, ok := resp.Answer[0].(*dns.TXT) + require.True(t, ok) + assert.Equal(t, "mail.example.com.", txt.Hdr.Name, "TXT owner should be rewritten") + }) + + t.Run("wildcard MX record", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeMX), Class: nbdns.DefaultClass, TTL: 300, RData: "10 mail.example.com."}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("sub.example.com.", dns.TypeMX) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1) + + mx, ok := resp.Answer[0].(*dns.MX) + require.True(t, ok) + assert.Equal(t, "sub.example.com.", mx.Hdr.Name, "MX owner should be rewritten") + }) + + t.Run("non-authoritative zone with wildcard CNAME triggers fallthrough for unmatched names", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + NonAuthoritative: true, + Records: []nbdns.SimpleRecord{ + {Name: "*.sub.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."}, + }, + }}) + + // Query for name not matching the wildcard pattern + msg := new(dns.Msg).SetQuestion("other.example.com.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + assert.True(t, resp.MsgHdr.Zero, "Should trigger fallthrough for non-authoritative zone") + }) +} + +// TestLocalResolver_MixedRecordTypes tests scenarios with A, AAAA, and CNAME records combined +func TestLocalResolver_MixedRecordTypes(t *testing.T) { + t.Run("specific A with wildcard CNAME - A query uses specific A", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."}, + {Name: "specific.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + {Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.2"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("specific.example.com.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1, "Should return only the specific A record") + + a, ok := resp.Answer[0].(*dns.A) + require.True(t, ok) + assert.Equal(t, "10.0.0.1", a.A.String(), "Should use specific A, not follow wildcard CNAME") + }) + + t.Run("specific AAAA with wildcard CNAME - AAAA query uses specific AAAA", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."}, + {Name: "specific.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"}, + {Name: "target.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::2"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("specific.example.com.", dns.TypeAAAA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 1, "Should return only the specific AAAA record") + + aaaa, ok := resp.Answer[0].(*dns.AAAA) + require.True(t, ok) + assert.Equal(t, "2001:db8::1", aaaa.AAAA.String(), "Should use specific AAAA, not follow wildcard CNAME") + }) + + t.Run("specific A only - AAAA query returns NODATA", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("host.example.com.", dns.TypeAAAA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA (success with no AAAA)") + assert.Len(t, resp.Answer, 0) + }) + + t.Run("specific AAAA only - A query returns NODATA", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "host.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("host.example.com.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA (success with no A)") + assert.Len(t, resp.Answer, 0) + }) + + t.Run("CNAME with both A and AAAA target - A query returns CNAME + A", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "alias.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."}, + {Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + {Name: "target.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("alias.example.com.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 2, "Should have CNAME + A") + + cname, ok := resp.Answer[0].(*dns.CNAME) + require.True(t, ok) + assert.Equal(t, "target.example.com.", cname.Target) + + a, ok := resp.Answer[1].(*dns.A) + require.True(t, ok) + assert.Equal(t, "10.0.0.1", a.A.String()) + }) + + t.Run("CNAME with both A and AAAA target - AAAA query returns CNAME + AAAA", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "alias.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."}, + {Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + {Name: "target.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("alias.example.com.", dns.TypeAAAA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + require.Equal(t, dns.RcodeSuccess, resp.Rcode) + require.Len(t, resp.Answer, 2, "Should have CNAME + AAAA") + + cname, ok := resp.Answer[0].(*dns.CNAME) + require.True(t, ok) + assert.Equal(t, "target.example.com.", cname.Target) + + aaaa, ok := resp.Answer[1].(*dns.AAAA) + require.True(t, ok) + assert.Equal(t, "2001:db8::1", aaaa.AAAA.String()) + }) + + t.Run("CNAME to target with only A - AAAA query returns CNAME only (NODATA)", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "alias.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."}, + {Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("alias.example.com.", dns.TypeAAAA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA with CNAME") + require.Len(t, resp.Answer, 1, "Should have only CNAME") + + _, ok := resp.Answer[0].(*dns.CNAME) + require.True(t, ok) + }) + + t.Run("CNAME to target with only AAAA - A query returns CNAME only (NODATA)", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "alias.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."}, + {Name: "target.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"}, + }, + }}) + + msg := new(dns.Msg).SetQuestion("alias.example.com.", dns.TypeA) + var resp *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg) + + require.NotNil(t, resp) + assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA with CNAME") + require.Len(t, resp.Answer, 1, "Should have only CNAME") + + _, ok := resp.Answer[0].(*dns.CNAME) + require.True(t, ok) + }) + + t.Run("wildcard A + wildcard AAAA + wildcard CNAME - each query type returns correct record", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + {Name: "*.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"}, + {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."}, + }, + }}) + + // A query should return wildcard A (not CNAME) + msgA := new(dns.Msg).SetQuestion("any.example.com.", dns.TypeA) + var respA *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respA = m; return nil }}, msgA) + + require.NotNil(t, respA) + require.Equal(t, dns.RcodeSuccess, respA.Rcode) + require.Len(t, respA.Answer, 1) + a, ok := respA.Answer[0].(*dns.A) + require.True(t, ok, "A query should return A record, not CNAME") + assert.Equal(t, "10.0.0.1", a.A.String()) + + // AAAA query should return wildcard AAAA (not CNAME) + msgAAAA := new(dns.Msg).SetQuestion("any.example.com.", dns.TypeAAAA) + var respAAAA *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respAAAA = m; return nil }}, msgAAAA) + + require.NotNil(t, respAAAA) + require.Equal(t, dns.RcodeSuccess, respAAAA.Rcode) + require.Len(t, respAAAA.Answer, 1) + aaaa, ok := respAAAA.Answer[0].(*dns.AAAA) + require.True(t, ok, "AAAA query should return AAAA record, not CNAME") + assert.Equal(t, "2001:db8::1", aaaa.AAAA.String()) + + // CNAME query should return wildcard CNAME + msgCNAME := new(dns.Msg).SetQuestion("any.example.com.", dns.TypeCNAME) + var respCNAME *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respCNAME = m; return nil }}, msgCNAME) + + require.NotNil(t, respCNAME) + require.Equal(t, dns.RcodeSuccess, respCNAME.Rcode) + require.Len(t, respCNAME.Answer, 1) + cname, ok := respCNAME.Answer[0].(*dns.CNAME) + require.True(t, ok, "CNAME query should return CNAME record") + assert.Equal(t, "target.example.com.", cname.Target) + }) + + t.Run("dual-stack host with both A and AAAA", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + {Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.2"}, + {Name: "host.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"}, + {Name: "host.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::2"}, + }, + }}) + + // A query + msgA := new(dns.Msg).SetQuestion("host.example.com.", dns.TypeA) + var respA *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respA = m; return nil }}, msgA) + + require.NotNil(t, respA) + require.Equal(t, dns.RcodeSuccess, respA.Rcode) + require.Len(t, respA.Answer, 2, "Should return both A records") + for _, ans := range respA.Answer { + _, ok := ans.(*dns.A) + require.True(t, ok) + } + + // AAAA query + msgAAAA := new(dns.Msg).SetQuestion("host.example.com.", dns.TypeAAAA) + var respAAAA *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respAAAA = m; return nil }}, msgAAAA) + + require.NotNil(t, respAAAA) + require.Equal(t, dns.RcodeSuccess, respAAAA.Rcode) + require.Len(t, respAAAA.Answer, 2, "Should return both AAAA records") + for _, ans := range respAAAA.Answer { + _, ok := ans.(*dns.AAAA) + require.True(t, ok) + } + }) + + t.Run("CNAME chain with mixed record types at target", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "alias1.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "alias2.example.com."}, + {Name: "alias2.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."}, + {Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + {Name: "target.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"}, + }, + }}) + + // A query through chain + msgA := new(dns.Msg).SetQuestion("alias1.example.com.", dns.TypeA) + var respA *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respA = m; return nil }}, msgA) + + require.NotNil(t, respA) + require.Equal(t, dns.RcodeSuccess, respA.Rcode) + require.Len(t, respA.Answer, 3, "Should have 2 CNAMEs + 1 A") + + // Verify chain order + cname1, ok := respA.Answer[0].(*dns.CNAME) + require.True(t, ok) + assert.Equal(t, "alias2.example.com.", cname1.Target) + + cname2, ok := respA.Answer[1].(*dns.CNAME) + require.True(t, ok) + assert.Equal(t, "target.example.com.", cname2.Target) + + a, ok := respA.Answer[2].(*dns.A) + require.True(t, ok) + assert.Equal(t, "10.0.0.1", a.A.String()) + + // AAAA query through chain + msgAAAA := new(dns.Msg).SetQuestion("alias1.example.com.", dns.TypeAAAA) + var respAAAA *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respAAAA = m; return nil }}, msgAAAA) + + require.NotNil(t, respAAAA) + require.Equal(t, dns.RcodeSuccess, respAAAA.Rcode) + require.Len(t, respAAAA.Answer, 3, "Should have 2 CNAMEs + 1 AAAA") + + aaaa, ok := respAAAA.Answer[2].(*dns.AAAA) + require.True(t, ok) + assert.Equal(t, "2001:db8::1", aaaa.AAAA.String()) + }) + + t.Run("wildcard CNAME with dual-stack target", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "*.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."}, + {Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + {Name: "target.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"}, + }, + }}) + + // A query via wildcard CNAME + msgA := new(dns.Msg).SetQuestion("any.example.com.", dns.TypeA) + var respA *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respA = m; return nil }}, msgA) + + require.NotNil(t, respA) + require.Equal(t, dns.RcodeSuccess, respA.Rcode) + require.Len(t, respA.Answer, 2, "Should have CNAME + A") + + cname, ok := respA.Answer[0].(*dns.CNAME) + require.True(t, ok) + assert.Equal(t, "any.example.com.", cname.Hdr.Name, "CNAME owner should be rewritten") + + a, ok := respA.Answer[1].(*dns.A) + require.True(t, ok) + assert.Equal(t, "10.0.0.1", a.A.String()) + + // AAAA query via wildcard CNAME + msgAAAA := new(dns.Msg).SetQuestion("other.example.com.", dns.TypeAAAA) + var respAAAA *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respAAAA = m; return nil }}, msgAAAA) + + require.NotNil(t, respAAAA) + require.Equal(t, dns.RcodeSuccess, respAAAA.Rcode) + require.Len(t, respAAAA.Answer, 2, "Should have CNAME + AAAA") + + cname2, ok := respAAAA.Answer[0].(*dns.CNAME) + require.True(t, ok) + assert.Equal(t, "other.example.com.", cname2.Hdr.Name, "CNAME owner should be rewritten") + + aaaa, ok := respAAAA.Answer[1].(*dns.AAAA) + require.True(t, ok) + assert.Equal(t, "2001:db8::1", aaaa.AAAA.String()) + }) + + t.Run("specific A + wildcard AAAA - each query type returns correct record", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + {Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, + {Name: "*.example.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8::1"}, + }, + }}) + + // A query for host should return specific A + msgA := new(dns.Msg).SetQuestion("host.example.com.", dns.TypeA) + var respA *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respA = m; return nil }}, msgA) + + require.NotNil(t, respA) + require.Equal(t, dns.RcodeSuccess, respA.Rcode) + require.Len(t, respA.Answer, 1) + a, ok := respA.Answer[0].(*dns.A) + require.True(t, ok) + assert.Equal(t, "10.0.0.1", a.A.String()) + + // AAAA query for host should return NODATA (specific A exists, no AAAA for host.example.com) + msgAAAA := new(dns.Msg).SetQuestion("host.example.com.", dns.TypeAAAA) + var respAAAA *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respAAAA = m; return nil }}, msgAAAA) + + require.NotNil(t, respAAAA) + // RFC 4592 section 2.2.1: wildcard should NOT match when the name EXISTS in zone. + // host.example.com exists (has A record), so AAAA query returns NODATA, not wildcard. + assert.Equal(t, dns.RcodeSuccess, respAAAA.Rcode, "Should return NODATA for existing host without AAAA") + assert.Len(t, respAAAA.Answer, 0, "RFC 4592: wildcard should not match when name exists") + + // AAAA query for other host should return wildcard AAAA + msgAAAAOther := new(dns.Msg).SetQuestion("other.example.com.", dns.TypeAAAA) + var respAAAAOther *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { respAAAAOther = m; return nil }}, msgAAAAOther) + + require.NotNil(t, respAAAAOther) + require.Equal(t, dns.RcodeSuccess, respAAAAOther.Rcode) + require.Len(t, respAAAAOther.Answer, 1) + aaaa, ok := respAAAAOther.Answer[0].(*dns.AAAA) + require.True(t, ok) + assert.Equal(t, "2001:db8::1", aaaa.AAAA.String()) + assert.Equal(t, "other.example.com.", aaaa.Hdr.Name, "Owner should be rewritten") + }) + + t.Run("multiple zones with mixed records", func(t *testing.T) { + resolver := NewResolver() + + resolver.Update([]nbdns.CustomZone{ + { + Domain: "zone1.com.", + Records: []nbdns.SimpleRecord{ + {Name: "host.zone1.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.1.0.1"}, + {Name: "host.zone1.com.", Type: int(dns.TypeAAAA), Class: nbdns.DefaultClass, TTL: 300, RData: "2001:db8:1::1"}, + }, + }, + { + Domain: "zone2.com.", + Records: []nbdns.SimpleRecord{ + {Name: "alias.zone2.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.zone2.com."}, + {Name: "target.zone2.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.2.0.1"}, + }, + }, + }) + + // Query zone1 A + msg1A := new(dns.Msg).SetQuestion("host.zone1.com.", dns.TypeA) + var resp1A *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp1A = m; return nil }}, msg1A) + + require.NotNil(t, resp1A) + require.Equal(t, dns.RcodeSuccess, resp1A.Rcode) + require.Len(t, resp1A.Answer, 1) + + // Query zone1 AAAA + msg1AAAA := new(dns.Msg).SetQuestion("host.zone1.com.", dns.TypeAAAA) + var resp1AAAA *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp1AAAA = m; return nil }}, msg1AAAA) + + require.NotNil(t, resp1AAAA) + require.Equal(t, dns.RcodeSuccess, resp1AAAA.Rcode) + require.Len(t, resp1AAAA.Answer, 1) + + // Query zone2 via CNAME + msg2A := new(dns.Msg).SetQuestion("alias.zone2.com.", dns.TypeA) + var resp2A *dns.Msg + resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp2A = m; return nil }}, msg2A) + + require.NotNil(t, resp2A) + require.Equal(t, dns.RcodeSuccess, resp2A.Rcode) + require.Len(t, resp2A.Answer, 2, "Should have CNAME + A") + }) +} + +// BenchmarkFindZone_BestCase benchmarks zone lookup with immediate match (first label) +func BenchmarkFindZone_BestCase(b *testing.B) { + resolver := NewResolver() + + // Single zone that matches immediately + resolver.Update([]nbdns.CustomZone{{ + Domain: "example.com.", + NonAuthoritative: true, + }}) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + resolver.shouldFallthrough("example.com.") + } +} + +// BenchmarkFindZone_WorstCase benchmarks zone lookup with many zones, no match, many labels +func BenchmarkFindZone_WorstCase(b *testing.B) { + resolver := NewResolver() + + // 100 zones that won't match + var zones []nbdns.CustomZone + for i := 0; i < 100; i++ { + zones = append(zones, nbdns.CustomZone{ + Domain: fmt.Sprintf("zone%d.internal.", i), + NonAuthoritative: true, + }) + } + resolver.Update(zones) + + // Query with many labels that won't match any zone + qname := "a.b.c.d.e.f.g.h.external.com." + + b.ResetTimer() + for i := 0; i < b.N; i++ { + resolver.shouldFallthrough(qname) + } +} + +// BenchmarkFindZone_TypicalCase benchmarks typical usage: few zones, subdomain match +func BenchmarkFindZone_TypicalCase(b *testing.B) { + resolver := NewResolver() + + // Typical setup: peer zone (authoritative) + one user zone (non-authoritative) + resolver.Update([]nbdns.CustomZone{ + {Domain: "netbird.cloud.", NonAuthoritative: false}, + {Domain: "custom.local.", NonAuthoritative: true}, + }) + + // Query for subdomain of user zone + qname := "myhost.custom.local." + + b.ResetTimer() + for i := 0; i < b.N; i++ { + resolver.shouldFallthrough(qname) + } +} + +// BenchmarkIsInManagedZone_ManyZones benchmarks isInManagedZone with 100 zones +func BenchmarkIsInManagedZone_ManyZones(b *testing.B) { + resolver := NewResolver() + + var zones []nbdns.CustomZone + for i := 0; i < 100; i++ { + zones = append(zones, nbdns.CustomZone{ + Domain: fmt.Sprintf("zone%d.internal.", i), + }) + } + resolver.Update(zones) + + // Query that matches zone50 + qname := "host.zone50.internal." + + b.ResetTimer() + for i := 0; i < b.N; i++ { + 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 290395473..ddc8cf585 100644 --- a/client/internal/dns/mgmt/mgmt.go +++ b/client/internal/dns/mgmt/mgmt.go @@ -2,34 +2,91 @@ package mgmt import ( "context" + "errors" "fmt" "net" "net/url" + "os" + "slices" "strings" "sync" + "sync/atomic" "time" "github.com/miekg/dns" log "github.com/sirupsen/logrus" + "golang.org/x/sync/singleflight" dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config" + "github.com/netbirdio/netbird/client/internal/dns/resutil" "github.com/netbirdio/netbird/shared/management/domain" ) -const dnsTimeout = 5 * time.Second +const ( + dnsTimeout = 5 * time.Second + defaultTTL = 300 * time.Second + refreshBackoff = 30 * time.Second -// Resolver caches critical NetBird infrastructure domains + // envMgmtCacheTTL overrides defaultTTL for integration/dev testing. + envMgmtCacheTTL = "NB_MGMT_CACHE_TTL" +) + +// ChainResolver lets the cache refresh stale entries through the DNS handler +// chain instead of net.DefaultResolver, avoiding loopback when NetBird is the +// system resolver. +type ChainResolver interface { + ResolveInternal(ctx context.Context, msg *dns.Msg, maxPriority int) (*dns.Msg, error) + HasRootHandlerAtOrBelow(maxPriority int) bool +} + +// cachedRecord holds DNS records plus timestamps used for TTL refresh. +// records and cachedAt are set at construction and treated as immutable; +// lastFailedRefresh and consecFailures are mutable and must be accessed under +// Resolver.mutex. +type cachedRecord struct { + records []dns.RR + cachedAt time.Time + lastFailedRefresh time.Time + consecFailures int +} + +// Resolver caches critical NetBird infrastructure domains. +// records, refreshing, failedResolves, mgmtDomain and serverDomains are all +// guarded by mutex. type Resolver struct { - records map[dns.Question][]dns.RR + 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 + + // refreshing tracks questions whose refresh is running via the OS + // fallback path. A ServeDNS hit for a question in this map indicates + // the OS resolver routed the recursive query back to us (loop). Only + // the OS path arms this so chain-path refreshes don't produce false + // positives. The atomic bool is CAS-flipped once per refresh to + // throttle the warning log. + refreshing map[dns.Question]*atomic.Bool + + cacheTTL time.Duration } // NewResolver creates a new management domains cache resolver. func NewResolver() *Resolver { return &Resolver{ - records: make(map[dns.Question][]dns.RR), + records: make(map[dns.Question]*cachedRecord), + refreshing: make(map[dns.Question]*atomic.Bool), + failedResolves: make(map[domain.Domain]time.Time), + cacheTTL: resolveCacheTTL(), } } @@ -38,7 +95,19 @@ func (m *Resolver) String() string { return "MgmtCacheResolver" } -// ServeDNS implements dns.Handler interface. +// SetChainResolver wires the handler chain used to refresh stale cache entries. +// maxPriority caps which handlers may answer refresh queries (typically +// PriorityUpstream, so upstream/default/fallback handlers are consulted and +// mgmt/route/local handlers are skipped). +func (m *Resolver) SetChainResolver(chain ChainResolver, maxPriority int) { + m.mutex.Lock() + m.chain = chain + m.chainMaxPriority = maxPriority + m.mutex.Unlock() +} + +// ServeDNS serves cached A/AAAA records. Stale entries are returned +// immediately and refreshed asynchronously (stale-while-revalidate). func (m *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { if len(r.Question) == 0 { m.continueToNext(w, r) @@ -54,7 +123,14 @@ func (m *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { } m.mutex.RLock() - records, found := m.records[question] + cached, found := m.records[question] + inflight := m.refreshing[question] + var shouldRefresh bool + if found { + stale := time.Since(cached.cachedAt) > m.cacheTTL + inBackoff := !cached.lastFailedRefresh.IsZero() && time.Since(cached.lastFailedRefresh) < refreshBackoff + shouldRefresh = stale && !inBackoff + } m.mutex.RUnlock() if !found { @@ -62,12 +138,23 @@ func (m *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { return } + if inflight != nil && inflight.CompareAndSwap(false, true) { + log.Warnf("mgmt cache: possible resolver loop for domain=%s: served stale while an OS-fallback refresh was inflight (if NetBird is the system resolver, the OS-path predicate is wrong)", + question.Name) + } + + // Skip scheduling a refresh goroutine if one is already inflight for + // this question; singleflight would dedup anyway but skipping avoids + // a parked goroutine per stale hit under bursty load. + if shouldRefresh && inflight == nil { + m.scheduleRefresh(question, cached) + } + resp := &dns.Msg{} resp.SetReply(r) resp.Authoritative = false resp.RecursionAvailable = true - - resp.Answer = append(resp.Answer, records...) + resp.Answer = cloneRecordsWithTTL(cached.records, m.responseTTL(cached.cachedAt)) log.Debugf("serving %d cached records for domain=%s", len(resp.Answer), question.Name) @@ -92,73 +179,268 @@ func (m *Resolver) continueToNext(w dns.ResponseWriter, r *dns.Msg) { } } -// AddDomain manually adds a domain to cache by resolving it. +// 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. 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())) ctx, cancel := context.WithTimeout(ctx, dnsTimeout) defer cancel() - ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", d.PunycodeString()) - if err != nil { - return fmt.Errorf("resolve domain %s: %w", d.SafeString(), err) + aRecords, aaaaRecords, errA, errAAAA := m.lookupBoth(ctx, d, dnsName) + + if errA != nil && errAAAA != nil { + return fmt.Errorf("resolve %s: %w", d.SafeString(), errors.Join(errA, errAAAA)) } - var aRecords, aaaaRecords []dns.RR - for _, ip := range ips { - if ip.Is4() { - rr := &dns.A{ - Hdr: dns.RR_Header{ - Name: dnsName, - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 300, - }, - A: ip.AsSlice(), - } - aRecords = append(aRecords, rr) - } else if ip.Is6() { - rr := &dns.AAAA{ - Hdr: dns.RR_Header{ - Name: dnsName, - Rrtype: dns.TypeAAAA, - Class: dns.ClassINET, - Ttl: 300, - }, - AAAA: ip.AsSlice(), - } - aaaaRecords = append(aaaaRecords, rr) + if len(aRecords) == 0 && len(aaaaRecords) == 0 { + if err := errors.Join(errA, errAAAA); err != nil { + return fmt.Errorf("resolve %s: no A/AAAA records: %w", d.SafeString(), err) } + return fmt.Errorf("resolve %s: no A/AAAA records", d.SafeString()) } + now := time.Now() m.mutex.Lock() + defer m.mutex.Unlock() - if len(aRecords) > 0 { - aQuestion := dns.Question{ - Name: dnsName, - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - } - m.records[aQuestion] = aRecords - } + m.applyFamilyRecords(dnsName, dns.TypeA, aRecords, errA, now) + m.applyFamilyRecords(dnsName, dns.TypeAAAA, aaaaRecords, errAAAA, now) - if len(aaaaRecords) > 0 { - aaaaQuestion := dns.Question{ - Name: dnsName, - Qtype: dns.TypeAAAA, - Qclass: dns.ClassINET, - } - m.records[aaaaQuestion] = aaaaRecords - } - - m.mutex.Unlock() - - log.Debugf("added domain=%s with %d A records and %d AAAA records", + 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 } +// applyFamilyRecords writes records, evicts on NODATA, leaves the cache +// untouched on error. Caller holds m.mutex. +func (m *Resolver) applyFamilyRecords(dnsName string, qtype uint16, records []dns.RR, err error, now time.Time) { + q := dns.Question{Name: dnsName, Qtype: qtype, Qclass: dns.ClassINET} + switch { + case len(records) > 0: + m.records[q] = &cachedRecord{records: records, cachedAt: now} + case err == nil: + delete(m.records, q) + } +} + +// scheduleRefresh kicks off an async refresh. DoChan spawns one goroutine per +// unique in-flight key; bursty stale hits share its channel. expected is the +// cachedRecord pointer observed by the caller; the refresh only mutates the +// cache if that pointer is still the one stored, so a stale in-flight refresh +// can't clobber a newer entry written by AddDomain or a competing refresh. +func (m *Resolver) scheduleRefresh(question dns.Question, expected *cachedRecord) { + key := question.Name + "|" + dns.TypeToString[question.Qtype] + _ = m.refreshGroup.DoChan(key, func() (any, error) { + return nil, m.refreshQuestion(question, expected) + }) +} + +// refreshQuestion replaces the cached records on success, or marks the entry +// failed (arming the backoff) on failure. While this runs, ServeDNS can detect +// a resolver loop by spotting a query for this same question arriving on us. +// expected pins the cache entry observed at schedule time; mutations only apply +// if m.records[question] still points at it. +func (m *Resolver) refreshQuestion(question dns.Question, expected *cachedRecord) error { + ctx, cancel := context.WithTimeout(context.Background(), dnsTimeout) + defer cancel() + + d, err := domain.FromString(strings.TrimSuffix(question.Name, ".")) + if err != nil { + m.markRefreshFailed(question, expected) + return fmt.Errorf("parse domain: %w", err) + } + + records, err := m.lookupRecords(ctx, d, question) + if err != nil { + fails := m.markRefreshFailed(question, expected) + logf := log.Warnf + if fails == 0 || fails > 1 { + logf = log.Debugf + } + logf("refresh mgmt cache domain=%s type=%s: %v (consecutive failures=%d)", + d.SafeString(), dns.TypeToString[question.Qtype], err, fails) + return err + } + + // NOERROR/NODATA: family gone upstream, evict so we stop serving stale. + if len(records) == 0 { + m.mutex.Lock() + if m.records[question] == expected { + delete(m.records, question) + m.mutex.Unlock() + log.Infof("removed mgmt cache domain=%s type=%s: no records returned", + d.SafeString(), dns.TypeToString[question.Qtype]) + return nil + } + m.mutex.Unlock() + log.Debugf("skipping refresh evict for domain=%s type=%s: entry changed during refresh", + d.SafeString(), dns.TypeToString[question.Qtype]) + return nil + } + + now := time.Now() + m.mutex.Lock() + if m.records[question] != expected { + m.mutex.Unlock() + log.Debugf("skipping refresh write for domain=%s type=%s: entry changed during refresh", + d.SafeString(), dns.TypeToString[question.Qtype]) + return nil + } + m.records[question] = &cachedRecord{records: records, cachedAt: now} + m.mutex.Unlock() + + log.Infof("refreshed mgmt cache domain=%s type=%s", + d.SafeString(), dns.TypeToString[question.Qtype]) + return nil +} + +func (m *Resolver) markRefreshing(question dns.Question) { + m.mutex.Lock() + m.refreshing[question] = &atomic.Bool{} + m.mutex.Unlock() +} + +func (m *Resolver) clearRefreshing(question dns.Question) { + m.mutex.Lock() + delete(m.refreshing, question) + m.mutex.Unlock() +} + +// markRefreshFailed arms the backoff and returns the new consecutive-failure +// count so callers can downgrade subsequent failure logs to debug. +func (m *Resolver) markRefreshFailed(question dns.Question, expected *cachedRecord) int { + m.mutex.Lock() + defer m.mutex.Unlock() + c, ok := m.records[question] + if !ok || c != expected { + return 0 + } + c.lastFailedRefresh = time.Now() + c.consecFailures++ + return c.consecFailures +} + +// lookupBoth resolves A and AAAA via chain or OS. Per-family errors let +// callers tell records, NODATA (nil err, no records), and failure apart. +func (m *Resolver) lookupBoth(ctx context.Context, d domain.Domain, dnsName string) (aRecords, aaaaRecords []dns.RR, errA, errAAAA error) { + m.mutex.RLock() + chain := m.chain + maxPriority := m.chainMaxPriority + m.mutex.RUnlock() + + if chain != nil && chain.HasRootHandlerAtOrBelow(maxPriority) { + aRecords, errA = m.lookupViaChain(ctx, chain, maxPriority, dnsName, dns.TypeA) + aaaaRecords, errAAAA = m.lookupViaChain(ctx, chain, maxPriority, dnsName, dns.TypeAAAA) + return + } + + // TODO: drop once every supported OS registers a fallback resolver. Safe + // today: no root handler at priority ≤ PriorityUpstream means NetBird is + // not the system resolver, so net.DefaultResolver will not loop back. + aRecords, errA = m.osLookup(ctx, d, dnsName, dns.TypeA) + aaaaRecords, errAAAA = m.osLookup(ctx, d, dnsName, dns.TypeAAAA) + return +} + +// lookupRecords resolves a single record type via chain or OS. The OS branch +// arms the loop detector for the duration of its call so that ServeDNS can +// spot the OS resolver routing the recursive query back to us. +func (m *Resolver) lookupRecords(ctx context.Context, d domain.Domain, q dns.Question) ([]dns.RR, error) { + m.mutex.RLock() + chain := m.chain + maxPriority := m.chainMaxPriority + m.mutex.RUnlock() + + if chain != nil && chain.HasRootHandlerAtOrBelow(maxPriority) { + return m.lookupViaChain(ctx, chain, maxPriority, q.Name, q.Qtype) + } + + // TODO: drop once every supported OS registers a fallback resolver. + m.markRefreshing(q) + defer m.clearRefreshing(q) + + return m.osLookup(ctx, d, q.Name, q.Qtype) +} + +// lookupViaChain resolves via the handler chain and rewrites each RR to use +// dnsName as owner and m.cacheTTL as TTL, so CNAME-backed domains don't cache +// target-owned records or upstream TTLs. NODATA returns (nil, nil). +func (m *Resolver) lookupViaChain(ctx context.Context, chain ChainResolver, maxPriority int, dnsName string, qtype uint16) ([]dns.RR, error) { + msg := &dns.Msg{} + msg.SetQuestion(dnsName, qtype) + msg.RecursionDesired = true + + resp, err := chain.ResolveInternal(ctx, msg, maxPriority) + if err != nil { + return nil, fmt.Errorf("chain resolve: %w", err) + } + if resp == nil { + return nil, fmt.Errorf("chain resolve returned nil response") + } + if resp.Rcode != dns.RcodeSuccess { + return nil, fmt.Errorf("chain resolve rcode=%s", dns.RcodeToString[resp.Rcode]) + } + + ttl := uint32(m.cacheTTL.Seconds()) + owners := cnameOwners(dnsName, resp.Answer) + var filtered []dns.RR + for _, rr := range resp.Answer { + h := rr.Header() + if h.Class != dns.ClassINET || h.Rrtype != qtype { + continue + } + if !owners[strings.ToLower(dns.Fqdn(h.Name))] { + continue + } + if cp := cloneIPRecord(rr, dnsName, ttl); cp != nil { + filtered = append(filtered, cp) + } + } + return filtered, nil +} + +// osLookup resolves a single family via net.DefaultResolver using resutil, +// which disambiguates NODATA from NXDOMAIN and Unmaps v4-mapped-v6. NODATA +// returns (nil, nil). +func (m *Resolver) osLookup(ctx context.Context, d domain.Domain, dnsName string, qtype uint16) ([]dns.RR, error) { + network := resutil.NetworkForQtype(qtype) + if network == "" { + return nil, fmt.Errorf("unsupported qtype %s", dns.TypeToString[qtype]) + } + + log.Infof("looking up IP for mgmt domain=%s type=%s", d.SafeString(), dns.TypeToString[qtype]) + defer log.Infof("done looking up IP for mgmt domain=%s type=%s", d.SafeString(), dns.TypeToString[qtype]) + + result := resutil.LookupIP(ctx, net.DefaultResolver, network, d.PunycodeString(), qtype) + if result.Rcode == dns.RcodeSuccess { + return resutil.IPsToRRs(dnsName, result.IPs, uint32(m.cacheTTL.Seconds())), nil + } + + if result.Err != nil { + return nil, fmt.Errorf("resolve %s type=%s: %w", d.SafeString(), dns.TypeToString[qtype], result.Err) + } + return nil, fmt.Errorf("resolve %s type=%s: rcode=%s", d.SafeString(), dns.TypeToString[qtype], dns.RcodeToString[result.Rcode]) +} + +// responseTTL returns the remaining cache lifetime in seconds (rounded up), +// so downstream resolvers don't cache an answer for longer than we will. +func (m *Resolver) responseTTL(cachedAt time.Time) uint32 { + remaining := m.cacheTTL - time.Since(cachedAt) + if remaining <= 0 { + return 0 + } + return uint32((remaining + time.Second - 1) / time.Second) +} + // PopulateFromConfig extracts and caches domains from the client configuration. func (m *Resolver) PopulateFromConfig(ctx context.Context, mgmtURL *url.URL) error { if mgmtURL == nil { @@ -188,19 +470,13 @@ func (m *Resolver) RemoveDomain(d domain.Domain) error { m.mutex.Lock() defer m.mutex.Unlock() - aQuestion := dns.Question{ - Name: dnsName, - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - } - delete(m.records, aQuestion) - - aaaaQuestion := dns.Question{ - Name: dnsName, - Qtype: dns.TypeAAAA, - Qclass: dns.ClassINET, - } - delete(m.records, aaaaQuestion) + qA := dns.Question{Name: dnsName, Qtype: dns.TypeA, Qclass: dns.ClassINET} + qAAAA := dns.Question{Name: dnsName, Qtype: dns.TypeAAAA, Qclass: dns.ClassINET} + delete(m.records, qA) + 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 @@ -244,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) @@ -316,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) } } } @@ -340,9 +689,9 @@ func (m *Resolver) extractDomainsFromServerDomains(serverDomains dnsconfig.Serve } } - if serverDomains.Flow != "" { - domains = append(domains, serverDomains.Flow) - } + // Flow receiver domain is intentionally excluded from caching. + // Cloud providers may rotate the IP behind this domain; a stale cached record + // causes TLS certificate verification failures on reconnect. for _, stun := range serverDomains.Stuns { if stun != "" { @@ -358,3 +707,73 @@ func (m *Resolver) extractDomainsFromServerDomains(serverDomains dnsconfig.Serve return domains } + +// cloneIPRecord returns a deep copy of rr retargeted to owner with ttl. Non +// A/AAAA records return nil. +func cloneIPRecord(rr dns.RR, owner string, ttl uint32) dns.RR { + switch r := rr.(type) { + case *dns.A: + cp := *r + cp.Hdr.Name = owner + cp.Hdr.Ttl = ttl + cp.A = slices.Clone(r.A) + return &cp + case *dns.AAAA: + cp := *r + cp.Hdr.Name = owner + cp.Hdr.Ttl = ttl + cp.AAAA = slices.Clone(r.AAAA) + return &cp + } + return nil +} + +// cloneRecordsWithTTL clones A/AAAA records preserving their owner and +// stamping ttl so the response shares no memory with the cached slice. +func cloneRecordsWithTTL(records []dns.RR, ttl uint32) []dns.RR { + out := make([]dns.RR, 0, len(records)) + for _, rr := range records { + if cp := cloneIPRecord(rr, rr.Header().Name, ttl); cp != nil { + out = append(out, cp) + } + } + return out +} + +// cnameOwners returns dnsName plus every target reachable by following CNAMEs +// in answer, iterating until fixed point so out-of-order chains resolve. +func cnameOwners(dnsName string, answer []dns.RR) map[string]bool { + owners := map[string]bool{dnsName: true} + for { + added := false + for _, rr := range answer { + cname, ok := rr.(*dns.CNAME) + if !ok { + continue + } + name := strings.ToLower(dns.Fqdn(cname.Hdr.Name)) + if !owners[name] { + continue + } + target := strings.ToLower(dns.Fqdn(cname.Target)) + if !owners[target] { + owners[target] = true + added = true + } + } + if !added { + return owners + } + } +} + +// resolveCacheTTL reads the cache TTL override env var; invalid or empty +// values fall back to defaultTTL. Called once per Resolver from NewResolver. +func resolveCacheTTL() time.Duration { + if v := os.Getenv(envMgmtCacheTTL); v != "" { + if d, err := time.ParseDuration(v); err == nil && d > 0 { + return d + } + } + return defaultTTL +} diff --git a/client/internal/dns/mgmt/mgmt_refresh_test.go b/client/internal/dns/mgmt/mgmt_refresh_test.go new file mode 100644 index 000000000..64a5342e2 --- /dev/null +++ b/client/internal/dns/mgmt/mgmt_refresh_test.go @@ -0,0 +1,419 @@ +package mgmt + +import ( + "context" + "errors" + "net" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/dns/test" + "github.com/netbirdio/netbird/shared/management/domain" +) + +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() +} + +func newFakeChain() *fakeChain { + return &fakeChain{ + calls: map[string]int{}, + answers: map[string][]dns.RR{}, + qErr: map[string]error{}, + hasRoot: true, + } +} + +func (f *fakeChain) HasRootHandlerAtOrBelow(maxPriority int) bool { + f.mu.Lock() + defer f.mu.Unlock() + return f.hasRoot +} + +func (f *fakeChain) ResolveInternal(ctx context.Context, msg *dns.Msg, maxPriority int) (*dns.Msg, error) { + f.mu.Lock() + q := msg.Question[0] + key := q.Name + "|" + dns.TypeToString[q.Qtype] + f.calls[key]++ + answers := f.answers[key] + err := f.err + if err == nil { + err = f.qErr[key] + } + onLookup := f.onLookup + f.mu.Unlock() + + if onLookup != nil { + onLookup() + } + if err != nil { + return nil, err + } + resp := &dns.Msg{} + resp.SetReply(msg) + resp.Answer = answers + return resp, nil +} + +func (f *fakeChain) setAnswer(name string, qtype uint16, ip string) { + f.mu.Lock() + defer f.mu.Unlock() + key := name + "|" + dns.TypeToString[qtype] + hdr := dns.RR_Header{Name: name, Rrtype: qtype, Class: dns.ClassINET, Ttl: 60} + switch qtype { + case dns.TypeA: + f.answers[key] = []dns.RR{&dns.A{Hdr: hdr, A: net.ParseIP(ip).To4()}} + case dns.TypeAAAA: + f.answers[key] = []dns.RR{&dns.AAAA{Hdr: hdr, AAAA: net.ParseIP(ip).To16()}} + } +} + +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() + return f.calls[name+"|"+dns.TypeToString[qtype]] +} + +// waitFor polls the predicate until it returns true or the deadline passes. +func waitFor(t *testing.T, d time.Duration, fn func() bool) { + t.Helper() + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + if fn() { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("condition not met within %s", d) +} + +func queryA(t *testing.T, r *Resolver, name string) *dns.Msg { + t.Helper() + msg := new(dns.Msg) + msg.SetQuestion(name, dns.TypeA) + w := &test.MockResponseWriter{} + r.ServeDNS(w, msg) + return w.GetLastResponse() +} + +func firstA(t *testing.T, resp *dns.Msg) string { + t.Helper() + require.NotNil(t, resp) + require.Greater(t, len(resp.Answer), 0, "expected at least one answer") + a, ok := resp.Answer[0].(*dns.A) + require.True(t, ok, "expected A record") + return a.A.String() +} + +func TestResolver_CacheTTLGatesRefresh(t *testing.T) { + // Same cached entry age, different cacheTTL values: the shorter TTL must + // trigger a background refresh, the longer one must not. Proves that the + // per-Resolver cacheTTL field actually drives the stale decision. + cachedAt := time.Now().Add(-100 * time.Millisecond) + + newRec := func() *cachedRecord { + return &cachedRecord{ + records: []dns.RR{&dns.A{ + Hdr: dns.RR_Header{Name: "mgmt.example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, + A: net.ParseIP("10.0.0.1").To4(), + }}, + cachedAt: cachedAt, + } + } + q := dns.Question{Name: "mgmt.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET} + + t.Run("short TTL treats entry as stale and refreshes", func(t *testing.T) { + r := NewResolver() + r.cacheTTL = 10 * time.Millisecond + chain := newFakeChain() + chain.setAnswer(q.Name, dns.TypeA, "10.0.0.2") + r.SetChainResolver(chain, 50) + r.records[q] = newRec() + + resp := queryA(t, r, q.Name) + assert.Equal(t, "10.0.0.1", firstA(t, resp), "stale entry must be served while refresh runs") + + waitFor(t, time.Second, func() bool { + return chain.callCount(q.Name, dns.TypeA) >= 1 + }) + }) + + t.Run("long TTL keeps entry fresh and skips refresh", func(t *testing.T) { + r := NewResolver() + r.cacheTTL = time.Hour + chain := newFakeChain() + chain.setAnswer(q.Name, dns.TypeA, "10.0.0.2") + r.SetChainResolver(chain, 50) + r.records[q] = newRec() + + resp := queryA(t, r, q.Name) + assert.Equal(t, "10.0.0.1", firstA(t, resp)) + + time.Sleep(50 * time.Millisecond) + assert.Equal(t, 0, chain.callCount(q.Name, dns.TypeA), "fresh entry must not trigger refresh") + }) +} + +func TestResolver_ServeFresh_NoRefresh(t *testing.T) { + r := NewResolver() + chain := newFakeChain() + chain.setAnswer("mgmt.example.com.", dns.TypeA, "10.0.0.2") + r.SetChainResolver(chain, 50) + + r.records[dns.Question{Name: "mgmt.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET}] = &cachedRecord{ + records: []dns.RR{&dns.A{ + Hdr: dns.RR_Header{Name: "mgmt.example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, + A: net.ParseIP("10.0.0.1").To4(), + }}, + cachedAt: time.Now(), // fresh + } + + resp := queryA(t, r, "mgmt.example.com.") + assert.Equal(t, "10.0.0.1", firstA(t, resp)) + + time.Sleep(20 * time.Millisecond) + assert.Equal(t, 0, chain.callCount("mgmt.example.com.", dns.TypeA), "fresh entry must not trigger refresh") +} + +func TestResolver_StaleTriggersAsyncRefresh(t *testing.T) { + r := NewResolver() + chain := newFakeChain() + chain.setAnswer("mgmt.example.com.", dns.TypeA, "10.0.0.2") + r.SetChainResolver(chain, 50) + + q := dns.Question{Name: "mgmt.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET} + r.records[q] = &cachedRecord{ + records: []dns.RR{&dns.A{ + Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, + A: net.ParseIP("10.0.0.1").To4(), + }}, + cachedAt: time.Now().Add(-2 * defaultTTL), // stale + } + + // First query: serves stale immediately. + resp := queryA(t, r, "mgmt.example.com.") + assert.Equal(t, "10.0.0.1", firstA(t, resp), "stale entry must be served while refresh runs") + + waitFor(t, time.Second, func() bool { + return chain.callCount("mgmt.example.com.", dns.TypeA) >= 1 + }) + + // Next query should now return the refreshed IP. + waitFor(t, time.Second, func() bool { + resp := queryA(t, r, "mgmt.example.com.") + return resp != nil && len(resp.Answer) > 0 && firstA(t, resp) == "10.0.0.2" + }) +} + +func TestResolver_ConcurrentStaleHitsCollapseRefresh(t *testing.T) { + r := NewResolver() + chain := newFakeChain() + chain.setAnswer("mgmt.example.com.", dns.TypeA, "10.0.0.2") + + var inflight atomic.Int32 + var maxInflight atomic.Int32 + chain.onLookup = func() { + cur := inflight.Add(1) + defer inflight.Add(-1) + for { + prev := maxInflight.Load() + if cur <= prev || maxInflight.CompareAndSwap(prev, cur) { + break + } + } + time.Sleep(50 * time.Millisecond) // hold inflight long enough to collide + } + + r.SetChainResolver(chain, 50) + + q := dns.Question{Name: "mgmt.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET} + r.records[q] = &cachedRecord{ + records: []dns.RR{&dns.A{ + Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, + A: net.ParseIP("10.0.0.1").To4(), + }}, + cachedAt: time.Now().Add(-2 * defaultTTL), + } + + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + queryA(t, r, "mgmt.example.com.") + }() + } + wg.Wait() + + waitFor(t, 2*time.Second, func() bool { + return inflight.Load() == 0 + }) + + calls := chain.callCount("mgmt.example.com.", dns.TypeA) + assert.LessOrEqual(t, calls, 2, "singleflight must collapse concurrent refreshes (got %d)", calls) + assert.Equal(t, int32(1), maxInflight.Load(), "only one refresh should run concurrently") +} + +func TestResolver_RefreshFailureArmsBackoff(t *testing.T) { + r := NewResolver() + chain := newFakeChain() + chain.err = errors.New("boom") + r.SetChainResolver(chain, 50) + + q := dns.Question{Name: "mgmt.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET} + r.records[q] = &cachedRecord{ + records: []dns.RR{&dns.A{ + Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, + A: net.ParseIP("10.0.0.1").To4(), + }}, + cachedAt: time.Now().Add(-2 * defaultTTL), + } + + // First stale hit triggers a refresh attempt that fails. + resp := queryA(t, r, "mgmt.example.com.") + assert.Equal(t, "10.0.0.1", firstA(t, resp), "stale entry served while refresh fails") + + waitFor(t, time.Second, func() bool { + return chain.callCount("mgmt.example.com.", dns.TypeA) == 1 + }) + waitFor(t, time.Second, func() bool { + r.mutex.RLock() + defer r.mutex.RUnlock() + c, ok := r.records[q] + return ok && !c.lastFailedRefresh.IsZero() + }) + + // Subsequent stale hits within backoff window should not schedule more refreshes. + for i := 0; i < 10; i++ { + queryA(t, r, "mgmt.example.com.") + } + time.Sleep(50 * time.Millisecond) + assert.Equal(t, 1, chain.callCount("mgmt.example.com.", dns.TypeA), "backoff must suppress further refreshes") +} + +func TestResolver_NoRootHandler_SkipsChain(t *testing.T) { + r := NewResolver() + chain := newFakeChain() + chain.hasRoot = false + chain.setAnswer("mgmt.example.com.", dns.TypeA, "10.0.0.2") + r.SetChainResolver(chain, 50) + + // With hasRoot=false the chain must not be consulted. Use a short + // deadline so the OS fallback returns quickly without waiting on a + // real network call in CI. + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _, _, _, _ = r.lookupBoth(ctx, domain.Domain("mgmt.example.com"), "mgmt.example.com.") + + assert.Equal(t, 0, chain.callCount("mgmt.example.com.", dns.TypeA), + "chain must not be used when no root handler is registered at the bound priority") +} + +func TestResolver_ServeDuringRefreshSetsLoopFlag(t *testing.T) { + // ServeDNS being invoked for a question while a refresh for that question + // is inflight indicates a resolver loop (OS resolver sent the recursive + // query back to us). The inflightRefresh.loopLoggedOnce flag must be set. + r := NewResolver() + + q := dns.Question{Name: "mgmt.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET} + r.records[q] = &cachedRecord{ + records: []dns.RR{&dns.A{ + Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, + A: net.ParseIP("10.0.0.1").To4(), + }}, + cachedAt: time.Now(), + } + + // Simulate an inflight refresh. + r.markRefreshing(q) + defer r.clearRefreshing(q) + + resp := queryA(t, r, "mgmt.example.com.") + assert.Equal(t, "10.0.0.1", firstA(t, resp), "stale entry must still be served to avoid breaking external queries") + + r.mutex.RLock() + inflight := r.refreshing[q] + r.mutex.RUnlock() + require.NotNil(t, inflight) + assert.True(t, inflight.Load(), "loop flag must be set once a ServeDNS during refresh was observed") +} + +func TestResolver_LoopFlagOnlyTrippedOncePerRefresh(t *testing.T) { + r := NewResolver() + + q := dns.Question{Name: "mgmt.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET} + r.records[q] = &cachedRecord{ + records: []dns.RR{&dns.A{ + Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, + A: net.ParseIP("10.0.0.1").To4(), + }}, + cachedAt: time.Now(), + } + + r.markRefreshing(q) + defer r.clearRefreshing(q) + + // Multiple ServeDNS calls during the same refresh must not re-set the flag + // (CompareAndSwap from false -> true returns true only on the first call). + for range 5 { + queryA(t, r, "mgmt.example.com.") + } + + r.mutex.RLock() + inflight := r.refreshing[q] + r.mutex.RUnlock() + assert.True(t, inflight.Load()) +} + +func TestResolver_NoLoopFlagWhenNotRefreshing(t *testing.T) { + r := NewResolver() + + q := dns.Question{Name: "mgmt.example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET} + r.records[q] = &cachedRecord{ + records: []dns.RR{&dns.A{ + Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, + A: net.ParseIP("10.0.0.1").To4(), + }}, + cachedAt: time.Now(), + } + + queryA(t, r, "mgmt.example.com.") + + r.mutex.RLock() + _, ok := r.refreshing[q] + r.mutex.RUnlock() + assert.False(t, ok, "no refresh inflight means no loop tracking") +} + +func TestResolver_AddDomain_UsesChainWhenRootRegistered(t *testing.T) { + r := NewResolver() + chain := newFakeChain() + chain.setAnswer("mgmt.example.com.", dns.TypeA, "10.0.0.2") + chain.setAnswer("mgmt.example.com.", dns.TypeAAAA, "fd00::2") + r.SetChainResolver(chain, 50) + + require.NoError(t, r.AddDomain(context.Background(), domain.Domain("mgmt.example.com"))) + + resp := queryA(t, r, "mgmt.example.com.") + assert.Equal(t, "10.0.0.2", firstA(t, resp)) + assert.Equal(t, 1, chain.callCount("mgmt.example.com.", dns.TypeA)) + assert.Equal(t, 1, chain.callCount("mgmt.example.com.", dns.TypeAAAA)) +} 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/mgmt/mgmt_test.go b/client/internal/dns/mgmt/mgmt_test.go index 99d289871..276cbba0a 100644 --- a/client/internal/dns/mgmt/mgmt_test.go +++ b/client/internal/dns/mgmt/mgmt_test.go @@ -6,6 +6,7 @@ import ( "net/url" "strings" "testing" + "time" "github.com/miekg/dns" "github.com/stretchr/testify/assert" @@ -23,6 +24,60 @@ func TestResolver_NewResolver(t *testing.T) { assert.False(t, resolver.MatchSubdomains()) } +func TestResolveCacheTTL(t *testing.T) { + tests := []struct { + name string + value string + want time.Duration + }{ + {"unset falls back to default", "", defaultTTL}, + {"valid duration", "45s", 45 * time.Second}, + {"valid minutes", "2m", 2 * time.Minute}, + {"malformed falls back to default", "not-a-duration", defaultTTL}, + {"zero falls back to default", "0s", defaultTTL}, + {"negative falls back to default", "-5s", defaultTTL}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(envMgmtCacheTTL, tc.value) + got := resolveCacheTTL() + assert.Equal(t, tc.want, got, "parsed TTL should match") + }) + } +} + +func TestNewResolver_CacheTTLFromEnv(t *testing.T) { + t.Setenv(envMgmtCacheTTL, "7s") + r := NewResolver() + assert.Equal(t, 7*time.Second, r.cacheTTL, "NewResolver should evaluate cacheTTL once from env") +} + +func TestResolver_ResponseTTL(t *testing.T) { + now := time.Now() + tests := []struct { + name string + cacheTTL time.Duration + cachedAt time.Time + wantMin uint32 + wantMax uint32 + }{ + {"fresh entry returns full TTL", 60 * time.Second, now, 59, 60}, + {"half-aged entry returns half TTL", 60 * time.Second, now.Add(-30 * time.Second), 29, 31}, + {"expired entry returns zero", 60 * time.Second, now.Add(-61 * time.Second), 0, 0}, + {"exactly expired returns zero", 10 * time.Second, now.Add(-10 * time.Second), 0, 0}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r := &Resolver{cacheTTL: tc.cacheTTL} + got := r.responseTTL(tc.cachedAt) + assert.GreaterOrEqual(t, got, tc.wantMin, "remaining TTL should be >= wantMin") + assert.LessOrEqual(t, got, tc.wantMax, "remaining TTL should be <= wantMax") + }) + } +} + func TestResolver_ExtractDomainFromURL(t *testing.T) { tests := []struct { name string @@ -391,7 +446,8 @@ func TestResolver_PartialUpdateAddsNewTypePreservesExisting(t *testing.T) { } assert.Len(t, resolver.GetCachedDomains(), 3) - // Update with partial ServerDomains (only flow domain - new type, should preserve all existing) + // Update with partial ServerDomains (only flow domain - flow is intentionally excluded from + // caching to prevent TLS failures from stale records, so all existing domains are preserved) partialDomains := dnsconfig.ServerDomains{ Flow: "github.com", } @@ -400,10 +456,10 @@ func TestResolver_PartialUpdateAddsNewTypePreservesExisting(t *testing.T) { t.Skipf("Skipping test due to DNS resolution failure: %v", err) } - assert.Len(t, removedDomains, 0, "Should not remove any domains when adding new type") + assert.Len(t, removedDomains, 0, "Should not remove any domains when only flow domain is provided") finalDomains := resolver.GetCachedDomains() - assert.Len(t, finalDomains, 4, "Should have all original domains plus new flow domain") + assert.Len(t, finalDomains, 3, "Flow domain is not cached; all original domains should be preserved") domainStrings := make([]string, len(finalDomains)) for i, d := range finalDomains { @@ -412,5 +468,5 @@ func TestResolver_PartialUpdateAddsNewTypePreservesExisting(t *testing.T) { assert.Contains(t, domainStrings, "example.org") assert.Contains(t, domainStrings, "google.com") assert.Contains(t, domainStrings, "cloudflare.com") - assert.Contains(t, domainStrings, "github.com") + assert.NotContains(t, domainStrings, "github.com") } diff --git a/client/internal/dns/mock_server.go b/client/internal/dns/mock_server.go index 0f89b9016..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) @@ -84,3 +82,33 @@ func (m *MockServer) UpdateServerConfig(domains dnsconfig.ServerDomains) error { func (m *MockServer) PopulateManagementDomain(mgmtURL *url.URL) error { return nil } + +// SetRouteSources mock implementation of SetRouteSources from Server interface +func (m *MockServer) SetRouteSources(selected, active func() route.HAMap) { + // Mock implementation - no-op +} + +// SetFirewall mock implementation of SetFirewall from Server interface +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 +} + +// EndBatch mock implementation of EndBatch from Server interface +func (m *MockServer) EndBatch() { + // Mock implementation - no-op +} + +// CancelBatch mock implementation of CancelBatch from Server interface +func (m *MockServer) CancelBatch() { + // Mock implementation - no-op +} diff --git a/client/internal/dns/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/response_writer.go b/client/internal/dns/response_writer.go index edc65a5d9..287cf28b0 100644 --- a/client/internal/dns/response_writer.go +++ b/client/internal/dns/response_writer.go @@ -104,3 +104,23 @@ func (r *responseWriter) TsigTimersOnly(bool) { // After a call to Hijack(), the DNS package will not do anything with the connection. func (r *responseWriter) Hijack() { } + +// remoteAddrFromPacket extracts the source IP:port from a decoded packet for logging. +func remoteAddrFromPacket(packet gopacket.Packet) *net.UDPAddr { + var srcIP net.IP + if ipv4 := packet.Layer(layers.LayerTypeIPv4); ipv4 != nil { + srcIP = ipv4.(*layers.IPv4).SrcIP + } else if ipv6 := packet.Layer(layers.LayerTypeIPv6); ipv6 != nil { + srcIP = ipv6.(*layers.IPv6).SrcIP + } + + var srcPort int + if udp := packet.Layer(layers.LayerTypeUDP); udp != nil { + srcPort = int(udp.(*layers.UDP).SrcPort) + } + + if srcIP == nil { + return nil + } + return &net.UDPAddr{IP: srcIP, Port: srcPort} +} diff --git a/client/internal/dns/resutil/resolve.go b/client/internal/dns/resutil/resolve.go new file mode 100644 index 000000000..931938755 --- /dev/null +++ b/client/internal/dns/resutil/resolve.go @@ -0,0 +1,469 @@ +// Package resutil provides shared DNS resolution utilities +package resutil + +import ( + "context" + "crypto/rand" + "encoding/hex" + "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) + if _, err := rand.Read(bytes); err != nil { + log.Errorf("generate request ID: %v", err) + return "" + } + return hex.EncodeToString(bytes) +} + +// IPsToRRs converts a slice of IP addresses to DNS resource records. +// IPv4 addresses become A records, IPv6 addresses become AAAA records. +func IPsToRRs(name string, ips []netip.Addr, ttl uint32) []dns.RR { + var result []dns.RR + + for _, ip := range ips { + if ip.Is6() { + result = append(result, &dns.AAAA{ + Hdr: dns.RR_Header{ + Name: name, + Rrtype: dns.TypeAAAA, + Class: dns.ClassINET, + Ttl: ttl, + }, + AAAA: ip.AsSlice(), + }) + } else { + result = append(result, &dns.A{ + Hdr: dns.RR_Header{ + Name: name, + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: ttl, + }, + A: ip.AsSlice(), + }) + } + } + + return result +} + +// NetworkForQtype returns the network string ("ip4" or "ip6") for a DNS query type. +// Returns empty string for unsupported types. +func NetworkForQtype(qtype uint16) string { + switch qtype { + case dns.TypeA: + return "ip4" + case dns.TypeAAAA: + return "ip6" + default: + return "" + } +} + +type resolver interface { + LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) +} + +// chainedWriter is implemented by ResponseWriters that carry request metadata +type chainedWriter interface { + RequestID() string + SetMeta(key, value string) +} + +// GetRequestID extracts a request ID from the ResponseWriter if available, +// otherwise generates a new one. +func GetRequestID(w dns.ResponseWriter) string { + if cw, ok := w.(chainedWriter); ok { + if id := cw.RequestID(); id != "" { + return id + } + } + return GenerateRequestID() +} + +// SetMeta sets metadata on the ResponseWriter if it supports it. +func SetMeta(w dns.ResponseWriter, key, value string) { + if cw, ok := w.(chainedWriter); ok { + cw.SetMeta(key, value) + } +} + +// LookupResult contains the result of an external DNS lookup +type LookupResult struct { + IPs []netip.Addr + Rcode int + Err error // Original error for caller's logging needs +} + +// LookupIP performs a DNS lookup and determines the appropriate rcode. +func LookupIP(ctx context.Context, r resolver, network, host string, qtype uint16) LookupResult { + ips, err := r.LookupNetIP(ctx, network, host) + if err != nil { + return LookupResult{ + Rcode: getRcodeForError(ctx, r, host, qtype, err), + Err: err, + } + } + + // Unmap IPv4-mapped IPv6 addresses that some resolvers may return + for i, ip := range ips { + ips[i] = ip.Unmap() + } + + return LookupResult{ + IPs: ips, + Rcode: dns.RcodeSuccess, + } +} + +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 + } + + if dnsErr.IsNotFound { + return getRcodeForNotFound(ctx, r, host, qtype) + } + + return dns.RcodeServerFailure +} + +// getRcodeForNotFound distinguishes between NXDOMAIN (domain doesn't exist) and NODATA +// (domain exists but no records of requested type) by checking the opposite record type. +// +// musl libc (the reason we need this distinction) only queries A/AAAA pairs in getaddrinfo, +// so checking the opposite A/AAAA type is sufficient. Other record types (MX, TXT, etc.) +// are not queried by musl and don't need this handling. +func getRcodeForNotFound(ctx context.Context, r resolver, domain string, originalQtype uint16) int { + // Try querying for a different record type to see if the domain exists + // If the original query was for AAAA, try A. If it was for A, try AAAA. + // This helps distinguish between NXDOMAIN and NODATA. + var alternativeNetwork string + switch originalQtype { + case dns.TypeAAAA: + alternativeNetwork = "ip4" + case dns.TypeA: + alternativeNetwork = "ip6" + default: + // 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 { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && dnsErr.IsNotFound { + // Alternative query also returned not found - domain truly doesn't exist + return dns.RcodeNameError + } + // Some other error (timeout, server failure, etc.) - can't determine, assume domain exists + return dns.RcodeSuccess + } + + // Alternative query succeeded - domain exists but has no records of this type + 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 { + return "[]" + } + + parts := make([]string, 0, len(answers)) + for _, rr := range answers { + switch r := rr.(type) { + case *dns.A: + parts = append(parts, r.A.String()) + case *dns.AAAA: + parts = append(parts, r.AAAA.String()) + case *dns.CNAME: + parts = append(parts, "CNAME:"+r.Target) + case *dns.PTR: + parts = append(parts, "PTR:"+r.Ptr) + default: + parts = append(parts, dns.TypeToString[rr.Header().Rrtype]) + } + } + 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 afaf0579f..f79454457 100644 --- a/client/internal/dns/server.go +++ b/client/internal/dns/server.go @@ -6,9 +6,11 @@ import ( "fmt" "net/netip" "net/url" - "runtime" + "os" + "slices" "strings" "sync" + "time" "github.com/miekg/dns" "github.com/mitchellh/hashstructure/v2" @@ -23,10 +25,36 @@ 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 ( + // 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 { OnReady() @@ -41,15 +69,20 @@ type IosDnsManager interface { type Server interface { RegisterHandler(domains domain.List, handler dns.Handler, priority int) DeregisterHandler(domains domain.List, priority int) + BeginBatch() + EndBatch() + CancelBatch() Initialize() error Stop() DnsIP() netip.Addr UpdateDNSServer(serial uint64, update nbdns.Config) error OnUpdatedHostDNSServer(addrs []netip.AddrPort) SearchDomains() []string - ProbeAvailability() UpdateServerConfig(domains dnsconfig.ServerDomains) error PopulateManagementDomain(mgmtURL *url.URL) error + SetRouteSources(selected, active func() route.HAMap) + SetFirewall(Firewall) + SetPeerActivator(local.PeerActivator) } type nsGroupsByDomain struct { @@ -57,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 @@ -73,15 +141,17 @@ type DefaultServer struct { disableSys bool mux sync.Mutex service service - dnsMuxMap registeredHandlerMap + dnsMuxHandlers []handlerWrapper localResolver *local.Resolver wgInterface WGIface hostManager hostManager updateSerial uint64 previousConfigHash uint64 currentConfig HostDNSConfig + currentConfigHash uint64 handlerChain *HandlerChain extraDomains map[domain.Domain]int + batchMode bool mgmtCacheResolver *mgmt.Resolver @@ -89,29 +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 + // selectedRoutes returns admin-enabled client routes. + selectedRoutes func() route.HAMap + // activeRoutes returns the subset whose peer is in StatusConnected. + activeRoutes func() route.HAMap + + 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() 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 @@ -136,7 +229,7 @@ func NewDefaultServer(ctx context.Context, config DefaultServerConfig) (*Default if config.WgInterface.IsUserspaceBind() { dnsService = NewServiceViaMemory(config.WgInterface) } else { - dnsService = newServiceViaListener(config.WgInterface, addrPort) + dnsService = newServiceViaListener(config.WgInterface, addrPort, nil) } server := newDefaultServer(ctx, config.WgInterface, dnsService, config.StatusRecorder, config.StateManager, config.DisableSys) @@ -158,7 +251,6 @@ 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.setListener(listener) @@ -166,7 +258,7 @@ func NewDefaultServerPermanentUpstream( 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, @@ -176,6 +268,7 @@ func NewDefaultServerIos( ) *DefaultServer { ds := newDefaultServer(ctx, wgInterface, NewServiceViaMemory(wgInterface), statusRecorder, nil, disableSys) ds.iosDnsManager = iosDnsManager + ds.permanent = true return ds } @@ -191,6 +284,7 @@ func newDefaultServer( ctx, stop := context.WithCancel(ctx) mgmtCacheResolver := mgmt.NewResolver() + mgmtCacheResolver.SetChainResolver(handlerChain, PriorityUpstream) defaultServer := &DefaultServer{ ctx: ctx, @@ -199,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, @@ -207,7 +300,15 @@ func newDefaultServer( hostsDNSHolder: newHostsDNSHolder(), 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) @@ -215,6 +316,28 @@ func newDefaultServer( return defaultServer } +// 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.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. // Any previously registered handler for the same domain and priority will be replaced. func (s *DefaultServer) RegisterHandler(domains domain.List, handler dns.Handler, priority int) { @@ -225,10 +348,11 @@ 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)]++ } - s.applyHostConfig() + if !s.batchMode { + s.applyHostConfig() + } } func (s *DefaultServer) registerHandler(domains []string, handler dns.Handler, priority int) { @@ -257,9 +381,41 @@ func (s *DefaultServer) DeregisterHandler(domains domain.List, priority int) { delete(s.extraDomains, zone) } } + if !s.batchMode { + s.applyHostConfig() + } +} + +// BeginBatch starts batch mode for DNS handler registration/deregistration. +// In batch mode, applyHostConfig() is not called after each handler operation, +// allowing multiple handlers to be registered/deregistered efficiently. +// Must be followed by EndBatch() to apply the accumulated changes. +func (s *DefaultServer) BeginBatch() { + s.mux.Lock() + defer s.mux.Unlock() + log.Debugf("DNS batch mode enabled") + s.batchMode = true +} + +// EndBatch ends batch mode and applies all accumulated DNS configuration changes. +func (s *DefaultServer) EndBatch() { + s.mux.Lock() + defer s.mux.Unlock() + log.Debugf("DNS batch mode disabled, applying accumulated changes") + s.batchMode = false s.applyHostConfig() } +// CancelBatch cancels batch mode without applying accumulated changes. +// This is useful when operations fail partway through and you want to +// discard partial state rather than applying it. +func (s *DefaultServer) CancelBatch() { + s.mux.Lock() + defer s.mux.Unlock() + log.Debugf("DNS batch mode cancelled, discarding accumulated changes") + s.batchMode = false +} + func (s *DefaultServer) deregisterHandler(domains []string, priority int) { log.Debugf("deregistering handler with priority %d for %v", priority, domains) @@ -292,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. @@ -305,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 } @@ -316,6 +481,24 @@ func (s *DefaultServer) DnsIP() netip.Addr { return s.service.RuntimeIP() } +// SetFirewall sets the firewall used for DNS port DNAT rules. +// This must be called before Initialize when using the listener-based service, +// because the firewall is typically not available at construction time. +func (s *DefaultServer) SetFirewall(fw Firewall) { + if svc, ok := s.service.(*serviceViaListener); ok { + svc.listenerFlagLock.Lock() + svc.firewall = fw + svc.listenerFlagLock.Unlock() + } +} + +// SetPeerActivator wires the DNS-time lazy-connection warm-up on the local +// resolver. Injected after the connection manager exists (it does not at +// DNS-server construction time). Pass nil to disable. +func (s *DefaultServer) SetPeerActivator(a local.PeerActivator) { + s.localResolver.SetPeerActivator(a) +} + // Stop stops the server func (s *DefaultServer) Stop() { s.ctxCancel() @@ -328,20 +511,30 @@ 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() error { - defer s.service.Stop() +func (s *DefaultServer) disableDNS() (retErr error) { + defer func() { + if err := s.service.Stop(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("stop DNS service: %w", err)) + } + }() if s.isUsingNoopHostManager() { 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 { @@ -355,27 +548,16 @@ func (s *DefaultServer) disableDNS() 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 @@ -434,20 +616,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 -func (s *DefaultServer) ProbeAvailability() { - var wg sync.WaitGroup - for _, mux := range s.dnsMuxMap { - wg.Add(1) - go func(mux handlerWithStop) { - defer wg.Done() - mux.ProbeAvailability() - }(mux.handler) - } - wg.Wait() -} - func (s *DefaultServer) UpdateServerConfig(domains dnsconfig.ServerDomains) error { s.mux.Lock() defer s.mux.Unlock() @@ -483,7 +651,7 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error { } } - localMuxUpdates, localRecords, err := s.buildLocalHandlerUpdate(update.CustomZones) + localMuxUpdates, localZones, err := s.buildLocalHandlerUpdate(update.CustomZones) if err != nil { return fmt.Errorf("local handler updater: %w", err) } @@ -496,8 +664,7 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error { s.updateMux(muxUpdates) - // register local records - s.localResolver.Update(localRecords) + s.localResolver.Update(localZones) s.currentConfig = dnsConfigToHostDNSConfig(update, s.service.RuntimeIP(), s.service.RuntimePort()) @@ -507,6 +674,7 @@ func (s *DefaultServer) applyConfiguration(update nbdns.Config) error { s.currentConfig.RouteAll = false } + // Always apply host config for management updates, regardless of batch mode s.applyHostConfig() s.shutdownWg.Add(1) @@ -586,32 +754,61 @@ func (s *DefaultServer) applyHostConfig() { log.Debugf("extra match domains: %v", maps.Keys(s.extraDomains)) + hash, err := hashstructure.Hash(config, hashstructure.FormatV2, &hashstructure.HashOptions{ + ZeroNil: true, + IgnoreZeroValue: true, + SlicesAsSets: true, + UseStringer: true, + }) + if err != nil { + log.Warnf("unable to hash the host dns configuration, will apply config anyway: %s", err) + // Fall through to apply config anyway (fail-safe approach) + } else if s.currentConfigHash == hash { + log.Debugf("not applying host config as there are no changes") + return + } + + log.Debugf("applying host config as there are changes") if err := s.hostManager.applyDNSConfig(config, s.stateManager); err != nil { log.Errorf("failed to apply DNS host manager update: %v", err) + return } - s.registerFallback(config) + // Only update hash if it was computed successfully and config was applied + if err == nil { + s.currentConfigHash = hash + } + + s.registerFallback() } -// registerFallback registers original nameservers as low-priority fallback handlers -func (s *DefaultServer) registerFallback(config HostDNSConfig) { - hostMgrWithNS, ok := s.hostManager.(hostManagerWithOriginalNS) - if !ok { +// registerFallback registers original nameservers as low-priority fallback handlers. +// 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 { - 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, - s.wgInterface.Name(), - s.wgInterface.Address().IP, - s.wgInterface.Address().Network, + s.wgInterface, s.statusRecorder, s.hostsDNSHolder, nbdns.RootZone, @@ -620,25 +817,28 @@ func (s *DefaultServer) registerFallback(config HostDNSConfig) { log.Errorf("failed to create upstream resolver for original nameservers: %v", err) return } + handler.selectedRoutes = s.selectedRoutes + handler.addRace(servers) - 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 */ } - + prev := s.fallbackHandler + s.fallbackHandler = handler s.registerHandler([]string{nbdns.RootZone}, handler, PriorityFallback) + if prev != nil { + prev.Stop() + } } -func (s *DefaultServer) buildLocalHandlerUpdate(customZones []nbdns.CustomZone) ([]handlerWrapper, []nbdns.SimpleRecord, error) { +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) { var muxUpdates []handlerWrapper - var localRecords []nbdns.SimpleRecord + var zones []nbdns.CustomZone for _, customZone := range customZones { if len(customZone.Records) == 0 { @@ -652,17 +852,20 @@ func (s *DefaultServer) buildLocalHandlerUpdate(customZones []nbdns.CustomZone) priority: PriorityLocal, }) + // zone records contain the fqdn, so we can just flatten them + var localRecords []nbdns.SimpleRecord for _, record := range customZone.Records { if record.Class != nbdns.DefaultClass { log.Warnf("received an invalid class type: %s", record.Class) continue } - // zone records contain the fqdn, so we can just flatten them localRecords = append(localRecords, record) } + customZone.Records = localRecords + zones = append(zones, customZone) } - return muxUpdates, localRecords, nil + return muxUpdates, zones, nil } func (s *DefaultServer) buildUpstreamHandlerUpdate(nameServerGroups []*nbdns.NameServerGroup) ([]handlerWrapper, error) { @@ -688,279 +891,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 +// 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, + domain.Domain(domainGroup.domain), + ) + if err != nil { + return nil, fmt.Errorf("create upstream resolver: %v", err) + } + handler.selectedRoutes = s.selectedRoutes - 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.Name(), - s.wgInterface.Address().IP, - s.wgInterface.Address().Network, - s.statusRecorder, - s.hostsDNSHolder, - domainGroup.domain, - ) - if err != nil { - return nil, fmt.Errorf("create upstream resolver: %v", err) - } - - 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") + 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 } - - // 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, - }) + handler.addRace(servers) } - return muxUpdates, nil + 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) 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 +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 +} - return false +// 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.dnsMuxMap { + for _, existing := range s.dnsMuxHandlers { s.deregisterHandler([]string{existing.domain}, existing.priority) - existing.handler.Stop() + // 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() + } } - 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 - } - } - - 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) - } - - 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 - } - - handler, err := newUpstreamResolver( - s.ctx, - s.wgInterface.Name(), - s.wgInterface.Address().IP, - s.wgInterface.Address().Network, - s.statusRecorder, - s.hostsDNSHolder, - nbdns.RootZone, - ) - if err != nil { - log.Errorf("unable to create a new upstream resolver, error: %v", err) - return - } - - handler.upstreamServers = maps.Keys(hostDNSServers) - handler.deactivate = func(error) {} - handler.reactivate = func() {} - - s.registerHandler([]string{nbdns.RootZone}, handler, PriorityDefault) + 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) { - var states []peer.NSGroupState + s.nsGroups = groups + select { + case s.healthRefresh <- struct{}{}: + default: + } +} - for _, group := range groups { - var servers []netip.AddrPort - for _, ns := range group.NameServers { - servers = append(servers, ns.AddrPort()) +// 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 + } + + s.healthProjectMu.Lock() + defer s.healthProjectMu.Unlock() + + if s.nsGroupProj == nil { + s.nsGroupProj = make(map[nsGroupID]*nsGroupProj) + } + + 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{}{} + + 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 @@ -1002,6 +1409,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 { @@ -1009,3 +1431,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_export_test.go b/client/internal/dns/server_export_test.go index 1fa343b52..25d08d698 100644 --- a/client/internal/dns/server_export_test.go +++ b/client/internal/dns/server_export_test.go @@ -18,7 +18,12 @@ func TestGetServerDns(t *testing.T) { t.Errorf("invalid dns server instance: %s", err) } - if srvB != srv { + mockSrvB, ok := srvB.(*MockServer) + if !ok { + t.Errorf("returned server is not a MockServer") + } + + if mockSrvB != srv { t.Errorf("mismatch dns instances") } } diff --git a/client/internal/dns/server_privileged_test.go b/client/internal/dns/server_privileged_test.go new file mode 100644 index 000000000..a03aea169 --- /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" + + "github.com/golang/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 451b83f92..96e55a354 100644 --- a/client/internal/dns/server_test.go +++ b/client/internal/dns/server_test.go @@ -6,22 +6,22 @@ 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" "github.com/netbirdio/netbird/client/firewall/uspfilter" "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" @@ -30,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" ) @@ -81,6 +83,10 @@ func (w *mocWGIface) GetStats(_ string) (configurer.WGStats, error) { return configurer.WGStats{}, nil } +func (w *mocWGIface) GetNet() *netstack.Net { + return nil +} + var zoneRecords = []nbdns.SimpleRecord{ { Name: "peera.netbird.cloud", @@ -96,481 +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 - initLocalRecords []nbdns.SimpleRecord - 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", - initLocalRecords: []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", - initLocalRecords: []nbdns.SimpleRecord{}, - initUpstreamMap: make(registeredHandlerMap), - initSerial: 2, - inputSerial: 1, - shouldFail: true, - }, - { - name: "Empty NS Group Domain Or Not Primary Element Should Fail", - initLocalRecords: []nbdns.SimpleRecord{}, - 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", - initLocalRecords: []nbdns.SimpleRecord{}, - 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", - initLocalRecords: []nbdns.SimpleRecord{}, - 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", - initLocalRecords: []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", - initLocalRecords: []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(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.initLocalRecords) - 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([]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().AddUDPPacketHook(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()) - packetfilter.EXPECT().RemovePacketHook(gomock.Any()) - - 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.RegisteredMap = local.RegistrationMap{local.BuildRecordKey("netbird.cloud", dns.ClassINET, dns.TypeA): struct{}{}} - dnsServer.localResolver.Update([]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 @@ -649,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") @@ -744,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") @@ -837,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") @@ -909,13 +376,25 @@ 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") defer t.Setenv("NB_WG_KERNEL_DISABLED", ov) t.Setenv("NB_WG_KERNEL_DISABLED", "true") - newNet, err := stdnet.NewNet([]string{"utun2301"}) + newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"}) if err != nil { t.Fatalf("create stdnet: %v", err) return nil, err @@ -925,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, @@ -1061,28 +540,27 @@ type mockHandler struct { func (m *mockHandler) ServeDNS(dns.ResponseWriter, *dns.Msg) {} func (m *mockHandler) Stop() {} -func (m *mockHandler) ProbeAvailability() {} func (m *mockHandler) ID() types.HandlerID { return types.HandlerID(m.Id) } type mockService struct{} func (m *mockService) Listen() error { return nil } -func (m *mockService) Stop() {} +func (m *mockService) Stop() error { return nil } func (m *mockService) RuntimeIP() netip.Addr { return netip.MustParseAddr("127.0.0.1") } func (m *mockService) RuntimePort() int { return 53 } 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", @@ -1091,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", @@ -1108,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", @@ -1134,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 @@ -1418,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 @@ -1458,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) { @@ -1467,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 @@ -1602,7 +1182,10 @@ func TestExtraDomains(t *testing.T) { "other.example.com.", "duplicate.example.com.", }, - applyHostConfigCall: 4, + // Expect 3 calls instead of 4 because when deregistering duplicate.example.com, + // the domain remains in the config (ref count goes from 2 to 1), so the host + // config hash doesn't change and applyDNSConfig is not called. + applyHostConfigCall: 3, }, { name: "Config update with new domains after registration", @@ -1657,7 +1240,10 @@ func TestExtraDomains(t *testing.T) { expectedMatchOnly: []string{ "extra.example.com.", }, - applyHostConfigCall: 3, + // Expect 2 calls instead of 3 because when deregistering protected.example.com, + // it's removed from extraDomains but still remains in the config (from customZones), + // so the host config hash doesn't change and applyDNSConfig is not called. + applyHostConfigCall: 2, }, { name: "Register domain that is part of nameserver group", @@ -2042,7 +1628,7 @@ func TestLocalResolverPriorityInServer(t *testing.T) { func TestLocalResolverPriorityConstants(t *testing.T) { // Test that priority constants are ordered correctly - assert.Greater(t, PriorityLocal, PriorityDNSRoute, "Local priority should be higher than DNS route") + assert.Greater(t, PriorityDNSRoute, PriorityLocal, "DNS Route should be higher than Local priority") assert.Greater(t, PriorityLocal, PriorityUpstream, "Local priority should be higher than upstream") assert.Greater(t, PriorityUpstream, PriorityDefault, "Upstream priority should be higher than default") @@ -2075,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) @@ -2087,7 +2286,6 @@ func TestDNSLoopPrevention(t *testing.T) { localResolver: local.NewResolver(), handlerChain: NewHandlerChain(), hostManager: &noopHostConfigurator{}, - dnsMuxMap: make(registeredHandlerMap), } tests := []struct { @@ -2173,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 6a76c53e3..04bcd5985 100644 --- a/client/internal/dns/service.go +++ b/client/internal/dns/service.go @@ -4,15 +4,25 @@ import ( "net/netip" "github.com/miekg/dns" + + firewall "github.com/netbirdio/netbird/client/firewall/manager" ) const ( DefaultPort = 53 ) +// Firewall provides DNAT capabilities for DNS port redirection. +// 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, originalPort, translatedPort uint16) error + RemoveOutputDNAT(localAddr netip.Addr, protocol firewall.Protocol, originalPort, translatedPort uint16) error +} + type service interface { Listen() error - Stop() + Stop() error RegisterMux(domain string, handler dns.Handler) DeregisterMux(key string) RuntimePort() int diff --git a/client/internal/dns/service_listener.go b/client/internal/dns/service_listener.go index 806559444..3dc29c4dc 100644 --- a/client/internal/dns/service_listener.go +++ b/client/internal/dns/service_listener.go @@ -6,12 +6,17 @@ import ( "net" "net/netip" "runtime" + "strconv" "sync" "time" + "github.com/hashicorp/go-multierror" "github.com/miekg/dns" log "github.com/sirupsen/logrus" + 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" ) @@ -30,25 +35,33 @@ type serviceViaListener struct { dnsMux *dns.ServeMux customAddr *netip.AddrPort server *dns.Server + tcpServer *dns.Server listenIP netip.Addr listenPort uint16 listenerIsRunning bool listenerFlagLock sync.Mutex ebpfService ebpfMgr.Manager + firewall Firewall + tcpDNATConfigured bool } -func newServiceViaListener(wgIface WGIface, customAddr *netip.AddrPort) *serviceViaListener { +func newServiceViaListener(wgIface WGIface, customAddr *netip.AddrPort, fw Firewall) *serviceViaListener { mux := dns.NewServeMux() s := &serviceViaListener{ wgInterface: wgIface, dnsMux: mux, customAddr: customAddr, + firewall: fw, server: &dns.Server{ Net: "udp", Handler: mux, UDPSize: 65535, }, + tcpServer: &dns.Server{ + Net: "tcp", + Handler: mux, + }, } return s @@ -69,43 +82,86 @@ func (s *serviceViaListener) Listen() error { return fmt.Errorf("eval listen address: %w", err) } s.listenIP = s.listenIP.Unmap() - s.server.Addr = fmt.Sprintf("%s:%d", s.listenIP, s.listenPort) - log.Debugf("starting dns on %s", s.server.Addr) - go func() { - s.setListenerStatus(true) - defer s.setListenerStatus(false) + addr := net.JoinHostPort(s.listenIP.String(), strconv.Itoa(int(s.listenPort))) + s.server.Addr = addr + s.tcpServer.Addr = addr - err := s.server.ListenAndServe() - if err != nil { - log.Errorf("dns server running with %d port returned an error: %v. Will not retry", s.listenPort, err) + log.Debugf("starting dns on %s (UDP + TCP)", addr) + s.listenerIsRunning = true + + go func() { + if err := s.server.ListenAndServe(); err != nil { + log.Errorf("failed to run DNS UDP server on port %d: %v", s.listenPort, err) + } + + s.listenerFlagLock.Lock() + unexpected := s.listenerIsRunning + s.listenerIsRunning = false + s.listenerFlagLock.Unlock() + + if unexpected { + if err := s.tcpServer.Shutdown(); err != nil { + log.Debugf("failed to shutdown DNS TCP server: %v", err) + } } }() + go func() { + if err := s.tcpServer.ListenAndServe(); err != nil { + log.Errorf("failed to run DNS TCP server on port %d: %v", s.listenPort, err) + } + }() + + // 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) + } + } + return nil } -func (s *serviceViaListener) Stop() { +func (s *serviceViaListener) Stop() error { s.listenerFlagLock.Lock() defer s.listenerFlagLock.Unlock() if !s.listenerIsRunning { - return + return nil } + s.listenerIsRunning = false ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - err := s.server.ShutdownContext(ctx) - if err != nil { - log.Errorf("stopping dns server listener returned an error: %v", err) + var merr *multierror.Error + + if err := s.server.ShutdownContext(ctx); err != nil { + merr = multierror.Append(merr, fmt.Errorf("stop DNS UDP server: %w", err)) + } + + if err := s.tcpServer.ShutdownContext(ctx); err != nil { + 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 { - err = s.ebpfService.FreeDNSFwd() - if err != nil { - log.Errorf("stopping traffic forwarder returned an error: %v", err) + if err := s.ebpfService.FreeDNSFwd(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("stop traffic forwarder: %w", err)) } } + + return nberrors.FormatErrorOrNil(merr) } func (s *serviceViaListener) RegisterMux(pattern string, handler dns.Handler) { @@ -132,17 +188,10 @@ func (s *serviceViaListener) RuntimeIP() netip.Addr { return s.listenIP } -func (s *serviceViaListener) setListenerStatus(running bool) { - s.listenerFlagLock.Lock() - defer s.listenerFlagLock.Unlock() - - s.listenerIsRunning = running -} - -// 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. +// First checks port 53 on WG interface or lo, then tries eBPF on a random port, +// then falls back to port 5053. func (s *serviceViaListener) evalListenAddress() (netip.Addr, uint16, error) { if s.customAddr != nil { return s.customAddr.Addr(), s.customAddr.Port(), nil @@ -186,18 +235,28 @@ func (s *serviceViaListener) testFreePort(port int) (netip.Addr, bool) { } func (s *serviceViaListener) tryToBind(ip netip.Addr, port int) bool { - addrString := fmt.Sprintf("%s:%d", ip, port) - udpAddr := net.UDPAddrFromAddrPort(netip.MustParseAddrPort(addrString)) - probeListener, err := net.ListenUDP("udp", udpAddr) + addrPort := netip.AddrPortFrom(ip, uint16(port)) + + udpAddr := net.UDPAddrFromAddrPort(addrPort) + udpLn, err := net.ListenUDP("udp", udpAddr) if err != nil { - log.Warnf("binding dns on %s is not available, error: %s", addrString, err) + log.Warnf("binding dns UDP on %s is not available: %s", addrPort, err) return false } - - err = probeListener.Close() - if err != nil { - log.Errorf("got an error closing the probe listener, error: %s", err) + if err := udpLn.Close(); err != nil { + log.Debugf("close UDP probe listener: %s", err) } + + tcpAddr := net.TCPAddrFromAddrPort(addrPort) + tcpLn, err := net.ListenTCP("tcp", tcpAddr) + if err != nil { + log.Warnf("binding dns TCP on %s is not available: %s", addrPort, err) + return false + } + if err := tcpLn.Close(); err != nil { + log.Debugf("close TCP probe listener: %s", err) + } + return true } @@ -218,7 +277,7 @@ func (s *serviceViaListener) tryToUseeBPF() (ebpfMgr.Manager, uint16, bool) { } ebpfSrv := ebpf.GetEbpfManagerInstance() - err = ebpfSrv.LoadDNSFwd(s.wgInterface.Address().IP.String(), int(port)) + err = ebpfSrv.LoadDNSFwd(s.wgInterface.Address().IP, int(port)) if err != nil { log.Warnf("failed to load DNS forwarder eBPF program, error: %s", err) return nil, 0, false @@ -233,18 +292,16 @@ func (s *serviceViaListener) generateFreePort() (uint16, error) { return customPort, nil } - udpAddr := net.UDPAddrFromAddrPort(netip.MustParseAddrPort("0.0.0.0:0")) - probeListener, err := net.ListenUDP("udp", udpAddr) + probeListener, err := net.ListenUDP("udp4", &net.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 { + port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port) + if err = probeListener.Close(); err != nil { log.Debugf("failed to free up DNS port: %s", err) return 0, err } - return addrPort.Port(), nil + return port, nil } diff --git a/client/internal/dns/service_listener_test.go b/client/internal/dns/service_listener_test.go new file mode 100644 index 000000000..90ef71d19 --- /dev/null +++ b/client/internal/dns/service_listener_test.go @@ -0,0 +1,86 @@ +package dns + +import ( + "fmt" + "net" + "net/netip" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestServiceViaListener_TCPAndUDP(t *testing.T) { + handler := dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + m.Answer = append(m.Answer, &dns.A{ + Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, + A: net.ParseIP("192.0.2.1"), + }) + if err := w.WriteMsg(m); err != nil { + t.Logf("write msg: %v", err) + } + }) + + // Create a service using a custom address to avoid needing root + svc := newServiceViaListener(nil, nil, nil) + svc.dnsMux.Handle(".", handler) + + // Bind both transports up front to avoid TOCTOU races. + udpAddr := net.UDPAddrFromAddrPort(netip.AddrPortFrom(customIP, 0)) + udpConn, err := net.ListenUDP("udp", udpAddr) + if err != nil { + t.Skip("cannot bind to 127.0.0.153, skipping") + } + port := uint16(udpConn.LocalAddr().(*net.UDPAddr).Port) + + tcpAddr := net.TCPAddrFromAddrPort(netip.AddrPortFrom(customIP, port)) + tcpLn, err := net.ListenTCP("tcp", tcpAddr) + if err != nil { + udpConn.Close() + t.Skip("cannot bind TCP on same port, skipping") + } + + addr := fmt.Sprintf("%s:%d", customIP, port) + svc.server.PacketConn = udpConn + svc.tcpServer.Listener = tcpLn + svc.listenIP = customIP + svc.listenPort = port + + go func() { + if err := svc.server.ActivateAndServe(); err != nil { + t.Logf("udp server: %v", err) + } + }() + go func() { + if err := svc.tcpServer.ActivateAndServe(); err != nil { + t.Logf("tcp server: %v", err) + } + }() + svc.listenerIsRunning = true + + defer func() { + require.NoError(t, svc.Stop()) + }() + + q := new(dns.Msg).SetQuestion("example.com.", dns.TypeA) + + // Test UDP query + udpClient := &dns.Client{Net: "udp", Timeout: 2 * time.Second} + udpResp, _, err := udpClient.Exchange(q, addr) + require.NoError(t, err, "UDP query should succeed") + require.NotNil(t, udpResp) + require.NotEmpty(t, udpResp.Answer) + assert.Contains(t, udpResp.Answer[0].String(), "192.0.2.1", "UDP response should contain expected IP") + + // Test TCP query + tcpClient := &dns.Client{Net: "tcp", Timeout: 2 * time.Second} + tcpResp, _, err := tcpClient.Exchange(q, addr) + require.NoError(t, err, "TCP query should succeed") + require.NotNil(t, tcpResp) + require.NotEmpty(t, tcpResp.Answer) + assert.Contains(t, tcpResp.Answer[0].String(), "192.0.2.1", "TCP response should contain expected IP") +} diff --git a/client/internal/dns/service_memory.go b/client/internal/dns/service_memory.go index 6ef0ab526..e8c036076 100644 --- a/client/internal/dns/service_memory.go +++ b/client/internal/dns/service_memory.go @@ -1,6 +1,7 @@ package dns import ( + "errors" "fmt" "net/netip" "sync" @@ -10,6 +11,7 @@ import ( "github.com/miekg/dns" log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/client/iface" nbnet "github.com/netbirdio/netbird/client/net" ) @@ -18,7 +20,8 @@ type ServiceViaMemory struct { dnsMux *dns.ServeMux runtimeIP netip.Addr runtimePort int - udpFilterHookID string + tcpDNS *tcpDNSServer + tcpHookSet bool listenerIsRunning bool listenerFlagLock sync.Mutex } @@ -28,14 +31,13 @@ func NewServiceViaMemory(wgIface WGIface) *ServiceViaMemory { if err != nil { log.Errorf("get last ip from network: %v", err) } - s := &ServiceViaMemory{ + + return &ServiceViaMemory{ wgInterface: wgIface, dnsMux: dns.NewServeMux(), - runtimeIP: lastIP, runtimePort: DefaultPort, } - return s } func (s *ServiceViaMemory) Listen() error { @@ -46,10 +48,8 @@ func (s *ServiceViaMemory) Listen() error { return nil } - var err error - s.udpFilterHookID, err = s.filterDNSTraffic() - if err != nil { - return fmt.Errorf("filter dns traffice: %w", err) + if err := s.filterDNSTraffic(); err != nil { + return fmt.Errorf("filter dns traffic: %w", err) } s.listenerIsRunning = true @@ -57,19 +57,29 @@ func (s *ServiceViaMemory) Listen() error { return nil } -func (s *ServiceViaMemory) Stop() { +func (s *ServiceViaMemory) Stop() error { s.listenerFlagLock.Lock() defer s.listenerFlagLock.Unlock() if !s.listenerIsRunning { - return + return nil } - if err := s.wgInterface.GetFilter().RemovePacketHook(s.udpFilterHookID); err != nil { - log.Errorf("unable to remove DNS packet hook: %s", err) + filter := s.wgInterface.GetFilter() + if filter != nil { + filter.SetUDPPacketHook(s.runtimeIP, uint16(s.runtimePort), nil) + if s.tcpHookSet { + filter.SetTCPPacketHook(s.runtimeIP, uint16(s.runtimePort), nil) + } + } + + if s.tcpDNS != nil { + s.tcpDNS.Stop() } s.listenerIsRunning = false + + return nil } func (s *ServiceViaMemory) RegisterMux(pattern string, handler dns.Handler) { @@ -88,10 +98,18 @@ func (s *ServiceViaMemory) RuntimeIP() netip.Addr { return s.runtimeIP } -func (s *ServiceViaMemory) filterDNSTraffic() (string, error) { +func (s *ServiceViaMemory) filterDNSTraffic() error { filter := s.wgInterface.GetFilter() if filter == nil { - return "", fmt.Errorf("can't set DNS filter, filter not initialized") + return errors.New("DNS filter not initialized") + } + + // Create TCP DNS server lazily here since the device may not exist at construction time. + if s.tcpDNS == nil { + if dev := s.wgInterface.GetDevice(); dev != nil { + // MTU only affects TCP segment sizing; DNS messages are small so this has no practical impact. + s.tcpDNS = newTCPDNSServer(s.dnsMux, dev.Device, s.runtimeIP, uint16(s.runtimePort), iface.DefaultMTU) + } } firstLayerDecoder := layers.LayerTypeIPv4 @@ -100,12 +118,16 @@ func (s *ServiceViaMemory) filterDNSTraffic() (string, error) { } hook := func(packetData []byte) bool { - // Decode the packet packet := gopacket.NewPacket(packetData, firstLayerDecoder, gopacket.Default) - // Get the UDP layer udpLayer := packet.Layer(layers.LayerTypeUDP) - udp := udpLayer.(*layers.UDP) + if udpLayer == nil { + return true + } + udp, ok := udpLayer.(*layers.UDP) + if !ok { + return true + } msg := new(dns.Msg) if err := msg.Unpack(udp.Payload); err != nil { @@ -113,13 +135,30 @@ func (s *ServiceViaMemory) filterDNSTraffic() (string, error) { return true } - writer := responseWriter{ - packet: packet, - device: s.wgInterface.GetDevice().Device, + dev := s.wgInterface.GetDevice() + if dev == nil { + return true } - go s.dnsMux.ServeDNS(&writer, msg) + + writer := &responseWriter{ + remote: remoteAddrFromPacket(packet), + packet: packet, + device: dev.Device, + } + go s.dnsMux.ServeDNS(writer, msg) return true } - return filter.AddUDPPacketHook(false, s.runtimeIP, uint16(s.runtimePort), hook), nil + filter.SetUDPPacketHook(s.runtimeIP, uint16(s.runtimePort), hook) + + if s.tcpDNS != nil { + tcpHook := func(packetData []byte) bool { + s.tcpDNS.InjectPacket(packetData) + return true + } + filter.SetTCPPacketHook(s.runtimeIP, uint16(s.runtimePort), tcpHook) + s.tcpHookSet = true + } + + return nil } 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/tcpstack.go b/client/internal/dns/tcpstack.go new file mode 100644 index 000000000..88e72e767 --- /dev/null +++ b/client/internal/dns/tcpstack.go @@ -0,0 +1,444 @@ +package dns + +import ( + "errors" + "fmt" + "io" + "net" + "net/netip" + "sync" + "sync/atomic" + "time" + + "github.com/miekg/dns" + log "github.com/sirupsen/logrus" + "golang.zx2c4.com/wireguard/tun" + "gvisor.dev/gvisor/pkg/buffer" + "gvisor.dev/gvisor/pkg/tcpip" + "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" + "gvisor.dev/gvisor/pkg/tcpip/header" + "gvisor.dev/gvisor/pkg/tcpip/network/ipv4" + "gvisor.dev/gvisor/pkg/tcpip/stack" + "gvisor.dev/gvisor/pkg/tcpip/transport/tcp" + "gvisor.dev/gvisor/pkg/waiter" +) + +const ( + dnsTCPReceiveWindow = 8192 + dnsTCPMaxInFlight = 16 + dnsTCPIdleTimeout = 30 * time.Second + dnsTCPReadTimeout = 5 * time.Second +) + +// tcpDNSServer is an on-demand TCP DNS server backed by a minimal gvisor stack. +// It is started lazily when a truncated DNS response is detected and shuts down +// after a period of inactivity to conserve resources. +type tcpDNSServer struct { + mu sync.Mutex + s *stack.Stack + ep *dnsEndpoint + mux *dns.ServeMux + tunDev tun.Device + ip netip.Addr + port uint16 + mtu uint16 + + running bool + closed bool + timerID uint64 + timer *time.Timer +} + +func newTCPDNSServer(mux *dns.ServeMux, tunDev tun.Device, ip netip.Addr, port uint16, mtu uint16) *tcpDNSServer { + return &tcpDNSServer{ + mux: mux, + tunDev: tunDev, + ip: ip, + port: port, + mtu: mtu, + } +} + +// InjectPacket ensures the stack is running and delivers a raw IP packet into +// the gvisor stack for TCP processing. Combining both operations under a single +// lock prevents a race where the idle timer could stop the stack between +// start and delivery. +func (t *tcpDNSServer) InjectPacket(payload []byte) { + t.mu.Lock() + defer t.mu.Unlock() + + if t.closed { + return + } + + if !t.running { + if err := t.startLocked(); err != nil { + log.Errorf("failed to start TCP DNS stack: %v", err) + return + } + t.running = true + log.Debugf("TCP DNS stack started on %s:%d (triggered by %s)", t.ip, t.port, srcAddrFromPacket(payload)) + } + t.resetTimerLocked() + + ep := t.ep + if ep == nil || ep.dispatcher == nil { + return + } + + pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{ + Payload: buffer.MakeWithData(payload), + }) + // DeliverNetworkPacket takes ownership of the packet buffer; do not DecRef. + ep.dispatcher.DeliverNetworkPacket(ipv4.ProtocolNumber, pkt) +} + +// Stop tears down the gvisor stack and releases resources permanently. +// After Stop, InjectPacket becomes a no-op. +func (t *tcpDNSServer) Stop() { + t.mu.Lock() + defer t.mu.Unlock() + + t.stopLocked() + t.closed = true +} + +func (t *tcpDNSServer) startLocked() error { + // TODO: add ipv6.NewProtocol when IPv6 overlay support lands. + s := stack.New(stack.Options{ + NetworkProtocols: []stack.NetworkProtocolFactory{ipv4.NewProtocol}, + TransportProtocols: []stack.TransportProtocolFactory{tcp.NewProtocol}, + HandleLocal: false, + }) + + nicID := tcpip.NICID(1) + ep := &dnsEndpoint{ + tunDev: t.tunDev, + } + ep.mtu.Store(uint32(t.mtu)) + + if err := s.CreateNIC(nicID, ep); err != nil { + s.Close() + s.Wait() + return fmt.Errorf("create NIC: %v", err) + } + + protoAddr := tcpip.ProtocolAddress{ + Protocol: ipv4.ProtocolNumber, + AddressWithPrefix: tcpip.AddressWithPrefix{ + Address: tcpip.AddrFromSlice(t.ip.AsSlice()), + PrefixLen: 32, + }, + } + if err := s.AddProtocolAddress(nicID, protoAddr, stack.AddressProperties{}); err != nil { + s.Close() + s.Wait() + return fmt.Errorf("add protocol address: %s", err) + } + + if err := s.SetPromiscuousMode(nicID, true); err != nil { + s.Close() + s.Wait() + return fmt.Errorf("set promiscuous mode: %s", err) + } + if err := s.SetSpoofing(nicID, true); err != nil { + s.Close() + s.Wait() + return fmt.Errorf("set spoofing: %s", err) + } + + defaultSubnet, err := tcpip.NewSubnet( + tcpip.AddrFrom4([4]byte{0, 0, 0, 0}), + tcpip.MaskFromBytes([]byte{0, 0, 0, 0}), + ) + if err != nil { + s.Close() + s.Wait() + return fmt.Errorf("create default subnet: %w", err) + } + + s.SetRouteTable([]tcpip.Route{ + {Destination: defaultSubnet, NIC: nicID}, + }) + + tcpFwd := tcp.NewForwarder(s, dnsTCPReceiveWindow, dnsTCPMaxInFlight, func(r *tcp.ForwarderRequest) { + t.handleTCPDNS(r) + }) + s.SetTransportProtocolHandler(tcp.ProtocolNumber, tcpFwd.HandlePacket) + + t.s = s + t.ep = ep + return nil +} + +func (t *tcpDNSServer) stopLocked() { + if !t.running { + return + } + + if t.timer != nil { + t.timer.Stop() + t.timer = nil + } + + if t.s != nil { + t.s.Close() + t.s.Wait() + t.s = nil + } + t.ep = nil + t.running = false + + log.Debugf("TCP DNS stack stopped") +} + +func (t *tcpDNSServer) resetTimerLocked() { + if t.timer != nil { + t.timer.Stop() + } + t.timerID++ + id := t.timerID + t.timer = time.AfterFunc(dnsTCPIdleTimeout, func() { + t.mu.Lock() + defer t.mu.Unlock() + + // Only stop if this timer is still the active one. + // A racing InjectPacket may have replaced it. + if t.timerID != id { + return + } + t.stopLocked() + }) +} + +func (t *tcpDNSServer) handleTCPDNS(r *tcp.ForwarderRequest) { + id := r.ID() + + wq := waiter.Queue{} + ep, epErr := r.CreateEndpoint(&wq) + if epErr != nil { + log.Debugf("TCP DNS: failed to create endpoint: %v", epErr) + r.Complete(true) + return + } + r.Complete(false) + + conn := gonet.NewTCPConn(&wq, ep) + defer func() { + if err := conn.Close(); err != nil { + log.Tracef("TCP DNS: close conn: %v", err) + } + }() + + // Reset idle timer on activity + t.mu.Lock() + t.resetTimerLocked() + t.mu.Unlock() + + localAddr := &net.TCPAddr{ + IP: id.LocalAddress.AsSlice(), + Port: int(id.LocalPort), + } + remoteAddr := &net.TCPAddr{ + IP: id.RemoteAddress.AsSlice(), + Port: int(id.RemotePort), + } + + for { + if err := conn.SetReadDeadline(time.Now().Add(dnsTCPReadTimeout)); err != nil { + log.Debugf("TCP DNS: set deadline for %s: %v", remoteAddr, err) + break + } + + msg, err := readTCPDNSMessage(conn) + if err != nil { + if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + log.Debugf("TCP DNS: read from %s: %v", remoteAddr, err) + } + break + } + + writer := &tcpResponseWriter{ + conn: conn, + localAddr: localAddr, + remoteAddr: remoteAddr, + } + t.mux.ServeDNS(writer, msg) + } +} + +// dnsEndpoint implements stack.LinkEndpoint for writing packets back via the tun device. +type dnsEndpoint struct { + dispatcher stack.NetworkDispatcher + tunDev tun.Device + mtu atomic.Uint32 +} + +func (e *dnsEndpoint) Attach(dispatcher stack.NetworkDispatcher) { e.dispatcher = dispatcher } +func (e *dnsEndpoint) IsAttached() bool { return e.dispatcher != nil } +func (e *dnsEndpoint) MTU() uint32 { return e.mtu.Load() } +func (e *dnsEndpoint) Capabilities() stack.LinkEndpointCapabilities { return stack.CapabilityNone } +func (e *dnsEndpoint) MaxHeaderLength() uint16 { return 0 } +func (e *dnsEndpoint) LinkAddress() tcpip.LinkAddress { return "" } +func (e *dnsEndpoint) Wait() { /* no async work */ } +func (e *dnsEndpoint) ARPHardwareType() header.ARPHardwareType { return header.ARPHardwareNone } +func (e *dnsEndpoint) AddHeader(*stack.PacketBuffer) { /* IP-level endpoint, no link header */ } +func (e *dnsEndpoint) ParseHeader(*stack.PacketBuffer) bool { return true } +func (e *dnsEndpoint) Close() { /* lifecycle managed by tcpDNSServer */ } +func (e *dnsEndpoint) SetLinkAddress(tcpip.LinkAddress) { /* no link address for tun */ } +func (e *dnsEndpoint) SetMTU(mtu uint32) { e.mtu.Store(mtu) } +func (e *dnsEndpoint) SetOnCloseAction(func()) { /* not needed */ } + +const tunPacketOffset = 40 + +func (e *dnsEndpoint) WritePackets(pkts stack.PacketBufferList) (int, tcpip.Error) { + var written int + for _, pkt := range pkts.AsSlice() { + data := stack.PayloadSince(pkt.NetworkHeader()) + if data == nil { + continue + } + + raw := data.AsSlice() + buf := make([]byte, tunPacketOffset, tunPacketOffset+len(raw)) + buf = append(buf, raw...) + data.Release() + + if _, err := e.tunDev.Write([][]byte{buf}, tunPacketOffset); err != nil { + log.Tracef("TCP DNS endpoint: failed to write packet: %v", err) + continue + } + written++ + } + return written, nil +} + +// tcpResponseWriter implements dns.ResponseWriter for TCP DNS connections. +type tcpResponseWriter struct { + conn *gonet.TCPConn + localAddr net.Addr + remoteAddr net.Addr +} + +func (w *tcpResponseWriter) LocalAddr() net.Addr { + return w.localAddr +} + +func (w *tcpResponseWriter) RemoteAddr() net.Addr { + return w.remoteAddr +} + +func (w *tcpResponseWriter) WriteMsg(msg *dns.Msg) error { + data, err := msg.Pack() + if err != nil { + return fmt.Errorf("pack: %w", err) + } + + // DNS TCP: 2-byte length prefix + message + buf := make([]byte, 2+len(data)) + buf[0] = byte(len(data) >> 8) + buf[1] = byte(len(data)) + copy(buf[2:], data) + + if _, err = w.conn.Write(buf); err != nil { + return err + } + return nil +} + +func (w *tcpResponseWriter) Write(data []byte) (int, error) { + buf := make([]byte, 2+len(data)) + buf[0] = byte(len(data) >> 8) + buf[1] = byte(len(data)) + copy(buf[2:], data) + if _, err := w.conn.Write(buf); err != nil { + return 0, err + } + return len(data), nil +} + +func (w *tcpResponseWriter) Close() error { + return w.conn.Close() +} + +func (w *tcpResponseWriter) TsigStatus() error { return nil } +func (w *tcpResponseWriter) TsigTimersOnly(bool) { /* TSIG not supported */ } +func (w *tcpResponseWriter) Hijack() { /* not supported */ } + +// readTCPDNSMessage reads a single DNS message from a TCP connection (length-prefixed). +func readTCPDNSMessage(conn *gonet.TCPConn) (*dns.Msg, error) { + // DNS over TCP uses a 2-byte length prefix + lenBuf := make([]byte, 2) + if _, err := io.ReadFull(conn, lenBuf); err != nil { + return nil, fmt.Errorf("read length: %w", err) + } + + msgLen := int(lenBuf[0])<<8 | int(lenBuf[1]) + if msgLen == 0 || msgLen > 65535 { + return nil, fmt.Errorf("invalid message length: %d", msgLen) + } + + msgBuf := make([]byte, msgLen) + if _, err := io.ReadFull(conn, msgBuf); err != nil { + return nil, fmt.Errorf("read message: %w", err) + } + + msg := new(dns.Msg) + if err := msg.Unpack(msgBuf); err != nil { + return nil, fmt.Errorf("unpack: %w", err) + } + return msg, nil +} + +// srcAddrFromPacket extracts the source IP:port from a raw IP+TCP packet for logging. +// Supports both IPv4 and IPv6. +func srcAddrFromPacket(pkt []byte) netip.AddrPort { + if len(pkt) == 0 { + return netip.AddrPort{} + } + + srcIP, transportOffset := srcIPFromPacket(pkt) + if !srcIP.IsValid() || len(pkt) < transportOffset+2 { + return netip.AddrPort{} + } + + srcPort := uint16(pkt[transportOffset])<<8 | uint16(pkt[transportOffset+1]) + return netip.AddrPortFrom(srcIP.Unmap(), srcPort) +} + +func srcIPFromPacket(pkt []byte) (netip.Addr, int) { + switch header.IPVersion(pkt) { + case 4: + return srcIPv4(pkt) + case 6: + return srcIPv6(pkt) + default: + return netip.Addr{}, 0 + } +} + +func srcIPv4(pkt []byte) (netip.Addr, int) { + if len(pkt) < header.IPv4MinimumSize { + return netip.Addr{}, 0 + } + hdr := header.IPv4(pkt) + src := hdr.SourceAddress() + ip, ok := netip.AddrFromSlice(src.AsSlice()) + if !ok { + return netip.Addr{}, 0 + } + return ip, int(hdr.HeaderLength()) +} + +func srcIPv6(pkt []byte) (netip.Addr, int) { + if len(pkt) < header.IPv6MinimumSize { + return netip.Addr{}, 0 + } + hdr := header.IPv6(pkt) + src := hdr.SourceAddress() + ip, ok := netip.AddrFromSlice(src.AsSlice()) + if !ok { + return netip.Addr{}, 0 + } + return ip, header.IPv6MinimumSize +} diff --git a/client/internal/dns/test/mock.go b/client/internal/dns/test/mock.go index 1db452805..8d16689bf 100644 --- a/client/internal/dns/test/mock.go +++ b/client/internal/dns/test/mock.go @@ -8,15 +8,21 @@ import ( type MockResponseWriter struct { WriteMsgFunc func(m *dns.Msg) error + lastResponse *dns.Msg } func (rw *MockResponseWriter) WriteMsg(m *dns.Msg) error { + rw.lastResponse = m if rw.WriteMsgFunc != nil { return rw.WriteMsgFunc(m) } return nil } +func (rw *MockResponseWriter) GetLastResponse() *dns.Msg { + return rw.lastResponse +} + func (rw *MockResponseWriter) LocalAddr() net.Addr { return nil } func (rw *MockResponseWriter) RemoteAddr() net.Addr { return nil } func (rw *MockResponseWriter) Write([]byte) (int, error) { return 0, nil } diff --git a/client/internal/dns/upstream.go b/client/internal/dns/upstream.go index c19e0acb5..72fc0450c 100644 --- a/client/internal/dns/upstream.go +++ b/client/internal/dns/upstream.go @@ -1,8 +1,36 @@ +// 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 ( "context" - "crypto/rand" "crypto/sha256" "encoding/hex" "errors" @@ -12,22 +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 } @@ -38,11 +94,32 @@ 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 + + // 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 testRecord = "com." +const ( + protoUDP = "udp" + protoTCP = "tcp" +) + +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) @@ -53,51 +130,115 @@ type UpstreamResolver interface { 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 +// 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 - deactivate func(error) - reactivate func() - statusRecorder *peer.Status +// 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 } -func newUpstreamResolverBase(ctx context.Context, statusRecorder *peer.Status, domain string) *upstreamResolverBase { +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) +} + +// dnsProtocolFromContext retrieves the inbound DNS protocol from context. +func dnsProtocolFromContext(ctx context.Context) string { + if ctx == nil { + return "" + } + if v, ok := ctx.Value(dnsProtocolKey{}).(string); ok { + return v + } + return "" +} + +// 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 +} + +// setUpstreamProtocol sets the upstream protocol on the result holder in context, if present. +func setUpstreamProtocol(ctx context.Context, protocol string) { + if ctx == nil { + return + } + if r, ok := ctx.Value(upstreamProtocolKey{}).(*upstreamProtocolResult); ok && r != nil { + r.protocol = protocol + } +} + +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])) } @@ -107,16 +248,39 @@ 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() } +// 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 func (u *upstreamResolverBase) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { - requestID := GenerateRequestID() - logger := log.WithField("request_id", requestID) - - logger.Tracef("received upstream question: domain=%s type=%v class=%v", r.Question[0].Name, r.Question[0].Qtype, r.Question[0].Qclass) + logger := log.WithFields(log.Fields{ + "request_id": resutil.GetRequestID(w), + "dns_id": fmt.Sprintf("%04x", r.Id), + }) u.prepareRequest(r) @@ -125,11 +289,22 @@ func (u *upstreamResolverBase) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { return } - if u.tryUpstreamServers(w, r, logger) { - return + // Propagate inbound protocol so upstream exchange can use TCP directly + // when the request came in over TCP. + ctx := u.ctx + if addr := w.RemoteAddr(); addr != nil { + network := addr.Network() + ctx = contextWithDNSProtocol(ctx, network) + resutil.SetMeta(w, "protocol", network) } - u.writeErrorResponse(w, r, logger) + ok, failures := u.tryUpstreamServers(ctx, w, r, logger) + if len(failures) > 0 { + u.logUpstreamFailures(r.Question[0].Name, failures, ok, logger) + } + if !ok { + u.writeErrorResponse(w, r, logger) + } } func (u *upstreamResolverBase) prepareRequest(r *dns.Msg) { @@ -138,191 +313,311 @@ func (u *upstreamResolverBase) prepareRequest(r *dns.Msg) { } } -func (u *upstreamResolverBase) tryUpstreamServers(w dns.ResponseWriter, r *dns.Msg, logger *log.Entry) bool { - 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 - } +func (u *upstreamResolverBase) tryUpstreamServers(ctx context.Context, w dns.ResponseWriter, r *dns.Msg, logger *log.Entry) (bool, []upstreamFailure) { + 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) } - - for _, upstream := range u.upstreamServers { - if u.queryUpstream(w, r, upstream, timeout, logger) { - return true - } - } - return false } -func (u *upstreamResolverBase) queryUpstream(w dns.ResponseWriter, r *dns.Msg, upstream netip.AddrPort, timeout time.Duration, logger *log.Entry) bool { - var rm *dns.Msg - var t time.Duration - var err error +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 +} - var startTime time.Time - func() { - ctx, cancel := context.WithTimeout(u.ctx, timeout) +// 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 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 +} + +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() - 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 { - u.handleUpstreamError(err, upstream, r.Question[0].Name, startTime, timeout, logger) - return false + // 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 { - logger.Warnf("no response from upstream %s for question domain=%s", upstream, r.Question[0].Name) - return false + u.markUpstreamFail(upstream, "no response") + return raceResult{}, &upstreamFailure{upstream: upstream, reason: "no response"} } - return u.writeSuccessResponse(w, rm, upstream, r.Question[0].Name, t, logger) + // 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 { + // 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} + } + + if !hadEdns { + resutil.StripOPT(rm) + } + + return raceResult{msg: rm, upstream: upstream, protocol: proto}, nil } -func (u *upstreamResolverBase) handleUpstreamError(err error, upstream netip.AddrPort, domain string, startTime time.Time, timeout time.Duration, logger *log.Entry) { +// 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 { if !errors.Is(err, context.DeadlineExceeded) && !isTimeout(err) { - logger.Warnf("failed to query upstream %s for question domain=%s: %s", upstream, domain, err) - return + return &upstreamFailure{upstream: upstream, reason: err.Error()} } elapsed := time.Since(startTime) - timeoutMsg := fmt.Sprintf("upstream %s timed out for question domain=%s after %v (timeout=%v)", upstream, domain, elapsed.Truncate(time.Millisecond), timeout) + reason := fmt.Sprintf("timeout after %v", elapsed.Truncate(time.Millisecond)) if peerInfo := u.debugUpstreamTimeout(upstream); peerInfo != "" { - timeoutMsg += " " + peerInfo + reason += " " + peerInfo } - timeoutMsg += fmt.Sprintf(" - error: %v", err) - logger.Warnf(timeoutMsg) + return &upstreamFailure{upstream: upstream, reason: reason} } -func (u *upstreamResolverBase) writeSuccessResponse(w dns.ResponseWriter, rm *dns.Msg, upstream netip.AddrPort, domain string, t time.Duration, logger *log.Entry) bool { - u.successCount.Add(1) - logger.Tracef("took %s to query the upstream %s for question domain=%s", t, upstream, domain) +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 proto != "" { + resutil.SetMeta(w, "upstream_protocol", proto) + } + + // Clear Zero bit from external responses to prevent upstream servers from + // manipulating our internal fallthrough signaling mechanism + rm.MsgHdr.Zero = false if err := w.WriteMsg(rm); err != nil { logger.Errorf("failed to write DNS response for question domain=%s: %s", domain, err) } - return true +} + +func (u *upstreamResolverBase) logUpstreamFailures(domain string, failures []upstreamFailure, succeeded bool, logger *log.Entry) { + totalUpstreams := len(u.flatUpstreams()) + failedCount := len(failures) + failureSummary := formatFailures(failures) + + if succeeded { + logger.Warnf("%d/%d upstreams failed for domain=%s: %s", failedCount, totalUpstreams, domain, failureSummary) + } else { + logger.Errorf("%d/%d upstreams failed for domain=%s: %s", failedCount, totalUpstreams, domain, failureSummary) + } } func (u *upstreamResolverBase) writeErrorResponse(w dns.ResponseWriter, r *dns.Msg, logger *log.Entry) { - logger.Errorf("all queries to the %s failed for question domain=%s", u, r.Question[0].Name) - m := new(dns.Msg) m.SetRcode(r, dns.RcodeServerFailure) if err := w.WriteMsg(m); err != nil { - logger.Errorf("failed to write error response for %s for question domain=%s: %s", u, r.Question[0].Name, err) + logger.Errorf("write error response for domain=%s: %s", r.Question[0].Name, err) } } -// ProbeAvailability tests all upstream servers simultaneously and -// disables the resolver if none work -func (u *upstreamResolverBase) ProbeAvailability() { - u.mutex.Lock() - defer u.mutex.Unlock() - - select { - case <-u.ctx.Done(): - return - default: - } - - // avoid probe if upstreams could resolve at least one query - if u.successCount.Load() > 0 { - return - } - - var success bool - var mu sync.Mutex - var wg sync.WaitGroup - - var errors *multierror.Error - for _, upstream := range u.upstreamServers { - upstream := upstream - - wg.Add(1) - go func() { - defer wg.Done() - err := u.testNameserver(upstream, 500*time.Millisecond) - if err != nil { - errors = multierror.Append(errors, err) - log.Warnf("probing upstream nameserver %s: %s", upstream, err) - return - } - - mu.Lock() - defer mu.Unlock() - success = true - }() - } - - wg.Wait() - - // didn't find a working upstream server, let's disable and try later - if !success { - u.disable(errors.ErrorOrNil()) - - if u.statusRecorder == nil { - return - } - - 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()}, - ) +func formatFailures(failures []upstreamFailure) string { + parts := make([]string, 0, len(failures)) + for _, f := range failures { + parts = append(parts, fmt.Sprintf("%s=%s", f.upstream, f.reason)) } + return strings.Join(parts, ", ") } -// 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, +// 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 } - - 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 _, o := range opt.Option { + ede, ok := o.(*dns.EDNS0_EDE) + if !ok { + continue } - - for _, upstream := range u.upstreamServers { - if err := u.testNameserver(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 - } + if _, ok := nonRetryableEDECodes[ede.InfoCode]; ok { + return ede.InfoCode, true } - - log.Tracef("checking connectivity with upstreams %s failed. Retrying in %s", u.upstreamServersString(), exponentialBackOff.NextBackOff()) - return fmt.Errorf("upstream check call error") } + return 0, false +} - err := backoff.Retry(operation, exponentialBackOff) - if err != nil { - log.Warn(err) - return +// 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 } - - log.Infof("upstreams %s are responsive again. Adding them back to system", u.upstreamServersString()) - u.successCount.Add(1) - u.reactivate() - u.disabled = false + return fmt.Sprintf("EDE %d", code) } // isTimeout returns true if the given error is a network timeout error. @@ -336,92 +631,162 @@ func isTimeout(err error) bool { return false } -func (u *upstreamResolverBase) disable(err error) { - if u.disabled { - return +// clientUDPMaxSize returns the maximum UDP response size the client accepts. +func clientUDPMaxSize(r *dns.Msg) int { + if opt := r.IsEdns0(); opt != nil { + return int(opt.UDPSize()) } - - log.Warnf("Upstream resolving is Disabled for %v", reactivatePeriod) - u.successCount.Store(0) - u.deactivate(err) - u.disabled = true - go 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(server netip.AddrPort, timeout time.Duration) error { - ctx, cancel := context.WithTimeout(u.ctx, timeout) - defer cancel() - - r := new(dns.Msg).SetQuestion(testRecord, dns.TypeSOA) - - _, _, err := u.upstreamClient.exchange(ctx, server.String(), r) - return err + return dns.MinMsgSize } // 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 passed context is nil, this will use Exchange instead of ExchangeContext. +// If the inbound request came over TCP (via context), it skips the UDP attempt. func ExchangeWithFallback(ctx context.Context, client *dns.Client, r *dns.Msg, upstream string) (*dns.Msg, time.Duration, error) { - // MTU - ip + udp headers - // Note: this could be sent out on an interface that is not ours, but higher MTU settings could break truncation handling. - client.UDPSize = uint16(currentMTU - (60 + 8)) - - 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) + // If the request came in over TCP, go straight to TCP upstream. + if dnsProtocolFromContext(ctx) == protoTCP { + rm, t, err := toTCPClient(client).ExchangeContext(ctx, r, upstream) + if err != nil { + return nil, t, fmt.Errorf("with tcp: %w", err) + } + setUpstreamProtocol(ctx, protoTCP) + return rm, t, nil } + clientMaxSize := clientUDPMaxSize(r) + + // Cap EDNS0 to our tunnel MTU so the upstream doesn't send a + // response larger than our read buffer. + // Note: the query could be sent out on an interface that is not ours, + // but higher MTU settings could break truncation handling. + maxUDPPayload := uint16(currentMTU - ipUDPHeaderSize) + client.UDPSize = maxUDPPayload + if opt := r.IsEdns0(); opt != nil && opt.UDPSize() > maxUDPPayload { + opt.SetUDPSize(maxUDPPayload) + } + + rm, t, err := client.ExchangeContext(ctx, r, upstream) if err != nil { return nil, t, fmt.Errorf("with udp: %w", err) } if rm == nil || !rm.MsgHdr.Truncated { + setUpstreamProtocol(ctx, protoUDP) return rm, t, nil } - log.Tracef("udp response for domain=%s type=%v class=%v is truncated, trying TCP.", - r.Question[0].Name, r.Question[0].Qtype, r.Question[0].Qclass) - - client.Net = "tcp" - - if ctx == nil { - rm, t, err = client.Exchange(r, upstream) - } else { - rm, t, err = client.ExchangeContext(ctx, r, upstream) - } + // TODO: if the upstream's truncated UDP response already contains more + // data than the client's buffer, we could truncate locally and skip + // the TCP retry. + rm, t, err = toTCPClient(client).ExchangeContext(ctx, r, upstream) if err != nil { return nil, t, fmt.Errorf("with tcp: %w", err) } - // TODO: once TCP is implemented, rm.Truncate() if the request came in over UDP + setUpstreamProtocol(ctx, protoTCP) + + if rm.Len() > clientMaxSize { + rm.Truncate(clientMaxSize) + } return rm, t, nil } -func GenerateRequestID() string { - bytes := make([]byte, 4) - _, err := rand.Read(bytes) - if err != nil { - log.Errorf("failed to generate request ID: %v", err) - return "" +// 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 } - return hex.EncodeToString(bytes) + 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) { + // If request came in over TCP, go straight to TCP upstream + if dnsProtocolFromContext(ctx) == protoTCP { + rm, err := netstackExchange(ctx, nsNet, r, upstream, protoTCP) + if err != nil { + return nil, err + } + setUpstreamProtocol(ctx, protoTCP) + return rm, nil + } + + clientMaxSize := clientUDPMaxSize(r) + + // Cap EDNS0 to our tunnel MTU so the upstream doesn't send a + // response larger than what we can read over UDP. + maxUDPPayload := uint16(currentMTU - ipUDPHeaderSize) + if opt := r.IsEdns0(); opt != nil && opt.UDPSize() > maxUDPPayload { + opt.SetUDPSize(maxUDPPayload) + } + + reply, err := netstackExchange(ctx, nsNet, r, upstream, protoUDP) + if err != nil { + return nil, err + } + + if reply != nil && reply.MsgHdr.Truncated { + rm, err := netstackExchange(ctx, nsNet, r, upstream, protoTCP) + if err != nil { + return nil, err + } + + setUpstreamProtocol(ctx, protoTCP) + if rm.Len() > clientMaxSize { + rm.Truncate(clientMaxSize) + } + + return rm, nil + } + + setUpstreamProtocol(ctx, protoUDP) + + return reply, nil +} + +func netstackExchange(ctx context.Context, nsNet *netstack.Net, r *dns.Msg, upstream, network string) (*dns.Msg, error) { + conn, err := nsNet.DialContext(ctx, network, upstream) + if err != nil { + return nil, fmt.Errorf("with %s: %w", network, err) + } + defer func() { + if err := conn.Close(); err != nil { + log.Debugf("failed to close DNS connection: %v", err) + } + }() + + if deadline, ok := ctx.Deadline(); ok { + if err := conn.SetDeadline(deadline); err != nil { + return nil, fmt.Errorf("set deadline: %w", err) + } + } + + dnsConn := &dns.Conn{Conn: conn, UDPSize: uint16(currentMTU - ipUDPHeaderSize)} + + if err := dnsConn.WriteMsg(r); err != nil { + return nil, fmt.Errorf("write %s message: %w", network, err) + } + + reply, err := dnsConn.ReadMsg() + if err != nil { + return nil, fmt.Errorf("read %s message: %w", network, err) + } + + return reply, nil } // FormatPeerStatus formats peer connection status information for debugging DNS timeouts @@ -488,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 def281f28..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 { @@ -23,14 +24,12 @@ type upstreamResolver struct { // first time, and we need to wait for a while to start to use again the proper DNS resolver. func newUpstreamResolver( ctx context.Context, - _ string, - _ netip.Addr, - _ netip.Prefix, + _ 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, @@ -53,7 +52,7 @@ func (u *upstreamResolver) exchangeWithinVPN(ctx context.Context, upstream strin upstreamExchangeClient := &dns.Client{ Timeout: ClientTimeout, } - return upstreamExchangeClient.ExchangeContext(ctx, r, upstream) + return ExchangeWithFallback(ctx, upstreamExchangeClient, r, upstream) } // exchangeWithoutVPN protect the UDP socket by Android SDK to avoid to goes through the VPN @@ -78,7 +77,7 @@ func (u *upstreamResolver) exchangeWithoutVPN(ctx context.Context, upstream stri Timeout: timeout, } - return upstreamExchangeClient.ExchangeContext(ctx, r, upstream) + return ExchangeWithFallback(ctx, upstreamExchangeClient, r, upstream) } func (u *upstreamResolver) isLocalResolver(upstream string) bool { @@ -88,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 434e5880b..dc841757b 100644 --- a/client/internal/dns/upstream_general.go +++ b/client/internal/dns/upstream_general.go @@ -5,42 +5,55 @@ package dns import ( "context" "net/netip" + "runtime" "time" "github.com/miekg/dns" + "golang.zx2c4.com/wireguard/tun/netstack" "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/shared/management/domain" ) type upstreamResolver struct { *upstreamResolverBase + nsNet *netstack.Net } func newUpstreamResolver( ctx context.Context, - _ string, - _ netip.Addr, - _ netip.Prefix, + 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(), } upstreamResolverBase.upstreamClient = nonIOS return nonIOS, nil } func (u *upstreamResolver) exchange(ctx context.Context, upstream string, r *dns.Msg) (rm *dns.Msg, t time.Duration, err error) { + // TODO: Check if upstream DNS server is routed through a peer before using netstack. + // Similar to iOS logic, we should determine if the DNS server is reachable directly + // or needs to go through the tunnel, and only use netstack when necessary. + // For now, only use netstack on JS platform where direct access is not possible. + if u.nsNet != nil && runtime.GOOS == "js" { + start := time.Now() + reply, err := ExchangeWithNetstack(ctx, u.nsNet, r, upstream) + return reply, time.Since(start), err + } + client := &dns.Client{ Timeout: ClientTimeout, } 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 eadcdd117..b989bf0f9 100644 --- a/client/internal/dns/upstream_ios.go +++ b/client/internal/dns/upstream_ios.go @@ -15,31 +15,26 @@ 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( ctx context.Context, - interfaceName string, - ip netip.Addr, - net netip.Prefix, + 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: ip, - lNet: net, - interfaceName: interfaceName, + wgIface: wgIface, } ios.upstreamClient = ios @@ -67,37 +62,55 @@ func (u *upstreamResolverIOS) exchange(ctx context.Context, upstream string, r * } else { upstreamIP = upstreamIP.Unmap() } - if u.lNet.Contains(upstreamIP) || upstreamIP.IsPrivate() { - log.Debugf("using private client to query upstream: %s", upstream) - client, err = GetClientPrivate(u.lIP, u.interfaceName, timeout) + 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.wgIface, upstreamIP, timeout) if err != nil { - return nil, 0, fmt.Errorf("error while creating private client: %s", err) + 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 { diff --git a/client/internal/dns/upstream_test.go b/client/internal/dns/upstream_test.go index e1573e75e..4c2784545 100644 --- a/client/internal/dns/upstream_test.go +++ b/client/internal/dns/upstream_test.go @@ -2,13 +2,21 @@ package dns import ( "context" + "fmt" + "net" "net/netip" "strings" + "sync/atomic" "testing" "time" "github.com/miekg/dns" + "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/dns/test" ) @@ -58,7 +66,7 @@ func TestUpstreamResolver_ServeDNS(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { ctx, cancel := context.WithCancel(context.TODO()) - resolver, _ := newUpstreamResolver(ctx, "", netip.Addr{}, netip.Prefix{}, nil, nil, ".") + resolver, _ := newUpstreamResolver(ctx, &mockNetstackProvider{}, nil, nil, ".") // Convert test servers to netip.AddrPort var servers []netip.AddrPort for _, server := range testCase.InputServers { @@ -66,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() @@ -112,65 +120,918 @@ func TestUpstreamResolver_ServeDNS(t *testing.T) { } } -type mockUpstreamResolver struct { - r *dns.Msg - rtt time.Duration - err error +type mockNetstackProvider struct{} + +func (m *mockNetstackProvider) Name() string { return "mock" } +func (m *mockNetstackProvider) Address() wgaddr.Address { return wgaddr.Address{} } +func (m *mockNetstackProvider) ToInterface() *net.Interface { return nil } +func (m *mockNetstackProvider) IsUserspaceBind() bool { return false } +func (m *mockNetstackProvider) GetFilter() device.PacketFilter { return nil } +func (m *mockNetstackProvider) GetDevice() *device.FilteredDevice { return nil } +func (m *mockNetstackProvider) GetNet() *netstack.Net { return nil } +func (m *mockNetstackProvider) GetInterfaceGUIDString() (string, error) { + return "", nil } -// 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 + delay time.Duration } -func TestUpstreamResolver_DeactivationReactivation(t *testing.T) { - mockClient := &mockUpstreamResolver{ - err: dns.ErrTime, - r: new(dns.Msg), +type mockUpstreamResolverPerServer struct { + responses map[string]mockUpstreamResponse + rtt time.Duration +} + +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) + } + 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) { + upstream1 := netip.MustParseAddrPort("192.0.2.1:53") + upstream2 := netip.MustParseAddrPort("192.0.2.2:53") + + successAnswer := "192.0.2.100" + timeoutErr := &net.OpError{Op: "read", Err: fmt.Errorf("i/o timeout")} + + testCases := []struct { + name string + upstream1 mockUpstreamResponse + upstream2 mockUpstreamResponse + expectedRcode int + expectAnswer bool + expectTrySecond bool + }{ + { + name: "success on first upstream", + upstream1: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeSuccess, successAnswer)}, + upstream2: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeSuccess, successAnswer)}, + expectedRcode: dns.RcodeSuccess, + expectAnswer: true, + expectTrySecond: false, + }, + { + name: "SERVFAIL from first should try second", + upstream1: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeServerFailure, "")}, + upstream2: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeSuccess, successAnswer)}, + expectedRcode: dns.RcodeSuccess, + expectAnswer: true, + expectTrySecond: true, + }, + { + name: "REFUSED from first should try second", + upstream1: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeRefused, "")}, + upstream2: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeSuccess, successAnswer)}, + expectedRcode: dns.RcodeSuccess, + expectAnswer: true, + expectTrySecond: true, + }, + { + name: "NXDOMAIN from first should NOT try second", + upstream1: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeNameError, "")}, + upstream2: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeSuccess, successAnswer)}, + expectedRcode: dns.RcodeNameError, + expectAnswer: false, + expectTrySecond: false, + }, + { + name: "timeout from first should try second", + upstream1: mockUpstreamResponse{err: timeoutErr}, + upstream2: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeSuccess, successAnswer)}, + expectedRcode: dns.RcodeSuccess, + expectAnswer: true, + expectTrySecond: true, + }, + { + name: "no response from first should try second", + upstream1: mockUpstreamResponse{msg: nil}, + upstream2: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeSuccess, successAnswer)}, + expectedRcode: dns.RcodeSuccess, + expectAnswer: true, + expectTrySecond: true, + }, + { + name: "both upstreams return SERVFAIL", + upstream1: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeServerFailure, "")}, + upstream2: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeServerFailure, "")}, + expectedRcode: dns.RcodeServerFailure, + expectAnswer: false, + expectTrySecond: true, + }, + { + name: "both upstreams timeout", + upstream1: mockUpstreamResponse{err: timeoutErr}, + upstream2: mockUpstreamResponse{err: timeoutErr}, + expectedRcode: dns.RcodeServerFailure, + expectAnswer: false, + expectTrySecond: true, + }, + { + name: "first SERVFAIL then timeout", + upstream1: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeServerFailure, "")}, + upstream2: mockUpstreamResponse{err: timeoutErr}, + expectedRcode: dns.RcodeServerFailure, + expectAnswer: false, + expectTrySecond: true, + }, + { + name: "first timeout then SERVFAIL", + upstream1: mockUpstreamResponse{err: timeoutErr}, + upstream2: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeServerFailure, "")}, + expectedRcode: dns.RcodeServerFailure, + expectAnswer: false, + expectTrySecond: true, + }, + { + name: "first REFUSED then SERVFAIL", + upstream1: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeRefused, "")}, + upstream2: mockUpstreamResponse{msg: buildMockResponse(dns.RcodeServerFailure, "")}, + expectedRcode: dns.RcodeServerFailure, + expectAnswer: false, + expectTrySecond: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var queriedUpstreams []string + mockClient := &mockUpstreamResolverPerServer{ + responses: map[string]mockUpstreamResponse{ + upstream1.String(): tc.upstream1, + upstream2.String(): tc.upstream2, + }, + rtt: time.Millisecond, + } + + trackingClient := &trackingMockClient{ + inner: mockClient, + queriedUpstreams: &queriedUpstreams, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + resolver := &upstreamResolverBase{ + ctx: ctx, + upstreamClient: trackingClient, + upstreamTimeout: UpstreamTimeout, + } + resolver.addRace([]netip.AddrPort{upstream1, upstream2}) + + 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) + resolver.ServeDNS(responseWriter, inputMSG) + + require.NotNil(t, responseMSG, "should write a response") + assert.Equal(t, tc.expectedRcode, responseMSG.Rcode, "unexpected rcode") + + if tc.expectAnswer { + require.NotEmpty(t, responseMSG.Answer, "expected answer records") + assert.Contains(t, responseMSG.Answer[0].String(), successAnswer) + } + + if tc.expectTrySecond { + assert.Len(t, queriedUpstreams, 2, "should have tried both upstreams") + assert.Equal(t, upstream1.String(), queriedUpstreams[0]) + assert.Equal(t, upstream2.String(), queriedUpstreams[1]) + } else { + assert.Len(t, queriedUpstreams, 1, "should have only tried first upstream") + assert.Equal(t, upstream1.String(), queriedUpstreams[0]) + } + }) + } +} + +type trackingMockClient struct { + inner *mockUpstreamResolverPerServer + queriedUpstreams *[]string +} + +func (t *trackingMockClient) exchange(ctx context.Context, upstream string, r *dns.Msg) (*dns.Msg, time.Duration, error) { + *t.queriedUpstreams = append(*t.queriedUpstreams, upstream) + return t.inner.exchange(ctx, upstream, r) +} + +func buildMockResponse(rcode int, answer string) *dns.Msg { + m := new(dns.Msg) + m.Response = true + m.Rcode = rcode + + if rcode == dns.RcodeSuccess && answer != "" { + m.Answer = []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "example.com.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 300, + }, + A: net.ParseIP(answer), + }, + } + } + return m +} + +func TestUpstreamResolver_SingleUpstreamFailure(t *testing.T) { + upstream := netip.MustParseAddrPort("192.0.2.1:53") + + mockClient := &mockUpstreamResolverPerServer{ + responses: map[string]mockUpstreamResponse{ + upstream.String(): {msg: buildMockResponse(dns.RcodeServerFailure, "")}, + }, rtt: time.Millisecond, } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + resolver := &upstreamResolverBase{ - ctx: context.TODO(), - upstreamClient: mockClient, - upstreamTimeout: UpstreamTimeout, - reactivatePeriod: time.Microsecond * 100, + ctx: ctx, + upstreamClient: mockClient, + upstreamTimeout: UpstreamTimeout, } - 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())} + resolver.addRace([]netip.AddrPort{upstream}) - failed := false - resolver.deactivate = func(error) { - failed = true - // After deactivation, make the mock client work again - mockClient.err = nil + var responseMSG *dns.Msg + responseWriter := &test.MockResponseWriter{ + WriteMsgFunc: func(m *dns.Msg) error { + responseMSG = m + return nil + }, } - reactivated := false - resolver.reactivate = func() { - reactivated = true + inputMSG := new(dns.Msg).SetQuestion("example.com.", dns.TypeA) + resolver.ServeDNS(responseWriter, inputMSG) + + require.NotNil(t, responseMSG, "should write a response") + 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, } - resolver.ProbeAvailability() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() - if !failed { - t.Errorf("expected that resolving was deactivated") - return + 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 + }, } - if !resolver.disabled { - t.Errorf("resolver should be Disabled") - return + 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, } - time.Sleep(time.Millisecond * 200) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() - if !reactivated { - t.Errorf("expected that resolving was reactivated") - return + 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 + }, } - if resolver.disabled { - t.Errorf("should be enabled") + 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 + failures []upstreamFailure + expected string + }{ + { + name: "empty slice", + failures: []upstreamFailure{}, + expected: "", + }, + { + name: "single failure", + failures: []upstreamFailure{ + {upstream: netip.MustParseAddrPort("8.8.8.8:53"), reason: "SERVFAIL"}, + }, + expected: "8.8.8.8:53=SERVFAIL", + }, + { + name: "multiple failures", + failures: []upstreamFailure{ + {upstream: netip.MustParseAddrPort("8.8.8.8:53"), reason: "SERVFAIL"}, + {upstream: netip.MustParseAddrPort("8.8.4.4:53"), reason: "timeout after 2s"}, + }, + expected: "8.8.8.8:53=SERVFAIL, 8.8.4.4:53=timeout after 2s", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := formatFailures(tc.failures) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestDNSProtocolContext(t *testing.T) { + t.Run("roundtrip udp", func(t *testing.T) { + ctx := contextWithDNSProtocol(context.Background(), protoUDP) + assert.Equal(t, protoUDP, dnsProtocolFromContext(ctx)) + }) + + t.Run("roundtrip tcp", func(t *testing.T) { + ctx := contextWithDNSProtocol(context.Background(), protoTCP) + assert.Equal(t, protoTCP, dnsProtocolFromContext(ctx)) + }) + + t.Run("missing returns empty", func(t *testing.T) { + assert.Equal(t, "", dnsProtocolFromContext(context.Background())) + }) +} + +func TestExchangeWithFallback_TCPContext(t *testing.T) { + // Start a local DNS server that responds on TCP only + tcpHandler := dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + m.Answer = append(m.Answer, &dns.A{ + Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, + A: net.ParseIP("10.0.0.1"), + }) + if err := w.WriteMsg(m); err != nil { + t.Logf("write msg: %v", err) + } + }) + + tcpServer := &dns.Server{ + Addr: "127.0.0.1:0", + Net: "tcp", + Handler: tcpHandler, + } + + tcpLn, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + tcpServer.Listener = tcpLn + + go func() { + if err := tcpServer.ActivateAndServe(); err != nil { + t.Logf("tcp server: %v", err) + } + }() + defer func() { + _ = tcpServer.Shutdown() + }() + + upstream := tcpLn.Addr().String() + + // With TCP context, should connect directly via TCP without trying UDP + ctx := contextWithDNSProtocol(context.Background(), protoTCP) + client := &dns.Client{Timeout: 2 * time.Second} + r := new(dns.Msg).SetQuestion("example.com.", dns.TypeA) + + rm, _, err := ExchangeWithFallback(ctx, client, r, upstream) + require.NoError(t, err) + require.NotNil(t, rm) + require.NotEmpty(t, rm.Answer) + assert.Contains(t, rm.Answer[0].String(), "10.0.0.1") +} + +func TestExchangeWithFallback_UDPFallbackToTCP(t *testing.T) { + // UDP handler returns a truncated response to trigger TCP retry. + udpHandler := dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + m.Truncated = true + if err := w.WriteMsg(m); err != nil { + t.Logf("write msg: %v", err) + } + }) + + // TCP handler returns the full answer. + tcpHandler := dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + m.Answer = append(m.Answer, &dns.A{ + Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, + A: net.ParseIP("10.0.0.3"), + }) + if err := w.WriteMsg(m); err != nil { + t.Logf("write msg: %v", err) + } + }) + + udpPC, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + addr := udpPC.LocalAddr().String() + + udpServer := &dns.Server{ + PacketConn: udpPC, + Net: "udp", + Handler: udpHandler, + } + + tcpLn, err := net.Listen("tcp", addr) + require.NoError(t, err) + + tcpServer := &dns.Server{ + Listener: tcpLn, + Net: "tcp", + Handler: tcpHandler, + } + + go func() { + if err := udpServer.ActivateAndServe(); err != nil { + t.Logf("udp server: %v", err) + } + }() + go func() { + if err := tcpServer.ActivateAndServe(); err != nil { + t.Logf("tcp server: %v", err) + } + }() + defer func() { + _ = udpServer.Shutdown() + _ = tcpServer.Shutdown() + }() + + ctx := context.Background() + client := &dns.Client{Timeout: 2 * time.Second} + r := new(dns.Msg).SetQuestion("example.com.", dns.TypeA) + + rm, _, err := ExchangeWithFallback(ctx, client, r, addr) + require.NoError(t, err, "should fall back to TCP after truncated UDP response") + require.NotNil(t, rm) + require.NotEmpty(t, rm.Answer, "TCP response should contain the full answer") + assert.Contains(t, rm.Answer[0].String(), "10.0.0.3") + assert.False(t, rm.Truncated, "TCP response should not be truncated") +} + +func TestExchangeWithFallback_TCPContextSkipsUDP(t *testing.T) { + // Start only a TCP server (no UDP). With TCP context it should succeed. + tcpHandler := dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + m.Answer = append(m.Answer, &dns.A{ + Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, + A: net.ParseIP("10.0.0.2"), + }) + if err := w.WriteMsg(m); err != nil { + t.Logf("write msg: %v", err) + } + }) + + tcpLn, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + tcpServer := &dns.Server{ + Listener: tcpLn, + Net: "tcp", + Handler: tcpHandler, + } + + go func() { + if err := tcpServer.ActivateAndServe(); err != nil { + t.Logf("tcp server: %v", err) + } + }() + defer func() { + _ = tcpServer.Shutdown() + }() + + upstream := tcpLn.Addr().String() + + // TCP context: should skip UDP entirely and go directly to TCP + ctx := contextWithDNSProtocol(context.Background(), protoTCP) + client := &dns.Client{Timeout: 2 * time.Second} + r := new(dns.Msg).SetQuestion("example.com.", dns.TypeA) + + rm, _, err := ExchangeWithFallback(ctx, client, r, upstream) + require.NoError(t, err) + require.NotNil(t, rm) + require.NotEmpty(t, rm.Answer) + assert.Contains(t, rm.Answer[0].String(), "10.0.0.2") + + // Without TCP context, trying to reach a TCP-only server via UDP should fail + ctx2 := context.Background() + client2 := &dns.Client{Timeout: 500 * time.Millisecond} + _, _, err = ExchangeWithFallback(ctx2, client2, r, upstream) + assert.Error(t, err, "should fail when no UDP server and no TCP context") +} + +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 atomic.Uint32 + udpHandler := dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) { + if opt := r.IsEdns0(); opt != nil { + receivedUDPSize.Store(uint32(opt.UDPSize())) + } + m := new(dns.Msg) + m.SetReply(r) + m.Answer = append(m.Answer, &dns.A{ + Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, + A: net.ParseIP("10.0.0.1"), + }) + if err := w.WriteMsg(m); err != nil { + t.Logf("write msg: %v", err) + } + }) + + udpPC, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + addr := udpPC.LocalAddr().String() + + udpServer := &dns.Server{PacketConn: udpPC, Net: "udp", Handler: udpHandler} + go func() { _ = udpServer.ActivateAndServe() }() + t.Cleanup(func() { _ = udpServer.Shutdown() }) + + ctx := context.Background() + client := &dns.Client{Timeout: 2 * time.Second} + r := new(dns.Msg).SetQuestion("example.com.", dns.TypeA) + r.SetEdns0(4096, false) + + rm, _, err := ExchangeWithFallback(ctx, client, r, addr) + require.NoError(t, err) + require.NotNil(t, rm) + + expectedMax := uint16(currentMTU - ipUDPHeaderSize) + assert.Equal(t, expectedMax, uint16(receivedUDPSize.Load()), + "upstream should see capped EDNS0, not the client's 4096") +} + +func TestExchangeWithFallback_TCPTruncatesToClientSize(t *testing.T) { + // When the client advertises a large EDNS0 (4096) and the upstream + // truncates, the TCP response should NOT be truncated since the full + // answer fits within the client's original buffer. + udpHandler := dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + m.Truncated = true + if err := w.WriteMsg(m); err != nil { + t.Logf("write msg: %v", err) + } + }) + + tcpHandler := dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + // Add enough records to exceed MTU but fit within 4096 + for i := range 20 { + m.Answer = append(m.Answer, &dns.TXT{ + Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 60}, + Txt: []string{fmt.Sprintf("record-%d-padding-data-to-make-it-longer", i)}, + }) + } + if err := w.WriteMsg(m); err != nil { + t.Logf("write msg: %v", err) + } + }) + + udpPC, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + addr := udpPC.LocalAddr().String() + + udpServer := &dns.Server{PacketConn: udpPC, Net: "udp", Handler: udpHandler} + tcpLn, err := net.Listen("tcp", addr) + require.NoError(t, err) + tcpServer := &dns.Server{Listener: tcpLn, Net: "tcp", Handler: tcpHandler} + + go func() { _ = udpServer.ActivateAndServe() }() + go func() { _ = tcpServer.ActivateAndServe() }() + t.Cleanup(func() { + _ = udpServer.Shutdown() + _ = tcpServer.Shutdown() + }) + + ctx := context.Background() + client := &dns.Client{Timeout: 2 * time.Second} + + // Client with large buffer: should get all records without truncation + r := new(dns.Msg).SetQuestion("example.com.", dns.TypeTXT) + r.SetEdns0(4096, false) + + rm, _, err := ExchangeWithFallback(ctx, client, r, addr) + require.NoError(t, err) + require.NotNil(t, rm) + assert.Len(t, rm.Answer, 20, "large EDNS0 client should get all records") + assert.False(t, rm.Truncated, "response should not be truncated for large buffer client") + + // Client with small buffer: should get truncated response + r2 := new(dns.Msg).SetQuestion("example.com.", dns.TypeTXT) + r2.SetEdns0(512, false) + + rm2, _, err := ExchangeWithFallback(ctx, &dns.Client{Timeout: 2 * time.Second}, r2, addr) + require.NoError(t, err) + require.NotNil(t, rm2) + 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/wgiface.go b/client/internal/dns/wgiface.go index 28e9cebf1..717e16325 100644 --- a/client/internal/dns/wgiface.go +++ b/client/internal/dns/wgiface.go @@ -5,6 +5,8 @@ package dns import ( "net" + "golang.zx2c4.com/wireguard/tun/netstack" + "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/wgaddr" ) @@ -17,4 +19,5 @@ type WGIface interface { IsUserspaceBind() bool GetFilter() device.PacketFilter GetDevice() *device.FilteredDevice + GetNet() *netstack.Net } diff --git a/client/internal/dns/wgiface_windows.go b/client/internal/dns/wgiface_windows.go index d1374fd54..347e0233a 100644 --- a/client/internal/dns/wgiface_windows.go +++ b/client/internal/dns/wgiface_windows.go @@ -1,6 +1,8 @@ package dns import ( + "golang.zx2c4.com/wireguard/tun/netstack" + "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/wgaddr" ) @@ -12,5 +14,6 @@ type WGIface interface { IsUserspaceBind() bool GetFilter() device.PacketFilter GetDevice() *device.FilteredDevice + GetNet() *netstack.Net GetInterfaceGUIDString() (string, error) } 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..e15cc8fb7 --- /dev/null +++ b/client/internal/dns_test.go @@ -0,0 +1,138 @@ +package internal + +import ( + "net/netip" + "testing" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbdns "github.com/netbirdio/netbird/dns" +) + +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) +} diff --git a/client/internal/dnsfwd/forwarder.go b/client/internal/dnsfwd/forwarder.go index aef16a8cf..b7e5a10e3 100644 --- a/client/internal/dnsfwd/forwarder.go +++ b/client/internal/dnsfwd/forwarder.go @@ -18,6 +18,7 @@ import ( nberrors "github.com/netbirdio/netbird/client/errors" firewall "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/internal/dns/resutil" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/route" ) @@ -25,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 { @@ -189,90 +205,153 @@ func (f *DNSForwarder) Close(ctx context.Context) error { return nberrors.FormatErrorOrNil(result) } -func (f *DNSForwarder) handleDNSQuery(w dns.ResponseWriter, query *dns.Msg) *dns.Msg { +func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, query *dns.Msg, startTime time.Time) { if len(query.Question) == 0 { - return nil + return } question := query.Question[0] - log.Tracef("received DNS request for DNS forwarder: domain=%v type=%v class=%v", - question.Name, question.Qtype, question.Qclass) + qname := strings.ToLower(question.Name) - domain := strings.ToLower(question.Name) + logger.Tracef("question: domain=%s type=%s class=%s", + qname, dns.TypeToString[question.Qtype], dns.ClassToString[question.Qclass]) resp := query.SetReply(query) - var network string - switch question.Qtype { - case dns.TypeA: - network = "ip4" - case dns.TypeAAAA: - network = "ip6" - default: - // TODO: Handle other types - resp.Rcode = dns.RcodeNotImplemented - if err := w.WriteMsg(resp); err != nil { - log.Errorf("failed to write DNS response: %v", err) - } - return nil - } - - mostSpecificResId, matchingEntries := f.getMatchingEntries(strings.TrimSuffix(domain, ".")) - // query doesn't match any configured domain + mostSpecificResId, matchingEntries := f.getMatchingEntries(strings.TrimSuffix(qname, ".")) if mostSpecificResId == "" { resp.Rcode = dns.RcodeRefused - if err := w.WriteMsg(resp); err != nil { - log.Errorf("failed to write DNS response: %v", err) - } - return nil + f.writeResponse(logger, w, resp, qname, startTime) + return } ctx, cancel := context.WithTimeout(context.Background(), upstreamTimeout) defer cancel() - ips, err := f.resolver.LookupNetIP(ctx, network, domain) - if err != nil { - f.handleDNSError(ctx, w, question, resp, domain, err) - return nil + + 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) } - - f.updateInternalState(ips, mostSpecificResId, matchingEntries) - f.addIPsToResponse(resp, domain, ips) - f.cache.set(domain, question.Qtype, ips) - - return resp } -func (f *DNSForwarder) handleDNSQueryUDP(w dns.ResponseWriter, query *dns.Msg) { - resp := f.handleDNSQuery(w, query) - if resp == nil { +// 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, reqHasEdns, startTime) return } - opt := query.IsEdns0() + f.updateInternalState(result.IPs, mostSpecificResId, matchingEntries) + resp.Answer = append(resp.Answer, resutil.IPsToRRs(qname, result.IPs, f.ttl)...) + f.cache.set(qname, question.Qtype, result.IPs) + + 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) + return + } + + logger.Tracef("response: domain=%s rcode=%s answers=%s size=%dB took=%s", + qname, dns.RcodeToString[resp.Rcode], resutil.FormatAnswers(resp.Answer), resp.Len(), time.Since(startTime)) +} + +// udpResponseWriter wraps a dns.ResponseWriter to handle UDP-specific truncation. +type udpResponseWriter struct { + dns.ResponseWriter + query *dns.Msg +} + +func (u *udpResponseWriter) WriteMsg(resp *dns.Msg) error { + opt := u.query.IsEdns0() maxSize := dns.MinMsgSize if opt != nil { - // client advertised a larger EDNS0 buffer maxSize = int(opt.UDPSize()) } - // if our response is too big, truncate and set the TC bit if resp.Len() > maxSize { resp.Truncate(maxSize) } - if err := w.WriteMsg(resp); err != nil { - log.Errorf("failed to write DNS response: %v", err) + return u.ResponseWriter.WriteMsg(resp) +} + +func (f *DNSForwarder) handleDNSQueryUDP(w dns.ResponseWriter, query *dns.Msg) { + startTime := time.Now() + fields := log.Fields{ + "request_id": resutil.GenerateRequestID(), + "dns_id": fmt.Sprintf("%04x", query.Id), } + if addr := w.RemoteAddr(); addr != nil { + fields["client"] = addr.String() + } + logger := log.WithFields(fields) + + f.handleDNSQuery(logger, &udpResponseWriter{ResponseWriter: w, query: query}, query, startTime) } func (f *DNSForwarder) handleDNSQueryTCP(w dns.ResponseWriter, query *dns.Msg) { - resp := f.handleDNSQuery(w, query) - if resp == nil { - return + startTime := time.Now() + fields := log.Fields{ + "request_id": resutil.GenerateRequestID(), + "dns_id": fmt.Sprintf("%04x", query.Id), } + if addr := w.RemoteAddr(); addr != nil { + fields["client"] = addr.String() + } + logger := log.WithFields(fields) - if err := w.WriteMsg(resp); err != nil { - log.Errorf("failed to write DNS response: %v", err) - } + f.handleDNSQuery(logger, w, query, startTime) } func (f *DNSForwarder) updateInternalState(ips []netip.Addr, mostSpecificResId route.ResID, matchingEntries []*ForwarderEntry) { @@ -310,141 +389,62 @@ func (f *DNSForwarder) updateFirewall(matchingEntries []*ForwarderEntry, prefixe } } -// setResponseCodeForNotFound determines and sets the appropriate response code when IsNotFound is true -// It distinguishes between NXDOMAIN (domain doesn't exist) and NODATA (domain exists but no records of requested type) -// -// LIMITATION: This function only checks A and AAAA record types to determine domain existence. -// If a domain has only other record types (MX, TXT, CNAME, etc.) but no A/AAAA records, -// it may incorrectly return NXDOMAIN instead of NODATA. This is acceptable since the forwarder -// only handles A/AAAA queries and returns NOTIMP for other types. -func (f *DNSForwarder) setResponseCodeForNotFound(ctx context.Context, resp *dns.Msg, domain string, originalQtype uint16) { - // Try querying for a different record type to see if the domain exists - // If the original query was for AAAA, try A. If it was for A, try AAAA. - // This helps distinguish between NXDOMAIN and NODATA. - var alternativeNetwork string - switch originalQtype { - case dns.TypeAAAA: - alternativeNetwork = "ip4" - case dns.TypeA: - alternativeNetwork = "ip6" - default: - resp.Rcode = dns.RcodeNameError - return - } - - if _, err := f.resolver.LookupNetIP(ctx, alternativeNetwork, domain); err != nil { - var dnsErr *net.DNSError - if errors.As(err, &dnsErr) && dnsErr.IsNotFound { - // Alternative query also returned not found - domain truly doesn't exist - resp.Rcode = dns.RcodeNameError - return - } - // Some other error (timeout, server failure, etc.) - can't determine, assume domain exists - resp.Rcode = dns.RcodeSuccess - return - } - - // Alternative query succeeded - domain exists but has no records of this type - resp.Rcode = dns.RcodeSuccess -} - // handleDNSError processes DNS lookup errors and sends an appropriate error response. func (f *DNSForwarder) handleDNSError( ctx context.Context, + logger *log.Entry, w dns.ResponseWriter, question dns.Question, resp *dns.Msg, domain string, - err error, + result resutil.LookupResult, + reqHasEdns bool, + startTime time.Time, ) { - // Default to SERVFAIL; override below when appropriate. - resp.Rcode = dns.RcodeServerFailure - qType := question.Qtype qTypeName := dns.TypeToString[qType] - // Prefer typed DNS errors; fall back to generic logging otherwise. - var dnsErr *net.DNSError - if !errors.As(err, &dnsErr) { - log.Warnf(errResolveFailed, domain, err) - if writeErr := w.WriteMsg(resp); writeErr != nil { - log.Errorf("failed to write failure DNS response: %v", writeErr) - } - return - } + resp.Rcode = result.Rcode - // NotFound: set NXDOMAIN / appropriate code via helper. - if dnsErr.IsNotFound { - f.setResponseCodeForNotFound(ctx, resp, domain, qType) - if writeErr := w.WriteMsg(resp); writeErr != nil { - log.Errorf("failed to write failure DNS response: %v", writeErr) - } + // NotFound: cache negative result and respond + if result.Rcode == dns.RcodeNameError || result.Rcode == dns.RcodeSuccess { f.cache.set(domain, question.Qtype, nil) + f.writeResponse(logger, w, resp, domain, startTime) return } // Upstream failed but we might have a cached answer—serve it if present. if ips, ok := f.cache.get(domain, qType); ok { if len(ips) > 0 { - log.Debugf("serving cached DNS response after upstream failure: domain=%s type=%s", domain, qTypeName) - f.addIPsToResponse(resp, domain, ips) + logger.Debugf("serving cached DNS response after upstream failure: domain=%s type=%s", domain, qTypeName) + resp.Answer = append(resp.Answer, resutil.IPsToRRs(domain, ips, f.ttl)...) resp.Rcode = dns.RcodeSuccess - if writeErr := w.WriteMsg(resp); writeErr != nil { - log.Errorf("failed to write cached DNS response: %v", writeErr) - } - } else { // send NXDOMAIN / appropriate code if cache is empty - f.setResponseCodeForNotFound(ctx, resp, domain, qType) - if writeErr := w.WriteMsg(resp); writeErr != nil { - log.Errorf("failed to write failure DNS response: %v", writeErr) - } + f.writeResponse(logger, w, resp, domain, startTime) + return + } + + // Cached negative result - re-verify NXDOMAIN vs NODATA + verifyResult := resutil.LookupIP(ctx, f.resolver, resutil.NetworkForQtype(qType), domain, qType) + if verifyResult.Rcode == dns.RcodeNameError || verifyResult.Rcode == dns.RcodeSuccess { + resp.Rcode = verifyResult.Rcode + f.writeResponse(logger, w, resp, domain, startTime) + return } - return } - // No cache. Log with or without the server field for more context. - if dnsErr.Server != "" { - log.Warnf("failed to resolve: type=%s domain=%s server=%s: %v", qTypeName, domain, dnsErr.Server, err) + // No cache or verification failed. Log with or without the server field for more context. + var dnsErr *net.DNSError + if errors.As(result.Err, &dnsErr) && dnsErr.Server != "" { + logger.Warnf("upstream failure: type=%s domain=%s server=%s: %v", qTypeName, domain, dnsErr.Server, result.Err) } else { - log.Warnf(errResolveFailed, domain, err) + logger.Warnf(errResolveFailed, domain, result.Err) } - // Write final failure response. - if writeErr := w.WriteMsg(resp); writeErr != nil { - log.Errorf("failed to write failure DNS response: %v", writeErr) + if reqHasEdns { + attachEDE(resp, edeCodeFor(dnsErr), edeText(dnsErr)) } -} -// addIPsToResponse adds IP addresses to the DNS response as appropriate A or AAAA records -func (f *DNSForwarder) addIPsToResponse(resp *dns.Msg, domain string, ips []netip.Addr) { - for _, ip := range ips { - var respRecord dns.RR - if ip.Is6() { - log.Tracef("resolved domain=%s to IPv6=%s", domain, ip) - rr := dns.AAAA{ - AAAA: ip.AsSlice(), - Hdr: dns.RR_Header{ - Name: domain, - Rrtype: dns.TypeAAAA, - Class: dns.ClassINET, - Ttl: f.ttl, - }, - } - respRecord = &rr - } else { - log.Tracef("resolved domain=%s to IPv4=%s", domain, ip) - rr := dns.A{ - A: ip.AsSlice(), - Hdr: dns.RR_Header{ - Name: domain, - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: f.ttl, - }, - } - respRecord = &rr - } - resp.Answer = append(resp.Answer, respRecord) - } + f.writeResponse(logger, w, resp, domain, startTime) } // getMatchingEntries retrieves the resource IDs for a given domain. @@ -484,3 +484,33 @@ 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}) +} diff --git a/client/internal/dnsfwd/forwarder_test.go b/client/internal/dnsfwd/forwarder_test.go index 4d0b96a75..c69a9166e 100644 --- a/client/internal/dnsfwd/forwarder_test.go +++ b/client/internal/dnsfwd/forwarder_test.go @@ -10,11 +10,13 @@ import ( "time" "github.com/miekg/dns" + log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "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" @@ -131,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 @@ -317,8 +354,9 @@ func TestDNSForwarder_UnauthorizedDomainAccess(t *testing.T) { query.SetQuestion(dns.Fqdn(tt.queryDomain), dns.TypeA) mockWriter := &test.MockResponseWriter{} - resp := forwarder.handleDNSQuery(mockWriter, query) + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now()) + resp := mockWriter.GetLastResponse() if tt.shouldResolve { require.NotNil(t, resp, "Expected response for authorized domain") require.Equal(t, dns.RcodeSuccess, resp.Rcode, "Expected successful response") @@ -328,10 +366,9 @@ func TestDNSForwarder_UnauthorizedDomainAccess(t *testing.T) { mockFirewall.AssertExpectations(t) mockResolver.AssertExpectations(t) } else { - if resp != nil { - assert.True(t, len(resp.Answer) == 0 || resp.Rcode != dns.RcodeSuccess, - "Unauthorized domain should not return successful answers") - } + require.NotNil(t, resp, "Expected response") + assert.True(t, len(resp.Answer) == 0 || resp.Rcode != dns.RcodeSuccess, + "Unauthorized domain should not return successful answers") mockFirewall.AssertNotCalled(t, "UpdateSet") mockResolver.AssertNotCalled(t, "LookupNetIP") } @@ -465,14 +502,16 @@ func TestDNSForwarder_FirewallSetUpdates(t *testing.T) { dnsQuery.SetQuestion(dns.Fqdn(tt.query), dns.TypeA) mockWriter := &test.MockResponseWriter{} - resp := forwarder.handleDNSQuery(mockWriter, dnsQuery) + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, dnsQuery, time.Now()) // Verify response + resp := mockWriter.GetLastResponse() if tt.shouldResolve { require.NotNil(t, resp, "Expected response for authorized domain") require.Equal(t, dns.RcodeSuccess, resp.Rcode) require.NotEmpty(t, resp.Answer) - } else if resp != nil { + } else { + require.NotNil(t, resp, "Expected response") assert.True(t, resp.Rcode == dns.RcodeRefused || len(resp.Answer) == 0, "Unauthorized domain should be refused or have no answers") } @@ -527,9 +566,10 @@ func TestDNSForwarder_MultipleIPsInSingleUpdate(t *testing.T) { query.SetQuestion("example.com.", dns.TypeA) mockWriter := &test.MockResponseWriter{} - resp := forwarder.handleDNSQuery(mockWriter, query) + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now()) // Verify response contains all IPs + resp := mockWriter.GetLastResponse() require.NotNil(t, resp) require.Equal(t, dns.RcodeSuccess, resp.Rcode) require.Len(t, resp.Answer, 3, "Should have 3 answer records") @@ -540,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 }{ { @@ -557,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", }, } @@ -594,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 @@ -604,11 +633,293 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) { }, } - _ = forwarder.handleDNSQuery(mockWriter, query) + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now()) // Check the response written to the writer require.NotNil(t, writtenResp, "Expected response to be written") 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") }) } } @@ -674,7 +985,8 @@ func TestDNSForwarder_ServeFromCacheOnUpstreamFailure(t *testing.T) { q1 := &dns.Msg{} q1.SetQuestion(dns.Fqdn("example.com"), dns.TypeA) w1 := &test.MockResponseWriter{} - resp1 := forwarder.handleDNSQuery(w1, q1) + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w1, q1, time.Now()) + resp1 := w1.GetLastResponse() require.NotNil(t, resp1) require.Equal(t, dns.RcodeSuccess, resp1.Rcode) require.Len(t, resp1.Answer, 1) @@ -682,13 +994,13 @@ func TestDNSForwarder_ServeFromCacheOnUpstreamFailure(t *testing.T) { // Second query: serve from cache after upstream failure q2 := &dns.Msg{} q2.SetQuestion(dns.Fqdn("example.com"), dns.TypeA) - var writtenResp *dns.Msg - w2 := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { writtenResp = m; return nil }} - _ = forwarder.handleDNSQuery(w2, q2) + w2 := &test.MockResponseWriter{} + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w2, q2, time.Now()) - require.NotNil(t, writtenResp, "expected response to be written") - require.Equal(t, dns.RcodeSuccess, writtenResp.Rcode) - require.Len(t, writtenResp.Answer, 1) + resp2 := w2.GetLastResponse() + require.NotNil(t, resp2, "expected response to be written") + require.Equal(t, dns.RcodeSuccess, resp2.Rcode) + require.Len(t, resp2.Answer, 1) mockResolver.AssertExpectations(t) } @@ -714,7 +1026,8 @@ func TestDNSForwarder_CacheNormalizationCasingAndDot(t *testing.T) { q1 := &dns.Msg{} q1.SetQuestion(mixedQuery+".", dns.TypeA) w1 := &test.MockResponseWriter{} - resp1 := forwarder.handleDNSQuery(w1, q1) + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w1, q1, time.Now()) + resp1 := w1.GetLastResponse() require.NotNil(t, resp1) require.Equal(t, dns.RcodeSuccess, resp1.Rcode) require.Len(t, resp1.Answer, 1) @@ -726,13 +1039,13 @@ func TestDNSForwarder_CacheNormalizationCasingAndDot(t *testing.T) { q2 := &dns.Msg{} q2.SetQuestion("EXAMPLE.COM", dns.TypeA) - var writtenResp *dns.Msg - w2 := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { writtenResp = m; return nil }} - _ = forwarder.handleDNSQuery(w2, q2) + w2 := &test.MockResponseWriter{} + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w2, q2, time.Now()) - require.NotNil(t, writtenResp) - require.Equal(t, dns.RcodeSuccess, writtenResp.Rcode) - require.Len(t, writtenResp.Answer, 1) + resp2 := w2.GetLastResponse() + require.NotNil(t, resp2) + require.Equal(t, dns.RcodeSuccess, resp2.Rcode) + require.Len(t, resp2.Answer, 1) mockResolver.AssertExpectations(t) } @@ -783,8 +1096,9 @@ func TestDNSForwarder_MultipleOverlappingPatterns(t *testing.T) { query.SetQuestion("smtp.mail.example.com.", dns.TypeA) mockWriter := &test.MockResponseWriter{} - resp := forwarder.handleDNSQuery(mockWriter, query) + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now()) + resp := mockWriter.GetLastResponse() require.NotNil(t, resp) assert.Equal(t, dns.RcodeSuccess, resp.Rcode) @@ -896,26 +1210,15 @@ func TestDNSForwarder_NodataVsNxdomain(t *testing.T) { query := &dns.Msg{} query.SetQuestion(dns.Fqdn("example.com"), tt.queryType) - var writtenResp *dns.Msg - mockWriter := &test.MockResponseWriter{ - WriteMsgFunc: func(m *dns.Msg) error { - writtenResp = m - return nil - }, - } + mockWriter := &test.MockResponseWriter{} + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now()) - resp := forwarder.handleDNSQuery(mockWriter, query) - - // If a response was returned, it means it should be written (happens in wrapper functions) - if resp != nil && writtenResp == nil { - writtenResp = resp - } - - require.NotNil(t, writtenResp, "Expected response to be written") - assert.Equal(t, tt.expectedCode, writtenResp.Rcode, tt.description) + resp := mockWriter.GetLastResponse() + require.NotNil(t, resp, "Expected response to be written") + assert.Equal(t, tt.expectedCode, resp.Rcode, tt.description) if tt.expectNoAnswer { - assert.Empty(t, writtenResp.Answer, "Response should have no answer records") + assert.Empty(t, resp.Answer, "Response should have no answer records") } mockResolver.AssertExpectations(t) @@ -930,15 +1233,8 @@ func TestDNSForwarder_EmptyQuery(t *testing.T) { query := &dns.Msg{} // Don't set any question - writeCalled := false - mockWriter := &test.MockResponseWriter{ - WriteMsgFunc: func(m *dns.Msg) error { - writeCalled = true - return nil - }, - } - resp := forwarder.handleDNSQuery(mockWriter, query) + mockWriter := &test.MockResponseWriter{} + forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now()) - assert.Nil(t, resp, "Should return nil for empty query") - assert.False(t, writeCalled, "Should not write response for empty query") + assert.Nil(t, mockWriter.GetLastResponse(), "Should not write response for empty query") } diff --git a/client/internal/dnsfwd/manager.go b/client/internal/dnsfwd/manager.go index 58b88d9ef..c4c16cd3f 100644 --- a/client/internal/dnsfwd/manager.go +++ b/client/internal/dnsfwd/manager.go @@ -80,6 +80,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 { diff --git a/client/internal/ebpf/ebpf/bpf_bpfeb.o b/client/internal/ebpf/ebpf/bpf_bpfeb.o index 6e9cda44a..7433ad740 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.o b/client/internal/ebpf/ebpf/bpf_bpfel.o index 6338f4774..779f43a00 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 index 93797da76..c575f9a7a 100644 --- a/client/internal/ebpf/ebpf/dns_fwd_linux.go +++ b/client/internal/ebpf/ebpf/dns_fwd_linux.go @@ -2,7 +2,8 @@ package ebpf import ( "encoding/binary" - "net" + "fmt" + "net/netip" log "github.com/sirupsen/logrus" ) @@ -12,7 +13,7 @@ const ( mapKeyDNSPort uint32 = 1 ) -func (tf *GeneralManager) LoadDNSFwd(ip string, dnsPort int) error { +func (tf *GeneralManager) LoadDNSFwd(ip netip.Addr, dnsPort int) error { log.Debugf("load eBPF DNS forwarder, watching addr: %s:53, redirect to port: %d", ip, dnsPort) tf.lock.Lock() defer tf.lock.Unlock() @@ -22,7 +23,11 @@ func (tf *GeneralManager) LoadDNSFwd(ip string, dnsPort int) error { return err } - err = tf.bpfObjs.NbMapDnsIp.Put(mapKeyDNSIP, ip2int(ip)) + if !ip.Is4() { + return fmt.Errorf("eBPF DNS forwarder only supports IPv4, got %s", ip) + } + ip4 := ip.As4() + err = tf.bpfObjs.NbMapDnsIp.Put(mapKeyDNSIP, binary.BigEndian.Uint32(ip4[:])) if err != nil { return err } @@ -44,8 +49,3 @@ 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/src/dns_fwd.c b/client/internal/ebpf/ebpf/src/dns_fwd.c index 5f3fbcc32..9f8de2001 100644 --- a/client/internal/ebpf/ebpf/src/dns_fwd.c +++ b/client/internal/ebpf/ebpf/src/dns_fwd.c @@ -52,11 +52,14 @@ int xdp_dns_fwd(struct iphdr *ip, struct udphdr *udp) { if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) { udp->dest = dns_port; + // Clear the now-stale checksum; zero means "not computed" for IPv4. + udp->check = 0; return XDP_PASS; } if (udp->source == dns_port && ip->saddr == dns_ip) { udp->source = GENERAL_DNS_PORT; + udp->check = 0; return XDP_PASS; } 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..25a767090 100644 --- a/client/internal/ebpf/manager/manager.go +++ b/client/internal/ebpf/manager/manager.go @@ -1,8 +1,10 @@ package manager +import "net/netip" + // Manager is used to load multiple eBPF programs. E.g., current DNS programs and WireGuard proxy type Manager interface { - LoadDNSFwd(ip string, dnsPort int) error + LoadDNSFwd(ip netip.Addr, dnsPort int) error FreeDNSFwd() error LoadWgProxy(proxyPort, wgPort int) error FreeWGProxy() error diff --git a/client/internal/engine.go b/client/internal/engine.go index ebc05c453..617892e43 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -9,7 +9,6 @@ import ( "net/netip" "net/url" "os" - "reflect" "runtime" "slices" "sort" @@ -23,20 +22,26 @@ import ( log "github.com/sirupsen/logrus" "golang.zx2c4.com/wireguard/tun/netstack" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" - "google.golang.org/protobuf/proto" 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" dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config" "github.com/netbirdio/netbird/client/internal/dnsfwd" + "github.com/netbirdio/netbird/client/internal/expose" "github.com/netbirdio/netbird/client/internal/ingressgw" + "github.com/netbirdio/netbird/client/internal/lazyconn" + "github.com/netbirdio/netbird/client/internal/metrics" "github.com/netbirdio/netbird/client/internal/netflow" nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" "github.com/netbirdio/netbird/client/internal/networkmonitor" @@ -44,27 +49,33 @@ import ( "github.com/netbirdio/netbird/client/internal/peer/guard" icemaker "github.com/netbirdio/netbird/client/internal/peer/ice" "github.com/netbirdio/netbird/client/internal/peerstore" + "github.com/netbirdio/netbird/client/internal/portforward" "github.com/netbirdio/netbird/client/internal/profilemanager" "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" cProto "github.com/netbirdio/netbird/client/proto" - "github.com/netbirdio/netbird/shared/management/domain" - semaphoregroup "github.com/netbirdio/netbird/util/semaphore-group" - - nbssh "github.com/netbirdio/netbird/client/ssh" "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. @@ -74,18 +85,26 @@ import ( const ( PeerConnectionTimeoutMax = 45000 // ms PeerConnectionTimeoutMin = 30000 // ms - connInitLimit = 200 + 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 ) var ErrResetConnection = fmt.Errorf("reset connection") -// EngineConfig is a config for the Engine +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 @@ -115,7 +134,12 @@ type EngineConfig struct { RosenpassEnabled bool RosenpassPermissive bool - ServerSSHAllowed bool + ServerSSHAllowed bool + EnableSSHRoot *bool + EnableSSHSFTP *bool + EnableSSHLocalPortForwarding *bool + EnableSSHRemotePortForwarding *bool + DisableSSHAuth *bool DNSRouteInterval time.Duration @@ -125,10 +149,37 @@ 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 + + // for debug bundle generation + ProfileConfig *profilemanager.Config + + 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. +type EngineServices struct { + SignalClient signal.Client + MgmClient mgm.Client + RelayManager *relayClient.Manager + StatusRecorder *peer.Status + Checks []*mgmProto.Checks + StateManager *statemanager.Manager + UpdateManager *updater.Manager + ClientMetrics *metrics.ClientMetrics + MetricsCtx context.Context } // Engine is a mechanism responsible for reacting on Signal and Management stream events and managing connections to the remote peers. @@ -148,8 +199,6 @@ type Engine struct { // syncMsgMux is used to guarantee sequential Management Service message processing syncMsgMux *sync.Mutex - // sshMux protects sshServer field access - sshMux sync.Mutex config *EngineConfig mobileDep MobileDependency @@ -166,6 +215,8 @@ type Engine struct { ctx context.Context cancel context.CancelFunc + started bool + wgInterface WGIface udpMux *udpmux.UniversalUDPMuxDefault @@ -173,10 +224,16 @@ 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 - sshServerFunc func(hostKeyPEM []byte, addr string) (nbssh.Server, error) - sshServer nbssh.Server + sshServer sshServer statusRecorder *peer.Status @@ -191,15 +248,26 @@ type Engine struct { // checks are the client-applied posture checks that need to be evaluated on the client checks []*mgmProto.Checks - relayManager *relayClient.Manager - stateManager *statemanager.Manager - srWatcher *guard.SRWatcher + relayManager *relayClient.Manager + stateManager *statemanager.Manager + portForwardManager *portforward.Manager + srWatcher *guard.SRWatcher - // Sync response persistence - persistSyncResponse bool - latestSyncResponse *mgmProto.SyncResponse - connSemaphore *semaphoregroup.SemaphoreGroup - 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 // WireGuard interface monitor wgIfaceMonitor *WGIfaceMonitor @@ -208,6 +276,29 @@ type Engine struct { shutdownWg sync.WaitGroup probeStunTurn *relay.StunTurnProbe + + // 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 @@ -224,50 +315,52 @@ type localIpUpdater interface { func NewEngine( clientCtx context.Context, clientCancel context.CancelFunc, - signalClient signal.Client, - mgmClient mgm.Client, - relayManager *relayClient.Manager, config *EngineConfig, + services EngineServices, mobileDep MobileDependency, - statusRecorder *peer.Status, - checks []*mgmProto.Checks, ) *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, - signal: signalClient, - signaler: peer.NewSignaler(signalClient, config.WgPrivateKey), - mgmClient: mgmClient, - relayManager: relayManager, - peerStore: peerstore.NewConnStore(), - syncMsgMux: &sync.Mutex{}, - config: config, - mobileDep: mobileDep, - STUNs: []*stun.URI{}, - TURNs: []*stun.URI{}, - networkSerial: 0, - sshServerFunc: nbssh.DefaultSSHServer, - statusRecorder: statusRecorder, - checks: checks, - connSemaphore: semaphoregroup.NewSemaphoreGroup(connInitLimit), - probeStunTurn: relay.NewStunTurnProbe(relay.DefaultCacheTTL), + clientCtx: clientCtx, + clientCancel: clientCancel, + ctx: ctx, + cancel: cancel, + signal: services.SignalClient, + signaler: peer.NewSignaler(services.SignalClient, config.WgPrivateKey), + mgmClient: services.MgmClient, + relayManager: services.RelayManager, + peerStore: peerstore.NewConnStore(), + syncMsgMux: &sync.Mutex{}, + config: config, + mobileDep: mobileDep, + STUNs: []*stun.URI{}, + TURNs: []*stun.URI{}, + networkSerial: 0, + statusRecorder: services.StatusRecorder, + stateManager: services.StateManager, + portForwardManager: portforward.NewManager(), + checks: services.Checks, + probeStunTurn: relay.NewStunTurnProbe(relay.DefaultCacheTTL), + jobExecutor: jobexec.NewExecutor(), + clientMetrics: services.ClientMetrics, + metricsCtx: services.MetricsCtx, + updateManager: services.UpdateManager, + syncStoreDir: config.StateDir, } - - sm := profilemanager.NewServiceManager("") - - path := sm.GetStatePath() - if runtime.GOOS == "ios" { - if !fileExists(mobileDep.StateFilePath) { - err := createFile(mobileDep.StateFilePath) - if err != nil { - log.Errorf("failed to create state file: %v", err) - // we are not exiting as we can run without the state manager - } - } - - path = mobileDep.StateFilePath - } - engine.stateManager = statemanager.New(path) + // 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 @@ -279,67 +372,12 @@ func (e *Engine) Stop() error { log.Debugf("tried stopping engine that is nil") return nil } + e.cancel() e.syncMsgMux.Lock() - defer e.syncMsgMux.Unlock() - if e.connMgr != nil { - e.connMgr.Close() - } + e.stopLocked() - // stopping network monitor first to avoid starting the engine again - if e.networkMonitor != nil { - e.networkMonitor.Stop() - } - log.Info("Network monitor: stopped") - - // stop/restore DNS first so dbus and friends don't complain because of a missing interface - e.stopDNSServer() - - if e.ingressGatewayMgr != nil { - if err := e.ingressGatewayMgr.Close(); err != nil { - log.Warnf("failed to cleanup forward rules: %v", err) - } - e.ingressGatewayMgr = nil - } - - e.stopDNSForwarder() - - if e.routeManager != nil { - e.routeManager.Stop(e.stateManager) - } - - if e.srWatcher != nil { - e.srWatcher.Close() - } - - e.statusRecorder.ReplaceOfflinePeers([]peer.State{}) - e.statusRecorder.UpdateDNSStates([]peer.NSGroupState{}) - e.statusRecorder.UpdateRelayStates([]relay.ProbeResult{}) - - if err := e.removeAllPeers(); err != nil { - return fmt.Errorf("failed to remove all peers: %s", err) - } - - if e.cancel != nil { - e.cancel() - } - - e.close() - - // stop flow manager after wg interface is gone - if e.flowManager != nil { - e.flowManager.Close() - } - - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - - if err := e.stateManager.Stop(ctx); err != nil { - return fmt.Errorf("failed to stop state manager: %w", err) - } - 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) @@ -355,6 +393,87 @@ func (e *Engine) Stop() error { 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() + } + + // stopping network monitor first to avoid starting the engine again + if e.networkMonitor != nil { + e.networkMonitor.Stop() + } + log.Info("Network monitor: stopped") + + if err := e.stopSSHServer(); err != nil { + log.Warnf("failed to stop SSH server: %v", err) + } + + e.cleanupSSHConfig() + + if e.ingressGatewayMgr != nil { + if err := e.ingressGatewayMgr.Close(); err != nil { + log.Warnf("failed to cleanup forward rules: %v", err) + } + e.ingressGatewayMgr = nil + } + + if e.srWatcher != nil { + e.srWatcher.Close() + } + + if e.sessionWatcher != nil { + e.sessionWatcher.Close() + } + + if e.updateManager != nil { + e.updateManager.SetDownloadOnly() + } + + log.Info("cleaning up status recorder states") + e.statusRecorder.ReplaceOfflinePeers([]peer.State{}) + e.statusRecorder.UpdateDNSStates([]peer.NSGroupState{}) + e.statusRecorder.UpdateRelayStates([]relay.ProbeResult{}) + + if err := e.removeAllPeers(); err != nil { + log.Errorf("failed to remove all peers: %s", err) + } + + if e.routeManager != nil { + e.routeManager.Stop(e.stateManager) + } + + e.stopDNSForwarder() + + // stop/restore DNS after peers are closed but before interface goes down + // so dbus and friends don't complain because of a missing interface + e.stopDNSServer() + + e.jobExecutorWG.Wait() // block until job goroutines finish + + e.close() + + // stop flow manager after wg interface is gone + if e.flowManager != nil { + e.flowManager.Close() + } + + stateCtx, stateCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer stateCancel() + + if err := e.stateManager.Stop(stateCtx); err != nil { + log.Errorf("failed to stop state manager: %v", err) + } + if err := e.stateManager.PersistState(context.Background()); err != nil { + log.Errorf("failed to persist state: %v", err) + } +} + // calculateShutdownTimeout returns shutdown timeout: 10s base + 100ms per peer, capped at 30s. func (e *Engine) calculateShutdownTimeout() time.Duration { peerCount := len(e.peerStore.PeersPubKey()) @@ -390,18 +509,39 @@ 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() if err != nil { @@ -422,12 +562,11 @@ 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) } - err := e.rpManager.Run() - if err != nil { + if err := e.rpManager.Run(); err != nil { return fmt.Errorf("run rosenpass manager: %w", err) } } @@ -435,13 +574,11 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) initialRoutes, dnsConfig, dnsFeatureFlag, err := e.readInitialSettings() 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 @@ -472,44 +609,86 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) e.routeManager.SetRouteChangeListener(e.mobileDep.NetworkChangeListener) + 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 { return err } + // Inject firewall into DNS server now that it's available. + // The DNS server is created before the firewall because the route manager + // depends on the DNS server, and the firewall depends on the wg interface. + e.dnsServer.SetFirewall(e.firewall) + 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) } + // Set up notrack rules immediately after proxy is listening to prevent + // conntrack entries from being created before the rules are in place + e.setupWGProxyNoTrack() + + // Start after interface is up since port may have been resolved from 0 or changed if occupied + e.shutdownWg.Add(1) + go func() { + defer e.shutdownWg.Done() + e.portForwardManager.Start(e.ctx, uint16(e.config.WgPort)) + }() + + // Set the WireGuard interface for rosenpass after interface is up + if e.rpManager != nil { + e.rpManager.SetInterface(e.wgInterface) + } + // if inbound conns are blocked there is no need to create the ACL manager if e.firewall != nil && !e.config.BlockInbound { 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) - e.srWatcher = guard.NewSRWatcher(e.signal, e.relayManager, e.mobileDep.IFaceDiscover, iceCfg) - e.srWatcher.Start() + // 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.receiveSignalEvents() + e.srWatcher = guard.NewSRWatcher(e.signal, e.relayManager, e.mobileDep.IFaceDiscover, iceCfg) + e.srWatcher.Start(peer.IsForceRelayed()) + + if err = e.receiveSignalEvents(); err != nil { + return err + } e.receiveManagementEvents() + e.receiveJobEvents() // starting network monitor at the very last to avoid disruptions e.startNetworkMonitor() @@ -517,11 +696,12 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) // monitor WireGuard interface lifecycle and restart engine on changes e.wgIfaceMonitor = NewWGIfaceMonitor() e.shutdownWg.Add(1) + wgIfaceName := e.wgInterface.Name() go func() { defer e.shutdownWg.Done() - if shouldRestart, err := e.wgIfaceMonitor.Start(e.ctx, e.wgInterface.Name()); shouldRestart { + if shouldRestart, err := e.wgIfaceMonitor.Start(e.ctx, wgIfaceName); shouldRestart { log.Infof("WireGuard interface monitor: %s, restarting engine", err) e.triggerClientRestart() } else if err != nil { @@ -538,11 +718,15 @@ func (e *Engine) createFirewall() error { return nil } + firewalld.SetParentContext(e.ctx) + var err error e.firewall, err = firewall.NewFirewall(e.wgInterface, e.stateManager, e.flowManager.GetLogger(), e.config.DisableServerRoutes, e.config.MTU) - if err != nil || e.firewall == nil { - log.Errorf("failed creating firewall manager: %s", err) - return nil + if err != nil { + return fmt.Errorf("create firewall manager: %w", err) + } + if e.firewall == nil { + return fmt.Errorf("create firewall manager: received nil manager") } if err := e.initFirewall(); err != nil { @@ -554,7 +738,6 @@ 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) } @@ -569,7 +752,7 @@ 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 + // IPv4-only: rosenpass peers connect via AllowedIps[0] which is always v4. if _, err := e.firewall.AddPeerFiltering( nil, net.IP{0, 0, 0, 0}, @@ -588,6 +771,23 @@ func (e *Engine) initFirewall() error { return nil } +// setupWGProxyNoTrack configures connection tracking exclusion for WireGuard proxy traffic. +// This prevents conntrack/MASQUERADE from affecting loopback traffic between WireGuard and the eBPF proxy. +func (e *Engine) setupWGProxyNoTrack() { + if e.firewall == nil { + return + } + + proxyPort := e.wgInterface.GetProxyPort() + if proxyPort == 0 { + return + } + + if err := e.firewall.SetupEBPFProxyNoTrack(proxyPort, uint16(e.config.WgPort)); err != nil { + log.Warnf("failed to setup ebpf proxy notrack: %v", err) + } +} + func (e *Engine) blockLanAccess() { if e.config.BlockInbound { // no need to set up extra deny rules if inbound is already blocked in general @@ -604,10 +804,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 { + source := v4 + if network.Addr().Is6() { + source = v6 + } if _, err := e.firewall.AddRouteFiltering( nil, - []netip.Prefix{v4}, + []netip.Prefix{source}, firewallManager.Network{Prefix: network}, firewallManager.ProtocolALL, nil, @@ -645,7 +850,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 } @@ -703,16 +908,10 @@ func (e *Engine) removeAllPeers() error { return nil } -// removePeer closes an existing peer connection, removes a peer, and clears authorized key of the SSH server +// removePeer closes an existing peer connection and removes a peer func (e *Engine) removePeer(peerKey string) error { log.Debugf("removing peer from engine %s", peerKey) - e.sshMux.Lock() - if !isNil(e.sshServer) { - e.sshServer.RemoveAuthorizedKey(peerKey) - } - e.sshMux.Unlock() - e.connMgr.RemovePeerConn(peerKey) err := e.statusRecorder.RemovePeer(peerKey) @@ -746,59 +945,130 @@ func (e *Engine) PopulateNetbirdConfig(netbirdConfig *mgmProto.NetbirdConfig, mg return nil } +func (e *Engine) handleAutoUpdateVersion(autoUpdateSettings *mgmProto.AutoUpdateSettings) { + if e.updateManager == nil { + return + } + + if autoUpdateSettings == nil { + return + } + + if autoUpdateSettings.Version == disableAutoUpdate { + log.Infof("auto-update is disabled") + e.updateManager.SetDownloadOnly() + return + } + + 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() { + duration := time.Since(started) + log.Infof("sync finished in %s", duration) + e.clientMetrics.RecordSyncDuration(e.ctx, duration) + }() e.syncMsgMux.Lock() defer e.syncMsgMux.Unlock() - 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 + // Check context INSIDE lock to ensure atomicity with shutdown + if e.ctx.Err() != nil { + return e.ctx.Err() } - if err := e.updateChecksIfNew(update.Checks); err != nil { + 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()) + } + + 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 } - // Store sync response if persistence is enabled - if e.persistSyncResponse { - e.latestSyncResponse = update - 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 @@ -809,6 +1079,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 @@ -820,7 +1163,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. @@ -844,6 +1192,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") @@ -868,11 +1224,22 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error { } 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 := system.GetInfoWithChecksTimeout(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) + } + 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, @@ -883,82 +1250,28 @@ 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, ) - - if err := e.mgmClient.SyncMeta(info); err != nil { - log.Errorf("could not sync meta: error %s", err) - return err - } - return nil } -func isNil(server nbssh.Server) bool { - return server == nil || reflect.ValueOf(server).IsNil() -} - -func (e *Engine) updateSSH(sshConf *mgmProto.SSHConfig) error { - if e.config.BlockInbound { - log.Infof("SSH server is disabled because inbound connections are blocked") - 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.ServerSSHAllowed { - log.Info("SSH server is not enabled") - return nil + if e.config.WgAddr.HasIPv6() { + ips = append(ips, e.config.WgAddr.IPv6) } - - if sshConf.GetSshEnabled() { - if runtime.GOOS == "windows" { - log.Warnf("running SSH server on %s is not supported", runtime.GOOS) - return nil - } - e.sshMux.Lock() - // start SSH server if it wasn't running - if isNil(e.sshServer) { - listenAddr := fmt.Sprintf("%s:%d", e.wgInterface.Address().IP.String(), nbssh.DefaultSSHPort) - if nbnetstack.IsEnabled() { - listenAddr = fmt.Sprintf("127.0.0.1:%d", nbssh.DefaultSSHPort) - } - // nil sshServer means it has not yet been started - server, err := e.sshServerFunc(e.config.SSHKey, listenAddr) - if err != nil { - e.sshMux.Unlock() - return fmt.Errorf("create ssh server: %w", err) - } - - e.sshServer = server - e.sshMux.Unlock() - - go func() { - // blocking - err = server.Start() - if err != nil { - // will throw error when we stop it even if it is a graceful stop - log.Debugf("stopped SSH server with error %v", err) - } - e.sshMux.Lock() - e.sshServer = nil - e.sshMux.Unlock() - log.Infof("stopped SSH server") - }() - } else { - e.sshMux.Unlock() - log.Debugf("SSH server is already running") - } - } else { - e.sshMux.Lock() - if !isNil(e.sshServer) { - // Disable SSH server request, so stop it if it was running - err := e.sshServer.Stop() - if err != nil { - log.Warnf("failed to stop SSH server %v", err) - } - e.sshServer = nil - } - e.sshMux.Unlock() - } - return nil + return ips } func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error { @@ -966,55 +1279,153 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error { return errors.New("wireguard interface is not initialized") } - // Cannot update the IP address without restarting the engine because - // the firewall, route manager, and other components cache the old address if e.wgInterface.Address().String() != conf.Address { - log.Infof("peer IP address has changed from %s to %s", e.wgInterface.Address().String(), conf.Address) + log.Infof("peer IP address changed from %s to %s, restarting client", e.wgInterface.Address().String(), conf.Address) + _ = CtxGetState(e.ctx).Wrap(ErrResetConnection) + e.clientCancel() + 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 { - err := e.updateSSH(conf.GetSshConfig()) - if err != nil { + if err := e.updateSSH(conf.GetSshConfig()); err != nil { log.Warnf("failed handling SSH server setup: %v", err) } } 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 = device.WireGuardModuleIsLoaded() + 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() { + defer e.jobExecutorWG.Done() + err := e.mgmClient.Job(e.ctx, func(msg *mgmProto.JobRequest) *mgmProto.JobResponse { + resp := mgmProto.JobResponse{ + ID: msg.ID, + Status: mgmProto.JobStatus_failed, + } + switch params := msg.WorkloadParameters.(type) { + case *mgmProto.JobRequest_Bundle: + bundleResult, err := e.handleBundle(params.Bundle) + if err != nil { + log.Errorf("handling bundle: %v", err) + resp.Reason = []byte(err.Error()) + return &resp + } + resp.Status = mgmProto.JobStatus_succeeded + resp.WorkloadResults = bundleResult + return &resp + default: + resp.Reason = []byte(jobexec.ErrJobNotImplemented.Error()) + return &resp + } + }) + if err != nil { + // happens if management is unavailable for a long time. + // We want to cancel the operation of the whole client + _ = CtxGetState(e.ctx).Wrap(ErrResetConnection) + e.clientCancel() + return + } + log.Info("stopped receiving jobs from Management Service") + }() + log.Info("connecting to Management Service jobs stream") +} + +func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobResponse_Bundle, error) { + log.Infof("handle remote debug bundle request: %s", params.String()) + syncResponse, err := e.GetLatestSyncResponse() + if err != nil { + log.Warnf("get latest sync response: %v", err) + } + + bundleDeps := debug.GeneratorDependencies{ + InternalConfig: e.config.ProfileConfig, + StatusRecorder: e.statusRecorder, + SyncResponse: syncResponse, + LogPath: e.config.LogPath, + TempDir: e.config.TempDir, + ClientMetrics: e.clientMetrics, + DaemonVersion: version.NetbirdVersion(), + RefreshStatus: func() { + e.RunHealthProbes(e.ctx, true) + }, + } + + bundleJobParams := debug.BundleConfig{ + Anonymize: params.Anonymize, + 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()) + if err != nil { + return nil, err + } + + response := &mgmProto.JobResponse_Bundle{ + Bundle: &mgmProto.BundleResult{ + UploadKey: uploadKey, + }, + } + return response, nil +} + // 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, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, e.checks, e.overlayAddresses()...) + if !ok { + // Gathering timed out; connect the stream with base info so management + // connectivity still comes up rather than blocking here. info = system.GetInfo(e.ctx) } - 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.applyInfoFlags(info) - err = e.mgmClient.Sync(e.ctx, info, e.handleSync) + err := e.mgmClient.Sync(e.ctx, 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 @@ -1105,15 +1516,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) @@ -1122,80 +1536,111 @@ 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 + done = e.phase("forward_rules") forwardingRules, err := e.updateForwardRules(networkMap.GetForwardingRules()) if 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") + excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers) + e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers) + 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())) + for _, p := range networkMap.GetRemotePeers() { + if p.GetWgPubKey() != localPubKey { + remotePeers = append(remotePeers, p) + } + } // cleanup request, most likely our peer has been deleted if networkMap.GetRemotePeersIsEmpty() { err := e.removeAllPeers() e.statusRecorder.FinishPeerListModifications() if err != nil { - return err + return nil, err } - } else { - err := e.removePeers(networkMap.GetRemotePeers()) - if err != nil { - return err - } - - err = e.modifyPeers(networkMap.GetRemotePeers()) - if err != nil { - return err - } - - err = e.addNewPeers(networkMap.GetRemotePeers()) - if err != nil { - return err - } - - e.statusRecorder.FinishPeerListModifications() - - // update SSHServer by adding remote peer SSH keys - e.sshMux.Lock() - if !isNil(e.sshServer) { - for _, config := range networkMap.GetRemotePeers() { - if config.GetSshConfig() != nil && config.GetSshConfig().GetSshPubKey() != nil { - err := e.sshServer.AddAuthorizedKey(config.WgPubKey, string(config.GetSshConfig().GetSshPubKey())) - if err != nil { - log.Warnf("failed adding authorized key to SSH DefaultServer %v", err) - } - } - } - } - e.sshMux.Unlock() + 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, networkMap.GetRemotePeers()) - 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. - e.dnsServer.ProbeAvailability() + done = e.phase("added_peers") + err = e.addNewPeers(remotePeers) + done() + if err != nil { + return nil, err + } - return nil + 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 { @@ -1258,7 +1703,10 @@ 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 { forwarderPort = nbdns.ForwarderClientPort @@ -1271,9 +1719,16 @@ func toDNSConfig(protoDNSConfig *mgmProto.DNSConfig, network netip.Prefix) nbdns ForwarderPort: forwarderPort, } - for _, zone := range protoDNSConfig.GetCustomZones() { + protoZones := protoDNSConfig.GetCustomZones() + // Treat single zone as authoritative for backward compatibility with old servers + // that only send the peer FQDN zone without setting field 4. + singleZoneCompat := len(protoZones) == 1 + + for _, zone := range protoZones { dnsZone := nbdns.CustomZone{ - Domain: zone.GetDomain(), + Domain: zone.GetDomain(), + SearchDomainDisabled: zone.GetSearchDomainDisabled(), + NonAuthoritative: zone.GetNonAuthoritative() && !singleZoneCompat, } for _, record := range zone.Records { dnsRecord := nbdns.SimpleRecord{ @@ -1307,6 +1762,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 @@ -1316,8 +1774,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, @@ -1328,6 +1788,37 @@ 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 { @@ -1353,15 +1844,23 @@ 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) } @@ -1403,12 +1902,13 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV } serviceDependencies := peer.ServiceDependencies{ - StatusRecorder: e.statusRecorder, - Signaler: e.signaler, - IFaceDiscover: e.mobileDep.IFaceDiscover, - RelayManager: e.relayManager, - SrWatcher: e.srWatcher, - Semaphore: e.connSemaphore, + StatusRecorder: e.statusRecorder, + Signaler: e.signaler, + IFaceDiscover: e.mobileDep.IFaceDiscover, + RelayManager: e.relayManager, + SrWatcher: e.srWatcher, + PortForwardManager: e.portForwardManager, + MetricsRecorder: e.clientMetrics, } peerConn, err := peer.NewConn(config, serviceDependencies) if err != nil { @@ -1418,20 +1918,35 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV if e.rpManager != nil { peerConn.SetOnConnected(e.rpManager.OnConnected) peerConn.SetOnDisconnected(e.rpManager.OnDisconnected) + peerConn.SetRosenpassInitializedPresharedKeyValidator(e.rpManager.IsPresharedKeyInitialized) } return peerConn, nil } // 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() // connect to a stream of messages coming from the signal server err := e.signal.Receive(e.ctx, func(msg *sProto.Message) error { + start := time.Now() e.syncMsgMux.Lock() defer e.syncMsgMux.Unlock() + gotLock := time.Since(start) + + // Check context INSIDE lock to ensure atomicity with shutdown + if e.ctx.Err() != nil { + 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 { @@ -1450,6 +1965,8 @@ func (e *Engine) receiveSignalEvents() { return err } + log.Debugf("receiveMSG: took %s to get lock for peer %s with session id %s", gotLock, msg.Key, offerAnswer.SessionID) + if msg.Body.Type == sProto.Body_OFFER { conn.OnRemoteOffer(*offerAnswer) } else { @@ -1479,7 +1996,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 { @@ -1535,7 +2057,13 @@ 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 { if err := e.wgInterface.Close(); err != nil { log.Errorf("failed closing Netbird interface %s %v", e.config.WgIfaceName, err) @@ -1544,15 +2072,6 @@ func (e *Engine) close() { e.statusRecorder.SetWgIface(nil) } - e.sshMux.Lock() - if !isNil(e.sshServer) { - err := e.sshServer.Stop() - if err != nil { - log.Warnf("failed stopping the SSH server: %v", err) - } - } - e.sshMux.Unlock() - if e.firewall != nil { err := e.firewall.Close(e.stateManager) if err != nil { @@ -1563,6 +2082,24 @@ func (e *Engine) close() { if e.rpManager != nil { _ = e.rpManager.Close() } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := e.portForwardManager.GracefullyStop(ctx); err != nil { + log.Warnf("failed to gracefully stop port forwarding manager: %s", err) + } + + // 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) + } + } } func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, error) { @@ -1582,7 +2119,13 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err 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, ) netMap, err := e.mgmClient.GetNetworkMap(info) @@ -1590,7 +2133,7 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err return nil, nil, false, err } routes := toRoutes(netMap.GetRoutes()) - dnsCfg := toDNSConfig(netMap.GetDNSConfig(), e.wgInterface.Address().Network) + dnsCfg := toDNSConfig(netMap.GetDNSConfig(), e.wgInterface.Address()) dnsFeatureFlag := toDNSFeatureFlag(netMap) return routes, &dnsCfg, dnsFeatureFlag, nil } @@ -1608,7 +2151,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, } @@ -1632,7 +2174,10 @@ func (e *Engine) wgInterfaceCreate() (err error) { case "android": err = e.wgInterface.CreateOnAndroid(e.routeManager.InitialRouteRange(), 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() @@ -1686,11 +2231,51 @@ func (e *Engine) GetRouteManager() routemanager.Manager { return e.routeManager } -// GetFirewallManager returns the firewall manager +// GetFirewallManager returns the firewall manager. func (e *Engine) GetFirewallManager() firewallManager.Manager { return e.firewall } +// GetExposeManager returns the expose session manager. +func (e *Engine) GetExposeManager() *expose.Manager { + e.syncMsgMux.Lock() + defer e.syncMsgMux.Unlock() + return e.exposeManager +} + +// IsBlockInbound returns whether inbound connections are blocked. +func (e *Engine) IsBlockInbound() bool { + return e.config.BlockInbound +} + +// GetClientMetrics returns the client metrics +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 { @@ -1726,9 +2311,22 @@ func (e *Engine) getRosenpassAddr() string { return "" } -// RunHealthProbes executes health checks for Signal, Management, Relay and WireGuard services +// 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() @@ -1740,42 +2338,31 @@ func (e *Engine) RunHealthProbes(waitForResult bool) bool { stuns := slices.Clone(e.STUNs) turns := slices.Clone(e.TURNs) - if e.wgInterface != nil { - stats, err := e.wgInterface.GetStats() - if err != nil { - log.Warnf("failed to get wireguard stats: %v", err) - e.syncMsgMux.Unlock() - return false - } - for _, key := range e.peerStore.PeersPubKey() { - // wgStats could be zero value, in which case we just reset the stats - wgStats, ok := stats[key] - if !ok { - continue - } - if err := e.statusRecorder.UpdateWireGuardPeerState(key, wgStats); err != nil { - log.Debugf("failed to update wg stats for peer %s: %s", key, err) - } - } + if err := e.statusRecorder.RefreshWireGuardStats(); err != nil { + log.Debugf("failed to refresh WireGuard stats: %v", err) } e.syncMsgMux.Unlock() - var results []relay.ProbeResult - if waitForResult { - results = e.probeStunTurn.ProbeAllWaitResult(e.ctx, stuns, turns) - } else { - results = e.probeStunTurn.ProbeAll(e.ctx, stuns, turns) - } - e.statusRecorder.UpdateRelayStates(results) + // Skip STUN/TURN probing for JS/WASM as it's not available relayHealthy := true - for _, res := range results { - if res.Err != nil { - relayHealthy = false - break + if runtime.GOOS != "js" { + var results []relay.ProbeResult + if waitForResult { + results = e.probeStunTurn.ProbeAllWaitResult(ctx, stuns, turns) + } else { + results = e.probeStunTurn.ProbeAll(ctx, stuns, turns) } + e.statusRecorder.UpdateRelayStates(results) + + for _, res := range results { + if res.Err != nil { + relayHealthy = false + break + } + } + log.Debugf("relay health check: healthy=%t", relayHealthy) } - log.Debugf("relay health check: healthy=%t", relayHealthy) allHealthy := signalHealthy && managementHealthy && relayHealthy log.Debugf("all health checks completed: healthy=%t", allHealthy) @@ -1801,7 +2388,7 @@ func (e *Engine) triggerClientRestart() { } func (e *Engine) startNetworkMonitor() { - if !e.config.NetworkMonitor { + if !e.config.NetworkMonitor || nbnetstack.IsEnabled() { log.Infof("Network monitor is disabled, not starting") return } @@ -1824,21 +2411,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 @@ -1854,43 +2426,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.syncMsgMux.Lock() - defer e.syncMsgMux.Unlock() + 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) { - e.syncMsgMux.Lock() - defer e.syncMsgMux.Unlock() + // Hold the lock for the whole Get so the store cannot be cleared + // (disabled / engine close) mid-call. + e.syncRespMux.RLock() + defer e.syncRespMux.RUnlock() - if !e.persistSyncResponse { + if e.syncStore == nil { return nil, errors.New("sync response persistence is disabled") } - if e.latestSyncResponse == nil { - //nolint:nilnil - return nil, nil - } - - log.Debugf("Retrieving latest sync response with size %d bytes", proto.Size(e.latestSyncResponse)) - sr, ok := proto.Clone(e.latestSyncResponse).(*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 @@ -1901,12 +2472,32 @@ 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 + e.syncMsgMux.Unlock() + + if wgInterface == nil { + return fmt.Errorf("wireguard interface not initialized") + } + + return wgInterface.RenewTun(fd) +} + // updateDNSForwarder start or stop the DNS forwarder based on the domains and the feature flag func (e *Engine) updateDNSForwarder( enabled bool, fwdEntries []*dnsfwd.ForwarderEntry, ) { - if e.config.DisableServerRoutes { + if e.config.DisableServerRoutes || e.config.BlockInbound { return } @@ -1977,6 +2568,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 { + 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") @@ -2047,13 +2694,14 @@ func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewal func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers []*mgmProto.RemotePeerConfig) map[string]bool { excludedPeers := make(map[string]bool) + + // Ingress forward targets: inbound forwarded traffic is initiated remotely and + // cannot wake a lazy connection, so the peer routing the target must stay + // permanently connected. AllowedIPs are already parsed on the peer conn, so + // reuse those typed prefixes instead of re-parsing the network map strings. for _, r := range rules { - ip := r.TranslatedAddress for _, p := range peers { - for _, allowedIP := range p.GetAllowedIps() { - if allowedIP != ip.String() { - continue - } + if e.peerRoutesAddr(p, r.TranslatedAddress) { log.Infof("exclude forwarder peer from lazy connection: %s", p.GetWgPubKey()) excludedPeers[p.GetWgPubKey()] = true } @@ -2063,6 +2711,27 @@ func (e *Engine) toExcludedLazyPeers(rules []firewallManager.ForwardRule, peers return excludedPeers } +// peerRoutesAddr reports whether the peer is a router for addr, matched against +// the peer's already-parsed AllowedIPs from the store (the same typed value the +// lazy manager consumes) rather than re-parsing the network map strings. +func (e *Engine) peerRoutesAddr(p *mgmProto.RemotePeerConfig, addr netip.Addr) bool { + prefixes, ok := e.peerStore.AllowedIPs(p.GetWgPubKey()) + if !ok { + return false + } + return prefixesContain(prefixes, addr) +} + +// prefixesContain reports whether addr falls within any of the prefixes. +func prefixesContain(prefixes []netip.Prefix, addr netip.Addr) bool { + for _, prefix := range prefixes { + if prefix.Contains(addr) { + return true + } + } + return false +} + // isChecksEqual checks if two slices of checks are equal. func isChecksEqual(checks1, checks2 []*mgmProto.Checks) bool { normalize := func(checks []*mgmProto.Checks) []string { @@ -2114,8 +2783,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 } @@ -2126,6 +2794,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 { @@ -2198,6 +2884,8 @@ func convertToOfferAnswer(msg *sProto.Message) (*peer.OfferAnswer, error) { } } + relayIP := decodeRelayIP(msg.GetBody().GetRelayServerIP()) + offerAnswer := peer.OfferAnswer{ IceCredentials: peer.IceCredentials{ UFrag: remoteCred.UFrag, @@ -2208,7 +2896,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_lazy_exclude_test.go b/client/internal/engine_lazy_exclude_test.go new file mode 100644 index 000000000..b5ef16c3b --- /dev/null +++ b/client/internal/engine_lazy_exclude_test.go @@ -0,0 +1,87 @@ +package internal + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/require" + + firewallManager "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/peerstore" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" +) + +func TestPrefixesContain(t *testing.T) { + tests := []struct { + name string + prefixes []string + addr string + want bool + }{ + {name: "own overlay /32 matches", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.145", want: true}, + {name: "addr inside routed subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.121.208.4", want: true}, + {name: "addr outside subnet", prefixes: []string{"10.121.0.0/16"}, addr: "10.122.0.1", want: false}, + {name: "different /32", prefixes: []string{"100.110.8.145/32"}, addr: "100.110.8.146", want: false}, + {name: "ipv6 /128 matches", prefixes: []string{"fd00::1/128"}, addr: "fd00::1", want: true}, + {name: "no prefixes", prefixes: nil, addr: "10.121.208.4", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prefixes := make([]netip.Prefix, 0, len(tt.prefixes)) + for _, p := range tt.prefixes { + prefixes = append(prefixes, netip.MustParsePrefix(p)) + } + require.Equal(t, tt.want, prefixesContain(prefixes, netip.MustParseAddr(tt.addr))) + }) + } +} + +// TestToExcludedLazyPeers_ForwardTarget guards a regression: the forward-target +// peer (the peer routing a ForwardRule.TranslatedAddress) must be excluded from +// lazy connections, matched via the peer's already-parsed AllowedIPs. +func TestToExcludedLazyPeers_ForwardTarget(t *testing.T) { + const targetPeerKey = "cccccccccccccccccccccccccccccccccccccccccc0=" + const otherPeerKey = "dddddddddddddddddddddddddddddddddddddddddd0=" + + store := peerstore.NewConnStore() + store.AddPeerConn(targetPeerKey, newTestConn(t, targetPeerKey, "100.110.8.145/32")) + store.AddPeerConn(otherPeerKey, newTestConn(t, otherPeerKey, "100.110.9.10/32")) + + e := &Engine{peerStore: store} + + peers := []*mgmProto.RemotePeerConfig{ + {WgPubKey: targetPeerKey, AllowedIps: []string{"100.110.8.145/32"}}, + {WgPubKey: otherPeerKey, AllowedIps: []string{"100.110.9.10/32"}}, + } + rules := []firewallManager.ForwardRule{ + {TranslatedAddress: netip.MustParseAddr("100.110.8.145")}, + } + + excluded := e.toExcludedLazyPeers(rules, peers) + + require.True(t, excluded[targetPeerKey], "forward-target peer must be excluded from lazy connections") + require.False(t, excluded[otherPeerKey], "non-target peer must not be excluded") + require.Len(t, excluded, 1) +} + +func TestToExcludedLazyPeers_NoRules(t *testing.T) { + e := &Engine{peerStore: peerstore.NewConnStore()} + + peers := []*mgmProto.RemotePeerConfig{ + {WgPubKey: "peer-a", AllowedIps: []string{"100.110.8.145/32"}}, + } + + require.Empty(t, e.toExcludedLazyPeers(nil, peers)) +} + +func newTestConn(t *testing.T, key, allowedIP string) *peer.Conn { + t.Helper() + conn, err := peer.NewConn(peer.ConnConfig{ + Key: key, + WgConfig: peer.WgConfig{AllowedIps: []netip.Prefix{netip.MustParsePrefix(allowedIP)}}, + }, peer.ServiceDependencies{}) + require.NoError(t, err) + return conn +} diff --git a/client/internal/engine_privileged_test.go b/client/internal/engine_privileged_test.go new file mode 100644 index 000000000..f787f741f --- /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/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" + "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/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/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" + 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, 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: 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, permissionsManager) + 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) + 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 new file mode 100644 index 000000000..53d2c1122 --- /dev/null +++ b/client/internal/engine_ssh.go @@ -0,0 +1,419 @@ +package internal + +import ( + "context" + "errors" + "fmt" + "net/netip" + "strings" + + log "github.com/sirupsen/logrus" + + firewallManager "github.com/netbirdio/netbird/client/firewall/manager" + "github.com/netbirdio/netbird/client/iface/netstack" + nftypes "github.com/netbirdio/netbird/client/internal/netflow/types" + sshauth "github.com/netbirdio/netbird/client/ssh/auth" + sshconfig "github.com/netbirdio/netbird/client/ssh/config" + sshserver "github.com/netbirdio/netbird/client/ssh/server" + mgmProto "github.com/netbirdio/netbird/shared/management/proto" + sshuserhash "github.com/netbirdio/netbird/shared/sshauth" +) + +type sshServer interface { + Start(ctx context.Context, addr netip.AddrPort) error + Stop() error + GetStatus() (bool, []sshserver.SessionInfo) + UpdateSSHAuth(config *sshauth.Config) +} + +func (e *Engine) setupSSHPortRedirection() error { + if e.firewall == nil || e.wgInterface == nil { + return nil + } + + localAddr := e.wgInterface.Address().IP + if !localAddr.IsValid() { + return errors.New("invalid local NetBird address") + } + + if err := e.firewall.AddInboundDNAT(localAddr, firewallManager.ProtocolTCP, 22, 22022); err != nil { + return fmt.Errorf("add SSH port redirection: %w", err) + } + 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 +} + +func (e *Engine) updateSSH(sshConf *mgmProto.SSHConfig) error { + if e.config.BlockInbound { + log.Info("SSH server is disabled because inbound connections are blocked") + return e.stopSSHServer() + } + + if !e.config.ServerSSHAllowed { + log.Info("SSH server is disabled in config") + return e.stopSSHServer() + } + + if !sshConf.GetSshEnabled() { + if e.config.ServerSSHAllowed { + log.Info("SSH server is locally allowed but disabled by management server") + } + return e.stopSSHServer() + } + + if e.sshServer != nil { + log.Debug("SSH server is already running") + return nil + } + + if e.config.DisableSSHAuth != nil && *e.config.DisableSSHAuth { + log.Info("starting SSH server without JWT authentication (authentication disabled by config)") + return e.startSSHServer(nil) + } + + if protoJWT := sshConf.GetJwtConfig(); protoJWT != nil { + audiences := protoJWT.GetAudiences() + if len(audiences) == 0 && protoJWT.GetAudience() != "" { + audiences = []string{protoJWT.GetAudience()} + } + + log.Debugf("starting SSH server with JWT authentication: audiences=%v", audiences) + + jwtConfig := &sshserver.JWTConfig{ + Issuer: protoJWT.GetIssuer(), + Audiences: audiences, + KeysLocation: protoJWT.GetKeysLocation(), + MaxTokenAge: protoJWT.GetMaxTokenAge(), + } + + return e.startSSHServer(jwtConfig) + } + + return errors.New("SSH server requires valid JWT configuration") +} + +// updateSSHClientConfig updates the SSH client configuration with peer information +func (e *Engine) updateSSHClientConfig(remotePeers []*mgmProto.RemotePeerConfig) error { + if netstack.IsEnabled() { + return nil + } + + peerInfo := e.extractPeerSSHInfo(remotePeers) + if len(peerInfo) == 0 { + log.Debug("no SSH-enabled peers found, skipping SSH config update") + return nil + } + + configMgr := sshconfig.New() + if err := configMgr.SetupSSHClientConfig(peerInfo); err != nil { + log.Warnf("failed to update SSH client config: %v", err) + return nil // Don't fail engine startup on SSH config issues + } + + log.Debugf("updated SSH client config with %d peers", len(peerInfo)) + + if err := e.stateManager.UpdateState(&sshconfig.ShutdownState{ + SSHConfigDir: configMgr.GetSSHConfigDir(), + SSHConfigFile: configMgr.GetSSHConfigFile(), + }); err != nil { + log.Warnf("failed to update SSH config state: %v", err) + } + + return nil +} + +// extractPeerSSHInfo extracts SSH information from peer configurations +func (e *Engine) extractPeerSSHInfo(remotePeers []*mgmProto.RemotePeerConfig) []sshconfig.PeerSSHInfo { + var peerInfo []sshconfig.PeerSSHInfo + + for _, peerConfig := range remotePeers { + if peerConfig.GetSshConfig() == nil { + continue + } + + sshPubKeyBytes := peerConfig.GetSshConfig().GetSshPubKey() + if len(sshPubKeyBytes) == 0 { + continue + } + + peerV4, peerV6 := overlayAddrsFromAllowedIPs(peerConfig.GetAllowedIps(), e.wgInterface.Address().IPv6Net) + hostname := e.extractHostname(peerConfig) + + peerInfo = append(peerInfo, sshconfig.PeerSSHInfo{ + Hostname: hostname, + IP: peerV4, + IPv6: peerV6, + FQDN: peerConfig.GetFqdn(), + }) + } + + return peerInfo +} + +// extractHostname extracts short hostname from FQDN +func (e *Engine) extractHostname(peerConfig *mgmProto.RemotePeerConfig) string { + fqdn := peerConfig.GetFqdn() + if fqdn == "" { + return "" + } + + parts := strings.Split(fqdn, ".") + if len(parts) > 0 && parts[0] != "" { + return parts[0] + } + return "" +} + +// updatePeerSSHHostKeys updates peer SSH host keys in the status recorder for daemon API access +func (e *Engine) updatePeerSSHHostKeys(remotePeers []*mgmProto.RemotePeerConfig) { + for _, peerConfig := range remotePeers { + if peerConfig.GetSshConfig() == nil { + continue + } + + sshPubKeyBytes := peerConfig.GetSshConfig().GetSshPubKey() + if len(sshPubKeyBytes) == 0 { + continue + } + + if err := e.statusRecorder.UpdatePeerSSHHostKey(peerConfig.GetWgPubKey(), sshPubKeyBytes); err != nil { + log.Warnf("failed to update SSH host key for peer %s: %v", peerConfig.GetWgPubKey(), err) + } + } + + log.Debugf("updated peer SSH host keys for daemon API access") +} + +// GetPeerSSHKey returns the SSH host key for a specific peer by IP or FQDN +func (e *Engine) GetPeerSSHKey(peerAddress string) ([]byte, bool) { + e.syncMsgMux.Lock() + statusRecorder := e.statusRecorder + e.syncMsgMux.Unlock() + + if statusRecorder == nil { + return nil, false + } + + fullStatus := statusRecorder.GetFullStatus() + for _, peerState := range fullStatus.Peers { + if peerState.IP == peerAddress || peerState.FQDN == peerAddress || peerState.IPv6 == peerAddress { + if len(peerState.SSHHostKey) > 0 { + return peerState.SSHHostKey, true + } + return nil, false + } + } + + return nil, false +} + +// cleanupSSHConfig removes NetBird SSH client configuration on shutdown +func (e *Engine) cleanupSSHConfig() { + if netstack.IsEnabled() { + return + } + + configMgr := sshconfig.New() + + if err := configMgr.RemoveSSHClientConfig(); err != nil { + log.Warnf("failed to remove SSH client config: %v", err) + } else { + log.Debugf("SSH client config cleanup completed") + } +} + +// startSSHServer initializes and starts the SSH server with proper configuration. +func (e *Engine) startSSHServer(jwtConfig *sshserver.JWTConfig) error { + if e.wgInterface == nil { + return errors.New("wg interface not initialized") + } + + serverConfig := &sshserver.Config{ + HostKeyPEM: e.config.SSHKey, + JWT: jwtConfig, + } + server := sshserver.New(serverConfig) + + wgAddr := e.wgInterface.Address() + server.SetNetworkValidation(wgAddr) + + netbirdIP := wgAddr.IP + listenAddr := netip.AddrPortFrom(netbirdIP, sshserver.InternalSSHPort) + + if netstackNet := e.wgInterface.GetNet(); netstackNet != nil { + server.SetNetstackNet(netstackNet) + } + + e.configureSSHServer(server) + + if err := server.Start(e.ctx, listenAddr); err != nil { + 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 { + if registrar, ok := e.firewall.(interface { + RegisterNetstackService(protocol nftypes.Protocol, port uint16) + }); ok { + registrar.RegisterNetstackService(nftypes.TCP, sshserver.InternalSSHPort) + log.Debugf("registered SSH service with netstack for TCP:%d", sshserver.InternalSSHPort) + } + } + + if err := e.setupSSHPortRedirection(); err != nil { + log.Warnf("failed to setup SSH port redirection: %v", err) + } + + return nil +} + +// configureSSHServer applies SSH configuration options to the server. +func (e *Engine) configureSSHServer(server *sshserver.Server) { + if e.config.EnableSSHRoot != nil && *e.config.EnableSSHRoot { + server.SetAllowRootLogin(true) + log.Info("SSH root login enabled") + } else { + server.SetAllowRootLogin(false) + log.Info("SSH root login disabled (default)") + } + + if e.config.EnableSSHSFTP != nil && *e.config.EnableSSHSFTP { + server.SetAllowSFTP(true) + log.Info("SSH SFTP subsystem enabled") + } else { + server.SetAllowSFTP(false) + log.Info("SSH SFTP subsystem disabled (default)") + } + + if e.config.EnableSSHLocalPortForwarding != nil && *e.config.EnableSSHLocalPortForwarding { + server.SetAllowLocalPortForwarding(true) + log.Info("SSH local port forwarding enabled") + } else { + server.SetAllowLocalPortForwarding(false) + log.Info("SSH local port forwarding disabled (default)") + } + + if e.config.EnableSSHRemotePortForwarding != nil && *e.config.EnableSSHRemotePortForwarding { + server.SetAllowRemotePortForwarding(true) + log.Info("SSH remote port forwarding enabled") + } else { + server.SetAllowRemotePortForwarding(false) + log.Info("SSH remote port forwarding disabled (default)") + } +} + +func (e *Engine) cleanupSSHPortRedirection() error { + if e.firewall == nil || e.wgInterface == nil { + return nil + } + + localAddr := e.wgInterface.Address().IP + if !localAddr.IsValid() { + return errors.New("invalid local NetBird address") + } + + if err := e.firewall.RemoveInboundDNAT(localAddr, firewallManager.ProtocolTCP, 22, 22022); err != nil { + return fmt.Errorf("remove SSH port redirection: %w", err) + } + 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 +} + +func (e *Engine) stopSSHServer() error { + if e.sshServer == nil { + return nil + } + + if err := e.cleanupSSHPortRedirection(); err != nil { + log.Warnf("failed to cleanup SSH port redirection: %v", err) + } + + if netstackNet := e.wgInterface.GetNet(); netstackNet != nil { + if registrar, ok := e.firewall.(interface { + UnregisterNetstackService(protocol nftypes.Protocol, port uint16) + }); ok { + registrar.UnregisterNetstackService(nftypes.TCP, sshserver.InternalSSHPort) + log.Debugf("unregistered SSH service from netstack for TCP:%d", sshserver.InternalSSHPort) + } + } + + log.Info("stopping SSH server") + err := e.sshServer.Stop() + e.sshServer = nil + if err != nil { + return fmt.Errorf("stop: %w", err) + } + return nil +} + +// GetSSHServerStatus returns the SSH server status and active sessions +func (e *Engine) GetSSHServerStatus() (enabled bool, sessions []sshserver.SessionInfo) { + e.syncMsgMux.Lock() + sshServer := e.sshServer + e.syncMsgMux.Unlock() + + if sshServer == nil { + return false, nil + } + + return sshServer.GetStatus() +} + +// updateSSHServerAuth updates SSH fine-grained access control configuration on a running SSH server +func (e *Engine) updateSSHServerAuth(sshAuth *mgmProto.SSHAuth) { + if sshAuth == nil { + return + } + + if e.sshServer == nil { + return + } + + protoUsers := sshAuth.GetAuthorizedUsers() + authorizedUsers := make([]sshuserhash.UserIDHash, len(protoUsers)) + for i, hash := range protoUsers { + if len(hash) != 16 { + log.Warnf("invalid hash length %d, expected 16 - skipping SSH server auth update", len(hash)) + return + } + authorizedUsers[i] = sshuserhash.UserIDHash(hash) + } + + machineUsers := make(map[string][]uint32) + for osUser, indexes := range sshAuth.GetMachineUsers() { + machineUsers[osUser] = indexes.GetIndexes() + } + + // Update SSH server with new authorization configuration + authConfig := &sshauth.Config{ + UserIDClaim: sshAuth.GetUserIDClaim(), + AuthorizedUsers: authorizedUsers, + MachineUsers: machineUsers, + } + + e.sshServer.UpdateSSHAuth(authConfig) +} diff --git a/client/internal/engine_stdnet.go b/client/internal/engine_stdnet.go index 9e171b0b2..1ebb5779c 100644 --- a/client/internal/engine_stdnet.go +++ b/client/internal/engine_stdnet.go @@ -7,5 +7,5 @@ import ( ) func (e *Engine) newStdNet() (*stdnet.Net, error) { - return stdnet.NewNet(e.config.IFaceBlackList) + return stdnet.NewNet(e.clientCtx, e.config.IFaceBlackList) } diff --git a/client/internal/engine_stdnet_android.go b/client/internal/engine_stdnet_android.go index 68a0ae719..de3c80bcf 100644 --- a/client/internal/engine_stdnet_android.go +++ b/client/internal/engine_stdnet_android.go @@ -3,5 +3,5 @@ package internal import "github.com/netbirdio/netbird/client/internal/stdnet" func (e *Engine) newStdNet() (*stdnet.Net, error) { - return stdnet.NewNetWithDiscover(e.mobileDep.IFaceDiscover, e.config.IFaceBlackList) + return stdnet.NewNetWithDiscover(e.clientCtx, e.mobileDep.IFaceDiscover, e.config.IFaceBlackList) } diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index 15ac0a947..fbd47ed74 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -6,33 +6,18 @@ import ( "net" "net/netip" "os" - "runtime" "strings" "sync" "testing" "time" - "github.com/golang/mock/gomock" - "github.com/google/uuid" - "github.com/pion/transport/v3/stdnet" - 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/management-integrations/integrations" - "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" - "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" - 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/management/server/peers/ephemeral/manager" + "github.com/netbirdio/netbird/client/internal/stdnet" "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface/configurer" @@ -46,43 +31,17 @@ import ( icemaker "github.com/netbirdio/netbird/client/internal/peer/ice" "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/internal/routemanager" - "github.com/netbirdio/netbird/client/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" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" - "github.com/netbirdio/netbird/management/server/peers" - "github.com/netbirdio/netbird/management/server/permissions" - "github.com/netbirdio/netbird/management/server/settings" - "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/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 @@ -91,7 +50,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 @@ -104,10 +63,15 @@ type MockWGIface struct { GetStatsFunc func() (map[string]configurer.WGStats, error) GetInterfaceGUIDStringFunc func() (string, error) GetProxyFunc func() wgproxy.Proxy + GetProxyPortFunc func() uint16 GetNetFunc func() *netstack.Net LastActivitiesFunc func() map[string]monotime.Time } +func (m *MockWGIface) RenewTun(_ int) error { + return nil +} + func (m *MockWGIface) RemoveEndpointAddress(_ string) error { return nil } @@ -148,7 +112,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) } @@ -196,6 +160,13 @@ func (m *MockWGIface) GetProxy() wgproxy.Proxy { return m.GetProxyFunc() } +func (m *MockWGIface) GetProxyPort() uint16 { + if m.GetProxyPortFunc != nil { + return m.GetProxyPortFunc() + } + return 0 +} + func (m *MockWGIface) GetNet() *netstack.Net { return m.GetNetFunc() } @@ -207,159 +178,79 @@ 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 +} + func TestMain(m *testing.M) { _ = util.InitLog("debug", util.LogConsole) code := m.Run() os.Exit(code) } -func TestEngine_SSH(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("skipping TestEngine_SSH") - } - - key, err := wgtypes.GeneratePrivateKey() - 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, - &signal.MockClient{}, - &mgmt.MockClient{}, - relayMgr, - &EngineConfig{ - WgIfaceName: "utun101", - WgAddr: "100.64.0.1/24", - WgPrivateKey: key, - WgPort: 33100, - ServerSSHAllowed: true, - MTU: iface.DefaultMTU, +func TestEngine_SSHUpdateLogic(t *testing.T) { + // Test that SSH server start/stop logic works based on config + engine := &Engine{ + config: &EngineConfig{ + ServerSSHAllowed: false, // Start with SSH disabled }, - MobileDependency{}, - peer.NewRecorder("https://mgm"), - nil, - ) - - engine.dnsServer = &dns.MockServer{ - UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil }, + syncMsgMux: &sync.Mutex{}, } - var sshKeysAdded []string - var sshPeersRemoved []string + // Test SSH disabled config + sshConfig := &mgmtProto.SSHConfig{SshEnabled: false} + err := engine.updateSSH(sshConfig) + assert.NoError(t, err) + assert.Nil(t, engine.sshServer) - sshCtx, cancel := context.WithCancel(context.Background()) + // Test inbound blocked + engine.config.BlockInbound = true + err = engine.updateSSH(&mgmtProto.SSHConfig{SshEnabled: true}) + assert.NoError(t, err) + assert.Nil(t, engine.sshServer) + engine.config.BlockInbound = false - engine.sshServerFunc = func(hostKeyPEM []byte, addr string) (ssh.Server, error) { - return &ssh.MockServer{ - Ctx: sshCtx, - StopFunc: func() error { - cancel() - return nil - }, - StartFunc: func() error { - <-ctx.Done() - return ctx.Err() - }, - AddAuthorizedKeyFunc: func(peer, newKey string) error { - sshKeysAdded = append(sshKeysAdded, newKey) - return nil - }, - RemoveAuthorizedKeyFunc: func(peer string) { - sshPeersRemoved = append(sshPeersRemoved, peer) - }, - }, nil - } - err = engine.Start(nil, nil) - if err != nil { - t.Fatal(err) - } + // Test with server SSH not allowed + err = engine.updateSSH(&mgmtProto.SSHConfig{SshEnabled: true}) + assert.NoError(t, err) + assert.Nil(t, engine.sshServer) +} - defer func() { - err := engine.Stop() - if err != nil { - return +func TestEngine_SSHServerConsistency(t *testing.T) { + + t.Run("server set only on successful creation", func(t *testing.T) { + engine := &Engine{ + config: &EngineConfig{ + ServerSSHAllowed: true, + SSHKey: []byte("test-key"), + }, + syncMsgMux: &sync.Mutex{}, } - }() - peerWithSSH := &mgmtProto.RemotePeerConfig{ - WgPubKey: "MNHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", - AllowedIps: []string{"100.64.0.21/24"}, - SshConfig: &mgmtProto.SSHConfig{ - SshPubKey: []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFATYCqaQw/9id1Qkq3n16JYhDhXraI6Pc1fgB8ynEfQ"), - }, - } + engine.wgInterface = nil - // 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.updateSSH(&mgmtProto.SSHConfig{SshEnabled: true}) - err = engine.updateNetworkMap(networkMap) - if err != nil { - t.Fatal(err) - } + assert.Error(t, err) + assert.Nil(t, engine.sshServer) + }) - 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}}, - RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH}, - RemotePeersIsEmpty: false, - } - - err = engine.updateNetworkMap(networkMap) - if err != nil { - t.Fatal(err) - } - - time.Sleep(250 * time.Millisecond) - assert.NotNil(t, engine.sshServer) - assert.Contains(t, sshKeysAdded, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFATYCqaQw/9id1Qkq3n16JYhDhXraI6Pc1fgB8ynEfQ") - - // now remove peer - networkMap = &mgmtProto.NetworkMap{ - Serial: 8, - RemotePeers: []*mgmtProto.RemotePeerConfig{}, - RemotePeersIsEmpty: false, - } - - err = engine.updateNetworkMap(networkMap) - if err != nil { - t.Fatal(err) - } - - // time.Sleep(250 * time.Millisecond) - assert.NotNil(t, engine.sshServer) - assert.Contains(t, sshPeersRemoved, "MNHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=") - - // 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) - if err != nil { - t.Fatal(err) - } - - assert.Nil(t, engine.sshServer) + t.Run("cleanup handles nil gracefully", func(t *testing.T) { + engine := &Engine{ + config: &EngineConfig{ + ServerSSHAllowed: false, + }, + syncMsgMux: &sync.Mutex{}, + } + err := engine.stopSSHServer() + assert.NoError(t, err) + assert.Nil(t, engine.sshServer) + }) } func TestEngine_UpdateNetworkMap(t *testing.T) { @@ -370,25 +261,22 @@ 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, - &signal.MockClient{}, - &mgmt.MockClient{}, - relayMgr, - &EngineConfig{ - WgIfaceName: "utun102", - WgAddr: "100.64.0.1/24", - WgPrivateKey: key, - WgPort: 33100, - MTU: iface.DefaultMTU, - }, - MobileDependency{}, - peer.NewRecorder("https://mgm"), - nil) + engine := NewEngine(ctx, cancel, &EngineConfig{ + WgIfaceName: "utun102", + WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"), + WgPrivateKey: key, + WgPort: 33100, + MTU: iface.DefaultMTU, + }, EngineServices{ + SignalClient: &signal.MockClient{}, + MgmClient: &mgmt.MockClient{}, + RelayManager: relayMgr, + StatusRecorder: peer.NewRecorder("https://mgm"), + }, MobileDependency{}) wgIface := &MockWGIface{ NameFunc: func() string { return "utun102" }, @@ -578,92 +466,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, &signal.MockClient{}, &mgmt.MockClient{SyncFunc: syncFunc}, relayMgr, &EngineConfig{ - WgIfaceName: "utun103", - WgAddr: "100.64.0.1/24", - WgPrivateKey: key, - WgPort: 33100, - MTU: iface.DefaultMTU, - }, MobileDependency{}, peer.NewRecorder("https://mgm"), nil) - 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 @@ -759,29 +561,34 @@ 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) wgAddr := fmt.Sprintf("100.66.%d.1/24", n) relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) - engine := NewEngine(ctx, cancel, &signal.MockClient{}, &mgmt.MockClient{}, relayMgr, &EngineConfig{ + engine := NewEngine(ctx, cancel, &EngineConfig{ WgIfaceName: wgIfaceName, - WgAddr: wgAddr, + WgAddr: wgaddr.MustParseWGAddress(wgAddr), WgPrivateKey: key, WgPort: 33100, MTU: iface.DefaultMTU, - }, MobileDependency{}, peer.NewRecorder("https://mgm"), nil) + }, EngineServices{ + SignalClient: &signal.MockClient{}, + MgmClient: &mgmt.MockClient{}, + RelayManager: relayMgr, + StatusRecorder: peer.NewRecorder("https://mgm"), + }, MobileDependency{}) engine.ctx = ctx - newNet, err := stdnet.NewNet() + newNet, err := stdnet.NewNet(context.Background(), nil) if err != nil { t.Fatal(err) } opts := iface.WGIFaceOpts{ IFaceName: wgIfaceName, - Address: wgAddr, + Address: wgaddr.MustParseWGAddress(wgAddr), WGPort: engine.config.WgPort, WGPrivKey: key.String(), MTU: iface.DefaultMTU, @@ -961,29 +768,34 @@ 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) wgAddr := fmt.Sprintf("100.66.%d.1/24", n) relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU) - engine := NewEngine(ctx, cancel, &signal.MockClient{}, &mgmt.MockClient{}, relayMgr, &EngineConfig{ + engine := NewEngine(ctx, cancel, &EngineConfig{ WgIfaceName: wgIfaceName, - WgAddr: wgAddr, + WgAddr: wgaddr.MustParseWGAddress(wgAddr), WgPrivateKey: key, WgPort: 33100, MTU: iface.DefaultMTU, - }, MobileDependency{}, peer.NewRecorder("https://mgm"), nil) + }, EngineServices{ + SignalClient: &signal.MockClient{}, + MgmClient: &mgmt.MockClient{}, + RelayManager: relayMgr, + StatusRecorder: peer.NewRecorder("https://mgm"), + }, MobileDependency{}) engine.ctx = ctx - newNet, err := stdnet.NewNet() + newNet, err := stdnet.NewNet(context.Background(), nil) if err != nil { t.Fatal(err) } opts := iface.WGIFaceOpts{ IFaceName: wgIfaceName, - Address: wgAddr, + Address: wgaddr.MustParseWGAddress(wgAddr), WGPort: 33100, WGPrivKey: key.String(), MTU: iface.DefaultMTU, @@ -1037,104 +849,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 { @@ -1458,174 +1172,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 - } - - publicKey, err := mgmtClient.GetServerPublicKey() - if err != nil { - return nil, err - } - - info := system.GetInfo(ctx) - resp, err := mgmtClient.Register(*publicKey, 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, signalClient, mgmtClient, relayMgr, conf, MobileDependency{}, peer.NewRecorder("https://mgm"), nil), 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, permissionsManager) - - ia, _ := integrations.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore) - - 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()) - accountManager, err := server.BuildManager(context.Background(), store, networkMapController, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false) - if err != nil { - return nil, "", err - } - - secretsManager := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager) - mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, updateManager, secretsManager, nil, &manager.EphemeralManager{}, nil, &server.MockIntegratedValidator{}, networkMapController) - 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/expose/manager.go b/client/internal/expose/manager.go new file mode 100644 index 000000000..076f92043 --- /dev/null +++ b/client/internal/expose/manager.go @@ -0,0 +1,104 @@ +package expose + +import ( + "context" + "time" + + log "github.com/sirupsen/logrus" + + mgm "github.com/netbirdio/netbird/shared/management/client" +) + +const ( + renewTimeout = 10 * time.Second +) + +// Response holds the response from exposing a service. +type Response struct { + ServiceName string + ServiceURL string + Domain string + PortAutoAssigned bool +} + +// Request holds the parameters for exposing a local service via the management server. +// It is part of the embed API surface and exposed via a type alias. +type Request struct { + NamePrefix string + Domain string + Port uint16 + Protocol ProtocolType + Pin string + Password string + UserGroups []string + ListenPort uint16 +} + +type ManagementClient interface { + CreateExpose(ctx context.Context, req mgm.ExposeRequest) (*mgm.ExposeResponse, error) + RenewExpose(ctx context.Context, domain string) error + StopExpose(ctx context.Context, domain string) error +} + +// Manager handles expose session lifecycle via the management client. +type Manager struct { + mgmClient ManagementClient + ctx context.Context +} + +// NewManager creates a new expose Manager using the given management client. +func NewManager(ctx context.Context, mgmClient ManagementClient) *Manager { + return &Manager{mgmClient: mgmClient, ctx: ctx} +} + +// Expose creates a new expose session via the management server. +func (m *Manager) Expose(ctx context.Context, req Request) (*Response, error) { + log.Infof("exposing service on port %d", req.Port) + resp, err := m.mgmClient.CreateExpose(ctx, toClientExposeRequest(req)) + if err != nil { + return nil, err + } + + log.Infof("expose session created for %s", resp.Domain) + + return fromClientExposeResponse(resp), nil +} + +// KeepAlive periodically renews the expose session for the given domain until the context is canceled or an error occurs. +// It is part of the embed API surface and exposed via a type alias. +func (m *Manager) KeepAlive(ctx context.Context, domain string) error { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + defer m.stop(domain) + + for { + select { + case <-ctx.Done(): + log.Infof("context canceled, stopping keep alive for %s", domain) + + return nil + case <-ticker.C: + if err := m.renew(ctx, domain); err != nil { + log.Errorf("renewing expose session for %s: %v", domain, err) + return err + } + } + } +} + +// renew extends the TTL of an active expose session. +func (m *Manager) renew(ctx context.Context, domain string) error { + renewCtx, cancel := context.WithTimeout(ctx, renewTimeout) + defer cancel() + return m.mgmClient.RenewExpose(renewCtx, domain) +} + +// stop terminates an active expose session. +func (m *Manager) stop(domain string) { + stopCtx, cancel := context.WithTimeout(m.ctx, renewTimeout) + defer cancel() + err := m.mgmClient.StopExpose(stopCtx, domain) + if err != nil { + log.Warnf("Failed stopping expose session for %s: %v", domain, err) + } +} diff --git a/client/internal/expose/manager_test.go b/client/internal/expose/manager_test.go new file mode 100644 index 000000000..7d76c9838 --- /dev/null +++ b/client/internal/expose/manager_test.go @@ -0,0 +1,95 @@ +package expose + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + daemonProto "github.com/netbirdio/netbird/client/proto" + mgm "github.com/netbirdio/netbird/shared/management/client" +) + +func TestManager_Expose_Success(t *testing.T) { + mock := &mgm.MockClient{ + CreateExposeFunc: func(ctx context.Context, req mgm.ExposeRequest) (*mgm.ExposeResponse, error) { + return &mgm.ExposeResponse{ + ServiceName: "my-service", + ServiceURL: "https://my-service.example.com", + Domain: "my-service.example.com", + }, nil + }, + } + + m := NewManager(context.Background(), mock) + result, err := m.Expose(context.Background(), Request{Port: 8080}) + require.NoError(t, err) + assert.Equal(t, "my-service", result.ServiceName, "service name should match") + assert.Equal(t, "https://my-service.example.com", result.ServiceURL, "service URL should match") + assert.Equal(t, "my-service.example.com", result.Domain, "domain should match") +} + +func TestManager_Expose_Error(t *testing.T) { + mock := &mgm.MockClient{ + CreateExposeFunc: func(ctx context.Context, req mgm.ExposeRequest) (*mgm.ExposeResponse, error) { + return nil, errors.New("permission denied") + }, + } + + m := NewManager(context.Background(), mock) + _, err := m.Expose(context.Background(), Request{Port: 8080}) + require.Error(t, err) + assert.Contains(t, err.Error(), "permission denied", "error should propagate") +} + +func TestManager_Renew_Success(t *testing.T) { + mock := &mgm.MockClient{ + RenewExposeFunc: func(ctx context.Context, domain string) error { + assert.Equal(t, "my-service.example.com", domain, "domain should be passed through") + return nil + }, + } + + m := NewManager(context.Background(), mock) + err := m.renew(context.Background(), "my-service.example.com") + require.NoError(t, err) +} + +func TestManager_Renew_Timeout(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + mock := &mgm.MockClient{ + RenewExposeFunc: func(ctx context.Context, domain string) error { + return ctx.Err() + }, + } + + m := NewManager(ctx, mock) + err := m.renew(ctx, "my-service.example.com") + require.Error(t, err) +} + +func TestNewRequest(t *testing.T) { + req := &daemonProto.ExposeServiceRequest{ + Port: 8080, + Protocol: daemonProto.ExposeProtocol_EXPOSE_HTTPS, + Pin: "123456", + Password: "secret", + UserGroups: []string{"group1", "group2"}, + Domain: "custom.example.com", + NamePrefix: "my-prefix", + } + + exposeReq := NewRequest(req) + + assert.Equal(t, uint16(8080), exposeReq.Port, "port should match") + assert.Equal(t, ProtocolType(daemonProto.ExposeProtocol_EXPOSE_HTTPS), exposeReq.Protocol, "protocol should match") + assert.Equal(t, "123456", exposeReq.Pin, "pin should match") + assert.Equal(t, "secret", exposeReq.Password, "password should match") + assert.Equal(t, []string{"group1", "group2"}, exposeReq.UserGroups, "user groups should match") + assert.Equal(t, "custom.example.com", exposeReq.Domain, "domain should match") + assert.Equal(t, "my-prefix", exposeReq.NamePrefix, "name prefix should match") +} diff --git a/client/internal/expose/protocol.go b/client/internal/expose/protocol.go new file mode 100644 index 000000000..d5026d51e --- /dev/null +++ b/client/internal/expose/protocol.go @@ -0,0 +1,40 @@ +package expose + +import ( + "fmt" + "strings" +) + +// ProtocolType represents the protocol used for exposing a service. +type ProtocolType int + +const ( + // ProtocolHTTP exposes the service as HTTP. + ProtocolHTTP ProtocolType = 0 + // ProtocolHTTPS exposes the service as HTTPS. + ProtocolHTTPS ProtocolType = 1 + // ProtocolTCP exposes the service as TCP. + ProtocolTCP ProtocolType = 2 + // ProtocolUDP exposes the service as UDP. + ProtocolUDP ProtocolType = 3 + // ProtocolTLS exposes the service as TLS. + ProtocolTLS ProtocolType = 4 +) + +// ParseProtocolType parses a protocol string into a ProtocolType. +func ParseProtocolType(s string) (ProtocolType, error) { + switch strings.ToLower(s) { + case "http": + return ProtocolHTTP, nil + case "https": + return ProtocolHTTPS, nil + case "tcp": + return ProtocolTCP, nil + case "udp": + return ProtocolUDP, nil + case "tls": + return ProtocolTLS, nil + default: + return 0, fmt.Errorf("unsupported protocol %q: must be http, https, tcp, udp, or tls", s) + } +} diff --git a/client/internal/expose/request.go b/client/internal/expose/request.go new file mode 100644 index 000000000..ec75bb276 --- /dev/null +++ b/client/internal/expose/request.go @@ -0,0 +1,42 @@ +package expose + +import ( + daemonProto "github.com/netbirdio/netbird/client/proto" + mgm "github.com/netbirdio/netbird/shared/management/client" +) + +// NewRequest converts a daemon ExposeServiceRequest to a management ExposeServiceRequest. +func NewRequest(req *daemonProto.ExposeServiceRequest) *Request { + return &Request{ + Port: uint16(req.Port), + Protocol: ProtocolType(req.Protocol), + Pin: req.Pin, + Password: req.Password, + UserGroups: req.UserGroups, + Domain: req.Domain, + NamePrefix: req.NamePrefix, + ListenPort: uint16(req.ListenPort), + } +} + +func toClientExposeRequest(req Request) mgm.ExposeRequest { + return mgm.ExposeRequest{ + NamePrefix: req.NamePrefix, + Domain: req.Domain, + Port: req.Port, + Protocol: int(req.Protocol), + Pin: req.Pin, + Password: req.Password, + UserGroups: req.UserGroups, + ListenPort: req.ListenPort, + } +} + +func fromClientExposeResponse(response *mgm.ExposeResponse) *Response { + return &Response{ + ServiceName: response.ServiceName, + Domain: response.Domain, + ServiceURL: response.ServiceURL, + PortAutoAssigned: response.PortAutoAssigned, + } +} diff --git a/client/internal/iface.go b/client/internal/iface.go index bd0069c19..a82d87aab 100644 --- a/client/internal/iface.go +++ b/client/internal/iface.go @@ -1,5 +1,4 @@ //go:build !windows -// +build !windows package internal diff --git a/client/internal/iface_common.go b/client/internal/iface_common.go index 98fe01912..8ffa0b102 100644 --- a/client/internal/iface_common.go +++ b/client/internal/iface_common.go @@ -20,13 +20,15 @@ import ( type wgIfaceBase interface { Create() error CreateOnAndroid(routeRange []string, ip string, domains []string) error + RenewTun(fd int) error IsUserspaceBind() bool Name() string 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 RemoveEndpointAddress(key string) error RemovePeer(peerKey string) error @@ -41,4 +43,6 @@ type wgIfaceBase interface { GetNet() *netstack.Net 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/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 db283ec9a..9de8c0fa7 100644 --- a/client/internal/lazyconn/activity/manager.go +++ b/client/internal/lazyconn/activity/manager.go @@ -4,7 +4,6 @@ import ( "errors" "net" "net/netip" - "runtime" "sync" "time" @@ -20,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 @@ -42,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{}), @@ -74,15 +81,6 @@ func (m *Manager) createListener(peerCfg lazyconn.PeerConfig) (listener, error) return NewUDPListener(m.wgIface, peerCfg) } - // BindListener is only used on Windows and JS platforms: - // - JS: Cannot listen to UDP sockets - // - Windows: IP_UNICAST_IF socket option forces packets out the interface the default - // gateway points to, preventing them from reaching the loopback interface. - // BindListener bypasses this by passing data directly through the bind. - if runtime.GOOS != "windows" && runtime.GOOS != "js" { - 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") @@ -126,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/login.go b/client/internal/login.go deleted file mode 100644 index 257e3c3ac..000000000 --- a/client/internal/login.go +++ /dev/null @@ -1,191 +0,0 @@ -package internal - -import ( - "context" - "net/url" - - "github.com/google/uuid" - log "github.com/sirupsen/logrus" - "golang.zx2c4.com/wireguard/wgctrl/wgtypes" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - "github.com/netbirdio/netbird/client/internal/profilemanager" - "github.com/netbirdio/netbird/client/ssh" - "github.com/netbirdio/netbird/client/system" - mgm "github.com/netbirdio/netbird/shared/management/client" - mgmProto "github.com/netbirdio/netbird/shared/management/proto" -) - -// IsLoginRequired check that the server is support SSO or not -func IsLoginRequired(ctx context.Context, config *profilemanager.Config) (bool, error) { - mgmURL := config.ManagementURL - mgmClient, err := getMgmClient(ctx, config.PrivateKey, mgmURL) - if err != nil { - return false, err - } - defer func() { - err = mgmClient.Close() - if err != nil { - cStatus, ok := status.FromError(err) - if !ok || ok && cStatus.Code() != codes.Canceled { - log.Warnf("failed to close the Management service client, err: %v", err) - } - } - }() - log.Debugf("connected to the Management service %s", mgmURL.String()) - - pubSSHKey, err := ssh.GeneratePublicKey([]byte(config.SSHKey)) - if err != nil { - return false, err - } - - _, _, err = doMgmLogin(ctx, mgmClient, pubSSHKey, config) - if isLoginNeeded(err) { - return true, nil - } - return false, err -} - -// Login or register the client -func Login(ctx context.Context, config *profilemanager.Config, setupKey string, jwtToken string) error { - mgmClient, err := getMgmClient(ctx, config.PrivateKey, config.ManagementURL) - if err != nil { - return err - } - defer func() { - err = mgmClient.Close() - if err != nil { - cStatus, ok := status.FromError(err) - if !ok || ok && cStatus.Code() != codes.Canceled { - log.Warnf("failed to close the Management service client, err: %v", err) - } - } - }() - log.Debugf("connected to the Management service %s", config.ManagementURL.String()) - - pubSSHKey, err := ssh.GeneratePublicKey([]byte(config.SSHKey)) - if err != nil { - return err - } - - serverKey, _, err := doMgmLogin(ctx, mgmClient, pubSSHKey, config) - if serverKey != nil && isRegistrationNeeded(err) { - log.Debugf("peer registration required") - _, err = registerPeer(ctx, *serverKey, mgmClient, setupKey, jwtToken, pubSSHKey, config) - if err != nil { - return err - } - } else if err != nil { - return err - } - - return nil -} - -func getMgmClient(ctx context.Context, privateKey string, mgmURL *url.URL) (*mgm.GrpcClient, error) { - // validate our peer's Wireguard PRIVATE key - myPrivateKey, err := wgtypes.ParseKey(privateKey) - if err != nil { - log.Errorf("failed parsing Wireguard key %s: [%s]", privateKey, err.Error()) - return nil, err - } - - var mgmTlsEnabled bool - if mgmURL.Scheme == "https" { - mgmTlsEnabled = true - } - - log.Debugf("connecting to the Management service %s", mgmURL.String()) - mgmClient, err := mgm.NewClient(ctx, mgmURL.Host, myPrivateKey, mgmTlsEnabled) - if err != nil { - log.Errorf("failed connecting to the Management service %s %v", mgmURL.String(), err) - return nil, err - } - return mgmClient, err -} - -func doMgmLogin(ctx context.Context, mgmClient *mgm.GrpcClient, pubSSHKey []byte, config *profilemanager.Config) (*wgtypes.Key, *mgmProto.LoginResponse, error) { - serverKey, err := mgmClient.GetServerPublicKey() - if err != nil { - log.Errorf("failed while getting Management Service public key: %v", err) - return nil, nil, err - } - - sysInfo := system.GetInfo(ctx) - sysInfo.SetFlags( - config.RosenpassEnabled, - config.RosenpassPermissive, - config.ServerSSHAllowed, - config.DisableClientRoutes, - config.DisableServerRoutes, - config.DisableDNS, - config.DisableFirewall, - config.BlockLANAccess, - config.BlockInbound, - config.LazyConnectionEnabled, - ) - loginResp, err := mgmClient.Login(*serverKey, sysInfo, pubSSHKey, config.DNSLabels) - return serverKey, loginResp, err -} - -// registerPeer checks whether setupKey was provided via cmd line and if not then it prompts user to enter a key. -// Otherwise tries to register with the provided setupKey via command line. -func registerPeer(ctx context.Context, serverPublicKey wgtypes.Key, client *mgm.GrpcClient, setupKey string, jwtToken string, pubSSHKey []byte, config *profilemanager.Config) (*mgmProto.LoginResponse, error) { - validSetupKey, err := uuid.Parse(setupKey) - if err != nil && jwtToken == "" { - return nil, status.Errorf(codes.InvalidArgument, "invalid setup-key or no sso information provided, err: %v", err) - } - - log.Debugf("sending peer registration request to Management Service") - info := system.GetInfo(ctx) - info.SetFlags( - config.RosenpassEnabled, - config.RosenpassPermissive, - config.ServerSSHAllowed, - config.DisableClientRoutes, - config.DisableServerRoutes, - config.DisableDNS, - config.DisableFirewall, - config.BlockLANAccess, - config.BlockInbound, - config.LazyConnectionEnabled, - ) - loginResp, err := client.Register(serverPublicKey, validSetupKey.String(), jwtToken, info, pubSSHKey, config.DNSLabels) - if err != nil { - log.Errorf("failed registering peer %v", err) - return nil, err - } - - log.Infof("peer has been successfully registered on Management Service") - - return loginResp, nil -} - -func isLoginNeeded(err error) bool { - if err == nil { - return false - } - s, ok := status.FromError(err) - if !ok { - return false - } - if s.Code() == codes.InvalidArgument || s.Code() == codes.PermissionDenied { - return true - } - return false -} - -func isRegistrationNeeded(err error) bool { - if err == nil { - return false - } - s, ok := status.FromError(err) - if !ok { - return false - } - if s.Code() == codes.PermissionDenied { - return true - } - return false -} diff --git a/client/internal/metrics/connection_type.go b/client/internal/metrics/connection_type.go new file mode 100644 index 000000000..a3406a6b8 --- /dev/null +++ b/client/internal/metrics/connection_type.go @@ -0,0 +1,17 @@ +package metrics + +// ConnectionType represents the type of peer connection +type ConnectionType string + +const ( + // ConnectionTypeICE represents a direct peer-to-peer connection using ICE + ConnectionTypeICE ConnectionType = "ice" + + // ConnectionTypeRelay represents a relayed connection + ConnectionTypeRelay ConnectionType = "relay" +) + +// String returns the string representation of the connection type +func (c ConnectionType) String() string { + return string(c) +} diff --git a/client/internal/metrics/deployment_type.go b/client/internal/metrics/deployment_type.go new file mode 100644 index 000000000..141173cb8 --- /dev/null +++ b/client/internal/metrics/deployment_type.go @@ -0,0 +1,51 @@ +package metrics + +import ( + "net/url" + "strings" +) + +// DeploymentType represents the type of NetBird deployment +type DeploymentType int + +const ( + // DeploymentTypeUnknown represents an unknown or uninitialized deployment type + DeploymentTypeUnknown DeploymentType = iota + + // DeploymentTypeCloud represents a cloud-hosted NetBird deployment + DeploymentTypeCloud + + // DeploymentTypeSelfHosted represents a self-hosted NetBird deployment + DeploymentTypeSelfHosted +) + +// String returns the string representation of the deployment type +func (d DeploymentType) String() string { + switch d { + case DeploymentTypeCloud: + return "cloud" + case DeploymentTypeSelfHosted: + return "selfhosted" + default: + return "unknown" + } +} + +// DetermineDeploymentType determines if the deployment is cloud or self-hosted +// based on the management URL string +func DetermineDeploymentType(managementURL string) DeploymentType { + if managementURL == "" { + return DeploymentTypeUnknown + } + + u, err := url.Parse(managementURL) + if err != nil { + return DeploymentTypeSelfHosted + } + + if strings.ToLower(u.Hostname()) == "api.netbird.io" { + return DeploymentTypeCloud + } + + return DeploymentTypeSelfHosted +} diff --git a/client/internal/metrics/env.go b/client/internal/metrics/env.go new file mode 100644 index 000000000..c19dcc7f1 --- /dev/null +++ b/client/internal/metrics/env.go @@ -0,0 +1,100 @@ +package metrics + +import ( + "net/url" + "os" + "strconv" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + // EnvMetricsPushEnabled controls whether collected metrics are pushed to the backend. + // Metrics collection itself is always active (for debug bundles). + // Disabled by default. Set NB_METRICS_PUSH_ENABLED=true to enable push. + EnvMetricsPushEnabled = "NB_METRICS_PUSH_ENABLED" + + // EnvMetricsForceSending if set to true, skips remote configuration fetch and forces metric sending + EnvMetricsForceSending = "NB_METRICS_FORCE_SENDING" + + // EnvMetricsConfigURL is the environment variable to override the metrics push config ServerAddress + EnvMetricsConfigURL = "NB_METRICS_CONFIG_URL" + + // EnvMetricsServerURL is the environment variable to override the metrics server address. + // When set, this takes precedence over the server_url from remote push config. + EnvMetricsServerURL = "NB_METRICS_SERVER_URL" + + // EnvMetricsInterval overrides the push interval from the remote config. + // Only affects how often metrics are pushed; remote config availability + // and version range checks are still respected. + // Format: duration string like "1h", "30m", "4h" + EnvMetricsInterval = "NB_METRICS_INTERVAL" + + defaultMetricsConfigURL = "https://ingest.netbird.io/config" +) + +// IsMetricsPushEnabled returns true if metrics push is enabled via NB_METRICS_PUSH_ENABLED env var. +// Disabled by default. Metrics collection is always active for debug bundles. +func IsMetricsPushEnabled() bool { + enabled, _ := strconv.ParseBool(os.Getenv(EnvMetricsPushEnabled)) + return enabled +} + +// getMetricsInterval returns the metrics push interval from NB_METRICS_INTERVAL env var. +// Returns 0 if not set or invalid. +func getMetricsInterval() time.Duration { + intervalStr := os.Getenv(EnvMetricsInterval) + if intervalStr == "" { + return 0 + } + interval, err := time.ParseDuration(intervalStr) + if err != nil { + log.Warnf("invalid metrics interval from env %q: %v", intervalStr, err) + return 0 + } + if interval <= 0 { + log.Warnf("invalid metrics interval from env %q: must be positive", intervalStr) + return 0 + } + 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 +} + +// getMetricsConfigURL returns the URL to fetch push configuration from +func getMetricsConfigURL() string { + if envURL := os.Getenv(EnvMetricsConfigURL); envURL != "" { + return envURL + } + return defaultMetricsConfigURL +} + +// getMetricsServerURL returns the metrics server URL from NB_METRICS_SERVER_URL env var. +// Returns nil if not set or invalid. +func getMetricsServerURL() *url.URL { + envURL := os.Getenv(EnvMetricsServerURL) + if envURL == "" { + return nil + } + parsed, err := url.ParseRequestURI(envURL) + if err != nil || parsed.Host == "" { + log.Warnf("invalid metrics server URL %q: must be an absolute HTTP(S) URL", envURL) + return nil + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + log.Warnf("invalid metrics server URL %q: unsupported scheme %q", envURL, parsed.Scheme) + return nil + } + return parsed +} diff --git a/client/internal/metrics/influxdb.go b/client/internal/metrics/influxdb.go new file mode 100644 index 000000000..4ba14bf44 --- /dev/null +++ b/client/internal/metrics/influxdb.go @@ -0,0 +1,243 @@ +package metrics + +import ( + "context" + "fmt" + "io" + "maps" + "slices" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + maxSampleAge = 5 * 24 * time.Hour // drop samples older than 5 days + maxBufferSize = 5 * 1024 * 1024 // drop oldest samples when estimated size exceeds 5 MB + // estimatedSampleSize is a rough per-sample memory estimate (measurement + tags + fields + timestamp) + estimatedSampleSize = 256 +) + +// influxSample is a single InfluxDB line protocol entry. +type influxSample struct { + measurement string + tags string + fields map[string]float64 + timestamp time.Time +} + +// influxDBMetrics collects metric events as timestamped samples. +// Each event is recorded with its exact timestamp, pushed once, then cleared. +type influxDBMetrics struct { + mu sync.Mutex + samples []influxSample +} + +func newInfluxDBMetrics() metricsImplementation { + return &influxDBMetrics{} +} +func (m *influxDBMetrics) RecordConnectionStages( + _ context.Context, + agentInfo AgentInfo, + connectionPairID string, + connectionType ConnectionType, + 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" + } + + 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, + agentInfo.Version, + agentInfo.OS, + agentInfo.Arch, + agentInfo.peerID, + connectionPairID, + ) + + now := time.Now() + + m.mu.Lock() + defer m.mu.Unlock() + + m.samples = append(m.samples, influxSample{ + measurement: "netbird_peer_connection", + tags: tags, + fields: map[string]float64{ + "signaling_to_connection_seconds": signalingReceivedToConnection, + "connection_to_wg_handshake_seconds": connectionToWgHandshake, + "total_seconds": totalDuration, + }, + timestamp: now, + }) + 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) +} + +func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration) { + tags := fmt.Sprintf("deployment_type=%s,version=%s,os=%s,arch=%s,peer_id=%s", + agentInfo.DeploymentType.String(), + agentInfo.Version, + agentInfo.OS, + agentInfo.Arch, + agentInfo.peerID, + ) + + m.mu.Lock() + defer m.mu.Unlock() + + m.samples = append(m.samples, influxSample{ + measurement: "netbird_sync", + tags: tags, + fields: map[string]float64{ + "duration_seconds": duration.Seconds(), + }, + timestamp: time.Now(), + }) + 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 { + result = "failure" + } + + tags := fmt.Sprintf("deployment_type=%s,result=%s,version=%s,os=%s,arch=%s,peer_id=%s", + agentInfo.DeploymentType.String(), + result, + agentInfo.Version, + agentInfo.OS, + agentInfo.Arch, + agentInfo.peerID, + ) + + m.mu.Lock() + defer m.mu.Unlock() + + m.samples = append(m.samples, influxSample{ + measurement: "netbird_login", + tags: tags, + fields: map[string]float64{ + "duration_seconds": duration.Seconds(), + }, + timestamp: time.Now(), + }) + m.trimLocked() + + log.Tracef("login metrics [%s, %s]: duration=%.3fs", agentInfo.DeploymentType.String(), result, duration.Seconds()) +} + +// Export writes pending samples in InfluxDB line protocol format. +// Format: measurement,tag=val,tag=val field=val,field=val timestamp_ns +func (m *influxDBMetrics) Export(w io.Writer) error { + m.mu.Lock() + samples := make([]influxSample, len(m.samples)) + copy(samples, m.samples) + m.mu.Unlock() + + for _, s := range samples { + if _, err := fmt.Fprintf(w, "%s,%s ", s.measurement, s.tags); err != nil { + return err + } + + sortedKeys := slices.Sorted(maps.Keys(s.fields)) + first := true + for _, k := range sortedKeys { + if !first { + if _, err := fmt.Fprint(w, ","); err != nil { + return err + } + } + if _, err := fmt.Fprintf(w, "%s=%g", k, s.fields[k]); err != nil { + return err + } + first = false + } + + if _, err := fmt.Fprintf(w, " %d\n", s.timestamp.UnixNano()); err != nil { + return err + } + } + return nil +} + +// Reset clears pending samples after a successful push +func (m *influxDBMetrics) Reset() { + m.mu.Lock() + defer m.mu.Unlock() + m.samples = m.samples[:0] +} + +// trimLocked removes samples that exceed age or size limits. +// Must be called with m.mu held. +func (m *influxDBMetrics) trimLocked() { + now := time.Now() + + // drop samples older than maxSampleAge + cutoff := 0 + for cutoff < len(m.samples) && now.Sub(m.samples[cutoff].timestamp) > maxSampleAge { + cutoff++ + } + if cutoff > 0 { + copy(m.samples, m.samples[cutoff:]) + m.samples = m.samples[:len(m.samples)-cutoff] + log.Debugf("influxdb metrics: dropped %d samples older than %s", cutoff, maxSampleAge) + } + + // drop oldest samples if estimated size exceeds maxBufferSize + maxSamples := maxBufferSize / estimatedSampleSize + if len(m.samples) > maxSamples { + drop := len(m.samples) - maxSamples + copy(m.samples, m.samples[drop:]) + m.samples = m.samples[:maxSamples] + log.Debugf("influxdb metrics: dropped %d oldest samples to stay under %d MB size limit", drop, maxBufferSize/(1024*1024)) + } +} diff --git a/client/internal/metrics/influxdb_test.go b/client/internal/metrics/influxdb_test.go new file mode 100644 index 000000000..b964e31a3 --- /dev/null +++ b/client/internal/metrics/influxdb_test.go @@ -0,0 +1,229 @@ +package metrics + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInfluxDBMetrics_RecordAndExport(t *testing.T) { + m := newInfluxDBMetrics().(*influxDBMetrics) + + agentInfo := AgentInfo{ + DeploymentType: DeploymentTypeCloud, + Version: "1.0.0", + OS: "linux", + Arch: "amd64", + peerID: "abc123", + } + + ts := ConnectionStageTimestamps{ + SignalingReceived: time.Now().Add(-3 * time.Second), + ConnectionReady: time.Now().Add(-2 * time.Second), + WgHandshakeSuccess: time.Now().Add(-1 * time.Second), + } + + m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts) + + var buf bytes.Buffer + err := m.Export(&buf) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "netbird_peer_connection,") + assert.Contains(t, output, "connection_to_wg_handshake_seconds=") + assert.Contains(t, output, "signaling_to_connection_seconds=") + assert.Contains(t, output, "total_seconds=") +} + +func TestInfluxDBMetrics_ExportDeterministicFieldOrder(t *testing.T) { + m := newInfluxDBMetrics().(*influxDBMetrics) + + agentInfo := AgentInfo{ + DeploymentType: DeploymentTypeCloud, + Version: "1.0.0", + OS: "linux", + Arch: "amd64", + peerID: "abc123", + } + + ts := ConnectionStageTimestamps{ + SignalingReceived: time.Now().Add(-3 * time.Second), + ConnectionReady: time.Now().Add(-2 * time.Second), + WgHandshakeSuccess: time.Now().Add(-1 * time.Second), + } + + // Record multiple times and verify consistent field order + for i := 0; i < 10; i++ { + m.RecordConnectionStages(context.Background(), agentInfo, "pair123", ConnectionTypeICE, false, ts) + } + + var buf bytes.Buffer + err := m.Export(&buf) + require.NoError(t, err) + + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + require.Len(t, lines, 10) + + // Extract field portion from each line and verify they're all identical + var fieldSections []string + for _, line := range lines { + parts := strings.SplitN(line, " ", 3) + require.Len(t, parts, 3, "each line should have measurement, fields, timestamp") + fieldSections = append(fieldSections, parts[1]) + } + + for i := 1; i < len(fieldSections); i++ { + assert.Equal(t, fieldSections[0], fieldSections[i], "field order should be deterministic across samples") + } + + // Fields should be alphabetically sorted + assert.True(t, strings.HasPrefix(fieldSections[0], "connection_to_wg_handshake_seconds="), + "fields should be sorted: connection_to_wg < signaling_to < total") +} + +func TestInfluxDBMetrics_RecordSyncDuration(t *testing.T) { + m := newInfluxDBMetrics().(*influxDBMetrics) + + agentInfo := AgentInfo{ + DeploymentType: DeploymentTypeSelfHosted, + Version: "2.0.0", + OS: "darwin", + Arch: "arm64", + peerID: "def456", + } + + m.RecordSyncDuration(context.Background(), agentInfo, 1500*time.Millisecond) + + var buf bytes.Buffer + err := m.Export(&buf) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "netbird_sync,") + assert.Contains(t, output, "duration_seconds=1.5") + assert.Contains(t, output, "deployment_type=selfhosted") +} + +func TestInfluxDBMetrics_Reset(t *testing.T) { + m := newInfluxDBMetrics().(*influxDBMetrics) + + agentInfo := AgentInfo{ + DeploymentType: DeploymentTypeCloud, + Version: "1.0.0", + OS: "linux", + Arch: "amd64", + peerID: "abc123", + } + + m.RecordSyncDuration(context.Background(), agentInfo, time.Second) + + var buf bytes.Buffer + err := m.Export(&buf) + require.NoError(t, err) + assert.NotEmpty(t, buf.String()) + + m.Reset() + + buf.Reset() + err = m.Export(&buf) + require.NoError(t, err) + assert.Empty(t, buf.String(), "should be empty after reset") +} + +func TestInfluxDBMetrics_ExportEmpty(t *testing.T) { + m := newInfluxDBMetrics().(*influxDBMetrics) + + var buf bytes.Buffer + err := m.Export(&buf) + require.NoError(t, err) + assert.Empty(t, buf.String()) +} + +func TestInfluxDBMetrics_TrimByAge(t *testing.T) { + m := newInfluxDBMetrics().(*influxDBMetrics) + + m.mu.Lock() + m.samples = append(m.samples, influxSample{ + measurement: "old", + tags: "t=1", + fields: map[string]float64{"v": 1}, + timestamp: time.Now().Add(-maxSampleAge - time.Hour), + }) + m.trimLocked() + remaining := len(m.samples) + m.mu.Unlock() + + assert.Equal(t, 0, remaining, "old samples should be trimmed") +} + +func TestInfluxDBMetrics_RecordLoginDuration(t *testing.T) { + m := newInfluxDBMetrics().(*influxDBMetrics) + + agentInfo := AgentInfo{ + DeploymentType: DeploymentTypeCloud, + Version: "1.0.0", + OS: "linux", + Arch: "amd64", + peerID: "abc123", + } + + m.RecordLoginDuration(context.Background(), agentInfo, 2500*time.Millisecond, true) + + var buf bytes.Buffer + err := m.Export(&buf) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "netbird_login,") + assert.Contains(t, output, "duration_seconds=2.5") + assert.Contains(t, output, "result=success") +} + +func TestInfluxDBMetrics_RecordLoginDurationFailure(t *testing.T) { + m := newInfluxDBMetrics().(*influxDBMetrics) + + agentInfo := AgentInfo{ + DeploymentType: DeploymentTypeSelfHosted, + Version: "1.0.0", + OS: "darwin", + Arch: "arm64", + peerID: "xyz789", + } + + m.RecordLoginDuration(context.Background(), agentInfo, 5*time.Second, false) + + var buf bytes.Buffer + err := m.Export(&buf) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "netbird_login,") + assert.Contains(t, output, "result=failure") + assert.Contains(t, output, "deployment_type=selfhosted") +} + +func TestInfluxDBMetrics_TrimBySize(t *testing.T) { + m := newInfluxDBMetrics().(*influxDBMetrics) + + maxSamples := maxBufferSize / estimatedSampleSize + m.mu.Lock() + for i := 0; i < maxSamples+100; i++ { + m.samples = append(m.samples, influxSample{ + measurement: "test", + tags: "t=1", + fields: map[string]float64{"v": float64(i)}, + timestamp: time.Now(), + }) + } + m.trimLocked() + remaining := len(m.samples) + m.mu.Unlock() + + assert.Equal(t, maxSamples, remaining, "should trim to max samples") +} diff --git a/client/internal/metrics/infra/.env.example b/client/internal/metrics/infra/.env.example new file mode 100644 index 000000000..9c5c1a258 --- /dev/null +++ b/client/internal/metrics/infra/.env.example @@ -0,0 +1,16 @@ +# Copy to .env and adjust values before running docker compose + +# InfluxDB admin (server-side only, never exposed to clients) +INFLUXDB_ADMIN_PASSWORD=changeme +INFLUXDB_ADMIN_TOKEN=changeme + +# Grafana admin credentials +GRAFANA_ADMIN_USER=admin +GRAFANA_ADMIN_PASSWORD=changeme + +# Remote config served by ingest at /config +# Set CONFIG_METRICS_SERVER_URL to the ingest server's public address to enable +CONFIG_METRICS_SERVER_URL= +CONFIG_VERSION_SINCE=0.0.0 +CONFIG_VERSION_UNTIL=99.99.99 +CONFIG_PERIOD_MINUTES=5 diff --git a/client/internal/metrics/infra/.gitignore b/client/internal/metrics/infra/.gitignore new file mode 100644 index 000000000..4c49bd78f --- /dev/null +++ b/client/internal/metrics/infra/.gitignore @@ -0,0 +1 @@ +.env diff --git a/client/internal/metrics/infra/README.md b/client/internal/metrics/infra/README.md new file mode 100644 index 000000000..7941a30cf --- /dev/null +++ b/client/internal/metrics/infra/README.md @@ -0,0 +1,261 @@ +# Client Metrics + +Internal documentation for the NetBird client metrics system. + +## Overview + +Client metrics track connection performance and sync durations using InfluxDB line protocol (`influxdb.go`). Each event is pushed once then cleared. + +Metrics collection is always active (for debug bundles). Push to backend is: +- Disabled by default (opt-in via `NB_METRICS_PUSH_ENABLED=true`) +- Managed at daemon layer (survives engine restarts) + +## Architecture + +### Layer Separation + +```text +Daemon Layer (connect.go) + ├─ Creates ClientMetrics instance once + ├─ Starts/stops push lifecycle + └─ Updates AgentInfo on profile switch + │ + ▼ +Engine Layer (engine.go) + └─ Records metrics via ClientMetrics methods +``` + +### Ingest Server + +Clients do not talk to InfluxDB directly. An ingest server sits between clients and InfluxDB: + +```text +Client ──POST──▶ Ingest Server (:8087) ──▶ InfluxDB (internal) + │ + ├─ 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. +- **InfluxDB is not exposed** — only accessible within the docker network +- Source: `ingest/main.go` + +## Metrics Collected + +### Connection Stage Timing + +Measurement: `netbird_peer_connection` + +| Field | Timestamps | Description | +|-------|-----------|-------------| +| `signaling_to_connection_seconds` | `SignalingReceived → ConnectionReady` | ICE/relay negotiation time after the first signal is received from the remote peer | +| `connection_to_wg_handshake_seconds` | `ConnectionReady → WgHandshakeSuccess` | WireGuard cryptographic handshake latency once the transport layer is ready | +| `total_seconds` | `SignalingReceived → WgHandshakeSuccess` | End-to-end connection time anchored at the first received signal | + +Tags: +- `deployment_type`: "cloud" | "selfhosted" | "unknown" +- `connection_type`: "ice" | "relay" +- `attempt_type`: "initial" | "reconnection" +- `version`: NetBird version string +- `os`: Operating system (linux, darwin, windows, android, ios, etc.) +- `arch`: CPU architecture (amd64, arm64, etc.) + +**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. + +### Sync Duration + +Measurement: `netbird_sync` + +| Field | Description | +|-------|-------------| +| `duration_seconds` | Time to process a sync message from management server | + +Tags: +- `deployment_type`: "cloud" | "selfhosted" | "unknown" +- `version`: NetBird version string +- `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` + +| Field | Description | +|-------|-------------| +| `duration_seconds` | Time to complete the login/auth exchange with management server | + +Tags: +- `deployment_type`: "cloud" | "selfhosted" | "unknown" +- `result`: "success" | "failure" +- `version`: NetBird version string +- `os`: Operating system (linux, darwin, windows, android, ios, etc.) +- `arch`: CPU architecture (amd64, arm64, etc.) + +## Buffer Limits + +The InfluxDB backend limits in-memory sample storage to prevent unbounded growth when pushes fail: +- **Max age:** Samples older than 5 days are dropped +- **Max size:** Estimated buffer size capped at 5 MB (~20k samples) + +## Configuration + +### Client Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `NB_METRICS_PUSH_ENABLED` | `false` | Enable metrics push to backend | +| `NB_METRICS_SERVER_URL` | *(from remote config)* | Ingest server URL (e.g., `https://ingest.netbird.io`) | +| `NB_METRICS_INTERVAL` | *(from remote config)* | Push interval (e.g., "1m", "30m", "4h") | +| `NB_METRICS_FORCE_SENDING` | `false` | Skip remote config, push unconditionally | +| `NB_METRICS_CONFIG_URL` | `https://ingest.netbird.io/config` | Remote push config URL | + +`NB_METRICS_SERVER_URL` and `NB_METRICS_INTERVAL` override their respective values but do not bypass remote config eligibility checks (version range). Use `NB_METRICS_FORCE_SENDING=true` to skip all remote config gating. + +### Ingest Server Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `INGEST_LISTEN_ADDR` | `:8087` | Listen address | +| `INFLUXDB_URL` | `http://influxdb:8086/api/v2/write?org=netbird&bucket=metrics&precision=ns` | InfluxDB write endpoint | +| `INFLUXDB_TOKEN` | *(required)* | InfluxDB auth token (server-side only) | +| `CONFIG_METRICS_SERVER_URL` | *(empty — disables /config)* | `server_url` in the remote config JSON (the URL clients push metrics to) | +| `CONFIG_VERSION_SINCE` | `0.0.0` | Minimum client version to push metrics | +| `CONFIG_VERSION_UNTIL` | `99.99.99` | Maximum client version to push metrics | +| `CONFIG_PERIOD_MINUTES` | `5` | Push interval in minutes | + +The ingest server serves a remote config JSON at `GET /config` when `CONFIG_METRICS_SERVER_URL` is set. Clients can use `NB_METRICS_CONFIG_URL=http:///config` to fetch it. + +### Configuration Precedence + +For URL and Interval, the precedence is: +1. **Environment variable** - `NB_METRICS_SERVER_URL` / `NB_METRICS_INTERVAL` +2. **Remote config** - fetched from `NB_METRICS_CONFIG_URL` +3. **Default** - 5 minute interval, URL from remote config + +## Push Behavior + +1. `StartPush()` spawns background goroutine with timer +2. First push happens immediately on startup +3. Periodically: `push()` → `Export()` → HTTP POST to ingest server +4. On failure: log error, continue (non-blocking) +5. On success: `Reset()` clears pushed samples +6. `StopPush()` cancels context and waits for goroutine + +Samples are collected with exact timestamps, pushed once, then cleared. No data is resent. + +## Local Development Setup + +### 1. Configure and Start Services + +```bash +# From this directory (client/internal/metrics/infra) +cp .env.example .env +# Edit .env to set INFLUXDB_ADMIN_PASSWORD, INFLUXDB_ADMIN_TOKEN, and GRAFANA_ADMIN_PASSWORD +docker compose up -d +``` + +This starts: +- **Ingest server** on http://localhost:8087 — accepts client metrics (requires `X-Peer-ID` header, no secret/token auth) +- **InfluxDB** — internal only, not exposed to host +- **Grafana** on http://localhost:3001 + +### 2. Configure Client + +```bash +export NB_METRICS_PUSH_ENABLED=true +export NB_METRICS_FORCE_SENDING=true +export NB_METRICS_SERVER_URL=http://localhost:8087 +export NB_METRICS_INTERVAL=1m +``` + +### 3. Run Client + +```bash +cd ../../../.. +go run ./client/ up +``` + +### 4. View in Grafana + +- **InfluxDB dashboard:** http://localhost:3001/d/netbird-influxdb-metrics + +### 5. Verify Data + +```bash +# Query via InfluxDB (using admin token from .env) +docker compose exec influxdb influx query \ + 'from(bucket: "metrics") |> range(start: -1h)' \ + --org netbird + +# Check ingest server health +curl http://localhost:8087/health +``` + +## 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/docker-compose.yml b/client/internal/metrics/infra/docker-compose.yml new file mode 100644 index 000000000..0f2b6b889 --- /dev/null +++ b/client/internal/metrics/infra/docker-compose.yml @@ -0,0 +1,69 @@ +version: '3.8' + +services: + ingest: + container_name: ingest + build: + context: ./ingest + ports: + - "8087:8087" + environment: + - INGEST_LISTEN_ADDR=:8087 + - INFLUXDB_URL=http://influxdb:8086/api/v2/write?org=netbird&bucket=metrics&precision=ns + - INFLUXDB_TOKEN=${INFLUXDB_ADMIN_TOKEN:?required} + - CONFIG_METRICS_SERVER_URL=${CONFIG_METRICS_SERVER_URL:-} + - CONFIG_VERSION_SINCE=${CONFIG_VERSION_SINCE:-0.0.0} + - CONFIG_VERSION_UNTIL=${CONFIG_VERSION_UNTIL:-99.99.99} + - CONFIG_PERIOD_MINUTES=${CONFIG_PERIOD_MINUTES:-5} + depends_on: + - influxdb + restart: unless-stopped + networks: + - metrics + + influxdb: + container_name: influxdb + image: influxdb:2 + # No ports exposed — only accessible within the metrics network + volumes: + - influxdb-data:/var/lib/influxdb2 + - ./influxdb/scripts:/docker-entrypoint-initdb.d + environment: + - DOCKER_INFLUXDB_INIT_MODE=setup + - DOCKER_INFLUXDB_INIT_USERNAME=admin + - DOCKER_INFLUXDB_INIT_PASSWORD=${INFLUXDB_ADMIN_PASSWORD:?required} + - DOCKER_INFLUXDB_INIT_ORG=netbird + - DOCKER_INFLUXDB_INIT_BUCKET=metrics + - DOCKER_INFLUXDB_INIT_RETENTION=365d + - DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=${INFLUXDB_ADMIN_TOKEN:-} + restart: unless-stopped + networks: + - metrics + + grafana: + container_name: grafana + image: grafana/grafana:11.6.0 + ports: + - "3001:3000" + environment: + - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:-admin} + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:?required} + - GF_USERS_ALLOW_SIGN_UP=false + - GF_INSTALL_PLUGINS= + - INFLUXDB_ADMIN_TOKEN=${INFLUXDB_ADMIN_TOKEN:-} + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning + depends_on: + - influxdb + restart: unless-stopped + networks: + - metrics + +volumes: + influxdb-data: + grafana-data: + +networks: + metrics: + driver: bridge diff --git a/client/internal/metrics/infra/grafana/provisioning/dashboards/dashboard.yml b/client/internal/metrics/infra/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 000000000..a7e8d3989 --- /dev/null +++ b/client/internal/metrics/infra/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: 'NetBird Dashboards' + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /etc/grafana/provisioning/dashboards/json \ No newline at end of file diff --git a/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-influxdb-metrics.json b/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-influxdb-metrics.json new file mode 100644 index 000000000..2bcc9cbab --- /dev/null +++ b/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-influxdb-metrics.json @@ -0,0 +1,280 @@ +{ + "uid": "netbird-influxdb-metrics", + "title": "NetBird Client Metrics (InfluxDB)", + "tags": ["netbird", "connections", "influxdb"], + "timezone": "browser", + "panels": [ + { + "id": 5, + "title": "Sync Duration Extremes", + "type": "stat", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "targets": [ + { + "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 |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"version\", \"os\", \"arch\", \"peer_id\"])\n |> min()\n |> set(key: \"_field\", value: \"Min\")", + "refId": "A" + }, + { + "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 |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"version\", \"os\", \"arch\", \"peer_id\"])\n |> max()\n |> set(key: \"_field\", value: \"Max\")", + "refId": "B" + } + ], + "fieldConfig": { + "defaults": { + "unit": "ms", + "min": 0 + } + }, + "options": { + "reduceOptions": { + "calcs": ["lastNotNull"] + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "auto" + } + }, + { + "id": 6, + "title": "Total Connection Time Extremes", + "type": "stat", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "targets": [ + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_peer_connection\" and r._field == \"total_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"connection_type\", \"attempt_type\", \"version\", \"os\", \"arch\", \"peer_id\", \"connection_pair_id\"])\n |> min()\n |> set(key: \"_field\", value: \"Min\")", + "refId": "A" + }, + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_peer_connection\" and r._field == \"total_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"connection_type\", \"attempt_type\", \"version\", \"os\", \"arch\", \"peer_id\", \"connection_pair_id\"])\n |> max()\n |> set(key: \"_field\", value: \"Max\")", + "refId": "B" + } + ], + "fieldConfig": { + "defaults": { + "unit": "ms", + "min": 0 + } + }, + "options": { + "reduceOptions": { + "calcs": ["lastNotNull"] + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "auto" + } + }, + { + "id": 1, + "title": "Sync Duration", + "type": "timeseries", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "targets": [ + { + "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 |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"version\", \"os\", \"arch\", \"peer_id\"])\n |> set(key: \"_field\", value: \"Sync Duration\")", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "ms", + "min": 0, + "custom": { + "drawStyle": "points", + "pointSize": 5 + } + } + } + }, + { + "id": 4, + "title": "ICE vs Relay", + "type": "piechart", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "targets": [ + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_peer_connection\" and r._field == \"total_seconds\")\n |> drop(columns: [\"deployment_type\", \"attempt_type\", \"version\", \"os\", \"arch\", \"peer_id\"])\n |> group(columns: [\"connection_pair_id\"])\n |> last()\n |> group(columns: [\"connection_type\"])\n |> count()", + "refId": "A" + } + ], + "options": { + "reduceOptions": { + "calcs": ["lastNotNull"] + }, + "pieType": "donut", + "tooltip": { + "mode": "multi" + } + } + }, + { + "id": 2, + "title": "Connection Stage Durations (avg)", + "type": "bargauge", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "targets": [ + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_peer_connection\" and r._field == \"signaling_to_connection_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"connection_type\", \"attempt_type\", \"version\", \"os\", \"arch\", \"peer_id\", \"connection_pair_id\"])\n |> mean()\n |> drop(columns: [\"_start\", \"_stop\", \"_measurement\", \"_time\", \"_field\"])\n |> rename(columns: {_value: \"Avg Signaling to Connection\"})", + "refId": "A" + }, + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_peer_connection\" and r._field == \"connection_to_wg_handshake_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"connection_type\", \"attempt_type\", \"version\", \"os\", \"arch\", \"peer_id\", \"connection_pair_id\"])\n |> mean()\n |> drop(columns: [\"_start\", \"_stop\", \"_measurement\", \"_time\", \"_field\"])\n |> rename(columns: {_value: \"Avg Connection to WG Handshake\"})", + "refId": "B" + } + ], + "fieldConfig": { + "defaults": { + "unit": "ms", + "min": 0 + } + }, + "options": { + "reduceOptions": { + "calcs": ["lastNotNull"] + }, + "orientation": "horizontal", + "displayMode": "gradient" + } + }, + { + "id": 3, + "title": "Total Connection Time", + "type": "timeseries", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "targets": [ + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_peer_connection\" and r._field == \"total_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"connection_type\", \"attempt_type\", \"version\", \"os\", \"arch\", \"peer_id\", \"connection_pair_id\"])\n |> set(key: \"_field\", value: \"Total Connection Time\")", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "ms", + "min": 0, + "custom": { + "drawStyle": "points", + "pointSize": 5 + } + } + } + }, + { + "id": 7, + "title": "Login Duration", + "type": "timeseries", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "targets": [ + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_login\" and r._field == \"duration_seconds\")\n |> map(fn: (r) => ({r with _value: r._value * 1000.0}))\n |> drop(columns: [\"deployment_type\", \"version\", \"os\", \"arch\", \"peer_id\"])\n |> set(key: \"_field\", value: \"Login Duration\")", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "ms", + "min": 0, + "custom": { + "drawStyle": "points", + "pointSize": 5 + } + } + } + }, + { + "id": 8, + "title": "Login Success vs Failure", + "type": "piechart", + "datasource": { + "type": "influxdb", + "uid": "influxdb" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "targets": [ + { + "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_login\" and r._field == \"duration_seconds\")\n |> drop(columns: [\"deployment_type\", \"version\", \"os\", \"arch\", \"peer_id\"])\n |> group(columns: [\"result\"])\n |> count()", + "refId": "A" + } + ], + "options": { + "reduceOptions": { + "calcs": ["lastNotNull"] + }, + "pieType": "donut", + "tooltip": { + "mode": "multi" + } + } + } + ], + "schemaVersion": 27, + "version": 2, + "refresh": "30s" +} 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/grafana/provisioning/datasources/influxdb.yml b/client/internal/metrics/infra/grafana/provisioning/datasources/influxdb.yml new file mode 100644 index 000000000..69b96a93a --- /dev/null +++ b/client/internal/metrics/infra/grafana/provisioning/datasources/influxdb.yml @@ -0,0 +1,15 @@ +apiVersion: 1 + +datasources: + - name: InfluxDB + uid: influxdb + type: influxdb + access: proxy + url: http://influxdb:8086 + editable: true + jsonData: + version: Flux + organization: netbird + defaultBucket: metrics + secureJsonData: + token: ${INFLUXDB_ADMIN_TOKEN} \ No newline at end of file diff --git a/client/internal/metrics/infra/influxdb/scripts/create-tokens.sh b/client/internal/metrics/infra/influxdb/scripts/create-tokens.sh new file mode 100755 index 000000000..2464803e8 --- /dev/null +++ b/client/internal/metrics/infra/influxdb/scripts/create-tokens.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Creates a scoped InfluxDB read-only token for Grafana. +# Clients do not need a token — they push via the ingest server. + +BUCKET_ID=$(influx bucket list --org netbird --name metrics --json | grep -oP '"id"\s*:\s*"\K[^"]+' | head -1) +ORG_ID=$(influx org list --name netbird --json | grep -oP '"id"\s*:\s*"\K[^"]+' | head -1) + +if [[ -z "$BUCKET_ID" ]] || [[ -z "$ORG_ID" ]]; then + echo "ERROR: Could not determine bucket or org ID" >&2 + echo "BUCKET_ID=$BUCKET_ID ORG_ID=$ORG_ID" >&2 + exit 1 +fi + +# Create read-only token for Grafana +READ_TOKEN=$(influx auth create \ + --org netbird \ + --read-bucket "$BUCKET_ID" \ + --description "Grafana read-only token" \ + --json | grep -oP '"token"\s*:\s*"\K[^"]+' | head -1) + +echo "" +echo "============================================" +echo "GRAFANA READ-ONLY TOKEN:" +echo "$READ_TOKEN" +echo "============================================" \ No newline at end of file diff --git a/client/internal/metrics/infra/ingest/Dockerfile b/client/internal/metrics/infra/ingest/Dockerfile new file mode 100644 index 000000000..3620c524b --- /dev/null +++ b/client/internal/metrics/infra/ingest/Dockerfile @@ -0,0 +1,10 @@ +FROM golang:1.25-alpine AS build +WORKDIR /app +COPY go.mod main.go ./ +RUN CGO_ENABLED=0 go build -o ingest . + +FROM alpine:3.20 +RUN adduser -D -H ingest +COPY --from=build /app/ingest /usr/local/bin/ingest +USER ingest +ENTRYPOINT ["ingest"] \ No newline at end of file diff --git a/client/internal/metrics/infra/ingest/go.mod b/client/internal/metrics/infra/ingest/go.mod new file mode 100644 index 000000000..aaf1ea9da --- /dev/null +++ b/client/internal/metrics/infra/ingest/go.mod @@ -0,0 +1,11 @@ +module github.com/netbirdio/netbird/client/internal/metrics/infra/ingest + +go 1.25 + +require github.com/stretchr/testify v1.11.1 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/client/internal/metrics/infra/ingest/go.sum b/client/internal/metrics/infra/ingest/go.sum new file mode 100644 index 000000000..c4c1710c4 --- /dev/null +++ b/client/internal/metrics/infra/ingest/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/client/internal/metrics/infra/ingest/main.go b/client/internal/metrics/infra/ingest/main.go new file mode 100644 index 000000000..91405b85f --- /dev/null +++ b/client/internal/metrics/infra/ingest/main.go @@ -0,0 +1,368 @@ +package main + +import ( + "bytes" + "compress/gzip" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +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 = 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 +) + +type measurementSpec struct { + allowedFields map[string]bool + allowedTags map[string]bool +} + +var allowedMeasurements = map[string]measurementSpec{ + "netbird_peer_connection": { + allowedFields: map[string]bool{ + "signaling_to_connection_seconds": true, + "connection_to_wg_handshake_seconds": true, + "total_seconds": true, + }, + allowedTags: map[string]bool{ + "deployment_type": true, + "connection_type": true, + "attempt_type": true, + "version": true, + "os": true, + "arch": true, + "peer_id": true, + "connection_pair_id": true, + }, + }, + "netbird_sync": { + allowedFields: map[string]bool{ + "duration_seconds": true, + }, + allowedTags: map[string]bool{ + "deployment_type": true, + "version": true, + "os": true, + "arch": true, + "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, + }, + allowedTags: map[string]bool{ + "deployment_type": true, + "result": true, + "version": true, + "os": true, + "arch": true, + "peer_id": true, + }, + }, +} + +func main() { + listenAddr := envOr("INGEST_LISTEN_ADDR", defaultListenAddr) + influxURL := envOr("INFLUXDB_URL", defaultInfluxDBURL) + influxToken := os.Getenv("INFLUXDB_TOKEN") + + if influxToken == "" { + log.Fatal("INFLUXDB_TOKEN is required") + } + + client := &http.Client{Timeout: 10 * time.Second} + + http.HandleFunc("/", handleIngest(client, influxURL, influxToken)) + + // Build config JSON once at startup from env vars + configJSON := buildConfigJSON() + if configJSON != nil { + log.Printf("serving remote config at /config") + } + + http.HandleFunc("/config", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if configJSON == nil { + http.Error(w, "config not configured", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(configJSON) //nolint:errcheck + }) + + http.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "ok") //nolint:errcheck + }) + + log.Printf("ingest server listening on %s, forwarding to %s", listenAddr, influxURL) + if err := http.ListenAndServe(listenAddr, nil); err != nil { //nolint:gosec + log.Fatal(err) + } +} + +func handleIngest(client *http.Client, influxURL, influxToken string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if err := validateAuth(r); err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + body, err := readBody(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if len(body) > maxBodySize { + http.Error(w, "body too large", http.StatusRequestEntityTooLarge) + return + } + + validated, err := validateLineProtocol(body) + if err != nil { + log.Printf("WARN validation failed from %s: %v", r.RemoteAddr, err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + forwardToInflux(w, r, client, influxURL, influxToken, validated) + } +} + +func forwardToInflux(w http.ResponseWriter, r *http.Request, client *http.Client, influxURL, influxToken string, body []byte) { + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, influxURL, bytes.NewReader(body)) + if err != nil { + log.Printf("ERROR create request: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + req.Header.Set("Content-Type", "text/plain; charset=utf-8") + req.Header.Set("Authorization", "Token "+influxToken) + + resp, err := client.Do(req) + if err != nil { + log.Printf("ERROR forward to influxdb: %v", err) + http.Error(w, "upstream error", http.StatusBadGateway) + return + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + + w.WriteHeader(resp.StatusCode) + 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 { + peerID := r.Header.Get("X-Peer-ID") + if peerID == "" { + return fmt.Errorf("missing X-Peer-ID header") + } + if len(peerID) != peerIDLength { + return fmt.Errorf("invalid X-Peer-ID header length") + } + if _, err := hex.DecodeString(peerID); err != nil { + return fmt.Errorf("invalid X-Peer-ID header format") + } + return nil +} + +// readBody reads the request body, decompressing gzip if Content-Encoding indicates it. +func readBody(r *http.Request) ([]byte, error) { + reader := io.LimitReader(r.Body, maxBodySize+1) + + if r.Header.Get("Content-Encoding") == "gzip" { + gz, err := gzip.NewReader(reader) + if err != nil { + return nil, fmt.Errorf("invalid gzip: %w", err) + } + defer gz.Close() + reader = io.LimitReader(gz, maxBodySize+1) + } + + return io.ReadAll(reader) +} + +// validateLineProtocol parses InfluxDB line protocol lines, +// whitelists measurements and fields, and checks value bounds. +func validateLineProtocol(body []byte) ([]byte, error) { + lines := strings.Split(strings.TrimSpace(string(body)), "\n") + var valid []string + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + if err := validateLine(line); err != nil { + return nil, err + } + + valid = append(valid, line) + } + + if len(valid) == 0 { + return nil, fmt.Errorf("no valid lines") + } + + return []byte(strings.Join(valid, "\n") + "\n"), nil +} + +func validateLine(line string) error { + // line protocol: measurement,tag=val,tag=val field=val,field=val timestamp + parts := strings.SplitN(line, " ", 3) + if len(parts) < 2 { + return fmt.Errorf("invalid line protocol: %q", truncate(line, 100)) + } + + // parts[0] is "measurement,tag=val,tag=val" + measurementAndTags := strings.Split(parts[0], ",") + measurement := measurementAndTags[0] + + spec, ok := allowedMeasurements[measurement] + if !ok { + return fmt.Errorf("unknown measurement: %q", measurement) + } + + // Validate tags (everything after measurement name in parts[0]) + for _, tagPair := range measurementAndTags[1:] { + if err := validateTag(tagPair, measurement, spec.allowedTags); err != nil { + return err + } + } + + // Validate fields + for _, pair := range strings.Split(parts[1], ",") { + if err := validateField(pair, measurement, spec.allowedFields); err != nil { + return err + } + } + + return nil +} + +func validateTag(pair, measurement string, allowedTags map[string]bool) error { + kv := strings.SplitN(pair, "=", 2) + if len(kv) != 2 { + return fmt.Errorf("invalid tag: %q", pair) + } + + tagName := kv[0] + if !allowedTags[tagName] { + return fmt.Errorf("unknown tag %q in measurement %q", tagName, measurement) + } + + if len(kv[1]) > maxTagValueLength { + return fmt.Errorf("tag value too long for %q: %d > %d", tagName, len(kv[1]), maxTagValueLength) + } + + return nil +} + +func validateField(pair, measurement string, allowedFields map[string]bool) error { + kv := strings.SplitN(pair, "=", 2) + if len(kv) != 2 { + return fmt.Errorf("invalid field: %q", pair) + } + + fieldName := kv[0] + if !allowedFields[fieldName] { + return fmt.Errorf("unknown field %q in measurement %q", fieldName, measurement) + } + + val, err := strconv.ParseFloat(kv[1], 64) + if err != nil { + return fmt.Errorf("invalid field value %q for %q", kv[1], fieldName) + } + if val < 0 { + return fmt.Errorf("negative value for %q: %g", fieldName, val) + } + if strings.HasSuffix(fieldName, "_seconds") && val > maxDurationSeconds { + return fmt.Errorf("%q too large: %g > %g", fieldName, val, maxDurationSeconds) + } + + return nil +} + +// buildConfigJSON builds the remote config JSON from env vars. +// Returns nil if required vars are not set. +func buildConfigJSON() []byte { + serverURL := os.Getenv("CONFIG_METRICS_SERVER_URL") + versionSince := envOr("CONFIG_VERSION_SINCE", "0.0.0") + versionUntil := envOr("CONFIG_VERSION_UNTIL", "99.99.99") + periodMinutes := envOr("CONFIG_PERIOD_MINUTES", "5") + + if serverURL == "" { + return nil + } + + period, err := strconv.Atoi(periodMinutes) + if err != nil || period <= 0 { + log.Printf("WARN invalid CONFIG_PERIOD_MINUTES: %q, using 5", periodMinutes) + period = 5 + } + + cfg := map[string]any{ + "server_url": serverURL, + "version-since": versionSince, + "version-until": versionUntil, + "period_minutes": period, + } + + data, err := json.Marshal(cfg) + if err != nil { + log.Printf("ERROR failed to marshal config: %v", err) + return nil + } + return data +} + +func envOr(key, defaultVal string) string { + if v := os.Getenv(key); v != "" { + return v + } + return defaultVal +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/client/internal/metrics/infra/ingest/main_test.go b/client/internal/metrics/infra/ingest/main_test.go new file mode 100644 index 000000000..96287813e --- /dev/null +++ b/client/internal/metrics/infra/ingest/main_test.go @@ -0,0 +1,124 @@ +package main + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateLine_ValidPeerConnection(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=abcdef0123456789,connection_pair_id=pair1234 signaling_to_connection_seconds=1.5,connection_to_wg_handshake_seconds=0.5,total_seconds=2 1234567890` + assert.NoError(t, validateLine(line)) +} + +func TestValidateLine_ValidSync(t *testing.T) { + line := `netbird_sync,deployment_type=selfhosted,version=2.0.0,os=darwin,arch=arm64,peer_id=abcdef0123456789 duration_seconds=1.5 1234567890` + assert.NoError(t, validateLine(line)) +} + +func TestValidateLine_ValidLogin(t *testing.T) { + line := `netbird_login,deployment_type=cloud,result=success,version=1.0.0,os=linux,arch=amd64,peer_id=abcdef0123456789 duration_seconds=3.2 1234567890` + assert.NoError(t, validateLine(line)) +} + +func TestValidateLine_UnknownMeasurement(t *testing.T) { + line := `unknown_metric,foo=bar value=1 1234567890` + err := validateLine(line) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown measurement") +} + +func TestValidateLine_UnknownTag(t *testing.T) { + line := `netbird_sync,deployment_type=cloud,evil_tag=injected,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=1.5 1234567890` + err := validateLine(line) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown tag") +} + +func TestValidateLine_UnknownField(t *testing.T) { + line := `netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc injected_field=1 1234567890` + err := validateLine(line) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown field") +} + +func TestValidateLine_NegativeValue(t *testing.T) { + line := `netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=-1.5 1234567890` + err := validateLine(line) + require.Error(t, err) + assert.Contains(t, err.Error(), "negative") +} + +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=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=100000 1234567890` + err := validateLine(line) + require.Error(t, err) + assert.Contains(t, err.Error(), "too large") +} + +func TestValidateLine_TagValueTooLong(t *testing.T) { + longTag := strings.Repeat("a", maxTagValueLength+1) + line := `netbird_sync,deployment_type=` + longTag + `,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=1.5 1234567890` + err := validateLine(line) + require.Error(t, err) + assert.Contains(t, err.Error(), "tag value too long") +} + +func TestValidateLineProtocol_MultipleLines(t *testing.T) { + body := []byte( + "netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=1.5 1234567890\n" + + "netbird_login,deployment_type=cloud,result=success,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=2.0 1234567890\n", + ) + validated, err := validateLineProtocol(body) + require.NoError(t, err) + assert.Contains(t, string(validated), "netbird_sync") + assert.Contains(t, string(validated), "netbird_login") +} + +func TestValidateLineProtocol_RejectsOnBadLine(t *testing.T) { + body := []byte( + "netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=1.5 1234567890\n" + + "evil_metric,foo=bar value=1 1234567890\n", + ) + _, err := validateLineProtocol(body) + require.Error(t, err) +} + +func TestValidateAuth(t *testing.T) { + tests := []struct { + name string + peerID string + wantErr bool + }{ + {"valid hex", "abcdef0123456789", false}, + {"empty", "", true}, + {"too short", "abcdef01234567", true}, + {"too long", "abcdef01234567890", true}, + {"invalid hex", "ghijklmnopqrstuv", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r, _ := http.NewRequest(http.MethodPost, "/", nil) + if tt.peerID != "" { + r.Header.Set("X-Peer-ID", tt.peerID) + } + err := validateAuth(r) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/client/internal/metrics/metrics.go b/client/internal/metrics/metrics.go new file mode 100644 index 000000000..cfe477107 --- /dev/null +++ b/client/internal/metrics/metrics.go @@ -0,0 +1,281 @@ +package metrics + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "sync" + "sync/atomic" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/metrics/remoteconfig" +) + +// AgentInfo holds static information about the agent +type AgentInfo struct { + DeploymentType DeploymentType + Version string + OS string // runtime.GOOS (linux, darwin, windows, etc.) + Arch string // runtime.GOARCH (amd64, arm64, etc.) + peerID string // anonymised peer identifier (SHA-256 of WireGuard public key) +} + +// peerIDFromPublicKey returns a truncated SHA-256 hash (8 bytes / 16 hex chars) of the given WireGuard public key. +func peerIDFromPublicKey(pubKey string) string { + hash := sha256.Sum256([]byte(pubKey)) + return hex.EncodeToString(hash[:8]) +} + +// connectionPairID returns a deterministic identifier for a connection between two peers. +// It sorts the two peer IDs before hashing so the same pair always produces the same ID +// regardless of which side computes it. +func connectionPairID(peerID1, peerID2 string) string { + a, b := peerID1, peerID2 + if a > b { + a, b = b, a + } + hash := sha256.Sum256([]byte(a + b)) + return hex.EncodeToString(hash[:8]) +} + +// metricsImplementation defines the internal interface for metrics implementations +type metricsImplementation interface { + // RecordConnectionStages records connection stage metrics from timestamps + RecordConnectionStages( + ctx context.Context, + agentInfo AgentInfo, + connectionPairID string, + connectionType ConnectionType, + isReconnection bool, + timestamps ConnectionStageTimestamps, + ) + + // 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) + + // Export exports metrics in InfluxDB line protocol format + Export(w io.Writer) error + + // Reset clears all collected metrics + Reset() +} + +type ClientMetrics struct { + impl metricsImplementation + + agentInfo AgentInfo + mu sync.RWMutex + + push atomic.Pointer[Push] + pushMu sync.Mutex + wg sync.WaitGroup + pushCancel context.CancelFunc +} + +// ConnectionStageTimestamps holds timestamps for each connection stage +type ConnectionStageTimestamps struct { + SignalingReceived time.Time // First signal received from remote peer (both initial and reconnection) + ConnectionReady time.Time + WgHandshakeSuccess time.Time +} + +// 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}", + c.SignalingReceived.Format(time.RFC3339Nano), + c.ConnectionReady.Format(time.RFC3339Nano), + c.WgHandshakeSuccess.Format(time.RFC3339Nano), + ) +} + +// RecordConnectionStages calculates stage durations from timestamps and records them. +// remotePubKey is the remote peer's WireGuard public key; it will be hashed for anonymisation. +func (c *ClientMetrics) RecordConnectionStages( + ctx context.Context, + remotePubKey string, + connectionType ConnectionType, + isReconnection bool, + timestamps ConnectionStageTimestamps, +) { + if c == nil { + return + } + c.mu.RLock() + agentInfo := c.agentInfo + c.mu.RUnlock() + + remotePeerID := peerIDFromPublicKey(remotePubKey) + pairID := connectionPairID(agentInfo.peerID, remotePeerID) + c.impl.RecordConnectionStages(ctx, agentInfo, pairID, connectionType, isReconnection, timestamps) +} + +// RecordSyncDuration records the duration of sync message processing +func (c *ClientMetrics) RecordSyncDuration(ctx context.Context, duration time.Duration) { + if c == nil { + return + } + c.mu.RLock() + agentInfo := c.agentInfo + c.mu.RUnlock() + + 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 { + return + } + c.mu.RLock() + agentInfo := c.agentInfo + c.mu.RUnlock() + + c.impl.RecordLoginDuration(ctx, agentInfo, duration, success) +} + +// UpdateAgentInfo updates the agent information (e.g., when switching profiles). +// publicKey is the WireGuard public key; it will be hashed for anonymisation. +func (c *ClientMetrics) UpdateAgentInfo(agentInfo AgentInfo, publicKey string) { + if c == nil { + return + } + + agentInfo.peerID = peerIDFromPublicKey(publicKey) + + c.mu.Lock() + c.agentInfo = agentInfo + c.mu.Unlock() + + if push := c.push.Load(); push != nil { + push.SetPeerID(agentInfo.peerID) + } +} + +// Export exports metrics to the writer +func (c *ClientMetrics) Export(w io.Writer) error { + if c == nil { + return nil + } + + return c.impl.Export(w) +} + +// 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 { + return + } + + c.pushMu.Lock() + defer c.pushMu.Unlock() + + 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 + c.mu.RUnlock() + + configManager := remoteconfig.NewManager(getMetricsConfigURL(), remoteconfig.DefaultMinRefreshInterval) + push, err := NewPush(c.impl, configManager, config, agentVersion) + if err != nil { + log.Errorf("failed to create metrics push: %v", err) + return + } + push.SetPeerID(peerID) + + 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) + }() +} + +// stopPushLocked stops push. Caller must hold pushMu. +func (c *ClientMetrics) stopPushLocked() { + if c.push.Load() == nil { + return + } + + c.pushCancel() + c.wg.Wait() + c.push.Store(nil) +} diff --git a/client/internal/metrics/metrics_default.go b/client/internal/metrics/metrics_default.go new file mode 100644 index 000000000..927ab51d1 --- /dev/null +++ b/client/internal/metrics/metrics_default.go @@ -0,0 +1,11 @@ +//go:build !js + +package metrics + +// NewClientMetrics creates a new ClientMetrics instance +func NewClientMetrics(agentInfo AgentInfo) *ClientMetrics { + return &ClientMetrics{ + impl: newInfluxDBMetrics(), + agentInfo: agentInfo, + } +} diff --git a/client/internal/metrics/metrics_js.go b/client/internal/metrics/metrics_js.go new file mode 100644 index 000000000..dfa6d8243 --- /dev/null +++ b/client/internal/metrics/metrics_js.go @@ -0,0 +1,8 @@ +//go:build js + +package metrics + +// NewClientMetrics returns nil on WASM builds — all ClientMetrics methods are nil-safe. +func NewClientMetrics(AgentInfo) *ClientMetrics { + return nil +} diff --git a/client/internal/metrics/push.go b/client/internal/metrics/push.go new file mode 100644 index 000000000..ee0508f36 --- /dev/null +++ b/client/internal/metrics/push.go @@ -0,0 +1,289 @@ +package metrics + +import ( + "bytes" + "compress/gzip" + "context" + "fmt" + "net/http" + "net/url" + "sync" + "time" + + goversion "github.com/hashicorp/go-version" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/metrics/remoteconfig" +) + +const ( + // defaultPushInterval is the default interval for pushing metrics + defaultPushInterval = 5 * time.Minute +) + +// defaultMetricsServerURL is used as fallback when NB_METRICS_FORCE_SENDING is true +var defaultMetricsServerURL *url.URL + +func init() { + defaultMetricsServerURL, _ = url.Parse("https://ingest.netbird.io") +} + +// PushConfig holds configuration for metrics push +type PushConfig struct { + // ServerAddress is the metrics server URL. If nil, uses remote config server_url. + ServerAddress *url.URL + // Interval is how often to push metrics. If 0, uses remote config interval or defaultPushInterval. + Interval time.Duration + // ForceSending skips remote configuration fetch and version checks, pushing unconditionally. + ForceSending bool +} + +// PushConfigFromEnv builds a PushConfig from environment variables. +func PushConfigFromEnv() PushConfig { + config := PushConfig{} + + config.ForceSending = isForceSending() + config.ServerAddress = getMetricsServerURL() + config.Interval = getMetricsInterval() + + return config +} + +// remoteConfigProvider abstracts remote push config fetching for testability +type remoteConfigProvider interface { + RefreshIfNeeded(ctx context.Context) *remoteconfig.Config +} + +// Push handles periodic pushing of metrics +type Push struct { + metrics metricsImplementation + configManager remoteConfigProvider + agentVersion *goversion.Version + + peerID string + peerMu sync.RWMutex + + client *http.Client + cfgForceSending bool + cfgInterval time.Duration + cfgAddress *url.URL +} + +// NewPush creates a new Push instance with configuration resolution +func NewPush(metrics metricsImplementation, configManager remoteConfigProvider, config PushConfig, agentVersion string) (*Push, error) { + var cfgInterval time.Duration + var cfgAddress *url.URL + + if config.ForceSending { + cfgInterval = config.Interval + if config.Interval <= 0 { + cfgInterval = defaultPushInterval + } + + cfgAddress = config.ServerAddress + if cfgAddress == nil { + cfgAddress = defaultMetricsServerURL + } + } else { + cfgAddress = config.ServerAddress + + if config.Interval < 0 { + log.Warnf("negative metrics push interval %s", config.Interval) + } else { + cfgInterval = config.Interval + } + } + + parsedVersion, err := goversion.NewVersion(agentVersion) + if err != nil { + if !config.ForceSending { + return nil, fmt.Errorf("parse agent version %q: %w", agentVersion, err) + } + } + + return &Push{ + metrics: metrics, + configManager: configManager, + agentVersion: parsedVersion, + cfgForceSending: config.ForceSending, + cfgInterval: cfgInterval, + cfgAddress: cfgAddress, + client: &http.Client{ + Timeout: 10 * time.Second, + }, + }, nil +} + +// SetPeerID updates the hashed peer ID used for the Authorization header. +func (p *Push) SetPeerID(peerID string) { + p.peerMu.Lock() + p.peerID = peerID + p.peerMu.Unlock() +} + +// Start starts the periodic push loop. +// The env interval override controls tick frequency but does not bypass remote config +// version gating. Use ForceSending to skip remote config entirely. +func (p *Push) Start(ctx context.Context) { + // Log initial state + switch { + case p.cfgForceSending: + log.Infof("started metrics push with force sending to %s, interval %s", p.cfgAddress, p.cfgInterval) + case p.cfgAddress != nil: + log.Infof("started metrics push with server URL override: %s", p.cfgAddress.String()) + default: + log.Infof("started metrics push, server URL will be resolved from remote config") + } + + timer := time.NewTimer(0) // fire immediately on first iteration + defer timer.Stop() + + for { + select { + case <-ctx.Done(): + log.Debug("stopping metrics push") + return + case <-timer.C: + } + + pushURL, interval := p.resolve(ctx) + if pushURL != "" { + if err := p.push(ctx, pushURL); err != nil { + log.Errorf("failed to push metrics: %v", err) + } + } + + if interval <= 0 { + interval = defaultPushInterval + } + timer.Reset(interval) + } +} + +// resolve returns the push URL and interval for the next cycle. +// Returns empty pushURL to skip this cycle. +func (p *Push) resolve(ctx context.Context) (pushURL string, interval time.Duration) { + if p.cfgForceSending { + return p.resolveServerURL(nil), p.cfgInterval + } + + config := p.configManager.RefreshIfNeeded(ctx) + if config == nil { + log.Debug("no metrics push config available, waiting to retry") + return "", defaultPushInterval + } + + // prefer env variables instead of remote config + if p.cfgInterval > 0 { + interval = p.cfgInterval + } else { + interval = config.Interval + } + + if !isVersionInRange(p.agentVersion, config.VersionSince, config.VersionUntil) { + log.Debugf("agent version %s not in range [%s, %s), skipping metrics push", + p.agentVersion, config.VersionSince, config.VersionUntil) + return "", interval + } + + pushURL = p.resolveServerURL(&config.ServerURL) + if pushURL == "" { + log.Warn("no metrics server URL available, skipping push") + } + return pushURL, interval +} + +// push exports metrics and sends them to the metrics server +func (p *Push) push(ctx context.Context, pushURL string) error { + // Export metrics without clearing + var buf bytes.Buffer + if err := p.metrics.Export(&buf); err != nil { + return fmt.Errorf("export metrics: %w", err) + } + + // Don't push if there are no metrics + if buf.Len() == 0 { + log.Tracef("no metrics to push") + return nil + } + + // Gzip compress the body + compressed, err := gzipCompress(buf.Bytes()) + if err != nil { + return fmt.Errorf("gzip compress: %w", err) + } + + // Create HTTP request + req, err := http.NewRequestWithContext(ctx, "POST", pushURL, compressed) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "text/plain; charset=utf-8") + req.Header.Set("Content-Encoding", "gzip") + + p.peerMu.RLock() + peerID := p.peerID + p.peerMu.RUnlock() + if peerID != "" { + req.Header.Set("X-Peer-ID", peerID) + } + + // Send request + resp, err := p.client.Do(req) + if err != nil { + return fmt.Errorf("send request: %w", err) + } + defer func() { + if resp.Body == nil { + return + } + if err := resp.Body.Close(); err != nil { + log.Warnf("failed to close response body: %v", err) + } + }() + + // Check response status + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("push failed with status %d", resp.StatusCode) + } + + log.Debugf("successfully pushed metrics to %s", pushURL) + p.metrics.Reset() + return nil +} + +// resolveServerURL determines the push URL. +// Precedence: envAddress (env var) > remote config server_url +func (p *Push) resolveServerURL(remoteServerURL *url.URL) string { + var baseURL *url.URL + if p.cfgAddress != nil { + baseURL = p.cfgAddress + } else { + baseURL = remoteServerURL + } + + if baseURL == nil { + return "" + } + + return baseURL.String() +} + +// gzipCompress compresses data using gzip and returns the compressed buffer. +func gzipCompress(data []byte) (*bytes.Buffer, error) { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + if _, err := gz.Write(data); err != nil { + _ = gz.Close() + return nil, err + } + if err := gz.Close(); err != nil { + return nil, err + } + return &buf, nil +} + +// isVersionInRange checks if current falls within [since, until) +func isVersionInRange(current, since, until *goversion.Version) bool { + return !current.LessThan(since) && current.LessThan(until) +} diff --git a/client/internal/metrics/push_test.go b/client/internal/metrics/push_test.go new file mode 100644 index 000000000..43c1b2c06 --- /dev/null +++ b/client/internal/metrics/push_test.go @@ -0,0 +1,346 @@ +package metrics + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "sync/atomic" + "testing" + "time" + + goversion "github.com/hashicorp/go-version" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/metrics/remoteconfig" +) + +func mustVersion(s string) *goversion.Version { + v, err := goversion.NewVersion(s) + if err != nil { + panic(err) + } + return v +} + +func mustURL(s string) url.URL { + u, err := url.Parse(s) + if err != nil { + panic(err) + } + return *u +} + +func parseURL(s string) *url.URL { + u, err := url.Parse(s) + if err != nil { + panic(err) + } + return u +} + +func testConfig(serverURL, since, until string, period time.Duration) *remoteconfig.Config { + return &remoteconfig.Config{ + ServerURL: mustURL(serverURL), + VersionSince: mustVersion(since), + VersionUntil: mustVersion(until), + Interval: period, + } +} + +// mockConfigProvider implements remoteConfigProvider for testing +type mockConfigProvider struct { + config *remoteconfig.Config +} + +func (m *mockConfigProvider) RefreshIfNeeded(_ context.Context) *remoteconfig.Config { + return m.config +} + +// mockMetrics implements metricsImplementation for testing +type mockMetrics struct { + exportData string +} + +func (m *mockMetrics) RecordConnectionStages(_ context.Context, _ AgentInfo, _ string, _ ConnectionType, _ bool, _ ConnectionStageTimestamps) { +} + +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) { +} + +func (m *mockMetrics) Export(w io.Writer) error { + if m.exportData != "" { + _, err := w.Write([]byte(m.exportData)) + return err + } + return nil +} + +func (m *mockMetrics) Reset() { +} + +func TestPush_OverrideIntervalPushes(t *testing.T) { + var pushCount atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pushCount.Add(1) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + metrics := &mockMetrics{exportData: "test_metric 1\n"} + configProvider := &mockConfigProvider{config: testConfig(server.URL, "1.0.0", "2.0.0", 60*time.Minute)} + + push, err := NewPush(metrics, configProvider, PushConfig{ + Interval: 50 * time.Millisecond, + ServerAddress: parseURL(server.URL), + }, "1.0.0") + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + push.Start(ctx) + close(done) + }() + + require.Eventually(t, func() bool { + return pushCount.Load() >= 3 + }, 2*time.Second, 10*time.Millisecond) + + cancel() + <-done +} + +func TestPush_RemoteConfigVersionInRange(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + metrics := &mockMetrics{exportData: "test_metric 1\n"} + configProvider := &mockConfigProvider{config: testConfig(server.URL, "1.0.0", "2.0.0", 1*time.Minute)} + + push, err := NewPush(metrics, configProvider, PushConfig{}, "1.5.0") + require.NoError(t, err) + + pushURL, interval := push.resolve(context.Background()) + assert.NotEmpty(t, pushURL) + assert.Equal(t, 1*time.Minute, interval) +} + +func TestPush_RemoteConfigVersionOutOfRange(t *testing.T) { + metrics := &mockMetrics{exportData: "test_metric 1\n"} + configProvider := &mockConfigProvider{config: testConfig("http://localhost", "1.0.0", "1.5.0", 1*time.Minute)} + + push, err := NewPush(metrics, configProvider, PushConfig{}, "2.0.0") + require.NoError(t, err) + + pushURL, interval := push.resolve(context.Background()) + assert.Empty(t, pushURL) + assert.Equal(t, 1*time.Minute, interval) +} + +func TestPush_NoConfigReturnsDefault(t *testing.T) { + metrics := &mockMetrics{} + configProvider := &mockConfigProvider{config: nil} + + push, err := NewPush(metrics, configProvider, PushConfig{}, "1.0.0") + require.NoError(t, err) + + pushURL, interval := push.resolve(context.Background()) + assert.Empty(t, pushURL) + assert.Equal(t, defaultPushInterval, interval) +} + +func TestPush_OverrideIntervalRespectsVersionCheck(t *testing.T) { + metrics := &mockMetrics{} + configProvider := &mockConfigProvider{config: testConfig("http://localhost", "3.0.0", "4.0.0", 60*time.Minute)} + + push, err := NewPush(metrics, configProvider, PushConfig{ + Interval: 30 * time.Second, + ServerAddress: parseURL("http://localhost"), + }, "1.0.0") + require.NoError(t, err) + + pushURL, interval := push.resolve(context.Background()) + assert.Empty(t, pushURL) // version out of range + assert.Equal(t, 30*time.Second, interval) // but uses override interval +} + +func TestPush_OverrideIntervalUsedWhenVersionInRange(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + metrics := &mockMetrics{} + configProvider := &mockConfigProvider{config: testConfig(server.URL, "1.0.0", "2.0.0", 60*time.Minute)} + + push, err := NewPush(metrics, configProvider, PushConfig{ + Interval: 30 * time.Second, + }, "1.5.0") + require.NoError(t, err) + + pushURL, interval := push.resolve(context.Background()) + assert.NotEmpty(t, pushURL) + assert.Equal(t, 30*time.Second, interval) +} + +func TestPush_NoMetricsSkipsPush(t *testing.T) { + var pushCount atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pushCount.Add(1) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + metrics := &mockMetrics{exportData: ""} // no metrics to export + configProvider := &mockConfigProvider{config: nil} + + push, err := NewPush(metrics, configProvider, PushConfig{}, "1.0.0") + require.NoError(t, err) + + err = push.push(context.Background(), server.URL) + assert.NoError(t, err) + assert.Equal(t, int32(0), pushCount.Load()) +} + +func TestPush_ServerURLFromRemoteConfig(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + metrics := &mockMetrics{exportData: "test_metric 1\n"} + configProvider := &mockConfigProvider{config: testConfig(server.URL, "1.0.0", "2.0.0", 1*time.Minute)} + + push, err := NewPush(metrics, configProvider, PushConfig{}, "1.5.0") + require.NoError(t, err) + + pushURL, interval := push.resolve(context.Background()) + assert.Contains(t, pushURL, server.URL) + assert.Equal(t, 1*time.Minute, interval) +} + +func TestPush_ServerAddressOverridesTakePrecedenceOverRemoteConfig(t *testing.T) { + overrideServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer overrideServer.Close() + + metrics := &mockMetrics{exportData: "test_metric 1\n"} + configProvider := &mockConfigProvider{config: testConfig("http://remote-config-server", "1.0.0", "2.0.0", 1*time.Minute)} + + push, err := NewPush(metrics, configProvider, PushConfig{ + ServerAddress: parseURL(overrideServer.URL), + }, "1.5.0") + require.NoError(t, err) + + pushURL, _ := push.resolve(context.Background()) + assert.Contains(t, pushURL, overrideServer.URL) + assert.NotContains(t, pushURL, "remote-config-server") +} + +func TestPush_OverrideIntervalWithoutOverrideURL_UsesRemoteConfigURL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + metrics := &mockMetrics{exportData: "test_metric 1\n"} + configProvider := &mockConfigProvider{config: testConfig(server.URL, "1.0.0", "2.0.0", 60*time.Minute)} + + push, err := NewPush(metrics, configProvider, PushConfig{ + Interval: 30 * time.Second, + }, "1.0.0") + require.NoError(t, err) + + pushURL, interval := push.resolve(context.Background()) + assert.Contains(t, pushURL, server.URL) + assert.Equal(t, 30*time.Second, interval) +} + +func TestPush_NoConfigSkipsPush(t *testing.T) { + metrics := &mockMetrics{exportData: "test_metric 1\n"} + configProvider := &mockConfigProvider{config: nil} + + push, err := NewPush(metrics, configProvider, PushConfig{ + Interval: 30 * time.Second, + }, "1.0.0") + require.NoError(t, err) + + pushURL, interval := push.resolve(context.Background()) + assert.Empty(t, pushURL) + assert.Equal(t, defaultPushInterval, interval) // no config available, use default retry interval +} + +func TestPush_ForceSendingSkipsRemoteConfig(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + metrics := &mockMetrics{exportData: "test_metric 1\n"} + configProvider := &mockConfigProvider{config: nil} + + push, err := NewPush(metrics, configProvider, PushConfig{ + ForceSending: true, + Interval: 1 * time.Minute, + ServerAddress: parseURL(server.URL), + }, "1.0.0") + require.NoError(t, err) + + pushURL, interval := push.resolve(context.Background()) + assert.NotEmpty(t, pushURL) + assert.Equal(t, 1*time.Minute, interval) +} + +func TestPush_ForceSendingUsesDefaultInterval(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + metrics := &mockMetrics{exportData: "test_metric 1\n"} + configProvider := &mockConfigProvider{config: nil} + + push, err := NewPush(metrics, configProvider, PushConfig{ + ForceSending: true, + ServerAddress: parseURL(server.URL), + }, "1.0.0") + require.NoError(t, err) + + pushURL, interval := push.resolve(context.Background()) + assert.NotEmpty(t, pushURL) + assert.Equal(t, defaultPushInterval, interval) +} + +func TestIsVersionInRange(t *testing.T) { + tests := []struct { + name string + current string + since string + until string + expected bool + }{ + {"at lower bound inclusive", "1.2.2", "1.2.2", "1.2.3", true}, + {"in range", "1.2.2", "1.2.0", "1.3.0", true}, + {"at upper bound exclusive", "1.2.3", "1.2.2", "1.2.3", false}, + {"below range", "1.2.1", "1.2.2", "1.2.3", false}, + {"above range", "1.3.0", "1.2.2", "1.2.3", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, isVersionInRange(mustVersion(tt.current), mustVersion(tt.since), mustVersion(tt.until))) + }) + } +} diff --git a/client/internal/metrics/remoteconfig/manager.go b/client/internal/metrics/remoteconfig/manager.go new file mode 100644 index 000000000..01c37891f --- /dev/null +++ b/client/internal/metrics/remoteconfig/manager.go @@ -0,0 +1,149 @@ +package remoteconfig + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sync" + "time" + + goversion "github.com/hashicorp/go-version" + log "github.com/sirupsen/logrus" +) + +const ( + DefaultMinRefreshInterval = 30 * time.Minute +) + +// Config holds the parsed remote push configuration +type Config struct { + ServerURL url.URL + VersionSince *goversion.Version + VersionUntil *goversion.Version + Interval time.Duration +} + +// rawConfig is the JSON wire format fetched from the remote server +type rawConfig struct { + ServerURL string `json:"server_url"` + VersionSince string `json:"version-since"` + VersionUntil string `json:"version-until"` + PeriodMinutes int `json:"period_minutes"` +} + +// Manager handles fetching and caching remote push configuration +type Manager struct { + configURL string + minRefreshInterval time.Duration + client *http.Client + + mu sync.Mutex + lastConfig *Config + lastFetched time.Time +} + +func NewManager(configURL string, minRefreshInterval time.Duration) *Manager { + return &Manager{ + configURL: configURL, + minRefreshInterval: minRefreshInterval, + client: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// RefreshIfNeeded fetches new config if the cached one is stale. +// Returns the current config (possibly just fetched) or nil if unavailable. +func (m *Manager) RefreshIfNeeded(ctx context.Context) *Config { + m.mu.Lock() + defer m.mu.Unlock() + + if m.isConfigFresh() { + return m.lastConfig + } + + fetchedConfig, err := m.fetch(ctx) + m.lastFetched = time.Now() + if err != nil { + log.Warnf("failed to fetch metrics remote config: %v", err) + return m.lastConfig // return cached (may be nil) + } + + m.lastConfig = fetchedConfig + + log.Tracef("fetched metrics remote config: version-since=%s version-until=%s period=%s", + fetchedConfig.VersionSince, fetchedConfig.VersionUntil, fetchedConfig.Interval) + + return fetchedConfig +} + +func (m *Manager) isConfigFresh() bool { + if m.lastConfig == nil { + return false + } + return time.Since(m.lastFetched) < m.minRefreshInterval +} + +func (m *Manager) fetch(ctx context.Context) (*Config, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.configURL, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + resp, err := m.client.Do(req) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer func() { + if resp.Body != nil { + _ = resp.Body.Close() + } + }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + + var raw rawConfig + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + if raw.PeriodMinutes <= 0 { + return nil, fmt.Errorf("invalid period_minutes: %d", raw.PeriodMinutes) + } + + if raw.ServerURL == "" { + return nil, fmt.Errorf("server_url is required") + } + + serverURL, err := url.Parse(raw.ServerURL) + if err != nil { + return nil, fmt.Errorf("parse server_url %q: %w", raw.ServerURL, err) + } + + since, err := goversion.NewVersion(raw.VersionSince) + if err != nil { + return nil, fmt.Errorf("parse version-since %q: %w", raw.VersionSince, err) + } + + until, err := goversion.NewVersion(raw.VersionUntil) + if err != nil { + return nil, fmt.Errorf("parse version-until %q: %w", raw.VersionUntil, err) + } + + return &Config{ + ServerURL: *serverURL, + VersionSince: since, + VersionUntil: until, + Interval: time.Duration(raw.PeriodMinutes) * time.Minute, + }, nil +} diff --git a/client/internal/metrics/remoteconfig/manager_test.go b/client/internal/metrics/remoteconfig/manager_test.go new file mode 100644 index 000000000..68ca3b4c4 --- /dev/null +++ b/client/internal/metrics/remoteconfig/manager_test.go @@ -0,0 +1,197 @@ +package remoteconfig + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testMinRefresh = 100 * time.Millisecond + +func TestManager_FetchSuccess(t *testing.T) { + server := newConfigServer(t, rawConfig{ + ServerURL: "https://ingest.example.com", + VersionSince: "1.0.0", + VersionUntil: "2.0.0", + PeriodMinutes: 60, + }) + defer server.Close() + + mgr := NewManager(server.URL, testMinRefresh) + config := mgr.RefreshIfNeeded(context.Background()) + + require.NotNil(t, config) + assert.Equal(t, "https://ingest.example.com", config.ServerURL.String()) + assert.Equal(t, "1.0.0", config.VersionSince.String()) + assert.Equal(t, "2.0.0", config.VersionUntil.String()) + assert.Equal(t, 60*time.Minute, config.Interval) +} + +func TestManager_CachesConfig(t *testing.T) { + var fetchCount atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fetchCount.Add(1) + err := json.NewEncoder(w).Encode(rawConfig{ + ServerURL: "https://ingest.example.com", + VersionSince: "1.0.0", + VersionUntil: "2.0.0", + PeriodMinutes: 60, + }) + require.NoError(t, err) + })) + defer server.Close() + + mgr := NewManager(server.URL, testMinRefresh) + + // First call fetches + config1 := mgr.RefreshIfNeeded(context.Background()) + require.NotNil(t, config1) + assert.Equal(t, int32(1), fetchCount.Load()) + + // Second call uses cache (within minRefreshInterval) + config2 := mgr.RefreshIfNeeded(context.Background()) + require.NotNil(t, config2) + assert.Equal(t, int32(1), fetchCount.Load()) + assert.Equal(t, config1, config2) +} + +func TestManager_RefetchesWhenStale(t *testing.T) { + var fetchCount atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fetchCount.Add(1) + err := json.NewEncoder(w).Encode(rawConfig{ + ServerURL: "https://ingest.example.com", + VersionSince: "1.0.0", + VersionUntil: "2.0.0", + PeriodMinutes: 60, + }) + require.NoError(t, err) + })) + defer server.Close() + + mgr := NewManager(server.URL, testMinRefresh) + + // First fetch + mgr.RefreshIfNeeded(context.Background()) + assert.Equal(t, int32(1), fetchCount.Load()) + + // Wait for config to become stale + time.Sleep(testMinRefresh + 10*time.Millisecond) + + // Should refetch + mgr.RefreshIfNeeded(context.Background()) + assert.Equal(t, int32(2), fetchCount.Load()) +} + +func TestManager_FetchFailureReturnsNil(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + mgr := NewManager(server.URL, testMinRefresh) + config := mgr.RefreshIfNeeded(context.Background()) + + assert.Nil(t, config) +} + +func TestManager_FetchFailureReturnsCached(t *testing.T) { + var fetchCount atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fetchCount.Add(1) + if fetchCount.Load() > 1 { + w.WriteHeader(http.StatusInternalServerError) + return + } + err := json.NewEncoder(w).Encode(rawConfig{ + ServerURL: "https://ingest.example.com", + VersionSince: "1.0.0", + VersionUntil: "2.0.0", + PeriodMinutes: 60, + }) + require.NoError(t, err) + })) + defer server.Close() + + mgr := NewManager(server.URL, testMinRefresh) + + // First call succeeds + config1 := mgr.RefreshIfNeeded(context.Background()) + require.NotNil(t, config1) + + // Wait for config to become stale + time.Sleep(testMinRefresh + 10*time.Millisecond) + + // Second call fails but returns cached + config2 := mgr.RefreshIfNeeded(context.Background()) + require.NotNil(t, config2) + assert.Equal(t, config1, config2) +} + +func TestManager_RejectsInvalidPeriod(t *testing.T) { + tests := []struct { + name string + period int + }{ + {"zero", 0}, + {"negative", -5}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := newConfigServer(t, rawConfig{ + ServerURL: "https://ingest.example.com", + VersionSince: "1.0.0", + VersionUntil: "2.0.0", + PeriodMinutes: tt.period, + }) + defer server.Close() + + mgr := NewManager(server.URL, testMinRefresh) + config := mgr.RefreshIfNeeded(context.Background()) + assert.Nil(t, config) + }) + } +} + +func TestManager_RejectsEmptyServerURL(t *testing.T) { + server := newConfigServer(t, rawConfig{ + ServerURL: "", + VersionSince: "1.0.0", + VersionUntil: "2.0.0", + PeriodMinutes: 60, + }) + defer server.Close() + + mgr := NewManager(server.URL, testMinRefresh) + config := mgr.RefreshIfNeeded(context.Background()) + assert.Nil(t, config) +} + +func TestManager_RejectsInvalidJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte("not json")) + require.NoError(t, err) + })) + defer server.Close() + + mgr := NewManager(server.URL, testMinRefresh) + config := mgr.RefreshIfNeeded(context.Background()) + assert.Nil(t, config) +} + +func newConfigServer(t *testing.T, config rawConfig) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(config) + require.NoError(t, err) + })) +} diff --git a/client/internal/mobile_dependency.go b/client/internal/mobile_dependency.go index 7c95e2b99..310d61a25 100644 --- a/client/internal/mobile_dependency.go +++ b/client/internal/mobile_dependency.go @@ -22,4 +22,8 @@ type MobileDependency struct { DnsManager dns.IosDnsManager FileDescriptor int32 StateFilePath string + + // TempDir is a writable directory for temporary files (e.g., debug bundle zip). + // On Android, this should be set to the app's cache directory. + TempDir string } diff --git a/client/internal/netflow/conntrack/conntrack.go b/client/internal/netflow/conntrack/conntrack.go index a4ffa3a25..6f1da5138 100644 --- a/client/internal/netflow/conntrack/conntrack.go +++ b/client/internal/netflow/conntrack/conntrack.go @@ -7,7 +7,9 @@ import ( "fmt" "net/netip" "sync" + "time" + "github.com/cenkalti/backoff/v4" "github.com/google/uuid" log "github.com/sirupsen/logrus" nfct "github.com/ti-mo/conntrack" @@ -17,31 +19,64 @@ import ( nbnet "github.com/netbirdio/netbird/client/net" ) -const defaultChannelSize = 100 +const ( + defaultChannelSize = 100 + reconnectInitInterval = 5 * time.Second + reconnectMaxInterval = 5 * time.Minute + reconnectRandomization = 0.5 +) + +// listener abstracts a netlink conntrack connection for testability. +type listener interface { + Listen(evChan chan<- nfct.Event, numWorkers uint8, groups []netfilter.NetlinkGroup) (chan error, error) + Close() error +} // ConnTrack manages kernel-based conntrack events type ConnTrack struct { flowLogger nftypes.FlowLogger iface nftypes.IFaceMapper - conn *nfct.Conn + conn listener mux sync.Mutex + dial func() (listener, error) instanceID uuid.UUID started bool done chan struct{} sysctlModified bool } +// DialFunc is a constructor for netlink conntrack connections. +type DialFunc func() (listener, error) + +// Option configures a ConnTrack instance. +type Option func(*ConnTrack) + +// WithDialer overrides the default netlink dialer, primarily for testing. +func WithDialer(dial DialFunc) Option { + return func(c *ConnTrack) { + c.dial = dial + } +} + +func defaultDial() (listener, error) { + return nfct.Dial(nil) +} + // New creates a new connection tracker that interfaces with the kernel's conntrack system -func New(flowLogger nftypes.FlowLogger, iface nftypes.IFaceMapper) *ConnTrack { - return &ConnTrack{ +func New(flowLogger nftypes.FlowLogger, iface nftypes.IFaceMapper, opts ...Option) *ConnTrack { + ct := &ConnTrack{ flowLogger: flowLogger, iface: iface, instanceID: uuid.New(), - started: false, + dial: defaultDial, done: make(chan struct{}, 1), } + for _, opt := range opts { + opt(ct) + } + return ct } // Start begins tracking connections by listening for conntrack events. This method is idempotent. @@ -59,8 +94,9 @@ func (c *ConnTrack) Start(enableCounters bool) error { c.EnableAccounting() } - conn, err := nfct.Dial(nil) + conn, err := c.dial() if err != nil { + c.RestoreAccounting() return fmt.Errorf("dial conntrack: %w", err) } c.conn = conn @@ -76,9 +112,16 @@ func (c *ConnTrack) Start(enableCounters bool) error { log.Errorf("Error closing conntrack connection: %v", err) } c.conn = nil + c.RestoreAccounting() return fmt.Errorf("start conntrack listener: %w", err) } + // Drain any stale stop signal from a previous cycle. + select { + case <-c.done: + default: + } + c.started = true go c.receiverRoutine(events, errChan) @@ -92,17 +135,98 @@ func (c *ConnTrack) receiverRoutine(events chan nfct.Event, errChan chan error) case event := <-events: c.handleEvent(event) case err := <-errChan: - log.Errorf("Error from conntrack event listener: %v", err) - if err := c.conn.Close(); err != nil { - log.Errorf("Error closing conntrack connection: %v", err) + if events, errChan = c.handleListenerError(err); events == nil { + return } - return case <-c.done: return } } } +// handleListenerError closes the failed connection and attempts to reconnect. +// Returns new channels on success, or nil if shutdown was requested. +func (c *ConnTrack) handleListenerError(err error) (chan nfct.Event, chan error) { + log.Warnf("conntrack event listener failed: %v", err) + c.closeConn() + return c.reconnect() +} + +func (c *ConnTrack) closeConn() { + c.mux.Lock() + defer c.mux.Unlock() + + if c.conn != nil { + if err := c.conn.Close(); err != nil { + log.Debugf("close conntrack connection: %v", err) + } + c.conn = nil + } +} + +// reconnect attempts to re-establish the conntrack netlink listener with exponential backoff. +// Returns new channels on success, or nil if shutdown was requested. +func (c *ConnTrack) reconnect() (chan nfct.Event, chan error) { + bo := &backoff.ExponentialBackOff{ + InitialInterval: reconnectInitInterval, + RandomizationFactor: reconnectRandomization, + Multiplier: backoff.DefaultMultiplier, + MaxInterval: reconnectMaxInterval, + MaxElapsedTime: 0, // retry indefinitely + Clock: backoff.SystemClock, + } + bo.Reset() + + for { + delay := bo.NextBackOff() + log.Infof("reconnecting conntrack listener in %s", delay) + + select { + case <-c.done: + c.mux.Lock() + c.started = false + c.mux.Unlock() + return nil, nil + case <-time.After(delay): + } + + conn, err := c.dial() + if err != nil { + log.Warnf("reconnect conntrack dial: %v", err) + continue + } + + events := make(chan nfct.Event, defaultChannelSize) + errChan, err := conn.Listen(events, 1, []netfilter.NetlinkGroup{ + netfilter.GroupCTNew, + netfilter.GroupCTDestroy, + }) + if err != nil { + log.Warnf("reconnect conntrack listen: %v", err) + if closeErr := conn.Close(); closeErr != nil { + log.Debugf("close conntrack connection: %v", closeErr) + } + continue + } + + c.mux.Lock() + if !c.started { + // Stop() ran while we were reconnecting. + c.mux.Unlock() + if closeErr := conn.Close(); closeErr != nil { + log.Debugf("close conntrack connection: %v", closeErr) + } + return nil, nil + } + c.conn = conn + c.mux.Unlock() + + log.Infof("conntrack listener reconnected successfully") + + return events, errChan + } +} + // Stop stops the connection tracking. This method is idempotent. func (c *ConnTrack) Stop() { c.mux.Lock() @@ -136,23 +260,27 @@ func (c *ConnTrack) Close() error { c.mux.Lock() defer c.mux.Unlock() - if c.started { - select { - case c.done <- struct{}{}: - default: - } + if !c.started { + return nil } + select { + case c.done <- struct{}{}: + default: + } + + c.started = false + + var closeErr error if c.conn != nil { - err := c.conn.Close() + closeErr = c.conn.Close() c.conn = nil - c.started = false + } - c.RestoreAccounting() + c.RestoreAccounting() - if err != nil { - return fmt.Errorf("close conntrack: %w", err) - } + if closeErr != nil { + return fmt.Errorf("close conntrack: %w", closeErr) } return nil @@ -188,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 } @@ -231,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 @@ -291,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/conntrack/conntrack_test.go b/client/internal/netflow/conntrack/conntrack_test.go new file mode 100644 index 000000000..35ceec90d --- /dev/null +++ b/client/internal/netflow/conntrack/conntrack_test.go @@ -0,0 +1,224 @@ +//go:build linux && !android + +package conntrack + +import ( + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + nfct "github.com/ti-mo/conntrack" + "github.com/ti-mo/netfilter" +) + +type mockListener struct { + errChan chan error + closed atomic.Bool + closedCh chan struct{} +} + +func newMockListener() *mockListener { + return &mockListener{ + errChan: make(chan error, 1), + closedCh: make(chan struct{}), + } +} + +func (m *mockListener) Listen(evChan chan<- nfct.Event, _ uint8, _ []netfilter.NetlinkGroup) (chan error, error) { + return m.errChan, nil +} + +func (m *mockListener) Close() error { + if m.closed.CompareAndSwap(false, true) { + close(m.closedCh) + } + return nil +} + +func TestReconnectAfterError(t *testing.T) { + first := newMockListener() + second := newMockListener() + third := newMockListener() + listeners := []*mockListener{first, second, third} + callCount := atomic.Int32{} + + ct := New(nil, nil, WithDialer(func() (listener, error) { + n := int(callCount.Add(1)) - 1 + return listeners[n], nil + })) + + err := ct.Start(false) + require.NoError(t, err) + + // Inject an error on the first listener. + first.errChan <- assert.AnError + + // Wait for reconnect to complete. + require.Eventually(t, func() bool { + return callCount.Load() >= 2 + }, 15*time.Second, 100*time.Millisecond, "reconnect should dial a new connection") + + // The first connection must have been closed. + select { + case <-first.closedCh: + case <-time.After(2 * time.Second): + t.Fatal("first connection was not closed") + } + + // Verify the receiver is still running by injecting and handling a second error. + second.errChan <- assert.AnError + + require.Eventually(t, func() bool { + return callCount.Load() >= 3 + }, 15*time.Second, 100*time.Millisecond, "second reconnect should succeed") + + ct.Stop() +} + +func TestStopDuringReconnectBackoff(t *testing.T) { + mock := newMockListener() + + ct := New(nil, nil, WithDialer(func() (listener, error) { + return mock, nil + })) + + err := ct.Start(false) + require.NoError(t, err) + + // Trigger an error so the receiver enters reconnect. + mock.errChan <- assert.AnError + + // Wait for the error handler to close the old listener before calling Stop. + select { + case <-mock.closedCh: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for reconnect to start") + } + + // Stop while reconnecting. + ct.Stop() + + ct.mux.Lock() + assert.False(t, ct.started, "started should be false after Stop") + assert.Nil(t, ct.conn, "conn should be nil after Stop") + ct.mux.Unlock() +} + +func TestStopRaceWithReconnectDial(t *testing.T) { + first := newMockListener() + dialStarted := make(chan struct{}) + dialProceed := make(chan struct{}) + second := newMockListener() + callCount := atomic.Int32{} + + ct := New(nil, nil, WithDialer(func() (listener, error) { + n := callCount.Add(1) + if n == 1 { + return first, nil + } + // Second dial: signal that we're in progress, wait for test to call Stop. + close(dialStarted) + <-dialProceed + return second, nil + })) + + err := ct.Start(false) + require.NoError(t, err) + + // Trigger error to enter reconnect. + first.errChan <- assert.AnError + + // Wait for reconnect's second dial to begin. + select { + case <-dialStarted: + case <-time.After(15 * time.Second): + t.Fatal("timed out waiting for reconnect dial") + } + + // Stop while dial is in progress (conn is nil at this point). + ct.Stop() + + // Let the dial complete. reconnect should detect started==false and close the new conn. + close(dialProceed) + + // The second connection should be closed (not leaked). + select { + case <-second.closedCh: + case <-time.After(2 * time.Second): + t.Fatal("second connection was leaked after Stop") + } + + ct.mux.Lock() + assert.False(t, ct.started) + assert.Nil(t, ct.conn) + ct.mux.Unlock() +} + +func TestCloseRaceWithReconnectDial(t *testing.T) { + first := newMockListener() + dialStarted := make(chan struct{}) + dialProceed := make(chan struct{}) + second := newMockListener() + callCount := atomic.Int32{} + + ct := New(nil, nil, WithDialer(func() (listener, error) { + n := callCount.Add(1) + if n == 1 { + return first, nil + } + close(dialStarted) + <-dialProceed + return second, nil + })) + + err := ct.Start(false) + require.NoError(t, err) + + first.errChan <- assert.AnError + + select { + case <-dialStarted: + case <-time.After(15 * time.Second): + t.Fatal("timed out waiting for reconnect dial") + } + + // Close while dial is in progress (conn is nil). + require.NoError(t, ct.Close()) + + close(dialProceed) + + // The second connection should be closed (not leaked). + select { + case <-second.closedCh: + case <-time.After(2 * time.Second): + t.Fatal("second connection was leaked after Close") + } + + ct.mux.Lock() + assert.False(t, ct.started) + assert.Nil(t, ct.conn) + ct.mux.Unlock() +} + +func TestStartIsIdempotent(t *testing.T) { + mock := newMockListener() + callCount := atomic.Int32{} + + ct := New(nil, nil, WithDialer(func() (listener, error) { + callCount.Add(1) + return mock, nil + })) + + err := ct.Start(false) + require.NoError(t, err) + + // Second Start should be a no-op. + err = ct.Start(false) + require.NoError(t, err) + + assert.Equal(t, int32(1), callCount.Load(), "dial should only be called once") + + ct.Stop() +} 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 c287236e8..f693081a6 100644 --- a/client/internal/networkmonitor/check_change_common.go +++ b/client/internal/networkmonitor/check_change_common.go @@ -22,71 +22,114 @@ func prepareFd() (int, error) { func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Nexthop) error { for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - buf := make([]byte, 2048) - n, err := unix.Read(fd, buf) + // Wait until fd is readable or context is cancelled, to avoid a busy-loop + // when the routing socket returns EAGAIN (e.g. immediately after wakeup). + if err := waitReadable(ctx, fd); err != nil { + return err + } + + buf := make([]byte, 2048) + n, err := unix.Read(fd, buf) + if err != nil { + if errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EINTR) { + continue + } + if errors.Is(err, unix.EBADF) || errors.Is(err, unix.EINVAL) { + return fmt.Errorf("routing socket closed: %w", err) + } + return fmt.Errorf("read routing socket: %w", err) + } + + if n < unix.SizeofRtMsghdr { + log.Debugf("Network monitor: read from routing socket returned less than expected: %d bytes", n) + continue + } + + msg := (*unix.RtMsghdr)(unsafe.Pointer(&buf[0])) + + switch msg.Type { + // handle route changes + case unix.RTM_ADD, syscall.RTM_DELETE: + route, flags, err := parseRouteMessage(buf[:n]) if err != nil { - if !errors.Is(err, unix.EBADF) && !errors.Is(err, unix.EINVAL) { - log.Warnf("Network monitor: failed to read from routing socket: %v", err) - } - continue - } - if n < unix.SizeofRtMsghdr { - log.Debugf("Network monitor: read from routing socket returned less than expected: %d bytes", n) + log.Debugf("Network monitor: error parsing routing message: %v", err) continue } - msg := (*unix.RtMsghdr)(unsafe.Pointer(&buf[0])) + if route.Dst.Bits() != 0 { + continue + } + intf := "" + if route.Interface != nil { + intf = route.Interface.Name + } switch msg.Type { - // handle route changes - case unix.RTM_ADD, syscall.RTM_DELETE: - route, err := parseRouteMessage(buf[:n]) - if err != nil { - log.Debugf("Network monitor: error parsing routing message: %v", err) + 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 } - - if route.Dst.Bits() != 0 { - continue - } - - intf := "" - if route.Interface != nil { - intf = route.Interface.Name - } - switch msg.Type { - case unix.RTM_ADD: - log.Infof("Network monitor: default route changed: via %s, interface %s", route.Gw, intf) + log.Infof("Network monitor: default route changed: via %s, interface %s", route.Gw, intf) + return nil + case unix.RTM_DELETE: + if nexthopv4.Intf != nil && route.Gw.Compare(nexthopv4.IP) == 0 || nexthopv6.Intf != nil && route.Gw.Compare(nexthopv6.IP) == 0 { + log.Infof("Network monitor: default route removed: via %s, interface %s", route.Gw, intf) return nil - case unix.RTM_DELETE: - if nexthopv4.Intf != nil && route.Gw.Compare(nexthopv4.IP) == 0 || nexthopv6.Intf != nil && route.Gw.Compare(nexthopv6.IP) == 0 { - log.Infof("Network monitor: default route removed: via %s, interface %s", route.Gw, intf) - return nil - } } } } } } -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. +func waitReadable(ctx context.Context, fd int) error { + var fdset unix.FdSet + if fd < 0 || fd/unix.NFDBITS >= len(fdset.Bits) { + return fmt.Errorf("fd %d out of range for FdSet", fd) + } + + for { + if err := ctx.Err(); err != nil { + return err + } + + fdset = unix.FdSet{} + fdset.Set(fd) + // Use a 1-second timeout so we can re-check ctx periodically. + tv := unix.Timeval{Sec: 1} + n, err := unix.Select(fd+1, &fdset, nil, nil, &tv) + if err != nil { + if errors.Is(err, unix.EINTR) { + continue + } + return fmt.Errorf("select on routing socket: %w", err) + } + if n > 0 { + return nil + } + // timeout — loop back and re-check ctx + } } diff --git a/client/internal/networkmonitor/check_change_darwin.go b/client/internal/networkmonitor/check_change_darwin.go index ddc6e1736..cb5236070 100644 --- a/client/internal/networkmonitor/check_change_darwin.go +++ b/client/internal/networkmonitor/check_change_darwin.go @@ -110,7 +110,6 @@ func wakeUpListen(ctx context.Context) { } if newHash == initialHash { - log.Tracef("no wakeup detected") continue } diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go index 68afe986a..09a4e8b02 100644 --- a/client/internal/peer/conn.go +++ b/client/internal/peer/conn.go @@ -3,10 +3,10 @@ package peer import ( "context" "fmt" - "math/rand" "net" "net/netip" "runtime" + "slices" "sync" "time" @@ -16,26 +16,45 @@ import ( "github.com/netbirdio/netbird/client/iface/configurer" "github.com/netbirdio/netbird/client/iface/wgproxy" + "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" icemaker "github.com/netbirdio/netbird/client/internal/peer/ice" "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/route" relayClient "github.com/netbirdio/netbird/shared/relay/client" - semaphoregroup "github.com/netbirdio/netbird/util/semaphore-group" ) +// 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( + ctx context.Context, + remotePubKey string, + connectionType metrics.ConnectionType, + isReconnection bool, + timestamps metrics.ConnectionStageTimestamps, + ) +} + type ServiceDependencies struct { StatusRecorder *Status Signaler *Signaler IFaceDiscover stdnet.ExternalIFaceDiscover RelayManager *relayClient.Manager SrWatcher *guard.SRWatcher - Semaphore *semaphoregroup.SemaphoreGroup PeerConnDispatcher *dispatcher.ConnectionDispatcher + PortForwardManager *portforward.Manager + MetricsRecorder MetricsRecorder } type WgConfig struct { @@ -77,19 +96,21 @@ type ConnConfig struct { } type Conn struct { - Log *log.Entry - mu sync.Mutex - ctx context.Context - ctxCancel context.CancelFunc - config ConnConfig - statusRecorder *Status - signaler *Signaler - iFaceDiscover stdnet.ExternalIFaceDiscover - relayManager *relayClient.Manager - srWatcher *guard.SRWatcher + Log *log.Entry + mu sync.Mutex + ctx context.Context + ctxCancel context.CancelFunc + config ConnConfig + statusRecorder *Status + signaler *Signaler + iFaceDiscover stdnet.ExternalIFaceDiscover + relayManager *relayClient.Manager + srWatcher *guard.SRWatcher + portForwardManager *portforward.Manager - onConnected func(remoteWireGuardKey string, remoteRosenpassPubKey []byte, wireGuardIP string, remoteRosenpassAddr string) - onDisconnected func(remotePeer string) + onConnected func(remoteWireGuardKey string, remoteRosenpassPubKey []byte, wireGuardIP string, remoteRosenpassAddr string) + onDisconnected func(remotePeer string) + rosenpassInitializedPresharedKeyValidator func(peerKey string) bool statusRelay *worker.AtomicWorkerStatus statusICE *worker.AtomicWorkerStatus @@ -98,7 +119,13 @@ type Conn struct { workerICE *WorkerICE workerRelay *WorkerRelay - wgWatcherWg sync.WaitGroup + + 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 @@ -107,14 +134,50 @@ type Conn struct { wgProxyRelay wgproxy.Proxy handshaker *Handshaker - guard *guard.Guard - semaphore *semaphoregroup.SemaphoreGroup - wg sync.WaitGroup + guard *guard.Guard + wg sync.WaitGroup // debug purpose dumpState *stateDump endpointUpdater *EndpointUpdater + + // 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. @@ -126,19 +189,21 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) { connLog := log.WithField("peer", config.Key) + dumpState := newStateDump(config.Key, connLog, services.StatusRecorder) var conn = &Conn{ - Log: connLog, - config: config, - statusRecorder: services.StatusRecorder, - signaler: services.Signaler, - iFaceDiscover: services.IFaceDiscover, - relayManager: services.RelayManager, - srWatcher: services.SrWatcher, - semaphore: services.Semaphore, - statusRelay: worker.NewAtomicStatus(), - statusICE: worker.NewAtomicStatus(), - dumpState: newStateDump(config.Key, connLog, services.StatusRecorder), - endpointUpdater: NewEndpointUpdater(connLog, config.WgConfig, isController(config)), + Log: connLog, + config: config, + statusRecorder: services.StatusRecorder, + signaler: services.Signaler, + iFaceDiscover: services.IFaceDiscover, + relayManager: services.RelayManager, + srWatcher: services.SrWatcher, + portForwardManager: services.PortForwardManager, + statusRelay: worker.NewAtomicStatus(), + statusICE: worker.NewAtomicStatus(), + dumpState: dumpState, + endpointUpdater: NewEndpointUpdater(connLog, config.WgConfig, isController(config)), + metricsRecorder: services.MetricsRecorder, } return conn, nil @@ -148,31 +213,44 @@ 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 { - conn.semaphore.Add(engineCtx) + 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() if conn.opened { - conn.semaphore.Done(engineCtx) return nil } + // Allocate new metrics stages so old goroutines don't corrupt new state + conn.metricsStages = &MetricsStages{} + conn.ctx, conn.ctxCancel = context.WithCancel(engineCtx) - conn.workerRelay = NewWorkerRelay(conn.ctx, conn.Log, isController(conn.config), conn.config, conn, conn.relayManager, conn.dumpState) + conn.workerRelay = NewWorkerRelay(conn.ctx, conn.Log, isController(conn.config), conn.config, conn, conn.relayManager) - relayIsSupportedLocally := conn.workerRelay.RelayIsSupportedLocally() - workerICE, err := NewWorkerICE(conn.ctx, conn.Log, conn.config, conn, conn.signaler, conn.iFaceDiscover, conn.statusRecorder, relayIsSupportedLocally) - if err != nil { - return err + forceRelay := IsForceRelayed() + if !forceRelay { + relayIsSupportedLocally := conn.workerRelay.RelayIsSupportedLocally() + workerICE, err := NewWorkerICE(conn.ctx, conn.Log, conn.config, conn, conn.signaler, conn.iFaceDiscover, conn.statusRecorder, relayIsSupportedLocally) + if err != nil { + return err + } + conn.workerICE = workerICE } - conn.workerICE = workerICE - conn.handshaker = NewHandshaker(conn.Log, conn.config, conn.signaler, conn.workerICE, conn.workerRelay) + conn.handshaker = NewHandshaker(conn.Log, conn.config, conn.signaler, conn.workerICE, conn.workerRelay, conn.metricsStages) conn.handshaker.AddRelayListener(conn.workerRelay.OnNewOffer) - if !isForceRelayed() { + if !forceRelay { conn.handshaker.AddICEListener(conn.workerICE.OnNewOffer) } @@ -198,12 +276,11 @@ func (conn *Conn) Open(engineCtx context.Context) error { conn.wg.Add(1) go func() { defer conn.wg.Done() - - conn.waitInitialRandomSleepTime(conn.ctx) - conn.semaphore.Done(conn.ctx) - conn.guard.Start(conn.ctx, conn.onGuardEvent) }() + if len(firstPacket) > 0 { + conn.pendingFirstPacket = slices.Clone(firstPacket) + } conn.opened = true return nil } @@ -228,9 +305,13 @@ func (conn *Conn) Close(signalToRemote bool) { conn.Log.Infof("close peer connection") conn.ctxCancel() - conn.workerRelay.DisableWgWatcher() + if conn.wgWatcherCancel != nil { + conn.wgWatcherCancel() + } conn.workerRelay.CloseConn() - conn.workerICE.Close() + if conn.workerICE != nil { + conn.workerICE.Close() + } if conn.wgProxyRelay != nil { err := conn.wgProxyRelay.CloseConn() @@ -273,7 +354,9 @@ func (conn *Conn) OnRemoteAnswer(answer OfferAnswer) { // OnRemoteCandidate Handles ICE connection Candidate provided by the remote peer. func (conn *Conn) OnRemoteCandidate(candidate ice.Candidate, haRoutes route.HAMap) { conn.dumpState.RemoteCandidate() - conn.workerICE.OnRemoteCandidate(candidate, haRoutes) + if conn.workerICE != nil { + conn.workerICE.OnRemoteCandidate(candidate, haRoutes) + } } // SetOnConnected sets a handler function to be triggered by Conn when a new connection to a remote peer established @@ -286,6 +369,13 @@ func (conn *Conn) SetOnDisconnected(handler func(remotePeer string)) { conn.onDisconnected = handler } +// SetRosenpassInitializedPresharedKeyValidator sets a function to check if Rosenpass has taken over +// PSK management for a peer. When this returns true, presharedKey() returns nil +// to prevent UpdatePeer from overwriting the Rosenpass-managed PSK. +func (conn *Conn) SetRosenpassInitializedPresharedKeyValidator(handler func(peerKey string) bool) { + conn.rosenpassInitializedPresharedKeyValidator = handler +} + func (conn *Conn) OnRemoteOffer(offer OfferAnswer) { conn.dumpState.RemoteOffer() conn.Log.Infof("OnRemoteOffer, on status ICE: %s, status Relay: %s", conn.statusICE, conn.statusRelay) @@ -332,7 +422,7 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn if conn.currentConnPriority > priority { conn.Log.Infof("current connection priority (%s) is higher than the new one (%s), do not upgrade connection", conn.currentConnPriority, priority) conn.statusICE.SetConnected() - conn.updateIceState(iceConnInfo) + conn.updateIceState(iceConnInfo, time.Now()) return } @@ -363,9 +453,6 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn ep = directEp } - conn.workerRelay.DisableWgWatcher() - // todo consider to run conn.wgWatcherWg.Wait() here - if conn.wgProxyRelay != nil { conn.wgProxyRelay.Pause() } @@ -375,6 +462,9 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn } conn.Log.Infof("configure WireGuard endpoint to: %s", ep.String()) + updateTime := time.Now() + conn.enableWgWatcherIfNeeded(updateTime) + presharedKey := conn.presharedKey(iceConnInfo.RosenpassPubKey) if err = conn.endpointUpdater.ConfigureWGEndpoint(ep, presharedKey); err != nil { conn.handleConfigurationFailure(err, wgProxy) @@ -387,13 +477,15 @@ 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) - conn.doOnConnected(iceConnInfo.RosenpassPubKey, iceConnInfo.RosenpassAddr) + conn.updateIceState(iceConnInfo, updateTime) + conn.doOnConnected(iceConnInfo.RosenpassPubKey, iceConnInfo.RosenpassAddr, updateTime) } -func (conn *Conn) onICEStateDisconnected() { +func (conn *Conn) onICEStateDisconnected(sessionChanged bool) { conn.mu.Lock() defer conn.mu.Unlock() @@ -413,19 +505,18 @@ func (conn *Conn) onICEStateDisconnected() { if conn.isReadyToUpgrade() { conn.Log.Infof("ICE disconnected, set Relay to active connection") conn.dumpState.SwitchToRelay() + if sessionChanged { + conn.resetEndpoint() + } + + // todo consider to move after the ConfigureWGEndpoint conn.wgProxyRelay.Work() presharedKey := conn.presharedKey(conn.rosenpassRemoteKey) - if err := conn.endpointUpdater.ConfigureWGEndpoint(conn.wgProxyRelay.EndpointAddr(), presharedKey); err != nil { + if err := conn.endpointUpdater.SwitchWGEndpoint(conn.wgProxyRelay.EndpointAddr(), presharedKey); err != nil { conn.Log.Errorf("failed to switch to relay conn: %v", err) } - conn.wgWatcherWg.Add(1) - go func() { - defer conn.wgWatcherWg.Done() - conn.workerRelay.EnableWgWatcher(conn.ctx) - }() - conn.wgProxyRelay.Work() conn.currentConnPriority = conntype.Relay } else { conn.Log.Infof("ICE disconnected, do not switch to Relay. Reset priority to: %s", conntype.None.String()) @@ -441,15 +532,19 @@ func (conn *Conn) onICEStateDisconnected() { } conn.statusICE.SetDisconnected() + conn.disableWgWatcherIfNeeded() + + if conn.currentConnPriority == conntype.None { + conn.metricsStages.Disconnected() + } + peerState := State{ PubKey: conn.config.Key, ConnStatus: conn.evalStatus(), Relayed: conn.isRelayed(), ConnStatusUpdate: time.Now(), } - - err := conn.statusRecorder.UpdatePeerICEStateToDisconnected(peerState) - if err != nil { + if err := conn.statusRecorder.UpdatePeerICEStateToDisconnected(peerState); err != nil { conn.Log.Warnf("unable to set peer's state to disconnected ice, got error: %v", err) } } @@ -483,40 +578,49 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) { conn.Log.Debugf("do not switch to relay because current priority is: %s", conn.currentConnPriority.String()) conn.setRelayedProxy(wgProxy) conn.statusRelay.SetConnected() - conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey) + conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey, time.Now()) return } - wgProxy.Work() - presharedKey := conn.presharedKey(rci.rosenpassPubKey) - if err := conn.endpointUpdater.ConfigureWGEndpoint(wgProxy.EndpointAddr(), presharedKey); err != nil { + controller := isController(conn.config) + + if controller { + wgProxy.Work() + } + updateTime := time.Now() + conn.enableWgWatcherIfNeeded(updateTime) + if err := conn.endpointUpdater.ConfigureWGEndpoint(wgProxy.EndpointAddr(), conn.presharedKey(rci.rosenpassPubKey)); err != nil { if err := wgProxy.CloseConn(); err != nil { conn.Log.Warnf("Failed to close relay connection: %v", err) } conn.Log.Errorf("Failed to update WireGuard peer configuration: %v", err) return } - - conn.wgWatcherWg.Add(1) - go func() { - defer conn.wgWatcherWg.Done() - conn.workerRelay.EnableWgWatcher(conn.ctx) - }() + if !controller { + wgProxy.Work() + } wgConfigWorkaround() + + conn.injectPendingFirstPacket(wgProxy, nil) + conn.rosenpassRemoteKey = rci.rosenpassPubKey conn.currentConnPriority = conntype.Relay conn.statusRelay.SetConnected() conn.setRelayedProxy(wgProxy) - conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey) + conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey, updateTime) conn.Log.Infof("start to communicate with peer via relay") - conn.doOnConnected(rci.rosenpassPubKey, rci.rosenpassAddr) + conn.doOnConnected(rci.rosenpassPubKey, rci.rosenpassAddr, updateTime) } func (conn *Conn) onRelayDisconnected() { conn.mu.Lock() defer conn.mu.Unlock() + conn.handleRelayDisconnectedLocked() +} +// handleRelayDisconnectedLocked handles relay disconnection. Caller must hold conn.mu. +func (conn *Conn) handleRelayDisconnectedLocked() { if conn.ctx.Err() != nil { return } @@ -542,6 +646,12 @@ func (conn *Conn) onRelayDisconnected() { } conn.statusRelay.SetDisconnected() + conn.disableWgWatcherIfNeeded() + + if conn.currentConnPriority == conntype.None { + conn.metricsStages.Disconnected() + } + peerState := State{ PubKey: conn.config.Key, ConnStatus: conn.evalStatus(), @@ -560,10 +670,56 @@ func (conn *Conn) onGuardEvent() { } } -func (conn *Conn) updateRelayStatus(relayServerAddr string, rosenpassPubKey []byte) { +func (conn *Conn) onWGDisconnected(watcherCtx context.Context) { + conn.mu.Lock() + defer conn.mu.Unlock() + + // watcherCtx guards against a stale watcher tearing down a connection that already superseded it. + if conn.ctx.Err() != nil || watcherCtx.Err() != nil { + return + } + + conn.Log.Warnf("WireGuard handshake timeout detected, closing current connection") + + // Close the active connection based on current priority + switch conn.currentConnPriority { + case conntype.Relay: + conn.workerRelay.CloseConn() + conn.handleRelayDisconnectedLocked() + case conntype.ICEP2P, conntype.ICETurn: + conn.workerICE.Close() + 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) { peerState := State{ PubKey: conn.config.Key, - ConnStatusUpdate: time.Now(), + ConnStatusUpdate: updateTime, ConnStatus: conn.evalStatus(), Relayed: conn.isRelayed(), RelayServerAddress: relayServerAddr, @@ -576,10 +732,10 @@ func (conn *Conn) updateRelayStatus(relayServerAddr string, rosenpassPubKey []by } } -func (conn *Conn) updateIceState(iceConnInfo ICEConnInfo) { +func (conn *Conn) updateIceState(iceConnInfo ICEConnInfo, updateTime time.Time) { peerState := State{ PubKey: conn.config.Key, - ConnStatusUpdate: time.Now(), + ConnStatusUpdate: updateTime, ConnStatus: conn.evalStatus(), Relayed: iceConnInfo.Relayed, LocalIceCandidateType: iceConnInfo.LocalIceCandidateType, @@ -617,29 +773,18 @@ func (conn *Conn) setStatusToDisconnected() { } } -func (conn *Conn) doOnConnected(remoteRosenpassPubKey []byte, remoteRosenpassAddr string) { +func (conn *Conn) doOnConnected(remoteRosenpassPubKey []byte, remoteRosenpassAddr string, updateTime time.Time) { if runtime.GOOS == "ios" { runtime.GC() } + conn.metricsStages.RecordConnectionReady(updateTime) + if conn.onConnected != nil { conn.onConnected(conn.config.Key, remoteRosenpassPubKey, conn.config.WgConfig.AllowedIps[0].Addr().String(), remoteRosenpassAddr) } } -func (conn *Conn) waitInitialRandomSleepTime(ctx context.Context) { - maxWait := 300 - duration := time.Duration(rand.Intn(maxWait)) * time.Millisecond - - timeout := time.NewTimer(duration) - defer timeout.Stop() - - select { - case <-ctx.Done(): - case <-timeout.C: - } -} - func (conn *Conn) isRelayed() bool { switch conn.currentConnPriority { case conntype.Relay, conntype.ICETurn: @@ -657,26 +802,70 @@ func (conn *Conn) evalStatus() ConnStatus { return StatusConnecting } -func (conn *Conn) isConnectedOnAllWay() (connected bool) { - // would be better to protect this with a mutex, but it could cause deadlock with Close function - +// isConnectedOnAllWay evaluates the overall connection status based on ICE and Relay transports. +// +// The result is a tri-state: +// - ConnStatusConnected: all available transports are up +// - ConnStatusPartiallyConnected: relay is up but ICE is still pending/reconnecting +// - ConnStatusDisconnected: no working transport +func (conn *Conn) isConnectedOnAllWay() (status guard.ConnStatus) { defer func() { - if !connected { + if status == guard.ConnStatusDisconnected { conn.logTraceConnState() } }() - if conn.statusICE.Get() == worker.StatusDisconnected && !conn.workerICE.InProgress() { - return false + iceWorkerCreated := conn.workerICE != nil + + var iceInProgress bool + if iceWorkerCreated { + iceInProgress = conn.workerICE.InProgress() } - if conn.workerRelay.IsRelayConnectionSupportedWithPeer() { - if conn.statusRelay.Get() == worker.StatusDisconnected { - return false - } + return evalConnStatus(connStatusInputs{ + forceRelay: IsForceRelayed(), + peerUsesRelay: conn.workerRelay.IsRelayConnectionSupportedWithPeer(), + relayConnected: conn.statusRelay.Get() == worker.StatusConnected, + remoteSupportsICE: conn.handshaker.RemoteICESupported(), + iceWorkerCreated: iceWorkerCreated, + iceStatusConnecting: conn.statusICE.Get() != worker.StatusDisconnected, + iceInProgress: iceInProgress, + }) +} + +// 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 != nil { + return } - return true + 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.wgWatcher == nil { + return + } + conn.wgWatcherCancel() + conn.wgWatcher = nil + conn.wgWatcherCancel = nil } func (conn *Conn) newProxy(remoteConn net.Conn) (wgproxy.Proxy, error) { @@ -694,6 +883,19 @@ func (conn *Conn) newProxy(remoteConn net.Conn) (wgproxy.Proxy, error) { return wgProxy, nil } +func (conn *Conn) resetEndpoint() { + if !isController(conn.config) { + return + } + conn.Log.Infof("reset wg endpoint") + 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) + } +} + func (conn *Conn) isReadyToUpgrade() bool { return conn.wgProxyRelay != nil && conn.currentConnPriority != conntype.Relay } @@ -731,6 +933,50 @@ func (conn *Conn) setRelayedProxy(proxy wgproxy.Proxy) { conn.wgProxyRelay = proxy } +// onWGHandshakeSuccess is called when the first WireGuard handshake is detected +func (conn *Conn) onWGHandshakeSuccess(when time.Time) { + conn.metricsStages.RecordWGHandshakeSuccess(when) + 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 { + return + } + + // Determine connection type based on current priority + conn.mu.Lock() + priority := conn.currentConnPriority + conn.mu.Unlock() + + var connType metrics.ConnectionType + switch priority { + case conntype.Relay: + connType = metrics.ConnectionTypeRelay + default: + connType = metrics.ConnectionTypeICE + } + + // Record metrics with timestamps - duration calculation happens in metrics package + conn.metricsRecorder.RecordConnectionStages( + context.Background(), + conn.config.Key, + connType, + conn.metricsStages.IsReconnection(), + conn.metricsStages.GetTimestamps(), + ) +} + // AllowedIP returns the allowed IP of the remote peer func (conn *Conn) AllowedIP() netip.Addr { return conn.config.WgConfig.AllowedIps[0].Addr() @@ -749,33 +995,27 @@ func (conn *Conn) presharedKey(remoteRosenpassKey []byte) *wgtypes.Key { return conn.config.WgConfig.PreSharedKey } - determKey, err := conn.rosenpassDetermKey() - if err != nil { - conn.Log.Errorf("failed to generate Rosenpass initial key: %v", err) + // If Rosenpass has already set a PSK for this peer, return nil to prevent + // UpdatePeer from overwriting the Rosenpass-managed key. + if conn.rosenpassInitializedPresharedKeyValidator != nil && conn.rosenpassInitializedPresharedKeyValidator(conn.config.Key) { + return nil + } + + // Use NetBird PSK as the seed for Rosenpass. This same PSK is passed to + // Rosenpass as PeerConfig.PresharedKey, ensuring the derived post-quantum + // key is cryptographically bound to the original secret. + if conn.config.WgConfig.PreSharedKey != nil { return conn.config.WgConfig.PreSharedKey } - 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) + // Fallback to deterministic key if no NetBird PSK is configured + determKey, err := rosenpass.DeterministicSeedKey(conn.config.LocalKey, conn.config.Key) if err != nil { - return nil, err + conn.Log.Errorf("failed to generate Rosenpass initial key: %v", err) + return nil } - return &key, nil + + return determKey } func isController(config ConnConfig) bool { @@ -786,8 +1026,42 @@ func isRosenpassEnabled(remoteRosenpassPubKey []byte) bool { return remoteRosenpassPubKey != nil } -// wgConfigWorkaround is a workaround for the issue with WireGuard configuration update -// When update a peer configuration in near to each other time, the second update can be ignored by WireGuard -func wgConfigWorkaround() { - time.Sleep(100 * time.Millisecond) +func evalConnStatus(in connStatusInputs) guard.ConnStatus { + // "Relay up and needed" — the peer uses relay and the transport is connected. + relayUsedAndUp := in.peerUsesRelay && in.relayConnected + + // Force-relay mode: ICE never runs. Relay is the only transport and must be up. + if in.forceRelay { + return boolToConnStatus(relayUsedAndUp) + } + + // Remote peer doesn't support ICE, or we haven't created the worker yet: + // relay is the only possible transport. + if !in.remoteSupportsICE || !in.iceWorkerCreated { + return boolToConnStatus(relayUsedAndUp) + } + + // ICE counts as "up" when the status is anything other than Disconnected, OR + // when a negotiation is currently in progress (so we don't spam offers while one is in flight). + iceUp := in.iceStatusConnecting || in.iceInProgress + + // Relay side is acceptable if the peer doesn't rely on relay, or relay is connected. + relayOK := !in.peerUsesRelay || in.relayConnected + + switch { + case iceUp && relayOK: + return guard.ConnStatusConnected + case relayUsedAndUp: + // Relay is up but ICE is down — partially connected. + return guard.ConnStatusPartiallyConnected + default: + return guard.ConnStatusDisconnected + } +} + +func boolToConnStatus(connected bool) guard.ConnStatus { + if connected { + return guard.ConnStatusConnected + } + return guard.ConnStatusDisconnected } diff --git a/client/internal/peer/conn_status.go b/client/internal/peer/conn_status.go index 73acc5ef5..d6ad37b70 100644 --- a/client/internal/peer/conn_status.go +++ b/client/internal/peer/conn_status.go @@ -13,6 +13,19 @@ const ( StatusConnected ) +// connStatusInputs is the primitive-valued snapshot of the state that drives the +// tri-state connection classification. Extracted so the decision logic can be unit-tested +// without constructing full Worker/Handshaker objects. +type connStatusInputs struct { + forceRelay bool // NB_FORCE_RELAY or JS/WASM + peerUsesRelay bool // remote peer advertises relay support AND local has relay + relayConnected bool // statusRelay reports Connected (independent of whether peer uses relay) + remoteSupportsICE bool // remote peer sent ICE credentials + iceWorkerCreated bool // local WorkerICE exists (false in force-relay mode) + iceStatusConnecting bool // statusICE is anything other than Disconnected + 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_status_eval_test.go b/client/internal/peer/conn_status_eval_test.go new file mode 100644 index 000000000..66393cafe --- /dev/null +++ b/client/internal/peer/conn_status_eval_test.go @@ -0,0 +1,201 @@ +package peer + +import ( + "testing" + + "github.com/netbirdio/netbird/client/internal/peer/guard" +) + +func TestEvalConnStatus_ForceRelay(t *testing.T) { + tests := []struct { + name string + in connStatusInputs + want guard.ConnStatus + }{ + { + name: "force relay, peer uses relay, relay up", + in: connStatusInputs{ + forceRelay: true, + peerUsesRelay: true, + relayConnected: true, + }, + want: guard.ConnStatusConnected, + }, + { + name: "force relay, peer uses relay, relay down", + in: connStatusInputs{ + forceRelay: true, + peerUsesRelay: true, + relayConnected: false, + }, + want: guard.ConnStatusDisconnected, + }, + { + name: "force relay, peer does NOT use relay - disconnected forever", + in: connStatusInputs{ + forceRelay: true, + peerUsesRelay: false, + relayConnected: true, + }, + want: guard.ConnStatusDisconnected, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := evalConnStatus(tc.in); got != tc.want { + t.Fatalf("evalConnStatus = %v, want %v", got, tc.want) + } + }) + } +} + +func TestEvalConnStatus_ICEUnavailable(t *testing.T) { + tests := []struct { + name string + in connStatusInputs + want guard.ConnStatus + }{ + { + name: "remote does not support ICE, peer uses relay, relay up", + in: connStatusInputs{ + peerUsesRelay: true, + relayConnected: true, + remoteSupportsICE: false, + iceWorkerCreated: true, + }, + want: guard.ConnStatusConnected, + }, + { + name: "remote does not support ICE, peer uses relay, relay down", + in: connStatusInputs{ + peerUsesRelay: true, + relayConnected: false, + remoteSupportsICE: false, + iceWorkerCreated: true, + }, + want: guard.ConnStatusDisconnected, + }, + { + name: "ICE worker not yet created, relay up", + in: connStatusInputs{ + peerUsesRelay: true, + relayConnected: true, + remoteSupportsICE: true, + iceWorkerCreated: false, + }, + want: guard.ConnStatusConnected, + }, + { + name: "remote does not support ICE, peer does not use relay", + in: connStatusInputs{ + peerUsesRelay: false, + relayConnected: false, + remoteSupportsICE: false, + iceWorkerCreated: true, + }, + want: guard.ConnStatusDisconnected, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := evalConnStatus(tc.in); got != tc.want { + t.Fatalf("evalConnStatus = %v, want %v", got, tc.want) + } + }) + } +} + +func TestEvalConnStatus_FullyAvailable(t *testing.T) { + base := connStatusInputs{ + remoteSupportsICE: true, + iceWorkerCreated: true, + } + + tests := []struct { + name string + mutator func(*connStatusInputs) + want guard.ConnStatus + }{ + { + name: "ICE connected, relay connected, peer uses relay", + mutator: func(in *connStatusInputs) { + in.peerUsesRelay = true + in.relayConnected = true + in.iceStatusConnecting = true + }, + want: guard.ConnStatusConnected, + }, + { + name: "ICE connected, peer does NOT use relay", + mutator: func(in *connStatusInputs) { + in.peerUsesRelay = false + in.relayConnected = false + in.iceStatusConnecting = true + }, + want: guard.ConnStatusConnected, + }, + { + name: "ICE InProgress only, peer does NOT use relay", + mutator: func(in *connStatusInputs) { + in.peerUsesRelay = false + in.iceStatusConnecting = false + in.iceInProgress = true + }, + want: guard.ConnStatusConnected, + }, + { + name: "ICE down, relay up, peer uses relay -> partial", + mutator: func(in *connStatusInputs) { + in.peerUsesRelay = true + in.relayConnected = true + in.iceStatusConnecting = false + in.iceInProgress = false + }, + want: guard.ConnStatusPartiallyConnected, + }, + { + name: "ICE down, peer does NOT use relay -> disconnected", + mutator: func(in *connStatusInputs) { + in.peerUsesRelay = false + in.relayConnected = false + in.iceStatusConnecting = false + in.iceInProgress = false + }, + want: guard.ConnStatusDisconnected, + }, + { + name: "ICE up, peer uses relay but relay down -> partial (relay required, ICE ignored)", + mutator: func(in *connStatusInputs) { + in.peerUsesRelay = true + in.relayConnected = false + in.iceStatusConnecting = true + }, + // relayOK = false (peer uses relay but it's down), iceUp = true + // first switch arm fails (relayOK false), relayUsedAndUp = false (relay down), + // falls into default: Disconnected. + want: guard.ConnStatusDisconnected, + }, + { + name: "ICE down, relay up but peer does not use relay -> disconnected", + mutator: func(in *connStatusInputs) { + in.peerUsesRelay = false + in.relayConnected = true // not actually used since peer doesn't rely on it + in.iceStatusConnecting = false + in.iceInProgress = false + }, + want: guard.ConnStatusDisconnected, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + in := base + tc.mutator(&in) + if got := evalConnStatus(in); got != tc.want { + t.Fatalf("evalConnStatus = %v, want %v (inputs: %+v)", got, tc.want, in) + } + }) + } +} diff --git a/client/internal/peer/conn_test.go b/client/internal/peer/conn_test.go index 6b47f95eb..49979ea83 100644 --- a/client/internal/peer/conn_test.go +++ b/client/internal/peer/conn_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/netbirdio/netbird/client/iface" @@ -15,7 +16,6 @@ import ( "github.com/netbirdio/netbird/client/internal/peer/ice" "github.com/netbirdio/netbird/client/internal/stdnet" "github.com/netbirdio/netbird/util" - semaphoregroup "github.com/netbirdio/netbird/util/semaphore-group" ) var testDispatcher = dispatcher.NewConnectionDispatcher() @@ -53,7 +53,6 @@ func TestConn_GetKey(t *testing.T) { sd := ServiceDependencies{ SrWatcher: swWatcher, - Semaphore: semaphoregroup.NewSemaphoreGroup(1), PeerConnDispatcher: testDispatcher, } conn, err := NewConn(connConf, sd) @@ -71,7 +70,6 @@ func TestConn_OnRemoteOffer(t *testing.T) { sd := ServiceDependencies{ StatusRecorder: NewRecorder("https://mgm"), SrWatcher: swWatcher, - Semaphore: semaphoregroup.NewSemaphoreGroup(1), PeerConnDispatcher: testDispatcher, } conn, err := NewConn(connConf, sd) @@ -110,7 +108,6 @@ func TestConn_OnRemoteAnswer(t *testing.T) { sd := ServiceDependencies{ StatusRecorder: NewRecorder("https://mgm"), SrWatcher: swWatcher, - Semaphore: semaphoregroup.NewSemaphoreGroup(1), PeerConnDispatcher: testDispatcher, } conn, err := NewConn(connConf, sd) @@ -284,3 +281,108 @@ func TestConn_presharedKey(t *testing.T) { }) } } + +func TestConn_presharedKey_RosenpassManaged(t *testing.T) { + conn := Conn{ + config: ConnConfig{ + Key: "LLHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", + LocalKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=", + RosenpassConfig: RosenpassConfig{PubKey: []byte("dummykey")}, + }, + } + + // When Rosenpass has already initialized the PSK for this peer, + // presharedKey must return nil to avoid UpdatePeer overwriting it. + conn.rosenpassInitializedPresharedKeyValidator = func(peerKey string) bool { return true } + if k := conn.presharedKey([]byte("remote")); k != nil { + t.Fatalf("expected nil presharedKey when Rosenpass manages PSK, got %v", k) + } + + // When Rosenpass hasn't taken over yet, presharedKey should provide + // a non-nil initial key (deterministic or from NetBird PSK). + conn.rosenpassInitializedPresharedKeyValidator = func(peerKey string) bool { return false } + if k := conn.presharedKey([]byte("remote")); k == nil { + 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") +} diff --git a/client/internal/peer/endpoint.go b/client/internal/peer/endpoint.go index 39cb95591..9ba1efb6e 100644 --- a/client/internal/peer/endpoint.go +++ b/client/internal/peer/endpoint.go @@ -20,7 +20,7 @@ type EndpointUpdater struct { wgConfig WgConfig initiator bool - // mu protects updateWireGuardPeer and cancelFunc + // mu protects cancelFunc mu sync.Mutex cancelFunc func() updateWg sync.WaitGroup @@ -34,28 +34,27 @@ func NewEndpointUpdater(log *logrus.Entry, wgConfig WgConfig, initiator bool) *E } } -// ConfigureWGEndpoint sets up the WireGuard endpoint configuration. -// The initiator immediately configures the endpoint, while the non-initiator -// waits for a fallback period before configuring to avoid handshake congestion. func (e *EndpointUpdater) ConfigureWGEndpoint(addr *net.UDPAddr, presharedKey *wgtypes.Key) error { e.mu.Lock() defer e.mu.Unlock() if e.initiator { - e.log.Debugf("configure up WireGuard as initiatr") - return e.updateWireGuardPeer(addr, presharedKey) + e.log.Debugf("configure up WireGuard as initiator") + return e.configureAsInitiator(addr, presharedKey) } + e.log.Debugf("configure up WireGuard as responder") + return e.configureAsResponder(addr, presharedKey) +} + +func (e *EndpointUpdater) SwitchWGEndpoint(addr *net.UDPAddr, presharedKey *wgtypes.Key) error { + e.mu.Lock() + defer e.mu.Unlock() + // prevent to run new update while cancel the previous update e.waitForCloseTheDelayedUpdate() - var ctx context.Context - ctx, e.cancelFunc = context.WithCancel(context.Background()) - e.updateWg.Add(1) - go e.scheduleDelayedUpdate(ctx, addr, presharedKey) - - e.log.Debugf("configure up WireGuard and wait for handshake") - return e.updateWireGuardPeer(nil, presharedKey) + return e.updateWireGuardPeer(addr, presharedKey) } func (e *EndpointUpdater) RemoveWgPeer() error { @@ -66,6 +65,38 @@ func (e *EndpointUpdater) RemoveWgPeer() error { return e.wgConfig.WgInterface.RemovePeer(e.wgConfig.RemoteKey) } +func (e *EndpointUpdater) RemoveEndpointAddress() error { + e.mu.Lock() + defer e.mu.Unlock() + + e.waitForCloseTheDelayedUpdate() + return e.wgConfig.WgInterface.RemoveEndpointAddress(e.wgConfig.RemoteKey) +} + +func (e *EndpointUpdater) configureAsInitiator(addr *net.UDPAddr, presharedKey *wgtypes.Key) error { + if err := e.updateWireGuardPeer(addr, presharedKey); err != nil { + return err + } + return nil +} + +func (e *EndpointUpdater) configureAsResponder(addr *net.UDPAddr, presharedKey *wgtypes.Key) error { + // prevent to run new update while cancel the previous update + e.waitForCloseTheDelayedUpdate() + + e.log.Debugf("configure up WireGuard and wait for handshake") + var ctx context.Context + ctx, e.cancelFunc = context.WithCancel(context.Background()) + e.updateWg.Add(1) + go e.scheduleDelayedUpdate(ctx, addr, presharedKey) + + if err := e.updateWireGuardPeer(nil, presharedKey); err != nil { + e.waitForCloseTheDelayedUpdate() + return err + } + return nil +} + func (e *EndpointUpdater) waitForCloseTheDelayedUpdate() { if e.cancelFunc == nil { return @@ -86,11 +117,9 @@ func (e *EndpointUpdater) scheduleDelayedUpdate(ctx context.Context, addr *net.U case <-ctx.Done(): return case <-t.C: - e.mu.Lock() if err := e.updateWireGuardPeer(addr, presharedKey); err != nil { e.log.Errorf("failed to update WireGuard peer, address: %s, error: %v", addr, err) } - e.mu.Unlock() } } @@ -103,3 +132,9 @@ func (e *EndpointUpdater) updateWireGuardPeer(endpoint *net.UDPAddr, presharedKe presharedKey, ) } + +// wgConfigWorkaround is a workaround for the issue with WireGuard configuration update +// When update a peer configuration in near to each other time, the second update can be ignored by WireGuard +func wgConfigWorkaround() { + time.Sleep(100 * time.Millisecond) +} diff --git a/client/internal/peer/env.go b/client/internal/peer/env.go index 32a458d00..ed6a3af53 100644 --- a/client/internal/peer/env.go +++ b/client/internal/peer/env.go @@ -2,13 +2,43 @@ package peer import ( "os" + "runtime" "strings" ) const ( - EnvKeyNBForceRelay = "NB_FORCE_RELAY" + EnvKeyNBForceRelay = "NB_FORCE_RELAY" + EnvKeyNBHomeRelayServers = "NB_HOME_RELAY_SERVERS" ) -func isForceRelayed() bool { +func IsForceRelayed() bool { + if runtime.GOOS == "js" { + return true + } 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 d93403730..6c2e846a9 100644 --- a/client/internal/peer/guard/guard.go +++ b/client/internal/peer/guard/guard.go @@ -8,7 +8,19 @@ import ( log "github.com/sirupsen/logrus" ) -type isConnectedFunc func() bool +// ConnStatus represents the connection state as seen by the guard. +type ConnStatus int + +const ( + // ConnStatusDisconnected means neither ICE nor Relay is connected. + ConnStatusDisconnected ConnStatus = iota + // ConnStatusPartiallyConnected means Relay is connected but ICE is not. + ConnStatusPartiallyConnected + // ConnStatusConnected means all required connections are established. + ConnStatusConnected +) + +type connStatusFunc func() ConnStatus // Guard is responsible for the reconnection logic. // It will trigger to send an offer to the peer then has connection issues. @@ -20,14 +32,14 @@ type isConnectedFunc func() bool // - ICE candidate changes type Guard struct { log *log.Entry - isConnectedOnAllWay isConnectedFunc + isConnectedOnAllWay connStatusFunc timeout time.Duration srWatcher *SRWatcher relayedConnDisconnected chan struct{} iCEConnDisconnected chan struct{} } -func NewGuard(log *log.Entry, isConnectedFn isConnectedFunc, timeout time.Duration, srWatcher *SRWatcher) *Guard { +func NewGuard(log *log.Entry, isConnectedFn connStatusFunc, timeout time.Duration, srWatcher *SRWatcher) *Guard { return &Guard{ log: log, isConnectedOnAllWay: isConnectedFn, @@ -57,47 +69,71 @@ func (g *Guard) SetICEConnDisconnected() { } } -// reconnectLoopWithRetry periodically check the connection status. -// Try to send offer while the P2P is not established or while the Relay is not connected if is it supported +// reconnectLoopWithRetry periodically checks the connection status and sends offers to re-establish connectivity. +// +// Behavior depends on the connection state reported by isConnectedOnAllWay: +// - Connected: no action, the peer is fully reachable. +// - Disconnected (neither ICE nor Relay): retries aggressively with exponential backoff (800ms doubling +// up to timeout), never gives up. This ensures rapid recovery when the peer has no connectivity at all. +// - PartiallyConnected (Relay up, ICE not): retries up to 3 times with exponential backoff, then switches +// to one attempt per hour. This limits signaling traffic when relay already provides connectivity. +// +// External events (relay/ICE disconnect, signal/relay reconnect, candidate changes) reset the retry +// counter and backoff ticker, giving ICE a fresh chance after network conditions change. func (g *Guard) reconnectLoopWithRetry(ctx context.Context, callback func()) { srReconnectedChan := g.srWatcher.NewListener() 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() + for { select { - case t := <-tickerChannel: - if t.IsZero() { - g.log.Infof("retry timed out, stop periodic offer sending") - // after backoff timeout the ticker.C will be closed. We need to a dummy channel to avoid loop - tickerChannel = make(<-chan time.Time) - continue + case <-tickerChannel: + switch g.isConnectedOnAllWay() { + case ConnStatusConnected: + // all good, nothing to do + case ConnStatusDisconnected: + callback() + case ConnStatusPartiallyConnected: + if iceState.shouldRetry() { + callback() + } else { + iceState.enterHourlyMode() + ticker.Stop() + tickerChannel = iceState.hourlyC() + } } - if !g.isConnectedOnAllWay() { - callback() - } case <-g.relayedConnDisconnected: g.log.Debugf("Relay connection changed, reset reconnection ticker") ticker.Stop() - ticker = g.prepareExponentTicker(ctx) + ticker = g.newReconnectTicker(ctx) tickerChannel = ticker.C + iceState.reset() case <-g.iCEConnDisconnected: g.log.Debugf("ICE connection changed, reset reconnection ticker") ticker.Stop() - ticker = g.prepareExponentTicker(ctx) + ticker = g.newReconnectTicker(ctx) tickerChannel = ticker.C + iceState.reset() case <-srReconnectedChan: g.log.Debugf("has network changes, reset reconnection ticker") ticker.Stop() - ticker = g.prepareExponentTicker(ctx) + ticker = g.newReconnectTicker(ctx) tickerChannel = ticker.C + iceState.reset() case <-ctx.Done(): g.log.Debugf("context is done, stop reconnect loop") @@ -120,7 +156,7 @@ func (g *Guard) initialTicker(ctx context.Context) *backoff.Ticker { return backoff.NewTicker(bo) } -func (g *Guard) prepareExponentTicker(ctx context.Context) *backoff.Ticker { +func (g *Guard) newReconnectTicker(ctx context.Context) *backoff.Ticker { bo := backoff.WithContext(&backoff.ExponentialBackOff{ InitialInterval: 800 * time.Millisecond, RandomizationFactor: 0.1, 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..ded3e4aea --- /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) +} + +// 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/ice_monitor.go b/client/internal/peer/guard/ice_monitor.go index 0f22ee7b0..a201dd095 100644 --- a/client/internal/peer/guard/ice_monitor.go +++ b/client/internal/peer/guard/ice_monitor.go @@ -78,7 +78,7 @@ func (cm *ICEMonitor) Start(ctx context.Context, onChanged func()) { func (cm *ICEMonitor) handleCandidateTick(ctx context.Context, ufrag string, pwd string) (bool, error) { log.Debugf("Gathering ICE candidates") - agent, err := icemaker.NewAgent(cm.iFaceDiscover, cm.iceConfig, candidateTypesP2P(), ufrag, pwd) + agent, err := icemaker.NewAgent(ctx, cm.iFaceDiscover, cm.iceConfig, candidateTypesP2P(), ufrag, pwd) if err != nil { return false, fmt.Errorf("create ICE agent: %w", err) } diff --git a/client/internal/peer/guard/ice_retry_state.go b/client/internal/peer/guard/ice_retry_state.go new file mode 100644 index 000000000..01dc1bf2d --- /dev/null +++ b/client/internal/peer/guard/ice_retry_state.go @@ -0,0 +1,61 @@ +package guard + +import ( + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + // maxICERetries is the maximum number of ICE offer attempts when relay is connected + maxICERetries = 3 + // iceRetryInterval is the periodic retry interval after ICE retries are exhausted + iceRetryInterval = 1 * time.Hour +) + +// iceRetryState tracks the limited ICE retry attempts when relay is already connected. +// After maxICERetries attempts it switches to a periodic hourly retry. +type iceRetryState struct { + log *log.Entry + retries int + hourly *time.Ticker +} + +func (s *iceRetryState) reset() { + s.retries = 0 + if s.hourly != nil { + s.hourly.Stop() + s.hourly = nil + } +} + +// shouldRetry reports whether the caller should send another ICE offer on this tick. +// Returns false when the per-cycle retry budget is exhausted and the caller must switch +// to the hourly ticker via enterHourlyMode + hourlyC. +func (s *iceRetryState) shouldRetry() bool { + if s.hourly != nil { + s.log.Debugf("hourly ICE retry attempt") + return true + } + + s.retries++ + if s.retries <= maxICERetries { + s.log.Debugf("ICE retry attempt %d/%d", s.retries, maxICERetries) + return true + } + + return false +} + +// enterHourlyMode starts the hourly retry ticker. Must be called after shouldRetry returns false. +func (s *iceRetryState) enterHourlyMode() { + s.log.Infof("ICE retries exhausted (%d/%d), switching to hourly retry", maxICERetries, maxICERetries) + s.hourly = time.NewTicker(iceRetryInterval) +} + +func (s *iceRetryState) hourlyC() <-chan time.Time { + if s.hourly == nil { + return nil + } + return s.hourly.C +} diff --git a/client/internal/peer/guard/ice_retry_state_test.go b/client/internal/peer/guard/ice_retry_state_test.go new file mode 100644 index 000000000..6a5b5a76f --- /dev/null +++ b/client/internal/peer/guard/ice_retry_state_test.go @@ -0,0 +1,103 @@ +package guard + +import ( + "testing" + + log "github.com/sirupsen/logrus" +) + +func newTestRetryState() *iceRetryState { + return &iceRetryState{log: log.NewEntry(log.StandardLogger())} +} + +func TestICERetryState_AllowsInitialBudget(t *testing.T) { + s := newTestRetryState() + + for i := 1; i <= maxICERetries; i++ { + if !s.shouldRetry() { + t.Fatalf("shouldRetry returned false on attempt %d, want true (budget = %d)", i, maxICERetries) + } + } +} + +func TestICERetryState_ExhaustsAfterBudget(t *testing.T) { + s := newTestRetryState() + + for i := 0; i < maxICERetries; i++ { + _ = s.shouldRetry() + } + + if s.shouldRetry() { + t.Fatalf("shouldRetry returned true after budget exhausted, want false") + } +} + +func TestICERetryState_HourlyCNilBeforeEnterHourlyMode(t *testing.T) { + s := newTestRetryState() + + if s.hourlyC() != nil { + t.Fatalf("hourlyC returned non-nil channel before enterHourlyMode") + } +} + +func TestICERetryState_EnterHourlyModeArmsTicker(t *testing.T) { + s := newTestRetryState() + for i := 0; i < maxICERetries+1; i++ { + _ = s.shouldRetry() + } + + s.enterHourlyMode() + defer s.reset() + + if s.hourlyC() == nil { + t.Fatalf("hourlyC returned nil after enterHourlyMode") + } +} + +func TestICERetryState_ShouldRetryTrueInHourlyMode(t *testing.T) { + s := newTestRetryState() + s.enterHourlyMode() + defer s.reset() + + if !s.shouldRetry() { + t.Fatalf("shouldRetry returned false in hourly mode, want true") + } + + // Subsequent calls also return true — we keep retrying on each hourly tick. + if !s.shouldRetry() { + t.Fatalf("second shouldRetry returned false in hourly mode, want true") + } +} + +func TestICERetryState_ResetRestoresBudget(t *testing.T) { + s := newTestRetryState() + for i := 0; i < maxICERetries+1; i++ { + _ = s.shouldRetry() + } + s.enterHourlyMode() + + s.reset() + + if s.hourlyC() != nil { + t.Fatalf("hourlyC returned non-nil channel after reset") + } + if s.retries != 0 { + t.Fatalf("retries = %d after reset, want 0", s.retries) + } + + for i := 1; i <= maxICERetries; i++ { + if !s.shouldRetry() { + t.Fatalf("shouldRetry returned false on attempt %d after reset, want true", i) + } + } +} + +func TestICERetryState_ResetIsIdempotent(t *testing.T) { + s := newTestRetryState() + s.reset() + s.reset() // second call must not panic or re-stop a nil ticker + + if s.hourlyC() != nil { + t.Fatalf("hourlyC non-nil after double reset") + } +} diff --git a/client/internal/peer/guard/sr_watcher.go b/client/internal/peer/guard/sr_watcher.go index 6f4f5ad4f..0befd7438 100644 --- a/client/internal/peer/guard/sr_watcher.go +++ b/client/internal/peer/guard/sr_watcher.go @@ -39,7 +39,7 @@ func NewSRWatcher(signalClient chNotifier, relayManager chNotifier, iFaceDiscove return srw } -func (w *SRWatcher) Start() { +func (w *SRWatcher) Start(disableICEMonitor bool) { w.mu.Lock() defer w.mu.Unlock() @@ -50,8 +50,10 @@ func (w *SRWatcher) Start() { ctx, cancel := context.WithCancel(context.Background()) w.cancelIceMonitor = cancel - iceMonitor := NewICEMonitor(w.iFaceDiscover, w.iceConfig, GetICEMonitorPeriod()) - go iceMonitor.Start(ctx, w.onICEChanged) + if !disableICEMonitor { + iceMonitor := NewICEMonitor(w.iFaceDiscover, w.iceConfig, GetICEMonitorPeriod()) + go iceMonitor.Start(ctx, w.onICEChanged) + } w.signalClient.SetOnReconnectedListener(w.onReconnected) w.relayManager.SetOnReconnectedListener(w.onReconnected) diff --git a/client/internal/peer/handshaker.go b/client/internal/peer/handshaker.go index aff26f847..56e82e6e3 100644 --- a/client/internal/peer/handshaker.go +++ b/client/internal/peer/handshaker.go @@ -3,7 +3,9 @@ package peer import ( "context" "errors" + "net/netip" "sync" + "sync/atomic" log "github.com/sirupsen/logrus" @@ -39,17 +41,26 @@ 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 } +func (o *OfferAnswer) hasICECredentials() bool { + return o.IceCredentials.UFrag != "" && o.IceCredentials.Pwd != "" +} + type Handshaker struct { - mu sync.Mutex - log *log.Entry - config ConnConfig - signaler *Signaler - ice *WorkerICE - relay *WorkerRelay + mu sync.Mutex + log *log.Entry + config ConnConfig + signaler *Signaler + ice *WorkerICE + relay *WorkerRelay + metricsStages *MetricsStages // relayListener is not blocking because the listener is using a goroutine to process the messages // and it will only keep the latest message if multiple offers are received in a short time // this is to avoid blocking the handshaker if the listener is doing some heavy processing @@ -58,22 +69,34 @@ type Handshaker struct { relayListener *AsyncOfferListener iceListener func(remoteOfferAnswer *OfferAnswer) + // remoteICESupported tracks whether the remote peer includes ICE credentials in its offers/answers. + // When false, the local side skips ICE listener dispatch and suppresses ICE credentials in responses. + remoteICESupported atomic.Bool + // remoteOffersCh is a channel used to wait for remote credentials to proceed with the connection remoteOffersCh chan OfferAnswer // remoteAnswerCh is a channel used to wait for remote credentials answer (confirmation of our offer) to proceed with the connection remoteAnswerCh chan OfferAnswer } -func NewHandshaker(log *log.Entry, config ConnConfig, signaler *Signaler, ice *WorkerICE, relay *WorkerRelay) *Handshaker { - return &Handshaker{ +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), } + // assume remote supports ICE until we learn otherwise from received offers + h.remoteICESupported.Store(ice != nil) + return h +} + +func (h *Handshaker) RemoteICESupported() bool { + return h.remoteICESupported.Load() } func (h *Handshaker) AddRelayListener(offer func(remoteOfferAnswer *OfferAnswer)) { @@ -88,12 +111,20 @@ func (h *Handshaker) Listen(ctx context.Context) { for { select { case remoteOfferAnswer := <-h.remoteOffersCh: - h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString()) + h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials()) + + // Record signaling received for reconnection attempts + if h.metricsStages != nil { + h.metricsStages.RecordSignalingReceived() + } + + h.updateRemoteICEState(&remoteOfferAnswer) + if h.relayListener != nil { h.relayListener.Notify(&remoteOfferAnswer) } - if h.iceListener != nil { + if h.iceListener != nil && h.RemoteICESupported() { h.iceListener(&remoteOfferAnswer) } @@ -102,12 +133,20 @@ func (h *Handshaker) Listen(ctx context.Context) { continue } case remoteOfferAnswer := <-h.remoteAnswerCh: - h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString()) + h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials()) + + // Record signaling received for reconnection attempts + if h.metricsStages != nil { + h.metricsStages.RecordSignalingReceived() + } + + h.updateRemoteICEState(&remoteOfferAnswer) + if h.relayListener != nil { h.relayListener.Notify(&remoteOfferAnswer) } - if h.iceListener != nil { + if h.iceListener != nil && h.RemoteICESupported() { h.iceListener(&remoteOfferAnswer) } case <-ctx.Done(): @@ -156,33 +195,52 @@ 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) } func (h *Handshaker) buildOfferAnswer() OfferAnswer { - uFrag, pwd := h.ice.GetLocalUserCredentials() - sid := h.ice.SessionID() answer := OfferAnswer{ - IceCredentials: IceCredentials{uFrag, pwd}, WgListenPort: h.config.LocalWgPort, Version: version.NetbirdVersion(), RosenpassPubKey: h.config.RosenpassConfig.PubKey, RosenpassAddr: h.config.RosenpassConfig.Addr, - SessionID: &sid, } - if addr, err := h.relay.RelayInstanceAddress(); err == nil { + if h.ice != nil && h.RemoteICESupported() { + uFrag, pwd := h.ice.GetLocalUserCredentials() + sid := h.ice.SessionID() + answer.IceCredentials = IceCredentials{uFrag, pwd} + answer.SessionID = &sid + } + + if addr, ip, err := h.relay.RelayInstanceAddress(); err == nil { answer.RelaySrvAddress = addr + answer.RelaySrvIP = ip } return answer } + +func (h *Handshaker) updateRemoteICEState(offer *OfferAnswer) { + hasICE := offer.hasICECredentials() + prev := h.remoteICESupported.Swap(hasICE) + if prev != hasICE { + if hasICE { + h.log.Infof("remote peer started sending ICE credentials") + } else { + h.log.Infof("remote peer stopped sending ICE credentials") + if h.ice != nil { + h.ice.Close() + } + } + } +} diff --git a/client/internal/peer/ice/agent.go b/client/internal/peer/ice/agent.go index e80c98884..c74b46d10 100644 --- a/client/internal/peer/ice/agent.go +++ b/client/internal/peer/ice/agent.go @@ -1,6 +1,8 @@ package ice import ( + "context" + "fmt" "sync" "time" @@ -22,6 +24,8 @@ const ( iceFailedTimeoutDefault = 6 * time.Second // iceRelayAcceptanceMinWaitDefault is the same as in the Pion ICE package iceRelayAcceptanceMinWaitDefault = 2 * time.Second + // iceAgentCloseTimeout is the maximum time to wait for ICE agent close to complete + iceAgentCloseTimeout = 3 * time.Second ) type ThreadSafeAgent struct { @@ -29,21 +33,13 @@ type ThreadSafeAgent struct { once sync.Once } -func (a *ThreadSafeAgent) Close() error { - var err error - a.once.Do(func() { - err = a.Agent.Close() - }) - return err -} - -func NewAgent(iFaceDiscover stdnet.ExternalIFaceDiscover, config Config, candidateTypes []ice.CandidateType, ufrag string, pwd string) (*ThreadSafeAgent, error) { +func NewAgent(ctx context.Context, iFaceDiscover stdnet.ExternalIFaceDiscover, config Config, candidateTypes []ice.CandidateType, ufrag string, pwd string) (*ThreadSafeAgent, error) { iceKeepAlive := iceKeepAlive() iceDisconnectedTimeout := iceDisconnectedTimeout() iceFailedTimeout := iceFailedTimeout() iceRelayAcceptanceMinWait := iceRelayAcceptanceMinWait() - transportNet, err := newStdNet(iFaceDiscover, config.InterfaceBlackList) + transportNet, err := newStdNet(ctx, iFaceDiscover, config.InterfaceBlackList) if err != nil { log.Errorf("failed to create pion's stdnet: %s", err) } @@ -80,9 +76,41 @@ func NewAgent(iFaceDiscover stdnet.ExternalIFaceDiscover, config Config, candida return nil, err } + if agent == nil { + return nil, fmt.Errorf("ice.NewAgent returned nil agent without error") + } + return &ThreadSafeAgent{Agent: agent}, nil } +func (a *ThreadSafeAgent) Close() error { + var err error + a.once.Do(func() { + // Defensive check to prevent nil pointer dereference + // This can happen during sleep/wake transitions or memory corruption scenarios + // github.com/netbirdio/netbird/client/internal/peer/ice.(*ThreadSafeAgent).Close(0x40006883f0?) + // [signal 0xc0000005 code=0x0 addr=0x0 pc=0x7ff7e73af83c] + agent := a.Agent + if agent == nil { + log.Warnf("ICE agent is nil during close, skipping") + return + } + + done := make(chan error, 1) + go func() { + done <- agent.Close() + }() + + select { + case err = <-done: + case <-time.After(iceAgentCloseTimeout): + log.Warnf("ICE agent close timed out after %v, proceeding with cleanup", iceAgentCloseTimeout) + err = nil + } + }) + return err +} + func GenerateICECredentials() (string, string, error) { ufrag, err := randutil.GenerateCryptoRandomString(lenUFrag, runesAlpha) if err != nil { diff --git a/client/internal/peer/ice/stdnet.go b/client/internal/peer/ice/stdnet.go index 3ce83727e..685ed0363 100644 --- a/client/internal/peer/ice/stdnet.go +++ b/client/internal/peer/ice/stdnet.go @@ -3,9 +3,11 @@ package ice import ( + "context" + "github.com/netbirdio/netbird/client/internal/stdnet" ) -func newStdNet(_ stdnet.ExternalIFaceDiscover, ifaceBlacklist []string) (*stdnet.Net, error) { - return stdnet.NewNet(ifaceBlacklist) +func newStdNet(ctx context.Context, _ stdnet.ExternalIFaceDiscover, ifaceBlacklist []string) (*stdnet.Net, error) { + return stdnet.NewNet(ctx, ifaceBlacklist) } diff --git a/client/internal/peer/ice/stdnet_android.go b/client/internal/peer/ice/stdnet_android.go index 84c665e6f..5033ec1b9 100644 --- a/client/internal/peer/ice/stdnet_android.go +++ b/client/internal/peer/ice/stdnet_android.go @@ -1,7 +1,11 @@ package ice -import "github.com/netbirdio/netbird/client/internal/stdnet" +import ( + "context" -func newStdNet(iFaceDiscover stdnet.ExternalIFaceDiscover, ifaceBlacklist []string) (*stdnet.Net, error) { - return stdnet.NewNetWithDiscover(iFaceDiscover, ifaceBlacklist) + "github.com/netbirdio/netbird/client/internal/stdnet" +) + +func newStdNet(ctx context.Context, iFaceDiscover stdnet.ExternalIFaceDiscover, ifaceBlacklist []string) (*stdnet.Net, error) { + return stdnet.NewNetWithDiscover(ctx, iFaceDiscover, ifaceBlacklist) } diff --git a/client/internal/peer/metrics_saver.go b/client/internal/peer/metrics_saver.go new file mode 100644 index 000000000..e32afbfe5 --- /dev/null +++ b/client/internal/peer/metrics_saver.go @@ -0,0 +1,73 @@ +package peer + +import ( + "sync" + "time" + + "github.com/netbirdio/netbird/client/internal/metrics" +) + +type MetricsStages struct { + isReconnectionAttempt bool // Track if current attempt is a reconnection + stageTimestamps metrics.ConnectionStageTimestamps + mu sync.Mutex +} + +// RecordSignalingReceived records when the first signal is received from the remote peer. +// Used as the base for all subsequent stage durations to avoid inflating metrics when +// the remote peer was offline. +func (s *MetricsStages) RecordSignalingReceived() { + s.mu.Lock() + defer s.mu.Unlock() + + if s.stageTimestamps.SignalingReceived.IsZero() { + s.stageTimestamps.SignalingReceived = time.Now() + } +} + +func (s *MetricsStages) RecordConnectionReady(when time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + if s.stageTimestamps.ConnectionReady.IsZero() { + s.stageTimestamps.ConnectionReady = when + } +} + +func (s *MetricsStages) RecordWGHandshakeSuccess(handshakeTime time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.stageTimestamps.ConnectionReady.IsZero() && s.stageTimestamps.WgHandshakeSuccess.IsZero() { + // WireGuard only reports handshake times with second precision, but ConnectionReady + // is captured with microsecond precision. If handshake appears before ConnectionReady + // due to truncation (e.g., handshake at 6.042s truncated to 6.000s), normalize to + // ConnectionReady to avoid negative duration metrics. + if handshakeTime.Before(s.stageTimestamps.ConnectionReady) { + s.stageTimestamps.WgHandshakeSuccess = s.stageTimestamps.ConnectionReady + } else { + s.stageTimestamps.WgHandshakeSuccess = handshakeTime + } + } +} + +// Disconnected sets the mode to reconnection. It is called only when both ICE and Relay have been disconnected at the same time. +func (s *MetricsStages) Disconnected() { + s.mu.Lock() + defer s.mu.Unlock() + + // Reset all timestamps for reconnection + s.stageTimestamps = metrics.ConnectionStageTimestamps{} + s.isReconnectionAttempt = true +} + +func (s *MetricsStages) IsReconnection() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.isReconnectionAttempt +} + +func (s *MetricsStages) GetTimestamps() metrics.ConnectionStageTimestamps { + s.mu.Lock() + defer s.mu.Unlock() + return s.stageTimestamps +} diff --git a/client/internal/peer/metrics_saver_test.go b/client/internal/peer/metrics_saver_test.go new file mode 100644 index 000000000..01c0aa9ac --- /dev/null +++ b/client/internal/peer/metrics_saver_test.go @@ -0,0 +1,125 @@ +package peer + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/metrics" +) + +func TestMetricsStages_RecordSignalingReceived(t *testing.T) { + s := &MetricsStages{} + + s.RecordSignalingReceived() + ts := s.GetTimestamps() + require.False(t, ts.SignalingReceived.IsZero()) + + // Second call should not overwrite + first := ts.SignalingReceived + time.Sleep(time.Millisecond) + s.RecordSignalingReceived() + ts = s.GetTimestamps() + assert.Equal(t, first, ts.SignalingReceived, "should keep the first signaling timestamp") +} + +func TestMetricsStages_RecordConnectionReady(t *testing.T) { + s := &MetricsStages{} + + now := time.Now() + s.RecordConnectionReady(now) + ts := s.GetTimestamps() + assert.Equal(t, now, ts.ConnectionReady) + + // Second call should not overwrite + later := now.Add(time.Second) + s.RecordConnectionReady(later) + ts = s.GetTimestamps() + assert.Equal(t, now, ts.ConnectionReady, "should keep the first connection ready timestamp") +} + +func TestMetricsStages_RecordWGHandshakeSuccess(t *testing.T) { + s := &MetricsStages{} + + connReady := time.Now() + s.RecordConnectionReady(connReady) + + handshake := connReady.Add(500 * time.Millisecond) + s.RecordWGHandshakeSuccess(handshake) + + ts := s.GetTimestamps() + assert.Equal(t, handshake, ts.WgHandshakeSuccess) +} + +func TestMetricsStages_HandshakeBeforeConnectionReady_Normalizes(t *testing.T) { + s := &MetricsStages{} + + connReady := time.Now() + s.RecordConnectionReady(connReady) + + // WG handshake appears before ConnectionReady due to second-precision truncation + handshake := connReady.Add(-100 * time.Millisecond) + s.RecordWGHandshakeSuccess(handshake) + + ts := s.GetTimestamps() + assert.Equal(t, connReady, ts.WgHandshakeSuccess, "should normalize to ConnectionReady when handshake appears earlier") +} + +func TestMetricsStages_HandshakeIgnoredWithoutConnectionReady(t *testing.T) { + s := &MetricsStages{} + + s.RecordWGHandshakeSuccess(time.Now()) + ts := s.GetTimestamps() + assert.True(t, ts.WgHandshakeSuccess.IsZero(), "should not record handshake without connection ready") +} + +func TestMetricsStages_HandshakeRecordedOnce(t *testing.T) { + s := &MetricsStages{} + + connReady := time.Now() + s.RecordConnectionReady(connReady) + + first := connReady.Add(time.Second) + s.RecordWGHandshakeSuccess(first) + + // Second call (rekey) should be ignored + second := connReady.Add(2 * time.Second) + s.RecordWGHandshakeSuccess(second) + + ts := s.GetTimestamps() + assert.Equal(t, first, ts.WgHandshakeSuccess, "should preserve first handshake, ignore rekeys") +} + +func TestMetricsStages_Disconnected(t *testing.T) { + s := &MetricsStages{} + + s.RecordSignalingReceived() + s.RecordConnectionReady(time.Now()) + assert.False(t, s.IsReconnection()) + + s.Disconnected() + + assert.True(t, s.IsReconnection()) + ts := s.GetTimestamps() + assert.True(t, ts.SignalingReceived.IsZero(), "timestamps should be reset after disconnect") + assert.True(t, ts.ConnectionReady.IsZero(), "timestamps should be reset after disconnect") + assert.True(t, ts.WgHandshakeSuccess.IsZero(), "timestamps should be reset after disconnect") +} + +func TestMetricsStages_GetTimestamps(t *testing.T) { + s := &MetricsStages{} + + ts := s.GetTimestamps() + assert.Equal(t, metrics.ConnectionStageTimestamps{}, ts) + + now := time.Now() + s.RecordSignalingReceived() + s.RecordConnectionReady(now) + + ts = s.GetTimestamps() + assert.False(t, ts.SignalingReceived.IsZero()) + assert.Equal(t, now, ts.ConnectionReady) + assert.True(t, ts.WgHandshakeSuccess.IsZero()) +} diff --git a/client/internal/peer/notifier_test.go b/client/internal/peer/notifier_test.go index bbdc00e13..0b7722b0c 100644 --- a/client/internal/peer/notifier_test.go +++ b/client/internal/peer/notifier_test.go @@ -8,6 +8,7 @@ import ( type mocListener struct { lastState int wg sync.WaitGroup + peersWg sync.WaitGroup peers int } @@ -33,6 +34,7 @@ func (l *mocListener) OnAddressChanged(host, addr string) { } func (l *mocListener) OnPeersListChanged(size int) { l.peers = size + l.peersWg.Done() } func (l *mocListener) setWaiter() { @@ -43,6 +45,14 @@ 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 { @@ -72,11 +82,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.setListener(listener) listener.wait() + listener.waitPeers() if listener.lastState != n.lastNotification { t.Errorf("invalid state: %d, expected: %d", listener.lastState, n.lastNotification) } @@ -85,9 +97,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.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 b28906625..5e437d96b 100644 --- a/client/internal/peer/signaler.go +++ b/client/internal/peer/signaler.go @@ -46,23 +46,27 @@ func (s *Signaler) Ready() bool { // SignalOfferAnswer signals either an offer or an answer to remote peer func (s *Signaler) signalOfferAnswer(offerAnswer OfferAnswer, remoteKey string, bodyType sProto.Body_Type) error { - sessionIDBytes, err := offerAnswer.SessionID.Bytes() - if err != nil { - log.Warnf("failed to get session ID bytes: %v", err) + var sessionIDBytes []byte + if offerAnswer.SessionID != nil { + var err error + sessionIDBytes, err = offerAnswer.SessionID.Bytes() + if err != nil { + 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 239cce7e0..423ce9b23 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" @@ -14,6 +15,7 @@ import ( "golang.org/x/exp/maps" "google.golang.org/grpc/codes" gstatus "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" firewall "github.com/netbirdio/netbird/client/firewall/manager" @@ -21,9 +23,9 @@ import ( "github.com/netbirdio/netbird/client/internal/ingressgw" "github.com/netbirdio/netbird/client/internal/relay" "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" relayClient "github.com/netbirdio/netbird/shared/relay/client" - "github.com/netbirdio/netbird/route" ) const eventQueueSize = 10 @@ -52,6 +54,7 @@ type RouterState struct { type State struct { Mux *sync.RWMutex IP string + IPv6 string PubKey string FQDN string ConnStatus ConnStatus @@ -67,6 +70,7 @@ type State struct { BytesRx int64 Latency time.Duration RosenpassEnabled bool + SSHHostKey []byte routes map[string]struct{} } @@ -104,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{} } @@ -157,6 +163,7 @@ type FullStatus struct { NSGroupStates []NSGroupState NumOfForwardingRules int LazyConnectionEnabled bool + Events []*proto.SystemEvent } type StatusChangeSubscription struct { @@ -180,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 @@ -212,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 @@ -222,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, @@ -233,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 } @@ -256,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() @@ -267,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 { @@ -288,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 { @@ -299,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 } @@ -317,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") } @@ -340,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") } @@ -368,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") } @@ -390,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 } @@ -407,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") } @@ -428,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") } @@ -458,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") } @@ -487,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") } @@ -519,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 } @@ -572,21 +686,54 @@ func (d *Status) UpdatePeerFQDN(peerPubKey, fqdn string) error { return nil } -// FinishPeerListModifications this event invoke the notification -func (d *Status) FinishPeerListModifications() { +// UpdatePeerSSHHostKey updates peer's SSH host key +func (d *Status) UpdatePeerSSHHostKey(peerPubKey string, sshHostKey []byte) error { d.mux.Lock() defer d.mux.Unlock() + peerState, ok := d.peers[peerPubKey] + if !ok { + return errors.New("peer doesn't exist") + } + + peerState.SSHHostKey = sshHostKey + d.peers[peerPubKey] = peerState + + return nil +} + +// FinishPeerListModifications this event invoke the notification +func (d *Status) FinishPeerListModifications() { + d.mux.Lock() + 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 { @@ -628,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 @@ -702,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 @@ -759,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 } @@ -820,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, @@ -829,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, @@ -862,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 { @@ -878,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, @@ -889,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, @@ -912,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 } @@ -929,16 +1154,16 @@ 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) } @@ -954,8 +1179,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 @@ -964,22 +1189,26 @@ func (d *Status) GetFullStatus() FullStatus { } fullStatus.Peers = append(fullStatus.Peers, d.offlinePeers...) + fullStatus.Events = d.GetEventHistory() return 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() } // SetConnectionListener set a listener to the notifier @@ -992,18 +1221,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] @@ -1011,13 +1239,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 { @@ -1027,14 +1277,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) } @@ -1108,6 +1350,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() @@ -1116,8 +1431,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") } @@ -1125,6 +1440,38 @@ func (d *Status) PeersStatus() (*configurer.Stats, error) { return d.wgIface.FullStats() } +// RefreshWireGuardStats fetches fresh WireGuard statistics from the interface +// and updates the cached peer states. This ensures accurate handshake times and +// transfer statistics in status reports without running full health probes. +func (d *Status) RefreshWireGuardStats() error { + d.mux.Lock() + defer d.mux.Unlock() + + if d.wgIface == nil { + return nil // silently skip if interface not set + } + + stats, err := d.wgIface.FullStats() + if err != nil { + return fmt.Errorf("get wireguard stats: %w", err) + } + + // Update each peer's WireGuard statistics + for _, peerStats := range stats.Peers { + peerState, ok := d.peers[peerStats.PublicKey] + if !ok { + continue + } + + peerState.LastWireguardHandshake = peerStats.LastHandshake + peerState.BytesRx = peerStats.RxBytes + peerState.BytesTx = peerStats.TxBytes + d.peers[peerStats.PublicKey] = peerState + } + + return nil +} + type EventQueue struct { maxSize int events []*proto.SystemEvent @@ -1164,3 +1511,101 @@ type EventSubscription struct { func (s *EventSubscription) Events() <-chan *proto.SystemEvent { return s.events } + +// ToProto converts FullStatus to proto.FullStatus. +func (fs FullStatus) ToProto() *proto.FullStatus { + pbFullStatus := proto.FullStatus{ + ManagementState: &proto.ManagementState{}, + SignalState: &proto.SignalState{}, + LocalPeerState: &proto.LocalPeerState{}, + Peers: []*proto.PeerState{}, + } + + pbFullStatus.ManagementState.URL = fs.ManagementState.URL + pbFullStatus.ManagementState.Connected = fs.ManagementState.Connected + if err := fs.ManagementState.Error; err != nil { + pbFullStatus.ManagementState.Error = err.Error() + } + + pbFullStatus.SignalState.URL = fs.SignalState.URL + pbFullStatus.SignalState.Connected = fs.SignalState.Connected + if err := fs.SignalState.Error; err != nil { + pbFullStatus.SignalState.Error = err.Error() + } + + 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) + pbFullStatus.LazyConnectionEnabled = fs.LazyConnectionEnabled + + pbFullStatus.LocalPeerState.Networks = maps.Keys(fs.LocalPeerState.Routes) + + for _, peerState := range fs.Peers { + networks := maps.Keys(peerState.GetRoutes()) + + pbPeerState := &proto.PeerState{ + IP: peerState.IP, + Ipv6: peerState.IPv6, + PubKey: peerState.PubKey, + ConnStatus: peerState.ConnStatus.String(), + ConnStatusUpdate: timestamppb.New(peerState.ConnStatusUpdate), + Relayed: peerState.Relayed, + LocalIceCandidateType: peerState.LocalIceCandidateType, + RemoteIceCandidateType: peerState.RemoteIceCandidateType, + LocalIceCandidateEndpoint: peerState.LocalIceCandidateEndpoint, + RemoteIceCandidateEndpoint: peerState.RemoteIceCandidateEndpoint, + RelayAddress: peerState.RelayServerAddress, + Fqdn: peerState.FQDN, + LastWireguardHandshake: timestamppb.New(peerState.LastWireguardHandshake), + BytesRx: peerState.BytesRx, + BytesTx: peerState.BytesTx, + RosenpassEnabled: peerState.RosenpassEnabled, + Networks: networks, + Latency: durationpb.New(peerState.Latency), + SshHostKey: peerState.SSHHostKey, + } + pbFullStatus.Peers = append(pbFullStatus.Peers, pbPeerState) + } + + for _, relayState := range fs.Relays { + pbRelayState := &proto.RelayState{ + URI: relayState.URI, + Available: relayState.Err == nil, + Transport: relayState.Transport, + } + if err := relayState.Err; err != nil { + pbRelayState.Error = err.Error() + } + pbFullStatus.Relays = append(pbFullStatus.Relays, pbRelayState) + } + + for _, dnsState := range fs.NSGroupStates { + var err string + if dnsState.Error != nil { + err = dnsState.Error.Error() + } + + var servers []string + for _, server := range dnsState.Servers { + servers = append(servers, server.String()) + } + + pbDnsState := &proto.NSGroupState{ + Servers: servers, + Domains: dnsState.Domains, + Enabled: dnsState.Enabled, + Error: err, + } + pbFullStatus.DnsServers = append(pbFullStatus.DnsServers, pbDnsState) + } + + pbFullStatus.Events = fs.Events + + return &pbFullStatus +} diff --git a/client/internal/peer/status_test.go b/client/internal/peer/status_test.go index 272638750..29404d413 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,72 @@ 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") +} + func TestStatus_UpdatePeerFQDN(t *testing.T) { key := "abc" fqdn := "peer-a.netbird.local" @@ -85,7 +152,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 +314,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 0ed200fda..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,16 +23,18 @@ 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 - ctx context.Context - ctxCancel context.CancelFunc - ctxLock sync.Mutex - enabledTime time.Time + // initialHandshake is not thread-safe; never call PrepareInitialHandshake and EnableWgWatcher concurrently. + initialHandshake time.Time + + resetCh chan struct{} } func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey string, stateDump *stateDump) *WGWatcher { @@ -42,56 +43,44 @@ func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey strin wgIfaceStater: wgIfaceStater, peerKey: peerKey, stateDump: stateDump, + resetCh: make(chan struct{}, 1), } } -// EnableWgWatcher starts the WireGuard watcher. If it is already enabled, it will return immediately and do nothing. -func (w *WGWatcher) EnableWgWatcher(parentCtx context.Context, onDisconnectedFn func()) { +// 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.ctxLock.Lock() - w.enabledTime = time.Now() - - if w.ctx != nil && w.ctx.Err() == nil { - w.log.Errorf("WireGuard watcher already enabled") - w.ctxLock.Unlock() - return - } - - ctx, ctxCancel := context.WithCancel(parentCtx) - w.ctx = ctx - w.ctxCancel = ctxCancel - w.ctxLock.Unlock() - - initialHandshake, err := w.wgState() - if err != nil { - w.log.Warnf("failed to read initial wg stats: %v", err) - } - - w.periodicHandshakeCheck(ctx, ctxCancel, onDisconnectedFn, initialHandshake) + handshake, _ := w.wgState() + w.initialHandshake = handshake } -// DisableWgWatcher stops the WireGuard watcher and wait for the watcher to exit -func (w *WGWatcher) DisableWgWatcher() { - w.ctxLock.Lock() - defer w.ctxLock.Unlock() +// 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) +} - if w.ctxCancel == nil { - return +// Reset signals the watcher that the WireGuard peer has been reset and a new +// handshake is expected. This restarts the handshake timeout from scratch. +func (w *WGWatcher) Reset() { + select { + case w.resetCh <- struct{}{}: + default: } - - w.log.Debugf("disable WireGuard watcher") - - w.ctxCancel() - w.ctxCancel = nil } // wgStateCheck help to check the state of the WireGuard handshake and relay connection -func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, ctxCancel context.CancelFunc, onDisconnectedFn func(), 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) defer timer.Stop() - defer ctxCancel() lastHandshake := initialHandshake @@ -100,12 +89,23 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, ctxCancel contex 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 := handshake.Sub(w.enabledTime).Seconds() + elapsed := calcElapsed(enabledTime, *handshake) w.log.Infof("first wg handshake detected within: %.2fsec, (%s)", elapsed, handshake) + if onHandshakeSuccessFn != nil && ctx.Err() == nil { + onHandshakeSuccessFn(*handshake) + } + } + + if onCheckSuccessFn != nil && ctx.Err() == nil { + onCheckSuccessFn() } lastHandshake = *handshake @@ -115,6 +115,12 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, ctxCancel contex w.stateDump.WGcheckSuccess() w.log.Debugf("WireGuard watcher reset timer: %v", resetTime) + case <-w.resetCh: + w.log.Infof("WireGuard watcher received peer reset, restarting handshake timeout") + lastHandshake = time.Time{} + enabledTime = time.Now() + timer.Stop() + timer.Reset(wgHandshakeOvertime) case <-ctx.Done(): w.log.Infof("WireGuard watcher stopped") return @@ -132,21 +138,21 @@ 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, closing relay connection: %v", handshake) + w.log.Warnf("WireGuard handshake not updated: %v", handshake) return nil, false } // in case if the machine is suspended, the handshake time will be in the past if handshake.Add(checkPeriod).Before(time.Now()) { - w.log.Warnf("WireGuard handshake timed out, closing relay connection: %v", handshake) + w.log.Warnf("WireGuard handshake timed out: %v", handshake) return nil, false } // error handling for handshake time in the future if handshake.After(time.Now()) { - w.log.Warnf("WireGuard handshake is in the future, closing relay connection: %v", handshake) + w.log.Warnf("WireGuard handshake is in the future: %v", handshake) return nil, false } @@ -164,3 +170,13 @@ func (w *WGWatcher) wgState() (time.Time, error) { } return wgState.LastHandshake, nil } + +// calcElapsed calculates elapsed time since watcher was enabled. +// The watcher started after the wg configuration happens, because of this need to normalise the negative value +func calcElapsed(enabledTime, handshake time.Time) float64 { + elapsed := handshake.Sub(enabledTime).Seconds() + if elapsed < 0 { + elapsed = 0 + } + return elapsed +} diff --git a/client/internal/peer/wg_watcher_test.go b/client/internal/peer/wg_watcher_test.go index d7c277eff..6a5a9acfe 100644 --- a/client/internal/peer/wg_watcher_test.go +++ b/client/internal/peer/wg_watcher_test.go @@ -2,6 +2,7 @@ package peer import ( "context" + "sync" "testing" "time" @@ -22,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 @@ -33,11 +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, func() { + 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) @@ -48,7 +119,6 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) { case <-time.After(10 * time.Second): t.Errorf("timeout") } - watcher.DisableWgWatcher() } func TestWGWatcher_ReEnable(t *testing.T) { @@ -60,17 +130,28 @@ 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) {}, nil) + }() + cancel() + + wg.Wait() + + // Re-enable with a new context + ctx, cancel = context.WithCancel(context.Background()) defer cancel() + watcher.PrepareInitialHandshake() + onDisconnected := make(chan struct{}, 1) - - go watcher.EnableWgWatcher(ctx, func() {}) - time.Sleep(1 * time.Second) - watcher.DisableWgWatcher() - - go watcher.EnableWgWatcher(ctx, func() { + go watcher.EnableWgWatcher(ctx, time.Now(), func() { onDisconnected <- struct{}{} - }) + }, func(when time.Time) {}, nil) time.Sleep(2 * time.Second) mocWgIface.disconnect() @@ -80,5 +161,4 @@ func TestWGWatcher_ReEnable(t *testing.T) { case <-time.After(10 * time.Second): t.Errorf("timeout") } - watcher.DisableWgWatcher() } diff --git a/client/internal/peer/worker_ice.go b/client/internal/peer/worker_ice.go index 5d8ebfe45..b1aa3e0f9 100644 --- a/client/internal/peer/worker_ice.go +++ b/client/internal/peer/worker_ice.go @@ -4,7 +4,7 @@ import ( "context" "fmt" "net" - "net/netip" + "strconv" "sync" "time" @@ -15,6 +15,7 @@ import ( "github.com/netbirdio/netbird/client/iface/udpmux" "github.com/netbirdio/netbird/client/internal/peer/conntype" icemaker "github.com/netbirdio/netbird/client/internal/peer/ice" + "github.com/netbirdio/netbird/client/internal/portforward" "github.com/netbirdio/netbird/client/internal/stdnet" "github.com/netbirdio/netbird/route" ) @@ -51,14 +52,18 @@ type WorkerICE struct { // increase by one when disconnecting the agent // with it the remote peer can discard the already deprecated offer/answer // Without it the remote peer may recreate a workable ICE connection - sessionID ICESessionID - muxAgent sync.Mutex + sessionID ICESessionID + remoteSessionChanged bool + muxAgent sync.Mutex localUfrag string localPwd string // we record the last known state of the ICE agent to avoid duplicate on disconnected events lastKnownState ice.ConnectionState + + // portForwardAttempted tracks if we've already tried port forwarding this session + portForwardAttempted bool } func NewWorkerICE(ctx context.Context, log *log.Entry, config ConnConfig, conn *Conn, signaler *Signaler, ifaceDiscover stdnet.ExternalIFaceDiscover, statusRecorder *Status, hasRelayOnLocally bool) (*WorkerICE, error) { @@ -105,9 +110,12 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) { return } w.log.Debugf("agent already exists, recreate the connection") + w.remoteSessionChanged = true w.agentDialerCancel() - if err := w.agent.Close(); err != nil { - w.log.Warnf("failed to close ICE agent: %s", err) + if w.agent != nil { + if err := w.agent.Close(); err != nil { + w.log.Warnf("failed to close ICE agent: %s", err) + } } sessionID, err := NewICESessionID() @@ -156,10 +164,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 @@ -209,7 +213,9 @@ func (w *WorkerICE) Close() { } func (w *WorkerICE) reCreateAgent(dialerCancel context.CancelFunc, candidates []ice.CandidateType) (*icemaker.ThreadSafeAgent, error) { - agent, err := icemaker.NewAgent(w.iFaceDiscover, w.config.ICEConfig, candidates, w.localUfrag, w.localPwd) + w.portForwardAttempted = false + + agent, err := icemaker.NewAgent(w.ctx, w.iFaceDiscover, w.config.ICEConfig, candidates, w.localUfrag, w.localPwd) if err != nil { return nil, fmt.Errorf("create agent: %w", err) } @@ -286,8 +292,8 @@ func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent RosenpassAddr: remoteOfferAnswer.RosenpassAddr, LocalIceCandidateType: pair.Local.Type().String(), RemoteIceCandidateType: pair.Remote.Type().String(), - LocalIceCandidateEndpoint: fmt.Sprintf("%s:%d", pair.Local.Address(), pair.Local.Port()), - RemoteIceCandidateEndpoint: fmt.Sprintf("%s:%d", pair.Remote.Address(), pair.Remote.Port()), + LocalIceCandidateEndpoint: net.JoinHostPort(pair.Local.Address(), strconv.Itoa(pair.Local.Port())), + RemoteIceCandidateEndpoint: net.JoinHostPort(pair.Remote.Address(), strconv.Itoa(pair.Remote.Port())), Relayed: isRelayed(pair), RelayedOnLocal: isRelayCandidate(pair.Local), } @@ -303,13 +309,17 @@ func (w *WorkerICE) connect(ctx context.Context, agent *icemaker.ThreadSafeAgent w.conn.onICEConnectionIsReady(selectedPriority(pair), ci) } -func (w *WorkerICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.CancelFunc) { +func (w *WorkerICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.CancelFunc) bool { cancel() if err := agent.Close(); err != nil { w.log.Warnf("failed to close ICE agent: %s", err) } w.muxAgent.Lock() + defer w.muxAgent.Unlock() + + sessionChanged := w.remoteSessionChanged + w.remoteSessionChanged = false if w.agent == agent { // consider to remove from here and move to the OnNewOffer @@ -322,19 +332,13 @@ func (w *WorkerICE) closeAgent(agent *icemaker.ThreadSafeAgent, cancel context.C w.agentConnecting = false w.remoteSessionID = "" } - w.muxAgent.Unlock() + return sessionChanged } func (w *WorkerICE) punchRemoteWGPort(pair *ice.CandidatePair, remoteWgPort int) { // wait local endpoint configuration time.Sleep(time.Second) - addrString := pair.Remote.Address() - parsed, err := netip.ParseAddr(addrString) - if (err == nil) && (parsed.Is6()) { - addrString = fmt.Sprintf("[%s]", addrString) - //IPv6 Literals need to be wrapped in brackets for Resolve*Addr() - } - addr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", addrString, remoteWgPort)) + addr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(pair.Remote.Address(), strconv.Itoa(remoteWgPort))) if err != nil { w.log.Warnf("got an error while resolving the udp address, err: %s", err) return @@ -367,6 +371,93 @@ func (w *WorkerICE) onICECandidate(candidate ice.Candidate) { w.log.Errorf("failed signaling candidate to the remote peer %s %s", w.config.Key, err) } }() + + if candidate.Type() == ice.CandidateTypeServerReflexive { + w.injectPortForwardedCandidate(candidate) + } +} + +// injectPortForwardedCandidate signals an additional candidate using the pre-created port mapping. +func (w *WorkerICE) injectPortForwardedCandidate(srflxCandidate ice.Candidate) { + pfManager := w.conn.portForwardManager + if pfManager == nil { + return + } + + mapping := pfManager.GetMapping() + if mapping == nil { + return + } + + w.muxAgent.Lock() + if w.portForwardAttempted { + w.muxAgent.Unlock() + return + } + w.portForwardAttempted = true + w.muxAgent.Unlock() + + forwardedCandidate, err := w.createForwardedCandidate(srflxCandidate, mapping) + if err != nil { + w.log.Warnf("create forwarded candidate: %v", err) + return + } + + w.log.Debugf("injecting port-forwarded candidate: %s (mapping: %d -> %d via %s, priority: %d)", + forwardedCandidate.String(), mapping.InternalPort, mapping.ExternalPort, mapping.NATType, forwardedCandidate.Priority()) + + go func() { + if err := w.signaler.SignalICECandidate(forwardedCandidate, w.config.Key); err != nil { + w.log.Errorf("signal port-forwarded candidate: %v", err) + } + }() +} + +// createForwardedCandidate creates a new server reflexive candidate with the forwarded port. +// It uses the NAT gateway's external IP with the forwarded port. +func (w *WorkerICE) createForwardedCandidate(srflxCandidate ice.Candidate, mapping *portforward.Mapping) (ice.Candidate, error) { + var externalIP string + if mapping.ExternalIP != nil && !mapping.ExternalIP.IsUnspecified() { + externalIP = mapping.ExternalIP.String() + } else { + // Fallback to STUN-discovered address if NAT didn't provide external IP + externalIP = srflxCandidate.Address() + } + + // Per RFC 8445, the related address for srflx is the base (host candidate address). + // If the original srflx has unspecified related address, use its own address as base. + relAddr := srflxCandidate.RelatedAddress().Address + if relAddr == "" || relAddr == "0.0.0.0" || relAddr == "::" { + relAddr = srflxCandidate.Address() + } + + // Arbitrary +1000 boost on top of RFC 8445 priority to favor port-forwarded candidates + // over regular srflx during ICE connectivity checks. + priority := srflxCandidate.Priority() + 1000 + + candidate, err := ice.NewCandidateServerReflexive(&ice.CandidateServerReflexiveConfig{ + Network: srflxCandidate.NetworkType().String(), + Address: externalIP, + Port: int(mapping.ExternalPort), + Component: srflxCandidate.Component(), + Priority: priority, + RelAddr: relAddr, + RelPort: int(mapping.InternalPort), + }) + if err != nil { + return nil, fmt.Errorf("create candidate: %w", err) + } + + for _, e := range srflxCandidate.Extensions() { + if e.Key == ice.ExtensionKeyCandidateID { + e.Value = srflxCandidate.ID() + } + if err := candidate.AddExtension(e); err != nil { + return nil, fmt.Errorf("add extension: %w", err) + } + } + + return candidate, nil } func (w *WorkerICE) onICESelectedCandidatePair(agent *icemaker.ThreadSafeAgent, c1, c2 ice.Candidate) { @@ -386,22 +477,54 @@ func (w *WorkerICE) onICESelectedCandidatePair(agent *icemaker.ThreadSafeAgent, } } +func (w *WorkerICE) logSuccessfulPaths(agent *icemaker.ThreadSafeAgent) { + sessionID := w.SessionID() + stats := agent.GetCandidatePairsStats() + localCandidates, _ := agent.GetLocalCandidates() + remoteCandidates, _ := agent.GetRemoteCandidates() + + localMap := make(map[string]ice.Candidate) + for _, c := range localCandidates { + localMap[c.ID()] = c + } + remoteMap := make(map[string]ice.Candidate) + for _, c := range remoteCandidates { + remoteMap[c.ID()] = c + } + + for _, stat := range stats { + if stat.State == ice.CandidatePairStateSucceeded { + local, lok := localMap[stat.LocalCandidateID] + remote, rok := remoteMap[stat.RemoteCandidateID] + if !lok || !rok { + continue + } + w.log.Debugf("successful ICE path %s: [%s %s %s:%d] <-> [%s %s %s:%d] rtt=%.3fms", + sessionID, + local.NetworkType(), local.Type(), local.Address(), local.Port(), + remote.NetworkType(), remote.Type(), remote.Address(), remote.Port(), + stat.CurrentRoundTripTime*1000) + } + } +} + func (w *WorkerICE) onConnectionStateChange(agent *icemaker.ThreadSafeAgent, dialerCancel context.CancelFunc) func(ice.ConnectionState) { return func(state ice.ConnectionState) { w.log.Debugf("ICE ConnectionState has changed to %s", state.String()) switch state { case ice.ConnectionStateConnected: w.lastKnownState = ice.ConnectionStateConnected + 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 - w.closeAgent(agent, dialerCancel) + sessionChanged := w.closeAgent(agent, dialerCancel) if w.lastKnownState == ice.ConnectionStateConnected { w.lastKnownState = ice.ConnectionStateDisconnected - w.conn.onICEStateDisconnected() + w.conn.onICEStateDisconnected(sessionChanged) } default: return @@ -461,34 +584,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_relay.go b/client/internal/peer/worker_relay.go index f584487f5..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" @@ -30,11 +31,9 @@ type WorkerRelay struct { relayLock sync.Mutex relaySupportedOnRemotePeer atomic.Bool - - wgWatcher *WGWatcher } -func NewWorkerRelay(ctx context.Context, log *log.Entry, ctrl bool, config ConnConfig, conn *Conn, relayManager *relayClient.Manager, stateDump *stateDump) *WorkerRelay { +func NewWorkerRelay(ctx context.Context, log *log.Entry, ctrl bool, config ConnConfig, conn *Conn, relayManager *relayClient.Manager) *WorkerRelay { r := &WorkerRelay{ peerCtx: ctx, log: log, @@ -42,7 +41,6 @@ func NewWorkerRelay(ctx context.Context, log *log.Entry, ctrl bool, config ConnC config: config, conn: conn, relayManager: relayManager, - wgWatcher: NewWGWatcher(log, config.WgConfig.WgInterface, config.Key, stateDump), } return r } @@ -56,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") @@ -93,15 +95,7 @@ func (w *WorkerRelay) OnNewOffer(remoteOfferAnswer *OfferAnswer) { }) } -func (w *WorkerRelay) EnableWgWatcher(ctx context.Context) { - w.wgWatcher.EnableWgWatcher(ctx, w.onWGDisconnected) -} - -func (w *WorkerRelay) DisableWgWatcher() { - w.wgWatcher.DisableWgWatcher() -} - -func (w *WorkerRelay) RelayInstanceAddress() (string, error) { +func (w *WorkerRelay) RelayInstanceAddress() (string, netip.Addr, error) { return w.relayManager.RelayInstanceAddress() } @@ -125,14 +119,6 @@ func (w *WorkerRelay) CloseConn() { } } -func (w *WorkerRelay) onWGDisconnected() { - w.relayLock.Lock() - _ = w.relayedConn.Close() - w.relayLock.Unlock() - - w.conn.onRelayDisconnected() -} - func (w *WorkerRelay) isRelaySupported(answer *OfferAnswer) bool { if !w.relayManager.HasRelayAddress() { return false @@ -148,6 +134,5 @@ func (w *WorkerRelay) preferredRelayServer(myRelayAddress, remoteRelayAddress st } func (w *WorkerRelay) onRelayClientDisconnected() { - w.wgWatcher.DisableWgWatcher() go w.conn.onRelayDisconnected() } 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/pkce_auth.go b/client/internal/pkce_auth.go deleted file mode 100644 index 23c92e8af..000000000 --- a/client/internal/pkce_auth.go +++ /dev/null @@ -1,138 +0,0 @@ -package internal - -import ( - "context" - "crypto/tls" - "fmt" - "net/url" - - log "github.com/sirupsen/logrus" - "golang.zx2c4.com/wireguard/wgctrl/wgtypes" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - mgm "github.com/netbirdio/netbird/shared/management/client" - "github.com/netbirdio/netbird/shared/management/client/common" -) - -// PKCEAuthorizationFlow represents PKCE Authorization Flow information -type PKCEAuthorizationFlow struct { - ProviderConfig PKCEAuthProviderConfig -} - -// PKCEAuthProviderConfig has all attributes needed to initiate pkce authorization flow -type PKCEAuthProviderConfig struct { - // ClientID An IDP application client id - ClientID string - // ClientSecret An IDP application client secret - ClientSecret string - // Audience An Audience for to authorization validation - Audience string - // TokenEndpoint is the endpoint of an IDP manager where clients can obtain access token - TokenEndpoint string - // AuthorizationEndpoint is the endpoint of an IDP manager where clients can obtain authorization code - AuthorizationEndpoint string - // Scopes provides the scopes to be included in the token request - Scope string - // RedirectURL handles authorization code from IDP manager - RedirectURLs []string - // UseIDToken indicates if the id token should be used for authentication - UseIDToken bool - // ClientCertPair is used for mTLS authentication to the IDP - ClientCertPair *tls.Certificate - // DisablePromptLogin makes the PKCE flow to not prompt the user for login - DisablePromptLogin bool - // LoginFlag is used to configure the PKCE flow login behavior - LoginFlag common.LoginFlag - // LoginHint is used to pre-fill the email/username field during authentication - LoginHint string -} - -// GetPKCEAuthorizationFlowInfo initialize a PKCEAuthorizationFlow instance and return with it -func GetPKCEAuthorizationFlowInfo(ctx context.Context, privateKey string, mgmURL *url.URL, clientCert *tls.Certificate) (PKCEAuthorizationFlow, error) { - // validate our peer's Wireguard PRIVATE key - myPrivateKey, err := wgtypes.ParseKey(privateKey) - if err != nil { - log.Errorf("failed parsing Wireguard key %s: [%s]", privateKey, err.Error()) - return PKCEAuthorizationFlow{}, err - } - - var mgmTLSEnabled bool - if mgmURL.Scheme == "https" { - mgmTLSEnabled = true - } - - log.Debugf("connecting to Management Service %s", mgmURL.String()) - mgmClient, err := mgm.NewClient(ctx, mgmURL.Host, myPrivateKey, mgmTLSEnabled) - if err != nil { - log.Errorf("failed connecting to Management Service %s %v", mgmURL.String(), err) - return PKCEAuthorizationFlow{}, err - } - log.Debugf("connected to the Management service %s", mgmURL.String()) - - defer func() { - err = mgmClient.Close() - if err != nil { - log.Warnf("failed to close the Management service client %v", err) - } - }() - - serverKey, err := mgmClient.GetServerPublicKey() - if err != nil { - log.Errorf("failed while getting Management Service public key: %v", err) - return PKCEAuthorizationFlow{}, err - } - - protoPKCEAuthorizationFlow, err := mgmClient.GetPKCEAuthorizationFlow(*serverKey) - if err != nil { - if s, ok := status.FromError(err); ok && s.Code() == codes.NotFound { - log.Warnf("server couldn't find pkce flow, contact admin: %v", err) - return PKCEAuthorizationFlow{}, err - } - log.Errorf("failed to retrieve pkce flow: %v", err) - return PKCEAuthorizationFlow{}, err - } - - authFlow := PKCEAuthorizationFlow{ - ProviderConfig: PKCEAuthProviderConfig{ - Audience: protoPKCEAuthorizationFlow.GetProviderConfig().GetAudience(), - ClientID: protoPKCEAuthorizationFlow.GetProviderConfig().GetClientID(), - ClientSecret: protoPKCEAuthorizationFlow.GetProviderConfig().GetClientSecret(), - TokenEndpoint: protoPKCEAuthorizationFlow.GetProviderConfig().GetTokenEndpoint(), - AuthorizationEndpoint: protoPKCEAuthorizationFlow.GetProviderConfig().GetAuthorizationEndpoint(), - Scope: protoPKCEAuthorizationFlow.GetProviderConfig().GetScope(), - RedirectURLs: protoPKCEAuthorizationFlow.GetProviderConfig().GetRedirectURLs(), - UseIDToken: protoPKCEAuthorizationFlow.GetProviderConfig().GetUseIDToken(), - ClientCertPair: clientCert, - DisablePromptLogin: protoPKCEAuthorizationFlow.GetProviderConfig().GetDisablePromptLogin(), - LoginFlag: common.LoginFlag(protoPKCEAuthorizationFlow.GetProviderConfig().GetLoginFlag()), - }, - } - - err = isPKCEProviderConfigValid(authFlow.ProviderConfig) - if err != nil { - return PKCEAuthorizationFlow{}, err - } - - return authFlow, nil -} - -func isPKCEProviderConfigValid(config PKCEAuthProviderConfig) error { - errorMSGFormat := "invalid provider configuration received from management: %s value is empty. Contact your NetBird administrator" - if config.ClientID == "" { - return fmt.Errorf(errorMSGFormat, "Client ID") - } - if config.TokenEndpoint == "" { - return fmt.Errorf(errorMSGFormat, "Token Endpoint") - } - if config.AuthorizationEndpoint == "" { - return fmt.Errorf(errorMSGFormat, "Authorization Auth Endpoint") - } - if config.Scope == "" { - return fmt.Errorf(errorMSGFormat, "PKCE Auth Scopes") - } - if config.RedirectURLs == nil { - return fmt.Errorf(errorMSGFormat, "PKCE Redirect URLs") - } - return nil -} diff --git a/client/internal/portforward/env.go b/client/internal/portforward/env.go new file mode 100644 index 000000000..ba83c79bf --- /dev/null +++ b/client/internal/portforward/env.go @@ -0,0 +1,35 @@ +package portforward + +import ( + "os" + "strconv" + + log "github.com/sirupsen/logrus" +) + +const ( + envDisableNATMapper = "NB_DISABLE_NAT_MAPPER" + envDisablePCPHealthCheck = "NB_DISABLE_PCP_HEALTH_CHECK" +) + +func isDisabledByEnv() bool { + return parseBoolEnv(envDisableNATMapper) +} + +func isHealthCheckDisabled() bool { + return parseBoolEnv(envDisablePCPHealthCheck) +} + +func parseBoolEnv(key string) bool { + val := os.Getenv(key) + if val == "" { + return false + } + + disabled, err := strconv.ParseBool(val) + if err != nil { + log.Warnf("failed to parse %s: %v", key, err) + return false + } + return disabled +} diff --git a/client/internal/portforward/manager.go b/client/internal/portforward/manager.go new file mode 100644 index 000000000..b0680160c --- /dev/null +++ b/client/internal/portforward/manager.go @@ -0,0 +1,342 @@ +//go:build !js + +package portforward + +import ( + "context" + "fmt" + "net" + "regexp" + "sync" + "time" + + "github.com/libp2p/go-nat" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/portforward/pcp" +) + +const ( + defaultMappingTTL = 2 * time.Hour + healthCheckInterval = 1 * time.Minute + discoveryTimeout = 10 * time.Second + mappingDescription = "NetBird" +) + +// upnpErrPermanentLeaseOnly matches UPnP error 725 in SOAP fault XML, +// allowing for whitespace/newlines between tags from different router firmware. +var upnpErrPermanentLeaseOnly = regexp.MustCompile(`\s*725\s*`) + +// Mapping represents an active NAT port mapping. +type Mapping struct { + Protocol string + InternalPort uint16 + ExternalPort uint16 + ExternalIP net.IP + NATType string + // TTL is the lease duration. Zero means a permanent lease that never expires. + TTL time.Duration +} + +// TODO: persist mapping state for crash recovery cleanup of permanent leases. +// Currently not done because State.Cleanup requires NAT gateway re-discovery, +// which blocks startup for ~10s when no gateway is present (affects all clients). + +type Manager struct { + cancel context.CancelFunc + + mapping *Mapping + mappingLock sync.Mutex + + wgPort uint16 + + done chan struct{} + stopCtx chan context.Context + + // protect exported functions + mu sync.Mutex +} + +// NewManager creates a new port forwarding manager. +func NewManager() *Manager { + return &Manager{ + stopCtx: make(chan context.Context, 1), + } +} + +func (m *Manager) Start(ctx context.Context, wgPort uint16) { + m.mu.Lock() + if m.cancel != nil { + m.mu.Unlock() + return + } + + if isDisabledByEnv() { + log.Infof("NAT port mapper disabled via %s", envDisableNATMapper) + m.mu.Unlock() + return + } + + if wgPort == 0 { + log.Warnf("invalid WireGuard port 0; NAT mapping disabled") + m.mu.Unlock() + return + } + m.wgPort = wgPort + + m.done = make(chan struct{}) + defer close(m.done) + + ctx, m.cancel = context.WithCancel(ctx) + m.mu.Unlock() + + gateway, mapping, err := m.setup(ctx) + if err != nil { + log.Infof("port forwarding setup: %v", err) + return + } + + m.mappingLock.Lock() + m.mapping = mapping + m.mappingLock.Unlock() + + m.renewLoop(ctx, gateway, mapping.TTL) + + select { + case cleanupCtx := <-m.stopCtx: + // block the Start while cleaned up gracefully + m.cleanup(cleanupCtx, gateway) + default: + // return Start immediately and cleanup in background + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second) + go func() { + defer cleanupCancel() + m.cleanup(cleanupCtx, gateway) + }() + } +} + +// GetMapping returns the current mapping if ready, nil otherwise +func (m *Manager) GetMapping() *Mapping { + m.mappingLock.Lock() + defer m.mappingLock.Unlock() + + if m.mapping == nil { + return nil + } + + mapping := *m.mapping + return &mapping +} + +// GracefullyStop cancels the manager and attempts to delete the port mapping. +// After GracefullyStop returns, the manager cannot be restarted. +func (m *Manager) GracefullyStop(ctx context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.cancel == nil { + return nil + } + + // Send cleanup context before cancelling, so Start picks it up after renewLoop exits. + m.startTearDown(ctx) + + m.cancel() + m.cancel = nil + + select { + case <-ctx.Done(): + return ctx.Err() + case <-m.done: + return nil + } +} + +func (m *Manager) setup(ctx context.Context) (nat.NAT, *Mapping, error) { + discoverCtx, discoverCancel := context.WithTimeout(ctx, discoveryTimeout) + defer discoverCancel() + + gateway, err := discoverGateway(discoverCtx) + if err != nil { + return nil, nil, fmt.Errorf("discover gateway: %w", err) + } + + log.Infof("discovered NAT gateway: %s", gateway.Type()) + + mapping, err := m.createMapping(ctx, gateway) + if err != nil { + return nil, nil, fmt.Errorf("create port mapping: %w", err) + } + return gateway, mapping, nil +} + +func (m *Manager) createMapping(ctx context.Context, gateway nat.NAT) (*Mapping, error) { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + ttl := defaultMappingTTL + externalPort, err := gateway.AddPortMapping(ctx, "udp", int(m.wgPort), mappingDescription, ttl) + if err != nil { + if !isPermanentLeaseRequired(err) { + return nil, err + } + log.Infof("gateway only supports permanent leases, retrying with indefinite duration") + ttl = 0 + externalPort, err = gateway.AddPortMapping(ctx, "udp", int(m.wgPort), mappingDescription, ttl) + if err != nil { + return nil, err + } + } + + externalIP, err := gateway.GetExternalAddress() + if err != nil { + log.Debugf("failed to get external address: %v", err) + } + + mapping := &Mapping{ + Protocol: "udp", + InternalPort: m.wgPort, + ExternalPort: uint16(externalPort), + ExternalIP: externalIP, + NATType: gateway.Type(), + TTL: ttl, + } + + log.Infof("created port mapping: %d -> %d via %s (external IP: %s)", + m.wgPort, externalPort, gateway.Type(), externalIP) + return mapping, nil +} + +func (m *Manager) renewLoop(ctx context.Context, gateway nat.NAT, ttl time.Duration) { + if ttl == 0 { + // Permanent mappings don't expire, just wait for cancellation + // but still run health checks for PCP gateways. + m.permanentLeaseLoop(ctx, gateway) + return + } + + renewTicker := time.NewTicker(ttl / 2) + healthTicker := time.NewTicker(healthCheckInterval) + defer renewTicker.Stop() + defer healthTicker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-renewTicker.C: + if err := m.renewMapping(ctx, gateway); err != nil { + log.Warnf("failed to renew port mapping: %v", err) + continue + } + case <-healthTicker.C: + if m.checkHealthAndRecreate(ctx, gateway) { + renewTicker.Reset(ttl / 2) + } + } + } +} + +func (m *Manager) permanentLeaseLoop(ctx context.Context, gateway nat.NAT) { + healthTicker := time.NewTicker(healthCheckInterval) + defer healthTicker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-healthTicker.C: + m.checkHealthAndRecreate(ctx, gateway) + } + } +} + +func (m *Manager) checkHealthAndRecreate(ctx context.Context, gateway nat.NAT) bool { + if isHealthCheckDisabled() { + return false + } + + m.mappingLock.Lock() + hasMapping := m.mapping != nil + m.mappingLock.Unlock() + + if !hasMapping { + return false + } + + pcpNAT, ok := gateway.(*pcp.NAT) + if !ok { + return false + } + + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + epoch, serverRestarted, err := pcpNAT.CheckServerHealth(ctx) + if err != nil { + log.Debugf("PCP health check failed: %v", err) + return false + } + + if serverRestarted { + log.Warnf("PCP server restart detected (epoch=%d), recreating port mapping", epoch) + if err := m.renewMapping(ctx, gateway); err != nil { + log.Errorf("failed to recreate port mapping after server restart: %v", err) + return false + } + return true + } + + return false +} + +func (m *Manager) renewMapping(ctx context.Context, gateway nat.NAT) error { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + externalPort, err := gateway.AddPortMapping(ctx, m.mapping.Protocol, int(m.mapping.InternalPort), mappingDescription, m.mapping.TTL) + if err != nil { + return fmt.Errorf("add port mapping: %w", err) + } + + if uint16(externalPort) != m.mapping.ExternalPort { + log.Warnf("external port changed on renewal: %d -> %d (candidate may be stale)", m.mapping.ExternalPort, externalPort) + m.mappingLock.Lock() + m.mapping.ExternalPort = uint16(externalPort) + m.mappingLock.Unlock() + } + + log.Debugf("renewed port mapping: %d -> %d", m.mapping.InternalPort, m.mapping.ExternalPort) + return nil +} + +func (m *Manager) cleanup(ctx context.Context, gateway nat.NAT) { + m.mappingLock.Lock() + mapping := m.mapping + m.mapping = nil + m.mappingLock.Unlock() + + if mapping == nil { + return + } + + if err := gateway.DeletePortMapping(ctx, mapping.Protocol, int(mapping.InternalPort)); err != nil { + log.Warnf("delete port mapping on stop: %v", err) + return + } + + log.Infof("deleted port mapping for port %d", mapping.InternalPort) +} + +func (m *Manager) startTearDown(ctx context.Context) { + select { + case m.stopCtx <- ctx: + default: + } +} + +// isPermanentLeaseRequired checks if a UPnP error indicates the gateway only supports permanent leases (error 725). +func isPermanentLeaseRequired(err error) bool { + return err != nil && upnpErrPermanentLeaseOnly.MatchString(err.Error()) +} diff --git a/client/internal/portforward/manager_js.go b/client/internal/portforward/manager_js.go new file mode 100644 index 000000000..36c55063b --- /dev/null +++ b/client/internal/portforward/manager_js.go @@ -0,0 +1,39 @@ +package portforward + +import ( + "context" + "net" + "time" +) + +// Mapping represents an active NAT port mapping. +type Mapping struct { + Protocol string + InternalPort uint16 + ExternalPort uint16 + ExternalIP net.IP + NATType string + // TTL is the lease duration. Zero means a permanent lease that never expires. + TTL time.Duration +} + +// Manager is a stub for js/wasm builds where NAT-PMP/UPnP is not supported. +type Manager struct{} + +// NewManager returns a stub manager for js/wasm builds. +func NewManager() *Manager { + return &Manager{} +} + +// Start is a no-op on js/wasm: NAT-PMP/UPnP is not available in browser environments. +func (m *Manager) Start(context.Context, uint16) { + // no NAT traversal in wasm +} + +// GracefullyStop is a no-op on js/wasm. +func (m *Manager) GracefullyStop(context.Context) error { return nil } + +// GetMapping always returns nil on js/wasm. +func (m *Manager) GetMapping() *Mapping { + return nil +} diff --git a/client/internal/portforward/manager_test.go b/client/internal/portforward/manager_test.go new file mode 100644 index 000000000..252a2ad51 --- /dev/null +++ b/client/internal/portforward/manager_test.go @@ -0,0 +1,200 @@ +//go:build !js + +package portforward + +import ( + "context" + "fmt" + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockNAT struct { + natType string + deviceAddr net.IP + externalAddr net.IP + internalAddr net.IP + mappings map[int]int + addMappingErr error + deleteMappingErr error + onlyPermanentLeases bool + lastTimeout time.Duration +} + +func newMockNAT() *mockNAT { + return &mockNAT{ + natType: "Mock-NAT", + deviceAddr: net.ParseIP("192.168.1.1"), + externalAddr: net.ParseIP("203.0.113.50"), + internalAddr: net.ParseIP("192.168.1.100"), + mappings: make(map[int]int), + } +} + +func (m *mockNAT) Type() string { + return m.natType +} + +func (m *mockNAT) GetDeviceAddress() (net.IP, error) { + return m.deviceAddr, nil +} + +func (m *mockNAT) GetExternalAddress() (net.IP, error) { + return m.externalAddr, nil +} + +func (m *mockNAT) GetInternalAddress() (net.IP, error) { + return m.internalAddr, nil +} + +func (m *mockNAT) AddPortMapping(ctx context.Context, protocol string, internalPort int, description string, timeout time.Duration) (int, error) { + if m.addMappingErr != nil { + return 0, m.addMappingErr + } + if m.onlyPermanentLeases && timeout != 0 { + return 0, fmt.Errorf("SOAP fault. Code: | Explanation: | Detail: 725OnlyPermanentLeasesSupported") + } + externalPort := internalPort + m.mappings[internalPort] = externalPort + m.lastTimeout = timeout + return externalPort, nil +} + +func (m *mockNAT) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error { + if m.deleteMappingErr != nil { + return m.deleteMappingErr + } + delete(m.mappings, internalPort) + return nil +} + +func TestManager_CreateMapping(t *testing.T) { + m := NewManager() + m.wgPort = 51820 + + gateway := newMockNAT() + mapping, err := m.createMapping(context.Background(), gateway) + require.NoError(t, err) + require.NotNil(t, mapping) + + assert.Equal(t, "udp", mapping.Protocol) + assert.Equal(t, uint16(51820), mapping.InternalPort) + assert.Equal(t, uint16(51820), mapping.ExternalPort) + assert.Equal(t, "Mock-NAT", mapping.NATType) + assert.Equal(t, net.ParseIP("203.0.113.50").To4(), mapping.ExternalIP.To4()) + assert.Equal(t, defaultMappingTTL, mapping.TTL) +} + +func TestManager_GetMapping_ReturnsNilWhenNotReady(t *testing.T) { + m := NewManager() + assert.Nil(t, m.GetMapping()) +} + +func TestManager_GetMapping_ReturnsCopy(t *testing.T) { + m := NewManager() + m.mapping = &Mapping{ + Protocol: "udp", + InternalPort: 51820, + ExternalPort: 51820, + } + + mapping := m.GetMapping() + require.NotNil(t, mapping) + assert.Equal(t, uint16(51820), mapping.InternalPort) + + // Mutating the returned copy should not affect the manager's mapping. + mapping.ExternalPort = 9999 + assert.Equal(t, uint16(51820), m.GetMapping().ExternalPort) +} + +func TestManager_Cleanup_DeletesMapping(t *testing.T) { + m := NewManager() + m.mapping = &Mapping{ + Protocol: "udp", + InternalPort: 51820, + ExternalPort: 51820, + } + + gateway := newMockNAT() + // Seed the mock so we can verify deletion. + gateway.mappings[51820] = 51820 + + m.cleanup(context.Background(), gateway) + + _, exists := gateway.mappings[51820] + assert.False(t, exists, "mapping should be deleted from gateway") + assert.Nil(t, m.GetMapping(), "in-memory mapping should be cleared") +} + +func TestManager_Cleanup_NilMapping(t *testing.T) { + m := NewManager() + gateway := newMockNAT() + + // Should not panic or call gateway. + m.cleanup(context.Background(), gateway) +} + +func TestManager_CreateMapping_PermanentLeaseFallback(t *testing.T) { + m := NewManager() + m.wgPort = 51820 + + gateway := newMockNAT() + gateway.onlyPermanentLeases = true + + mapping, err := m.createMapping(context.Background(), gateway) + require.NoError(t, err) + require.NotNil(t, mapping) + + assert.Equal(t, uint16(51820), mapping.InternalPort) + assert.Equal(t, time.Duration(0), mapping.TTL, "should return zero TTL for permanent lease") + assert.Equal(t, time.Duration(0), gateway.lastTimeout, "should have retried with zero duration") +} + +func TestIsPermanentLeaseRequired(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + { + name: "nil error", + err: nil, + expected: false, + }, + { + name: "UPnP error 725", + err: fmt.Errorf("SOAP fault. Code: | Detail: 725OnlyPermanentLeasesSupported"), + expected: true, + }, + { + name: "wrapped error with 725", + err: fmt.Errorf("add port mapping: %w", fmt.Errorf("Detail: 725")), + expected: true, + }, + { + name: "error 725 with newlines in XML", + err: fmt.Errorf("\n 725\n"), + expected: true, + }, + { + name: "bare 725 without XML tag", + err: fmt.Errorf("error code 725"), + expected: false, + }, + { + name: "unrelated error", + err: fmt.Errorf("connection refused"), + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, isPermanentLeaseRequired(tt.err)) + }) + } +} diff --git a/client/internal/portforward/pcp/client.go b/client/internal/portforward/pcp/client.go new file mode 100644 index 000000000..f6d243ef9 --- /dev/null +++ b/client/internal/portforward/pcp/client.go @@ -0,0 +1,408 @@ +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 new file mode 100644 index 000000000..79f44a426 --- /dev/null +++ b/client/internal/portforward/pcp/client_test.go @@ -0,0 +1,187 @@ +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 new file mode 100644 index 000000000..0e635b6c8 --- /dev/null +++ b/client/internal/portforward/pcp/nat.go @@ -0,0 +1,222 @@ +package pcp + +import ( + "context" + "fmt" + "net" + "net/netip" + "runtime" + "sync" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/libp2p/go-nat" + "github.com/libp2p/go-netroute" +) + +var _ nat.NAT = (*NAT)(nil) + +// NAT implements the go-nat NAT interface using PCP. +// Supports dual-stack (IPv4 and IPv6) when available. +// All methods are safe for concurrent use. +// +// TODO: IPv6 pinholes use the local IPv6 address. If the address changes +// (e.g., due to SLAAC rotation or network change), the pinhole becomes stale +// and needs to be recreated with the new address. +type NAT struct { + client *Client + + mu sync.RWMutex + // client6 is the IPv6 PCP client, nil if IPv6 is unavailable. + client6 *Client + // localIP6 caches the local IPv6 address used for PCP requests. + localIP6 netip.Addr +} + +// NewNAT creates a new NAT instance backed by PCP. +func NewNAT(gateway, localIP net.IP) *NAT { + client := NewClient(gateway) + client.SetLocalIP(localIP) + return &NAT{ + client: client, + } +} + +// Type returns "PCP" as the NAT type. +func (n *NAT) Type() string { + return "PCP" +} + +// GetDeviceAddress returns the gateway IP address. +func (n *NAT) GetDeviceAddress() (net.IP, error) { + return n.client.Gateway(), nil +} + +// GetExternalAddress returns the external IP address. +func (n *NAT) GetExternalAddress() (net.IP, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return n.client.GetExternalAddress(ctx) +} + +// GetInternalAddress returns the local IP address used to communicate with the gateway. +func (n *NAT) GetInternalAddress() (net.IP, error) { + addr, err := n.client.getLocalIP() + if err != nil { + return nil, err + } + return addr.AsSlice(), nil +} + +// AddPortMapping creates a port mapping on both IPv4 and IPv6 (if available). +func (n *NAT) AddPortMapping(ctx context.Context, protocol string, internalPort int, _ string, timeout time.Duration) (int, error) { + resp, err := n.client.AddPortMapping(ctx, protocol, internalPort, timeout) + if err != nil { + return 0, fmt.Errorf("add mapping: %w", err) + } + + n.mu.RLock() + client6 := n.client6 + localIP6 := n.localIP6 + n.mu.RUnlock() + + if client6 == nil { + return int(resp.ExternalPort), nil + } + + if _, err := client6.AddPortMapping(ctx, protocol, internalPort, timeout); err != nil { + log.Warnf("IPv6 PCP mapping failed (continuing with IPv4): %v", err) + return int(resp.ExternalPort), nil + } + + log.Infof("created IPv6 PCP pinhole: %s:%d", localIP6, internalPort) + return int(resp.ExternalPort), nil +} + +// DeletePortMapping removes a port mapping from both IPv4 and IPv6. +func (n *NAT) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error { + err := n.client.DeletePortMapping(ctx, protocol, internalPort) + + n.mu.RLock() + client6 := n.client6 + n.mu.RUnlock() + + if client6 != nil { + if err6 := client6.DeletePortMapping(ctx, protocol, internalPort); err6 != nil { + log.Warnf("IPv6 PCP delete mapping failed: %v", err6) + } + } + + if err != nil { + return fmt.Errorf("delete mapping: %w", err) + } + return nil +} + +// CheckServerHealth sends an ANNOUNCE to verify the server is still responsive. +// Returns the current epoch and whether the server may have restarted (epoch state loss detected). +func (n *NAT) CheckServerHealth(ctx context.Context) (epoch uint32, serverRestarted bool, err error) { + epoch, err = n.client.Announce(ctx) + if err != nil { + return 0, false, fmt.Errorf("announce: %w", err) + } + return epoch, n.client.EpochStateLost(), nil +} + +// DiscoverPCP attempts to discover a PCP-capable gateway. +// Returns a NAT interface if PCP is supported, or an error otherwise. +// Discovers both IPv4 and IPv6 gateways when available. +func DiscoverPCP(ctx context.Context) (nat.NAT, error) { + gateway, localIP, err := getDefaultGateway() + if err != nil { + return nil, fmt.Errorf("get default gateway: %w", err) + } + + client := NewClient(gateway) + client.SetLocalIP(localIP) + if _, err := client.Announce(ctx); err != nil { + return nil, fmt.Errorf("PCP announce: %w", err) + } + + result := &NAT{client: client} + discoverIPv6(ctx, result) + + return result, nil +} + +func discoverIPv6(ctx context.Context, result *NAT) { + gateway6, localIP6, err := getDefaultGateway6() + if err != nil { + log.Debugf("IPv6 gateway discovery failed: %v", err) + return + } + + client6 := NewClient(gateway6) + client6.SetLocalIP(localIP6) + if _, err := client6.Announce(ctx); err != nil { + log.Debugf("PCP IPv6 announce failed: %v", err) + return + } + + addr, ok := netip.AddrFromSlice(localIP6) + if !ok { + log.Debugf("invalid IPv6 local IP: %v", localIP6) + return + } + result.mu.Lock() + result.client6 = client6 + result.localIP6 = addr + result.mu.Unlock() + log.Debugf("PCP IPv6 gateway discovered: %s (local: %s)", gateway6, localIP6) +} + +// getDefaultGateway returns the default IPv4 gateway and local IP using the system routing table. +func getDefaultGateway() (gateway net.IP, localIP net.IP, err error) { + router, err := netroute.New() + if err != nil { + return nil, nil, err + } + + dst := net.IPv4zero + if runtime.GOOS == "linux" || runtime.GOOS == "android" { + // go-netroute v0.4.0 rejects unspecified destinations client-side on Linux/Android. + // TODO: on android/ios, use platform APIs (ConnectivityManager.getLinkProperties / + // NWPathMonitor) when netlink-based lookup is restricted or unavailable. + dst = net.IPv4(0, 0, 0, 1) + } + _, gateway, localIP, err = router.Route(dst) + if err != nil { + return nil, nil, err + } + + if gateway == nil { + return nil, nil, nat.ErrNoNATFound + } + + return gateway, localIP, nil +} + +// getDefaultGateway6 returns the default IPv6 gateway IP address using the system routing table. +func getDefaultGateway6() (gateway net.IP, localIP net.IP, err error) { + router, err := netroute.New() + if err != nil { + return nil, nil, err + } + + dst := net.IPv6zero + if runtime.GOOS == "linux" || runtime.GOOS == "android" { + // ::2 + dst = net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2} + } + _, gateway, localIP, err = router.Route(dst) + if err != nil { + return nil, nil, err + } + + if gateway == nil { + return nil, nil, nat.ErrNoNATFound + } + + return gateway, localIP, nil +} diff --git a/client/internal/portforward/pcp/protocol.go b/client/internal/portforward/pcp/protocol.go new file mode 100644 index 000000000..d81c50c8c --- /dev/null +++ b/client/internal/portforward/pcp/protocol.go @@ -0,0 +1,225 @@ +// 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/state.go b/client/internal/portforward/state.go new file mode 100644 index 000000000..b1315cdc0 --- /dev/null +++ b/client/internal/portforward/state.go @@ -0,0 +1,63 @@ +//go:build !js + +package portforward + +import ( + "context" + "fmt" + + "github.com/libp2p/go-nat" + 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) + + return nat.DiscoverGateway(ctx) +} + +// State is persisted only for crash recovery cleanup +type State struct { + InternalPort uint16 `json:"internal_port,omitempty"` + Protocol string `json:"protocol,omitempty"` +} + +func (s *State) Name() string { + return "port_forward_state" +} + +// Cleanup implements statemanager.CleanableState for crash recovery +func (s *State) Cleanup() error { + if s.InternalPort == 0 { + return nil + } + + log.Infof("cleaning up stale port mapping for port %d", s.InternalPort) + + ctx, cancel := context.WithTimeout(context.Background(), discoveryTimeout) + defer cancel() + + gateway, err := discoverGateway(ctx) + if err != nil { + // Discovery failure is not an error - gateway may not exist + log.Debugf("cleanup: no gateway found: %v", err) + return nil + } + + if err := gateway.DeletePortMapping(ctx, s.Protocol, int(s.InternalPort)); err != nil { + return fmt.Errorf("delete port mapping: %w", err) + } + + return nil +} diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index f03822089..a110e4102 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -3,9 +3,12 @@ package profilemanager import ( "context" "crypto/tls" + "encoding/json" "fmt" + "net" "net/url" "os" + "os/user" "path/filepath" "reflect" "runtime" @@ -19,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" @@ -37,31 +41,53 @@ const ( DefaultAdminURL = "https://app.netbird.io:443" ) +// mgmProber is the subset of management client needed for URL migration probes. +type mgmProber interface { + HealthCheck() error + Close() error +} + +// newMgmProber creates a management client for probing URL reachability. +// Overridden in tests to avoid real network calls. +var newMgmProber = func(ctx context.Context, addr string, key wgtypes.Key, tlsEnabled bool) (mgmProber, error) { + return mgm.NewClient(ctx, addr, key, tlsEnabled) +} + var DefaultInterfaceBlacklist = []string{ iface.WgInterfaceDefault, "wt", "utun", "tun0", "zt", "ZeroTier", "wg", "ts", "Tailscale", "tailscale", "docker", "veth", "br-", "lo", } +// loadMDMPolicy is the package-level indirection used by apply() to read the +// active MDM policy. Tests override this to inject a fake policy. +var loadMDMPolicy = mdm.LoadPolicy + // ConfigInput carries configuration changes to the client type ConfigInput struct { - ManagementURL string - AdminURL string - ConfigPath string - StateFilePath string - PreSharedKey *string - ServerSSHAllowed *bool - NATExternalIPs []string - CustomDNSAddress []byte - RosenpassEnabled *bool - RosenpassPermissive *bool - InterfaceName *string - WireguardPort *int - NetworkMonitor *bool - DisableAutoConnect *bool - ExtraIFaceBlackList []string - DNSRouteInterval *time.Duration - ClientCertPath string - ClientCertKeyPath string + ManagementURL string + AdminURL string + ConfigPath string + StateFilePath string + PreSharedKey *string + ServerSSHAllowed *bool + EnableSSHRoot *bool + EnableSSHSFTP *bool + EnableSSHLocalPortForwarding *bool + EnableSSHRemotePortForwarding *bool + DisableSSHAuth *bool + SSHJWTCacheTTL *int + NATExternalIPs []string + CustomDNSAddress []byte + RosenpassEnabled *bool + RosenpassPermissive *bool + InterfaceName *string + WireguardPort *int + NetworkMonitor *bool + DisableAutoConnect *bool + ExtraIFaceBlackList []string + DNSRouteInterval *time.Duration + ClientCertPath string + ClientCertKeyPath string DisableClientRoutes *bool DisableServerRoutes *bool @@ -69,31 +95,41 @@ type ConfigInput struct { DisableFirewall *bool BlockLANAccess *bool BlockInbound *bool + DisableIPv6 *bool + SyncMessageVersion *int DisableNotifications *bool DNSLabels domain.List - LazyConnectionEnabled *bool - MTU *uint16 } // 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 - ManagementURL *url.URL - AdminURL *url.URL - WgIface string - WgPort int - NetworkMonitor *bool - IFaceBlackList []string - DisableIPv6Discovery bool - RosenpassEnabled bool - RosenpassPermissive bool - ServerSSHAllowed *bool + PrivateKey string + PreSharedKey string + ManagementURL *url.URL + AdminURL *url.URL + WgIface string + WgPort int + NetworkMonitor *bool + IFaceBlackList []string + DisableIPv6Discovery bool + RosenpassEnabled bool + RosenpassPermissive bool + ServerSSHAllowed *bool + EnableSSHRoot *bool + EnableSSHSFTP *bool + EnableSSHLocalPortForwarding *bool + EnableSSHRemotePortForwarding *bool + DisableSSHAuth *bool + SSHJWTCacheTTL *int DisableClientRoutes bool DisableServerRoutes bool @@ -101,6 +137,8 @@ type Config struct { DisableFirewall bool BlockLANAccess bool BlockInbound bool + DisableIPv6 bool + SyncMessageVersion *int DisableNotifications *bool @@ -142,9 +180,28 @@ 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:"-"` MTU uint16 + + // policy is the MDM policy that produced the currently-set values for + // any MDM-enforced fields. Set by applyMDMPolicy at the tail of apply() + // and reset on every apply() invocation. Never persisted to disk. + // Callers query enforcement state via Policy() and the mdm.Policy API + // (HasKey, ManagedKeys, IsEmpty). + policy *mdm.Policy `json:"-"` +} + +// 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 @@ -153,19 +210,26 @@ func getConfigDir() (string, error) { if ConfigDirOverride != "" { return ConfigDirOverride, nil } - configDir, err := os.UserConfigDir() + + base, err := baseConfigDir() if err != nil { return "", err } - configDir = filepath.Join(configDir, "netbird") - if _, err := os.Stat(configDir); os.IsNotExist(err) { - if err := os.MkdirAll(configDir, 0755); err != nil { - return "", err + configDir := filepath.Join(base, "netbird") + if err := os.MkdirAll(configDir, 0o755); err != nil { + return "", err + } + return configDir, nil +} + +func baseConfigDir() (string, error) { + if runtime.GOOS == "darwin" { + if u, err := user.Current(); err == nil && u.HomeDir != "" { + return filepath.Join(u.HomeDir, "Library", "Application Support"), nil } } - - return configDir, nil + return os.UserConfigDir() } func getConfigDirForUser(username string) (string, error) { @@ -177,7 +241,7 @@ func getConfigDirForUser(username string) (string, error) { configDir := filepath.Join(DefaultConfigPathDir, username) if _, err := os.Stat(configDir); os.IsNotExist(err) { - if err := os.MkdirAll(configDir, 0600); err != nil { + if err := os.MkdirAll(configDir, 0700); err != nil { return "", err } } @@ -185,9 +249,15 @@ func getConfigDirForUser(username string) (string, error) { return configDir, nil } -func fileExists(path string) bool { +func fileExists(path string) (bool, error) { _, err := os.Stat(path) - return !os.IsNotExist(err) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err } // createNewConfig creates a new config generating a new Wireguard key and saving to file @@ -206,6 +276,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) @@ -231,7 +311,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { } if config.AdminURL == nil { - log.Infof("using default Admin URL %s", DefaultManagementURL) + log.Infof("using default Admin URL %s", DefaultAdminURL) config.AdminURL, err = parseURL("Admin URL", DefaultAdminURL) if err != nil { return false, err @@ -308,7 +388,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } - if input.NetworkMonitor != nil && input.NetworkMonitor != config.NetworkMonitor { + 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 @@ -355,7 +435,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 { @@ -376,6 +456,62 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } + if input.EnableSSHRoot != nil && (config.EnableSSHRoot == nil || *input.EnableSSHRoot != *config.EnableSSHRoot) { + if *input.EnableSSHRoot { + log.Infof("enabling SSH root login") + } else { + log.Infof("disabling SSH root login") + } + config.EnableSSHRoot = input.EnableSSHRoot + updated = true + } + + if input.EnableSSHSFTP != nil && (config.EnableSSHSFTP == nil || *input.EnableSSHSFTP != *config.EnableSSHSFTP) { + if *input.EnableSSHSFTP { + log.Infof("enabling SSH SFTP subsystem") + } else { + log.Infof("disabling SSH SFTP subsystem") + } + config.EnableSSHSFTP = input.EnableSSHSFTP + updated = true + } + + if input.EnableSSHLocalPortForwarding != nil && (config.EnableSSHLocalPortForwarding == nil || *input.EnableSSHLocalPortForwarding != *config.EnableSSHLocalPortForwarding) { + if *input.EnableSSHLocalPortForwarding { + log.Infof("enabling SSH local port forwarding") + } else { + log.Infof("disabling SSH local port forwarding") + } + config.EnableSSHLocalPortForwarding = input.EnableSSHLocalPortForwarding + updated = true + } + + if input.EnableSSHRemotePortForwarding != nil && (config.EnableSSHRemotePortForwarding == nil || *input.EnableSSHRemotePortForwarding != *config.EnableSSHRemotePortForwarding) { + if *input.EnableSSHRemotePortForwarding { + log.Infof("enabling SSH remote port forwarding") + } else { + log.Infof("disabling SSH remote port forwarding") + } + config.EnableSSHRemotePortForwarding = input.EnableSSHRemotePortForwarding + updated = true + } + + if input.DisableSSHAuth != nil && (config.DisableSSHAuth == nil || *input.DisableSSHAuth != *config.DisableSSHAuth) { + if *input.DisableSSHAuth { + log.Infof("disabling SSH authentication") + } else { + log.Infof("enabling SSH authentication") + } + config.DisableSSHAuth = input.DisableSSHAuth + updated = true + } + + 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 + } + if input.DNSRouteInterval != nil && *input.DNSRouteInterval != config.DNSRouteInterval { log.Infof("updating DNS route interval to %s (old value %s)", input.DNSRouteInterval.String(), config.DNSRouteInterval.String()) @@ -447,7 +583,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 { @@ -492,12 +640,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 @@ -508,10 +650,102 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } + // MDM is the last override layer: any key present in the policy + // supersedes defaults, on-disk config, env vars and CLI input. + config.applyMDMPolicy(loadMDMPolicy()) + 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 + 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.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 }) + + 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) + } +} + +// 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. func parseURL(serviceName, serviceURL string) (*url.URL, error) { parsedMgmtURL, err := url.ParseRequestURI(serviceURL) if err != nil { @@ -558,7 +792,11 @@ func isPreSharedKeyHidden(preSharedKey *string) bool { // UpdateConfig update existing configuration according to input configuration and return with the configuration func UpdateConfig(input ConfigInput) (*Config, error) { - if !fileExists(input.ConfigPath) { + configExists, err := fileExists(input.ConfigPath) + if err != nil { + return nil, fmt.Errorf("failed to check if config file exists: %w", err) + } + if !configExists { return nil, fmt.Errorf("config file %s does not exist", input.ConfigPath) } @@ -567,7 +805,11 @@ func UpdateConfig(input ConfigInput) (*Config, error) { // UpdateOrCreateConfig reads existing config or generates a new one func UpdateOrCreateConfig(input ConfigInput) (*Config, error) { - if !fileExists(input.ConfigPath) { + configExists, err := fileExists(input.ConfigPath) + if err != nil { + return nil, fmt.Errorf("failed to check if config file exists: %w", err) + } + if !configExists { log.Infof("generating new config %s", input.ConfigPath) cfg, err := createNewConfig(input) if err != nil { @@ -580,7 +822,7 @@ func UpdateOrCreateConfig(input ConfigInput) (*Config, error) { if isPreSharedKeyHidden(input.PreSharedKey) { input.PreSharedKey = nil } - err := util.EnforcePermission(input.ConfigPath) + err = util.EnforcePermission(input.ConfigPath) if err != nil { log.Errorf("failed to enforce permission on config dir: %v", err) } @@ -608,7 +850,7 @@ func update(input ConfigInput) (*Config, error) { return config, nil } -// GetConfig read config file and return with Config. Errors out if it does not exist +// GetConfig read config file and return with Config and if it was created. Errors out if it does not exist func GetConfig(configPath string) (*Config, error) { return readConfig(configPath, false) } @@ -648,8 +890,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 } @@ -662,21 +903,19 @@ func UpdateOldManagementURL(ctx context.Context, config *Config, configPath stri return config, err } - client, err := mgm.NewClient(ctx, newURL.Host, key, mgmTlsEnabled) + client, err := newMgmProber(ctx, newURL.Host, key, mgmTlsEnabled) if err != nil { log.Infof("couldn't switch to the new Management %s", newURL.String()) return config, err } defer func() { - err = client.Close() - if err != nil { + if err := client.Close(); err != nil { log.Warnf("failed to close the Management service client %v", err) } }() // gRPC check - _, err = client.GetServerPublicKey() - if err != nil { + if err = client.HealthCheck(); err != nil { log.Infof("couldn't switch to the new Management %s", newURL.String()) return nil, err } @@ -707,7 +946,12 @@ func ReadConfig(configPath string) (*Config, error) { // ReadConfig read config file and return with Config. If it is not exists create a new with default values func readConfig(configPath string, createIfMissing bool) (*Config, error) { - if fileExists(configPath) { + configExists, err := fileExists(configPath) + if err != nil { + return nil, fmt.Errorf("failed to check if config file exists: %w", err) + } + + if configExists { err := util.EnforcePermission(configPath) if err != nil { log.Errorf("failed to enforce permission on config dir: %v", err) @@ -744,3 +988,89 @@ func readConfig(configPath string, createIfMissing bool) (*Config, error) { func WriteOutConfig(path string, config *Config) error { return util.WriteJson(context.Background(), path, config) } + +// DirectWriteOutConfig writes config directly without atomic temp file operations. +// Use this on platforms where atomic writes are blocked (e.g., tvOS sandbox). +func DirectWriteOutConfig(path string, config *Config) error { + return util.DirectWriteJson(context.Background(), path, config) +} + +// DirectUpdateOrCreateConfig is like UpdateOrCreateConfig but uses direct (non-atomic) writes. +// Use this on platforms where atomic writes are blocked (e.g., tvOS sandbox). +func DirectUpdateOrCreateConfig(input ConfigInput) (*Config, error) { + configExists, err := fileExists(input.ConfigPath) + if err != nil { + return nil, fmt.Errorf("failed to check if config file exists: %w", err) + } + if !configExists { + log.Infof("generating new config %s", input.ConfigPath) + cfg, err := createNewConfig(input) + if err != nil { + return nil, err + } + err = util.DirectWriteJson(context.Background(), input.ConfigPath, cfg) + return cfg, err + } + + if isPreSharedKeyHidden(input.PreSharedKey) { + input.PreSharedKey = nil + } + + // Enforce permissions on existing config files (same as UpdateOrCreateConfig) + if err := util.EnforcePermission(input.ConfigPath); err != nil { + log.Errorf("failed to enforce permission on config file: %v", err) + } + + return directUpdate(input) +} + +func directUpdate(input ConfigInput) (*Config, error) { + config := &Config{} + + if _, err := util.ReadJson(input.ConfigPath, config); err != nil { + return nil, err + } + + updated, err := config.apply(input) + if err != nil { + return nil, err + } + + if updated { + if err := util.DirectWriteJson(context.Background(), input.ConfigPath, config); err != nil { + return nil, err + } + } + + return config, nil +} + +// ConfigToJSON serializes a Config struct to a JSON string. +// This is useful for exporting config to alternative storage mechanisms +// (e.g., UserDefaults on tvOS where file writes are blocked). +func ConfigToJSON(config *Config) (string, error) { + bs, err := json.MarshalIndent(config, "", " ") + if err != nil { + return "", err + } + return string(bs), nil +} + +// ConfigFromJSON deserializes a JSON string to a Config struct. +// This is useful for restoring config from alternative storage mechanisms. +// After unmarshaling, defaults are applied to ensure the config is fully initialized. +func ConfigFromJSON(jsonStr string) (*Config, error) { + config := &Config{} + err := json.Unmarshal([]byte(jsonStr), config) + if err != nil { + return nil, err + } + + // Apply defaults to ensure required fields are initialized. + // This mirrors what readConfig does after loading from file. + if _, err := config.apply(ConfigInput{}); err != nil { + return nil, fmt.Errorf("failed to apply defaults to config: %w", err) + } + + return config, nil +} diff --git a/client/internal/profilemanager/config_mdm_test.go b/client/internal/profilemanager/config_mdm_test.go new file mode 100644 index 000000000..c6a688ab2 --- /dev/null +++ b/client/internal/profilemanager/config_mdm_test.go @@ -0,0 +1,183 @@ +package profilemanager + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/mdm" +) + +// withMDMPolicy temporarily overrides the package-level loadMDMPolicy hook so +// apply() observes the supplied Policy. The original loader is restored at +// test cleanup. +func withMDMPolicy(t *testing.T, policy *mdm.Policy) { + t.Helper() + prev := loadMDMPolicy + loadMDMPolicy = func() *mdm.Policy { return policy } + t.Cleanup(func() { loadMDMPolicy = prev }) +} + +func TestApply_MDMEmpty_NoEnforcement(t *testing.T) { + withMDMPolicy(t, mdm.NewPolicy(nil)) + + cfg, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }) + require.NoError(t, err) + require.NotNil(t, cfg) + + 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" + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: mdmURL, + mdm.KeyDisableClientRoutes: true, + mdm.KeyBlockInbound: true, + })) + + cfg, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }) + require.NoError(t, err) + require.NotNil(t, cfg) + + assert.Equal(t, mdmURL, cfg.ManagementURL.String()) + assert.True(t, cfg.DisableClientRoutes) + assert.True(t, cfg.BlockInbound) + + 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" + + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: mdmURL, + })) + + cfg, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + ManagementURL: cliURL, + }) + require.NoError(t, err) + require.NotNil(t, cfg) + + // 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) { + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: "not-a-url", + })) + + cfg, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }) + require.NoError(t, err) + require.NotNil(t, cfg) + + // Invalid MDM URL is logged and skipped: default URL stays in place + // to keep the client functional. + assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String()) + + // 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. + withMDMPolicy(t, mdm.NewPolicy(nil)) + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: tmp, + DisableClientRoutes: boolPtr(false), + RosenpassEnabled: boolPtr(false), + }) + require.NoError(t, err) + + // Now enable MDM enforcement for these keys. + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyDisableClientRoutes: true, + mdm.KeyRosenpassEnabled: true, + })) + + cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp}) + require.NoError(t, err) + require.NotNil(t, cfg) + + assert.True(t, cfg.DisableClientRoutes, "MDM override should flip on-disk false to true") + assert.True(t, cfg.RosenpassEnabled) + assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes)) + assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled)) +} + +func TestApply_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) { + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyLazyConnection: c.raw, + })) + + cfg, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }) + require.NoError(t, err) + require.NotNil(t, cfg) + + assert.Equal(t, c.want, cfg.LazyConnection) + assert.True(t, cfg.Policy().HasKey(mdm.KeyLazyConnection)) + }) + } +} + +func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) { + const maskSentinel = "**********" + + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyPreSharedKey: maskSentinel, + })) + + cfg, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }) + require.NoError(t, err) + require.NotNil(t, cfg) + + // Mask sentinel must not be persisted as the actual PSK. + assert.NotEqual(t, maskSentinel, cfg.PreSharedKey) + // Key still marked managed so user writes are still rejected. + assert.True(t, cfg.Policy().HasKey(mdm.KeyPreSharedKey)) +} + +func boolPtr(b bool) *bool { return &b } diff --git a/client/internal/profilemanager/config_test.go b/client/internal/profilemanager/config_test.go index 90bde7707..736ff3412 100644 --- a/client/internal/profilemanager/config_test.go +++ b/client/internal/profilemanager/config_test.go @@ -10,12 +10,21 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/internal/routemanager/dynamic" "github.com/netbirdio/netbird/util" ) +type mockMgmProber struct{} + +func (m *mockMgmProber) HealthCheck() error { + return nil +} + +func (m *mockMgmProber) Close() error { return nil } + func TestGetConfig(t *testing.T) { // case 1: new default config has to be generated config, err := UpdateOrCreateConfig(ConfigInput{ @@ -193,10 +202,10 @@ func TestWireguardPortZeroExplicit(t *testing.T) { func TestWireguardPortDefaultVsExplicit(t *testing.T) { tests := []struct { - name string - wireguardPort *int - expectedPort int - description string + name string + wireguardPort *int + expectedPort int + description string }{ { name: "no port specified uses default", @@ -233,7 +242,42 @@ 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 TestUpdateOldManagementURL(t *testing.T) { + origProber := newMgmProber + newMgmProber = func(_ context.Context, _ string, _ wgtypes.Key, _ bool) (mgmProber, error) { + return &mockMgmProber{}, nil + } + t.Cleanup(func() { newMgmProber = origProber }) + tests := []struct { name string previousManagementURL string @@ -273,18 +317,17 @@ func TestUpdateOldManagementURL(t *testing.T) { ConfigPath: configPath, }) require.NoError(t, err, "failed to create testing config") - previousStats, err := os.Stat(configPath) - require.NoError(t, err, "failed to create testing config stats") + previousContent, err := os.ReadFile(configPath) + require.NoError(t, err, "failed to read initial config") resultConfig, err := UpdateOldManagementURL(context.TODO(), config, configPath) require.NoError(t, err, "got error when updating old management url") require.Equal(t, tt.expectedManagementURL, resultConfig.ManagementURL.String()) - newStats, err := os.Stat(configPath) - require.NoError(t, err, "failed to create testing config stats") - switch tt.fileShouldNotChange { - case true: - require.Equal(t, previousStats.ModTime(), newStats.ModTime(), "file should not change") - case false: - require.NotEqual(t, previousStats.ModTime(), newStats.ModTime(), "file should have changed") + newContent, err := os.ReadFile(configPath) + require.NoError(t, err, "failed to read updated config") + if tt.fileShouldNotChange { + require.Equal(t, string(previousContent), string(newContent), "file should not change") + } else { + require.NotEqual(t, string(previousContent), string(newContent), "file should have changed") } }) } 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/profilemanager.go b/client/internal/profilemanager/profilemanager.go index fe0afae2b..e25d493d5 100644 --- a/client/internal/profilemanager/profilemanager.go +++ b/client/internal/profilemanager/profilemanager.go @@ -19,19 +19,41 @@ 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 } + if !IsValidProfileFilenameStem(id) { + return "", fmt.Errorf("invalid profile ID: %q", id) + } + username, err := user.Current() if err != nil { return "", fmt.Errorf("failed to get current user: %w", err) @@ -42,10 +64,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 +82,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 +116,7 @@ func sanitizeProfileName(name string) string { }, name) } -func (pm *ProfileManager) getActiveProfileState() string { +func (pm *ProfileManager) getActiveProfileState() ID { configDir, err := getConfigDir() if err != nil { @@ -113,10 +144,10 @@ 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 { configDir, err := getConfigDir() if err != nil { @@ -125,10 +156,28 @@ 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) } return nil } + +// GetLoginHint retrieves the email from the active profile to use as login_hint. +func GetLoginHint() string { + pm := NewProfileManager() + activeProf, err := pm.GetActiveProfile() + if err != nil { + log.Debugf("failed to get active profile for login hint: %v", err) + return "" + } + + profileState, err := pm.GetProfileState(activeProf.ID) + if err != nil { + log.Debugf("failed to get profile state for login hint: %v", err) + return "" + } + + return profileState.Email +} 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 faccf5f68..696a60310 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,28 +87,38 @@ 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 { + profilesDir string // If set, overrides ConfigDirOverride for profile operations } func NewServiceManager(defaultConfigPath string) *ServiceManager { @@ -85,6 +128,17 @@ func NewServiceManager(defaultConfigPath string) *ServiceManager { return &ServiceManager{} } +// NewServiceManagerWithProfilesDir creates a ServiceManager with a specific profiles directory +// This allows setting the profiles directory without modifying the global ConfigDirOverride +func NewServiceManagerWithProfilesDir(defaultConfigPath string, profilesDir string) *ServiceManager { + if defaultConfigPath != "" { + DefaultConfigPath = defaultConfigPath + } + return &ServiceManager{ + profilesDir: profilesDir, + } +} + func (s *ServiceManager) CopyDefaultProfileIfNotExists() (bool, error) { if err := os.MkdirAll(DefaultConfigPathDir, 0600); err != nil { @@ -114,14 +168,6 @@ func (s *ServiceManager) CopyDefaultProfileIfNotExists() (bool, error) { log.Warnf("failed to set permissions for default profile: %v", err) } - if err := s.SetActiveProfileState(&ActiveProfileState{ - Name: "default", - Username: "", - }); err != nil { - log.Errorf("failed to set active profile state: %v", err) - return false, fmt.Errorf("failed to set active profile state: %w", err) - } - return true, nil } @@ -174,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 { @@ -182,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 } @@ -212,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: "", }) } @@ -239,49 +289,117 @@ func (s *ServiceManager) DefaultProfilePath() string { return DefaultConfigPath } -func (s *ServiceManager) AddProfile(profileName, username string) error { - configDir, err := getConfigDirForUser(username) +// 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) + displayName, err = sanitizeDisplayName(displayName) + if err != nil { + return nil, fmt.Errorf("invalid profile name: %w", err) } - profPath := filepath.Join(configDir, profileName+".json") - if fileExists(profPath) { - return ErrProfileAlreadyExists + 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 := getConfigDirForUser(username) +// 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) + } + + profiles, err := s.loadAllProfiles(username) if err != nil { - return fmt.Errorf("failed to get config directory: %w", err) + return fmt.Errorf("load profiles: %w", err) } - profileName = sanitizeProfileName(profileName) - - if profileName == defaultProfileName { - return fmt.Errorf("cannot remove profile with reserved name: %s", defaultProfileName) + var target *Profile + for i := range profiles { + if profiles[i].ID == id { + target = &profiles[i] + break + } } - profPath := filepath.Join(configDir, profileName+".json") - if !fileExists(profPath) { + if target == nil { return ErrProfileNotFound } @@ -289,57 +407,26 @@ 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) + } + 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 := getConfigDirForUser(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 @@ -353,19 +440,203 @@ 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 } - configDir, err := getConfigDirForUser(activeProf.Username) + if !IsValidProfileFilenameStem(activeProf.ID) { + log.Warnf("invalid active profile ID %q, using default state path", activeProf.ID) + return defaultStatePath + } + + configDir, err := s.getConfigDir(activeProf.Username) if err != nil { log.Warnf("failed to get config directory for user %s: %v", activeProf.Username, err) 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 +func (s *ServiceManager) getConfigDir(username string) (string, error) { + if s.profilesDir != "" { + return s.profilesDir, nil + } + + 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 f84cb1032..9e9577796 100644 --- a/client/internal/profilemanager/state.go +++ b/client/internal/profilemanager/state.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "os" "path/filepath" "github.com/netbirdio/netbird/util" @@ -13,14 +14,25 @@ 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 !fileExists(stateFile) { + 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) + } + if !stateFileExists { return nil, errors.New("profile state file does not exist") } @@ -47,7 +59,12 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error { return fmt.Errorf("get active profile: %w", err) } - stateFile := filepath.Join(configDir, activeProf.Name+".state.json") + id := activeProf.ID + if id != defaultProfileName && !IsValidProfileFilenameStem(id) { + return fmt.Errorf("invalid active profile ID: %q", id) + } + + stateFile := filepath.Join(configDir, id.String()+".state.json") err = util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state) if err != nil { return fmt.Errorf("write profile state: %w", err) @@ -55,3 +72,22 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error { return nil } + +// RemoveProfileState deletes the per-profile state file (which holds the +// account email used for the SSO login hint and the UI display). Called after +// a successful logout so a logged-out profile no longer shows a stale account +// email. The state file only stores the email, so deleting it is equivalent to +// clearing it; the next SSO login recreates it. A missing file is not an error. +func (pm *ProfileManager) RemoveProfileState(profileName string) error { + configDir, err := getConfigDir() + if err != nil { + 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 693ea1f31..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 { @@ -197,7 +201,7 @@ func (p *StunTurnProbe) probeSTUN(ctx context.Context, uri *stun.URI) (addr stri } }() - net, err := stdnet.NewNet(nil) + net, err := stdnet.NewNet(ctx, nil) if err != nil { probeErr = fmt.Errorf("new net: %w", err) return @@ -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 { @@ -286,7 +290,7 @@ func (p *StunTurnProbe) probeTURN(ctx context.Context, uri *stun.URI) (addr stri } }() - net, err := stdnet.NewNet(nil) + net, err := stdnet.NewNet(ctx, nil) if err != nil { probeErr = fmt.Errorf("new net: %w", err) return diff --git a/client/internal/rosenpass/manager.go b/client/internal/rosenpass/manager.go index d2d7408fd..21dd751df 100644 --- a/client/internal/rosenpass/manager.go +++ b/client/internal/rosenpass/manager.go @@ -17,35 +17,68 @@ import ( "golang.zx2c4.com/wireguard/wgctrl/wgtypes" ) +const ( + defaultLog = slog.LevelInfo + defaultLogLevelVar = "NB_ROSENPASS_LOG_LEVEL" +) + func hashRosenpassKey(key []byte) string { hasher := sha256.New() hasher.Write(key) 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 } rpKeyHash := hashRosenpassKey(public) - log.Debugf("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 + log.Tracef("generated new rosenpass key pair with public key %s", rpKeyHash) + 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 { @@ -59,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 { @@ -69,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 { @@ -100,7 +153,7 @@ func (m *Manager) removePeer(wireGuardPubKey string) error { func (m *Manager) generateConfig() (rp.Config, error) { opts := &slog.HandlerOptions{ - Level: slog.LevelDebug, + Level: getLogLevel(), } logger := slog.New(slog.NewTextHandler(os.Stdout, opts)) cfg := rp.Config{Logger: logger} @@ -109,7 +162,13 @@ func (m *Manager) generateConfig() (rp.Config, error) { cfg.SecretKey = m.ssk cfg.Peers = []rp.PeerConfig{} - m.rpWgHandler, _ = NewNetbirdHandler(m.preSharedKey, m.ifaceName) + + m.lock.Lock() + m.rpWgHandler = NewNetbirdHandler(m.preSharedKey, m.localWgKey) + if m.wgIface != nil { + m.rpWgHandler.SetInterface(m.wgIface) + } + m.lock.Unlock() cfg.Handlers = []rp.Handler{m.rpWgHandler} @@ -126,6 +185,26 @@ func (m *Manager) generateConfig() (rp.Config, error) { return cfg, nil } +func getLogLevel() slog.Level { + level, ok := os.LookupEnv(defaultLogLevelVar) + if !ok { + return defaultLog + } + switch strings.ToLower(level) { + case "debug": + return slog.LevelDebug + case "info": + return slog.LevelInfo + case "warn": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + log.Warnf("unknown log level: %s. Using default %s", level, defaultLog.String()) + return defaultLog + } +} + func (m *Manager) OnDisconnected(peerKey string) { m.lock.Lock() defer m.lock.Unlock() @@ -150,28 +229,49 @@ 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 } +// SetInterface sets the WireGuard interface for the rosenpass handler. +// This can be called before or after Run() - the interface will be stored +// and passed to the handler when it's created or updated immediately if +// already running. +func (m *Manager) SetInterface(iface PresharedKeySetter) { + m.lock.Lock() + defer m.lock.Unlock() + + m.wgIface = iface + if m.rpWgHandler != nil { + m.rpWgHandler.SetInterface(iface) + } +} + // OnConnected is a handler function that is triggered when a connection to a remote peer establishes func (m *Manager) OnConnected(remoteWireGuardKey string, remoteRosenpassPubKey []byte, wireGuardIP string, remoteRosenpassAddr string) { m.lock.Lock() @@ -192,6 +292,20 @@ func (m *Manager) OnConnected(remoteWireGuardKey string, remoteRosenpassPubKey [ } } +// IsPresharedKeyInitialized returns true if Rosenpass has completed a handshake +// and set a PSK for the given WireGuard peer. +func (m *Manager) IsPresharedKeyInitialized(wireGuardPubKey string) bool { + m.lock.Lock() + defer m.lock.Unlock() + + peerID, ok := m.rpPeerIDs[wireGuardPubKey] + if !ok || peerID == nil { + return false + } + + return m.rpWgHandler.IsPeerInitialized(*peerID) +} + func findRandomAvailableUDPPort() (int, error) { conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) if err != nil { @@ -199,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 345f95c01..672650ca7 100644 --- a/client/internal/rosenpass/netbird_handler.go +++ b/client/internal/rosenpass/netbird_handler.go @@ -1,126 +1,202 @@ package rosenpass import ( - "fmt" - "log/slog" + "sync" rp "cunicu.li/go-rosenpass" log "github.com/sirupsen/logrus" - "golang.zx2c4.com/wireguard/wgctrl" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" ) +// PresharedKeySetter is the interface for setting preshared keys on WireGuard peers. +// This minimal interface allows rosenpass to update PSKs without depending on the full WGIface. +type PresharedKeySetter interface { + SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error +} + 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 { - ifaceName string - client *wgctrl.Client - peers map[rp.PeerID]wireGuardPeer - presharedKey [32]byte + 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(preSharedKey *[32]byte, wgIfaceName string) (hdlr *NetbirdHandler, err error) { - hdlr = &NetbirdHandler{ - ifaceName: wgIfaceName, - peers: map[rp.PeerID]wireGuardPeer{}, +func NewNetbirdHandler(preSharedKey *[32]byte, localWgKey wgtypes.Key) *NetbirdHandler { + return &NetbirdHandler{ + preSharedKey: preSharedKey, + localWgKey: localWgKey, + peers: map[rp.PeerID]*wireGuardPeer{}, } - - if preSharedKey != nil { - hdlr.presharedKey = *preSharedKey - } - - if hdlr.client, err = wgctrl.New(); err != nil { - return nil, fmt.Errorf("failed to creat WireGuard client: %w", err) - } - - return hdlr, nil } +// SetInterface sets the WireGuard interface for the handler. +// This must be called after the WireGuard interface is created. +func (h *NetbirdHandler) SetInterface(iface PresharedKeySetter) { + h.mu.Lock() + defer h.mu.Unlock() + 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.peers[pid] = wireGuardPeer{ + h.mu.Lock() + defer h.mu.Unlock() + if existing, ok := h.peers[pid]; ok && existing.PublicKey == pk { + existing.Interface = intf + return + } + h.peers[pid] = &wireGuardPeer{ Interface: intf, PublicKey: pk, } } func (h *NetbirdHandler) RemovePeer(pid rp.PeerID) { + h.mu.Lock() + defer h.mu.Unlock() delete(h.peers, pid) } +// IsPeerInitialized returns true if Rosenpass has completed a handshake +// and set a PSK for this peer. +func (h *NetbirdHandler) IsPeerInitialized(pid rp.PeerID) bool { + h.mu.Lock() + defer h.mu.Unlock() + 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) { - log.Debug("Handshake complete") - h.outputKey(rp.KeyOutputReasonStale, pid, key) + psk := wgtypes.Key(key) + + h.mu.Lock() + defer h.mu.Unlock() + + 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) { - key, _ := rp.GeneratePresharedKey() - log.Debug("Handshake expired") - h.outputKey(rp.KeyOutputReasonStale, pid, key) -} + h.mu.Lock() + defer h.mu.Unlock() -func (h *NetbirdHandler) outputKey(_ rp.KeyOutputReason, pid rp.PeerID, psk rp.Key) { - wg, ok := h.peers[pid] + peer, ok := h.peers[pid] if !ok { return } - device, err := h.client.Device(h.ifaceName) - if err != nil { - log.Errorf("Failed to get WireGuard device: %v", err) - return - } - config := []wgtypes.PeerConfig{ - { - UpdateOnly: true, - PublicKey: wgtypes.Key(wg.PublicKey), - PresharedKey: (*wgtypes.Key)(&psk), - }, - } - for _, peer := range device.Peers { - if peer.PublicKey == wgtypes.Key(wg.PublicKey) { - if publicKeyEmpty(peer.PresharedKey) || peer.PresharedKey == h.presharedKey { - log.Debugf("Restart wireguard connection to peer %s", peer.PublicKey) - config = []wgtypes.PeerConfig{ - { - PublicKey: wgtypes.Key(wg.PublicKey), - PresharedKey: (*wgtypes.Key)(&psk), - Endpoint: peer.Endpoint, - AllowedIPs: peer.AllowedIPs, - }, - } - err = h.client.ConfigureDevice(wg.Interface, wgtypes.Config{ - Peers: []wgtypes.PeerConfig{ - { - Remove: true, - PublicKey: wgtypes.Key(wg.PublicKey), - }, - }, - }) - if err != nil { - slog.Debug("Failed to remove peer") - return - } - } + peer.expiries++ + 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 } - if err = h.client.ConfigureDevice(wg.Interface, wgtypes.Config{ - Peers: config, - }); err != nil { - log.Errorf("Failed to apply rosenpass key: %v", err) - } + h.applyKeyLocked(pid, psk, true) } -func publicKeyEmpty(key wgtypes.Key) bool { - for _, b := range key { - if b != 0 { - return false - } +// 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 + } + 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 0b8e161d2..c691c54f8 100644 --- a/client/internal/routemanager/client/client.go +++ b/client/internal/routemanager/client/client.go @@ -3,6 +3,7 @@ package client import ( "context" "fmt" + "net/netip" "reflect" "time" @@ -263,8 +264,14 @@ func (w *Watcher) watchPeerStatusChanges(ctx context.Context, peerKey string, pe case <-closer: return case routerStates := <-subscription.Events(): - peerStateUpdate <- routerStates - log.Debugf("triggered route state update for Peer: %s", peerKey) + select { + case peerStateUpdate <- routerStates: + log.Debugf("triggered route state update for Peer: %s", peerKey) + case <-ctx.Done(): + return + case <-closer: + return + } } } } @@ -558,7 +565,7 @@ func HandlerFromRoute(params common.HandlerParams) RouteHandler { return dnsinterceptor.New(params) case handlerTypeDynamic: dns := nbdns.NewServiceViaMemory(params.WgInterface) - dnsAddr := fmt.Sprintf("%s:%d", dns.RuntimeIP(), 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 348338dac..b784cc274 100644 --- a/client/internal/routemanager/dnsinterceptor/handler.go +++ b/client/internal/routemanager/dnsinterceptor/handler.go @@ -4,8 +4,10 @@ import ( "context" "errors" "fmt" + "net" "net/netip" "runtime" + "strconv" "strings" "sync" "sync/atomic" @@ -17,12 +19,13 @@ import ( nberrors "github.com/netbirdio/netbird/client/errors" firewall "github.com/netbirdio/netbird/client/firewall/manager" - "github.com/netbirdio/netbird/client/iface/wgaddr" nbdns "github.com/netbirdio/netbird/client/internal/dns" + "github.com/netbirdio/netbird/client/internal/dns/resutil" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/peerstore" "github.com/netbirdio/netbird/client/internal/routemanager/common" "github.com/netbirdio/netbird/client/internal/routemanager/fakeip" + iface "github.com/netbirdio/netbird/client/internal/routemanager/iface" "github.com/netbirdio/netbird/client/internal/routemanager/refcounter" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" @@ -37,11 +40,6 @@ type internalDNATer interface { AddInternalDNATMapping(netip.Addr, netip.Addr) error } -type wgInterface interface { - Name() string - Address() wgaddr.Address -} - type DnsInterceptor struct { mu sync.RWMutex route *route.Route @@ -51,7 +49,7 @@ type DnsInterceptor struct { dnsServer nbdns.Server currentPeerKey string interceptedDomains domainMap - wgInterface wgInterface + wgInterface iface.WGIface peerStore *peerstore.Store firewall firewall.Manager fakeIPManager *fakeip.Manager @@ -219,21 +217,20 @@ func (d *DnsInterceptor) RemoveAllowedIPs() error { // ServeDNS implements the dns.Handler interface func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { - requestID := nbdns.GenerateRequestID() - logger := log.WithField("request_id", requestID) + logger := log.WithFields(log.Fields{ + "request_id": resutil.GetRequestID(w), + "dns_id": fmt.Sprintf("%04x", r.Id), + }) if len(r.Question) == 0 { return } - logger.Tracef("received DNS request for domain=%s type=%v class=%v", - r.Question[0].Name, r.Question[0].Qtype, r.Question[0].Qclass) - - // 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() @@ -249,46 +246,38 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { return } - client, err := nbdns.GetClientPrivate(d.wgInterface.Address().IP, d.wgInterface.Name(), dnsTimeout) - if err != nil { - d.writeDNSError(w, r, logger, fmt.Sprintf("create DNS client: %v", err)) - return - } - if r.Extra == nil { r.MsgHdr.AuthenticatedData = true } - upstream := fmt.Sprintf("%s:%d", upstreamIP.String(), uint16(d.forwarderPort.Load())) + // 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() - startTime := time.Now() - reply, _, err := nbdns.ExchangeWithFallback(ctx, client, r, upstream) - if err != nil { - if errors.Is(err, context.DeadlineExceeded) { - elapsed := time.Since(startTime) - peerInfo := d.debugPeerTimeout(upstreamIP, peerKey) - logger.Errorf("peer DNS timeout after %v (timeout=%v) for domain=%s to peer %s (%s)%s - error: %v", - elapsed.Truncate(time.Millisecond), dnsTimeout, r.Question[0].Name, upstreamIP.String(), peerKey, peerInfo, err) - } else { - logger.Errorf("failed to exchange DNS request with %s (%s) for domain=%s: %v", upstreamIP.String(), peerKey, r.Question[0].Name, err) - } - if err := w.WriteMsg(&dns.Msg{MsgHdr: dns.MsgHdr{Rcode: dns.RcodeServerFailure, Id: r.Id}}); err != nil { - logger.Errorf("failed writing DNS response: %v", err) - } + reply := d.queryUpstreamDNS(ctx, w, r, upstream, upstreamIP, peerKey, logger) + if reply == nil { return } - var answer []dns.RR - if reply != nil { - answer = reply.Answer + if ede, ok := resutil.ExtractEDE(reply); ok { + resutil.SetMeta(w, "ede", fmt.Sprintf("%d %s", ede.InfoCode, ede.ExtraText)) + } + if !hadEdns { + resutil.StripOPT(reply) } - logger.Tracef("upstream %s (%s) DNS response for domain=%s answers=%v", upstreamIP.String(), peerKey, r.Question[0].Name, answer) + resutil.SetMeta(w, "peer", peerKey) reply.Id = r.Id - if err := d.writeMsg(w, reply); err != nil { + if err := d.writeMsg(w, reply, logger); err != nil { logger.Errorf("failed writing DNS response: %v", err) } } @@ -303,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 { @@ -324,11 +300,15 @@ func (d *DnsInterceptor) getUpstreamIP(peerKey string) (netip.Addr, error) { return peerAllowedIP, nil } -func (d *DnsInterceptor) writeMsg(w dns.ResponseWriter, r *dns.Msg) error { +func (d *DnsInterceptor) writeMsg(w dns.ResponseWriter, r *dns.Msg, logger *log.Entry) error { if r == nil { return fmt.Errorf("received nil DNS message") } + // Clear Zero bit from peer responses to prevent external sources from + // manipulating our internal fallthrough signaling mechanism + r.MsgHdr.Zero = false + if len(r.Answer) > 0 && len(r.Question) > 0 { origPattern := "" if writer, ok := w.(*nbdns.ResponseWriterChain); ok { @@ -350,14 +330,14 @@ func (d *DnsInterceptor) writeMsg(w dns.ResponseWriter, r *dns.Msg) error { case *dns.A: addr, ok := netip.AddrFromSlice(rr.A) if !ok { - log.Tracef("failed to convert A record for domain=%s ip=%v", resolvedDomain, rr.A) + logger.Tracef("failed to convert A record for domain=%s ip=%v", resolvedDomain, rr.A) continue } ip = addr case *dns.AAAA: addr, ok := netip.AddrFromSlice(rr.AAAA) if !ok { - log.Tracef("failed to convert AAAA record for domain=%s ip=%v", resolvedDomain, rr.AAAA) + logger.Tracef("failed to convert AAAA record for domain=%s ip=%v", resolvedDomain, rr.AAAA) continue } ip = addr @@ -370,11 +350,16 @@ func (d *DnsInterceptor) writeMsg(w dns.ResponseWriter, r *dns.Msg) error { } if len(newPrefixes) > 0 { - if err := d.updateDomainPrefixes(resolvedDomain, originalDomain, newPrefixes); err != nil { - log.Errorf("failed to update domain prefixes: %v", err) + if err := d.updateDomainPrefixes(resolvedDomain, originalDomain, newPrefixes, logger); err != nil { + logger.Errorf("failed to update domain prefixes: %v", err) } - d.replaceIPsInDNSResponse(r, newPrefixes) + // Allow time for route changes to be applied before sending + // the DNS response (relevant on iOS where setTunnelNetworkSettings + // is asynchronous). + waitForRouteSettlement(logger) + + d.replaceIPsInDNSResponse(r, newPrefixes, logger) } } @@ -386,22 +371,22 @@ func (d *DnsInterceptor) writeMsg(w dns.ResponseWriter, r *dns.Msg) error { } // logPrefixChanges handles the logging for prefix changes -func (d *DnsInterceptor) logPrefixChanges(resolvedDomain, originalDomain domain.Domain, toAdd, toRemove []netip.Prefix) { +func (d *DnsInterceptor) logPrefixChanges(resolvedDomain, originalDomain domain.Domain, toAdd, toRemove []netip.Prefix, logger *log.Entry) { if len(toAdd) > 0 { - log.Debugf("added dynamic route(s) for domain=%s (pattern: domain=%s): %s", + logger.Debugf("added dynamic route(s) for domain=%s (pattern: domain=%s): %s", resolvedDomain.SafeString(), originalDomain.SafeString(), toAdd) } if len(toRemove) > 0 && !d.route.KeepRoute { - log.Debugf("removed dynamic route(s) for domain=%s (pattern: domain=%s): %s", + logger.Debugf("removed dynamic route(s) for domain=%s (pattern: domain=%s): %s", resolvedDomain.SafeString(), originalDomain.SafeString(), toRemove) } } -func (d *DnsInterceptor) updateDomainPrefixes(resolvedDomain, originalDomain domain.Domain, newPrefixes []netip.Prefix) error { +func (d *DnsInterceptor) updateDomainPrefixes(resolvedDomain, originalDomain domain.Domain, newPrefixes []netip.Prefix, logger *log.Entry) error { d.mu.Lock() defer d.mu.Unlock() @@ -418,9 +403,9 @@ func (d *DnsInterceptor) updateDomainPrefixes(resolvedDomain, originalDomain dom realIP := prefix.Addr() if fakeIP, err := d.fakeIPManager.AllocateFakeIP(realIP); err == nil { dnatMappings[fakeIP] = realIP - log.Tracef("allocated fake IP %s for real IP %s", fakeIP, realIP) + logger.Tracef("allocated fake IP %s for real IP %s", fakeIP, realIP) } else { - log.Errorf("Failed to allocate fake IP for %s: %v", realIP, err) + logger.Errorf("failed to allocate fake IP for %s: %v", realIP, err) } } } @@ -432,7 +417,7 @@ func (d *DnsInterceptor) updateDomainPrefixes(resolvedDomain, originalDomain dom } } - d.addDNATMappings(dnatMappings) + d.addDNATMappings(dnatMappings, logger) if !d.route.KeepRoute { // Remove old prefixes @@ -448,7 +433,7 @@ func (d *DnsInterceptor) updateDomainPrefixes(resolvedDomain, originalDomain dom } } - d.removeDNATMappings(toRemove) + d.removeDNATMappings(toRemove, logger) } // Update domain prefixes using resolved domain as key - store real IPs @@ -463,14 +448,14 @@ func (d *DnsInterceptor) updateDomainPrefixes(resolvedDomain, originalDomain dom // Store real IPs for status (user-facing), not fake IPs d.statusRecorder.UpdateResolvedDomainsStates(originalDomain, resolvedDomain, newPrefixes, d.route.GetResourceID()) - d.logPrefixChanges(resolvedDomain, originalDomain, toAdd, toRemove) + d.logPrefixChanges(resolvedDomain, originalDomain, toAdd, toRemove, logger) } return nberrors.FormatErrorOrNil(merr) } // removeDNATMappings removes DNAT mappings from the firewall for real IP prefixes -func (d *DnsInterceptor) removeDNATMappings(realPrefixes []netip.Prefix) { +func (d *DnsInterceptor) removeDNATMappings(realPrefixes []netip.Prefix, logger *log.Entry) { if len(realPrefixes) == 0 { return } @@ -484,9 +469,9 @@ func (d *DnsInterceptor) removeDNATMappings(realPrefixes []netip.Prefix) { realIP := prefix.Addr() if fakeIP, exists := d.fakeIPManager.GetFakeIP(realIP); exists { if err := dnatFirewall.RemoveInternalDNATMapping(fakeIP); err != nil { - log.Errorf("Failed to remove DNAT mapping for %s: %v", fakeIP, err) + logger.Errorf("failed to remove DNAT mapping for %s: %v", fakeIP, err) } else { - log.Debugf("Removed DNAT mapping for: %s -> %s", fakeIP, realIP) + logger.Debugf("removed DNAT mapping: %s -> %s", fakeIP, realIP) } } } @@ -502,7 +487,7 @@ func (d *DnsInterceptor) internalDnatFw() (internalDNATer, bool) { } // addDNATMappings adds DNAT mappings to the firewall -func (d *DnsInterceptor) addDNATMappings(mappings map[netip.Addr]netip.Addr) { +func (d *DnsInterceptor) addDNATMappings(mappings map[netip.Addr]netip.Addr, logger *log.Entry) { if len(mappings) == 0 { return } @@ -514,9 +499,9 @@ func (d *DnsInterceptor) addDNATMappings(mappings map[netip.Addr]netip.Addr) { for fakeIP, realIP := range mappings { if err := dnatFirewall.AddInternalDNATMapping(fakeIP, realIP); err != nil { - log.Errorf("Failed to add DNAT mapping %s -> %s: %v", fakeIP, realIP, err) + logger.Errorf("failed to add DNAT mapping %s -> %s: %v", fakeIP, realIP, err) } else { - log.Debugf("Added DNAT mapping: %s -> %s", fakeIP, realIP) + logger.Debugf("added DNAT mapping: %s -> %s", fakeIP, realIP) } } } @@ -528,12 +513,12 @@ func (d *DnsInterceptor) cleanupDNATMappings() { } for _, prefixes := range d.interceptedDomains { - d.removeDNATMappings(prefixes) + d.removeDNATMappings(prefixes, log.NewEntry(log.StandardLogger())) } } // replaceIPsInDNSResponse replaces real IPs with fake IPs in the DNS response -func (d *DnsInterceptor) replaceIPsInDNSResponse(reply *dns.Msg, realPrefixes []netip.Prefix) { +func (d *DnsInterceptor) replaceIPsInDNSResponse(reply *dns.Msg, realPrefixes []netip.Prefix, logger *log.Entry) { if _, ok := d.internalDnatFw(); !ok { return } @@ -549,7 +534,7 @@ func (d *DnsInterceptor) replaceIPsInDNSResponse(reply *dns.Msg, realPrefixes [] if fakeIP, exists := d.fakeIPManager.GetFakeIP(realIP); exists { rr.A = fakeIP.AsSlice() - log.Tracef("Replaced real IP %s with fake IP %s in DNS response", realIP, fakeIP) + logger.Tracef("replaced real IP %s with fake IP %s in DNS response", realIP, fakeIP) } case *dns.AAAA: @@ -560,7 +545,7 @@ func (d *DnsInterceptor) replaceIPsInDNSResponse(reply *dns.Msg, realPrefixes [] if fakeIP, exists := d.fakeIPManager.GetFakeIP(realIP); exists { rr.AAAA = fakeIP.AsSlice() - log.Tracef("Replaced real IP %s with fake IP %s in DNS response", realIP, fakeIP) + logger.Tracef("replaced real IP %s with fake IP %s in DNS response", realIP, fakeIP) } } } @@ -586,6 +571,44 @@ func determinePrefixChanges(oldPrefixes, newPrefixes []netip.Prefix) (toAdd, toR return } +// queryUpstreamDNS queries the upstream DNS server using netstack if available, otherwise uses regular client. +// Returns the DNS reply on success, or nil on error (error responses are written internally). +func (d *DnsInterceptor) queryUpstreamDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg, upstream string, upstreamIP netip.Addr, peerKey string, logger *log.Entry) *dns.Msg { + startTime := time.Now() + + nsNet := d.wgInterface.GetNet() + var reply *dns.Msg + var err error + + if nsNet != nil { + reply, err = nbdns.ExchangeWithNetstack(ctx, nsNet, r, upstream) + } else { + 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 + } + reply, _, err = nbdns.ExchangeWithFallback(ctx, client, r, upstream) + } + + if err == nil { + return reply + } + + if errors.Is(err, context.DeadlineExceeded) { + elapsed := time.Since(startTime) + peerInfo := d.debugPeerTimeout(upstreamIP, peerKey) + logger.Errorf("peer DNS timeout after %v (timeout=%v) for domain=%s to peer %s (%s)%s - error: %v", + elapsed.Truncate(time.Millisecond), dnsTimeout, r.Question[0].Name, upstreamIP.String(), peerKey, peerInfo, err) + } else { + logger.Errorf("failed to exchange DNS request with %s (%s) for domain=%s: %v", upstreamIP.String(), peerKey, r.Question[0].Name, err) + } + if err := w.WriteMsg(&dns.Msg{MsgHdr: dns.MsgHdr{Rcode: dns.RcodeServerFailure, Id: r.Id}}); err != nil { + logger.Errorf("failed writing DNS response: %v", err) + } + return nil +} + func (d *DnsInterceptor) debugPeerTimeout(peerIP netip.Addr, peerKey string) string { if d.statusRecorder == nil { return "" diff --git a/client/internal/routemanager/dnsinterceptor/handler_ios.go b/client/internal/routemanager/dnsinterceptor/handler_ios.go new file mode 100644 index 000000000..4cf80eb16 --- /dev/null +++ b/client/internal/routemanager/dnsinterceptor/handler_ios.go @@ -0,0 +1,20 @@ +//go:build ios + +package dnsinterceptor + +import ( + "time" + + log "github.com/sirupsen/logrus" +) + +const routeSettleDelay = 500 * time.Millisecond + +// waitForRouteSettlement introduces a short delay on iOS to allow +// setTunnelNetworkSettings to apply route changes before the DNS +// response reaches the application. Without this, the first request +// to a newly resolved domain may bypass the tunnel. +func waitForRouteSettlement(logger *log.Entry) { + logger.Tracef("waiting %v for iOS route settlement", routeSettleDelay) + time.Sleep(routeSettleDelay) +} diff --git a/client/internal/routemanager/dnsinterceptor/handler_nonios.go b/client/internal/routemanager/dnsinterceptor/handler_nonios.go new file mode 100644 index 000000000..68cd7330b --- /dev/null +++ b/client/internal/routemanager/dnsinterceptor/handler_nonios.go @@ -0,0 +1,12 @@ +//go:build !ios + +package dnsinterceptor + +import log "github.com/sirupsen/logrus" + +func waitForRouteSettlement(_ *log.Entry) { + // No-op on non-iOS platforms: route changes are applied synchronously by + // the kernel, so no settlement delay is needed before the DNS response + // reaches the application. The delay is only required on iOS where + // setTunnelNetworkSettings applies routes asynchronously. +} diff --git a/client/internal/routemanager/dynamic/route.go b/client/internal/routemanager/dynamic/route.go index 587e05c74..3fe8a4bb3 100644 --- a/client/internal/routemanager/dynamic/route.go +++ b/client/internal/routemanager/dynamic/route.go @@ -18,8 +18,8 @@ import ( "github.com/netbirdio/netbird/client/internal/routemanager/iface" "github.com/netbirdio/netbird/client/internal/routemanager/refcounter" "github.com/netbirdio/netbird/client/internal/routemanager/util" - "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/domain" ) const ( @@ -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, @@ -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 @@ -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/iface/iface.go b/client/internal/routemanager/iface/iface.go index 57dbec03d..b44d9fa65 100644 --- a/client/internal/routemanager/iface/iface.go +++ b/client/internal/routemanager/iface/iface.go @@ -1,5 +1,4 @@ //go:build !windows -// +build !windows package iface diff --git a/client/internal/routemanager/iface/iface_common.go b/client/internal/routemanager/iface/iface_common.go index f844f4bed..9b7bce751 100644 --- a/client/internal/routemanager/iface/iface_common.go +++ b/client/internal/routemanager/iface/iface_common.go @@ -4,6 +4,8 @@ import ( "net" "net/netip" + "golang.zx2c4.com/wireguard/tun/netstack" + "github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/wgaddr" ) @@ -18,4 +20,5 @@ type wgIfaceBase interface { IsUserspaceBind() bool GetFilter() device.PacketFilter GetDevice() *device.FilteredDevice + GetNet() *netstack.Net } diff --git a/client/internal/routemanager/ipfwdstate/ipfwdstate.go b/client/internal/routemanager/ipfwdstate/ipfwdstate.go index da81c18f9..2be1c2ae7 100644 --- a/client/internal/routemanager/ipfwdstate/ipfwdstate.go +++ b/client/internal/routemanager/ipfwdstate/ipfwdstate.go @@ -9,7 +9,11 @@ import ( ) // IPForwardingState is a struct that keeps track of the IP forwarding state. -// todo: read initial state of the IP forwarding from the system and reset the state based on it +// todo: read initial state of the IP forwarding from the system and reset the state based on it. +// todo: separate v4/v6 forwarding state, since the sysctls are independent +// (net.ipv4.ip_forward vs net.ipv6.conf.all.forwarding). Currently the nftables +// manager shares one instance between both routers, which works only because +// EnableIPForwarding enables both sysctls in a single call. type IPForwardingState struct { enabledCounter int } diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 26cf758d9..ef69b81a4 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -9,8 +9,10 @@ import ( "net/url" "runtime" "slices" + "strings" "sync" "sync/atomic" + "syscall" "time" "github.com/google/uuid" @@ -24,7 +26,6 @@ import ( "github.com/netbirdio/netbird/client/iface/netstack" "github.com/netbirdio/netbird/client/internal/dns" "github.com/netbirdio/netbird/client/internal/listener" - nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/client/internal/peer" "github.com/netbirdio/netbird/client/internal/peerstore" "github.com/netbirdio/netbird/client/internal/routemanager/client" @@ -39,6 +40,7 @@ import ( "github.com/netbirdio/netbird/client/internal/routeselector" "github.com/netbirdio/netbird/client/internal/statemanager" nbnet "github.com/netbirdio/netbird/client/net" + nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/route" relayClient "github.com/netbirdio/netbird/shared/relay/client" "github.com/netbirdio/netbird/version" @@ -52,11 +54,14 @@ type Manager interface { TriggerSelection(route.HAMap) GetRouteSelector() *routeselector.RouteSelector GetClientRoutes() route.HAMap + GetSelectedClientRoutes() route.HAMap + GetActiveClientRoutes() route.HAMap GetClientRoutesWithNetID() map[route.NetID][]*route.Route SetRouteChangeListener(listener listener.NetworkChangeListener) InitialRouteRange() []string SetFirewall(firewall.Manager) error SetDNSForwarderPort(port uint16) + ReconcilePeerAllowedIPs(peerKey string) error Stop(stateManager *statemanager.Manager) } @@ -158,27 +163,45 @@ func (m *DefaultManager) setupAndroidRoutes(config ManagerConfig) { if config.DNSFeatureFlag { m.fakeIPManager = fakeip.NewManager() - id := uuid.NewString() + v4ID := uuid.NewString() fakeIPRoute := &route.Route{ - ID: route.ID(id), + ID: route.ID(v4ID), Network: m.fakeIPManager.GetFakeIPBlock(), - NetID: route.NetID(id), + NetID: route.NetID(v4ID), Peer: m.pubKey, NetworkType: route.IPv4Network, } - cr = append(cr, fakeIPRoute) + v6ID := uuid.NewString() + fakeIPv6Route := &route.Route{ + ID: route.ID(v6ID), + Network: m.fakeIPManager.GetFakeIPv6Block(), + NetID: route.NetID(v6ID), + Peer: m.pubKey, + NetworkType: route.IPv6Network, + } + cr = append(cr, fakeIPRoute, fakeIPv6Route) + m.notifier.SetFakeIPRoutes([]*route.Route{fakeIPRoute, fakeIPv6Route}) } m.notifier.SetInitialClientRoutes(cr, routesForComparison) } func (m *DefaultManager) setupRefCounters(useNoop bool) { + var once sync.Once + var wgIface *net.Interface + toInterface := func() *net.Interface { + once.Do(func() { + wgIface = m.wgInterface.ToInterface() + }) + return wgIface + } + m.routeRefCounter = refcounter.New( func(prefix netip.Prefix, _ struct{}) (struct{}, error) { - return struct{}{}, m.sysOps.AddVPNRoute(prefix, m.wgInterface.ToInterface()) + return struct{}{}, m.sysOps.AddVPNRoute(prefix, toInterface()) }, func(prefix netip.Prefix, _ struct{}) error { - return m.sysOps.RemoveVPNRoute(prefix, m.wgInterface.ToInterface()) + return m.sysOps.RemoveVPNRoute(prefix, toInterface()) }, ) @@ -210,6 +233,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() @@ -243,7 +290,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() } @@ -312,6 +363,8 @@ func (m *DefaultManager) Stop(stateManager *statemanager.Manager) { } } + m.notifier.Close() + m.mux.Lock() defer m.mux.Unlock() m.clientRoutes = nil @@ -337,6 +390,23 @@ func (m *DefaultManager) updateSystemRoutes(newRoutes route.HAMap) error { } var merr *multierror.Error + + // Begin batch mode to avoid calling applyHostConfig() after each DNS handler operation + batchStarted := false + if m.dnsServer != nil { + m.dnsServer.BeginBatch() + batchStarted = true + defer func() { + if merr != nil { + // On error, cancel batch to discard partial DNS state + m.dnsServer.CancelBatch() + } else { + // On success, apply accumulated DNS changes + m.dnsServer.EndBatch() + } + }() + } + for id, handler := range toRemove { if err := handler.RemoveRoute(); err != nil { merr = multierror.Append(merr, fmt.Errorf("remove route %s: %w", handler.String(), err)) @@ -367,6 +437,7 @@ func (m *DefaultManager) updateSystemRoutes(newRoutes route.HAMap) error { m.activeRoutes[id] = handler } + _ = batchStarted // Mark as used return nberrors.FormatErrorOrNil(merr) } @@ -401,6 +472,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 @@ -438,6 +514,49 @@ func (m *DefaultManager) GetClientRoutes() route.HAMap { return maps.Clone(m.clientRoutes) } +// GetSelectedClientRoutes returns only the currently selected/active client routes, +// filtering out deselected exit nodes. Use this instead of GetClientRoutes when checking +// if traffic should be routed through the tunnel. +func (m *DefaultManager) GetSelectedClientRoutes() route.HAMap { + m.mux.Lock() + defer m.mux.Unlock() + + 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() @@ -498,6 +617,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 @@ -617,15 +740,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 { @@ -635,6 +792,10 @@ 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 @@ -644,6 +805,9 @@ func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeI } netID := haID.NetID() + if strings.HasSuffix(string(netID), route.V6ExitSuffix) { + continue + } info.allIDs = append(info.allIDs, netID) if m.routeSelector.HasUserSelectionForRoute(netID) { @@ -657,7 +821,10 @@ func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeI } func (m *DefaultManager) isExitNodeRoute(routes []*route.Route) bool { - return len(routes) > 0 && routes[0].Network.String() == vars.ExitNodeCIDR + if len(routes) == 0 { + return false + } + return route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network) } func (m *DefaultManager) categorizeUserSelection(netID route.NetID, info *exitNodeInfo) { @@ -677,45 +844,52 @@ 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)) +} + +// 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 "" + } + best := ids[0] + for _, id := range ids[1:] { + if id < best { + best = id } } - return routesToDeselect -} - -func (m *DefaultManager) deselectExitNodes(routesToDeselect []route.NetID) { - if len(routesToDeselect) == 0 { - return - } - - err := m.routeSelector.DeselectRoutes(routesToDeselect, routesToDeselect) - if err != nil { - log.Warnf("Failed to deselect exit nodes: %v", err) - } -} - -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 d2f02526c..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 ( @@ -6,12 +8,13 @@ import ( "net/netip" "testing" - "github.com/pion/transport/v3/stdnet" + "github.com/netbirdio/netbird/client/internal/stdnet" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "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" ) @@ -403,13 +406,13 @@ func TestManagerUpdateRoutes(t *testing.T) { for n, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { peerPrivateKey, _ := wgtypes.GeneratePrivateKey() - newNet, err := stdnet.NewNet() + newNet, err := stdnet.NewNet(context.Background(), nil) if err != nil { t.Fatal(err) } 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 6b06144b2..c1620b24c 100644 --- a/client/internal/routemanager/mock.go +++ b/client/internal/routemanager/mock.go @@ -18,6 +18,8 @@ type MockManager struct { TriggerSelectionFunc func(haMap route.HAMap) 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) } @@ -61,7 +63,7 @@ func (m *MockManager) GetRouteSelector() *routeselector.RouteSelector { return nil } -// GetClientRoutes mock implementation of GetClientRoutes from Manager interface +// GetClientRoutes mock implementation of GetClientRoutes from the Manager interface func (m *MockManager) GetClientRoutes() route.HAMap { if m.GetClientRoutesFunc != nil { return m.GetClientRoutesFunc() @@ -69,6 +71,22 @@ func (m *MockManager) GetClientRoutes() route.HAMap { return nil } +// GetSelectedClientRoutes mock implementation of GetSelectedClientRoutes from the Manager interface +func (m *MockManager) GetSelectedClientRoutes() route.HAMap { + if m.GetSelectedClientRoutesFunc != nil { + return m.GetSelectedClientRoutesFunc() + } + 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 { @@ -94,6 +112,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 dec0af87c..49300dbb2 100644 --- a/client/internal/routemanager/notifier/notifier_android.go +++ b/client/internal/routemanager/notifier/notifier_android.go @@ -16,6 +16,7 @@ import ( type Notifier struct { initialRoutes []*route.Route currentRoutes []*route.Route + fakeIPRoutes []*route.Route listener listener.NetworkChangeListener listenerMux sync.Mutex @@ -31,26 +32,15 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) { n.listener = listener } +// SetInitialClientRoutes stores the initial route sets for TUN configuration. func (n *Notifier) SetInitialClientRoutes(initialRoutes []*route.Route, routesForComparison []*route.Route) { - // initialRoutes contains fake IP block for interface configuration - filteredInitial := make([]*route.Route, 0) - for _, r := range initialRoutes { - if r.IsDynamic() { - continue - } - filteredInitial = append(filteredInitial, r) - } - n.initialRoutes = filteredInitial + n.initialRoutes = filterStatic(initialRoutes) + n.currentRoutes = filterStatic(routesForComparison) +} - // routesForComparison excludes fake IP block for comparison with new routes - filteredComparison := make([]*route.Route, 0) - for _, r := range routesForComparison { - if r.IsDynamic() { - continue - } - filteredComparison = append(filteredComparison, r) - } - n.currentRoutes = filteredComparison +// SetFakeIPRoutes stores the fake IP routes to be included in every TUN rebuild. +func (n *Notifier) SetFakeIPRoutes(routes []*route.Route) { + n.fakeIPRoutes = routes } func (n *Notifier) OnNewRoutes(idMap route.HAMap) { @@ -83,13 +73,26 @@ func (n *Notifier) notify() { return } - routeStrings := n.routesToStrings(n.currentRoutes) + allRoutes := slices.Clone(n.currentRoutes) + allRoutes = append(allRoutes, n.fakeIPRoutes...) + + routeStrings := n.routesToStrings(allRoutes) sort.Strings(routeStrings) go func(l listener.NetworkChangeListener) { - l.OnNetworkChanged(strings.Join(n.addIPv6RangeIfNeeded(routeStrings, n.currentRoutes), ",")) + l.OnNetworkChanged(strings.Join(routeStrings, ",")) }(n.listener) } +func filterStatic(routes []*route.Route) []*route.Route { + out := make([]*route.Route, 0, len(routes)) + for _, r := range routes { + if !r.IsDynamic() { + out = append(out, r) + } + } + return out +} + func (n *Notifier) routesToStrings(routes []*route.Route) []string { nets := make([]string, 0, len(routes)) for _, r := range routes { @@ -114,14 +117,9 @@ func (n *Notifier) hasRouteDiff(a []*route.Route, b []*route.Route) bool { func (n *Notifier) GetInitialRouteRanges() []string { initialStrings := n.routesToStrings(n.initialRoutes) sort.Strings(initialStrings) - return n.addIPv6RangeIfNeeded(initialStrings, n.initialRoutes) + return initialStrings } -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 bb125cfa4..d0888f3a1 100644 --- a/client/internal/routemanager/notifier/notifier_ios.go +++ b/client/internal/routemanager/notifier/notifier_ios.go @@ -3,6 +3,7 @@ package notifier import ( + "container/list" "net/netip" "slices" "sort" @@ -14,19 +15,26 @@ import ( ) type Notifier struct { + mu sync.Mutex + cond *sync.Cond currentPrefixes []string - - listener listener.NetworkChangeListener - listenerMux sync.Mutex + listener listener.NetworkChangeListener + queue *list.List + closed bool } func NewNotifier() *Notifier { - return &Notifier{} + n := &Notifier{ + queue: list.New(), + } + n.cond = sync.NewCond(&n.mu) + go n.deliverLoop() + return n } func (n *Notifier) SetListener(listener listener.NetworkChangeListener) { - n.listenerMux.Lock() - defer n.listenerMux.Unlock() + n.mu.Lock() + defer n.mu.Unlock() n.listener = listener } @@ -34,47 +42,61 @@ func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) { // iOS doesn't care about initial routes } +func (n *Notifier) SetFakeIPRoutes([]*route.Route) { + // Not used on iOS +} + func (n *Notifier) OnNewRoutes(route.HAMap) { // Not used on iOS } 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() if slices.Equal(n.currentPrefixes, newNets) { + n.mu.Unlock() return } - n.currentPrefixes = newNets - n.notify() + routes := strings.Join(n.currentPrefixes, ",") + n.queue.PushBack(routes) + n.cond.Signal() + n.mu.Unlock() } -func (n *Notifier) notify() { - n.listenerMux.Lock() - defer n.listenerMux.Unlock() - if n.listener == nil { - return - } - - go func(l listener.NetworkChangeListener) { - l.OnNetworkChanged(strings.Join(n.addIPv6RangeIfNeeded(n.currentPrefixes), ",")) - }(n.listener) +func (n *Notifier) Close() { + n.mu.Lock() + n.closed = true + n.cond.Signal() + n.mu.Unlock() } func (n *Notifier) GetInitialRouteRanges() []string { return nil } -func (n *Notifier) addIPv6RangeIfNeeded(inputRanges []string) []string { - for _, r := range inputRanges { - if r == "0.0.0.0/0" { - return append(slices.Clone(inputRanges), "::/0") +func (n *Notifier) deliverLoop() { + for { + n.mu.Lock() + for n.queue.Len() == 0 && !n.closed { + n.cond.Wait() + } + if n.closed && n.queue.Len() == 0 { + n.mu.Unlock() + return + } + routes := n.queue.Remove(n.queue.Front()).(string) + l := n.listener + n.mu.Unlock() + + if l != nil { + l.OnNetworkChanged(routes) } } - return inputRanges } diff --git a/client/internal/routemanager/notifier/notifier_other.go b/client/internal/routemanager/notifier/notifier_other.go index 0521e3dc2..71b1096c2 100644 --- a/client/internal/routemanager/notifier/notifier_other.go +++ b/client/internal/routemanager/notifier/notifier_other.go @@ -23,6 +23,10 @@ func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) { // Not used on non-mobile platforms } +func (n *Notifier) SetFakeIPRoutes([]*route.Route) { + // Not used on non-mobile platforms +} + func (n *Notifier) OnNewRoutes(idMap route.HAMap) { // Not used on non-mobile platforms } @@ -34,3 +38,7 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) { func (n *Notifier) GetInitialRouteRanges() []string { return []string{} } + +func (n *Notifier) Close() { + // unused +} diff --git a/client/internal/routemanager/reconcile_test.go b/client/internal/routemanager/reconcile_test.go new file mode 100644 index 000000000..2a8a4dc10 --- /dev/null +++ b/client/internal/routemanager/reconcile_test.go @@ -0,0 +1,90 @@ +//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 only records AddAllowedIP calls; every other +// method is an inert stub because ReconcilePeerAllowedIPs exercises none of them. +type reconcileWGMock struct { + mu sync.Mutex + adds map[string][]netip.Prefix +} + +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 wgaddr.Address{} } +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.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 := 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/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/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..f569c0cac 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,7 +129,7 @@ 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) } @@ -146,8 +151,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 +162,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/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/routeflags_bsd.go b/client/internal/routemanager/systemops/routeflags_bsd.go index ad32e5029..33280bfb3 100644 --- a/client/internal/routemanager/systemops/routeflags_bsd.go +++ b/client/internal/routemanager/systemops/routeflags_bsd.go @@ -4,16 +4,17 @@ package systemops import ( "strings" - "syscall" + + "golang.org/x/sys/unix" ) // filterRoutesByFlags returns true if the route message should be ignored based on its flags. func filterRoutesByFlags(routeMessageFlags int) bool { - if routeMessageFlags&syscall.RTF_UP == 0 { + if routeMessageFlags&unix.RTF_UP == 0 { return true } - if routeMessageFlags&(syscall.RTF_REJECT|syscall.RTF_BLACKHOLE|syscall.RTF_WASCLONED) != 0 { + if routeMessageFlags&(unix.RTF_REJECT|unix.RTF_BLACKHOLE|unix.RTF_WASCLONED) != 0 { return true } @@ -24,42 +25,51 @@ func filterRoutesByFlags(routeMessageFlags int) bool { func formatBSDFlags(flags int) string { var flagStrs []string - if flags&syscall.RTF_UP != 0 { + if flags&unix.RTF_UP != 0 { flagStrs = append(flagStrs, "U") } - if flags&syscall.RTF_GATEWAY != 0 { + if flags&unix.RTF_GATEWAY != 0 { flagStrs = append(flagStrs, "G") } - if flags&syscall.RTF_HOST != 0 { + if flags&unix.RTF_HOST != 0 { flagStrs = append(flagStrs, "H") } - if flags&syscall.RTF_REJECT != 0 { + if flags&unix.RTF_REJECT != 0 { flagStrs = append(flagStrs, "R") } - if flags&syscall.RTF_DYNAMIC != 0 { + if flags&unix.RTF_DYNAMIC != 0 { flagStrs = append(flagStrs, "D") } - if flags&syscall.RTF_MODIFIED != 0 { + if flags&unix.RTF_MODIFIED != 0 { flagStrs = append(flagStrs, "M") } - if flags&syscall.RTF_STATIC != 0 { + if flags&unix.RTF_STATIC != 0 { flagStrs = append(flagStrs, "S") } - if flags&syscall.RTF_LLINFO != 0 { + if flags&unix.RTF_LLINFO != 0 { flagStrs = append(flagStrs, "L") } - if flags&syscall.RTF_LOCAL != 0 { + if flags&unix.RTF_LOCAL != 0 { flagStrs = append(flagStrs, "l") } - if flags&syscall.RTF_BLACKHOLE != 0 { + if flags&unix.RTF_BLACKHOLE != 0 { flagStrs = append(flagStrs, "B") } - if flags&syscall.RTF_CLONING != 0 { + if flags&unix.RTF_CLONING != 0 { flagStrs = append(flagStrs, "C") } - if flags&syscall.RTF_WASCLONED != 0 { + if flags&unix.RTF_WASCLONED != 0 { flagStrs = append(flagStrs, "W") } + if flags&unix.RTF_PROTO1 != 0 { + flagStrs = append(flagStrs, "1") + } + if flags&unix.RTF_PROTO2 != 0 { + flagStrs = append(flagStrs, "2") + } + if flags&unix.RTF_PROTO3 != 0 { + flagStrs = append(flagStrs, "3") + } if len(flagStrs) == 0 { return "-" diff --git a/client/internal/routemanager/systemops/routeflags_freebsd.go b/client/internal/routemanager/systemops/routeflags_freebsd.go index 2338fe5d8..a8c82b3ed 100644 --- a/client/internal/routemanager/systemops/routeflags_freebsd.go +++ b/client/internal/routemanager/systemops/routeflags_freebsd.go @@ -4,17 +4,18 @@ package systemops import ( "strings" - "syscall" + + "golang.org/x/sys/unix" ) // filterRoutesByFlags returns true if the route message should be ignored based on its flags. func filterRoutesByFlags(routeMessageFlags int) bool { - if routeMessageFlags&syscall.RTF_UP == 0 { + if routeMessageFlags&unix.RTF_UP == 0 { return true } - // NOTE: syscall.RTF_WASCLONED deprecated in FreeBSD 8.0 - if routeMessageFlags&(syscall.RTF_REJECT|syscall.RTF_BLACKHOLE) != 0 { + // NOTE: RTF_WASCLONED deprecated in FreeBSD 8.0 + if routeMessageFlags&(unix.RTF_REJECT|unix.RTF_BLACKHOLE) != 0 { return true } @@ -25,37 +26,46 @@ func filterRoutesByFlags(routeMessageFlags int) bool { func formatBSDFlags(flags int) string { var flagStrs []string - if flags&syscall.RTF_UP != 0 { + if flags&unix.RTF_UP != 0 { flagStrs = append(flagStrs, "U") } - if flags&syscall.RTF_GATEWAY != 0 { + if flags&unix.RTF_GATEWAY != 0 { flagStrs = append(flagStrs, "G") } - if flags&syscall.RTF_HOST != 0 { + if flags&unix.RTF_HOST != 0 { flagStrs = append(flagStrs, "H") } - if flags&syscall.RTF_REJECT != 0 { + if flags&unix.RTF_REJECT != 0 { flagStrs = append(flagStrs, "R") } - if flags&syscall.RTF_DYNAMIC != 0 { + if flags&unix.RTF_DYNAMIC != 0 { flagStrs = append(flagStrs, "D") } - if flags&syscall.RTF_MODIFIED != 0 { + if flags&unix.RTF_MODIFIED != 0 { flagStrs = append(flagStrs, "M") } - if flags&syscall.RTF_STATIC != 0 { + if flags&unix.RTF_STATIC != 0 { flagStrs = append(flagStrs, "S") } - if flags&syscall.RTF_LLINFO != 0 { + if flags&unix.RTF_LLINFO != 0 { flagStrs = append(flagStrs, "L") } - if flags&syscall.RTF_LOCAL != 0 { + if flags&unix.RTF_LOCAL != 0 { flagStrs = append(flagStrs, "l") } - if flags&syscall.RTF_BLACKHOLE != 0 { + if flags&unix.RTF_BLACKHOLE != 0 { flagStrs = append(flagStrs, "B") } // Note: RTF_CLONING and RTF_WASCLONED deprecated in FreeBSD 8.0 + if flags&unix.RTF_PROTO1 != 0 { + flagStrs = append(flagStrs, "1") + } + if flags&unix.RTF_PROTO2 != 0 { + flagStrs = append(flagStrs, "2") + } + if flags&unix.RTF_PROTO3 != 0 { + flagStrs = append(flagStrs, "3") + } if len(flagStrs) == 0 { return "-" diff --git a/client/internal/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_bsd_other.go b/client/internal/routemanager/systemops/systemops_bsd_other.go new file mode 100644 index 000000000..3f09219aa --- /dev/null +++ b/client/internal/routemanager/systemops/systemops_bsd_other.go @@ -0,0 +1,10 @@ +//go:build (dragonfly || freebsd || netbsd || openbsd) && !darwin + +package systemops + +// Non-darwin BSDs don't support the IP_BOUND_IF + scoped default model. They +// always fall through to the ref-counter exclusion-route path; these stubs +// exist only so systemops_unix.go compiles. +func (r *SysOps) setupAdvancedRouting() error { return nil } +func (r *SysOps) cleanupAdvancedRouting() error { return nil } +func (r *SysOps) flushPlatformExtras() 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 new file mode 100644 index 000000000..3fcac4c6a --- /dev/null +++ b/client/internal/routemanager/systemops/systemops_darwin.go @@ -0,0 +1,253 @@ +//go:build darwin && !ios + +package systemops + +import ( + "errors" + "fmt" + "net/netip" + "os" + "time" + + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + "golang.org/x/net/route" + "golang.org/x/sys/unix" + + nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/client/internal/routemanager/vars" + nbnet "github.com/netbirdio/netbird/client/net" +) + +// scopedRouteBudget bounds retries for the scoped default route. Installing or +// deleting it matters enough that we're willing to spend longer waiting for the +// kernel reply than for per-prefix exclusion routes. +const scopedRouteBudget = 5 * time.Second + +// setupAdvancedRouting installs an RTF_IFSCOPE default route per address family +// pinned to the current physical egress, so IP_BOUND_IF scoped lookups can +// resolve gateway'd destinations while the VPN's split default owns the +// unscoped table. +// +// Timing note: this runs during routeManager.Init, which happens before the +// VPN interface is created and before any peer routes propagate. The initial +// mgmt / signal / relay TCP dials always fire before this runs, so those +// sockets miss the IP_BOUND_IF binding and rely on the kernel's normal route +// lookup, which at that point correctly picks the physical default. Those +// already-established TCP flows keep their originally-selected interface for +// their lifetime on Darwin because the kernel caches the egress route +// per-socket at connect time; adding the VPN's 0/1 + 128/1 split default +// afterwards does not migrate them since the original en0 default stays in +// the table. Any subsequent reconnect via nbnet.NewDialer picks up the +// populated bound-iface cache and gets IP_BOUND_IF set cleanly. +func (r *SysOps) setupAdvancedRouting() error { + // Drop any previously-cached egress interface before reinstalling. On a + // refresh, a family that no longer resolves would otherwise keep the stale + // binding, causing new sockets to scope to an interface without a matching + // scoped default. + nbnet.ClearBoundInterfaces() + + if err := r.flushScopedDefaults(); err != nil { + log.Warnf("flush residual scoped defaults: %v", err) + } + + var merr *multierror.Error + installed := 0 + + for _, unspec := range []netip.Addr{netip.IPv4Unspecified(), netip.IPv6Unspecified()} { + ok, err := r.installScopedDefaultFor(unspec) + if err != nil { + merr = multierror.Append(merr, err) + continue + } + if ok { + installed++ + } + } + + if installed == 0 && merr != nil { + return nberrors.FormatErrorOrNil(merr) + } + if merr != nil { + log.Warnf("advanced routing setup partially succeeded: %v", nberrors.FormatErrorOrNil(merr)) + } + return nil +} + +// installScopedDefaultFor resolves the physical default nexthop for the given +// address family, installs a scoped default via it, and caches the iface for +// subsequent IP_BOUND_IF / IPV6_BOUND_IF socket binds. +func (r *SysOps) installScopedDefaultFor(unspec netip.Addr) (bool, error) { + nexthop, err := GetNextHop(unspec) + if err != nil { + if errors.Is(err, vars.ErrRouteNotFound) { + return false, nil + } + return false, fmt.Errorf("get default nexthop for %s: %w", unspec, err) + } + if nexthop.Intf == nil { + return false, fmt.Errorf("unusable default nexthop for %s (no interface)", unspec) + } + + reused := false + if err := r.addScopedDefault(unspec, nexthop); err != nil { + 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 + if unspec.Is6() { + af = unix.AF_INET6 + } + nbnet.SetBoundInterface(af, nexthop.Intf) + via := "point-to-point" + if nexthop.IP.IsValid() { + via = nexthop.IP.String() + } + 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 +} + +func (r *SysOps) cleanupAdvancedRouting() error { + nbnet.ClearBoundInterfaces() + return r.flushScopedDefaults() +} + +// flushPlatformExtras runs darwin-specific residual cleanup hooked into the +// generic FlushMarkedRoutes path, so a crashed daemon's scoped defaults get +// removed on the next boot regardless of whether a profile is brought up. +func (r *SysOps) flushPlatformExtras() error { + return r.flushScopedDefaults() +} + +// flushScopedDefaults removes any scoped default routes tagged with routeProtoFlag. +// Safe to call at startup to clear residual entries from a prior session. +func (r *SysOps) flushScopedDefaults() error { + rib, err := retryFetchRIB() + if err != nil { + return fmt.Errorf("fetch routing table: %w", err) + } + + msgs, err := route.ParseRIB(route.RIBTypeRoute, rib) + if err != nil { + return fmt.Errorf("parse routing table: %w", err) + } + + var merr *multierror.Error + removed := 0 + + for _, msg := range msgs { + rtMsg, ok := msg.(*route.RouteMessage) + if !ok { + continue + } + if rtMsg.Flags&routeProtoFlag == 0 { + continue + } + if rtMsg.Flags&unix.RTF_IFSCOPE == 0 { + continue + } + + info, err := MsgToRoute(rtMsg) + if err != nil { + log.Debugf("skip scoped flush: %v", err) + continue + } + if !info.Dst.IsValid() || info.Dst.Bits() != 0 { + continue + } + + if err := r.deleteScopedRoute(rtMsg); err != nil { + merr = multierror.Append(merr, fmt.Errorf("delete scoped default %s on index %d: %w", + info.Dst, rtMsg.Index, err)) + continue + } + removed++ + log.Debugf("flushed residual scoped default %s on index %d", info.Dst, rtMsg.Index) + } + + if removed > 0 { + log.Infof("flushed %d residual scoped default route(s)", removed) + } + return nberrors.FormatErrorOrNil(merr) +} + +func (r *SysOps) addScopedDefault(unspec netip.Addr, nexthop Nexthop) error { + return r.scopedRouteSocket(unix.RTM_ADD, unspec, nexthop) +} + +func (r *SysOps) deleteScopedRoute(rtMsg *route.RouteMessage) error { + // Preserve identifying flags from the stored route (including RTF_GATEWAY + // only if present); kernel-set bits like RTF_DONE don't belong on RTM_DELETE. + keep := unix.RTF_UP | unix.RTF_STATIC | unix.RTF_GATEWAY | unix.RTF_IFSCOPE | routeProtoFlag + del := &route.RouteMessage{ + Type: unix.RTM_DELETE, + Flags: rtMsg.Flags & keep, + Version: unix.RTM_VERSION, + Seq: r.getSeq(), + Index: rtMsg.Index, + Addrs: rtMsg.Addrs, + } + return r.writeRouteMessage(del, scopedRouteBudget) +} + +func (r *SysOps) scopedRouteSocket(action int, unspec netip.Addr, nexthop Nexthop) error { + flags := unix.RTF_UP | unix.RTF_STATIC | unix.RTF_IFSCOPE | routeProtoFlag + + msg := &route.RouteMessage{ + Type: action, + Flags: flags, + Version: unix.RTM_VERSION, + ID: uintptr(os.Getpid()), + Seq: r.getSeq(), + Index: nexthop.Intf.Index, + } + + const numAddrs = unix.RTAX_NETMASK + 1 + addrs := make([]route.Addr, numAddrs) + + dst, err := addrToRouteAddr(unspec) + if err != nil { + return fmt.Errorf("build destination: %w", err) + } + mask, err := prefixToRouteNetmask(netip.PrefixFrom(unspec, 0)) + if err != nil { + return fmt.Errorf("build netmask: %w", err) + } + addrs[unix.RTAX_DST] = dst + addrs[unix.RTAX_NETMASK] = mask + + if nexthop.IP.IsValid() { + msg.Flags |= unix.RTF_GATEWAY + gw, err := addrToRouteAddr(nexthop.IP.Unmap()) + if err != nil { + return fmt.Errorf("build gateway: %w", err) + } + addrs[unix.RTAX_GATEWAY] = gw + } else { + addrs[unix.RTAX_GATEWAY] = &route.LinkAddr{ + Index: nexthop.Intf.Index, + Name: nexthop.Intf.Name, + } + } + msg.Addrs = addrs + + return r.writeRouteMessage(msg, scopedRouteBudget) +} + +func afOf(a netip.Addr) string { + if a.Is4() { + return "IPv4" + } + return "IPv6" +} 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 26a548634..bb9ac494d 100644 --- a/client/internal/routemanager/systemops/systemops_generic.go +++ b/client/internal/routemanager/systemops/systemops_generic.go @@ -21,6 +21,7 @@ import ( "github.com/netbirdio/netbird/client/internal/routemanager/util" "github.com/netbirdio/netbird/client/internal/routemanager/vars" "github.com/netbirdio/netbird/client/internal/statemanager" + nbnet "github.com/netbirdio/netbird/client/net" "github.com/netbirdio/netbird/client/net/hooks" ) @@ -31,8 +32,6 @@ var splitDefaultv4_2 = netip.PrefixFrom(netip.AddrFrom4([4]byte{128}), 1) var splitDefaultv6_1 = netip.PrefixFrom(netip.IPv6Unspecified(), 1) var splitDefaultv6_2 = netip.PrefixFrom(netip.AddrFrom16([16]byte{0x80}), 1) -var ErrRoutingIsSeparate = errors.New("routing is separate") - func (r *SysOps) setupRefCounter(initAddresses []net.IP, stateManager *statemanager.Manager) error { stateManager.RegisterState(&ShutdownState{}) @@ -122,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 @@ -210,7 +212,8 @@ func (r *SysOps) refreshLocalSubnetsCache() { func (r *SysOps) genericAddVPNRoute(prefix netip.Prefix, intf *net.Interface) error { nextHop := Nexthop{netip.Addr{}, intf} - if prefix == vars.Defaultv4 { + switch prefix { + case vars.Defaultv4: if err := r.addToRouteTable(splitDefaultv4_1, nextHop); err != nil { return err } @@ -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 - } else if prefix == 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 + case vars.Defaultv6: + return r.addV6SplitDefault(nextHop) } return r.addToRouteTable(prefix, nextHop) @@ -255,7 +248,8 @@ func (r *SysOps) genericAddVPNRoute(prefix netip.Prefix, intf *net.Interface) er func (r *SysOps) genericRemoveVPNRoute(prefix netip.Prefix, intf *net.Interface) error { nextHop := Nexthop{netip.Addr{}, intf} - if prefix == vars.Defaultv4 { + switch prefix { + case vars.Defaultv4: var result *multierror.Error if err := r.removeFromRouteTable(splitDefaultv4_1, nextHop); err != nil { result = multierror.Append(result, err) @@ -264,28 +258,40 @@ 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) - } - - return nberrors.FormatErrorOrNil(result) - } else if prefix == 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) + if !r.wgInterface.Address().HasIPv6() { + result = multierror.Append(result, r.removeV6SplitDefault(nextHop)) } return nberrors.FormatErrorOrNil(result) + case vars.Defaultv6: + return nberrors.FormatErrorOrNil(r.removeV6SplitDefault(nextHop)) + default: + return r.removeFromRouteTable(prefix, nextHop) } +} - 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 { @@ -341,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) @@ -395,12 +417,16 @@ func ipToAddr(ip net.IP, intf *net.Interface) (netip.Addr, error) { } // IsAddrRouted checks if the candidate address would route to the vpn, in which case it returns true and the matched prefix. +// When advanced routing is active the WG socket is bound to the physical interface (fwmark on linux, +// IP_UNICAST_IF on windows, IP_BOUND_IF on darwin) and bypasses the main routing table, so the check is skipped. func IsAddrRouted(addr netip.Addr, vpnRoutes []netip.Prefix) (bool, netip.Prefix) { - localRoutes, err := hasSeparateRouting() + if nbnet.AdvancedRouting() { + return false, netip.Prefix{} + } + + localRoutes, err := GetRoutesFromTable() if err != nil { - if !errors.Is(err, ErrRoutingIsSeparate) { - log.Errorf("Failed to get routes: %v", err) - } + log.Errorf("Failed to get routes: %v", err) return false, netip.Prefix{} } diff --git a/client/internal/routemanager/systemops/systemops_generic_test.go b/client/internal/routemanager/systemops/systemops_generic_test.go index d9b109beb..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 @@ -15,21 +15,17 @@ import ( "syscall" "testing" - "github.com/pion/transport/v3/stdnet" + "github.com/netbirdio/netbird/client/internal/stdnet" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "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) { @@ -436,12 +436,12 @@ func createWGInterface(t *testing.T, interfaceName, ipAddressCIDR string, listen peerPrivateKey, err := wgtypes.GeneratePrivateKey() require.NoError(t, err) - newNet, err := stdnet.NewNet() + newNet, err := stdnet.NewNet(context.Background(), nil) require.NoError(t, err) 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_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_js.go b/client/internal/routemanager/systemops/systemops_js.go index 808507fc9..242571b3d 100644 --- a/client/internal/routemanager/systemops/systemops_js.go +++ b/client/internal/routemanager/systemops/systemops_js.go @@ -22,10 +22,6 @@ func GetRoutesFromTable() ([]netip.Prefix, error) { return []netip.Prefix{}, nil } -func hasSeparateRouting() ([]netip.Prefix, error) { - return []netip.Prefix{}, nil -} - // GetDetailedRoutesFromTable returns empty routes for WASM. func GetDetailedRoutesFromTable() ([]DetailedRoute, error) { return []DetailedRoute{}, nil diff --git a/client/internal/routemanager/systemops/systemops_linux.go b/client/internal/routemanager/systemops/systemops_linux.go index bd10f131f..8c6b7d9a9 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 { @@ -762,8 +764,13 @@ func flushRoutes(tableID, family int) error { } func EnableIPForwarding() error { - _, err := sysctl.Set(ipv4ForwardingPath, 1, false) - return err + if _, err := sysctl.Set(ipv4ForwardingPath, 1, false); err != nil { + return err + } + if _, err := sysctl.Set(ipv6ForwardingPath, 1, false); err != nil { + log.Warnf("failed to enable IPv6 forwarding: %v", err) + } + return nil } // entryExists checks if the specified ID or name already exists in the rt_tables file @@ -894,13 +901,6 @@ func getAddressFamily(prefix netip.Prefix) int { return netlink.FAMILY_V6 } -func hasSeparateRouting() ([]netip.Prefix, error) { - if !nbnet.AdvancedRouting() { - return GetRoutesFromTable() - } - return nil, ErrRoutingIsSeparate -} - func isOpErr(err error) bool { // EAFTNOSUPPORT when ipv6 is disabled via sysctl, EOPNOTSUPP when disabled in boot options or otherwise not supported if errors.Is(err, syscall.EAFNOSUPPORT) || errors.Is(err, syscall.EOPNOTSUPP) { 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 905a7bc12..016a62ebd 100644 --- a/client/internal/routemanager/systemops/systemops_nonlinux.go +++ b/client/internal/routemanager/systemops/systemops_nonlinux.go @@ -48,10 +48,6 @@ func EnableIPForwarding() error { return nil } -func hasSeparateRouting() ([]netip.Prefix, error) { - return GetRoutesFromTable() -} - // GetIPRules returns IP rules for debugging (not supported on non-Linux platforms) func GetIPRules() ([]IPRule, error) { log.Infof("IP rules collection is not supported on %s", runtime.GOOS) 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.go b/client/internal/routemanager/systemops/systemops_unix.go index 7089178fb..2d3f9b69a 100644 --- a/client/internal/routemanager/systemops/systemops_unix.go +++ b/client/internal/routemanager/systemops/systemops_unix.go @@ -25,6 +25,9 @@ import ( const ( envRouteProtoFlag = "NB_ROUTE_PROTO_FLAG" + + // routeBudget bounds retries for per-prefix exclusion route programming. + routeBudget = 1 * time.Second ) var routeProtoFlag int @@ -41,26 +44,42 @@ func init() { } func (r *SysOps) SetupRouting(initAddresses []net.IP, stateManager *statemanager.Manager, advancedRouting bool) error { + if advancedRouting { + return r.setupAdvancedRouting() + } + + log.Infof("Using legacy routing setup with ref counters") return r.setupRefCounter(initAddresses, stateManager) } func (r *SysOps) CleanupRouting(stateManager *statemanager.Manager, advancedRouting bool) error { + if advancedRouting { + return r.cleanupAdvancedRouting() + } + return r.cleanupRefCounter(stateManager) } // FlushMarkedRoutes removes single IP exclusion routes marked with the configured RTF_PROTO flag. +// On darwin it also flushes residual RTF_IFSCOPE scoped default routes so a +// crashed prior session can't leave crud in the table. func (r *SysOps) FlushMarkedRoutes() error { + var merr *multierror.Error + + if err := r.flushPlatformExtras(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("flush platform extras: %w", err)) + } + rib, err := retryFetchRIB() if err != nil { - return fmt.Errorf("fetch routing table: %w", err) + return nberrors.FormatErrorOrNil(multierror.Append(merr, fmt.Errorf("fetch routing table: %w", err))) } msgs, err := route.ParseRIB(route.RIBTypeRoute, rib) if err != nil { - return fmt.Errorf("parse routing table: %w", err) + return nberrors.FormatErrorOrNil(multierror.Append(merr, fmt.Errorf("parse routing table: %w", err))) } - var merr *multierror.Error flushedCount := 0 for _, msg := range msgs { @@ -117,12 +136,12 @@ func (r *SysOps) routeSocket(action int, prefix netip.Prefix, nexthop Nexthop) e return fmt.Errorf("invalid prefix: %s", prefix) } - expBackOff := backoff.NewExponentialBackOff() - expBackOff.InitialInterval = 50 * time.Millisecond - expBackOff.MaxInterval = 500 * time.Millisecond - expBackOff.MaxElapsedTime = 1 * time.Second + msg, err := r.buildRouteMessage(action, prefix, nexthop) + if err != nil { + return fmt.Errorf("build route message: %w", err) + } - if err := backoff.Retry(r.routeOp(action, prefix, nexthop), expBackOff); err != nil { + if err := r.writeRouteMessage(msg, routeBudget); err != nil { a := "add" if action == unix.RTM_DELETE { a = "remove" @@ -132,50 +151,91 @@ func (r *SysOps) routeSocket(action int, prefix netip.Prefix, nexthop Nexthop) e return nil } -func (r *SysOps) routeOp(action int, prefix netip.Prefix, nexthop Nexthop) func() error { - operation := func() error { - fd, err := unix.Socket(syscall.AF_ROUTE, syscall.SOCK_RAW, syscall.AF_UNSPEC) - if err != nil { - return fmt.Errorf("open routing socket: %w", err) +// writeRouteMessage sends a route message over AF_ROUTE and waits for the +// kernel's matching reply, retrying transient failures until budget elapses. +// Callers do not need to manage sockets or seq numbers themselves. +func (r *SysOps) writeRouteMessage(msg *route.RouteMessage, budget time.Duration) error { + expBackOff := backoff.NewExponentialBackOff() + expBackOff.InitialInterval = 50 * time.Millisecond + expBackOff.MaxInterval = 500 * time.Millisecond + expBackOff.MaxElapsedTime = budget + + return backoff.Retry(func() error { return routeMessageRoundtrip(msg) }, expBackOff) +} + +func routeMessageRoundtrip(msg *route.RouteMessage) error { + fd, err := unix.Socket(syscall.AF_ROUTE, syscall.SOCK_RAW, syscall.AF_UNSPEC) + if err != nil { + return fmt.Errorf("open routing socket: %w", err) + } + defer func() { + if err := unix.Close(fd); err != nil && !errors.Is(err, unix.EBADF) { + log.Warnf("close routing socket: %v", err) } - defer func() { - if err := unix.Close(fd); err != nil && !errors.Is(err, unix.EBADF) { - log.Warnf("failed to close routing socket: %v", err) + }() + + tv := unix.Timeval{Sec: 1} + if err := unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv); err != nil { + return backoff.Permanent(fmt.Errorf("set recv timeout: %w", err)) + } + + // AF_ROUTE is a broadcast channel: every route socket on the host sees + // every RTM_* event. With concurrent route programming the default + // per-socket queue overflows and our own reply gets dropped. + if err := unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_RCVBUF, 1<<20); err != nil { + log.Debugf("set SO_RCVBUF on route socket: %v", err) + } + + bytes, err := msg.Marshal() + if err != nil { + return backoff.Permanent(fmt.Errorf("marshal: %w", err)) + } + + if _, err = unix.Write(fd, bytes); err != nil { + if errors.Is(err, unix.ENOBUFS) || errors.Is(err, unix.EAGAIN) { + return fmt.Errorf("write: %w", err) + } + return backoff.Permanent(fmt.Errorf("write: %w", err)) + } + return readRouteResponse(fd, msg.Type, msg.Seq) +} + +// readRouteResponse reads from the AF_ROUTE socket until it sees a reply +// matching our write (same type, seq, and pid). AF_ROUTE SOCK_RAW is a +// broadcast channel: interface up/down, third-party route changes and neighbor +// discovery events can all land between our write and read, so we must filter. +func readRouteResponse(fd, wantType, wantSeq int) error { + pid := int32(os.Getpid()) + resp := make([]byte, 2048) + deadline := time.Now().Add(time.Second) + for { + if time.Now().After(deadline) { + // Transient: under concurrent pressure the kernel can drop our reply + // from the socket buffer. Let backoff.Retry re-send with a fresh seq. + return fmt.Errorf("read: timeout waiting for route reply type=%d seq=%d", wantType, wantSeq) + } + n, err := unix.Read(fd, resp) + if err != nil { + if errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EWOULDBLOCK) { + // SO_RCVTIMEO fired while waiting; loop to re-check the absolute deadline. + continue } - }() - - msg, err := r.buildRouteMessage(action, prefix, nexthop) - if err != nil { - return backoff.Permanent(fmt.Errorf("build route message: %w", err)) + return backoff.Permanent(fmt.Errorf("read: %w", err)) } - - msgBytes, err := msg.Marshal() - if err != nil { - return backoff.Permanent(fmt.Errorf("marshal route message: %w", err)) + if n < int(unsafe.Sizeof(unix.RtMsghdr{})) { + continue } - - if _, err = unix.Write(fd, msgBytes); err != nil { - if errors.Is(err, unix.ENOBUFS) || errors.Is(err, unix.EAGAIN) { - return fmt.Errorf("write: %w", err) - } - return backoff.Permanent(fmt.Errorf("write: %w", err)) + hdr := (*unix.RtMsghdr)(unsafe.Pointer(&resp[0])) + // Darwin reflects the sender's pid on replies; matching (Type, Seq, Pid) + // uniquely identifies our own reply among broadcast traffic. + if int(hdr.Type) != wantType || int(hdr.Seq) != wantSeq || hdr.Pid != pid { + continue } - - respBuf := make([]byte, 2048) - n, err := unix.Read(fd, respBuf) - if err != nil { - return backoff.Permanent(fmt.Errorf("read route response: %w", err)) + if hdr.Errno != 0 { + return backoff.Permanent(fmt.Errorf("kernel: %w", syscall.Errno(hdr.Errno))) } - - if n > 0 { - if err := r.parseRouteResponse(respBuf[:n]); err != nil { - return backoff.Permanent(err) - } - } - return nil } - return operation } func (r *SysOps) buildRouteMessage(action int, prefix netip.Prefix, nexthop Nexthop) (msg *route.RouteMessage, err error) { @@ -183,6 +243,7 @@ func (r *SysOps) buildRouteMessage(action int, prefix netip.Prefix, nexthop Next Type: action, Flags: unix.RTF_UP | routeProtoFlag, Version: unix.RTM_VERSION, + ID: uintptr(os.Getpid()), Seq: r.getSeq(), } @@ -221,19 +282,6 @@ func (r *SysOps) buildRouteMessage(action int, prefix netip.Prefix, nexthop Next return msg, nil } -func (r *SysOps) parseRouteResponse(buf []byte) error { - if len(buf) < int(unsafe.Sizeof(unix.RtMsghdr{})) { - return nil - } - - rtMsg := (*unix.RtMsghdr)(unsafe.Pointer(&buf[0])) - if rtMsg.Errno != 0 { - return fmt.Errorf("parse: %d", rtMsg.Errno) - } - - return nil -} - // addrToRouteAddr converts a netip.Addr to the appropriate route.Addr (*route.Inet4Addr or *route.Inet6Addr). func addrToRouteAddr(addr netip.Addr) (route.Addr, error) { if addr.Is4() { 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_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/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..449d4cbd2 --- /dev/null +++ b/client/internal/routemanager/systemops/v6route_linux_test.go @@ -0,0 +1,41 @@ +//go:build linux && !android && privileged + +package systemops + +import ( + "errors" + "net" + "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) { + 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) + } + }) +} 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..1254b384d 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{} @@ -44,8 +39,8 @@ 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{}{} } @@ -78,8 +73,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 +111,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 +159,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 +200,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 +228,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 +243,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 +296,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..2b1ba3fb9 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,31 @@ 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") +} diff --git a/client/internal/sleep/detector_darwin.go b/client/internal/sleep/detector_darwin.go new file mode 100644 index 000000000..fc4713b21 --- /dev/null +++ b/client/internal/sleep/detector_darwin.go @@ -0,0 +1,361 @@ +//go:build darwin && !ios + +package sleep + +import ( + "fmt" + "runtime" + "sync" + "time" + "unsafe" + + "github.com/ebitengine/purego" + log "github.com/sirupsen/logrus" +) + +// IOKit message types from IOKit/IOMessage.h. +const ( + kIOMessageCanSystemSleep uintptr = 0xe0000270 + kIOMessageSystemWillSleep uintptr = 0xe0000280 + kIOMessageSystemHasPoweredOn uintptr = 0xe0000300 +) + +var ( + ioKit iokitFuncs + cf cfFuncs + cfCommonModes uintptr + + libInitOnce sync.Once + libInitErr error + + // 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) +} + +// 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) +} + +// 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 +} + +// 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) + 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 { + 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() + + if !needSetup { + return nil + } + + 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 leaves, IOKit +// notifications are torn down and the runloop is stopped. +func (d *Detector) Deregister() error { + lifecycleMu.Lock() + defer lifecycleMu.Unlock() + + serviceRegistryMu.Lock() + if _, exists := serviceRegistry[d]; !exists { + serviceRegistryMu.Unlock() + return nil + } + close(d.done) + delete(serviceRegistry, d) + + if len(serviceRegistry) > 0 { + serviceRegistryMu.Unlock() + return nil + } + sess := session + serviceRegistryMu.Unlock() + + log.Info("sleep detection service stopping (deregister)") + + 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, cb func(event EventType), done <-chan struct{}) { + if cb == nil || done == nil { + return + } + + 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() + + 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") + cb(event) + }() + + select { + case <-doneChan: + case <-done: + case <-timeout.C: + 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/detector_notsupported.go b/client/internal/sleep/detector_notsupported.go new file mode 100644 index 000000000..6323bf5d1 --- /dev/null +++ b/client/internal/sleep/detector_notsupported.go @@ -0,0 +1,9 @@ +//go:build !darwin || ios + +package sleep + +import "fmt" + +func NewDetector() (detector, error) { + return nil, fmt.Errorf("sleep not supported on this platform") +} diff --git a/client/internal/sleep/handler/handler.go b/client/internal/sleep/handler/handler.go new file mode 100644 index 000000000..9c2c5d4d5 --- /dev/null +++ b/client/internal/sleep/handler/handler.go @@ -0,0 +1,80 @@ +package handler + +import ( + "context" + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal" +) + +type Agent interface { + Up(ctx context.Context) error + Down(ctx context.Context) error + Status() (internal.StatusType, error) +} + +type SleepHandler struct { + agent Agent + + mu sync.Mutex + // sleepTriggeredDown indicates whether the sleep handler triggered the last client down, to avoid unnecessary up on wake + sleepTriggeredDown bool +} + +func New(agent Agent) *SleepHandler { + return &SleepHandler{ + agent: agent, + } +} + +func (s *SleepHandler) HandleWakeUp(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.sleepTriggeredDown { + log.Info("skipping up because wasn't sleep down") + return nil + } + + // avoid other wakeup runs if sleep didn't make the computer sleep + s.sleepTriggeredDown = false + + log.Info("running up after wake up") + err := s.agent.Up(ctx) + if err != nil { + log.Errorf("running up failed: %v", err) + return err + } + + log.Info("running up command executed successfully") + return nil +} + +func (s *SleepHandler) HandleSleep(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + + status, err := s.agent.Status() + if err != nil { + return err + } + + if status != internal.StatusConnecting && status != internal.StatusConnected { + log.Infof("skipping setting the agent down because status is %s", status) + return nil + } + + log.Info("running down after system started sleeping") + + if err = s.agent.Down(ctx); err != nil { + log.Errorf("running down failed: %v", err) + return err + } + + s.sleepTriggeredDown = true + + log.Info("running down executed successfully") + return nil +} diff --git a/client/internal/sleep/handler/handler_test.go b/client/internal/sleep/handler/handler_test.go new file mode 100644 index 000000000..9f79428fb --- /dev/null +++ b/client/internal/sleep/handler/handler_test.go @@ -0,0 +1,153 @@ +package handler + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal" +) + +type mockAgent struct { + upErr error + downErr error + statusErr error + status internal.StatusType + upCalls int +} + +func (m *mockAgent) Up(_ context.Context) error { + m.upCalls++ + return m.upErr +} + +func (m *mockAgent) Down(_ context.Context) error { + return m.downErr +} + +func (m *mockAgent) Status() (internal.StatusType, error) { + return m.status, m.statusErr +} + +func newHandler(status internal.StatusType) (*SleepHandler, *mockAgent) { + agent := &mockAgent{status: status} + return New(agent), agent +} + +func TestHandleWakeUp_SkipsWhenFlagFalse(t *testing.T) { + h, agent := newHandler(internal.StatusIdle) + + err := h.HandleWakeUp(context.Background()) + + require.NoError(t, err) + assert.Equal(t, 0, agent.upCalls, "Up should not be called when flag is false") +} + +func TestHandleWakeUp_ResetsFlagBeforeUp(t *testing.T) { + h, _ := newHandler(internal.StatusIdle) + h.sleepTriggeredDown = true + + // Even if Up fails, flag should be reset + _ = h.HandleWakeUp(context.Background()) + + assert.False(t, h.sleepTriggeredDown, "flag must be reset before calling Up") +} + +func TestHandleWakeUp_CallsUpWhenFlagSet(t *testing.T) { + h, agent := newHandler(internal.StatusIdle) + h.sleepTriggeredDown = true + + err := h.HandleWakeUp(context.Background()) + + require.NoError(t, err) + assert.Equal(t, 1, agent.upCalls) + assert.False(t, h.sleepTriggeredDown) +} + +func TestHandleWakeUp_ReturnsErrorFromUp(t *testing.T) { + h, agent := newHandler(internal.StatusIdle) + h.sleepTriggeredDown = true + agent.upErr = errors.New("up failed") + + err := h.HandleWakeUp(context.Background()) + + assert.ErrorIs(t, err, agent.upErr) + assert.False(t, h.sleepTriggeredDown, "flag should still be reset even when Up fails") +} + +func TestHandleWakeUp_SecondCallIsNoOp(t *testing.T) { + h, agent := newHandler(internal.StatusIdle) + h.sleepTriggeredDown = true + + _ = h.HandleWakeUp(context.Background()) + err := h.HandleWakeUp(context.Background()) + + require.NoError(t, err) + assert.Equal(t, 1, agent.upCalls, "second wakeup should be no-op") +} + +func TestHandleSleep_SkipsForNonActiveStates(t *testing.T) { + tests := []struct { + name string + status internal.StatusType + }{ + {"Idle", internal.StatusIdle}, + {"NeedsLogin", internal.StatusNeedsLogin}, + {"LoginFailed", internal.StatusLoginFailed}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h, _ := newHandler(tt.status) + + err := h.HandleSleep(context.Background()) + + require.NoError(t, err) + assert.False(t, h.sleepTriggeredDown) + }) + } +} + +func TestHandleSleep_ProceedsForActiveStates(t *testing.T) { + tests := []struct { + name string + status internal.StatusType + }{ + {"Connecting", internal.StatusConnecting}, + {"Connected", internal.StatusConnected}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h, _ := newHandler(tt.status) + + err := h.HandleSleep(context.Background()) + + require.NoError(t, err) + assert.True(t, h.sleepTriggeredDown) + }) + } +} + +func TestHandleSleep_ReturnsErrorFromStatus(t *testing.T) { + agent := &mockAgent{statusErr: errors.New("status error")} + h := New(agent) + + err := h.HandleSleep(context.Background()) + + assert.ErrorIs(t, err, agent.statusErr) + assert.False(t, h.sleepTriggeredDown) +} + +func TestHandleSleep_ReturnsErrorFromDown(t *testing.T) { + agent := &mockAgent{status: internal.StatusConnected, downErr: errors.New("down failed")} + h := New(agent) + + err := h.HandleSleep(context.Background()) + + assert.ErrorIs(t, err, agent.downErr) + assert.False(t, h.sleepTriggeredDown, "flag should not be set when Down fails") +} diff --git a/client/internal/sleep/service.go b/client/internal/sleep/service.go new file mode 100644 index 000000000..196a33f52 --- /dev/null +++ b/client/internal/sleep/service.go @@ -0,0 +1,37 @@ +package sleep + +var ( + EventTypeUnknown EventType = 0 + EventTypeSleep EventType = 1 + EventTypeWakeUp EventType = 2 +) + +type EventType int + +type detector interface { + Register(callback func(eventType EventType)) error + Deregister() error +} + +type Service struct { + detector detector +} + +func New() (*Service, error) { + d, err := NewDetector() + if err != nil { + return nil, err + } + + return &Service{ + detector: d, + }, nil +} + +func (s *Service) Register(callback func(eventType EventType)) error { + return s.detector.Register(callback) +} + +func (s *Service) Deregister() error { + return s.detector.Deregister() +} 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/stdnet/stdnet.go b/client/internal/stdnet/stdnet.go index 4b031c05c..381886ac6 100644 --- a/client/internal/stdnet/stdnet.go +++ b/client/internal/stdnet/stdnet.go @@ -4,17 +4,28 @@ package stdnet import ( + "context" + "errors" "fmt" + "net" + "net/netip" "slices" + "strconv" "sync" "time" - "github.com/netbirdio/netbird/client/iface/netstack" "github.com/pion/transport/v3" "github.com/pion/transport/v3/stdnet" + + "github.com/netbirdio/netbird/client/iface/netstack" ) -const updateInterval = 30 * time.Second +const ( + updateInterval = 30 * time.Second + dnsResolveTimeout = 30 * time.Second +) + +var errNoSuitableAddress = errors.New("no suitable address found") // Net is an implementation of the net.Net interface // based on functions of the standard net package. @@ -28,12 +39,19 @@ type Net struct { // mu is shared between interfaces and lastUpdate mu sync.Mutex + + // ctx is the context for network operations that supports cancellation + ctx context.Context } // NewNetWithDiscover creates a new StdNet instance. -func NewNetWithDiscover(iFaceDiscover ExternalIFaceDiscover, disallowList []string) (*Net, error) { +func NewNetWithDiscover(ctx context.Context, iFaceDiscover ExternalIFaceDiscover, disallowList []string) (*Net, error) { + if ctx == nil { + ctx = context.Background() + } n := &Net{ interfaceFilter: InterfaceFilter(disallowList), + ctx: ctx, } // current ExternalIFaceDiscover implement in android-client https://github.dev/netbirdio/android-client // so in android cli use pionDiscover @@ -46,14 +64,64 @@ func NewNetWithDiscover(iFaceDiscover ExternalIFaceDiscover, disallowList []stri } // NewNet creates a new StdNet instance. -func NewNet(disallowList []string) (*Net, error) { +func NewNet(ctx context.Context, disallowList []string) (*Net, error) { + if ctx == nil { + ctx = context.Background() + } n := &Net{ iFaceDiscover: pionDiscover{}, interfaceFilter: InterfaceFilter(disallowList), + ctx: ctx, } return n, n.UpdateInterfaces() } +// resolveAddr performs DNS resolution with context support and timeout. +func (n *Net) resolveAddr(network, address string) (netip.AddrPort, error) { + host, portStr, err := net.SplitHostPort(address) + if err != nil { + return netip.AddrPort{}, err + } + + port, err := strconv.Atoi(portStr) + if err != nil { + return netip.AddrPort{}, fmt.Errorf("invalid port: %w", err) + } + if port < 0 || port > 65535 { + return netip.AddrPort{}, fmt.Errorf("invalid port: %d", port) + } + + ipNet := "ip" + switch network { + case "tcp4", "udp4": + ipNet = "ip4" + case "tcp6", "udp6": + ipNet = "ip6" + } + + if host == "" { + addr := netip.IPv4Unspecified() + if ipNet == "ip6" { + addr = netip.IPv6Unspecified() + } + return netip.AddrPortFrom(addr, uint16(port)), nil + } + + ctx, cancel := context.WithTimeout(n.ctx, dnsResolveTimeout) + defer cancel() + + addrs, err := net.DefaultResolver.LookupNetIP(ctx, ipNet, host) + if err != nil { + return netip.AddrPort{}, err + } + + if len(addrs) == 0 { + return netip.AddrPort{}, errNoSuitableAddress + } + + return netip.AddrPortFrom(addrs[0], uint16(port)), nil +} + // UpdateInterfaces updates the internal list of network interfaces // and associated addresses filtering them by name. // The interfaces are discovered by an external iFaceDiscover function or by a default discoverer if the external one @@ -137,3 +205,39 @@ func (n *Net) filterInterfaces(interfaces []*transport.Interface) []*transport.I } return result } + +// ResolveUDPAddr resolves UDP addresses with context support and timeout. +func (n *Net) ResolveUDPAddr(network, address string) (*net.UDPAddr, error) { + switch network { + case "udp", "udp4", "udp6": + case "": + network = "udp" + default: + return nil, &net.OpError{Op: "resolve", Net: network, Err: net.UnknownNetworkError(network)} + } + + addrPort, err := n.resolveAddr(network, address) + if err != nil { + return nil, &net.OpError{Op: "resolve", Net: network, Addr: &net.UDPAddr{IP: nil}, Err: err} + } + + return net.UDPAddrFromAddrPort(addrPort), nil +} + +// ResolveTCPAddr resolves TCP addresses with context support and timeout. +func (n *Net) ResolveTCPAddr(network, address string) (*net.TCPAddr, error) { + switch network { + case "tcp", "tcp4", "tcp6": + case "": + network = "tcp" + default: + return nil, &net.OpError{Op: "resolve", Net: network, Err: net.UnknownNetworkError(network)} + } + + addrPort, err := n.resolveAddr(network, address) + if err != nil { + return nil, &net.OpError{Op: "resolve", Net: network, Addr: &net.TCPAddr{IP: nil}, Err: err} + } + + return net.TCPAddrFromAddrPort(addrPort), nil +} 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/templates/pkce-auth-msg.html b/client/internal/templates/pkce-auth-msg.html index 4825c48e7..175a6f05c 100644 --- a/client/internal/templates/pkce-auth-msg.html +++ b/client/internal/templates/pkce-auth-msg.html @@ -1,88 +1,93 @@ + - + + + + NetBird Login + + + - NetBird Login Successful + -
                                                - -
                                                - {{ if .Error }} - - - - -
                                                -
                                                - Login failed +
                                                +
                                                + + +
                                                + + + + + + + + + + + + + + + + + + +
                                                - {{ .Error }}. -
                                                - {{ else }} - - - - -
                                                -
                                                - Login successful + +
                                                + +
                                                + + {{ if .Error }} + +
                                                + + + + +
                                                + {{ else }} + +
                                                + + + + +
                                                + {{ end }} + + +
                                                + {{ if .Error }} +

                                                Login Failed

                                                + {{ else }} +

                                                Login Successful

                                                + {{ end }} +
                                                + + + {{ if .Error }} +
                                                + {{ .Error }} +
                                                + {{ else }} + +
                                                + Your device is now registered and logged in to NetBird. You can now close this window. +
                                                + {{ end }} + +
                                                - Your device is now registered and logged in to NetBird. -
                                                - You can now close this window.
                                                - {{ end }}
                                                + diff --git a/client/internal/templates/pkce_auth_msg_test.go b/client/internal/templates/pkce_auth_msg_test.go new file mode 100644 index 000000000..75b1c9e76 --- /dev/null +++ b/client/internal/templates/pkce_auth_msg_test.go @@ -0,0 +1,299 @@ +package templates + +import ( + "html/template" + "os" + "path/filepath" + "testing" +) + +func TestPKCEAuthMsgTemplate(t *testing.T) { + tests := []struct { + name string + data map[string]string + outputFile string + expectedTitle string + expectedInContent []string + notExpectedInContent []string + }{ + { + name: "error_state", + data: map[string]string{ + "Error": "authentication failed: invalid state", + }, + outputFile: "pkce-auth-error.html", + expectedTitle: "Login Failed", + expectedInContent: []string{ + "authentication failed: invalid state", + "Login Failed", + }, + notExpectedInContent: []string{ + "Login Successful", + "Your device is now registered and logged in to NetBird", + }, + }, + { + name: "success_state", + data: map[string]string{ + // No error field means success + }, + outputFile: "pkce-auth-success.html", + expectedTitle: "Login Successful", + expectedInContent: []string{ + "Login Successful", + "Your device is now registered and logged in to NetBird. You can now close this window.", + }, + notExpectedInContent: []string{ + "Login Failed", + }, + }, + { + name: "error_state_timeout", + data: map[string]string{ + "Error": "authentication timeout: request expired after 5 minutes", + }, + outputFile: "pkce-auth-timeout.html", + expectedTitle: "Login Failed", + expectedInContent: []string{ + "authentication timeout: request expired after 5 minutes", + "Login Failed", + }, + notExpectedInContent: []string{ + "Login Successful", + "Your device is now registered and logged in to NetBird", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Parse the template + tmpl, err := template.New("pkce-auth-msg").Parse(PKCEAuthMsgTmpl) + if err != nil { + t.Fatalf("Failed to parse template: %v", err) + } + + // Create temp directory for this test + tempDir := t.TempDir() + outputPath := filepath.Join(tempDir, tt.outputFile) + + // Create output file + file, err := os.Create(outputPath) + if err != nil { + t.Fatalf("Failed to create output file: %v", err) + } + + // Execute the template + if err := tmpl.Execute(file, tt.data); err != nil { + file.Close() + t.Fatalf("Failed to execute template: %v", err) + } + file.Close() + + t.Logf("Generated test output: %s", outputPath) + + // Read the generated file + content, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + + contentStr := string(content) + + // Verify file has content + if len(contentStr) == 0 { + t.Error("Output file is empty") + } + + // Verify basic HTML structure + basicElements := []string{ + "", + "", + "", + "NetBird", + } + + for _, elem := range basicElements { + if !contains(contentStr, elem) { + t.Errorf("Expected HTML to contain '%s', but it was not found", elem) + } + } + + // Verify expected title + if !contains(contentStr, tt.expectedTitle) { + t.Errorf("Expected HTML to contain title '%s', but it was not found", tt.expectedTitle) + } + + // Verify expected content is present + for _, expected := range tt.expectedInContent { + if !contains(contentStr, expected) { + t.Errorf("Expected HTML to contain '%s', but it was not found", expected) + } + } + + // Verify unexpected content is not present + for _, notExpected := range tt.notExpectedInContent { + if contains(contentStr, notExpected) { + t.Errorf("Expected HTML to NOT contain '%s', but it was found", notExpected) + } + } + }) + } +} + +func TestPKCEAuthMsgTemplateValidation(t *testing.T) { + // Test that the template can be parsed without errors + tmpl, err := template.New("pkce-auth-msg").Parse(PKCEAuthMsgTmpl) + if err != nil { + t.Fatalf("Template parsing failed: %v", err) + } + + // Test with empty data + t.Run("empty_data", func(t *testing.T) { + tempDir := t.TempDir() + outputPath := filepath.Join(tempDir, "empty-data.html") + + file, err := os.Create(outputPath) + if err != nil { + t.Fatalf("Failed to create output file: %v", err) + } + defer file.Close() + + if err := tmpl.Execute(file, nil); err != nil { + t.Errorf("Template execution with nil data failed: %v", err) + } + }) + + // Test with error data + t.Run("with_error", func(t *testing.T) { + tempDir := t.TempDir() + outputPath := filepath.Join(tempDir, "with-error.html") + + file, err := os.Create(outputPath) + if err != nil { + t.Fatalf("Failed to create output file: %v", err) + } + defer file.Close() + + data := map[string]string{ + "Error": "test error message", + } + if err := tmpl.Execute(file, data); err != nil { + t.Errorf("Template execution with error data failed: %v", err) + } + }) +} + +func TestPKCEAuthMsgTemplateContent(t *testing.T) { + // Test that the template contains expected elements + tmpl, err := template.New("pkce-auth-msg").Parse(PKCEAuthMsgTmpl) + if err != nil { + t.Fatalf("Template parsing failed: %v", err) + } + + t.Run("success_content", func(t *testing.T) { + tempDir := t.TempDir() + outputPath := filepath.Join(tempDir, "success.html") + + file, err := os.Create(outputPath) + if err != nil { + t.Fatalf("Failed to create output file: %v", err) + } + defer file.Close() + + data := map[string]string{} + if err := tmpl.Execute(file, data); err != nil { + t.Fatalf("Template execution failed: %v", err) + } + + // Read the file and verify it contains expected content + content, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + + // Check for success indicators + contentStr := string(content) + if len(contentStr) == 0 { + t.Error("Generated HTML is empty") + } + + // Basic HTML structure checks + requiredElements := []string{ + "", + "", + "", + "Login Successful", + "NetBird", + } + + for _, elem := range requiredElements { + if !contains(contentStr, elem) { + t.Errorf("Expected HTML to contain '%s', but it was not found", elem) + } + } + }) + + t.Run("error_content", func(t *testing.T) { + tempDir := t.TempDir() + outputPath := filepath.Join(tempDir, "error.html") + + file, err := os.Create(outputPath) + if err != nil { + t.Fatalf("Failed to create output file: %v", err) + } + defer file.Close() + + errorMsg := "test error message" + data := map[string]string{ + "Error": errorMsg, + } + if err := tmpl.Execute(file, data); err != nil { + t.Fatalf("Template execution failed: %v", err) + } + + // Read the file and verify it contains expected content + content, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + + // Check for error indicators + contentStr := string(content) + if len(contentStr) == 0 { + t.Error("Generated HTML is empty") + } + + // Basic HTML structure checks + requiredElements := []string{ + "", + "", + "", + "Login Failed", + errorMsg, + } + + for _, elem := range requiredElements { + if !contains(contentStr, elem) { + t.Errorf("Expected HTML to contain '%s', but it was not found", elem) + } + } + }) +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || + (len(s) > 0 && len(substr) > 0 && containsHelper(s, substr))) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/client/internal/updater/doc.go b/client/internal/updater/doc.go new file mode 100644 index 000000000..e1924aa43 --- /dev/null +++ b/client/internal/updater/doc.go @@ -0,0 +1,35 @@ +// Package updater provides automatic update management for the NetBird client. +// It monitors for new versions, handles update triggers from management server directives, +// and orchestrates the download and installation of client updates. +// +// # Overview +// +// The update manager operates as a background service that continuously monitors for +// available updates and automatically initiates the update process when conditions are met. +// It integrates with the installer package to perform the actual installation. +// +// # Update Flow +// +// The complete update process follows these steps: +// +// 1. Manager receives update directive via SetVersion() or detects new version +// 2. Manager validates update should proceed (version comparison, rate limiting) +// 3. Manager publishes "updating" event to status recorder +// 4. Manager persists UpdateState to track update attempt +// 5. Manager downloads installer file (.msi or .exe) to temporary directory +// 6. Manager triggers installation via installer.RunInstallation() +// 7. Installer package handles the actual installation process +// 8. On next startup, CheckUpdateSuccess() verifies update completion +// 9. Manager publishes success/failure event to status recorder +// 10. Manager cleans up UpdateState +// +// # State Management +// +// Update state is persisted across restarts to track update attempts: +// +// - PreUpdateVersion: Version before update attempt +// - TargetVersion: Version attempting to update to +// +// This enables verification of successful updates and appropriate user notification +// after the client restarts with the new version. +package updater diff --git a/client/internal/updater/downloader/downloader.go b/client/internal/updater/downloader/downloader.go new file mode 100644 index 000000000..2ac36efed --- /dev/null +++ b/client/internal/updater/downloader/downloader.go @@ -0,0 +1,138 @@ +package downloader + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/version" +) + +const ( + userAgent = "NetBird agent installer/%s" + DefaultRetryDelay = 3 * time.Second +) + +func DownloadToFile(ctx context.Context, retryDelay time.Duration, url, dstFile string) error { + log.Debugf("starting download from %s", url) + + out, err := os.Create(dstFile) + if err != nil { + return fmt.Errorf("failed to create destination file %q: %w", dstFile, err) + } + defer func() { + if cerr := out.Close(); cerr != nil { + log.Warnf("error closing file %q: %v", dstFile, cerr) + } + }() + + // First attempt + err = downloadToFileOnce(ctx, url, out) + if err == nil { + log.Infof("successfully downloaded file to %s", dstFile) + return nil + } + + // If retryDelay is 0, don't retry + if retryDelay == 0 { + return err + } + + log.Warnf("download failed, retrying after %v: %v", retryDelay, err) + + // Sleep before retry + if sleepErr := sleepWithContext(ctx, retryDelay); sleepErr != nil { + return fmt.Errorf("download cancelled during retry delay: %w", sleepErr) + } + + // Truncate file before retry + if err := out.Truncate(0); err != nil { + return fmt.Errorf("failed to truncate file on retry: %w", err) + } + if _, err := out.Seek(0, 0); err != nil { + return fmt.Errorf("failed to seek to beginning of file: %w", err) + } + + // Second attempt + if err := downloadToFileOnce(ctx, url, out); err != nil { + return fmt.Errorf("download failed after retry: %w", err) + } + + log.Infof("successfully downloaded file to %s", dstFile) + return nil +} + +func DownloadToMemory(ctx context.Context, url string, limit int64) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request: %w", err) + } + + // Add User-Agent header + req.Header.Set("User-Agent", fmt.Sprintf(userAgent, version.NetbirdVersion())) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to perform HTTP request: %w", err) + } + defer func() { + if cerr := resp.Body.Close(); cerr != nil { + log.Warnf("error closing response body: %v", cerr) + } + }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected HTTP status: %d", resp.StatusCode) + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, limit)) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + return data, nil +} + +func downloadToFileOnce(ctx context.Context, url string, out *os.File) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("failed to create HTTP request: %w", err) + } + + // Add User-Agent header + req.Header.Set("User-Agent", fmt.Sprintf(userAgent, version.NetbirdVersion())) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to perform HTTP request: %w", err) + } + defer func() { + if cerr := resp.Body.Close(); cerr != nil { + log.Warnf("error closing response body: %v", cerr) + } + }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected HTTP status: %d", resp.StatusCode) + } + + if _, err := io.Copy(out, resp.Body); err != nil { + return fmt.Errorf("failed to write response body to file: %w", err) + } + + return nil +} + +func sleepWithContext(ctx context.Context, duration time.Duration) error { + select { + case <-time.After(duration): + return nil + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/client/internal/updater/downloader/downloader_test.go b/client/internal/updater/downloader/downloader_test.go new file mode 100644 index 000000000..045db3a2d --- /dev/null +++ b/client/internal/updater/downloader/downloader_test.go @@ -0,0 +1,199 @@ +package downloader + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +const ( + retryDelay = 100 * time.Millisecond +) + +func TestDownloadToFile_Success(t *testing.T) { + // Create a test server that responds successfully + content := "test file content" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(content)) + })) + defer server.Close() + + // Create a temporary file for download + tempDir := t.TempDir() + dstFile := filepath.Join(tempDir, "downloaded.txt") + + // Download the file + err := DownloadToFile(context.Background(), retryDelay, server.URL, dstFile) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + // Verify the file content + data, err := os.ReadFile(dstFile) + if err != nil { + t.Fatalf("failed to read downloaded file: %v", err) + } + + if string(data) != content { + t.Errorf("expected content %q, got %q", content, string(data)) + } +} + +func TestDownloadToFile_SuccessAfterRetry(t *testing.T) { + content := "test file content after retry" + var attemptCount atomic.Int32 + + // Create a test server that fails on first attempt, succeeds on second + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempt := attemptCount.Add(1) + if attempt == 1 { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("error")) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(content)) + })) + defer server.Close() + + // Create a temporary file for download + tempDir := t.TempDir() + dstFile := filepath.Join(tempDir, "downloaded.txt") + + // Download the file (should succeed after retry) + if err := DownloadToFile(context.Background(), 10*time.Millisecond, server.URL, dstFile); err != nil { + t.Fatalf("expected no error after retry, got: %v", err) + } + + // Verify the file content + data, err := os.ReadFile(dstFile) + if err != nil { + t.Fatalf("failed to read downloaded file: %v", err) + } + + if string(data) != content { + t.Errorf("expected content %q, got %q", content, string(data)) + } + + // Verify it took 2 attempts + if attemptCount.Load() != 2 { + t.Errorf("expected 2 attempts, got %d", attemptCount.Load()) + } +} + +func TestDownloadToFile_FailsAfterRetry(t *testing.T) { + var attemptCount atomic.Int32 + + // Create a test server that always fails + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attemptCount.Add(1) + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("error")) + })) + defer server.Close() + + // Create a temporary file for download + tempDir := t.TempDir() + dstFile := filepath.Join(tempDir, "downloaded.txt") + + // Download the file (should fail after retry) + if err := DownloadToFile(context.Background(), 10*time.Millisecond, server.URL, dstFile); err == nil { + t.Fatal("expected error after retry, got nil") + } + + // Verify it tried 2 times + if attemptCount.Load() != 2 { + t.Errorf("expected 2 attempts, got %d", attemptCount.Load()) + } +} + +func TestDownloadToFile_ContextCancellationDuringRetry(t *testing.T) { + var attemptCount atomic.Int32 + + // Create a test server that always fails + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attemptCount.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + // Create a temporary file for download + tempDir := t.TempDir() + dstFile := filepath.Join(tempDir, "downloaded.txt") + + // Create a context that will be cancelled during retry delay + ctx, cancel := context.WithCancel(context.Background()) + + // Cancel after a short delay (during the retry sleep) + go func() { + time.Sleep(100 * time.Millisecond) + cancel() + }() + + // Download the file (should fail due to context cancellation during retry) + err := DownloadToFile(ctx, 1*time.Second, server.URL, dstFile) + if err == nil { + t.Fatal("expected error due to context cancellation, got nil") + } + + // Should have only made 1 attempt (cancelled during retry delay) + if attemptCount.Load() != 1 { + t.Errorf("expected 1 attempt, got %d", attemptCount.Load()) + } +} + +func TestDownloadToFile_InvalidURL(t *testing.T) { + tempDir := t.TempDir() + dstFile := filepath.Join(tempDir, "downloaded.txt") + + err := DownloadToFile(context.Background(), retryDelay, "://invalid-url", dstFile) + if err == nil { + t.Fatal("expected error for invalid URL, got nil") + } +} + +func TestDownloadToFile_InvalidDestination(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("test")) + })) + defer server.Close() + + // Use an invalid destination path + err := DownloadToFile(context.Background(), retryDelay, server.URL, "/invalid/path/that/does/not/exist/file.txt") + if err == nil { + t.Fatal("expected error for invalid destination, got nil") + } +} + +func TestDownloadToFile_NoRetry(t *testing.T) { + var attemptCount atomic.Int32 + + // Create a test server that always fails + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attemptCount.Add(1) + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("error")) + })) + defer server.Close() + + // Create a temporary file for download + tempDir := t.TempDir() + dstFile := filepath.Join(tempDir, "downloaded.txt") + + // Download the file with retryDelay = 0 (should not retry) + if err := DownloadToFile(context.Background(), 0, server.URL, dstFile); err == nil { + t.Fatal("expected error, got nil") + } + + // Verify it only made 1 attempt (no retry) + if attemptCount.Load() != 1 { + t.Errorf("expected 1 attempt, got %d", attemptCount.Load()) + } +} diff --git a/client/internal/updater/installer/binary_nowindows.go b/client/internal/updater/installer/binary_nowindows.go new file mode 100644 index 000000000..19f3bef83 --- /dev/null +++ b/client/internal/updater/installer/binary_nowindows.go @@ -0,0 +1,7 @@ +//go:build !windows + +package installer + +func UpdaterBinaryNameWithoutExtension() string { + return updaterBinary +} diff --git a/client/internal/updater/installer/binary_windows.go b/client/internal/updater/installer/binary_windows.go new file mode 100644 index 000000000..4c66391c2 --- /dev/null +++ b/client/internal/updater/installer/binary_windows.go @@ -0,0 +1,11 @@ +package installer + +import ( + "path/filepath" + "strings" +) + +func UpdaterBinaryNameWithoutExtension() string { + ext := filepath.Ext(updaterBinary) + return strings.TrimSuffix(updaterBinary, ext) +} diff --git a/client/internal/updater/installer/doc.go b/client/internal/updater/installer/doc.go new file mode 100644 index 000000000..0a60454bb --- /dev/null +++ b/client/internal/updater/installer/doc.go @@ -0,0 +1,111 @@ +// Package installer provides functionality for managing NetBird application +// updates and installations across Windows, macOS. It handles +// the complete update lifecycle including artifact download, cryptographic verification, +// installation execution, process management, and result reporting. +// +// # Architecture +// +// The installer package uses a two-process architecture to enable self-updates: +// +// 1. Service Process: The main NetBird daemon process that initiates updates +// 2. Updater Process: A detached child process that performs the actual installation +// +// This separation is critical because: +// - The service binary cannot update itself while running +// - The installer (EXE/MSI/PKG) will terminate the service during installation +// - The updater process survives service termination and restarts it after installation +// - Results can be communicated back to the service after it restarts +// +// # Update Flow +// +// Service Process (RunInstallation): +// +// 1. Validates target version format (semver) +// 2. Determines installer type (EXE, MSI, PKG, or Homebrew) +// 3. Downloads installer file from GitHub releases (if applicable) +// 4. Verifies installer signature using reposign package (cryptographic verification in service process before +// launching updater) +// 5. Copies service binary to tempDir as "updater" (or "updater.exe" on Windows) +// 6. Launches updater process with detached mode: +// - --temp-dir: Temporary directory path +// - --service-dir: Service installation directory +// - --installer-file: Path to downloaded installer (if applicable) +// - --dry-run: Optional flag to test without actually installing +// 7. Service process continues running (will be terminated by installer later) +// 8. Service can watch for result.json using ResultHandler.Watch() to detect completion +// +// Updater Process (Setup): +// +// 1. Receives parameters from service via command-line arguments +// 2. 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 +// - 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: +// - 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 +// - 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 +// +// # Result Communication +// +// The ResultHandler (result.go) manages communication between updater and service: +// +// Result Structure: +// +// type Result struct { +// Success bool // true if installation succeeded +// Error string // error message if Success is false +// ExecutedAt time.Time // when installation completed +// } +// +// Result files are automatically cleaned up after being read. +// +// # File Locations +// +// Temporary Directory (platform-specific): +// +// Windows: +// - Path: %ProgramData%\Netbird\tmp-install +// - Example: C:\ProgramData\Netbird\tmp-install +// +// macOS: +// - Path: /var/lib/netbird/tmp-install +// - Requires root permissions +// +// Files created during installation: +// +// tmp-install/ +// installer.log +// updater[.exe] # Copy of service binary +// netbird_installer_*.[exe|msi|pkg] # Downloaded installer +// result.json # Installation result +// msi.log # MSI verbose log (Windows MSI only) +// +// # API Reference +// +// # Cleanup +// +// CleanUpInstallerFiles() removes temporary files after successful installation: +// - Downloaded installer files (*.exe, *.msi, *.pkg) +// - Updater binary copy +// - Does NOT remove result.json (cleaned by ResultHandler after read) +// - Does NOT remove msi.log (kept for debugging) +// +// # Dry-Run Mode +// +// Dry-run mode allows testing the update process without actually installing: +// +// Enable via environment variable: +// +// export NB_AUTO_UPDATE_DRY_RUN=true +// netbird service install-update 0.29.0 +package installer diff --git a/client/internal/updater/installer/installer.go b/client/internal/updater/installer/installer.go new file mode 100644 index 000000000..caf5873f8 --- /dev/null +++ b/client/internal/updater/installer/installer.go @@ -0,0 +1,50 @@ +//go:build !windows && !darwin + +package installer + +import ( + "context" + "fmt" +) + +const ( + updaterBinary = "updater" +) + +type Installer struct { + tempDir string +} + +// New used by the service +func New() *Installer { + return &Installer{} +} + +// NewWithDir used by the updater process, get the tempDir from the service via cmd line +func NewWithDir(tempDir string) *Installer { + return &Installer{ + tempDir: tempDir, + } +} + +func (u *Installer) TempDir() string { + return "" +} + +func (c *Installer) LogFiles() []string { + return []string{} +} + +func (u *Installer) CleanUpInstallerFiles() error { + return nil +} + +func (u *Installer) RunInstallation(ctx context.Context, targetVersion string) error { + return fmt.Errorf("unsupported platform") +} + +// Setup runs the installer with appropriate arguments and manages the daemon/UI state +// This will be run by the updater process +func (u *Installer) Setup(ctx context.Context, dryRun bool, targetVersion string, daemonFolder string) (resultErr error) { + return fmt.Errorf("unsupported platform") +} diff --git a/client/internal/updater/installer/installer_common.go b/client/internal/updater/installer/installer_common.go new file mode 100644 index 000000000..8e44bee82 --- /dev/null +++ b/client/internal/updater/installer/installer_common.go @@ -0,0 +1,293 @@ +//go:build windows || darwin + +package installer + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + + "github.com/hashicorp/go-multierror" + goversion "github.com/hashicorp/go-version" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/updater/downloader" + "github.com/netbirdio/netbird/client/internal/updater/reposign" +) + +type Installer struct { + tempDir string +} + +// New used by the service +func New() *Installer { + return &Installer{ + tempDir: defaultTempDir, + } +} + +// NewWithDir used by the updater process, get the tempDir from the service via cmd line +func NewWithDir(tempDir string) *Installer { + return &Installer{ + tempDir: tempDir, + } +} + +// RunInstallation starts the updater process to run the installation +// This will run by the original service process +func (u *Installer) RunInstallation(ctx context.Context, targetVersion string) (err error) { + resultHandler := NewResultHandler(u.tempDir) + + defer func() { + if err != nil { + if writeErr := resultHandler.WriteErr(err); writeErr != nil { + log.Errorf("failed to write error result: %v", writeErr) + } + } + }() + + if err := validateTargetVersion(targetVersion); err != nil { + return err + } + + if err := u.mkTempDir(); err != nil { + return err + } + + var installerFile string + // Download files only when not using any third-party store + if installerType := TypeOfInstaller(ctx); installerType.Downloadable() { + log.Infof("download installer") + var err error + installerFile, err = u.downloadInstaller(ctx, installerType, targetVersion) + if err != nil { + log.Errorf("failed to download installer: %v", err) + return err + } + + artifactVerify, err := reposign.NewArtifactVerify(DefaultSigningKeysBaseURL) + if err != nil { + log.Errorf("failed to create artifact verify: %v", err) + return err + } + + if err := artifactVerify.Verify(ctx, targetVersion, installerFile); err != nil { + log.Errorf("artifact verification error: %v", err) + return err + } + } + + log.Infof("running installer") + updaterPath, err := u.copyUpdater() + if err != nil { + return err + } + + // the directory where the service has been installed + workspace, err := getServiceDir() + if err != nil { + return err + } + + args := []string{ + "--temp-dir", u.tempDir, + "--service-dir", workspace, + } + + if isDryRunEnabled() { + args = append(args, "--dry-run=true") + } + + if installerFile != "" { + args = append(args, "--installer-file", installerFile) + } + + updateCmd := exec.Command(updaterPath, args...) + log.Infof("starting updater process: %s", updateCmd.String()) + + // Configure the updater to run in a separate session/process group + // so it survives the parent daemon being stopped + setUpdaterProcAttr(updateCmd) + + // Start the updater process asynchronously + if err := updateCmd.Start(); err != nil { + return err + } + + pid := updateCmd.Process.Pid + log.Infof("updater started with PID %d", pid) + + // Release the process so the OS can fully detach it + if err := updateCmd.Process.Release(); err != nil { + log.Warnf("failed to release updater process: %v", err) + } + + return nil +} + +// CleanUpInstallerFiles +// - the installer file (pkg, exe, msi) +// - the selfcopy updater.exe +func (u *Installer) CleanUpInstallerFiles() error { + // Check if tempDir exists + info, err := os.Stat(u.tempDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + if !info.IsDir() { + return nil + } + + 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)) + } + + entries, err := os.ReadDir(u.tempDir) + if err != nil { + return err + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + 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)) + } + break + } + } + } + + return merr.ErrorOrNil() +} + +func (u *Installer) downloadInstaller(ctx context.Context, installerType Type, targetVersion string) (string, error) { + fileURL := urlWithVersionArch(installerType, targetVersion) + + // Clean up temp directory on error + var success bool + defer func() { + if !success { + if err := os.RemoveAll(u.tempDir); err != nil { + log.Errorf("error cleaning up temporary directory: %v", err) + } + } + }() + + fileName := path.Base(fileURL) + if fileName == "." || fileName == "/" || fileName == "" { + return "", fmt.Errorf("invalid file URL: %s", fileURL) + } + + outputFilePath := filepath.Join(u.tempDir, fileName) + if err := downloader.DownloadToFile(ctx, downloader.DefaultRetryDelay, fileURL, outputFilePath); err != nil { + return "", err + } + + success = true + return outputFilePath, nil +} + +func (u *Installer) TempDir() string { + return u.tempDir +} + +func (u *Installer) mkTempDir() error { + if err := os.MkdirAll(u.tempDir, 0o755); err != nil { + log.Debugf("failed to create tempdir: %s", u.tempDir) + return err + } + return nil +} + +func (u *Installer) copyUpdater() (string, error) { + src, err := getServiceBinary() + if err != nil { + return "", fmt.Errorf("failed to get updater binary: %w", err) + } + + dst := filepath.Join(u.tempDir, updaterBinary) + if err := copyFile(src, dst); err != nil { + return "", fmt.Errorf("failed to copy updater binary: %w", err) + } + + if err := os.Chmod(dst, 0o755); err != nil { + return "", fmt.Errorf("failed to set permissions: %w", err) + } + + return dst, nil +} + +func validateTargetVersion(targetVersion string) error { + if targetVersion == "" { + return fmt.Errorf("target version cannot be empty") + } + + _, err := goversion.NewVersion(targetVersion) + if err != nil { + return fmt.Errorf("invalid target version %q: %w", targetVersion, err) + } + + return nil +} + +func copyFile(src, dst string) error { + log.Infof("copying %s to %s", src, dst) + in, err := os.Open(src) + if err != nil { + return fmt.Errorf("open source: %w", err) + } + defer func() { + if err := in.Close(); err != nil { + log.Warnf("failed to close source file: %v", err) + } + }() + + out, err := os.Create(dst) + if err != nil { + return fmt.Errorf("create destination: %w", err) + } + defer func() { + if err := out.Close(); err != nil { + log.Warnf("failed to close destination file: %v", err) + } + }() + + if _, err := io.Copy(out, in); err != nil { + return fmt.Errorf("copy: %w", err) + } + + return nil +} + +func getServiceDir() (string, error) { + exePath, err := os.Executable() + if err != nil { + return "", err + } + return filepath.Dir(exePath), nil +} + +func getServiceBinary() (string, error) { + return os.Executable() +} + +func isDryRunEnabled() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv("NB_AUTO_UPDATE_DRY_RUN")), "true") +} diff --git a/client/internal/updater/installer/installer_log_darwin.go b/client/internal/updater/installer/installer_log_darwin.go new file mode 100644 index 000000000..50dd5d197 --- /dev/null +++ b/client/internal/updater/installer/installer_log_darwin.go @@ -0,0 +1,11 @@ +package installer + +import ( + "path/filepath" +) + +func (u *Installer) LogFiles() []string { + return []string{ + filepath.Join(u.tempDir, LogFile), + } +} diff --git a/client/internal/updater/installer/installer_log_windows.go b/client/internal/updater/installer/installer_log_windows.go new file mode 100644 index 000000000..96e4cfd1f --- /dev/null +++ b/client/internal/updater/installer/installer_log_windows.go @@ -0,0 +1,12 @@ +package installer + +import ( + "path/filepath" +) + +func (u *Installer) LogFiles() []string { + return []string{ + filepath.Join(u.tempDir, msiLogFile), + filepath.Join(u.tempDir, LogFile), + } +} diff --git a/client/internal/updater/installer/installer_run_darwin.go b/client/internal/updater/installer/installer_run_darwin.go new file mode 100644 index 000000000..248a404aa --- /dev/null +++ b/client/internal/updater/installer/installer_run_darwin.go @@ -0,0 +1,238 @@ +package installer + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + daemonName = "netbird" + updaterBinary = "updater" + uiBinary = "/Applications/NetBird.app" + + defaultTempDir = "/var/lib/netbird/tmp-install" + + pkgDownloadURL = "https://github.com/netbirdio/netbird/releases/download/v%version/netbird_%version_darwin_%arch.pkg" +) + +var ( + binaryExtensions = []string{"pkg"} +) + +// Setup runs the installer with appropriate arguments and manages the daemon/UI state +// This will be run by the updater process +func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string, daemonFolder string) (resultErr error) { + resultHandler := NewResultHandler(u.tempDir) + + // Always ensure daemon and UI are restarted after setup + defer func() { + log.Infof("write out result") + var err error + if resultErr == nil { + err = resultHandler.WriteSuccess() + } else { + err = resultHandler.WriteErr(resultErr) + } + if err != nil { + log.Errorf("failed to write update result: %v", err) + } + + // skip service restart if dry-run mode is enabled + if dryRun { + return + } + + log.Infof("starting daemon back") + if err := u.startDaemon(daemonFolder); err != nil { + log.Errorf("failed to start daemon: %v", err) + } + + log.Infof("starting UI back") + if err := u.startUIAsUser(); err != nil { + log.Errorf("failed to start UI: %v", err) + } + + }() + + if dryRun { + time.Sleep(7 * time.Second) + log.Infof("dry-run mode enabled, skipping actual installation") + resultErr = fmt.Errorf("dry-run mode enabled") + return + } + + switch TypeOfInstaller(ctx) { + case TypePKG: + resultErr = u.installPkgFile(ctx, installerFile) + case TypeHomebrew: + resultErr = u.updateHomeBrew(ctx) + } + + return resultErr +} + +func (u *Installer) startDaemon(daemonFolder string) error { + log.Infof("starting netbird service") + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, filepath.Join(daemonFolder, daemonName), "service", "start") + if output, err := cmd.CombinedOutput(); err != nil { + log.Warnf("failed to start netbird service: %v, output: %s", err, string(output)) + return err + } + log.Infof("netbird service started successfully") + return nil +} + +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() + if err != nil { + return fmt.Errorf("failed to get console user: %w", 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) + } + + // 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("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) + } + + log.Infof("netbird-ui started successfully for user %s", username) + return nil +} + +func (u *Installer) installPkgFile(ctx context.Context, path string) error { + log.Infof("installing pkg file: %s", path) + + // Kill any existing UI processes before installation + // This ensures the postinstall script's "open $APP" will start the new version + u.killUI() + + volume := "/" + + cmd := exec.CommandContext(ctx, "installer", "-pkg", path, "-target", volume) + if err := cmd.Start(); err != nil { + return fmt.Errorf("error running pkg file: %w", err) + } + log.Infof("installer started with PID %d", cmd.Process.Pid) + if err := cmd.Wait(); err != nil { + return fmt.Errorf("error running pkg file: %w", err) + } + log.Infof("pkg file installed successfully") + return nil +} + +func (u *Installer) updateHomeBrew(ctx context.Context) error { + log.Infof("updating homebrew") + + // Kill any existing UI processes before upgrade + // This ensures the new version will be started after upgrade + u.killUI() + + // Homebrew must be run as a non-root user + // To find out which user installed NetBird using HomeBrew we can check the owner of our brew tap directory + // Check both Apple Silicon and Intel Mac paths + brewTapPath := "/opt/homebrew/Library/Taps/netbirdio/homebrew-tap/" + brewBinPath := "/opt/homebrew/bin/brew" + if _, err := os.Stat(brewTapPath); os.IsNotExist(err) { + // Try Intel Mac path + brewTapPath = "/usr/local/Homebrew/Library/Taps/netbirdio/homebrew-tap/" + brewBinPath = "/usr/local/bin/brew" + } + + fileInfo, err := os.Stat(brewTapPath) + if err != nil { + return fmt.Errorf("error getting homebrew installation path info: %w", err) + } + + fileSysInfo, ok := fileInfo.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("error checking file owner, sysInfo type is %T not *syscall.Stat_t", fileInfo.Sys()) + } + + // Get username from UID + brewUser, err := user.LookupId(fmt.Sprintf("%d", fileSysInfo.Uid)) + if err != nil { + return fmt.Errorf("error looking up brew installer user: %w", err) + } + userName := brewUser.Username + // Get user HOME, required for brew to run correctly + // https://github.com/Homebrew/brew/issues/15833 + homeDir := brewUser.HomeDir + + // Check if netbird-ui is installed (must run as the brew user, not root) + checkUICmd := exec.CommandContext(ctx, "sudo", "-u", userName, brewBinPath, "list", "--formula", "netbirdio/tap/netbird-ui") + checkUICmd.Env = append(os.Environ(), "HOME="+homeDir) + uiInstalled := checkUICmd.Run() == nil + + // Homebrew does not support installing specific versions + // Thus it will always update to latest and ignore targetVersion + upgradeArgs := []string{"-u", userName, brewBinPath, "upgrade", "netbirdio/tap/netbird"} + if uiInstalled { + upgradeArgs = append(upgradeArgs, "netbirdio/tap/netbird-ui") + } + + cmd := exec.CommandContext(ctx, "sudo", upgradeArgs...) + cmd.Env = append(os.Environ(), "HOME="+homeDir) + + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("error running brew upgrade: %w, output: %s", err, string(output)) + } + + log.Infof("homebrew updated successfully") + return nil +} + +func (u *Installer) killUI() { + log.Infof("killing existing netbird-ui processes") + cmd := exec.Command("pkill", "-x", "netbird-ui") + if output, err := cmd.CombinedOutput(); err != nil { + // pkill returns exit code 1 if no processes matched, which is fine + log.Debugf("pkill netbird-ui result: %v, output: %s", err, string(output)) + } else { + log.Infof("netbird-ui processes killed") + } +} + +func urlWithVersionArch(_ Type, version string) string { + url := strings.ReplaceAll(pkgDownloadURL, "%version", version) + return strings.ReplaceAll(url, "%arch", runtime.GOARCH) +} diff --git a/client/internal/updater/installer/installer_run_windows.go b/client/internal/updater/installer/installer_run_windows.go new file mode 100644 index 000000000..70c7e32cf --- /dev/null +++ b/client/internal/updater/installer/installer_run_windows.go @@ -0,0 +1,213 @@ +package installer + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + "unsafe" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +const ( + daemonName = "netbird.exe" + uiName = "netbird-ui.exe" + updaterBinary = "updater.exe" + + msiLogFile = "msi.log" + + 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" +) + +var ( + defaultTempDir = filepath.Join(os.Getenv("ProgramData"), "Netbird", "tmp-install") + + // for the cleanup + binaryExtensions = []string{"msi", "exe"} +) + +// Setup runs the installer with appropriate arguments and manages the daemon/UI state +// This will be run by the updater process +func (u *Installer) Setup(ctx context.Context, dryRun bool, installerFile string, daemonFolder string) (resultErr error) { + resultHandler := NewResultHandler(u.tempDir) + + // Always ensure daemon and UI are restarted after setup + defer func() { + log.Infof("starting daemon back") + if err := u.startDaemon(daemonFolder); err != nil { + log.Errorf("failed to start daemon: %v", err) + } + + log.Infof("starting UI back") + if err := u.startUIAsUser(daemonFolder); err != nil { + log.Errorf("failed to start UI: %v", err) + } + + log.Infof("write out result") + var err error + if resultErr == nil { + err = resultHandler.WriteSuccess() + } else { + err = resultHandler.WriteErr(resultErr) + } + if err != nil { + log.Errorf("failed to write update result: %v", err) + } + }() + + if dryRun { + log.Infof("dry-run mode enabled, skipping actual installation") + resultErr = fmt.Errorf("dry-run mode enabled") + return + } + + installerType, err := typeByFileExtension(installerFile) + if err != nil { + log.Debugf("%v", err) + resultErr = err + return + } + + var cmd *exec.Cmd + switch installerType { + case TypeExe: + log.Infof("run exe installer: %s", installerFile) + cmd = exec.CommandContext(ctx, installerFile, "/S") + default: + 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) + } + + cmd.Dir = filepath.Dir(installerFile) + + if resultErr = cmd.Start(); resultErr != nil { + log.Errorf("error starting installer: %v", resultErr) + return + } + + 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 + } + + return nil +} + +func (u *Installer) startDaemon(daemonFolder string) error { + log.Infof("starting netbird service") + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, filepath.Join(daemonFolder, daemonName), "service", "start") + if output, err := cmd.CombinedOutput(); err != nil { + log.Debugf("failed to start netbird service: %v, output: %s", err, string(output)) + return err + } + log.Infof("netbird service started successfully") + return nil +} + +func (u *Installer) startUIAsUser(daemonFolder string) 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") + } + + // Get the user token for that session + var userToken windows.Token + err := windows.WTSQueryUserToken(sessionID, &userToken) + if err != nil { + return fmt.Errorf("failed to query user token: %w", err) + } + defer func() { + if err := userToken.Close(); err != nil { + log.Warnf("failed to close user token: %v", err) + } + }() + + // Duplicate the token to a primary token + var primaryToken windows.Token + err = windows.DuplicateTokenEx( + userToken, + windows.MAXIMUM_ALLOWED, + nil, + windows.SecurityImpersonation, + windows.TokenPrimary, + &primaryToken, + ) + if err != nil { + return fmt.Errorf("failed to duplicate token: %w", err) + } + defer func() { + if err := primaryToken.Close(); err != nil { + log.Warnf("failed to close token: %v", err) + } + }() + + // Prepare startup info + var si windows.StartupInfo + si.Cb = uint32(unsafe.Sizeof(si)) + si.Desktop = windows.StringToUTF16Ptr("winsta0\\default") + + var pi windows.ProcessInformation + + cmdLine, err := windows.UTF16PtrFromString(fmt.Sprintf("\"%s\"", uiPath)) + if err != nil { + return fmt.Errorf("failed to convert path to UTF16: %w", err) + } + + creationFlags := uint32(0x00000200 | 0x00000008 | 0x00000400) // CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS | CREATE_UNICODE_ENVIRONMENT + + err = windows.CreateProcessAsUser( + primaryToken, + nil, + cmdLine, + nil, + nil, + false, + creationFlags, + nil, + nil, + &si, + &pi, + ) + if err != nil { + return fmt.Errorf("CreateProcessAsUser failed: %w", err) + } + + // Close handles + if err := windows.CloseHandle(pi.Process); err != nil { + log.Warnf("failed to close process handle: %v", err) + } + if err := windows.CloseHandle(pi.Thread); err != nil { + log.Warnf("failed to close thread handle: %v", err) + } + + log.Infof("netbird-ui started successfully in session %d", sessionID) + return nil +} + +func urlWithVersionArch(it Type, version string) string { + var url string + if it == TypeExe { + url = exeDownloadURL + } else { + url = msiDownloadURL + } + url = strings.ReplaceAll(url, "%version", version) + return strings.ReplaceAll(url, "%arch", runtime.GOARCH) +} diff --git a/client/internal/updater/installer/log.go b/client/internal/updater/installer/log.go new file mode 100644 index 000000000..8b60dba28 --- /dev/null +++ b/client/internal/updater/installer/log.go @@ -0,0 +1,5 @@ +package installer + +const ( + LogFile = "installer.log" +) diff --git a/client/internal/updater/installer/procattr_darwin.go b/client/internal/updater/installer/procattr_darwin.go new file mode 100644 index 000000000..56f2018bb --- /dev/null +++ b/client/internal/updater/installer/procattr_darwin.go @@ -0,0 +1,15 @@ +package installer + +import ( + "os/exec" + "syscall" +) + +// setUpdaterProcAttr configures the updater process to run in a new session, +// making it independent of the parent daemon process. This ensures the updater +// survives when the daemon is stopped during the pkg installation. +func setUpdaterProcAttr(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + } +} diff --git a/client/internal/updater/installer/procattr_windows.go b/client/internal/updater/installer/procattr_windows.go new file mode 100644 index 000000000..29a8a2de0 --- /dev/null +++ b/client/internal/updater/installer/procattr_windows.go @@ -0,0 +1,14 @@ +package installer + +import ( + "os/exec" + "syscall" +) + +// setUpdaterProcAttr configures the updater process to run detached from the parent, +// making it independent of the parent daemon process. +func setUpdaterProcAttr(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | 0x00000008, // 0x00000008 is DETACHED_PROCESS + } +} diff --git a/client/internal/updater/installer/repourl_dev.go b/client/internal/updater/installer/repourl_dev.go new file mode 100644 index 000000000..088821ad3 --- /dev/null +++ b/client/internal/updater/installer/repourl_dev.go @@ -0,0 +1,7 @@ +//go:build devartifactsign + +package installer + +const ( + DefaultSigningKeysBaseURL = "http://192.168.0.10:9089/signrepo" +) diff --git a/client/internal/updater/installer/repourl_prod.go b/client/internal/updater/installer/repourl_prod.go new file mode 100644 index 000000000..abddc62c1 --- /dev/null +++ b/client/internal/updater/installer/repourl_prod.go @@ -0,0 +1,7 @@ +//go:build !devartifactsign + +package installer + +const ( + DefaultSigningKeysBaseURL = "https://publickeys.netbird.io/artifact-signatures" +) diff --git a/client/internal/updater/installer/result.go b/client/internal/updater/installer/result.go new file mode 100644 index 000000000..526c3eb53 --- /dev/null +++ b/client/internal/updater/installer/result.go @@ -0,0 +1,233 @@ +package installer + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/fsnotify/fsnotify" + log "github.com/sirupsen/logrus" +) + +const ( + resultFile = "result.json" +) + +type Result struct { + Success bool + Error string + ExecutedAt time.Time +} + +// ResultHandler handles reading and writing update results +type ResultHandler struct { + resultFile string +} + +// NewResultHandler creates a new communicator with the given directory path +// The result file will be created as "result.json" in the specified directory +func NewResultHandler(installerDir string) *ResultHandler { + // Create it if it doesn't exist + // do not care if already exists + _ = os.MkdirAll(installerDir, 0o700) + + rh := &ResultHandler{ + resultFile: filepath.Join(installerDir, resultFile), + } + return rh +} + +func (rh *ResultHandler) GetErrorResultReason() string { + result, err := rh.tryReadResult() + if err == nil && !result.Success { + return result.Error + } + + if err := rh.cleanup(); err != nil { + log.Warnf("failed to cleanup result file: %v", err) + } + + return "" +} + +func (rh *ResultHandler) WriteSuccess() error { + result := Result{ + Success: true, + ExecutedAt: time.Now(), + } + return rh.write(result) +} + +func (rh *ResultHandler) WriteErr(errReason error) error { + result := Result{ + Success: false, + Error: errReason.Error(), + ExecutedAt: time.Now(), + } + return rh.write(result) +} + +func (rh *ResultHandler) Watch(ctx context.Context) (Result, error) { + log.Infof("start watching result: %s", rh.resultFile) + + // Check if file already exists (updater finished before we started watching) + if result, err := rh.tryReadResult(); err == nil { + log.Infof("installer result: %v", result) + return result, nil + } + + dir := filepath.Dir(rh.resultFile) + + if err := rh.waitForDirectory(ctx, dir); err != nil { + return Result{}, err + } + + return rh.watchForResultFile(ctx, dir) +} + +func (rh *ResultHandler) waitForDirectory(ctx context.Context, dir string) error { + ticker := time.NewTicker(300 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + if info, err := os.Stat(dir); err == nil && info.IsDir() { + return nil + } + } + } +} + +func (rh *ResultHandler) watchForResultFile(ctx context.Context, dir string) (Result, error) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Error(err) + return Result{}, err + } + + defer func() { + if err := watcher.Close(); err != nil { + log.Warnf("failed to close watcher: %v", err) + } + }() + + if err := watcher.Add(dir); err != nil { + return Result{}, fmt.Errorf("failed to watch directory: %v", err) + } + + // Check again after setting up watcher to avoid race condition + // (file could have been created between initial check and watcher setup) + if result, err := rh.tryReadResult(); err == nil { + log.Infof("installer result: %v", result) + return result, nil + } + + for { + select { + case <-ctx.Done(): + return Result{}, ctx.Err() + case event, ok := <-watcher.Events: + if !ok { + return Result{}, errors.New("watcher closed unexpectedly") + } + + if result, done := rh.handleWatchEvent(event); done { + return result, nil + } + case err, ok := <-watcher.Errors: + if !ok { + return Result{}, errors.New("watcher closed unexpectedly") + } + return Result{}, fmt.Errorf("watcher error: %w", err) + } + } +} + +func (rh *ResultHandler) handleWatchEvent(event fsnotify.Event) (Result, bool) { + if event.Name != rh.resultFile { + return Result{}, false + } + + if event.Has(fsnotify.Create) { + result, err := rh.tryReadResult() + if err != nil { + log.Debugf("error while reading result: %v", err) + return result, true + } + log.Infof("installer result: %v", result) + return result, true + } + + return Result{}, false +} + +// Write writes the update result to a file for the UI to read +func (rh *ResultHandler) write(result Result) error { + log.Infof("write out installer result to: %s", rh.resultFile) + // Ensure directory exists + dir := filepath.Dir(rh.resultFile) + if err := os.MkdirAll(dir, 0o755); err != nil { + log.Errorf("failed to create directory %s: %v", dir, err) + return err + } + + data, err := json.Marshal(result) + if err != nil { + return err + } + + // Write to a temporary file first, then rename for atomic operation + tmpPath := rh.resultFile + ".tmp" + if err := os.WriteFile(tmpPath, data, 0o600); err != nil { + log.Errorf("failed to create temp file: %s", err) + return err + } + + // Atomic rename + if err := os.Rename(tmpPath, rh.resultFile); err != nil { + if cleanupErr := os.Remove(tmpPath); cleanupErr != nil { + log.Warnf("Failed to remove temp result file: %v", err) + } + return err + } + + return nil +} + +func (rh *ResultHandler) cleanup() error { + err := os.Remove(rh.resultFile) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + log.Debugf("delete installer result file: %s", rh.resultFile) + return nil +} + +// tryReadResult attempts to read and validate the result file +func (rh *ResultHandler) tryReadResult() (Result, error) { + data, err := os.ReadFile(rh.resultFile) + if err != nil { + return Result{}, err + } + + var result Result + if err := json.Unmarshal(data, &result); err != nil { + return Result{}, fmt.Errorf("invalid result format: %w", err) + } + + if err := rh.cleanup(); err != nil { + log.Warnf("failed to cleanup result file: %v", err) + } + + return result, nil +} diff --git a/client/internal/updater/installer/types.go b/client/internal/updater/installer/types.go new file mode 100644 index 000000000..656d84f88 --- /dev/null +++ b/client/internal/updater/installer/types.go @@ -0,0 +1,14 @@ +package installer + +type Type struct { + name string + downloadable bool +} + +func (t Type) String() string { + return t.name +} + +func (t Type) Downloadable() bool { + return t.downloadable +} diff --git a/client/internal/updater/installer/types_darwin.go b/client/internal/updater/installer/types_darwin.go new file mode 100644 index 000000000..95a0cb737 --- /dev/null +++ b/client/internal/updater/installer/types_darwin.go @@ -0,0 +1,22 @@ +package installer + +import ( + "context" + "os/exec" +) + +var ( + TypeHomebrew = Type{name: "Homebrew", downloadable: false} + TypePKG = Type{name: "pkg", downloadable: true} +) + +func TypeOfInstaller(ctx context.Context) Type { + cmd := exec.CommandContext(ctx, "pkgutil", "--pkg-info", "io.netbird.client") + _, err := cmd.Output() + if err != nil && cmd.ProcessState.ExitCode() == 1 { + // Not installed using pkg file, thus installed using Homebrew + + return TypeHomebrew + } + return TypePKG +} diff --git a/client/internal/updater/installer/types_windows.go b/client/internal/updater/installer/types_windows.go new file mode 100644 index 000000000..d4e5d83bd --- /dev/null +++ b/client/internal/updater/installer/types_windows.go @@ -0,0 +1,51 @@ +package installer + +import ( + "context" + "fmt" + "strings" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows/registry" +) + +const ( + uninstallKeyPath64 = `SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Netbird` + uninstallKeyPath32 = `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Netbird` +) + +var ( + TypeExe = Type{name: "EXE", downloadable: true} + TypeMSI = Type{name: "MSI", downloadable: true} +) + +func TypeOfInstaller(_ context.Context) Type { + paths := []string{uninstallKeyPath64, uninstallKeyPath32} + + for _, path := range paths { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE) + if err != nil { + continue + } + + if err := k.Close(); err != nil { + log.Warnf("Error closing registry key: %v", err) + } + return TypeExe + + } + + log.Debug("No registry entry found for Netbird, assuming MSI installation") + return TypeMSI +} + +func typeByFileExtension(filePath string) (Type, error) { + switch { + case strings.HasSuffix(strings.ToLower(filePath), ".exe"): + return TypeExe, nil + case strings.HasSuffix(strings.ToLower(filePath), ".msi"): + return TypeMSI, nil + default: + return Type{}, fmt.Errorf("unsupported installer type for file: %s", filePath) + } +} diff --git a/client/internal/updater/manager.go b/client/internal/updater/manager.go new file mode 100644 index 000000000..7fc300739 --- /dev/null +++ b/client/internal/updater/manager.go @@ -0,0 +1,510 @@ +package updater + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + v "github.com/hashicorp/go-version" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/statemanager" + "github.com/netbirdio/netbird/client/internal/updater/installer" + cProto "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/version" +) + +const ( + latestVersion = "latest" +) + +var errNoUpdateState = errors.New("no update state found") + +type UpdateState struct { + PreUpdateVersion string + TargetVersion string +} + +func (u UpdateState) Name() string { + return "autoUpdate" +} + +type Manager struct { + statusRecorder *peer.Status + stateManager *statemanager.Manager + + downloadOnly bool // true when no enforcement from management; notifies UI to download latest + forceUpdate bool // true when management sets AlwaysUpdate; skips UI interaction and installs directly + + lastTrigger time.Time + mgmUpdateChan chan struct{} + updateChannel chan struct{} + currentVersion string + update UpdateInterface + wg sync.WaitGroup + + cancel context.CancelFunc + + expectedVersion *v.Version + updateToLatestVersion bool + + pendingVersion *v.Version + + // updateMutex protects update, expectedVersion, updateToLatestVersion, + // downloadOnly, forceUpdate, pendingVersion, and lastTrigger fields + updateMutex sync.Mutex + + // installMutex and installing guard against concurrent installation attempts + installMutex sync.Mutex + installing bool + + // protect to start the service multiple times + mu sync.Mutex + + autoUpdateSupported func() bool +} + +// NewManager creates a new update manager. The manager is single-use: once Stop() is called, it cannot be restarted. +func NewManager(statusRecorder *peer.Status, stateManager *statemanager.Manager) *Manager { + manager := &Manager{ + statusRecorder: statusRecorder, + stateManager: stateManager, + mgmUpdateChan: make(chan struct{}, 1), + updateChannel: make(chan struct{}, 1), + currentVersion: version.NetbirdVersion(), + update: version.NewUpdate("nb/client"), + downloadOnly: true, + autoUpdateSupported: isAutoUpdateSupported, + } + + stateManager.RegisterState(&UpdateState{}) + + return manager +} + +// CheckUpdateSuccess checks if the update was successful and send a notification. +// It works without to start the update manager. +func (m *Manager) CheckUpdateSuccess(ctx context.Context) { + reason := m.lastResultErrReason() + if reason != "" { + m.statusRecorder.PublishEvent( + cProto.SystemEvent_ERROR, + cProto.SystemEvent_SYSTEM, + "Auto-update failed", + fmt.Sprintf("Auto-update failed: %s", reason), + nil, + ) + } + + updateState, err := m.loadAndDeleteUpdateState(ctx) + if err != nil { + if errors.Is(err, errNoUpdateState) { + return + } + log.Errorf("failed to load update state: %v", err) + return + } + + log.Debugf("auto-update state loaded, %v", *updateState) + + if updateState.TargetVersion == m.currentVersion { + m.statusRecorder.PublishEvent( + cProto.SystemEvent_INFO, + cProto.SystemEvent_SYSTEM, + "Auto-update completed", + fmt.Sprintf("Your NetBird Client was auto-updated to version %s", m.currentVersion), + nil, + ) + return + } +} + +func (m *Manager) Start(ctx context.Context) { + log.Infof("starting update manager") + m.mu.Lock() + defer m.mu.Unlock() + if m.cancel != nil { + return + } + + m.update.SetDaemonVersion(m.currentVersion) + m.update.SetOnUpdateListener(func() { + select { + case m.updateChannel <- struct{}{}: + default: + } + }) + go m.update.StartFetcher() + + ctx, cancel := context.WithCancel(ctx) + m.cancel = cancel + + m.wg.Add(1) + go func() { + defer m.wg.Done() + m.updateLoop(ctx) + }() +} + +func (m *Manager) SetDownloadOnly() { + m.updateMutex.Lock() + m.downloadOnly = true + m.forceUpdate = false + m.expectedVersion = nil + m.updateToLatestVersion = false + m.lastTrigger = time.Time{} + m.updateMutex.Unlock() + + select { + case m.mgmUpdateChan <- struct{}{}: + default: + } +} + +func (m *Manager) SetVersion(expectedVersion string, forceUpdate bool) { + log.Infof("expected version changed to %s, force update: %t", expectedVersion, forceUpdate) + + if !m.autoUpdateSupported() { + log.Warnf("auto-update not supported on this platform") + return + } + + m.updateMutex.Lock() + defer m.updateMutex.Unlock() + + if expectedVersion == "" { + log.Errorf("empty expected version provided") + m.expectedVersion = nil + m.updateToLatestVersion = false + m.downloadOnly = true + return + } + + if expectedVersion == latestVersion { + m.updateToLatestVersion = true + m.expectedVersion = nil + } else { + expectedSemVer, err := v.NewVersion(expectedVersion) + if err != nil { + log.Errorf("error parsing version: %v", err) + return + } + if m.expectedVersion != nil && m.expectedVersion.Equal(expectedSemVer) { + return + } + m.expectedVersion = expectedSemVer + m.updateToLatestVersion = false + } + + m.lastTrigger = time.Time{} + m.downloadOnly = false + m.forceUpdate = forceUpdate + + select { + case m.mgmUpdateChan <- struct{}{}: + default: + } +} + +// Install triggers the installation of the pending version. It is called when the user clicks the install button in the UI. +func (m *Manager) Install(ctx context.Context) error { + if !m.autoUpdateSupported() { + return fmt.Errorf("auto-update not supported on this platform") + } + + m.updateMutex.Lock() + pending := m.pendingVersion + m.updateMutex.Unlock() + + if pending == nil { + return fmt.Errorf("no pending version to install") + } + + return m.tryInstall(ctx, pending) +} + +// tryInstall ensures only one installation runs at a time. Concurrent callers +// receive an error immediately rather than queuing behind a running install. +func (m *Manager) tryInstall(ctx context.Context, targetVersion *v.Version) error { + m.installMutex.Lock() + if m.installing { + m.installMutex.Unlock() + return fmt.Errorf("installation already in progress") + } + m.installing = true + m.installMutex.Unlock() + + defer func() { + m.installMutex.Lock() + m.installing = false + m.installMutex.Unlock() + }() + + return m.install(ctx, targetVersion) +} + +// NotifyUI re-publishes the current update state to a newly connected UI client. +// Only needed for download-only mode where the latest version is already cached +// NotifyUI re-publishes the current update state so a newly connected UI gets the info. +func (m *Manager) NotifyUI() { + m.updateMutex.Lock() + if m.update == nil { + m.updateMutex.Unlock() + return + } + downloadOnly := m.downloadOnly + pendingVersion := m.pendingVersion + latestVersion := m.update.LatestVersion() + m.updateMutex.Unlock() + + if downloadOnly { + if latestVersion == nil { + return + } + currentVersion, err := v.NewVersion(m.currentVersion) + if err != nil || currentVersion.GreaterThanOrEqual(latestVersion) { + return + } + m.statusRecorder.PublishEvent( + cProto.SystemEvent_INFO, + cProto.SystemEvent_SYSTEM, + "New version available", + "", + map[string]string{"new_version_available": latestVersion.String()}, + ) + return + } + + if pendingVersion != nil { + m.statusRecorder.PublishEvent( + cProto.SystemEvent_INFO, + cProto.SystemEvent_SYSTEM, + "New version available", + "", + map[string]string{"new_version_available": pendingVersion.String(), "enforced": "true"}, + ) + } +} + +// Stop is not used at the moment because it fully depends on the daemon. In a future refactor it may make sense to use it. +func (m *Manager) Stop() { + if m.cancel == nil { + return + } + + m.cancel() + m.updateMutex.Lock() + if m.update != nil { + m.update.StopWatch() + m.update = nil + } + m.updateMutex.Unlock() + + m.wg.Wait() +} + +func (m *Manager) onContextCancel() { + if m.cancel == nil { + return + } + + m.updateMutex.Lock() + defer m.updateMutex.Unlock() + if m.update != nil { + m.update.StopWatch() + m.update = nil + } +} + +func (m *Manager) updateLoop(ctx context.Context) { + for { + select { + case <-ctx.Done(): + m.onContextCancel() + return + case <-m.mgmUpdateChan: + case <-m.updateChannel: + log.Infof("fetched new version info") + } + + m.handleUpdate(ctx) + } +} + +func (m *Manager) handleUpdate(ctx context.Context) { + var updateVersion *v.Version + + m.updateMutex.Lock() + if m.update == nil { + m.updateMutex.Unlock() + return + } + + downloadOnly := m.downloadOnly + forceUpdate := m.forceUpdate + curLatestVersion := m.update.LatestVersion() + + switch { + // Download-only mode or resolve "latest" to actual version + case downloadOnly, m.updateToLatestVersion: + if curLatestVersion == nil { + log.Tracef("latest version not fetched yet") + m.updateMutex.Unlock() + return + } + updateVersion = curLatestVersion + // Install to specific version + case m.expectedVersion != nil: + updateVersion = m.expectedVersion + default: + log.Debugf("no expected version information set") + m.updateMutex.Unlock() + return + } + + log.Debugf("checking update option, current version: %s, target version: %s", m.currentVersion, updateVersion) + if !m.shouldUpdate(updateVersion, forceUpdate) { + m.updateMutex.Unlock() + return + } + + m.lastTrigger = time.Now() + log.Infof("new version available: %s", updateVersion) + + if !downloadOnly && !forceUpdate { + m.pendingVersion = updateVersion + } + m.updateMutex.Unlock() + + if downloadOnly { + m.statusRecorder.PublishEvent( + cProto.SystemEvent_INFO, + cProto.SystemEvent_SYSTEM, + "New version available", + "", + map[string]string{"new_version_available": updateVersion.String()}, + ) + return + } + + if forceUpdate { + if err := m.tryInstall(ctx, updateVersion); err != nil { + log.Errorf("force update failed: %v", err) + } + return + } + + m.statusRecorder.PublishEvent( + cProto.SystemEvent_INFO, + cProto.SystemEvent_SYSTEM, + "New version available", + "", + map[string]string{"new_version_available": updateVersion.String(), "enforced": "true"}, + ) +} + +func (m *Manager) install(ctx context.Context, pendingVersion *v.Version) error { + m.statusRecorder.PublishEvent( + cProto.SystemEvent_CRITICAL, + cProto.SystemEvent_SYSTEM, + "Updating client", + "Installing update now.", + nil, + ) + m.statusRecorder.PublishEvent( + cProto.SystemEvent_CRITICAL, + cProto.SystemEvent_SYSTEM, + "", + "", + map[string]string{"progress_window": "show", "version": pendingVersion.String()}, + ) + + updateState := UpdateState{ + PreUpdateVersion: m.currentVersion, + TargetVersion: pendingVersion.String(), + } + if err := m.stateManager.UpdateState(updateState); err != nil { + log.Warnf("failed to update state: %v", err) + } else { + if err = m.stateManager.PersistState(ctx); err != nil { + log.Warnf("failed to persist state: %v", err) + } + } + + inst := installer.New() + if err := inst.RunInstallation(ctx, pendingVersion.String()); err != nil { + log.Errorf("error triggering update: %v", err) + m.statusRecorder.PublishEvent( + cProto.SystemEvent_ERROR, + cProto.SystemEvent_SYSTEM, + "Auto-update failed", + fmt.Sprintf("Auto-update failed: %v", err), + nil, + ) + return err + } + return nil +} + +// loadAndDeleteUpdateState loads the update state, deletes it from storage, and returns it. +// Returns nil if no state exists. +func (m *Manager) loadAndDeleteUpdateState(ctx context.Context) (*UpdateState, error) { + stateType := &UpdateState{} + + m.stateManager.RegisterState(stateType) + if err := m.stateManager.LoadState(stateType); err != nil { + return nil, fmt.Errorf("load state: %w", err) + } + + state := m.stateManager.GetState(stateType) + if state == nil { + return nil, errNoUpdateState + } + + updateState, ok := state.(*UpdateState) + if !ok { + return nil, fmt.Errorf("failed to cast state to UpdateState") + } + + if err := m.stateManager.DeleteState(updateState); err != nil { + return nil, fmt.Errorf("delete state: %w", err) + } + + if err := m.stateManager.PersistState(ctx); err != nil { + return nil, fmt.Errorf("persist state: %w", err) + } + + return updateState, nil +} + +func (m *Manager) shouldUpdate(updateVersion *v.Version, forceUpdate bool) bool { + if version.IsDevelopmentVersion(m.currentVersion) { + log.Debugf("skipping auto-update, running development version") + return false + } + currentVersion, err := v.NewVersion(m.currentVersion) + if err != nil { + log.Errorf("error checking for update, error parsing version `%s`: %v", m.currentVersion, err) + return false + } + if currentVersion.GreaterThanOrEqual(updateVersion) { + log.Infof("current version (%s) is equal to or higher than auto-update version (%s)", m.currentVersion, updateVersion) + return false + } + + if forceUpdate && time.Since(m.lastTrigger) < 3*time.Minute { + log.Infof("skipping auto-update, last update was %s ago", time.Since(m.lastTrigger)) + return false + } + + return true +} + +func (m *Manager) lastResultErrReason() string { + inst := installer.New() + result := installer.NewResultHandler(inst.TempDir()) + return result.GetErrorResultReason() +} diff --git a/client/internal/updater/manager_linux_test.go b/client/internal/updater/manager_linux_test.go new file mode 100644 index 000000000..b05dd7e7d --- /dev/null +++ b/client/internal/updater/manager_linux_test.go @@ -0,0 +1,111 @@ +//go:build !windows && !darwin + +package updater + +import ( + "context" + "fmt" + "path" + "testing" + "time" + + v "github.com/hashicorp/go-version" + + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/statemanager" +) + +// On Linux, only Mode 1 (downloadOnly) is supported. +// SetVersion is a no-op because auto-update installation is not supported. + +func Test_LatestVersion_Linux(t *testing.T) { + testMatrix := []struct { + name string + daemonVersion string + initialLatestVersion *v.Version + latestVersion *v.Version + shouldUpdateInit bool + shouldUpdateLater bool + }{ + { + name: "Should notify again when a newer version arrives even within 5 minutes", + daemonVersion: "1.0.0", + initialLatestVersion: v.Must(v.NewSemver("1.0.1")), + latestVersion: v.Must(v.NewSemver("1.0.2")), + shouldUpdateInit: true, + shouldUpdateLater: true, + }, + { + name: "Shouldn't notify initially, but should notify as soon as latest version is fetched", + daemonVersion: "1.0.0", + initialLatestVersion: nil, + latestVersion: v.Must(v.NewSemver("1.0.1")), + shouldUpdateInit: false, + shouldUpdateLater: true, + }, + } + + for idx, c := range testMatrix { + mockUpdate := &versionUpdateMock{latestVersion: c.initialLatestVersion} + tmpFile := path.Join(t.TempDir(), fmt.Sprintf("update-test-%d.json", idx)) + recorder := peer.NewRecorder("") + sub := recorder.SubscribeToEvents() + defer recorder.UnsubscribeFromEvents(sub) + + m := NewManager(recorder, statemanager.New(tmpFile)) + m.update = mockUpdate + m.currentVersion = c.daemonVersion + m.Start(context.Background()) + m.SetDownloadOnly() + + ver, enforced := waitForUpdateEvent(sub, 500*time.Millisecond) + triggeredInit := ver != "" + if enforced { + t.Errorf("%s: Linux Mode 1 must never have enforced metadata", c.name) + } + if triggeredInit != c.shouldUpdateInit { + t.Errorf("%s: Initial notify mismatch, expected %v, got %v", c.name, c.shouldUpdateInit, triggeredInit) + } + if triggeredInit && c.initialLatestVersion != nil && ver != c.initialLatestVersion.String() { + t.Errorf("%s: Initial version mismatch, expected %v, got %v", c.name, c.initialLatestVersion.String(), ver) + } + + mockUpdate.latestVersion = c.latestVersion + mockUpdate.onUpdate() + + ver, enforced = waitForUpdateEvent(sub, 500*time.Millisecond) + triggeredLater := ver != "" + if enforced { + t.Errorf("%s: Linux Mode 1 must never have enforced metadata", c.name) + } + if triggeredLater != c.shouldUpdateLater { + t.Errorf("%s: Later notify mismatch, expected %v, got %v", c.name, c.shouldUpdateLater, triggeredLater) + } + if triggeredLater && c.latestVersion != nil && ver != c.latestVersion.String() { + t.Errorf("%s: Later version mismatch, expected %v, got %v", c.name, c.latestVersion.String(), ver) + } + + m.Stop() + } +} + +func Test_SetVersion_NoOp_Linux(t *testing.T) { + // On Linux, SetVersion should be a no-op — no events fired + tmpFile := path.Join(t.TempDir(), "update-test-noop.json") + recorder := peer.NewRecorder("") + sub := recorder.SubscribeToEvents() + defer recorder.UnsubscribeFromEvents(sub) + + m := NewManager(recorder, statemanager.New(tmpFile)) + m.update = &versionUpdateMock{latestVersion: v.Must(v.NewSemver("1.0.1"))} + m.currentVersion = "1.0.0" + m.Start(context.Background()) + m.SetVersion("1.0.1", false) + + ver, _ := waitForUpdateEvent(sub, 500*time.Millisecond) + if ver != "" { + t.Errorf("SetVersion should be a no-op on Linux, but got event with version %s", ver) + } + + m.Stop() +} diff --git a/client/internal/updater/manager_test.go b/client/internal/updater/manager_test.go new file mode 100644 index 000000000..107dca2b3 --- /dev/null +++ b/client/internal/updater/manager_test.go @@ -0,0 +1,227 @@ +//go:build windows || darwin + +package updater + +import ( + "context" + "fmt" + "path" + "testing" + "time" + + v "github.com/hashicorp/go-version" + + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/statemanager" + cProto "github.com/netbirdio/netbird/client/proto" +) + +func Test_LatestVersion(t *testing.T) { + testMatrix := []struct { + name string + daemonVersion string + initialLatestVersion *v.Version + latestVersion *v.Version + shouldUpdateInit bool + shouldUpdateLater bool + }{ + { + name: "Should notify again when a newer version arrives even within 5 minutes", + daemonVersion: "1.0.0", + initialLatestVersion: v.Must(v.NewSemver("1.0.1")), + latestVersion: v.Must(v.NewSemver("1.0.2")), + shouldUpdateInit: true, + shouldUpdateLater: true, + }, + { + name: "Shouldn't update initially, but should update as soon as latest version is fetched", + daemonVersion: "1.0.0", + initialLatestVersion: nil, + latestVersion: v.Must(v.NewSemver("1.0.1")), + shouldUpdateInit: false, + shouldUpdateLater: true, + }, + } + + for idx, c := range testMatrix { + mockUpdate := &versionUpdateMock{latestVersion: c.initialLatestVersion} + tmpFile := path.Join(t.TempDir(), fmt.Sprintf("update-test-%d.json", idx)) + recorder := peer.NewRecorder("") + sub := recorder.SubscribeToEvents() + defer recorder.UnsubscribeFromEvents(sub) + + m := NewManager(recorder, statemanager.New(tmpFile)) + m.update = mockUpdate + m.currentVersion = c.daemonVersion + m.autoUpdateSupported = func() bool { return true } + m.Start(context.Background()) + m.SetVersion("latest", false) + + ver, _ := waitForUpdateEvent(sub, 500*time.Millisecond) + triggeredInit := ver != "" + if triggeredInit != c.shouldUpdateInit { + t.Errorf("%s: Initial update trigger mismatch, expected %v, got %v", c.name, c.shouldUpdateInit, triggeredInit) + } + if triggeredInit && c.initialLatestVersion != nil && ver != c.initialLatestVersion.String() { + t.Errorf("%s: Initial update version mismatch, expected %v, got %v", c.name, c.initialLatestVersion.String(), ver) + } + + mockUpdate.latestVersion = c.latestVersion + mockUpdate.onUpdate() + + ver, _ = waitForUpdateEvent(sub, 500*time.Millisecond) + triggeredLater := ver != "" + if triggeredLater != c.shouldUpdateLater { + t.Errorf("%s: Later update trigger mismatch, expected %v, got %v", c.name, c.shouldUpdateLater, triggeredLater) + } + if triggeredLater && c.latestVersion != nil && ver != c.latestVersion.String() { + t.Errorf("%s: Later update version mismatch, expected %v, got %v", c.name, c.latestVersion.String(), ver) + } + + m.Stop() + } +} + +func Test_HandleUpdate(t *testing.T) { + testMatrix := []struct { + name string + daemonVersion string + latestVersion *v.Version + expectedVersion string + shouldUpdate bool + }{ + { + name: "Install to a specific version should update regardless of if latestVersion is available yet", + daemonVersion: "0.55.0", + latestVersion: nil, + expectedVersion: "0.56.0", + shouldUpdate: true, + }, + { + name: "Install to specific version should not update if version matches", + daemonVersion: "0.55.0", + latestVersion: nil, + expectedVersion: "0.55.0", + shouldUpdate: false, + }, + { + name: "Install to specific version should not update if current version is newer", + daemonVersion: "0.55.0", + latestVersion: nil, + expectedVersion: "0.54.0", + shouldUpdate: false, + }, + { + name: "Install to latest version should update if latest is newer", + daemonVersion: "0.55.0", + latestVersion: v.Must(v.NewSemver("0.56.0")), + expectedVersion: "latest", + shouldUpdate: true, + }, + { + name: "Install to latest version should not update if latest == current", + daemonVersion: "0.56.0", + latestVersion: v.Must(v.NewSemver("0.56.0")), + expectedVersion: "latest", + shouldUpdate: false, + }, + { + name: "Should not update if daemon version is invalid", + daemonVersion: "development", + latestVersion: v.Must(v.NewSemver("1.0.0")), + expectedVersion: "latest", + shouldUpdate: false, + }, + { + name: "Should not update if expecting latest and latest version is unavailable", + daemonVersion: "0.55.0", + latestVersion: nil, + expectedVersion: "latest", + shouldUpdate: false, + }, + { + name: "Should not update if expected version is invalid", + daemonVersion: "0.55.0", + latestVersion: nil, + expectedVersion: "development", + shouldUpdate: false, + }, + } + for idx, c := range testMatrix { + tmpFile := path.Join(t.TempDir(), fmt.Sprintf("update-test-%d.json", idx)) + recorder := peer.NewRecorder("") + sub := recorder.SubscribeToEvents() + defer recorder.UnsubscribeFromEvents(sub) + + m := NewManager(recorder, statemanager.New(tmpFile)) + m.update = &versionUpdateMock{latestVersion: c.latestVersion} + m.currentVersion = c.daemonVersion + m.autoUpdateSupported = func() bool { return true } + m.Start(context.Background()) + m.SetVersion(c.expectedVersion, false) + + ver, _ := waitForUpdateEvent(sub, 500*time.Millisecond) + updateTriggered := ver != "" + + if updateTriggered { + if c.expectedVersion == "latest" && c.latestVersion != nil && ver != c.latestVersion.String() { + t.Errorf("%s: Version mismatch, expected %v, got %v", c.name, c.latestVersion.String(), ver) + } else if c.expectedVersion != "latest" && c.expectedVersion != "development" && ver != c.expectedVersion { + t.Errorf("%s: Version mismatch, expected %v, got %v", c.name, c.expectedVersion, ver) + } + } + + if updateTriggered != c.shouldUpdate { + t.Errorf("%s: Update trigger mismatch, expected %v, got %v", c.name, c.shouldUpdate, updateTriggered) + } + m.Stop() + } +} + +func Test_EnforcedMetadata(t *testing.T) { + // Mode 1 (downloadOnly): no enforced metadata + tmpFile := path.Join(t.TempDir(), "update-test-mode1.json") + recorder := peer.NewRecorder("") + sub := recorder.SubscribeToEvents() + defer recorder.UnsubscribeFromEvents(sub) + + m := NewManager(recorder, statemanager.New(tmpFile)) + m.update = &versionUpdateMock{latestVersion: v.Must(v.NewSemver("1.0.1"))} + m.currentVersion = "1.0.0" + m.Start(context.Background()) + m.SetDownloadOnly() + + ver, enforced := waitForUpdateEvent(sub, 500*time.Millisecond) + if ver == "" { + t.Fatal("Mode 1: expected new_version_available event") + } + if enforced { + t.Error("Mode 1: expected no enforced metadata") + } + m.Stop() + + // Mode 2 (enforced, forceUpdate=false): enforced metadata present, no auto-install + tmpFile2 := path.Join(t.TempDir(), "update-test-mode2.json") + recorder2 := peer.NewRecorder("") + sub2 := recorder2.SubscribeToEvents() + defer recorder2.UnsubscribeFromEvents(sub2) + + m2 := NewManager(recorder2, statemanager.New(tmpFile2)) + m2.update = &versionUpdateMock{latestVersion: nil} + m2.currentVersion = "1.0.0" + m2.autoUpdateSupported = func() bool { return true } + m2.Start(context.Background()) + m2.SetVersion("1.0.1", false) + + ver, enforced2 := waitForUpdateEvent(sub2, 500*time.Millisecond) + if ver == "" { + t.Fatal("Mode 2: expected new_version_available event") + } + if !enforced2 { + t.Error("Mode 2: expected enforced metadata") + } + m2.Stop() +} + +// ensure the proto import is used +var _ = cProto.SystemEvent_INFO diff --git a/client/internal/updater/manager_test_helpers_test.go b/client/internal/updater/manager_test_helpers_test.go new file mode 100644 index 000000000..c7faee1f4 --- /dev/null +++ b/client/internal/updater/manager_test_helpers_test.go @@ -0,0 +1,56 @@ +package updater + +import ( + "strconv" + "time" + + v "github.com/hashicorp/go-version" + + "github.com/netbirdio/netbird/client/internal/peer" +) + +type versionUpdateMock struct { + latestVersion *v.Version + onUpdate func() +} + +func (m versionUpdateMock) StopWatch() {} + +func (m versionUpdateMock) SetDaemonVersion(newVersion string) bool { + return false +} + +func (m *versionUpdateMock) SetOnUpdateListener(updateFn func()) { + m.onUpdate = updateFn +} + +func (m versionUpdateMock) LatestVersion() *v.Version { + return m.latestVersion +} + +func (m versionUpdateMock) StartFetcher() {} + +// waitForUpdateEvent waits for a new_version_available event, returns the version string or "" on timeout. +func waitForUpdateEvent(sub *peer.EventSubscription, timeout time.Duration) (version string, enforced bool) { + timer := time.NewTimer(timeout) + defer timer.Stop() + for { + select { + case event, ok := <-sub.Events(): + if !ok { + return "", false + } + if val, ok := event.Metadata["new_version_available"]; ok { + enforced := false + if raw, ok := event.Metadata["enforced"]; ok { + if parsed, err := strconv.ParseBool(raw); err == nil { + enforced = parsed + } + } + return val, enforced + } + case <-timer.C: + return "", false + } + } +} diff --git a/client/internal/updater/reposign/artifact.go b/client/internal/updater/reposign/artifact.go new file mode 100644 index 000000000..3d4fe9c74 --- /dev/null +++ b/client/internal/updater/reposign/artifact.go @@ -0,0 +1,302 @@ +package reposign + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/binary" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "hash" + "time" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/blake2s" +) + +const ( + tagArtifactPrivate = "ARTIFACT PRIVATE KEY" + tagArtifactPublic = "ARTIFACT PUBLIC KEY" + + maxArtifactKeySignatureAge = 10 * 365 * 24 * time.Hour + maxArtifactSignatureAge = 10 * 365 * 24 * time.Hour +) + +// ArtifactHash wraps a hash.Hash and counts bytes written +type ArtifactHash struct { + hash.Hash +} + +// NewArtifactHash returns an initialized ArtifactHash using BLAKE2s +func NewArtifactHash() *ArtifactHash { + h, err := blake2s.New256(nil) + if err != nil { + panic(err) // Should never happen with nil Key + } + return &ArtifactHash{Hash: h} +} + +func (ah *ArtifactHash) Write(b []byte) (int, error) { + return ah.Hash.Write(b) +} + +// ArtifactKey is a signing Key used to sign artifacts +type ArtifactKey struct { + PrivateKey +} + +func (k ArtifactKey) String() string { + return fmt.Sprintf( + "ArtifactKey[ID=%s, CreatedAt=%s, ExpiresAt=%s]", + k.Metadata.ID, + k.Metadata.CreatedAt.Format(time.RFC3339), + k.Metadata.ExpiresAt.Format(time.RFC3339), + ) +} + +func GenerateArtifactKey(rootKey *RootKey, expiration time.Duration) (*ArtifactKey, []byte, []byte, []byte, error) { + // Verify root key is still valid + if !rootKey.Metadata.ExpiresAt.IsZero() && time.Now().After(rootKey.Metadata.ExpiresAt) { + return nil, nil, nil, nil, fmt.Errorf("root key has expired on %s", rootKey.Metadata.ExpiresAt.Format(time.RFC3339)) + } + + now := time.Now() + expirationTime := now.Add(expiration) + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("generate ed25519 key: %w", err) + } + + metadata := KeyMetadata{ + ID: computeKeyID(pub), + CreatedAt: now.UTC(), + ExpiresAt: expirationTime.UTC(), + } + + ak := &ArtifactKey{ + PrivateKey{ + Key: priv, + Metadata: metadata, + }, + } + + // Marshal PrivateKey struct to JSON + privJSON, err := json.Marshal(ak.PrivateKey) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to marshal private key: %w", err) + } + + // Marshal PublicKey struct to JSON + pubKey := PublicKey{ + Key: pub, + Metadata: metadata, + } + pubJSON, err := json.Marshal(pubKey) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to marshal public key: %w", err) + } + + // Encode to PEM with metadata embedded in bytes + privPEM := pem.EncodeToMemory(&pem.Block{ + Type: tagArtifactPrivate, + Bytes: privJSON, + }) + + pubPEM := pem.EncodeToMemory(&pem.Block{ + Type: tagArtifactPublic, + Bytes: pubJSON, + }) + + // Sign the public key with the root key + signature, err := SignArtifactKey(*rootKey, pubPEM) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to sign artifact key: %w", err) + } + + return ak, privPEM, pubPEM, signature, nil +} + +func ParseArtifactKey(privKeyPEM []byte) (ArtifactKey, error) { + pk, err := parsePrivateKey(privKeyPEM, tagArtifactPrivate) + if err != nil { + return ArtifactKey{}, fmt.Errorf("failed to parse artifact Key: %w", err) + } + return ArtifactKey{pk}, nil +} + +func ParseArtifactPubKey(data []byte) (PublicKey, error) { + pk, _, err := parsePublicKey(data, tagArtifactPublic) + return pk, err +} + +func BundleArtifactKeys(rootKey *RootKey, keys []PublicKey) ([]byte, []byte, error) { + if len(keys) == 0 { + return nil, nil, errors.New("no keys to bundle") + } + + // Create bundle by concatenating PEM-encoded keys + var pubBundle []byte + + for _, pk := range keys { + // Marshal PublicKey struct to JSON + pubJSON, err := json.Marshal(pk) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal public key: %w", err) + } + + // Encode to PEM + pubPEM := pem.EncodeToMemory(&pem.Block{ + Type: tagArtifactPublic, + Bytes: pubJSON, + }) + + pubBundle = append(pubBundle, pubPEM...) + } + + // Sign the entire bundle with the root key + signature, err := SignArtifactKey(*rootKey, pubBundle) + if err != nil { + return nil, nil, fmt.Errorf("failed to sign artifact key bundle: %w", err) + } + + return pubBundle, signature, nil +} + +func ValidateArtifactKeys(publicRootKeys []PublicKey, data []byte, signature Signature, revocationList *RevocationList) ([]PublicKey, error) { + now := time.Now().UTC() + if signature.Timestamp.After(now.Add(maxClockSkew)) { + err := fmt.Errorf("signature timestamp is in the future: %v", signature.Timestamp) + log.Debugf("artifact signature error: %v", err) + return nil, err + } + if now.Sub(signature.Timestamp) > maxArtifactKeySignatureAge { + err := fmt.Errorf("signature is too old: %v (created %v)", now.Sub(signature.Timestamp), signature.Timestamp) + log.Debugf("artifact signature error: %v", err) + return nil, err + } + + // Reconstruct the signed message: artifact_key_data || timestamp + msg := make([]byte, 0, len(data)+8) + msg = append(msg, data...) + msg = binary.LittleEndian.AppendUint64(msg, uint64(signature.Timestamp.Unix())) + + if !verifyAny(publicRootKeys, msg, signature.Signature) { + return nil, errors.New("failed to verify signature of artifact keys") + } + + pubKeys, err := parsePublicKeyBundle(data, tagArtifactPublic) + if err != nil { + log.Debugf("failed to parse public keys: %s", err) + return nil, err + } + + validKeys := make([]PublicKey, 0, len(pubKeys)) + for _, pubKey := range pubKeys { + // Filter out expired keys + if !pubKey.Metadata.ExpiresAt.IsZero() && now.After(pubKey.Metadata.ExpiresAt) { + log.Debugf("Key %s is expired at %v (current time %v)", + pubKey.Metadata.ID, pubKey.Metadata.ExpiresAt, now) + continue + } + + if revocationList != nil { + if revTime, revoked := revocationList.Revoked[pubKey.Metadata.ID]; revoked { + log.Debugf("Key %s is revoked as of %v (created %v)", + pubKey.Metadata.ID, revTime, pubKey.Metadata.CreatedAt) + continue + } + } + validKeys = append(validKeys, pubKey) + } + + if len(validKeys) == 0 { + log.Debugf("no valid public keys found for artifact keys") + return nil, fmt.Errorf("all %d artifact keys are revoked", len(pubKeys)) + } + + return validKeys, nil +} + +func ValidateArtifact(artifactPubKeys []PublicKey, data []byte, signature Signature) error { + // Validate signature timestamp + now := time.Now().UTC() + if signature.Timestamp.After(now.Add(maxClockSkew)) { + err := fmt.Errorf("artifact signature timestamp is in the future: %v", signature.Timestamp) + log.Debugf("failed to verify signature of artifact: %s", err) + return err + } + if now.Sub(signature.Timestamp) > maxArtifactSignatureAge { + return fmt.Errorf("artifact signature is too old: %v (created %v)", + now.Sub(signature.Timestamp), signature.Timestamp) + } + + h := NewArtifactHash() + if _, err := h.Write(data); err != nil { + return fmt.Errorf("failed to hash artifact: %w", err) + } + hash := h.Sum(nil) + + // Reconstruct the signed message: hash || length || timestamp + msg := make([]byte, 0, len(hash)+8+8) + msg = append(msg, hash...) + msg = binary.LittleEndian.AppendUint64(msg, uint64(len(data))) + msg = binary.LittleEndian.AppendUint64(msg, uint64(signature.Timestamp.Unix())) + + // Find matching Key and verify + for _, keyInfo := range artifactPubKeys { + if keyInfo.Metadata.ID == signature.KeyID { + // Check Key expiration + if !keyInfo.Metadata.ExpiresAt.IsZero() && + signature.Timestamp.After(keyInfo.Metadata.ExpiresAt) { + return fmt.Errorf("signing Key %s expired at %v, signature from %v", + signature.KeyID, keyInfo.Metadata.ExpiresAt, signature.Timestamp) + } + + if ed25519.Verify(keyInfo.Key, msg, signature.Signature) { + log.Debugf("artifact verified successfully with Key: %s", signature.KeyID) + return nil + } + return fmt.Errorf("signature verification failed for Key %s", signature.KeyID) + } + } + + return fmt.Errorf("no signing Key found with ID %s", signature.KeyID) +} + +func SignData(artifactKey ArtifactKey, data []byte) ([]byte, error) { + if len(data) == 0 { // Check happens too late + return nil, fmt.Errorf("artifact length must be positive, got %d", len(data)) + } + + h := NewArtifactHash() + if _, err := h.Write(data); err != nil { + return nil, fmt.Errorf("failed to write artifact hash: %w", err) + } + + timestamp := time.Now().UTC() + + if !artifactKey.Metadata.ExpiresAt.IsZero() && timestamp.After(artifactKey.Metadata.ExpiresAt) { + return nil, fmt.Errorf("artifact key expired at %v", artifactKey.Metadata.ExpiresAt) + } + + hash := h.Sum(nil) + + // Create message: hash || length || timestamp + msg := make([]byte, 0, len(hash)+8+8) + msg = append(msg, hash...) + msg = binary.LittleEndian.AppendUint64(msg, uint64(len(data))) + msg = binary.LittleEndian.AppendUint64(msg, uint64(timestamp.Unix())) + + sig := ed25519.Sign(artifactKey.Key, msg) + + bundle := Signature{ + Signature: sig, + Timestamp: timestamp, + KeyID: artifactKey.Metadata.ID, + Algorithm: "ed25519", + HashAlgo: "blake2s", + } + + return json.Marshal(bundle) +} diff --git a/client/internal/updater/reposign/artifact_test.go b/client/internal/updater/reposign/artifact_test.go new file mode 100644 index 000000000..8865e2d0a --- /dev/null +++ b/client/internal/updater/reposign/artifact_test.go @@ -0,0 +1,1080 @@ +package reposign + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "encoding/pem" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test ArtifactHash + +func TestNewArtifactHash(t *testing.T) { + h := NewArtifactHash() + assert.NotNil(t, h) + assert.NotNil(t, h.Hash) +} + +func TestArtifactHash_Write(t *testing.T) { + h := NewArtifactHash() + + data := []byte("test data") + n, err := h.Write(data) + require.NoError(t, err) + assert.Equal(t, len(data), n) + + hash := h.Sum(nil) + assert.NotEmpty(t, hash) + assert.Equal(t, 32, len(hash)) // BLAKE2s-256 +} + +func TestArtifactHash_Deterministic(t *testing.T) { + data := []byte("test data") + + h1 := NewArtifactHash() + if _, err := h1.Write(data); err != nil { + t.Fatal(err) + } + hash1 := h1.Sum(nil) + + h2 := NewArtifactHash() + if _, err := h2.Write(data); err != nil { + t.Fatal(err) + } + hash2 := h2.Sum(nil) + + assert.Equal(t, hash1, hash2) +} + +func TestArtifactHash_DifferentData(t *testing.T) { + h1 := NewArtifactHash() + if _, err := h1.Write([]byte("data1")); err != nil { + t.Fatal(err) + } + hash1 := h1.Sum(nil) + + h2 := NewArtifactHash() + if _, err := h2.Write([]byte("data2")); err != nil { + t.Fatal(err) + } + hash2 := h2.Sum(nil) + + assert.NotEqual(t, hash1, hash2) +} + +// Test ArtifactKey.String() + +func TestArtifactKey_String(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + createdAt := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + expiresAt := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC) + + ak := ArtifactKey{ + PrivateKey{ + Key: priv, + Metadata: KeyMetadata{ + ID: computeKeyID(pub), + CreatedAt: createdAt, + ExpiresAt: expiresAt, + }, + }, + } + + str := ak.String() + assert.Contains(t, str, "ArtifactKey") + assert.Contains(t, str, computeKeyID(pub).String()) + assert.Contains(t, str, "2024-01-15") + assert.Contains(t, str, "2025-01-15") +} + +// Test GenerateArtifactKey + +func TestGenerateArtifactKey_Valid(t *testing.T) { + // Create root key + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := &RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().Add(365 * 24 * time.Hour).UTC(), + }, + }, + } + + // Generate artifact key + ak, privPEM, pubPEM, signature, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + assert.NotNil(t, ak) + assert.NotEmpty(t, privPEM) + assert.NotEmpty(t, pubPEM) + assert.NotEmpty(t, signature) + + // Verify expiration + assert.True(t, ak.Metadata.ExpiresAt.After(time.Now())) + assert.True(t, ak.Metadata.ExpiresAt.Before(time.Now().Add(31*24*time.Hour))) +} + +func TestGenerateArtifactKey_ExpiredRoot(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + // Create expired root key + rootKey := &RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().Add(-2 * 365 * 24 * time.Hour).UTC(), + ExpiresAt: time.Now().Add(-1 * time.Hour).UTC(), // Expired + }, + }, + } + + _, _, _, _, err = GenerateArtifactKey(rootKey, 30*24*time.Hour) + assert.Error(t, err) + assert.Contains(t, err.Error(), "expired") +} + +func TestGenerateArtifactKey_NoExpiration(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + // Root key with no expiration + rootKey := &RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Time{}, // No expiration + }, + }, + } + + ak, _, _, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + assert.NotNil(t, ak) +} + +// Test ParseArtifactKey + +func TestParseArtifactKey_Valid(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := &RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + original, privPEM, _, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + + // Parse it back + parsed, err := ParseArtifactKey(privPEM) + require.NoError(t, err) + + assert.Equal(t, original.Key, parsed.Key) + assert.Equal(t, original.Metadata.ID, parsed.Metadata.ID) +} + +func TestParseArtifactKey_InvalidPEM(t *testing.T) { + _, err := ParseArtifactKey([]byte("invalid pem")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse") +} + +func TestParseArtifactKey_WrongType(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + // Create a root key (wrong type) + rootKey := RootKey{ + PrivateKey{ + Key: priv, + Metadata: KeyMetadata{ + ID: computeKeyID(pub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + privJSON, err := json.Marshal(rootKey.PrivateKey) + require.NoError(t, err) + + privPEM := encodePrivateKey(privJSON, tagRootPrivate) + + _, err = ParseArtifactKey(privPEM) + assert.Error(t, err) +} + +// Test ParseArtifactPubKey + +func TestParseArtifactPubKey_Valid(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := &RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + original, _, pubPEM, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + + parsed, err := ParseArtifactPubKey(pubPEM) + require.NoError(t, err) + + assert.Equal(t, original.Metadata.ID, parsed.Metadata.ID) +} + +func TestParseArtifactPubKey_Invalid(t *testing.T) { + _, err := ParseArtifactPubKey([]byte("invalid")) + assert.Error(t, err) +} + +// Test BundleArtifactKeys + +func TestBundleArtifactKeys_Single(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := &RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + _, _, pubPEM, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + + pubKey, err := ParseArtifactPubKey(pubPEM) + require.NoError(t, err) + + bundle, signature, err := BundleArtifactKeys(rootKey, []PublicKey{pubKey}) + require.NoError(t, err) + assert.NotEmpty(t, bundle) + assert.NotEmpty(t, signature) +} + +func TestBundleArtifactKeys_Multiple(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := &RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + // Generate 3 artifact keys + var pubKeys []PublicKey + for i := 0; i < 3; i++ { + _, _, pubPEM, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + + pubKey, err := ParseArtifactPubKey(pubPEM) + require.NoError(t, err) + pubKeys = append(pubKeys, pubKey) + } + + bundle, signature, err := BundleArtifactKeys(rootKey, pubKeys) + require.NoError(t, err) + assert.NotEmpty(t, bundle) + assert.NotEmpty(t, signature) + + // Verify we can parse the bundle + parsed, err := parsePublicKeyBundle(bundle, tagArtifactPublic) + require.NoError(t, err) + assert.Len(t, parsed, 3) +} + +func TestBundleArtifactKeys_Empty(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := &RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + _, _, err = BundleArtifactKeys(rootKey, []PublicKey{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no keys") +} + +// Test ValidateArtifactKeys + +func TestSingleValidateArtifactKey_Valid(t *testing.T) { + // Create root key + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := &RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + rootKeys := []PublicKey{ + { + Key: rootPub, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + // Generate artifact key + _, _, pubPEM, sigData, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + + sig, _ := ParseSignature(sigData) + + // Validate + validKeys, err := ValidateArtifactKeys(rootKeys, pubPEM, *sig, nil) + require.NoError(t, err) + assert.Len(t, validKeys, 1) +} + +func TestValidateArtifactKeys_Valid(t *testing.T) { + // Create root key + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := &RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + rootKeys := []PublicKey{ + { + Key: rootPub, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + // Generate artifact key + _, _, pubPEM, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + + pubKey, err := ParseArtifactPubKey(pubPEM) + require.NoError(t, err) + + // Bundle and sign + bundle, sigData, err := BundleArtifactKeys(rootKey, []PublicKey{pubKey}) + require.NoError(t, err) + + sig, err := ParseSignature(sigData) + require.NoError(t, err) + + // Validate + validKeys, err := ValidateArtifactKeys(rootKeys, bundle, *sig, nil) + require.NoError(t, err) + assert.Len(t, validKeys, 1) +} + +func TestValidateArtifactKeys_FutureTimestamp(t *testing.T) { + rootPub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKeys := []PublicKey{ + { + Key: rootPub, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + sig := Signature{ + Signature: make([]byte, 64), + Timestamp: time.Now().UTC().Add(10 * time.Minute), + KeyID: computeKeyID(rootPub), + Algorithm: "ed25519", + HashAlgo: "blake2s", + } + + _, err = ValidateArtifactKeys(rootKeys, []byte("data"), sig, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "in the future") +} + +func TestValidateArtifactKeys_TooOld(t *testing.T) { + rootPub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKeys := []PublicKey{ + { + Key: rootPub, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + sig := Signature{ + Signature: make([]byte, 64), + Timestamp: time.Now().UTC().Add(-20 * 365 * 24 * time.Hour), + KeyID: computeKeyID(rootPub), + Algorithm: "ed25519", + HashAlgo: "blake2s", + } + + _, err = ValidateArtifactKeys(rootKeys, []byte("data"), sig, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "too old") +} + +func TestValidateArtifactKeys_InvalidSignature(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := &RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + rootKeys := []PublicKey{ + { + Key: rootPub, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + _, _, pubPEM, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + + pubKey, err := ParseArtifactPubKey(pubPEM) + require.NoError(t, err) + + bundle, _, err := BundleArtifactKeys(rootKey, []PublicKey{pubKey}) + require.NoError(t, err) + + // Create invalid signature + invalidSig := Signature{ + Signature: make([]byte, 64), + Timestamp: time.Now().UTC(), + KeyID: computeKeyID(rootPub), + Algorithm: "ed25519", + HashAlgo: "blake2s", + } + + _, err = ValidateArtifactKeys(rootKeys, bundle, invalidSig, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to verify") +} + +func TestValidateArtifactKeys_WithRevocation(t *testing.T) { + // Create root key + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := &RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + rootKeys := []PublicKey{ + { + Key: rootPub, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + // Generate two artifact keys + _, _, pubPEM1, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + pubKey1, err := ParseArtifactPubKey(pubPEM1) + require.NoError(t, err) + + _, _, pubPEM2, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + pubKey2, err := ParseArtifactPubKey(pubPEM2) + require.NoError(t, err) + + // Bundle both keys + bundle, sigData, err := BundleArtifactKeys(rootKey, []PublicKey{pubKey1, pubKey2}) + require.NoError(t, err) + + sig, err := ParseSignature(sigData) + require.NoError(t, err) + + // Create revocation list with first key revoked + revocationList := &RevocationList{ + Revoked: map[KeyID]time.Time{ + pubKey1.Metadata.ID: time.Now().UTC(), + }, + LastUpdated: time.Now().UTC(), + } + + // Validate - should only return second key + validKeys, err := ValidateArtifactKeys(rootKeys, bundle, *sig, revocationList) + require.NoError(t, err) + assert.Len(t, validKeys, 1) + assert.Equal(t, pubKey2.Metadata.ID, validKeys[0].Metadata.ID) +} + +func TestValidateArtifactKeys_AllRevoked(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := &RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + rootKeys := []PublicKey{ + { + Key: rootPub, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + _, _, pubPEM, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + pubKey, err := ParseArtifactPubKey(pubPEM) + require.NoError(t, err) + + bundle, sigData, err := BundleArtifactKeys(rootKey, []PublicKey{pubKey}) + require.NoError(t, err) + + sig, err := ParseSignature(sigData) + require.NoError(t, err) + + // Revoke the key + revocationList := &RevocationList{ + Revoked: map[KeyID]time.Time{ + pubKey.Metadata.ID: time.Now().UTC(), + }, + LastUpdated: time.Now().UTC(), + } + + _, err = ValidateArtifactKeys(rootKeys, bundle, *sig, revocationList) + assert.Error(t, err) + assert.Contains(t, err.Error(), "revoked") +} + +// Test ValidateArtifact + +func TestValidateArtifact_Valid(t *testing.T) { + // Create root key + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := &RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + // Generate artifact key + artifactKey, _, _, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + + // Sign some data + data := []byte("test artifact data") + sigData, err := SignData(*artifactKey, data) + require.NoError(t, err) + + sig, err := ParseSignature(sigData) + require.NoError(t, err) + + // Get public key for validation + artifactPubKey := PublicKey{ + Key: artifactKey.Key.Public().(ed25519.PublicKey), + Metadata: artifactKey.Metadata, + } + + // Validate + err = ValidateArtifact([]PublicKey{artifactPubKey}, data, *sig) + assert.NoError(t, err) +} + +func TestValidateArtifact_FutureTimestamp(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + artifactPubKey := PublicKey{ + Key: pub, + Metadata: KeyMetadata{ + ID: computeKeyID(pub), + CreatedAt: time.Now().UTC(), + }, + } + + sig := Signature{ + Signature: make([]byte, 64), + Timestamp: time.Now().UTC().Add(10 * time.Minute), + KeyID: computeKeyID(pub), + Algorithm: "ed25519", + HashAlgo: "blake2s", + } + + err = ValidateArtifact([]PublicKey{artifactPubKey}, []byte("data"), sig) + assert.Error(t, err) + assert.Contains(t, err.Error(), "in the future") +} + +func TestValidateArtifact_TooOld(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + artifactPubKey := PublicKey{ + Key: pub, + Metadata: KeyMetadata{ + ID: computeKeyID(pub), + CreatedAt: time.Now().UTC(), + }, + } + + sig := Signature{ + Signature: make([]byte, 64), + Timestamp: time.Now().UTC().Add(-20 * 365 * 24 * time.Hour), + KeyID: computeKeyID(pub), + Algorithm: "ed25519", + HashAlgo: "blake2s", + } + + err = ValidateArtifact([]PublicKey{artifactPubKey}, []byte("data"), sig) + assert.Error(t, err) + assert.Contains(t, err.Error(), "too old") +} + +func TestValidateArtifact_ExpiredKey(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := &RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + // Generate artifact key with very short expiration + artifactKey, _, _, _, err := GenerateArtifactKey(rootKey, 1*time.Millisecond) + require.NoError(t, err) + + // Wait for key to expire + time.Sleep(10 * time.Millisecond) + + // Try to sign - should succeed but with old timestamp + data := []byte("test data") + sigData, err := SignData(*artifactKey, data) + require.Error(t, err) // Key is expired, so signing should fail + assert.Contains(t, err.Error(), "expired") + assert.Nil(t, sigData) +} + +func TestValidateArtifact_WrongKey(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := &RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + // Generate two artifact keys + artifactKey1, _, _, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + + artifactKey2, _, _, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + + // Sign with key1 + data := []byte("test data") + sigData, err := SignData(*artifactKey1, data) + require.NoError(t, err) + + sig, err := ParseSignature(sigData) + require.NoError(t, err) + + // Try to validate with key2 only + artifactPubKey2 := PublicKey{ + Key: artifactKey2.Key.Public().(ed25519.PublicKey), + Metadata: artifactKey2.Metadata, + } + + err = ValidateArtifact([]PublicKey{artifactPubKey2}, data, *sig) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no signing Key found") +} + +func TestValidateArtifact_TamperedData(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := &RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + artifactKey, _, _, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + + // Sign original data + originalData := []byte("original data") + sigData, err := SignData(*artifactKey, originalData) + require.NoError(t, err) + + sig, err := ParseSignature(sigData) + require.NoError(t, err) + + artifactPubKey := PublicKey{ + Key: artifactKey.Key.Public().(ed25519.PublicKey), + Metadata: artifactKey.Metadata, + } + + // Try to validate with tampered data + tamperedData := []byte("tampered data") + err = ValidateArtifact([]PublicKey{artifactPubKey}, tamperedData, *sig) + assert.Error(t, err) + assert.Contains(t, err.Error(), "verification failed") +} + +func TestValidateArtifactKeys_TwoKeysOneExpired(t *testing.T) { + // Test ValidateArtifactKeys with a bundle containing two keys where one is expired + // Should return only the valid key + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := &RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + rootKeys := []PublicKey{ + { + Key: rootPub, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + // Generate first key with very short expiration + _, _, expiredPubPEM, _, err := GenerateArtifactKey(rootKey, 1*time.Millisecond) + require.NoError(t, err) + expiredPubKey, err := ParseArtifactPubKey(expiredPubPEM) + require.NoError(t, err) + + // Wait for first key to expire + time.Sleep(10 * time.Millisecond) + + // Generate second key with normal expiration + _, _, validPubPEM, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + validPubKey, err := ParseArtifactPubKey(validPubPEM) + require.NoError(t, err) + + // Bundle both keys together + bundle, sigData, err := BundleArtifactKeys(rootKey, []PublicKey{expiredPubKey, validPubKey}) + require.NoError(t, err) + + sig, err := ParseSignature(sigData) + require.NoError(t, err) + + // ValidateArtifactKeys should return only the valid key + validKeys, err := ValidateArtifactKeys(rootKeys, bundle, *sig, nil) + require.NoError(t, err) + assert.Len(t, validKeys, 1) + assert.Equal(t, validPubKey.Metadata.ID, validKeys[0].Metadata.ID) +} + +func TestValidateArtifactKeys_TwoKeysBothExpired(t *testing.T) { + // Test ValidateArtifactKeys with a bundle containing two expired keys + // Should fail because no valid keys remain + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := &RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + rootKeys := []PublicKey{ + { + Key: rootPub, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + // Generate first key with + _, _, pubPEM1, _, err := GenerateArtifactKey(rootKey, 24*time.Hour) + require.NoError(t, err) + pubKey1, err := ParseArtifactPubKey(pubPEM1) + require.NoError(t, err) + + // Generate second key with very short expiration + _, _, pubPEM2, _, err := GenerateArtifactKey(rootKey, 1*time.Millisecond) + require.NoError(t, err) + pubKey2, err := ParseArtifactPubKey(pubPEM2) + require.NoError(t, err) + + // Wait for expire + time.Sleep(10 * time.Millisecond) + + bundle, sigData, err := BundleArtifactKeys(rootKey, []PublicKey{pubKey1, pubKey2}) + require.NoError(t, err) + + sig, err := ParseSignature(sigData) + require.NoError(t, err) + + // ValidateArtifactKeys should fail because all keys are expired + keys, err := ValidateArtifactKeys(rootKeys, bundle, *sig, nil) + assert.NoError(t, err) + assert.Len(t, keys, 1) +} + +// Test SignData + +func TestSignData_Valid(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := &RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + artifactKey, _, _, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + + data := []byte("test data to sign") + sigData, err := SignData(*artifactKey, data) + require.NoError(t, err) + assert.NotEmpty(t, sigData) + + // Verify signature can be parsed + sig, err := ParseSignature(sigData) + require.NoError(t, err) + assert.NotEmpty(t, sig.Signature) + assert.Equal(t, "ed25519", sig.Algorithm) + assert.Equal(t, "blake2s", sig.HashAlgo) +} + +func TestSignData_EmptyData(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := &RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + artifactKey, _, _, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + + _, err = SignData(*artifactKey, []byte{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "must be positive") +} + +func TestSignData_ExpiredKey(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := &RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + // Generate key with very short expiration + artifactKey, _, _, _, err := GenerateArtifactKey(rootKey, 1*time.Millisecond) + require.NoError(t, err) + + // Wait for expiration + time.Sleep(10 * time.Millisecond) + + // Try to sign with expired key + _, err = SignData(*artifactKey, []byte("data")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "expired") +} + +// Integration test + +func TestArtifact_FullWorkflow(t *testing.T) { + // Step 1: Create root key + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := &RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + rootKeys := []PublicKey{ + { + Key: rootPub, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + // Step 2: Generate artifact key + artifactKey, _, pubPEM, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + + // Step 3: Create and validate key bundle + artifactPubKey, err := ParseArtifactPubKey(pubPEM) + require.NoError(t, err) + + bundle, bundleSig, err := BundleArtifactKeys(rootKey, []PublicKey{artifactPubKey}) + require.NoError(t, err) + + sig, err := ParseSignature(bundleSig) + require.NoError(t, err) + + validKeys, err := ValidateArtifactKeys(rootKeys, bundle, *sig, nil) + require.NoError(t, err) + assert.Len(t, validKeys, 1) + + // Step 4: Sign artifact data + artifactData := []byte("This is my artifact data that needs to be signed") + artifactSig, err := SignData(*artifactKey, artifactData) + require.NoError(t, err) + + // Step 5: Validate artifact + parsedSig, err := ParseSignature(artifactSig) + require.NoError(t, err) + + err = ValidateArtifact(validKeys, artifactData, *parsedSig) + assert.NoError(t, err) +} + +// Helper function for tests +func encodePrivateKey(jsonData []byte, typeTag string) []byte { + return pem.EncodeToMemory(&pem.Block{ + Type: typeTag, + Bytes: jsonData, + }) +} diff --git a/client/internal/updater/reposign/certs/root-pub.pem b/client/internal/updater/reposign/certs/root-pub.pem new file mode 100644 index 000000000..e7c2fd2c0 --- /dev/null +++ b/client/internal/updater/reposign/certs/root-pub.pem @@ -0,0 +1,6 @@ +-----BEGIN ROOT PUBLIC KEY----- +eyJLZXkiOiJoaGIxdGRDSEZNMFBuQWp1b2w2cXJ1QXRFbWFFSlg1QjFsZUNxWmpn +V1pvPSIsIk1ldGFkYXRhIjp7ImlkIjoiOWE0OTg2NmI2MzE2MjNiNCIsImNyZWF0 +ZWRfYXQiOiIyMDI1LTExLTI0VDE3OjE1OjI4LjYyNzE3MzE3MVoiLCJleHBpcmVz +X2F0IjoiMjAzNS0xMS0yMlQxNzoxNToyOC42MjcxNzMxNzFaIn19 +-----END ROOT PUBLIC KEY----- diff --git a/client/internal/updater/reposign/certsdev/root-pub.pem b/client/internal/updater/reposign/certsdev/root-pub.pem new file mode 100644 index 000000000..f7145477b --- /dev/null +++ b/client/internal/updater/reposign/certsdev/root-pub.pem @@ -0,0 +1,6 @@ +-----BEGIN ROOT PUBLIC KEY----- +eyJLZXkiOiJyTDByVTN2MEFOZUNmbDZraitiUUd3TE1waU5CaUJLdVBWSnZtQzgr +ZS84PSIsIk1ldGFkYXRhIjp7ImlkIjoiMTBkNjQyZTY2N2FmMDNkNCIsImNyZWF0 +ZWRfYXQiOiIyMDI1LTExLTIwVDE3OjI5OjI5LjE4MDk0NjMxNloiLCJleHBpcmVz +X2F0IjoiMjAyNi0xMS0yMFQxNzoyOToyOS4xODA5NDYzMTZaIn19 +-----END ROOT PUBLIC KEY----- diff --git a/client/internal/updater/reposign/doc.go b/client/internal/updater/reposign/doc.go new file mode 100644 index 000000000..660b9d11d --- /dev/null +++ b/client/internal/updater/reposign/doc.go @@ -0,0 +1,174 @@ +// Package reposign implements a cryptographic signing and verification system +// for NetBird software update artifacts. It provides a hierarchical key +// management system with support for key rotation, revocation, and secure +// artifact distribution. +// +// # Architecture +// +// The package uses a two-tier key hierarchy: +// +// - Root Keys: Long-lived keys that sign artifact keys. These are embedded +// in the client binary and establish the root of trust. Root keys should +// be kept offline and highly secured. +// +// - Artifact Keys: Short-lived keys that sign release artifacts (binaries, +// packages, etc.). These are rotated regularly and can be revoked if +// compromised. Artifact keys are signed by root keys and distributed via +// a public repository. +// +// This separation allows for operational flexibility: artifact keys can be +// rotated frequently without requiring client updates, while root keys remain +// stable and embedded in the software. +// +// # Cryptographic Primitives +// +// The package uses strong, modern cryptographic algorithms: +// - Ed25519: Fast, secure digital signatures (no timing attacks) +// - BLAKE2s-256: Fast cryptographic hash for artifacts +// - SHA-256: Key ID generation +// - JSON: Structured key and signature serialization +// - PEM: Standard key encoding format +// +// # Security Features +// +// Timestamp Binding: +// - All signatures include cryptographically-bound timestamps +// - Prevents replay attacks and enforces signature freshness +// - Clock skew tolerance: 5 minutes +// +// Key Expiration: +// - All keys have expiration times +// - Expired keys are automatically rejected +// - Signing with an expired key fails immediately +// +// Key Revocation: +// - Compromised keys can be revoked via a signed revocation list +// - Revocation list is checked during artifact validation +// - Revoked keys are filtered out before artifact verification +// +// # File Structure +// +// The package expects the following file layout in the key repository: +// +// signrepo/ +// artifact-key-pub.pem # Bundle of artifact public keys +// artifact-key-pub.pem.sig # Root signature of the bundle +// revocation-list.json # List of revoked key IDs +// revocation-list.json.sig # Root signature of revocation list +// +// And in the artifacts repository: +// +// releases/ +// v0.28.0/ +// netbird-linux-amd64 +// netbird-linux-amd64.sig # Artifact signature +// netbird-darwin-amd64 +// netbird-darwin-amd64.sig +// ... +// +// # Embedded Root Keys +// +// Root public keys are embedded in the client binary at compile time: +// - Production keys: certs/ directory +// - Development keys: certsdev/ directory +// +// The build tag determines which keys are embedded: +// - Production builds: //go:build !devartifactsign +// - Development builds: //go:build devartifactsign +// +// This ensures that development artifacts cannot be verified using production +// keys and vice versa. +// +// # Key Rotation Strategies +// +// Root Key Rotation: +// +// Root keys can be rotated without breaking existing clients by leveraging +// the multi-key verification system. The loadEmbeddedPublicKeys function +// reads ALL files from the certs/ directory and accepts signatures from ANY +// of the embedded root keys. +// +// To rotate root keys: +// +// 1. Generate a new root key pair: +// newRootKey, privPEM, pubPEM, err := GenerateRootKey(10 * 365 * 24 * time.Hour) +// +// 2. Add the new public key to the certs/ directory as a new file: +// certs/ +// root-pub-2024.pem # Old key (keep this!) +// root-pub-2025.pem # New key (add this) +// +// 3. Build new client versions with both keys embedded. The verification +// will accept signatures from either key. +// +// 4. Start signing new artifact keys with the new root key. Old clients +// with only the old root key will reject these, but new clients with +// both keys will accept them. +// +// Each file in certs/ can contain a single key or a bundle of keys (multiple +// PEM blocks). The system will parse all keys from all files and use them +// for verification. This provides maximum flexibility for key management. +// +// Important: Never remove all old root keys at once. Always maintain at least +// one overlapping key between releases to ensure smooth transitions. +// +// Artifact Key Rotation: +// +// Artifact keys should be rotated regularly (e.g., every 90 days) using the +// bundling mechanism. The BundleArtifactKeys function allows multiple artifact +// keys to be bundled together in a single signed package, and ValidateArtifact +// will accept signatures from ANY key in the bundle. +// +// To rotate artifact keys smoothly: +// +// 1. Generate a new artifact key while keeping the old one: +// newKey, newPrivPEM, newPubPEM, newSig, err := GenerateArtifactKey(rootKey, 90 * 24 * time.Hour) +// // Keep oldPubPEM and oldKey available +// +// 2. Create a bundle containing both old and new public keys +// +// 3. Upload the bundle and its signature to the key repository: +// signrepo/artifact-key-pub.pem # Contains both keys +// signrepo/artifact-key-pub.pem.sig # Root signature +// +// 4. Start signing new releases with the NEW key, but keep the bundle +// unchanged. Clients will download the bundle (containing both keys) +// and accept signatures from either key. +// +// Key bundle validation workflow: +// 1. Client downloads artifact-key-pub.pem and artifact-key-pub.pem.sig +// 2. ValidateArtifactKeys verifies the bundle signature with ANY embedded root key +// 3. ValidateArtifactKeys parses all public keys from the bundle +// 4. ValidateArtifactKeys filters out expired or revoked keys +// 5. When verifying an artifact, ValidateArtifact tries each key until one succeeds +// +// This multi-key acceptance model enables overlapping validity periods and +// smooth transitions without client update requirements. +// +// # Best Practices +// +// Root Key Management: +// - Generate root keys offline on an air-gapped machine +// - Store root private keys in hardware security modules (HSM) if possible +// - Use separate root keys for production and development +// - Rotate root keys infrequently (e.g., every 5-10 years) +// - Plan for root key rotation: embed multiple root public keys +// +// Artifact Key Management: +// - Rotate artifact keys regularly (e.g., every 90 days) +// - Use separate artifact keys for different release channels if needed +// - Revoke keys immediately upon suspected compromise +// - Bundle multiple artifact keys to enable smooth rotation +// +// Signing Process: +// - Sign artifacts in a secure CI/CD environment +// - Never commit private keys to version control +// - Use environment variables or secret management for keys +// - Verify signatures immediately after signing +// +// Distribution: +// - Serve keys and revocation lists from a reliable CDN +// - Use HTTPS for all key and artifact downloads +// - Monitor download failures and signature verification failures +// - Keep revocation list up to date +package reposign diff --git a/client/internal/updater/reposign/embed_dev.go b/client/internal/updater/reposign/embed_dev.go new file mode 100644 index 000000000..ef8f77373 --- /dev/null +++ b/client/internal/updater/reposign/embed_dev.go @@ -0,0 +1,10 @@ +//go:build devartifactsign + +package reposign + +import "embed" + +//go:embed certsdev +var embeddedCerts embed.FS + +const embeddedCertsDir = "certsdev" diff --git a/client/internal/updater/reposign/embed_prod.go b/client/internal/updater/reposign/embed_prod.go new file mode 100644 index 000000000..91530e5f4 --- /dev/null +++ b/client/internal/updater/reposign/embed_prod.go @@ -0,0 +1,10 @@ +//go:build !devartifactsign + +package reposign + +import "embed" + +//go:embed certs +var embeddedCerts embed.FS + +const embeddedCertsDir = "certs" diff --git a/client/internal/updater/reposign/key.go b/client/internal/updater/reposign/key.go new file mode 100644 index 000000000..bedfef70d --- /dev/null +++ b/client/internal/updater/reposign/key.go @@ -0,0 +1,171 @@ +package reposign + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "time" +) + +const ( + maxClockSkew = 5 * time.Minute +) + +// KeyID is a unique identifier for a Key (first 8 bytes of SHA-256 of public Key) +type KeyID [8]byte + +// computeKeyID generates a unique ID from a public Key +func computeKeyID(pub ed25519.PublicKey) KeyID { + h := sha256.Sum256(pub) + var id KeyID + copy(id[:], h[:8]) + return id +} + +// MarshalJSON implements json.Marshaler for KeyID +func (k KeyID) MarshalJSON() ([]byte, error) { + return json.Marshal(k.String()) +} + +// UnmarshalJSON implements json.Unmarshaler for KeyID +func (k *KeyID) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + + parsed, err := ParseKeyID(s) + if err != nil { + return err + } + + *k = parsed + return nil +} + +// ParseKeyID parses a hex string (16 hex chars = 8 bytes) into a KeyID. +func ParseKeyID(s string) (KeyID, error) { + var id KeyID + if len(s) != 16 { + return id, fmt.Errorf("invalid KeyID length: got %d, want 16 hex chars (8 bytes)", len(s)) + } + + b, err := hex.DecodeString(s) + if err != nil { + return id, fmt.Errorf("failed to decode KeyID: %w", err) + } + + copy(id[:], b) + return id, nil +} + +func (k KeyID) String() string { + return fmt.Sprintf("%x", k[:]) +} + +// KeyMetadata contains versioning and lifecycle information for a Key +type KeyMetadata struct { + ID KeyID `json:"id"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at,omitempty"` // Optional expiration +} + +// PublicKey wraps a public Key with its Metadata +type PublicKey struct { + Key ed25519.PublicKey + Metadata KeyMetadata +} + +func parsePublicKeyBundle(bundle []byte, typeTag string) ([]PublicKey, error) { + var keys []PublicKey + for len(bundle) > 0 { + keyInfo, rest, err := parsePublicKey(bundle, typeTag) + if err != nil { + return nil, err + } + keys = append(keys, keyInfo) + bundle = rest + } + if len(keys) == 0 { + return nil, errors.New("no keys found in bundle") + } + return keys, nil +} + +func parsePublicKey(data []byte, typeTag string) (PublicKey, []byte, error) { + b, rest := pem.Decode(data) + if b == nil { + return PublicKey{}, nil, errors.New("failed to decode PEM data") + } + if b.Type != typeTag { + return PublicKey{}, nil, fmt.Errorf("PEM type is %q, want %q", b.Type, typeTag) + } + + // Unmarshal JSON-embedded format + var pub PublicKey + if err := json.Unmarshal(b.Bytes, &pub); err != nil { + return PublicKey{}, nil, fmt.Errorf("failed to unmarshal public key: %w", err) + } + + // Validate key length + if len(pub.Key) != ed25519.PublicKeySize { + return PublicKey{}, nil, fmt.Errorf("incorrect Ed25519 public key size: expected %d, got %d", + ed25519.PublicKeySize, len(pub.Key)) + } + + // Always recompute ID to ensure integrity + pub.Metadata.ID = computeKeyID(pub.Key) + + return pub, rest, nil +} + +type PrivateKey struct { + Key ed25519.PrivateKey + Metadata KeyMetadata +} + +func parsePrivateKey(data []byte, typeTag string) (PrivateKey, error) { + b, rest := pem.Decode(data) + if b == nil { + return PrivateKey{}, errors.New("failed to decode PEM data") + } + if len(rest) > 0 { + return PrivateKey{}, errors.New("trailing PEM data") + } + if b.Type != typeTag { + return PrivateKey{}, fmt.Errorf("PEM type is %q, want %q", b.Type, typeTag) + } + + // Unmarshal JSON-embedded format + var pk PrivateKey + if err := json.Unmarshal(b.Bytes, &pk); err != nil { + return PrivateKey{}, fmt.Errorf("failed to unmarshal private key: %w", err) + } + + // Validate key length + if len(pk.Key) != ed25519.PrivateKeySize { + return PrivateKey{}, fmt.Errorf("incorrect Ed25519 private key size: expected %d, got %d", + ed25519.PrivateKeySize, len(pk.Key)) + } + + return pk, nil +} + +func verifyAny(publicRootKeys []PublicKey, msg, sig []byte) bool { + // Verify with root keys + var rootKeys []ed25519.PublicKey + for _, r := range publicRootKeys { + rootKeys = append(rootKeys, r.Key) + } + + for _, k := range rootKeys { + if ed25519.Verify(k, msg, sig) { + return true + } + } + return false +} diff --git a/client/internal/updater/reposign/key_test.go b/client/internal/updater/reposign/key_test.go new file mode 100644 index 000000000..f8e1676fb --- /dev/null +++ b/client/internal/updater/reposign/key_test.go @@ -0,0 +1,636 @@ +package reposign + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/json" + "encoding/pem" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test KeyID functions + +func TestComputeKeyID(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + keyID := computeKeyID(pub) + + // Verify it's the first 8 bytes of SHA-256 + h := sha256.Sum256(pub) + expectedID := KeyID{} + copy(expectedID[:], h[:8]) + + assert.Equal(t, expectedID, keyID) +} + +func TestComputeKeyID_Deterministic(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + // Computing KeyID multiple times should give the same result + keyID1 := computeKeyID(pub) + keyID2 := computeKeyID(pub) + + assert.Equal(t, keyID1, keyID2) +} + +func TestComputeKeyID_DifferentKeys(t *testing.T) { + pub1, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + pub2, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + keyID1 := computeKeyID(pub1) + keyID2 := computeKeyID(pub2) + + // Different keys should produce different IDs + assert.NotEqual(t, keyID1, keyID2) +} + +func TestParseKeyID_Valid(t *testing.T) { + hexStr := "0123456789abcdef" + + keyID, err := ParseKeyID(hexStr) + require.NoError(t, err) + + expected := KeyID{0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef} + assert.Equal(t, expected, keyID) +} + +func TestParseKeyID_InvalidLength(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"too short", "01234567"}, + {"too long", "0123456789abcdef00"}, + {"empty", ""}, + {"odd length", "0123456789abcde"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseKeyID(tt.input) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid KeyID length") + }) + } +} + +func TestParseKeyID_InvalidHex(t *testing.T) { + invalidHex := "0123456789abcxyz" // 'xyz' are not valid hex + + _, err := ParseKeyID(invalidHex) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to decode KeyID") +} + +func TestKeyID_String(t *testing.T) { + keyID := KeyID{0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef} + + str := keyID.String() + assert.Equal(t, "0123456789abcdef", str) +} + +func TestKeyID_RoundTrip(t *testing.T) { + original := "fedcba9876543210" + + keyID, err := ParseKeyID(original) + require.NoError(t, err) + + result := keyID.String() + assert.Equal(t, original, result) +} + +func TestKeyID_ZeroValue(t *testing.T) { + keyID := KeyID{} + str := keyID.String() + assert.Equal(t, "0000000000000000", str) +} + +// Test KeyMetadata + +func TestKeyMetadata_JSONMarshaling(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + metadata := KeyMetadata{ + ID: computeKeyID(pub), + CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), + ExpiresAt: time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC), + } + + jsonData, err := json.Marshal(metadata) + require.NoError(t, err) + + var decoded KeyMetadata + err = json.Unmarshal(jsonData, &decoded) + require.NoError(t, err) + + assert.Equal(t, metadata.ID, decoded.ID) + assert.Equal(t, metadata.CreatedAt.Unix(), decoded.CreatedAt.Unix()) + assert.Equal(t, metadata.ExpiresAt.Unix(), decoded.ExpiresAt.Unix()) +} + +func TestKeyMetadata_NoExpiration(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + metadata := KeyMetadata{ + ID: computeKeyID(pub), + CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), + ExpiresAt: time.Time{}, // Zero value = no expiration + } + + jsonData, err := json.Marshal(metadata) + require.NoError(t, err) + + var decoded KeyMetadata + err = json.Unmarshal(jsonData, &decoded) + require.NoError(t, err) + + assert.True(t, decoded.ExpiresAt.IsZero()) +} + +// Test PublicKey + +func TestPublicKey_JSONMarshaling(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + pubKey := PublicKey{ + Key: pub, + Metadata: KeyMetadata{ + ID: computeKeyID(pub), + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().Add(365 * 24 * time.Hour).UTC(), + }, + } + + jsonData, err := json.Marshal(pubKey) + require.NoError(t, err) + + var decoded PublicKey + err = json.Unmarshal(jsonData, &decoded) + require.NoError(t, err) + + assert.Equal(t, pubKey.Key, decoded.Key) + assert.Equal(t, pubKey.Metadata.ID, decoded.Metadata.ID) +} + +// Test parsePublicKey + +func TestParsePublicKey_Valid(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + metadata := KeyMetadata{ + ID: computeKeyID(pub), + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().Add(365 * 24 * time.Hour).UTC(), + } + + pubKey := PublicKey{ + Key: pub, + Metadata: metadata, + } + + // Marshal to JSON + jsonData, err := json.Marshal(pubKey) + require.NoError(t, err) + + // Encode to PEM + pemData := pem.EncodeToMemory(&pem.Block{ + Type: tagRootPublic, + Bytes: jsonData, + }) + + // Parse it back + parsed, rest, err := parsePublicKey(pemData, tagRootPublic) + require.NoError(t, err) + assert.Empty(t, rest) + assert.Equal(t, pub, parsed.Key) + assert.Equal(t, metadata.ID, parsed.Metadata.ID) +} + +func TestParsePublicKey_InvalidPEM(t *testing.T) { + invalidPEM := []byte("not a PEM") + + _, _, err := parsePublicKey(invalidPEM, tagRootPublic) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to decode PEM") +} + +func TestParsePublicKey_WrongType(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + pubKey := PublicKey{ + Key: pub, + Metadata: KeyMetadata{ + ID: computeKeyID(pub), + CreatedAt: time.Now().UTC(), + }, + } + + jsonData, err := json.Marshal(pubKey) + require.NoError(t, err) + + // Encode with wrong type + pemData := pem.EncodeToMemory(&pem.Block{ + Type: "WRONG TYPE", + Bytes: jsonData, + }) + + _, _, err = parsePublicKey(pemData, tagRootPublic) + assert.Error(t, err) + assert.Contains(t, err.Error(), "PEM type") +} + +func TestParsePublicKey_InvalidJSON(t *testing.T) { + pemData := pem.EncodeToMemory(&pem.Block{ + Type: tagRootPublic, + Bytes: []byte("invalid json"), + }) + + _, _, err := parsePublicKey(pemData, tagRootPublic) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to unmarshal") +} + +func TestParsePublicKey_InvalidKeySize(t *testing.T) { + // Create a public key with wrong size + pubKey := PublicKey{ + Key: []byte{0x01, 0x02, 0x03}, // Too short + Metadata: KeyMetadata{ + ID: KeyID{}, + CreatedAt: time.Now().UTC(), + }, + } + + jsonData, err := json.Marshal(pubKey) + require.NoError(t, err) + + pemData := pem.EncodeToMemory(&pem.Block{ + Type: tagRootPublic, + Bytes: jsonData, + }) + + _, _, err = parsePublicKey(pemData, tagRootPublic) + assert.Error(t, err) + assert.Contains(t, err.Error(), "incorrect Ed25519 public key size") +} + +func TestParsePublicKey_IDRecomputation(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + // Create a public key with WRONG ID + wrongID := KeyID{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff} + pubKey := PublicKey{ + Key: pub, + Metadata: KeyMetadata{ + ID: wrongID, + CreatedAt: time.Now().UTC(), + }, + } + + jsonData, err := json.Marshal(pubKey) + require.NoError(t, err) + + pemData := pem.EncodeToMemory(&pem.Block{ + Type: tagRootPublic, + Bytes: jsonData, + }) + + // Parse should recompute the correct ID + parsed, _, err := parsePublicKey(pemData, tagRootPublic) + require.NoError(t, err) + + correctID := computeKeyID(pub) + assert.Equal(t, correctID, parsed.Metadata.ID) + assert.NotEqual(t, wrongID, parsed.Metadata.ID) +} + +// Test parsePublicKeyBundle + +func TestParsePublicKeyBundle_Single(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + pubKey := PublicKey{ + Key: pub, + Metadata: KeyMetadata{ + ID: computeKeyID(pub), + CreatedAt: time.Now().UTC(), + }, + } + + jsonData, err := json.Marshal(pubKey) + require.NoError(t, err) + + pemData := pem.EncodeToMemory(&pem.Block{ + Type: tagRootPublic, + Bytes: jsonData, + }) + + keys, err := parsePublicKeyBundle(pemData, tagRootPublic) + require.NoError(t, err) + assert.Len(t, keys, 1) + assert.Equal(t, pub, keys[0].Key) +} + +func TestParsePublicKeyBundle_Multiple(t *testing.T) { + var bundle []byte + + // Create 3 keys + for i := 0; i < 3; i++ { + pub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + pubKey := PublicKey{ + Key: pub, + Metadata: KeyMetadata{ + ID: computeKeyID(pub), + CreatedAt: time.Now().UTC(), + }, + } + + jsonData, err := json.Marshal(pubKey) + require.NoError(t, err) + + pemData := pem.EncodeToMemory(&pem.Block{ + Type: tagRootPublic, + Bytes: jsonData, + }) + + bundle = append(bundle, pemData...) + } + + keys, err := parsePublicKeyBundle(bundle, tagRootPublic) + require.NoError(t, err) + assert.Len(t, keys, 3) +} + +func TestParsePublicKeyBundle_Empty(t *testing.T) { + _, err := parsePublicKeyBundle([]byte{}, tagRootPublic) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no keys found") +} + +func TestParsePublicKeyBundle_Invalid(t *testing.T) { + _, err := parsePublicKeyBundle([]byte("invalid data"), tagRootPublic) + assert.Error(t, err) +} + +// Test PrivateKey + +func TestPrivateKey_JSONMarshaling(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + privKey := PrivateKey{ + Key: priv, + Metadata: KeyMetadata{ + ID: computeKeyID(pub), + CreatedAt: time.Now().UTC(), + }, + } + + jsonData, err := json.Marshal(privKey) + require.NoError(t, err) + + var decoded PrivateKey + err = json.Unmarshal(jsonData, &decoded) + require.NoError(t, err) + + assert.Equal(t, privKey.Key, decoded.Key) + assert.Equal(t, privKey.Metadata.ID, decoded.Metadata.ID) +} + +// Test parsePrivateKey + +func TestParsePrivateKey_Valid(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + privKey := PrivateKey{ + Key: priv, + Metadata: KeyMetadata{ + ID: computeKeyID(pub), + CreatedAt: time.Now().UTC(), + }, + } + + jsonData, err := json.Marshal(privKey) + require.NoError(t, err) + + pemData := pem.EncodeToMemory(&pem.Block{ + Type: tagRootPrivate, + Bytes: jsonData, + }) + + parsed, err := parsePrivateKey(pemData, tagRootPrivate) + require.NoError(t, err) + assert.Equal(t, priv, parsed.Key) +} + +func TestParsePrivateKey_InvalidPEM(t *testing.T) { + _, err := parsePrivateKey([]byte("not a PEM"), tagRootPrivate) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to decode PEM") +} + +func TestParsePrivateKey_TrailingData(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + privKey := PrivateKey{ + Key: priv, + Metadata: KeyMetadata{ + ID: computeKeyID(pub), + CreatedAt: time.Now().UTC(), + }, + } + + jsonData, err := json.Marshal(privKey) + require.NoError(t, err) + + pemData := pem.EncodeToMemory(&pem.Block{ + Type: tagRootPrivate, + Bytes: jsonData, + }) + + // Add trailing data + pemData = append(pemData, []byte("extra data")...) + + _, err = parsePrivateKey(pemData, tagRootPrivate) + assert.Error(t, err) + assert.Contains(t, err.Error(), "trailing PEM data") +} + +func TestParsePrivateKey_WrongType(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + privKey := PrivateKey{ + Key: priv, + Metadata: KeyMetadata{ + ID: computeKeyID(pub), + CreatedAt: time.Now().UTC(), + }, + } + + jsonData, err := json.Marshal(privKey) + require.NoError(t, err) + + pemData := pem.EncodeToMemory(&pem.Block{ + Type: "WRONG TYPE", + Bytes: jsonData, + }) + + _, err = parsePrivateKey(pemData, tagRootPrivate) + assert.Error(t, err) + assert.Contains(t, err.Error(), "PEM type") +} + +func TestParsePrivateKey_InvalidKeySize(t *testing.T) { + privKey := PrivateKey{ + Key: []byte{0x01, 0x02, 0x03}, // Too short + Metadata: KeyMetadata{ + ID: KeyID{}, + CreatedAt: time.Now().UTC(), + }, + } + + jsonData, err := json.Marshal(privKey) + require.NoError(t, err) + + pemData := pem.EncodeToMemory(&pem.Block{ + Type: tagRootPrivate, + Bytes: jsonData, + }) + + _, err = parsePrivateKey(pemData, tagRootPrivate) + assert.Error(t, err) + assert.Contains(t, err.Error(), "incorrect Ed25519 private key size") +} + +// Test verifyAny + +func TestVerifyAny_ValidSignature(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + message := []byte("test message") + signature := ed25519.Sign(priv, message) + + rootKeys := []PublicKey{ + { + Key: pub, + Metadata: KeyMetadata{ + ID: computeKeyID(pub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + result := verifyAny(rootKeys, message, signature) + assert.True(t, result) +} + +func TestVerifyAny_InvalidSignature(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + message := []byte("test message") + invalidSignature := make([]byte, ed25519.SignatureSize) + + rootKeys := []PublicKey{ + { + Key: pub, + Metadata: KeyMetadata{ + ID: computeKeyID(pub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + result := verifyAny(rootKeys, message, invalidSignature) + assert.False(t, result) +} + +func TestVerifyAny_MultipleKeys(t *testing.T) { + // Create 3 key pairs + pub1, priv1, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + pub2, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + pub3, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + message := []byte("test message") + signature := ed25519.Sign(priv1, message) + + rootKeys := []PublicKey{ + {Key: pub2, Metadata: KeyMetadata{ID: computeKeyID(pub2)}}, + {Key: pub1, Metadata: KeyMetadata{ID: computeKeyID(pub1)}}, // Correct key in middle + {Key: pub3, Metadata: KeyMetadata{ID: computeKeyID(pub3)}}, + } + + result := verifyAny(rootKeys, message, signature) + assert.True(t, result) +} + +func TestVerifyAny_NoMatchingKey(t *testing.T) { + _, priv1, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + pub2, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + message := []byte("test message") + signature := ed25519.Sign(priv1, message) + + // Only include pub2, not pub1 + rootKeys := []PublicKey{ + {Key: pub2, Metadata: KeyMetadata{ID: computeKeyID(pub2)}}, + } + + result := verifyAny(rootKeys, message, signature) + assert.False(t, result) +} + +func TestVerifyAny_EmptyKeys(t *testing.T) { + message := []byte("test message") + signature := make([]byte, ed25519.SignatureSize) + + result := verifyAny([]PublicKey{}, message, signature) + assert.False(t, result) +} + +func TestVerifyAny_TamperedMessage(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + message := []byte("test message") + signature := ed25519.Sign(priv, message) + + rootKeys := []PublicKey{ + {Key: pub, Metadata: KeyMetadata{ID: computeKeyID(pub)}}, + } + + // Verify with different message + tamperedMessage := []byte("different message") + result := verifyAny(rootKeys, tamperedMessage, signature) + assert.False(t, result) +} diff --git a/client/internal/updater/reposign/revocation.go b/client/internal/updater/reposign/revocation.go new file mode 100644 index 000000000..e679e212f --- /dev/null +++ b/client/internal/updater/reposign/revocation.go @@ -0,0 +1,229 @@ +package reposign + +import ( + "crypto/ed25519" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + maxRevocationSignatureAge = 10 * 365 * 24 * time.Hour + defaultRevocationListExpiration = 365 * 24 * time.Hour +) + +type RevocationList struct { + Revoked map[KeyID]time.Time `json:"revoked"` // KeyID -> revocation time + LastUpdated time.Time `json:"last_updated"` // When the list was last modified + ExpiresAt time.Time `json:"expires_at"` // When the list expires +} + +func (rl RevocationList) MarshalJSON() ([]byte, error) { + // Convert map[KeyID]time.Time to map[string]time.Time + strMap := make(map[string]time.Time, len(rl.Revoked)) + for k, v := range rl.Revoked { + strMap[k.String()] = v + } + + return json.Marshal(map[string]interface{}{ + "revoked": strMap, + "last_updated": rl.LastUpdated, + "expires_at": rl.ExpiresAt, + }) +} + +func (rl *RevocationList) UnmarshalJSON(data []byte) error { + var temp struct { + Revoked map[string]time.Time `json:"revoked"` + LastUpdated time.Time `json:"last_updated"` + ExpiresAt time.Time `json:"expires_at"` + Version int `json:"version"` + } + + if err := json.Unmarshal(data, &temp); err != nil { + return err + } + + // Convert map[string]time.Time back to map[KeyID]time.Time + rl.Revoked = make(map[KeyID]time.Time, len(temp.Revoked)) + for k, v := range temp.Revoked { + kid, err := ParseKeyID(k) + if err != nil { + return fmt.Errorf("failed to parse KeyID %q: %w", k, err) + } + rl.Revoked[kid] = v + } + + rl.LastUpdated = temp.LastUpdated + rl.ExpiresAt = temp.ExpiresAt + + return nil +} + +func ParseRevocationList(data []byte) (*RevocationList, error) { + var rl RevocationList + if err := json.Unmarshal(data, &rl); err != nil { + return nil, fmt.Errorf("failed to unmarshal revocation list: %w", err) + } + + // Initialize the map if it's nil (in case of empty JSON object) + if rl.Revoked == nil { + rl.Revoked = make(map[KeyID]time.Time) + } + + if rl.LastUpdated.IsZero() { + return nil, fmt.Errorf("revocation list missing last_updated timestamp") + } + + if rl.ExpiresAt.IsZero() { + return nil, fmt.Errorf("revocation list missing expires_at timestamp") + } + + return &rl, nil +} + +func ValidateRevocationList(publicRootKeys []PublicKey, data []byte, signature Signature) (*RevocationList, error) { + revoList, err := ParseRevocationList(data) + if err != nil { + log.Debugf("failed to parse revocation list: %s", err) + return nil, err + } + + now := time.Now().UTC() + + // Validate signature timestamp + if signature.Timestamp.After(now.Add(maxClockSkew)) { + err := fmt.Errorf("revocation signature timestamp is in the future: %v", signature.Timestamp) + log.Debugf("revocation list signature error: %v", err) + return nil, err + } + + if now.Sub(signature.Timestamp) > maxRevocationSignatureAge { + err := fmt.Errorf("revocation list signature is too old: %v (created %v)", + now.Sub(signature.Timestamp), signature.Timestamp) + log.Debugf("revocation list signature error: %v", err) + return nil, err + } + + // Ensure LastUpdated is not in the future (with clock skew tolerance) + if revoList.LastUpdated.After(now.Add(maxClockSkew)) { + err := fmt.Errorf("revocation list LastUpdated is in the future: %v", revoList.LastUpdated) + log.Errorf("rejecting future-dated revocation list: %v", err) + return nil, err + } + + // Check if the revocation list has expired + if now.After(revoList.ExpiresAt) { + err := fmt.Errorf("revocation list expired at %v (current time: %v)", revoList.ExpiresAt, now) + log.Errorf("rejecting expired revocation list: %v", err) + return nil, err + } + + // Ensure ExpiresAt is not in the future by more than the expected expiration window + // (allows some clock skew but prevents maliciously long expiration times) + if revoList.ExpiresAt.After(now.Add(maxRevocationSignatureAge)) { + err := fmt.Errorf("revocation list ExpiresAt is too far in the future: %v", revoList.ExpiresAt) + log.Errorf("rejecting revocation list with invalid expiration: %v", err) + return nil, err + } + + // Validate signature timestamp is close to LastUpdated + // (prevents signing old lists with new timestamps) + timeDiff := signature.Timestamp.Sub(revoList.LastUpdated).Abs() + if timeDiff > maxClockSkew { + err := fmt.Errorf("signature timestamp %v differs too much from list LastUpdated %v (diff: %v)", + signature.Timestamp, revoList.LastUpdated, timeDiff) + log.Errorf("timestamp mismatch in revocation list: %v", err) + return nil, err + } + + // Reconstruct the signed message: revocation_list_data || timestamp || version + msg := make([]byte, 0, len(data)+8) + msg = append(msg, data...) + msg = binary.LittleEndian.AppendUint64(msg, uint64(signature.Timestamp.Unix())) + + if !verifyAny(publicRootKeys, msg, signature.Signature) { + return nil, errors.New("revocation list verification failed") + } + return revoList, nil +} + +func CreateRevocationList(privateRootKey RootKey, expiration time.Duration) ([]byte, []byte, error) { + now := time.Now() + rl := RevocationList{ + Revoked: make(map[KeyID]time.Time), + LastUpdated: now.UTC(), + ExpiresAt: now.Add(expiration).UTC(), + } + + signature, err := signRevocationList(privateRootKey, rl) + if err != nil { + return nil, nil, fmt.Errorf("failed to sign revocation list: %w", err) + } + + rlData, err := json.Marshal(&rl) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal revocation list: %w", err) + } + + signData, err := json.Marshal(signature) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal signature: %w", err) + } + + return rlData, signData, nil +} + +func ExtendRevocationList(privateRootKey RootKey, rl RevocationList, kid KeyID, expiration time.Duration) ([]byte, []byte, error) { + now := time.Now().UTC() + + rl.Revoked[kid] = now + rl.LastUpdated = now + rl.ExpiresAt = now.Add(expiration) + + signature, err := signRevocationList(privateRootKey, rl) + if err != nil { + return nil, nil, fmt.Errorf("failed to sign revocation list: %w", err) + } + + rlData, err := json.Marshal(&rl) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal revocation list: %w", err) + } + + signData, err := json.Marshal(signature) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal signature: %w", err) + } + + return rlData, signData, nil +} + +func signRevocationList(privateRootKey RootKey, rl RevocationList) (*Signature, error) { + data, err := json.Marshal(rl) + if err != nil { + return nil, fmt.Errorf("failed to marshal revocation list for signing: %w", err) + } + + timestamp := time.Now().UTC() + + msg := make([]byte, 0, len(data)+8) + msg = append(msg, data...) + msg = binary.LittleEndian.AppendUint64(msg, uint64(timestamp.Unix())) + + sig := ed25519.Sign(privateRootKey.Key, msg) + + signature := &Signature{ + Signature: sig, + Timestamp: timestamp, + KeyID: privateRootKey.Metadata.ID, + Algorithm: "ed25519", + HashAlgo: "sha512", + } + + return signature, nil +} diff --git a/client/internal/updater/reposign/revocation_test.go b/client/internal/updater/reposign/revocation_test.go new file mode 100644 index 000000000..d6d748f3d --- /dev/null +++ b/client/internal/updater/reposign/revocation_test.go @@ -0,0 +1,860 @@ +package reposign + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test RevocationList marshaling/unmarshaling + +func TestRevocationList_MarshalJSON(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + keyID := computeKeyID(pub) + revokedTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + lastUpdated := time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC) + expiresAt := time.Date(2024, 4, 15, 11, 0, 0, 0, time.UTC) + + rl := &RevocationList{ + Revoked: map[KeyID]time.Time{ + keyID: revokedTime, + }, + LastUpdated: lastUpdated, + ExpiresAt: expiresAt, + } + + jsonData, err := json.Marshal(rl) + require.NoError(t, err) + + // Verify it can be unmarshaled back + var decoded map[string]interface{} + err = json.Unmarshal(jsonData, &decoded) + require.NoError(t, err) + + assert.Contains(t, decoded, "revoked") + assert.Contains(t, decoded, "last_updated") + assert.Contains(t, decoded, "expires_at") +} + +func TestRevocationList_UnmarshalJSON(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + keyID := computeKeyID(pub) + revokedTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + lastUpdated := time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC) + + jsonData := map[string]interface{}{ + "revoked": map[string]string{ + keyID.String(): revokedTime.Format(time.RFC3339), + }, + "last_updated": lastUpdated.Format(time.RFC3339), + } + + jsonBytes, err := json.Marshal(jsonData) + require.NoError(t, err) + + var rl RevocationList + err = json.Unmarshal(jsonBytes, &rl) + require.NoError(t, err) + + assert.Len(t, rl.Revoked, 1) + assert.Contains(t, rl.Revoked, keyID) + assert.Equal(t, lastUpdated.Unix(), rl.LastUpdated.Unix()) +} + +func TestRevocationList_MarshalUnmarshal_Roundtrip(t *testing.T) { + pub1, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + pub2, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + keyID1 := computeKeyID(pub1) + keyID2 := computeKeyID(pub2) + + original := &RevocationList{ + Revoked: map[KeyID]time.Time{ + keyID1: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC), + keyID2: time.Date(2024, 2, 20, 14, 45, 0, 0, time.UTC), + }, + LastUpdated: time.Date(2024, 2, 20, 15, 0, 0, 0, time.UTC), + } + + // Marshal + jsonData, err := original.MarshalJSON() + require.NoError(t, err) + + // Unmarshal + var decoded RevocationList + err = decoded.UnmarshalJSON(jsonData) + require.NoError(t, err) + + // Verify + assert.Len(t, decoded.Revoked, 2) + assert.Equal(t, original.Revoked[keyID1].Unix(), decoded.Revoked[keyID1].Unix()) + assert.Equal(t, original.Revoked[keyID2].Unix(), decoded.Revoked[keyID2].Unix()) + assert.Equal(t, original.LastUpdated.Unix(), decoded.LastUpdated.Unix()) +} + +func TestRevocationList_UnmarshalJSON_InvalidKeyID(t *testing.T) { + jsonData := []byte(`{ + "revoked": { + "invalid_key_id": "2024-01-15T10:30:00Z" + }, + "last_updated": "2024-01-15T11:00:00Z" + }`) + + var rl RevocationList + err := json.Unmarshal(jsonData, &rl) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse KeyID") +} + +func TestRevocationList_EmptyRevoked(t *testing.T) { + rl := &RevocationList{ + Revoked: make(map[KeyID]time.Time), + LastUpdated: time.Now().UTC(), + } + + jsonData, err := rl.MarshalJSON() + require.NoError(t, err) + + var decoded RevocationList + err = decoded.UnmarshalJSON(jsonData) + require.NoError(t, err) + + assert.Empty(t, decoded.Revoked) + assert.NotNil(t, decoded.Revoked) +} + +// Test ParseRevocationList + +func TestParseRevocationList_Valid(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + keyID := computeKeyID(pub) + revokedTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + lastUpdated := time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC) + + rl := RevocationList{ + Revoked: map[KeyID]time.Time{ + keyID: revokedTime, + }, + LastUpdated: lastUpdated, + ExpiresAt: time.Date(2025, 2, 20, 14, 45, 0, 0, time.UTC), + } + + jsonData, err := rl.MarshalJSON() + require.NoError(t, err) + + parsed, err := ParseRevocationList(jsonData) + require.NoError(t, err) + assert.NotNil(t, parsed) + assert.Len(t, parsed.Revoked, 1) + assert.Equal(t, lastUpdated.Unix(), parsed.LastUpdated.Unix()) +} + +func TestParseRevocationList_InvalidJSON(t *testing.T) { + invalidJSON := []byte("not valid json") + + _, err := ParseRevocationList(invalidJSON) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to unmarshal") +} + +func TestParseRevocationList_MissingLastUpdated(t *testing.T) { + jsonData := []byte(`{ + "revoked": {} + }`) + + _, err := ParseRevocationList(jsonData) + assert.Error(t, err) + assert.Contains(t, err.Error(), "missing last_updated") +} + +func TestParseRevocationList_EmptyObject(t *testing.T) { + jsonData := []byte(`{}`) + + _, err := ParseRevocationList(jsonData) + assert.Error(t, err) + assert.Contains(t, err.Error(), "missing last_updated") +} + +func TestParseRevocationList_NilRevoked(t *testing.T) { + lastUpdated := time.Now().UTC() + expiresAt := lastUpdated.Add(90 * 24 * time.Hour) + jsonData := []byte(`{ + "last_updated": "` + lastUpdated.Format(time.RFC3339) + `", + "expires_at": "` + expiresAt.Format(time.RFC3339) + `" + }`) + + parsed, err := ParseRevocationList(jsonData) + require.NoError(t, err) + assert.NotNil(t, parsed.Revoked) + assert.Empty(t, parsed.Revoked) +} + +func TestParseRevocationList_MissingExpiresAt(t *testing.T) { + lastUpdated := time.Now().UTC() + jsonData := []byte(`{ + "revoked": {}, + "last_updated": "` + lastUpdated.Format(time.RFC3339) + `" + }`) + + _, err := ParseRevocationList(jsonData) + assert.Error(t, err) + assert.Contains(t, err.Error(), "missing expires_at") +} + +// Test ValidateRevocationList + +func TestValidateRevocationList_Valid(t *testing.T) { + // Generate root key + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + rootKeys := []PublicKey{ + { + Key: rootPub, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + // Create revocation list + rlData, sigData, err := CreateRevocationList(rootKey, defaultRevocationListExpiration) + require.NoError(t, err) + + signature, err := ParseSignature(sigData) + require.NoError(t, err) + + // Validate + rl, err := ValidateRevocationList(rootKeys, rlData, *signature) + require.NoError(t, err) + assert.NotNil(t, rl) + assert.Empty(t, rl.Revoked) +} + +func TestValidateRevocationList_InvalidSignature(t *testing.T) { + // Generate root key + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + rootKeys := []PublicKey{ + { + Key: rootPub, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + // Create revocation list + rlData, _, err := CreateRevocationList(rootKey, defaultRevocationListExpiration) + require.NoError(t, err) + + // Create invalid signature + invalidSig := Signature{ + Signature: make([]byte, 64), + Timestamp: time.Now().UTC(), + KeyID: computeKeyID(rootPub), + Algorithm: "ed25519", + HashAlgo: "sha512", + } + + // Validate should fail + _, err = ValidateRevocationList(rootKeys, rlData, invalidSig) + assert.Error(t, err) + assert.Contains(t, err.Error(), "verification failed") +} + +func TestValidateRevocationList_FutureTimestamp(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + rootKeys := []PublicKey{ + { + Key: rootPub, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + rlData, sigData, err := CreateRevocationList(rootKey, defaultRevocationListExpiration) + require.NoError(t, err) + + signature, err := ParseSignature(sigData) + require.NoError(t, err) + + // Modify timestamp to be in the future + signature.Timestamp = time.Now().UTC().Add(10 * time.Minute) + + _, err = ValidateRevocationList(rootKeys, rlData, *signature) + assert.Error(t, err) + assert.Contains(t, err.Error(), "in the future") +} + +func TestValidateRevocationList_TooOld(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + rootKeys := []PublicKey{ + { + Key: rootPub, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + rlData, sigData, err := CreateRevocationList(rootKey, defaultRevocationListExpiration) + require.NoError(t, err) + + signature, err := ParseSignature(sigData) + require.NoError(t, err) + + // Modify timestamp to be too old + signature.Timestamp = time.Now().UTC().Add(-20 * 365 * 24 * time.Hour) + + _, err = ValidateRevocationList(rootKeys, rlData, *signature) + assert.Error(t, err) + assert.Contains(t, err.Error(), "too old") +} + +func TestValidateRevocationList_InvalidJSON(t *testing.T) { + rootPub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKeys := []PublicKey{ + { + Key: rootPub, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + signature := Signature{ + Signature: make([]byte, 64), + Timestamp: time.Now().UTC(), + KeyID: computeKeyID(rootPub), + Algorithm: "ed25519", + HashAlgo: "sha512", + } + + _, err = ValidateRevocationList(rootKeys, []byte("invalid json"), signature) + assert.Error(t, err) +} + +func TestValidateRevocationList_FutureLastUpdated(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + rootKeys := []PublicKey{ + { + Key: rootPub, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + // Create revocation list with future LastUpdated + rl := RevocationList{ + Revoked: make(map[KeyID]time.Time), + LastUpdated: time.Now().UTC().Add(10 * time.Minute), + ExpiresAt: time.Now().UTC().Add(365 * 24 * time.Hour), + } + + rlData, err := json.Marshal(rl) + require.NoError(t, err) + + // Sign it + sig, err := signRevocationList(rootKey, rl) + require.NoError(t, err) + + _, err = ValidateRevocationList(rootKeys, rlData, *sig) + assert.Error(t, err) + assert.Contains(t, err.Error(), "LastUpdated is in the future") +} + +func TestValidateRevocationList_TimestampMismatch(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + rootKeys := []PublicKey{ + { + Key: rootPub, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + // Create revocation list with LastUpdated far in the past + rl := RevocationList{ + Revoked: make(map[KeyID]time.Time), + LastUpdated: time.Now().UTC().Add(-1 * time.Hour), + ExpiresAt: time.Now().UTC().Add(365 * 24 * time.Hour), + } + + rlData, err := json.Marshal(rl) + require.NoError(t, err) + + // Sign it with current timestamp + sig, err := signRevocationList(rootKey, rl) + require.NoError(t, err) + + // Modify signature timestamp to differ too much from LastUpdated + sig.Timestamp = time.Now().UTC() + + _, err = ValidateRevocationList(rootKeys, rlData, *sig) + assert.Error(t, err) + assert.Contains(t, err.Error(), "differs too much") +} + +func TestValidateRevocationList_Expired(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + rootKeys := []PublicKey{ + { + Key: rootPub, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + // Create revocation list that expired in the past + now := time.Now().UTC() + rl := RevocationList{ + Revoked: make(map[KeyID]time.Time), + LastUpdated: now.Add(-100 * 24 * time.Hour), + ExpiresAt: now.Add(-10 * 24 * time.Hour), // Expired 10 days ago + } + + rlData, err := json.Marshal(rl) + require.NoError(t, err) + + // Sign it + sig, err := signRevocationList(rootKey, rl) + require.NoError(t, err) + // Adjust signature timestamp to match LastUpdated + sig.Timestamp = rl.LastUpdated + + _, err = ValidateRevocationList(rootKeys, rlData, *sig) + assert.Error(t, err) + assert.Contains(t, err.Error(), "expired") +} + +func TestValidateRevocationList_ExpiresAtTooFarInFuture(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + rootKeys := []PublicKey{ + { + Key: rootPub, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + // Create revocation list with ExpiresAt too far in the future (beyond maxRevocationSignatureAge) + now := time.Now().UTC() + rl := RevocationList{ + Revoked: make(map[KeyID]time.Time), + LastUpdated: now, + ExpiresAt: now.Add(15 * 365 * 24 * time.Hour), // 15 years in the future + } + + rlData, err := json.Marshal(rl) + require.NoError(t, err) + + // Sign it + sig, err := signRevocationList(rootKey, rl) + require.NoError(t, err) + + _, err = ValidateRevocationList(rootKeys, rlData, *sig) + assert.Error(t, err) + assert.Contains(t, err.Error(), "too far in the future") +} + +// Test CreateRevocationList + +func TestCreateRevocationList_Valid(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + rlData, sigData, err := CreateRevocationList(rootKey, defaultRevocationListExpiration) + require.NoError(t, err) + assert.NotEmpty(t, rlData) + assert.NotEmpty(t, sigData) + + // Verify it can be parsed + rl, err := ParseRevocationList(rlData) + require.NoError(t, err) + assert.Empty(t, rl.Revoked) + assert.False(t, rl.LastUpdated.IsZero()) + + // Verify signature can be parsed + sig, err := ParseSignature(sigData) + require.NoError(t, err) + assert.NotEmpty(t, sig.Signature) +} + +// Test ExtendRevocationList + +func TestExtendRevocationList_AddKey(t *testing.T) { + // Generate root key + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + // Create empty revocation list + rlData, _, err := CreateRevocationList(rootKey, defaultRevocationListExpiration) + require.NoError(t, err) + + rl, err := ParseRevocationList(rlData) + require.NoError(t, err) + assert.Empty(t, rl.Revoked) + + // Generate a key to revoke + revokedPub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + revokedKeyID := computeKeyID(revokedPub) + + // Extend the revocation list + newRLData, newSigData, err := ExtendRevocationList(rootKey, *rl, revokedKeyID, defaultRevocationListExpiration) + require.NoError(t, err) + + // Verify the new list + newRL, err := ParseRevocationList(newRLData) + require.NoError(t, err) + assert.Len(t, newRL.Revoked, 1) + assert.Contains(t, newRL.Revoked, revokedKeyID) + + // Verify signature + sig, err := ParseSignature(newSigData) + require.NoError(t, err) + assert.NotEmpty(t, sig.Signature) +} + +func TestExtendRevocationList_MultipleKeys(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + // Create empty revocation list + rlData, _, err := CreateRevocationList(rootKey, defaultRevocationListExpiration) + require.NoError(t, err) + + rl, err := ParseRevocationList(rlData) + require.NoError(t, err) + + // Add first key + key1Pub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + key1ID := computeKeyID(key1Pub) + + rlData, _, err = ExtendRevocationList(rootKey, *rl, key1ID, defaultRevocationListExpiration) + require.NoError(t, err) + + rl, err = ParseRevocationList(rlData) + require.NoError(t, err) + assert.Len(t, rl.Revoked, 1) + + // Add second key + key2Pub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + key2ID := computeKeyID(key2Pub) + + rlData, _, err = ExtendRevocationList(rootKey, *rl, key2ID, defaultRevocationListExpiration) + require.NoError(t, err) + + rl, err = ParseRevocationList(rlData) + require.NoError(t, err) + assert.Len(t, rl.Revoked, 2) + assert.Contains(t, rl.Revoked, key1ID) + assert.Contains(t, rl.Revoked, key2ID) +} + +func TestExtendRevocationList_DuplicateKey(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + // Create empty revocation list + rlData, _, err := CreateRevocationList(rootKey, defaultRevocationListExpiration) + require.NoError(t, err) + + rl, err := ParseRevocationList(rlData) + require.NoError(t, err) + + // Add a key + keyPub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + keyID := computeKeyID(keyPub) + + rlData, _, err = ExtendRevocationList(rootKey, *rl, keyID, defaultRevocationListExpiration) + require.NoError(t, err) + + rl, err = ParseRevocationList(rlData) + require.NoError(t, err) + firstRevocationTime := rl.Revoked[keyID] + + // Wait a bit + time.Sleep(10 * time.Millisecond) + + // Add the same key again + rlData, _, err = ExtendRevocationList(rootKey, *rl, keyID, defaultRevocationListExpiration) + require.NoError(t, err) + + rl, err = ParseRevocationList(rlData) + require.NoError(t, err) + assert.Len(t, rl.Revoked, 1) + + // The revocation time should be updated + secondRevocationTime := rl.Revoked[keyID] + assert.True(t, secondRevocationTime.After(firstRevocationTime) || secondRevocationTime.Equal(firstRevocationTime)) +} + +func TestExtendRevocationList_UpdatesLastUpdated(t *testing.T) { + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + // Create revocation list + rlData, _, err := CreateRevocationList(rootKey, defaultRevocationListExpiration) + require.NoError(t, err) + + rl, err := ParseRevocationList(rlData) + require.NoError(t, err) + firstLastUpdated := rl.LastUpdated + + // Wait a bit + time.Sleep(10 * time.Millisecond) + + // Extend list + keyPub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + keyID := computeKeyID(keyPub) + + rlData, _, err = ExtendRevocationList(rootKey, *rl, keyID, defaultRevocationListExpiration) + require.NoError(t, err) + + rl, err = ParseRevocationList(rlData) + require.NoError(t, err) + + // LastUpdated should be updated + assert.True(t, rl.LastUpdated.After(firstLastUpdated)) +} + +// Integration test + +func TestRevocationList_FullWorkflow(t *testing.T) { + // Create root key + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + rootKey := RootKey{ + PrivateKey{ + Key: rootPriv, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + rootKeys := []PublicKey{ + { + Key: rootPub, + Metadata: KeyMetadata{ + ID: computeKeyID(rootPub), + CreatedAt: time.Now().UTC(), + }, + }, + } + + // Step 1: Create empty revocation list + rlData, sigData, err := CreateRevocationList(rootKey, defaultRevocationListExpiration) + require.NoError(t, err) + + // Step 2: Validate it + sig, err := ParseSignature(sigData) + require.NoError(t, err) + + rl, err := ValidateRevocationList(rootKeys, rlData, *sig) + require.NoError(t, err) + assert.Empty(t, rl.Revoked) + + // Step 3: Revoke a key + revokedPub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + revokedKeyID := computeKeyID(revokedPub) + + rlData, sigData, err = ExtendRevocationList(rootKey, *rl, revokedKeyID, defaultRevocationListExpiration) + require.NoError(t, err) + + // Step 4: Validate the extended list + sig, err = ParseSignature(sigData) + require.NoError(t, err) + + rl, err = ValidateRevocationList(rootKeys, rlData, *sig) + require.NoError(t, err) + assert.Len(t, rl.Revoked, 1) + assert.Contains(t, rl.Revoked, revokedKeyID) + + // Step 5: Verify the revocation time is reasonable + revTime := rl.Revoked[revokedKeyID] + now := time.Now().UTC() + assert.True(t, revTime.Before(now) || revTime.Equal(now)) + assert.True(t, now.Sub(revTime) < time.Minute) +} diff --git a/client/internal/updater/reposign/root.go b/client/internal/updater/reposign/root.go new file mode 100644 index 000000000..2c3ca54a0 --- /dev/null +++ b/client/internal/updater/reposign/root.go @@ -0,0 +1,120 @@ +package reposign + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/binary" + "encoding/json" + "encoding/pem" + "fmt" + "time" +) + +const ( + tagRootPrivate = "ROOT PRIVATE KEY" + tagRootPublic = "ROOT PUBLIC KEY" +) + +// RootKey is a root Key used to sign signing keys +type RootKey struct { + PrivateKey +} + +func (k RootKey) String() string { + return fmt.Sprintf( + "RootKey[ID=%s, CreatedAt=%s, ExpiresAt=%s]", + k.Metadata.ID, + k.Metadata.CreatedAt.Format(time.RFC3339), + k.Metadata.ExpiresAt.Format(time.RFC3339), + ) +} + +func ParseRootKey(privKeyPEM []byte) (*RootKey, error) { + pk, err := parsePrivateKey(privKeyPEM, tagRootPrivate) + if err != nil { + return nil, fmt.Errorf("failed to parse root Key: %w", err) + } + return &RootKey{pk}, nil +} + +// ParseRootPublicKey parses a root public key from PEM format +func ParseRootPublicKey(pubKeyPEM []byte) (PublicKey, error) { + pk, _, err := parsePublicKey(pubKeyPEM, tagRootPublic) + if err != nil { + return PublicKey{}, fmt.Errorf("failed to parse root public key: %w", err) + } + return pk, nil +} + +// GenerateRootKey generates a new root Key pair with Metadata +func GenerateRootKey(expiration time.Duration) (*RootKey, []byte, []byte, error) { + now := time.Now() + expirationTime := now.Add(expiration) + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, nil, err + } + + metadata := KeyMetadata{ + ID: computeKeyID(pub), + CreatedAt: now.UTC(), + ExpiresAt: expirationTime.UTC(), + } + + rk := &RootKey{ + PrivateKey{ + Key: priv, + Metadata: metadata, + }, + } + + // Marshal PrivateKey struct to JSON + privJSON, err := json.Marshal(rk.PrivateKey) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to marshal private key: %w", err) + } + + // Marshal PublicKey struct to JSON + pubKey := PublicKey{ + Key: pub, + Metadata: metadata, + } + pubJSON, err := json.Marshal(pubKey) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to marshal public key: %w", err) + } + + // Encode to PEM with metadata embedded in bytes + privPEM := pem.EncodeToMemory(&pem.Block{ + Type: tagRootPrivate, + Bytes: privJSON, + }) + + pubPEM := pem.EncodeToMemory(&pem.Block{ + Type: tagRootPublic, + Bytes: pubJSON, + }) + + return rk, privPEM, pubPEM, nil +} + +func SignArtifactKey(rootKey RootKey, data []byte) ([]byte, error) { + timestamp := time.Now().UTC() + + // This ensures the timestamp is cryptographically bound to the signature + msg := make([]byte, 0, len(data)+8) + msg = append(msg, data...) + msg = binary.LittleEndian.AppendUint64(msg, uint64(timestamp.Unix())) + + sig := ed25519.Sign(rootKey.Key, msg) + // Create signature bundle with timestamp and Metadata + bundle := Signature{ + Signature: sig, + Timestamp: timestamp, + KeyID: rootKey.Metadata.ID, + Algorithm: "ed25519", + HashAlgo: "sha512", + } + + return json.Marshal(bundle) +} diff --git a/client/internal/updater/reposign/root_test.go b/client/internal/updater/reposign/root_test.go new file mode 100644 index 000000000..e75e29729 --- /dev/null +++ b/client/internal/updater/reposign/root_test.go @@ -0,0 +1,476 @@ +package reposign + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/binary" + "encoding/json" + "encoding/pem" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test RootKey.String() + +func TestRootKey_String(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + createdAt := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + expiresAt := time.Date(2034, 1, 15, 10, 30, 0, 0, time.UTC) + + rk := RootKey{ + PrivateKey{ + Key: priv, + Metadata: KeyMetadata{ + ID: computeKeyID(pub), + CreatedAt: createdAt, + ExpiresAt: expiresAt, + }, + }, + } + + str := rk.String() + assert.Contains(t, str, "RootKey") + assert.Contains(t, str, computeKeyID(pub).String()) + assert.Contains(t, str, "2024-01-15") + assert.Contains(t, str, "2034-01-15") +} + +func TestRootKey_String_NoExpiration(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + createdAt := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + + rk := RootKey{ + PrivateKey{ + Key: priv, + Metadata: KeyMetadata{ + ID: computeKeyID(pub), + CreatedAt: createdAt, + ExpiresAt: time.Time{}, // No expiration + }, + }, + } + + str := rk.String() + assert.Contains(t, str, "RootKey") + assert.Contains(t, str, "0001-01-01") // Zero time format +} + +// Test GenerateRootKey + +func TestGenerateRootKey_Valid(t *testing.T) { + expiration := 10 * 365 * 24 * time.Hour // 10 years + + rk, privPEM, pubPEM, err := GenerateRootKey(expiration) + require.NoError(t, err) + assert.NotNil(t, rk) + assert.NotEmpty(t, privPEM) + assert.NotEmpty(t, pubPEM) + + // Verify the key has correct metadata + assert.False(t, rk.Metadata.CreatedAt.IsZero()) + assert.False(t, rk.Metadata.ExpiresAt.IsZero()) + assert.True(t, rk.Metadata.ExpiresAt.After(rk.Metadata.CreatedAt)) + + // Verify expiration is approximately correct + expectedExpiration := time.Now().Add(expiration) + timeDiff := rk.Metadata.ExpiresAt.Sub(expectedExpiration) + assert.True(t, timeDiff < time.Minute && timeDiff > -time.Minute) +} + +func TestGenerateRootKey_ShortExpiration(t *testing.T) { + expiration := 24 * time.Hour // 1 day + + rk, _, _, err := GenerateRootKey(expiration) + require.NoError(t, err) + assert.NotNil(t, rk) + + // Verify expiration + expectedExpiration := time.Now().Add(expiration) + timeDiff := rk.Metadata.ExpiresAt.Sub(expectedExpiration) + assert.True(t, timeDiff < time.Minute && timeDiff > -time.Minute) +} + +func TestGenerateRootKey_ZeroExpiration(t *testing.T) { + rk, _, _, err := GenerateRootKey(0) + require.NoError(t, err) + assert.NotNil(t, rk) + + // With zero expiration, ExpiresAt should be equal to CreatedAt + assert.Equal(t, rk.Metadata.CreatedAt, rk.Metadata.ExpiresAt) +} + +func TestGenerateRootKey_PEMFormat(t *testing.T) { + rk, privPEM, pubPEM, err := GenerateRootKey(365 * 24 * time.Hour) + require.NoError(t, err) + + // Verify private key PEM + privBlock, _ := pem.Decode(privPEM) + require.NotNil(t, privBlock) + assert.Equal(t, tagRootPrivate, privBlock.Type) + + var privKey PrivateKey + err = json.Unmarshal(privBlock.Bytes, &privKey) + require.NoError(t, err) + assert.Equal(t, rk.Key, privKey.Key) + + // Verify public key PEM + pubBlock, _ := pem.Decode(pubPEM) + require.NotNil(t, pubBlock) + assert.Equal(t, tagRootPublic, pubBlock.Type) + + var pubKey PublicKey + err = json.Unmarshal(pubBlock.Bytes, &pubKey) + require.NoError(t, err) + assert.Equal(t, rk.Metadata.ID, pubKey.Metadata.ID) +} + +func TestGenerateRootKey_KeySize(t *testing.T) { + rk, _, _, err := GenerateRootKey(365 * 24 * time.Hour) + require.NoError(t, err) + + // Ed25519 private key should be 64 bytes + assert.Equal(t, ed25519.PrivateKeySize, len(rk.Key)) + + // Ed25519 public key should be 32 bytes + pubKey := rk.Key.Public().(ed25519.PublicKey) + assert.Equal(t, ed25519.PublicKeySize, len(pubKey)) +} + +func TestGenerateRootKey_UniqueKeys(t *testing.T) { + rk1, _, _, err := GenerateRootKey(365 * 24 * time.Hour) + require.NoError(t, err) + + rk2, _, _, err := GenerateRootKey(365 * 24 * time.Hour) + require.NoError(t, err) + + // Different keys should have different IDs + assert.NotEqual(t, rk1.Metadata.ID, rk2.Metadata.ID) + assert.NotEqual(t, rk1.Key, rk2.Key) +} + +// Test ParseRootKey + +func TestParseRootKey_Valid(t *testing.T) { + original, privPEM, _, err := GenerateRootKey(365 * 24 * time.Hour) + require.NoError(t, err) + + parsed, err := ParseRootKey(privPEM) + require.NoError(t, err) + assert.NotNil(t, parsed) + + // Verify the parsed key matches the original + assert.Equal(t, original.Key, parsed.Key) + assert.Equal(t, original.Metadata.ID, parsed.Metadata.ID) + assert.Equal(t, original.Metadata.CreatedAt.Unix(), parsed.Metadata.CreatedAt.Unix()) + assert.Equal(t, original.Metadata.ExpiresAt.Unix(), parsed.Metadata.ExpiresAt.Unix()) +} + +func TestParseRootKey_InvalidPEM(t *testing.T) { + _, err := ParseRootKey([]byte("not a valid PEM")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse") +} + +func TestParseRootKey_EmptyData(t *testing.T) { + _, err := ParseRootKey([]byte{}) + assert.Error(t, err) +} + +func TestParseRootKey_WrongType(t *testing.T) { + // Generate an artifact key instead of root key + rootKey, _, _, err := GenerateRootKey(365 * 24 * time.Hour) + require.NoError(t, err) + + artifactKey, privPEM, _, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + + // Try to parse artifact key as root key + _, err = ParseRootKey(privPEM) + assert.Error(t, err) + assert.Contains(t, err.Error(), "PEM type") + + // Just to use artifactKey to avoid unused variable warning + _ = artifactKey +} + +func TestParseRootKey_CorruptedJSON(t *testing.T) { + // Create PEM with corrupted JSON + corruptedPEM := pem.EncodeToMemory(&pem.Block{ + Type: tagRootPrivate, + Bytes: []byte("corrupted json data"), + }) + + _, err := ParseRootKey(corruptedPEM) + assert.Error(t, err) +} + +func TestParseRootKey_InvalidKeySize(t *testing.T) { + // Create a key with invalid size + invalidKey := PrivateKey{ + Key: []byte{0x01, 0x02, 0x03}, // Too short + Metadata: KeyMetadata{ + ID: KeyID{}, + CreatedAt: time.Now().UTC(), + }, + } + + privJSON, err := json.Marshal(invalidKey) + require.NoError(t, err) + + invalidPEM := pem.EncodeToMemory(&pem.Block{ + Type: tagRootPrivate, + Bytes: privJSON, + }) + + _, err = ParseRootKey(invalidPEM) + assert.Error(t, err) + assert.Contains(t, err.Error(), "incorrect Ed25519 private key size") +} + +func TestParseRootKey_Roundtrip(t *testing.T) { + // Generate a key + original, privPEM, _, err := GenerateRootKey(365 * 24 * time.Hour) + require.NoError(t, err) + + // Parse it + parsed, err := ParseRootKey(privPEM) + require.NoError(t, err) + + // Generate PEM again from parsed key + privJSON2, err := json.Marshal(parsed.PrivateKey) + require.NoError(t, err) + + privPEM2 := pem.EncodeToMemory(&pem.Block{ + Type: tagRootPrivate, + Bytes: privJSON2, + }) + + // Parse again + parsed2, err := ParseRootKey(privPEM2) + require.NoError(t, err) + + // Should still match original + assert.Equal(t, original.Key, parsed2.Key) + assert.Equal(t, original.Metadata.ID, parsed2.Metadata.ID) +} + +// Test SignArtifactKey + +func TestSignArtifactKey_Valid(t *testing.T) { + rootKey, _, _, err := GenerateRootKey(365 * 24 * time.Hour) + require.NoError(t, err) + + data := []byte("test data to sign") + sigData, err := SignArtifactKey(*rootKey, data) + require.NoError(t, err) + assert.NotEmpty(t, sigData) + + // Parse and verify signature + sig, err := ParseSignature(sigData) + require.NoError(t, err) + assert.NotEmpty(t, sig.Signature) + assert.Equal(t, rootKey.Metadata.ID, sig.KeyID) + assert.Equal(t, "ed25519", sig.Algorithm) + assert.Equal(t, "sha512", sig.HashAlgo) + assert.False(t, sig.Timestamp.IsZero()) +} + +func TestSignArtifactKey_EmptyData(t *testing.T) { + rootKey, _, _, err := GenerateRootKey(365 * 24 * time.Hour) + require.NoError(t, err) + + sigData, err := SignArtifactKey(*rootKey, []byte{}) + require.NoError(t, err) + assert.NotEmpty(t, sigData) + + // Should still be able to parse + sig, err := ParseSignature(sigData) + require.NoError(t, err) + assert.NotEmpty(t, sig.Signature) +} + +func TestSignArtifactKey_Verify(t *testing.T) { + rootKey, _, pubPEM, err := GenerateRootKey(365 * 24 * time.Hour) + require.NoError(t, err) + + // Parse public key + pubKey, _, err := parsePublicKey(pubPEM, tagRootPublic) + require.NoError(t, err) + + // Sign some data + data := []byte("test data for verification") + sigData, err := SignArtifactKey(*rootKey, data) + require.NoError(t, err) + + // Parse signature + sig, err := ParseSignature(sigData) + require.NoError(t, err) + + // Reconstruct message + msg := make([]byte, 0, len(data)+8) + msg = append(msg, data...) + msg = binary.LittleEndian.AppendUint64(msg, uint64(sig.Timestamp.Unix())) + + // Verify signature + valid := ed25519.Verify(pubKey.Key, msg, sig.Signature) + assert.True(t, valid) +} + +func TestSignArtifactKey_DifferentData(t *testing.T) { + rootKey, _, _, err := GenerateRootKey(365 * 24 * time.Hour) + require.NoError(t, err) + + data1 := []byte("data1") + data2 := []byte("data2") + + sig1, err := SignArtifactKey(*rootKey, data1) + require.NoError(t, err) + + sig2, err := SignArtifactKey(*rootKey, data2) + require.NoError(t, err) + + // Different data should produce different signatures + assert.NotEqual(t, sig1, sig2) +} + +func TestSignArtifactKey_MultipleSignatures(t *testing.T) { + rootKey, _, _, err := GenerateRootKey(365 * 24 * time.Hour) + require.NoError(t, err) + + data := []byte("test data") + + // Sign twice with a small delay + sig1, err := SignArtifactKey(*rootKey, data) + require.NoError(t, err) + + time.Sleep(10 * time.Millisecond) + + sig2, err := SignArtifactKey(*rootKey, data) + require.NoError(t, err) + + // Signatures should be different due to different timestamps + assert.NotEqual(t, sig1, sig2) + + // Parse both signatures + parsed1, err := ParseSignature(sig1) + require.NoError(t, err) + + parsed2, err := ParseSignature(sig2) + require.NoError(t, err) + + // Timestamps should be different + assert.True(t, parsed2.Timestamp.After(parsed1.Timestamp)) +} + +func TestSignArtifactKey_LargeData(t *testing.T) { + rootKey, _, _, err := GenerateRootKey(365 * 24 * time.Hour) + require.NoError(t, err) + + // Create 1MB of data + largeData := make([]byte, 1024*1024) + for i := range largeData { + largeData[i] = byte(i % 256) + } + + sigData, err := SignArtifactKey(*rootKey, largeData) + require.NoError(t, err) + assert.NotEmpty(t, sigData) + + // Verify signature can be parsed + sig, err := ParseSignature(sigData) + require.NoError(t, err) + assert.NotEmpty(t, sig.Signature) +} + +func TestSignArtifactKey_TimestampInSignature(t *testing.T) { + rootKey, _, _, err := GenerateRootKey(365 * 24 * time.Hour) + require.NoError(t, err) + + beforeSign := time.Now().UTC() + data := []byte("test data") + sigData, err := SignArtifactKey(*rootKey, data) + require.NoError(t, err) + afterSign := time.Now().UTC() + + sig, err := ParseSignature(sigData) + require.NoError(t, err) + + // Timestamp should be between before and after + assert.True(t, sig.Timestamp.After(beforeSign.Add(-time.Second))) + assert.True(t, sig.Timestamp.Before(afterSign.Add(time.Second))) +} + +// Integration test + +func TestRootKey_FullWorkflow(t *testing.T) { + // Step 1: Generate root key + rootKey, privPEM, pubPEM, err := GenerateRootKey(10 * 365 * 24 * time.Hour) + require.NoError(t, err) + assert.NotNil(t, rootKey) + assert.NotEmpty(t, privPEM) + assert.NotEmpty(t, pubPEM) + + // Step 2: Parse the private key back + parsedRootKey, err := ParseRootKey(privPEM) + require.NoError(t, err) + assert.Equal(t, rootKey.Key, parsedRootKey.Key) + assert.Equal(t, rootKey.Metadata.ID, parsedRootKey.Metadata.ID) + + // Step 3: Generate an artifact key using root key + artifactKey, _, artifactPubPEM, artifactSig, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + assert.NotNil(t, artifactKey) + + // Step 4: Verify the artifact key signature + pubKey, _, err := parsePublicKey(pubPEM, tagRootPublic) + require.NoError(t, err) + + sig, err := ParseSignature(artifactSig) + require.NoError(t, err) + + artifactPubKey, _, err := parsePublicKey(artifactPubPEM, tagArtifactPublic) + require.NoError(t, err) + + // Reconstruct message - SignArtifactKey signs the PEM, not the JSON + msg := make([]byte, 0, len(artifactPubPEM)+8) + msg = append(msg, artifactPubPEM...) + msg = binary.LittleEndian.AppendUint64(msg, uint64(sig.Timestamp.Unix())) + + // Verify with root public key + valid := ed25519.Verify(pubKey.Key, msg, sig.Signature) + assert.True(t, valid, "Artifact key signature should be valid") + + // Step 5: Use artifact key to sign data + testData := []byte("This is test artifact data") + dataSig, err := SignData(*artifactKey, testData) + require.NoError(t, err) + assert.NotEmpty(t, dataSig) + + // Step 6: Verify the artifact data signature + dataSigParsed, err := ParseSignature(dataSig) + require.NoError(t, err) + + err = ValidateArtifact([]PublicKey{artifactPubKey}, testData, *dataSigParsed) + assert.NoError(t, err, "Artifact data signature should be valid") +} + +func TestRootKey_ExpiredKeyWorkflow(t *testing.T) { + // Generate a root key that expires very soon + rootKey, _, _, err := GenerateRootKey(1 * time.Millisecond) + require.NoError(t, err) + + // Wait for expiration + time.Sleep(10 * time.Millisecond) + + // Try to generate artifact key with expired root key + _, _, _, _, err = GenerateArtifactKey(rootKey, 30*24*time.Hour) + assert.Error(t, err) + assert.Contains(t, err.Error(), "expired") +} diff --git a/client/internal/updater/reposign/signature.go b/client/internal/updater/reposign/signature.go new file mode 100644 index 000000000..c7f06e94e --- /dev/null +++ b/client/internal/updater/reposign/signature.go @@ -0,0 +1,24 @@ +package reposign + +import ( + "encoding/json" + "time" +) + +// Signature contains a signature with associated Metadata +type Signature struct { + Signature []byte `json:"signature"` + Timestamp time.Time `json:"timestamp"` + KeyID KeyID `json:"key_id"` + Algorithm string `json:"algorithm"` // "ed25519" + HashAlgo string `json:"hash_algo"` // "blake2s" or sha512 +} + +func ParseSignature(data []byte) (*Signature, error) { + var signature Signature + if err := json.Unmarshal(data, &signature); err != nil { + return nil, err + } + + return &signature, nil +} diff --git a/client/internal/updater/reposign/signature_test.go b/client/internal/updater/reposign/signature_test.go new file mode 100644 index 000000000..1960c5518 --- /dev/null +++ b/client/internal/updater/reposign/signature_test.go @@ -0,0 +1,277 @@ +package reposign + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseSignature_Valid(t *testing.T) { + timestamp := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + keyID, err := ParseKeyID("0123456789abcdef") + require.NoError(t, err) + + signatureData := []byte{0x01, 0x02, 0x03, 0x04} + + jsonData, err := json.Marshal(Signature{ + Signature: signatureData, + Timestamp: timestamp, + KeyID: keyID, + Algorithm: "ed25519", + HashAlgo: "blake2s", + }) + require.NoError(t, err) + + sig, err := ParseSignature(jsonData) + require.NoError(t, err) + assert.NotNil(t, sig) + assert.Equal(t, signatureData, sig.Signature) + assert.Equal(t, timestamp.Unix(), sig.Timestamp.Unix()) + assert.Equal(t, keyID, sig.KeyID) + assert.Equal(t, "ed25519", sig.Algorithm) + assert.Equal(t, "blake2s", sig.HashAlgo) +} + +func TestParseSignature_InvalidJSON(t *testing.T) { + invalidJSON := []byte(`{invalid json}`) + + sig, err := ParseSignature(invalidJSON) + assert.Error(t, err) + assert.Nil(t, sig) +} + +func TestParseSignature_EmptyData(t *testing.T) { + emptyJSON := []byte(`{}`) + + sig, err := ParseSignature(emptyJSON) + require.NoError(t, err) + assert.NotNil(t, sig) + assert.Empty(t, sig.Signature) + assert.True(t, sig.Timestamp.IsZero()) + assert.Equal(t, KeyID{}, sig.KeyID) + assert.Empty(t, sig.Algorithm) + assert.Empty(t, sig.HashAlgo) +} + +func TestParseSignature_MissingFields(t *testing.T) { + // JSON with only some fields + partialJSON := []byte(`{ + "signature": "AQIDBA==", + "algorithm": "ed25519" + }`) + + sig, err := ParseSignature(partialJSON) + require.NoError(t, err) + assert.NotNil(t, sig) + assert.NotEmpty(t, sig.Signature) + assert.Equal(t, "ed25519", sig.Algorithm) + assert.True(t, sig.Timestamp.IsZero()) +} + +func TestSignature_MarshalUnmarshal_Roundtrip(t *testing.T) { + timestamp := time.Date(2024, 6, 20, 14, 45, 30, 0, time.UTC) + keyID, err := ParseKeyID("fedcba9876543210") + require.NoError(t, err) + + original := Signature{ + Signature: []byte{0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe}, + Timestamp: timestamp, + KeyID: keyID, + Algorithm: "ed25519", + HashAlgo: "sha512", + } + + // Marshal + jsonData, err := json.Marshal(original) + require.NoError(t, err) + + // Unmarshal + parsed, err := ParseSignature(jsonData) + require.NoError(t, err) + + // Verify + assert.Equal(t, original.Signature, parsed.Signature) + assert.Equal(t, original.Timestamp.Unix(), parsed.Timestamp.Unix()) + assert.Equal(t, original.KeyID, parsed.KeyID) + assert.Equal(t, original.Algorithm, parsed.Algorithm) + assert.Equal(t, original.HashAlgo, parsed.HashAlgo) +} + +func TestSignature_NilSignatureBytes(t *testing.T) { + timestamp := time.Now().UTC() + keyID, err := ParseKeyID("0011223344556677") + require.NoError(t, err) + + sig := Signature{ + Signature: nil, + Timestamp: timestamp, + KeyID: keyID, + Algorithm: "ed25519", + HashAlgo: "blake2s", + } + + jsonData, err := json.Marshal(sig) + require.NoError(t, err) + + parsed, err := ParseSignature(jsonData) + require.NoError(t, err) + assert.Nil(t, parsed.Signature) +} + +func TestSignature_LargeSignature(t *testing.T) { + timestamp := time.Now().UTC() + keyID, err := ParseKeyID("aabbccddeeff0011") + require.NoError(t, err) + + // Create a large signature (64 bytes for ed25519) + largeSignature := make([]byte, 64) + for i := range largeSignature { + largeSignature[i] = byte(i) + } + + sig := Signature{ + Signature: largeSignature, + Timestamp: timestamp, + KeyID: keyID, + Algorithm: "ed25519", + HashAlgo: "blake2s", + } + + jsonData, err := json.Marshal(sig) + require.NoError(t, err) + + parsed, err := ParseSignature(jsonData) + require.NoError(t, err) + assert.Equal(t, largeSignature, parsed.Signature) +} + +func TestSignature_WithDifferentHashAlgorithms(t *testing.T) { + tests := []struct { + name string + hashAlgo string + }{ + {"blake2s", "blake2s"}, + {"sha512", "sha512"}, + {"sha256", "sha256"}, + {"empty", ""}, + } + + keyID, err := ParseKeyID("1122334455667788") + require.NoError(t, err) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sig := Signature{ + Signature: []byte{0x01, 0x02}, + Timestamp: time.Now().UTC(), + KeyID: keyID, + Algorithm: "ed25519", + HashAlgo: tt.hashAlgo, + } + + jsonData, err := json.Marshal(sig) + require.NoError(t, err) + + parsed, err := ParseSignature(jsonData) + require.NoError(t, err) + assert.Equal(t, tt.hashAlgo, parsed.HashAlgo) + }) + } +} + +func TestSignature_TimestampPrecision(t *testing.T) { + // Test that timestamp preserves precision through JSON marshaling + timestamp := time.Date(2024, 3, 15, 10, 30, 45, 123456789, time.UTC) + keyID, err := ParseKeyID("8877665544332211") + require.NoError(t, err) + + sig := Signature{ + Signature: []byte{0xaa, 0xbb}, + Timestamp: timestamp, + KeyID: keyID, + Algorithm: "ed25519", + HashAlgo: "blake2s", + } + + jsonData, err := json.Marshal(sig) + require.NoError(t, err) + + parsed, err := ParseSignature(jsonData) + require.NoError(t, err) + + // JSON timestamps typically have second or millisecond precision + // so we check that at least seconds match + assert.Equal(t, timestamp.Unix(), parsed.Timestamp.Unix()) +} + +func TestParseSignature_MalformedKeyID(t *testing.T) { + // Test with a malformed KeyID field + malformedJSON := []byte(`{ + "signature": "AQID", + "timestamp": "2024-01-15T10:30:00Z", + "key_id": "invalid_keyid_format", + "algorithm": "ed25519", + "hash_algo": "blake2s" + }`) + + // This should fail since "invalid_keyid_format" is not a valid KeyID + sig, err := ParseSignature(malformedJSON) + assert.Error(t, err) + assert.Nil(t, sig) +} + +func TestParseSignature_InvalidTimestamp(t *testing.T) { + // Test with an invalid timestamp format + invalidTimestampJSON := []byte(`{ + "signature": "AQID", + "timestamp": "not-a-timestamp", + "key_id": "0123456789abcdef", + "algorithm": "ed25519", + "hash_algo": "blake2s" + }`) + + sig, err := ParseSignature(invalidTimestampJSON) + assert.Error(t, err) + assert.Nil(t, sig) +} + +func TestSignature_ZeroKeyID(t *testing.T) { + // Test with a zero KeyID + sig := Signature{ + Signature: []byte{0x01, 0x02, 0x03}, + Timestamp: time.Now().UTC(), + KeyID: KeyID{}, + Algorithm: "ed25519", + HashAlgo: "blake2s", + } + + jsonData, err := json.Marshal(sig) + require.NoError(t, err) + + parsed, err := ParseSignature(jsonData) + require.NoError(t, err) + assert.Equal(t, KeyID{}, parsed.KeyID) +} + +func TestParseSignature_ExtraFields(t *testing.T) { + // JSON with extra fields that should be ignored + jsonWithExtra := []byte(`{ + "signature": "AQIDBA==", + "timestamp": "2024-01-15T10:30:00Z", + "key_id": "0123456789abcdef", + "algorithm": "ed25519", + "hash_algo": "blake2s", + "extra_field": "should be ignored", + "another_extra": 12345 + }`) + + sig, err := ParseSignature(jsonWithExtra) + require.NoError(t, err) + assert.NotNil(t, sig) + assert.NotEmpty(t, sig.Signature) + assert.Equal(t, "ed25519", sig.Algorithm) + assert.Equal(t, "blake2s", sig.HashAlgo) +} diff --git a/client/internal/updater/reposign/verify.go b/client/internal/updater/reposign/verify.go new file mode 100644 index 000000000..f64b26a30 --- /dev/null +++ b/client/internal/updater/reposign/verify.go @@ -0,0 +1,187 @@ +package reposign + +import ( + "context" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/updater/downloader" +) + +const ( + artifactPubKeysFileName = "artifact-key-pub.pem" + artifactPubKeysSigFileName = "artifact-key-pub.pem.sig" + revocationFileName = "revocation-list.json" + revocationSignFileName = "revocation-list.json.sig" + + keySizeLimit = 5 * 1024 * 1024 //5MB + signatureLimit = 1024 + revocationLimit = 10 * 1024 * 1024 +) + +type ArtifactVerify struct { + rootKeys []PublicKey + keysBaseURL *url.URL + + revocationList *RevocationList +} + +func NewArtifactVerify(keysBaseURL string) (*ArtifactVerify, error) { + allKeys, err := loadEmbeddedPublicKeys() + if err != nil { + return nil, err + } + + return newArtifactVerify(keysBaseURL, allKeys) +} + +func newArtifactVerify(keysBaseURL string, allKeys []PublicKey) (*ArtifactVerify, error) { + ku, err := url.Parse(keysBaseURL) + if err != nil { + return nil, fmt.Errorf("invalid keys base URL %q: %v", keysBaseURL, err) + } + + a := &ArtifactVerify{ + rootKeys: allKeys, + keysBaseURL: ku, + } + return a, nil +} + +func (a *ArtifactVerify) Verify(ctx context.Context, version string, artifactFile string) error { + version = strings.TrimPrefix(version, "v") + + revocationList, err := a.loadRevocationList(ctx) + if err != nil { + return fmt.Errorf("failed to load revocation list: %v", err) + } + a.revocationList = revocationList + + artifactPubKeys, err := a.loadArtifactKeys(ctx) + if err != nil { + return fmt.Errorf("failed to load artifact keys: %v", err) + } + + signature, err := a.loadArtifactSignature(ctx, version, artifactFile) + if err != nil { + return fmt.Errorf("failed to download signature file for: %s, %v", filepath.Base(artifactFile), err) + } + + artifactData, err := os.ReadFile(artifactFile) + if err != nil { + log.Errorf("failed to read artifact file: %v", err) + return fmt.Errorf("failed to read artifact file: %w", err) + } + + if err := ValidateArtifact(artifactPubKeys, artifactData, *signature); err != nil { + return fmt.Errorf("failed to validate artifact: %v", err) + } + + return nil +} + +func (a *ArtifactVerify) loadRevocationList(ctx context.Context) (*RevocationList, error) { + downloadURL := a.keysBaseURL.JoinPath("keys", revocationFileName).String() + data, err := downloader.DownloadToMemory(ctx, downloadURL, revocationLimit) + if err != nil { + log.Debugf("failed to download revocation list '%s': %s", downloadURL, err) + return nil, err + } + + downloadURL = a.keysBaseURL.JoinPath("keys", revocationSignFileName).String() + sigData, err := downloader.DownloadToMemory(ctx, downloadURL, signatureLimit) + if err != nil { + log.Debugf("failed to download revocation list '%s': %s", downloadURL, err) + return nil, err + } + + signature, err := ParseSignature(sigData) + if err != nil { + log.Debugf("failed to parse revocation list signature: %s", err) + return nil, err + } + + return ValidateRevocationList(a.rootKeys, data, *signature) +} + +func (a *ArtifactVerify) loadArtifactKeys(ctx context.Context) ([]PublicKey, error) { + downloadURL := a.keysBaseURL.JoinPath("keys", artifactPubKeysFileName).String() + log.Debugf("starting downloading artifact keys from: %s", downloadURL) + data, err := downloader.DownloadToMemory(ctx, downloadURL, keySizeLimit) + if err != nil { + log.Debugf("failed to download artifact keys: %s", err) + return nil, err + } + + downloadURL = a.keysBaseURL.JoinPath("keys", artifactPubKeysSigFileName).String() + log.Debugf("start downloading signature of artifact pub key from: %s", downloadURL) + sigData, err := downloader.DownloadToMemory(ctx, downloadURL, signatureLimit) + if err != nil { + log.Debugf("failed to download signature of public keys: %s", err) + return nil, err + } + + signature, err := ParseSignature(sigData) + if err != nil { + log.Debugf("failed to parse signature of public keys: %s", err) + return nil, err + } + + return ValidateArtifactKeys(a.rootKeys, data, *signature, a.revocationList) +} + +func (a *ArtifactVerify) loadArtifactSignature(ctx context.Context, version string, artifactFile string) (*Signature, error) { + artifactFile = filepath.Base(artifactFile) + downloadURL := a.keysBaseURL.JoinPath("tag", "v"+version, artifactFile+".sig").String() + data, err := downloader.DownloadToMemory(ctx, downloadURL, signatureLimit) + if err != nil { + log.Debugf("failed to download artifact signature: %s", err) + return nil, err + } + + signature, err := ParseSignature(data) + if err != nil { + log.Debugf("failed to parse artifact signature: %s", err) + return nil, err + } + + return signature, nil + +} + +func loadEmbeddedPublicKeys() ([]PublicKey, error) { + files, err := embeddedCerts.ReadDir(embeddedCertsDir) + if err != nil { + return nil, fmt.Errorf("failed to read embedded certs: %w", err) + } + + var allKeys []PublicKey + for _, file := range files { + if file.IsDir() { + continue + } + + data, err := embeddedCerts.ReadFile(embeddedCertsDir + "/" + file.Name()) + if err != nil { + return nil, fmt.Errorf("failed to read cert file %s: %w", file.Name(), err) + } + + keys, err := parsePublicKeyBundle(data, tagRootPublic) + if err != nil { + return nil, fmt.Errorf("failed to parse cert %s: %w", file.Name(), err) + } + + allKeys = append(allKeys, keys...) + } + + if len(allKeys) == 0 { + return nil, fmt.Errorf("no valid public keys found in embedded certs") + } + + return allKeys, nil +} diff --git a/client/internal/updater/reposign/verify_test.go b/client/internal/updater/reposign/verify_test.go new file mode 100644 index 000000000..c29393bad --- /dev/null +++ b/client/internal/updater/reposign/verify_test.go @@ -0,0 +1,528 @@ +package reposign + +import ( + "context" + "crypto/ed25519" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test ArtifactVerify construction + +func TestArtifactVerify_Construction(t *testing.T) { + // Generate test root key + rootKey, _, rootPubPEM, err := GenerateRootKey(365 * 24 * time.Hour) + require.NoError(t, err) + + rootPubKey, _, err := parsePublicKey(rootPubPEM, tagRootPublic) + require.NoError(t, err) + + keysBaseURL := "http://localhost:8080/artifact-signatures" + + av, err := newArtifactVerify(keysBaseURL, []PublicKey{rootPubKey}) + require.NoError(t, err) + + assert.NotNil(t, av) + assert.NotEmpty(t, av.rootKeys) + assert.Equal(t, keysBaseURL, av.keysBaseURL.String()) + + // Verify root key structure + assert.NotEmpty(t, av.rootKeys[0].Key) + assert.Equal(t, rootKey.Metadata.ID, av.rootKeys[0].Metadata.ID) + assert.False(t, av.rootKeys[0].Metadata.CreatedAt.IsZero()) +} + +func TestArtifactVerify_MultipleRootKeys(t *testing.T) { + // Generate multiple test root keys + rootKey1, _, rootPubPEM1, err := GenerateRootKey(365 * 24 * time.Hour) + require.NoError(t, err) + rootPubKey1, _, err := parsePublicKey(rootPubPEM1, tagRootPublic) + require.NoError(t, err) + + rootKey2, _, rootPubPEM2, err := GenerateRootKey(365 * 24 * time.Hour) + require.NoError(t, err) + rootPubKey2, _, err := parsePublicKey(rootPubPEM2, tagRootPublic) + require.NoError(t, err) + + keysBaseURL := "http://localhost:8080/artifact-signatures" + + av, err := newArtifactVerify(keysBaseURL, []PublicKey{rootPubKey1, rootPubKey2}) + assert.NoError(t, err) + assert.Len(t, av.rootKeys, 2) + assert.NotEqual(t, rootKey1.Metadata.ID, rootKey2.Metadata.ID) +} + +// Test Verify workflow with mock HTTP server + +func TestArtifactVerify_FullWorkflow(t *testing.T) { + // Create temporary test directory + tempDir := t.TempDir() + + // Step 1: Generate root key + rootKey, _, _, err := GenerateRootKey(10 * 365 * 24 * time.Hour) + require.NoError(t, err) + + // Step 2: Generate artifact key + artifactKey, _, artifactPubPEM, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + + artifactPubKey, err := ParseArtifactPubKey(artifactPubPEM) + require.NoError(t, err) + + // Step 3: Create revocation list + revocationData, revocationSig, err := CreateRevocationList(*rootKey, defaultRevocationListExpiration) + require.NoError(t, err) + + // Step 4: Bundle artifact keys + artifactKeysBundle, artifactKeysSig, err := BundleArtifactKeys(rootKey, []PublicKey{artifactPubKey}) + require.NoError(t, err) + + // Step 5: Create test artifact + artifactPath := filepath.Join(tempDir, "test-artifact.bin") + artifactData := []byte("This is test artifact data for verification") + err = os.WriteFile(artifactPath, artifactData, 0644) + require.NoError(t, err) + + // Step 6: Sign artifact + artifactSigData, err := SignData(*artifactKey, artifactData) + require.NoError(t, err) + + // Step 7: Setup mock HTTP server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/artifact-signatures/keys/" + revocationFileName: + _, _ = w.Write(revocationData) + case "/artifact-signatures/keys/" + revocationSignFileName: + _, _ = w.Write(revocationSig) + case "/artifact-signatures/keys/" + artifactPubKeysFileName: + _, _ = w.Write(artifactKeysBundle) + case "/artifact-signatures/keys/" + artifactPubKeysSigFileName: + _, _ = w.Write(artifactKeysSig) + case "/artifacts/v1.0.0/test-artifact.bin": + _, _ = w.Write(artifactData) + case "/artifact-signatures/tag/v1.0.0/test-artifact.bin.sig": + _, _ = w.Write(artifactSigData) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + // Step 8: Create ArtifactVerify with test root key + rootPubKey := PublicKey{ + Key: rootKey.Key.Public().(ed25519.PublicKey), + Metadata: rootKey.Metadata, + } + + av, err := newArtifactVerify(server.URL+"/artifact-signatures", []PublicKey{rootPubKey}) + require.NoError(t, err) + + // Step 9: Verify artifact + ctx := context.Background() + err = av.Verify(ctx, "1.0.0", artifactPath) + assert.NoError(t, err) +} + +func TestArtifactVerify_InvalidRevocationList(t *testing.T) { + tempDir := t.TempDir() + artifactPath := filepath.Join(tempDir, "test.bin") + err := os.WriteFile(artifactPath, []byte("test"), 0644) + require.NoError(t, err) + + // Setup server with invalid revocation list + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/artifact-signatures/keys/" + revocationFileName: + _, _ = w.Write([]byte("invalid data")) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + rootKey, _, _, err := GenerateRootKey(365 * 24 * time.Hour) + require.NoError(t, err) + + rootPubKey := PublicKey{ + Key: rootKey.Key.Public().(ed25519.PublicKey), + Metadata: rootKey.Metadata, + } + + av, err := newArtifactVerify(server.URL+"/artifact-signatures", []PublicKey{rootPubKey}) + require.NoError(t, err) + + ctx := context.Background() + err = av.Verify(ctx, "1.0.0", artifactPath) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to load revocation list") +} + +func TestArtifactVerify_MissingArtifactFile(t *testing.T) { + rootKey, _, _, err := GenerateRootKey(365 * 24 * time.Hour) + require.NoError(t, err) + + rootPubKey := PublicKey{ + Key: rootKey.Key.Public().(ed25519.PublicKey), + Metadata: rootKey.Metadata, + } + + // Create revocation list + revocationData, revocationSig, err := CreateRevocationList(*rootKey, defaultRevocationListExpiration) + require.NoError(t, err) + + artifactKey, _, artifactPubPEM, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + + artifactPubKey, err := ParseArtifactPubKey(artifactPubPEM) + require.NoError(t, err) + + artifactKeysBundle, artifactKeysSig, err := BundleArtifactKeys(rootKey, []PublicKey{artifactPubKey}) + require.NoError(t, err) + + // Create signature for non-existent file + testData := []byte("test") + artifactSigData, err := SignData(*artifactKey, testData) + require.NoError(t, err) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/artifact-signatures/keys/" + revocationFileName: + _, _ = w.Write(revocationData) + case "/artifact-signatures/keys/" + revocationSignFileName: + _, _ = w.Write(revocationSig) + case "/artifact-signatures/keys/" + artifactPubKeysFileName: + _, _ = w.Write(artifactKeysBundle) + case "/artifact-signatures/keys/" + artifactPubKeysSigFileName: + _, _ = w.Write(artifactKeysSig) + case "/artifact-signatures/tag/v1.0.0/missing.bin.sig": + _, _ = w.Write(artifactSigData) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + av, err := newArtifactVerify(server.URL+"/artifact-signatures", []PublicKey{rootPubKey}) + require.NoError(t, err) + + ctx := context.Background() + err = av.Verify(ctx, "1.0.0", "file.bin") + assert.Error(t, err) +} + +func TestArtifactVerify_ServerUnavailable(t *testing.T) { + tempDir := t.TempDir() + artifactPath := filepath.Join(tempDir, "test.bin") + err := os.WriteFile(artifactPath, []byte("test"), 0644) + require.NoError(t, err) + + rootKey, _, _, err := GenerateRootKey(365 * 24 * time.Hour) + require.NoError(t, err) + + rootPubKey := PublicKey{ + Key: rootKey.Key.Public().(ed25519.PublicKey), + Metadata: rootKey.Metadata, + } + + // Use URL that doesn't exist + av, err := newArtifactVerify("http://localhost:19999/keys", []PublicKey{rootPubKey}) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + err = av.Verify(ctx, "1.0.0", artifactPath) + assert.Error(t, err) +} + +func TestArtifactVerify_ContextCancellation(t *testing.T) { + tempDir := t.TempDir() + artifactPath := filepath.Join(tempDir, "test.bin") + err := os.WriteFile(artifactPath, []byte("test"), 0644) + require.NoError(t, err) + + // Create a server that delays response + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(500 * time.Millisecond) + _, _ = w.Write([]byte("data")) + })) + defer server.Close() + + rootKey, _, _, err := GenerateRootKey(365 * 24 * time.Hour) + require.NoError(t, err) + + rootPubKey := PublicKey{ + Key: rootKey.Key.Public().(ed25519.PublicKey), + Metadata: rootKey.Metadata, + } + + av, err := newArtifactVerify(server.URL, []PublicKey{rootPubKey}) + require.NoError(t, err) + + // Create context that cancels quickly + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + err = av.Verify(ctx, "1.0.0", artifactPath) + assert.Error(t, err) +} + +func TestArtifactVerify_WithRevocation(t *testing.T) { + tempDir := t.TempDir() + + // Generate root key + rootKey, _, _, err := GenerateRootKey(10 * 365 * 24 * time.Hour) + require.NoError(t, err) + + // Generate two artifact keys + artifactKey1, _, artifactPubPEM1, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + artifactPubKey1, err := ParseArtifactPubKey(artifactPubPEM1) + require.NoError(t, err) + + _, _, artifactPubPEM2, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + artifactPubKey2, err := ParseArtifactPubKey(artifactPubPEM2) + require.NoError(t, err) + + // Create revocation list with first key revoked + emptyRevocation, _, err := CreateRevocationList(*rootKey, defaultRevocationListExpiration) + require.NoError(t, err) + + parsedRevocation, err := ParseRevocationList(emptyRevocation) + require.NoError(t, err) + + revocationData, revocationSig, err := ExtendRevocationList(*rootKey, *parsedRevocation, artifactPubKey1.Metadata.ID, defaultRevocationListExpiration) + require.NoError(t, err) + + // Bundle both keys + artifactKeysBundle, artifactKeysSig, err := BundleArtifactKeys(rootKey, []PublicKey{artifactPubKey1, artifactPubKey2}) + require.NoError(t, err) + + // Create artifact signed by revoked key + artifactPath := filepath.Join(tempDir, "test.bin") + artifactData := []byte("test data") + err = os.WriteFile(artifactPath, artifactData, 0644) + require.NoError(t, err) + + artifactSigData, err := SignData(*artifactKey1, artifactData) + require.NoError(t, err) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/artifact-signatures/keys/" + revocationFileName: + _, _ = w.Write(revocationData) + case "/artifact-signatures/keys/" + revocationSignFileName: + _, _ = w.Write(revocationSig) + case "/artifact-signatures/keys/" + artifactPubKeysFileName: + _, _ = w.Write(artifactKeysBundle) + case "/artifact-signatures/keys/" + artifactPubKeysSigFileName: + _, _ = w.Write(artifactKeysSig) + case "/artifact-signatures/tag/v1.0.0/test.bin.sig": + _, _ = w.Write(artifactSigData) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + rootPubKey := PublicKey{ + Key: rootKey.Key.Public().(ed25519.PublicKey), + Metadata: rootKey.Metadata, + } + + av, err := newArtifactVerify(server.URL+"/artifact-signatures", []PublicKey{rootPubKey}) + require.NoError(t, err) + + ctx := context.Background() + err = av.Verify(ctx, "1.0.0", artifactPath) + // Should fail because the signing key is revoked + assert.Error(t, err) + assert.Contains(t, err.Error(), "no signing Key found") +} + +func TestArtifactVerify_ValidWithSecondKey(t *testing.T) { + tempDir := t.TempDir() + + // Generate root key + rootKey, _, _, err := GenerateRootKey(10 * 365 * 24 * time.Hour) + require.NoError(t, err) + + // Generate two artifact keys + _, _, artifactPubPEM1, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + artifactPubKey1, err := ParseArtifactPubKey(artifactPubPEM1) + require.NoError(t, err) + + artifactKey2, _, artifactPubPEM2, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + artifactPubKey2, err := ParseArtifactPubKey(artifactPubPEM2) + require.NoError(t, err) + + // Create revocation list with first key revoked + emptyRevocation, _, err := CreateRevocationList(*rootKey, defaultRevocationListExpiration) + require.NoError(t, err) + + parsedRevocation, err := ParseRevocationList(emptyRevocation) + require.NoError(t, err) + + revocationData, revocationSig, err := ExtendRevocationList(*rootKey, *parsedRevocation, artifactPubKey1.Metadata.ID, defaultRevocationListExpiration) + require.NoError(t, err) + + // Bundle both keys + artifactKeysBundle, artifactKeysSig, err := BundleArtifactKeys(rootKey, []PublicKey{artifactPubKey1, artifactPubKey2}) + require.NoError(t, err) + + // Create artifact signed by second key (not revoked) + artifactPath := filepath.Join(tempDir, "test.bin") + artifactData := []byte("test data") + err = os.WriteFile(artifactPath, artifactData, 0644) + require.NoError(t, err) + + artifactSigData, err := SignData(*artifactKey2, artifactData) + require.NoError(t, err) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/artifact-signatures/keys/" + revocationFileName: + _, _ = w.Write(revocationData) + case "/artifact-signatures/keys/" + revocationSignFileName: + _, _ = w.Write(revocationSig) + case "/artifact-signatures/keys/" + artifactPubKeysFileName: + _, _ = w.Write(artifactKeysBundle) + case "/artifact-signatures/keys/" + artifactPubKeysSigFileName: + _, _ = w.Write(artifactKeysSig) + case "/artifact-signatures/tag/v1.0.0/test.bin.sig": + _, _ = w.Write(artifactSigData) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + rootPubKey := PublicKey{ + Key: rootKey.Key.Public().(ed25519.PublicKey), + Metadata: rootKey.Metadata, + } + + av, err := newArtifactVerify(server.URL+"/artifact-signatures", []PublicKey{rootPubKey}) + require.NoError(t, err) + + ctx := context.Background() + err = av.Verify(ctx, "1.0.0", artifactPath) + // Should succeed because second key is not revoked + assert.NoError(t, err) +} + +func TestArtifactVerify_TamperedArtifact(t *testing.T) { + tempDir := t.TempDir() + + // Generate root key and artifact key + rootKey, _, _, err := GenerateRootKey(10 * 365 * 24 * time.Hour) + require.NoError(t, err) + + artifactKey, _, artifactPubPEM, _, err := GenerateArtifactKey(rootKey, 30*24*time.Hour) + require.NoError(t, err) + artifactPubKey, err := ParseArtifactPubKey(artifactPubPEM) + require.NoError(t, err) + + // Create revocation list + revocationData, revocationSig, err := CreateRevocationList(*rootKey, defaultRevocationListExpiration) + require.NoError(t, err) + + // Bundle keys + artifactKeysBundle, artifactKeysSig, err := BundleArtifactKeys(rootKey, []PublicKey{artifactPubKey}) + require.NoError(t, err) + + // Sign original data + originalData := []byte("original data") + artifactSigData, err := SignData(*artifactKey, originalData) + require.NoError(t, err) + + // Write tampered data to file + artifactPath := filepath.Join(tempDir, "test.bin") + tamperedData := []byte("tampered data") + err = os.WriteFile(artifactPath, tamperedData, 0644) + require.NoError(t, err) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/artifact-signatures/keys/" + revocationFileName: + _, _ = w.Write(revocationData) + case "/artifact-signatures/keys/" + revocationSignFileName: + _, _ = w.Write(revocationSig) + case "/artifact-signatures/keys/" + artifactPubKeysFileName: + _, _ = w.Write(artifactKeysBundle) + case "/artifact-signatures/keys/" + artifactPubKeysSigFileName: + _, _ = w.Write(artifactKeysSig) + case "/artifact-signatures/tag/v1.0.0/test.bin.sig": + _, _ = w.Write(artifactSigData) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + rootPubKey := PublicKey{ + Key: rootKey.Key.Public().(ed25519.PublicKey), + Metadata: rootKey.Metadata, + } + + av, err := newArtifactVerify(server.URL+"/artifact-signatures", []PublicKey{rootPubKey}) + require.NoError(t, err) + + ctx := context.Background() + err = av.Verify(ctx, "1.0.0", artifactPath) + // Should fail because artifact was tampered + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to validate artifact") +} + +// Test URL validation + +func TestArtifactVerify_URLParsing(t *testing.T) { + tests := []struct { + name string + keysBaseURL string + expectError bool + }{ + { + name: "Valid HTTP URL", + keysBaseURL: "http://example.com/artifact-signatures", + expectError: false, + }, + { + name: "Valid HTTPS URL", + keysBaseURL: "https://example.com/artifact-signatures", + expectError: false, + }, + { + name: "URL with port", + keysBaseURL: "http://localhost:8080/artifact-signatures", + expectError: false, + }, + { + name: "Invalid URL", + keysBaseURL: "://invalid", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := newArtifactVerify(tt.keysBaseURL, nil) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/client/internal/updater/supported_darwin.go b/client/internal/updater/supported_darwin.go new file mode 100644 index 000000000..b27754366 --- /dev/null +++ b/client/internal/updater/supported_darwin.go @@ -0,0 +1,22 @@ +package updater + +import ( + "context" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/updater/installer" +) + +func isAutoUpdateSupported() bool { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + isBrew := !installer.TypeOfInstaller(ctx).Downloadable() + if isBrew { + log.Warnf("auto-update disabled on Homebrew installation") + return false + } + return true +} diff --git a/client/internal/updater/supported_other.go b/client/internal/updater/supported_other.go new file mode 100644 index 000000000..e09e8c3a3 --- /dev/null +++ b/client/internal/updater/supported_other.go @@ -0,0 +1,7 @@ +//go:build !windows && !darwin + +package updater + +func isAutoUpdateSupported() bool { + return false +} diff --git a/client/internal/updater/supported_windows.go b/client/internal/updater/supported_windows.go new file mode 100644 index 000000000..0c28878c7 --- /dev/null +++ b/client/internal/updater/supported_windows.go @@ -0,0 +1,5 @@ +package updater + +func isAutoUpdateSupported() bool { + return true +} diff --git a/client/internal/updater/update.go b/client/internal/updater/update.go new file mode 100644 index 000000000..3056c77e1 --- /dev/null +++ b/client/internal/updater/update.go @@ -0,0 +1,11 @@ +package updater + +import v "github.com/hashicorp/go-version" + +type UpdateInterface interface { + StopWatch() + SetDaemonVersion(newVersion string) bool + SetOnUpdateListener(updateFn func()) + LatestVersion() *v.Version + StartFetcher() +} diff --git a/client/internal/wg_iface_monitor.go b/client/internal/wg_iface_monitor.go index 78d70c15b..2a2fa2366 100644 --- a/client/internal/wg_iface_monitor.go +++ b/client/internal/wg_iface_monitor.go @@ -6,9 +6,10 @@ import ( "fmt" "net" "runtime" - "time" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/iface/netstack" ) // WGIfaceMonitor monitors the WireGuard interface lifecycle and restarts the engine @@ -26,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) @@ -35,6 +40,11 @@ func (m *WGIfaceMonitor) Start(ctx context.Context, ifaceName string) (shouldRes return false, errors.New("not supported on mobile platforms") } + if netstack.IsEnabled() { + log.Debugf("Interface monitor: skipped in netstack mode") + return false, nil + } + if ifaceName == "" { log.Debugf("Interface monitor: empty interface name, skipping monitor") return false, errors.New("empty interface name") @@ -49,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 fa1c89aab..a2f123900 100644 --- a/client/ios/NetBirdSDK/client.go +++ b/client/ios/NetBirdSDK/client.go @@ -1,9 +1,12 @@ +//go:build ios + package NetBirdSDK import ( "context" "fmt" "net/netip" + "os" "sort" "strings" "sync" @@ -14,14 +17,16 @@ import ( "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/system" "github.com/netbirdio/netbird/formatter" - "github.com/netbirdio/netbird/shared/management/domain" "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 @@ -47,10 +52,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() { @@ -61,6 +68,8 @@ func init() { type Client struct { cfgFile string stateFile string + cacheDir string + logFilePath string recorder *peer.Status ctxCancel context.CancelFunc ctxCancelLock *sync.Mutex @@ -71,14 +80,21 @@ type Client struct { 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 + + stateMu sync.RWMutex + connectClient *internal.ConnectClient + config *profilemanager.Config } // 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 { return &Client{ cfgFile: cfgFile, stateFile: stateFile, + cacheDir: cacheDir, + logFilePath: logFilePath, deviceName: deviceName, osName: osName, osVersion: osVersion, @@ -89,16 +105,44 @@ func NewClient(cfgFile, stateFile, deviceName string, osVersion string, osName s } } +// 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. +func (c *Client) SetConfigFromJSON(jsonStr string) error { + cfg, err := profilemanager.ConfigFromJSON(jsonStr) + if err != nil { + log.Errorf("SetConfigFromJSON: failed to parse config JSON: %v", err) + return err + } + c.preloadedConfig = cfg + log.Infof("SetConfigFromJSON: config loaded successfully from JSON") + return nil +} + // Run start the internal client. It is a blocker function -func (c *Client) Run(fd int32, interfaceName string) error { +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) - cfg, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ - ConfigPath: c.cfgFile, - StateFilePath: c.stateFile, - }) - if err != nil { - return err + + 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 + } } c.recorder.UpdateManagementAddress(cfg.ManagementURL.String()) c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive) @@ -116,7 +160,7 @@ func (c *Client) Run(fd int32, interfaceName string) error { c.ctxCancelLock.Unlock() auth := NewAuthWithConfig(ctx, cfg) - err = auth.Login() + err = auth.LoginSync() if err != nil { return err } @@ -127,8 +171,13 @@ func (c *Client) Run(fd int32, interfaceName string) error { c.onHostDnsFn = func([]string) {} cfg.WgIface = interfaceName - c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder) - return c.connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile) + connectClient := internal.NewConnectClient(ctx, cfg, c.recorder) + 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 @@ -140,6 +189,87 @@ func (c *Client) Stop() { } c.ctxCancel() + c.setState(nil, nil) +} + +// DebugBundle generates a debug bundle, uploads it and returns the upload key. +// It works with or without a running engine: when the engine is up it reuses +// the live config, sync response and client metrics; otherwise it loads the +// config from disk (or the preloaded tvOS config). +func (c *Client) DebugBundle(anonymize bool) (string, error) { + cfg, cc := c.stateSnapshot() + + // If the engine hasn't been started, load config so we can reach management. + if cfg == nil { + if c.preloadedConfig != nil { + cfg = c.preloadedConfig + } else { + var err error + // Use DirectUpdateOrCreateConfig to avoid atomic file operations + // (temp file + rename) blocked by the tvOS sandbox. + cfg, err = profilemanager.DirectUpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: c.cfgFile, + StateFilePath: c.stateFile, + }) + if err != nil { + return "", fmt.Errorf("load config: %w", err) + } + } + } + + 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, + IncludeSystemInfo: true, + }, + ) + + path, err := bundleGenerator.Generate() + if err != nil { + return "", fmt.Errorf("generate debug bundle: %w", err) + } + defer func() { + if err := os.Remove(path); err != nil { + log.Errorf("failed to remove debug bundle file: %v", err) + } + }() + + uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path) + if err != nil { + return "", fmt.Errorf("upload debug bundle: %w", err) + } + + log.Infof("debug bundle uploaded with key %s", key) + return key, nil } // SetTraceLogLevel configure the logger to trace level @@ -161,6 +291,7 @@ func (c *Client) GetStatusDetails() *StatusDetails { } pi := PeerInfo{ IP: p.IP, + IPv6: p.IPv6, FQDN: p.FQDN, LocalIceCandidateEndpoint: p.LocalIceCandidateEndpoint, RemoteIceCandidateEndpoint: p.RemoteIceCandidateEndpoint, @@ -179,7 +310,7 @@ 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 @@ -192,6 +323,16 @@ 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 @@ -204,14 +345,52 @@ func (c *Client) IsLoginRequired() bool { defer c.ctxCancelLock.Unlock() ctx, c.ctxCancel = context.WithCancel(ctxWithValues) - cfg, _ := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ - ConfigPath: c.cfgFile, - }) + var cfg *profilemanager.Config + var err error - needsLogin, _ := internal.IsLoginRequired(ctx, cfg) + // 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") + return true + } + + authClient, err := auth.NewAuth(ctx, cfg.PrivateKey, cfg.ManagementURL, cfg) + if err != nil { + log.Errorf("IsLoginRequired: failed to create auth client: %v", err) + return true // Assume login is required if we can't create auth client + } + defer authClient.Close() + + needsLogin, err := authClient.IsLoginRequired(ctx) + if err != nil { + log.Errorf("IsLoginRequired: check failed: %v", err) + // If the check fails, assume login is required to be safe + return true + } + log.Infof("IsLoginRequired: needsLogin=%v", needsLogin) return needsLogin } +// loginForMobileAuthTimeout is the timeout for requesting auth info from the server +const loginForMobileAuthTimeout = 30 * time.Second + func (c *Client) LoginForMobile() string { var ctx context.Context //nolint @@ -224,31 +403,48 @@ func (c *Client) LoginForMobile() string { defer c.ctxCancelLock.Unlock() ctx, c.ctxCancel = context.WithCancel(ctxWithValues) - cfg, _ := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + // 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("LoginForMobile: failed to load config: %v", err) + return fmt.Sprintf("failed to load config: %v", err) + } - oAuthFlow, err := auth.NewOAuthFlow(ctx, cfg, false, "") + oAuthFlow, err := auth.NewOAuthFlow(ctx, cfg, false, false, "") if err != nil { return err.Error() } - flowInfo, err := oAuthFlow.RequestAuthInfo(context.TODO()) + // Use a bounded timeout for the auth info request to prevent indefinite hangs + authInfoCtx, authInfoCancel := context.WithTimeout(ctx, loginForMobileAuthTimeout) + defer authInfoCancel() + + flowInfo, err := oAuthFlow.RequestAuthInfo(authInfoCtx) if err != nil { return err.Error() } // This could cause a potential race condition with loading the extension which need to be handled on swift side go func() { - waitTimeout := time.Duration(flowInfo.ExpiresIn) * time.Second - waitCTX, cancel := context.WithTimeout(ctx, waitTimeout) - defer cancel() - tokenInfo, err := oAuthFlow.WaitToken(waitCTX, flowInfo) + tokenInfo, err := oAuthFlow.WaitToken(ctx, flowInfo) if err != nil { + log.Errorf("LoginForMobile: WaitToken failed: %v", err) return } jwtToken := tokenInfo.GetTokenToUse() - _ = internal.Login(ctx, cfg, "", jwtToken) + authClient, err := auth.NewAuth(ctx, cfg.PrivateKey, cfg.ManagementURL, cfg) + if err != nil { + log.Errorf("LoginForMobile: failed to create auth client: %v", err) + return + } + defer authClient.Close() + if err, _ := authClient.Login(ctx, "", jwtToken); err != nil { + log.Errorf("LoginForMobile: Login failed: %v", err) + return + } c.loginComplete = true }() @@ -264,84 +460,164 @@ func (c *Client) ClearLoginComplete() { } 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, }) } @@ -350,11 +626,12 @@ 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") } @@ -367,7 +644,9 @@ func (c *Client) SelectRoute(id string) error { } 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 { + routesMap := routeManager.GetClientRoutesWithNetID() + routes = route.ExpandV6ExitPairs(routes, routesMap) + if err := routeSelector.SelectRoutes(routes, true, maps.Keys(routesMap)); err != nil { log.Debugf("error when selecting routes: %s", err) return fmt.Errorf("select routes: %w", err) } @@ -378,10 +657,11 @@ func (c *Client) SelectRoute(id string) error { } 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") } @@ -394,7 +674,9 @@ func (c *Client) DeselectRoute(id string) error { } else { log.Debugf("deselect route with id: %s", id) routes := toNetIDs([]string{id}) - if err := routeSelector.DeselectRoutes(routes, maps.Keys(routeManager.GetClientRoutesWithNetID())); err != nil { + routesMap := routeManager.GetClientRoutesWithNetID() + routes = route.ExpandV6ExitPairs(routes, routesMap) + if err := routeSelector.DeselectRoutes(routes, maps.Keys(routesMap)); err != nil { log.Debugf("error when deselecting routes: %s", err) return fmt.Errorf("deselect routes: %w", err) } @@ -403,6 +685,22 @@ func (c *Client) DeselectRoute(id string) error { return nil } +// setState stores the running engine state so DebugBundle can reuse the live +// config and ConnectClient. It is cleared on Stop. +func (c *Client) setState(cfg *profilemanager.Config, cc *internal.ConnectClient) { + c.stateMu.Lock() + defer c.stateMu.Unlock() + c.config = cfg + c.connectClient = cc +} + +// 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, ".") @@ -433,3 +731,19 @@ func toNetIDs(routes []string) []route.NetID { } return netIDs } + +func exportEnvList(list *EnvList) { + if list == nil { + return + } + for k, v := range list.AllItems() { + log.Debugf("Env variable %s's value is currently: %s", k, os.Getenv(k)) + log.Debugf("Setting env variable %s: %s", k, v) + + if err := os.Setenv(k, v); err != nil { + log.Errorf("could not set env variable %s: %v", k, err) + } else { + log.Debugf("Env variable %s was set successfully", k) + } + } +} diff --git a/client/ios/NetBirdSDK/env_list.go b/client/ios/NetBirdSDK/env_list.go new file mode 100644 index 000000000..a3ffa0ebe --- /dev/null +++ b/client/ios/NetBirdSDK/env_list.go @@ -0,0 +1,47 @@ +//go:build ios + +package NetBirdSDK + +import ( + "github.com/netbirdio/netbird/client/internal/lazyconn" + "github.com/netbirdio/netbird/client/internal/peer" +) + +// EnvList is an exported struct to be bound by gomobile +type EnvList struct { + data map[string]string +} + +// NewEnvList creates a new EnvList +func NewEnvList() *EnvList { + return &EnvList{data: make(map[string]string)} +} + +// Put adds a key-value pair +func (el *EnvList) Put(key, value string) { + el.data[key] = value +} + +// Get retrieves a value by key +func (el *EnvList) Get(key string) string { + return el.data[key] +} + +func (el *EnvList) AllItems() map[string]string { + return el.data +} + +// GetEnvKeyNBForceRelay Exports the environment variable for the iOS client +func GetEnvKeyNBForceRelay() string { + return peer.EnvKeyNBForceRelay +} + +// GetEnvKeyNBLazyConn Exports the environment variable for the iOS client +func GetEnvKeyNBLazyConn() string { + return lazyconn.EnvLazyConn +} + +// GetEnvKeyNBInactivityThreshold Exports the environment variable for the iOS client +func GetEnvKeyNBInactivityThreshold() string { + return lazyconn.EnvInactivityThreshold +} diff --git a/client/ios/NetBirdSDK/gomobile.go b/client/ios/NetBirdSDK/gomobile.go index 9eadd6a7f..79bf0c2ac 100644 --- a/client/ios/NetBirdSDK/gomobile.go +++ b/client/ios/NetBirdSDK/gomobile.go @@ -1,3 +1,5 @@ +//go:build ios + package NetBirdSDK import _ "golang.org/x/mobile/bind" diff --git a/client/ios/NetBirdSDK/logger.go b/client/ios/NetBirdSDK/logger.go index f1ad1b9f6..531d0ba89 100644 --- a/client/ios/NetBirdSDK/logger.go +++ b/client/ios/NetBirdSDK/logger.go @@ -1,3 +1,5 @@ +//go:build ios + package NetBirdSDK import ( diff --git a/client/ios/NetBirdSDK/login.go b/client/ios/NetBirdSDK/login.go index 570c44f80..99486839b 100644 --- a/client/ios/NetBirdSDK/login.go +++ b/client/ios/NetBirdSDK/login.go @@ -1,3 +1,5 @@ +//go:build ios + package NetBirdSDK import ( @@ -5,13 +7,9 @@ import ( "fmt" "time" - "github.com/cenkalti/backoff/v4" log "github.com/sirupsen/logrus" - "google.golang.org/grpc/codes" - gstatus "google.golang.org/grpc/status" - "github.com/netbirdio/netbird/client/cmd" - "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/system" ) @@ -31,12 +29,14 @@ type ErrListener interface { // URLOpener it is a callback interface. The Open function will be triggered if // the backend want to show an url for the user type URLOpener interface { - Open(string) + Open(url string, userCode string) + OnLoginSuccess() } // Auth can register or login new client type Auth struct { ctx context.Context + cancel context.CancelFunc config *profilemanager.Config cfgPath string } @@ -44,16 +44,42 @@ type Auth struct { // NewAuth instantiate Auth struct and validate the management URL func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { inputCfg := profilemanager.ConfigInput{ + ConfigPath: cfgPath, 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 } + // 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. + ctx, cancel := context.WithCancel(context.Background()) + return &Auth{ - ctx: context.Background(), + ctx: ctx, + cancel: cancel, config: cfg, cfgPath: cfgPath, }, nil @@ -61,100 +87,264 @@ func NewAuth(cfgPath string, mgmURL string) (*Auth, error) { // 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, } } +// 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 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. -func (a *Auth) SaveConfigIfSSOSupported() (bool, error) { - supportsSSO := true - err := a.withBackOff(a.ctx, func() (err error) { - _, err = internal.GetDeviceAuthorizationFlowInfo(a.ctx, a.config.PrivateKey, a.config.ManagementURL) - if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.NotFound || s.Code() == codes.Unimplemented) { - _, err = internal.GetPKCEAuthorizationFlowInfo(a.ctx, a.config.PrivateKey, a.config.ManagementURL, nil) - if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.NotFound || s.Code() == codes.Unimplemented) { - supportsSSO = false - err = nil - } - - return err +func (a *Auth) SaveConfigIfSSOSupported(listener SSOListener) { + if listener == nil { + log.Errorf("SaveConfigIfSSOSupported: listener is nil") + return + } + go func() { + sso, err := a.saveConfigIfSSOSupported() + if err != nil { + listener.OnError(err) + } else { + listener.OnSuccess(sso) } + }() +} - return err - }) +func (a *Auth) saveConfigIfSSOSupported() (bool, error) { + authClient, err := auth.NewAuth(a.ctx, a.config.PrivateKey, a.config.ManagementURL, a.config) + if err != nil { + return false, fmt.Errorf("failed to create auth client: %v", err) + } + defer authClient.Close() + + supportsSSO, err := authClient.IsSSOSupported(a.ctx) + if err != nil { + return false, fmt.Errorf("failed to check SSO support: %v", err) + } if !supportsSSO { return false, nil } - if err != nil { - return false, fmt.Errorf("backoff cycle failed: %v", err) - } - - err = profilemanager.WriteOutConfig(a.cfgPath, a.config) + // 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 } // LoginWithSetupKeyAndSaveConfig test the connectivity with the management server with the setup key. -func (a *Auth) LoginWithSetupKeyAndSaveConfig(setupKey string, deviceName string) error { - //nolint - ctxWithValues := context.WithValue(a.ctx, system.DeviceNameCtxKey, deviceName) - - err := a.withBackOff(a.ctx, func() error { - backoffErr := internal.Login(ctxWithValues, a.config, setupKey, "") - if s, ok := gstatus.FromError(backoffErr); ok && (s.Code() == codes.PermissionDenied) { - // we got an answer from management, exit backoff earlier - return backoff.Permanent(backoffErr) - } - return backoffErr - }) - if err != nil { - return fmt.Errorf("backoff cycle failed: %v", err) +func (a *Auth) LoginWithSetupKeyAndSaveConfig(resultListener ErrListener, setupKey string, deviceName string) { + if resultListener == nil { + log.Errorf("LoginWithSetupKeyAndSaveConfig: resultListener is nil") + return } - - return profilemanager.WriteOutConfig(a.cfgPath, a.config) + go func() { + err := a.loginWithSetupKeyAndSaveConfig(setupKey, deviceName) + if err != nil { + resultListener.OnError(err) + } else { + resultListener.OnSuccess() + } + }() } -func (a *Auth) Login() error { - var needsLogin bool +func (a *Auth) loginWithSetupKeyAndSaveConfig(setupKey string, deviceName string) error { + authClient, err := auth.NewAuth(a.ctx, a.config.PrivateKey, a.config.ManagementURL, a.config) + if err != nil { + return fmt.Errorf("failed to create auth client: %v", err) + } + defer authClient.Close() + + //nolint + ctxWithValues := context.WithValue(a.ctx, system.DeviceNameCtxKey, deviceName) + err, _ = authClient.Login(ctxWithValues, setupKey, "") + 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) +} + +// LoginSync performs a synchronous login check without UI interaction +// Used for background VPN connection where user should already be authenticated +func (a *Auth) LoginSync() error { + authClient, err := auth.NewAuth(a.ctx, a.config.PrivateKey, a.config.ManagementURL, a.config) + if err != nil { + return fmt.Errorf("failed to create auth client: %v", err) + } + defer authClient.Close() // check if we need to generate JWT token - err := a.withBackOff(a.ctx, func() (err error) { - needsLogin, err = internal.IsLoginRequired(a.ctx, a.config) - return - }) + needsLogin, err := authClient.IsLoginRequired(a.ctx) if err != nil { - return fmt.Errorf("backoff cycle failed: %v", err) + return fmt.Errorf("failed to check login requirement: %v", err) } jwtToken := "" if needsLogin { - return fmt.Errorf("Not authenticated") + return fmt.Errorf("not authenticated") } - err = a.withBackOff(a.ctx, func() error { - err := internal.Login(a.ctx, a.config, "", jwtToken) - if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.InvalidArgument || s.Code() == codes.PermissionDenied) { - return nil - } - return err - }) + err, isAuthError := authClient.Login(a.ctx, "", jwtToken) if err != nil { - return fmt.Errorf("backoff cycle failed: %v", err) + if isAuthError { + // PermissionDenied means registration is required or peer is blocked + return fmt.Errorf("authentication error: %v", err) + } + return fmt.Errorf("login failed: %v", err) } return nil } -func (a *Auth) withBackOff(ctx context.Context, bf func() error) error { - return backoff.RetryNotify( - bf, - backoff.WithContext(cmd.CLIBackOffSettings, ctx), - func(err error, duration time.Duration) { - log.Warnf("retrying Login to the Management service in %v due to error %v", duration, err) - }) +// Login performs interactive login with device authentication support +// Deprecated: Use LoginWithDeviceName instead to ensure proper device naming on tvOS +func (a *Auth) Login(resultListener ErrListener, urlOpener URLOpener, forceDeviceAuth bool) { + // Use empty device name - system will use hostname as fallback + a.LoginWithDeviceName(resultListener, urlOpener, forceDeviceAuth, "") +} + +// 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) { + if resultListener == nil { + log.Errorf("LoginWithDeviceName: resultListener is nil") + return + } + if urlOpener == nil { + log.Errorf("LoginWithDeviceName: urlOpener is nil") + resultListener.OnError(fmt.Errorf("urlOpener is nil")) + return + } + go func() { + err := a.login(urlOpener, forceDeviceAuth, deviceName) + if err != nil { + resultListener.OnError(err) + } else { + resultListener.OnSuccess() + } + }() +} + +func (a *Auth) login(urlOpener URLOpener, forceDeviceAuth bool, deviceName string) error { + // Create context with device name if provided + ctx := a.ctx + if deviceName != "" { + //nolint:staticcheck + ctx = context.WithValue(a.ctx, system.DeviceNameCtxKey, deviceName) + } + + authClient, err := auth.NewAuth(ctx, a.config.PrivateKey, a.config.ManagementURL, a.config) + if err != nil { + return fmt.Errorf("failed to create auth client: %v", err) + } + 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) + } + + jwtToken := "" + if needsLogin { + tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, forceDeviceAuth) + if err != nil { + return fmt.Errorf("interactive sso login failed: %v", err) + } + jwtToken = tokenInfo.GetTokenToUse() + } + + err, isAuthError := authClient.Login(ctx, "", jwtToken) + if err != nil { + if isAuthError { + // PermissionDenied means registration is required or peer is blocked + return fmt.Errorf("authentication error: %v", err) + } + 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) + } + } + + // Notify caller of successful login synchronously before returning + urlOpener.OnLoginSuccess() + + return nil +} + +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) + if err != nil { + return nil, fmt.Errorf("failed to get OAuth flow: %v", err) + } + + // Use a bounded timeout for the auth info request to prevent indefinite hangs + authInfoCtx, authInfoCancel := context.WithTimeout(a.ctx, authInfoRequestTimeout) + defer authInfoCancel() + + flowInfo, err := oAuthFlow.RequestAuthInfo(authInfoCtx) + if err != nil { + return nil, fmt.Errorf("getting a request OAuth flow info failed: %v", err) + } + + urlOpener.Open(flowInfo.VerificationURIComplete, flowInfo.UserCode) + + waitTimeout := time.Duration(flowInfo.ExpiresIn) * time.Second + waitCTX, cancel := context.WithTimeout(a.ctx, waitTimeout) + defer cancel() + tokenInfo, err := oAuthFlow.WaitToken(waitCTX, flowInfo) + if err != nil { + return nil, fmt.Errorf("waiting for browser login failed: %v", err) + } + + 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). +func (a *Auth) GetConfigJSON() (string, error) { + if a.config == nil { + return "", fmt.Errorf("no config available") + } + return profilemanager.ConfigToJSON(a.config) +} + +// SetConfigFromJSON loads config from a JSON string. +// This can be used to restore config from alternative storage mechanisms. +func (a *Auth) SetConfigFromJSON(jsonStr string) error { + cfg, err := profilemanager.ConfigFromJSON(jsonStr) + if err != nil { + return err + } + a.config = cfg + return nil } diff --git a/client/ios/NetBirdSDK/peer_notifier.go b/client/ios/NetBirdSDK/peer_notifier.go index 16c5039eb..025cd94cd 100644 --- a/client/ios/NetBirdSDK/peer_notifier.go +++ b/client/ios/NetBirdSDK/peer_notifier.go @@ -1,8 +1,11 @@ +//go:build ios + 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 @@ -21,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 @@ -55,6 +63,7 @@ type StatusDetails struct { items []PeerInfo fqdn string ip string + ipv6 string } // Add new PeerInfo to the collection @@ -98,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 5e7050465..ed49ccddb 100644 --- a/client/ios/NetBirdSDK/preferences.go +++ b/client/ios/NetBirdSDK/preferences.go @@ -1,3 +1,5 @@ +//go:build ios + package NetBirdSDK import ( @@ -108,8 +110,28 @@ 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 +} + // Commit write out the changes into config file func (p *Preferences) Commit() error { - _, err := profilemanager.UpdateOrCreateConfig(p.configInput) + // 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) return err } diff --git a/client/ios/NetBirdSDK/preferences_test.go b/client/ios/NetBirdSDK/preferences_test.go index 780443a7b..5f75e7c9a 100644 --- a/client/ios/NetBirdSDK/preferences_test.go +++ b/client/ios/NetBirdSDK/preferences_test.go @@ -1,3 +1,5 @@ +//go:build ios + package NetBirdSDK import ( diff --git a/client/ios/NetBirdSDK/routes.go b/client/ios/NetBirdSDK/routes.go index 30d0d0d0a..56af2a1ad 100644 --- a/client/ios/NetBirdSDK/routes.go +++ b/client/ios/NetBirdSDK/routes.go @@ -1,3 +1,5 @@ +//go:build ios + package NetBirdSDK // RoutesSelectionInfoCollection made for Java layer to get non default types as collection @@ -18,6 +20,7 @@ type RoutesSelectionInfo struct { Network string Domains *DomainDetails Selected bool + Status string } type DomainCollection interface { @@ -32,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 new file mode 100644 index 000000000..e29cc8840 --- /dev/null +++ b/client/jobexec/executor.go @@ -0,0 +1,76 @@ +package jobexec + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/debug" + "github.com/netbirdio/netbird/upload-server/types" +) + +const ( + MaxBundleWaitTime = 60 * time.Minute // maximum wait time for bundle generation (1 hour) +) + +var ( + ErrJobNotImplemented = errors.New("job not implemented") +) + +type Executor struct { +} + +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) { + if waitForDuration > MaxBundleWaitTime { + log.Warnf("bundle wait time %v exceeds maximum %v, capping to maximum", waitForDuration, MaxBundleWaitTime) + waitForDuration = MaxBundleWaitTime + } + + if waitForDuration > 0 { + if err := waitFor(ctx, waitForDuration); err != nil { + return "", err + } + } + + log.Infof("execute debug bundle generation") + + bundleGenerator := debug.NewBundleGenerator(debugBundleDependencies, params) + + 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) + } + }() + + key, err := debug.UploadDebugBundle(ctx, types.DefaultBundleURL, mgmURL, path) + if err != nil { + log.Errorf("failed to upload debug bundle: %v", err) + return "", fmt.Errorf("upload debug bundle: %w", err) + } + + log.Infof("debug bundle has been generated successfully") + return key, nil +} + +func waitFor(ctx context.Context, duration time.Duration) error { + log.Infof("wait for %v minutes before executing debug bundle", duration.Minutes()) + select { + case <-time.After(duration): + return nil + case <-ctx.Done(): + log.Infof("wait cancelled: %v", ctx.Err()) + return ctx.Err() + } +} diff --git a/client/mdm/canonical_loaders.go b/client/mdm/canonical_loaders.go new file mode 100644 index 000000000..29288b511 --- /dev/null +++ b/client/mdm/canonical_loaders.go @@ -0,0 +1,53 @@ +//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, + KeySplitTunnelMode, + KeySplitTunnelApps, + KeyLazyConnection, +} + +// 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/policy.go b/client/mdm/policy.go new file mode 100644 index 000000000..1feff28f8 --- /dev/null +++ b/client/mdm/policy.go @@ -0,0 +1,268 @@ +// 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" + + // 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" +) + +// 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: {}, +} + +// 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} +} + +// LoadPolicy reads the platform-native MDM configuration. Returns an +// empty (but non-nil) Policy when no source is present, the source is +// empty, or the platform is unsupported. +// +// Diagnostic logging differentiates the three states: +// - source absent / unsupported platform: trace log only +// - source present, zero keys: info "MDM enrolled (no managed keys)" +// - source present, N keys: info "MDM enrolled with N managed keys: [...]" +func LoadPolicy() *Policy { + values, err := loadPlatformPolicy() + 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 + } + 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 LoadPolicy'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..57aa1168c --- /dev/null +++ b/client/mdm/policy_darwin.go @@ -0,0 +1,90 @@ +//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" + +// loadPlatformPolicy reads the MDM-managed configuration from the macOS +// managed-preferences plist at policyPlistPath. 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 loadPlatformPolicy() (map[string]any, error) { + f, err := os.Open(policyPlistPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + // Not enrolled for NetBird. Caller treats nil as + // "no MDM source present". + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. + 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..ec25d4bb1 --- /dev/null +++ b/client/mdm/policy_mobile.go @@ -0,0 +1,14 @@ +//go:build ios || android + +package mdm + +// loadPlatformPolicy is unused on mobile: the native layer (Swift on iOS, +// Kotlin/Java on Android) reads the OS managed-config store and pushes the +// resulting dictionary in-process via a gomobile entry point that lands in +// Phase 5 / Phase 6. The stub keeps the package compilable for mobile +// builds and returns (nil, nil) — the platform-absent sentinel that +// LoadPolicy in policy.go treats as "no MDM source present". +func loadPlatformPolicy() (map[string]any, error) { + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. + return nil, nil +} diff --git a/client/mdm/policy_other.go b/client/mdm/policy_other.go new file mode 100644 index 000000000..f4263afa2 --- /dev/null +++ b/client/mdm/policy_other.go @@ -0,0 +1,14 @@ +//go:build !windows && !darwin && !ios && !android + +package mdm + +// loadPlatformPolicy returns no policy on platforms without an MDM channel +// (Linux, FreeBSD). MDM enforcement is off and the client behaves as if +// the feature did not exist. Returns (nil, nil) — the platform-absent +// sentinel the caller (LoadPolicy in policy.go) treats as "no MDM +// source present"; an error here would just translate to the same +// outcome with an extra log line. +func loadPlatformPolicy() (map[string]any, error) { + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. + return nil, nil +} diff --git a/client/mdm/policy_test.go b/client/mdm/policy_test.go new file mode 100644 index 000000000..6cbe69776 --- /dev/null +++ b/client/mdm/policy_test.go @@ -0,0 +1,165 @@ +package mdm + +import ( + "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 unsupported", 1.0, false, false}, + } + 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) + }) +} + +func TestLoadPolicy_PlatformStubReturnsEmpty(t *testing.T) { + // loadPlatformPolicy is a stub on every OS for Phase 1. LoadPolicy must + // degrade gracefully and never return nil. + p := LoadPolicy() + 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..0c2629f98 --- /dev/null +++ b/client/mdm/policy_windows.go @@ -0,0 +1,108 @@ +//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) + } +} + +// loadPlatformPolicy reads the MDM-managed configuration from the +// Windows registry under HKLM\Software\Policies\NetBird. 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 loadPlatformPolicy() (map[string]any, error) { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, policyRegistryPath, registry.QUERY_VALUE) + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + // Not enrolled. Caller treats nil as "no MDM source present". + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. + 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/ticker.go b/client/mdm/ticker.go new file mode 100644 index 000000000..abd6ae233 --- /dev/null +++ b/client/mdm/ticker.go @@ -0,0 +1,129 @@ +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 + +// policyLoader is the indirection through which the ticker reads the +// OS-native policy, both for the initial observation and on every tick. +// Production points it at LoadPolicy; tests in this package override it to +// feed a scripted sequence of policies without touching the real OS store. +var policyLoader = LoadPolicy + +// Ticker periodically re-reads the OS-native MDM policy via LoadPolicy and +// invokes the onChange callback (supplied to Run) whenever the observed +// Policy diverges from the last observation (added / removed / changed +// keys). Launch with Run from a goroutine; cancel the supplied context +// to stop. +type Ticker struct { + interval time.Duration + prev *Policy +} + +// NewTicker constructs a Ticker that will re-read the OS-native policy +// every reloadInterval once Run is called. +// The initial snapshot is populated by calling policyLoader at +// construction time so the first tick only fires +// onChange when the policy actually changed since boot — without +// this baseline the first tick would report every currently-managed +// key as "added" and trigger a spurious engine restart. +func NewTicker(reloadInterval time.Duration) *Ticker { + return &Ticker{ + interval: reloadInterval, + prev: policyLoader(), + } +} + +// 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 := policyLoader() + if policiesEqual(t.prev, curr) { + continue + } + added, removed, changed := diffPolicies(t.prev, curr) + log.Infof("MDM policy changed: added=%v removed=%v changed=%v", + added, removed, changed) + prev := t.prev + if err := onChange(prev, curr); err != nil { + log.Errorf("MDM policy change handler failed (retrying in 1 minute): %v", err) + 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 +} diff --git a/client/mdm/ticker_test.go b/client/mdm/ticker_test.go new file mode 100644 index 000000000..17f3cfc2f --- /dev/null +++ b/client/mdm/ticker_test.go @@ -0,0 +1,100 @@ +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 + +// withPolicyLoader overrides the package-level policyLoader for the duration +// of the test so the ticker observes a scripted policy instead of the real +// OS-native store. The original loader is restored on cleanup. +func withPolicyLoader(t *testing.T, fn func() *Policy) { + t.Helper() + prev := policyLoader + policyLoader = fn + t.Cleanup(func() { policyLoader = prev }) +} + +func TestTicker_FiresOnChangeWithDelta(t *testing.T) { + var mu sync.Mutex + current := NewPolicy(nil) // initial observation: empty (no enforcement) + withPolicyLoader(t, func() *Policy { + mu.Lock() + defer mu.Unlock() + return current + }) + + type change struct{ prev, curr *Policy } + changes := make(chan change, 1) + tk := NewTicker(testReloadInterval) + 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 policyLoader + // restore in t.Cleanup can't race the ticker goroutine still reading it. + defer func() { cancel(); <-done }() + + // Flip the OS-observed policy from empty to one managed key. The next + // tick must detect the diff and invoke onChange. + mu.Lock() + current = NewPolicy(map[string]any{KeyManagementURL: "https://mdm.example.com:443"}) + mu.Unlock() + + 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) { + withPolicyLoader(t, func() *Policy { + return NewPolicy(map[string]any{KeyBlockInbound: true}) + }) + + fired := make(chan struct{}, 1) + tk := NewTicker(testReloadInterval) + + 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/net/dialer_init_darwin.go b/client/net/dialer_init_darwin.go new file mode 100644 index 000000000..e18909ff7 --- /dev/null +++ b/client/net/dialer_init_darwin.go @@ -0,0 +1,5 @@ +package net + +func (d *Dialer) init() { + d.Dialer.Control = applyBoundIfToSocket +} diff --git a/client/net/dialer_init_generic.go b/client/net/dialer_init_generic.go index 18ebc6ad1..78973b47d 100644 --- a/client/net/dialer_init_generic.go +++ b/client/net/dialer_init_generic.go @@ -1,4 +1,4 @@ -//go:build !linux && !windows +//go:build !linux && !windows && !darwin package net diff --git a/client/net/env_android.go b/client/net/env_android.go deleted file mode 100644 index 9d89951a1..000000000 --- a/client/net/env_android.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build android - -package net - -// Init initializes the network environment for Android -func Init() { - // No initialization needed on Android -} - -// AdvancedRouting reports whether routing loops can be avoided without using exclusion routes. -// Always returns true on Android since we cannot handle routes dynamically. -func AdvancedRouting() bool { - return true -} - -// SetVPNInterfaceName is a no-op on Android -func SetVPNInterfaceName(name string) { - // No-op on Android - not needed for Android VPN service -} - -// GetVPNInterfaceName returns empty string on Android -func GetVPNInterfaceName() string { - return "" -} diff --git a/client/net/env_windows.go b/client/net/env_bound_iface.go similarity index 71% rename from client/net/env_windows.go rename to client/net/env_bound_iface.go index 7e8868ba5..593988c2c 100644 --- a/client/net/env_windows.go +++ b/client/net/env_bound_iface.go @@ -1,4 +1,4 @@ -//go:build windows +//go:build (darwin && !ios) || windows package net @@ -24,17 +24,22 @@ func Init() { } func checkAdvancedRoutingSupport() bool { - var err error - var legacyRouting bool + legacyRouting := false if val := os.Getenv(envUseLegacyRouting); val != "" { - legacyRouting, err = strconv.ParseBool(val) + parsed, err := strconv.ParseBool(val) if err != nil { - log.Warnf("failed to parse %s: %v", envUseLegacyRouting, err) + log.Warnf("ignoring unparsable %s=%q: %v", envUseLegacyRouting, val, err) + } else { + legacyRouting = parsed } } - if legacyRouting || netstack.IsEnabled() { - log.Info("advanced routing has been requested to be disabled") + if legacyRouting { + log.Infof("advanced routing disabled: legacy routing requested via %s", envUseLegacyRouting) + return false + } + if netstack.IsEnabled() { + log.Info("advanced routing disabled: netstack mode is enabled") return false } diff --git a/client/net/env_generic.go b/client/net/env_generic.go index f467930c3..18c10bb78 100644 --- a/client/net/env_generic.go +++ b/client/net/env_generic.go @@ -1,4 +1,4 @@ -//go:build !linux && !windows && !android +//go:build !linux && !windows && !darwin package net diff --git a/client/net/env_mobile.go b/client/net/env_mobile.go new file mode 100644 index 000000000..80b0fad8d --- /dev/null +++ b/client/net/env_mobile.go @@ -0,0 +1,25 @@ +//go:build ios || android + +package net + +// Init initializes the network environment for mobile platforms. +func Init() { + // no-op on mobile: routing scope is owned by the VPN extension. +} + +// AdvancedRouting reports whether routing loops can be avoided without using exclusion routes. +// Always returns true on mobile since routes cannot be handled dynamically and the VPN extension +// owns the routing scope. +func AdvancedRouting() bool { + return true +} + +// SetVPNInterfaceName is a no-op on mobile. +func SetVPNInterfaceName(string) { + // no-op on mobile: the VPN extension manages the interface. +} + +// GetVPNInterfaceName returns an empty string on mobile. +func GetVPNInterfaceName() string { + return "" +} diff --git a/client/net/listener_init_darwin.go b/client/net/listener_init_darwin.go new file mode 100644 index 000000000..f2fcc80ed --- /dev/null +++ b/client/net/listener_init_darwin.go @@ -0,0 +1,5 @@ +package net + +func (l *ListenerConfig) init() { + l.ListenConfig.Control = applyBoundIfToSocket +} diff --git a/client/net/listener_init_generic.go b/client/net/listener_init_generic.go index 4f8f17ab2..65a785222 100644 --- a/client/net/listener_init_generic.go +++ b/client/net/listener_init_generic.go @@ -1,4 +1,4 @@ -//go:build !linux && !windows +//go:build !linux && !windows && !darwin package net diff --git a/client/net/net_darwin.go b/client/net/net_darwin.go new file mode 100644 index 000000000..00d858a6a --- /dev/null +++ b/client/net/net_darwin.go @@ -0,0 +1,160 @@ +package net + +import ( + "fmt" + "net" + "net/netip" + "strconv" + "strings" + "sync" + "syscall" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/unix" +) + +// On darwin IPV6_BOUND_IF also scopes v4-mapped egress from dual-stack +// (IPV6_V6ONLY=0) AF_INET6 sockets, so a single setsockopt on "udp6"/"tcp6" +// covers both families. Setting IP_BOUND_IF on an AF_INET6 socket returns +// EINVAL regardless of V6ONLY because the IPPROTO_IP ctloutput path is +// dispatched by socket domain (AF_INET only) not by inp_vflag. + +// boundIface holds the physical interface chosen at routing setup time. Sockets +// created via nbnet.NewDialer / nbnet.NewListener bind to it via IP_BOUND_IF +// (IPv4) or IPV6_BOUND_IF (IPv6 / dual-stack) so their scoped route lookup +// hits the RTF_IFSCOPE default installed by the routemanager, rather than +// following the VPN's split default. +var ( + boundIfaceMu sync.RWMutex + boundIface4 *net.Interface + boundIface6 *net.Interface +) + +// SetBoundInterface records the egress interface for an address family. Called +// by the routemanager after a scoped default route has been installed. +// af must be unix.AF_INET or unix.AF_INET6; other values are ignored. +// nil iface is rejected — use ClearBoundInterfaces to clear all slots. +func SetBoundInterface(af int, iface *net.Interface) { + if iface == nil { + log.Warnf("SetBoundInterface: nil iface for AF %d, ignored", af) + return + } + boundIfaceMu.Lock() + defer boundIfaceMu.Unlock() + switch af { + case unix.AF_INET: + boundIface4 = iface + case unix.AF_INET6: + boundIface6 = iface + default: + log.Warnf("SetBoundInterface: unsupported address family %d", af) + } +} + +// ClearBoundInterfaces resets the cached egress interfaces. Called by the +// routemanager during cleanup. +func ClearBoundInterfaces() { + boundIfaceMu.Lock() + defer boundIfaceMu.Unlock() + boundIface4 = nil + boundIface6 = nil +} + +// boundInterfaceFor returns the cached egress interface for a socket's address +// family, falling back to the other family if the preferred slot is empty. +// The kernel stores both IP_BOUND_IF and IPV6_BOUND_IF in inp_boundifp, so +// either setsockopt scopes the socket; preferring same-family still matters +// when v4 and v6 defaults egress different NICs. +func boundInterfaceFor(network, address string) *net.Interface { + if iface := zoneInterface(address); iface != nil { + return iface + } + + boundIfaceMu.RLock() + defer boundIfaceMu.RUnlock() + + primary, secondary := boundIface4, boundIface6 + if isV6Network(network) { + primary, secondary = boundIface6, boundIface4 + } + if primary != nil { + return primary + } + return secondary +} + +func isV6Network(network string) bool { + return strings.HasSuffix(network, "6") +} + +// zoneInterface extracts an explicit interface from an IPv6 link-local zone (e.g. fe80::1%en0). +func zoneInterface(address string) *net.Interface { + if address == "" { + return nil + } + addr, err := netip.ParseAddrPort(address) + if err != nil { + a, err := netip.ParseAddr(address) + if err != nil { + return nil + } + addr = netip.AddrPortFrom(a, 0) + } + zone := addr.Addr().Zone() + if zone == "" { + return nil + } + if iface, err := net.InterfaceByName(zone); err == nil { + return iface + } + if idx, err := strconv.Atoi(zone); err == nil { + if iface, err := net.InterfaceByIndex(idx); err == nil { + return iface + } + } + return nil +} + +func setIPv4BoundIf(fd uintptr, iface *net.Interface) error { + if err := unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_BOUND_IF, iface.Index); err != nil { + return fmt.Errorf("set IP_BOUND_IF: %w (interface: %s, index: %d)", err, iface.Name, iface.Index) + } + return nil +} + +func setIPv6BoundIf(fd uintptr, iface *net.Interface) error { + if err := unix.SetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_BOUND_IF, iface.Index); err != nil { + return fmt.Errorf("set IPV6_BOUND_IF: %w (interface: %s, index: %d)", err, iface.Name, iface.Index) + } + return nil +} + +// applyBoundIfToSocket binds the socket to the cached physical egress interface +// so scoped route lookup avoids the VPN utun and egresses the underlay directly. +func applyBoundIfToSocket(network, address string, c syscall.RawConn) error { + if !AdvancedRouting() { + return nil + } + + iface := boundInterfaceFor(network, address) + if iface == nil { + log.Debugf("no bound iface cached for %s to %s, skipping BOUND_IF", network, address) + return nil + } + + isV6 := isV6Network(network) + var controlErr error + if err := c.Control(func(fd uintptr) { + if isV6 { + controlErr = setIPv6BoundIf(fd, iface) + } else { + controlErr = setIPv4BoundIf(fd, iface) + } + if controlErr == nil { + log.Debugf("set BOUND_IF=%d on %s for %s to %s", iface.Index, iface.Name, network, address) + } + }); err != nil { + return fmt.Errorf("control: %w", err) + } + return controlErr +} diff --git a/client/netbird-entrypoint.sh b/client/netbird-entrypoint.sh index 7c9fa021a..0e330bdac 100755 --- a/client/netbird-entrypoint.sh +++ b/client/netbird-entrypoint.sh @@ -1,12 +1,10 @@ #!/usr/bin/env bash set -eEuo pipefail -: ${NB_ENTRYPOINT_SERVICE_TIMEOUT:="5"} -: ${NB_ENTRYPOINT_LOGIN_TIMEOUT:="5"} +: ${NB_ENTRYPOINT_SERVICE_TIMEOUT:="30"} NETBIRD_BIN="${NETBIRD_BIN:-"netbird"}" export NB_LOG_FILE="${NB_LOG_FILE:-"console,/var/log/netbird/client.log"}" service_pids=() -log_file_path="" _log() { # mimic Go logger's output for easier parsing @@ -33,60 +31,29 @@ on_exit() { fi } -wait_for_message() { - local timeout="${1}" message="${2}" - if test "${timeout}" -eq 0; then - info "not waiting for log line ${message@Q} due to zero timeout." - elif test -n "${log_file_path}"; then - info "waiting for log line ${message@Q} for ${timeout} seconds..." - grep -E -q "${message}" <(timeout "${timeout}" tail -F "${log_file_path}" 2>/dev/null) - else - info "log file unsupported, sleeping for ${timeout} seconds..." - sleep "${timeout}" - fi -} - -locate_log_file() { - local log_files_string="${1}" - - while read -r log_file; do - case "${log_file}" in - console | syslog) ;; - *) - log_file_path="${log_file}" - return - ;; - esac - done < <(sed 's#,#\n#g' <<<"${log_files_string}") - - warn "log files parsing for ${log_files_string@Q} is not supported by debug bundles" - warn "please consider removing the \$NB_LOG_FILE or setting it to real file, before gathering debug bundles." -} - wait_for_daemon_startup() { local timeout="${1}" - - if test -n "${log_file_path}"; then - if ! wait_for_message "${timeout}" "started daemon server"; then - warn "log line containing 'started daemon server' not found after ${timeout} seconds" - warn "daemon failed to start, exiting..." - exit 1 - fi - else - warn "daemon service startup not discovered, sleeping ${timeout} instead" - sleep "${timeout}" + if [[ "${timeout}" -eq 0 ]]; then + info "not waiting for daemon startup due to zero timeout." + return fi + + local deadline=$((SECONDS + timeout)) + while [[ "${SECONDS}" -lt "${deadline}" ]]; do + if "${NETBIRD_BIN}" status --check live 2>/dev/null; then + return + fi + sleep 1 + done + + warn "daemon did not become responsive after ${timeout} seconds, exiting..." + exit 1 } -login_if_needed() { - local timeout="${1}" - - if test -n "${log_file_path}" && wait_for_message "${timeout}" 'peer has been successfully registered|management connection state READY'; then - info "already logged in, skipping 'netbird up'..." - else - info "logging in..." - "${NETBIRD_BIN}" up - fi +connect() { + info "running 'netbird up'..." + "${NETBIRD_BIN}" up + return $? } main() { @@ -95,9 +62,8 @@ main() { service_pids+=("$!") info "registered new service process 'netbird service run', currently running: ${service_pids[@]@Q}" - locate_log_file "${NB_LOG_FILE}" wait_for_daemon_startup "${NB_ENTRYPOINT_SERVICE_TIMEOUT}" - login_if_needed "${NB_ENTRYPOINT_LOGIN_TIMEOUT}" + connect wait "${service_pids[@]}" } diff --git a/client/netbird.wxs b/client/netbird.wxs index ba827debf..f30a7aa7e 100644 --- a/client/netbird.wxs +++ b/client/netbird.wxs @@ -18,13 +18,17 @@ - - + + + + + + + + - - - + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + - + diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 02f09b08a..6fbb09958 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.6 -// protoc v6.32.1 +// protoc v6.33.1 // source: daemon.proto package proto @@ -88,6 +88,61 @@ func (LogLevel) EnumDescriptor() ([]byte, []int) { return file_daemon_proto_rawDescGZIP(), []int{0} } +type ExposeProtocol int32 + +const ( + ExposeProtocol_EXPOSE_HTTP ExposeProtocol = 0 + ExposeProtocol_EXPOSE_HTTPS ExposeProtocol = 1 + ExposeProtocol_EXPOSE_TCP ExposeProtocol = 2 + ExposeProtocol_EXPOSE_UDP ExposeProtocol = 3 + ExposeProtocol_EXPOSE_TLS ExposeProtocol = 4 +) + +// Enum value maps for ExposeProtocol. +var ( + ExposeProtocol_name = map[int32]string{ + 0: "EXPOSE_HTTP", + 1: "EXPOSE_HTTPS", + 2: "EXPOSE_TCP", + 3: "EXPOSE_UDP", + 4: "EXPOSE_TLS", + } + ExposeProtocol_value = map[string]int32{ + "EXPOSE_HTTP": 0, + "EXPOSE_HTTPS": 1, + "EXPOSE_TCP": 2, + "EXPOSE_UDP": 3, + "EXPOSE_TLS": 4, + } +) + +func (x ExposeProtocol) Enum() *ExposeProtocol { + p := new(ExposeProtocol) + *p = x + return p +} + +func (x ExposeProtocol) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ExposeProtocol) Descriptor() protoreflect.EnumDescriptor { + return file_daemon_proto_enumTypes[1].Descriptor() +} + +func (ExposeProtocol) Type() protoreflect.EnumType { + return &file_daemon_proto_enumTypes[1] +} + +func (x ExposeProtocol) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ExposeProtocol.Descriptor instead. +func (ExposeProtocol) EnumDescriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{1} +} + type SystemEvent_Severity int32 const ( @@ -124,11 +179,11 @@ func (x SystemEvent_Severity) String() string { } func (SystemEvent_Severity) Descriptor() protoreflect.EnumDescriptor { - return file_daemon_proto_enumTypes[1].Descriptor() + return file_daemon_proto_enumTypes[2].Descriptor() } func (SystemEvent_Severity) Type() protoreflect.EnumType { - return &file_daemon_proto_enumTypes[1] + return &file_daemon_proto_enumTypes[2] } func (x SystemEvent_Severity) Number() protoreflect.EnumNumber { @@ -137,7 +192,7 @@ func (x SystemEvent_Severity) Number() protoreflect.EnumNumber { // Deprecated: Use SystemEvent_Severity.Descriptor instead. func (SystemEvent_Severity) EnumDescriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{49, 0} + return file_daemon_proto_rawDescGZIP(), []int{53, 0} } type SystemEvent_Category int32 @@ -179,11 +234,11 @@ func (x SystemEvent_Category) String() string { } func (SystemEvent_Category) Descriptor() protoreflect.EnumDescriptor { - return file_daemon_proto_enumTypes[2].Descriptor() + return file_daemon_proto_enumTypes[3].Descriptor() } func (SystemEvent_Category) Type() protoreflect.EnumType { - return &file_daemon_proto_enumTypes[2] + return &file_daemon_proto_enumTypes[3] } func (x SystemEvent_Category) Number() protoreflect.EnumNumber { @@ -192,7 +247,7 @@ func (x SystemEvent_Category) Number() protoreflect.EnumNumber { // Deprecated: Use SystemEvent_Category.Descriptor instead. func (SystemEvent_Category) EnumDescriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{49, 1} + return file_daemon_proto_rawDescGZIP(), []int{53, 1} } type EmptyRequest struct { @@ -280,9 +335,16 @@ type LoginRequest struct { Username *string `protobuf:"bytes,31,opt,name=username,proto3,oneof" json:"username,omitempty"` Mtu *int64 `protobuf:"varint,32,opt,name=mtu,proto3,oneof" json:"mtu,omitempty"` // hint is used to pre-fill the email/username field during SSO authentication - Hint *string `protobuf:"bytes,33,opt,name=hint,proto3,oneof" json:"hint,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Hint *string `protobuf:"bytes,33,opt,name=hint,proto3,oneof" json:"hint,omitempty"` + EnableSSHRoot *bool `protobuf:"varint,34,opt,name=enableSSHRoot,proto3,oneof" json:"enableSSHRoot,omitempty"` + EnableSSHSFTP *bool `protobuf:"varint,35,opt,name=enableSSHSFTP,proto3,oneof" json:"enableSSHSFTP,omitempty"` + EnableSSHLocalPortForwarding *bool `protobuf:"varint,36,opt,name=enableSSHLocalPortForwarding,proto3,oneof" json:"enableSSHLocalPortForwarding,omitempty"` + 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"` + DisableIpv6 *bool `protobuf:"varint,40,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *LoginRequest) Reset() { @@ -547,6 +609,55 @@ func (x *LoginRequest) GetHint() string { return "" } +func (x *LoginRequest) GetEnableSSHRoot() bool { + if x != nil && x.EnableSSHRoot != nil { + return *x.EnableSSHRoot + } + return false +} + +func (x *LoginRequest) GetEnableSSHSFTP() bool { + if x != nil && x.EnableSSHSFTP != nil { + return *x.EnableSSHSFTP + } + return false +} + +func (x *LoginRequest) GetEnableSSHLocalPortForwarding() bool { + if x != nil && x.EnableSSHLocalPortForwarding != nil { + return *x.EnableSSHLocalPortForwarding + } + return false +} + +func (x *LoginRequest) GetEnableSSHRemotePortForwarding() bool { + if x != nil && x.EnableSSHRemotePortForwarding != nil { + return *x.EnableSSHRemotePortForwarding + } + return false +} + +func (x *LoginRequest) GetDisableSSHAuth() bool { + if x != nil && x.DisableSSHAuth != nil { + return *x.DisableSSHAuth + } + return false +} + +func (x *LoginRequest) GetSshJWTCacheTTL() int32 { + if x != nil && x.SshJWTCacheTTL != nil { + return *x.SshJWTCacheTTL + } + return 0 +} + +func (x *LoginRequest) GetDisableIpv6() bool { + if x != nil && x.DisableIpv6 != nil { + return *x.DisableIpv6 + } + return false +} + type LoginResponse struct { state protoimpl.MessageState `protogen:"open.v1"` NeedsSSOLogin bool `protobuf:"varint,1,opt,name=needsSSOLogin,proto3" json:"needsSSOLogin,omitempty"` @@ -712,9 +823,15 @@ 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 } @@ -763,6 +880,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 @@ -867,8 +991,12 @@ 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() { @@ -922,6 +1050,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 @@ -1057,24 +1192,37 @@ type GetConfigResponse struct { // preSharedKey settings value. PreSharedKey string `protobuf:"bytes,4,opt,name=preSharedKey,proto3" json:"preSharedKey,omitempty"` // adminURL settings value. - AdminURL string `protobuf:"bytes,5,opt,name=adminURL,proto3" json:"adminURL,omitempty"` - InterfaceName string `protobuf:"bytes,6,opt,name=interfaceName,proto3" json:"interfaceName,omitempty"` - WireguardPort int64 `protobuf:"varint,7,opt,name=wireguardPort,proto3" json:"wireguardPort,omitempty"` - Mtu int64 `protobuf:"varint,8,opt,name=mtu,proto3" json:"mtu,omitempty"` - DisableAutoConnect bool `protobuf:"varint,9,opt,name=disableAutoConnect,proto3" json:"disableAutoConnect,omitempty"` - ServerSSHAllowed bool `protobuf:"varint,10,opt,name=serverSSHAllowed,proto3" json:"serverSSHAllowed,omitempty"` - RosenpassEnabled bool `protobuf:"varint,11,opt,name=rosenpassEnabled,proto3" json:"rosenpassEnabled,omitempty"` - RosenpassPermissive bool `protobuf:"varint,12,opt,name=rosenpassPermissive,proto3" json:"rosenpassPermissive,omitempty"` - DisableNotifications bool `protobuf:"varint,13,opt,name=disable_notifications,json=disableNotifications,proto3" json:"disable_notifications,omitempty"` - LazyConnectionEnabled bool `protobuf:"varint,14,opt,name=lazyConnectionEnabled,proto3" json:"lazyConnectionEnabled,omitempty"` - BlockInbound bool `protobuf:"varint,15,opt,name=blockInbound,proto3" json:"blockInbound,omitempty"` - NetworkMonitor bool `protobuf:"varint,16,opt,name=networkMonitor,proto3" json:"networkMonitor,omitempty"` - DisableDns bool `protobuf:"varint,17,opt,name=disable_dns,json=disableDns,proto3" json:"disable_dns,omitempty"` - DisableClientRoutes bool `protobuf:"varint,18,opt,name=disable_client_routes,json=disableClientRoutes,proto3" json:"disable_client_routes,omitempty"` - DisableServerRoutes bool `protobuf:"varint,19,opt,name=disable_server_routes,json=disableServerRoutes,proto3" json:"disable_server_routes,omitempty"` - BlockLanAccess bool `protobuf:"varint,20,opt,name=block_lan_access,json=blockLanAccess,proto3" json:"block_lan_access,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + AdminURL string `protobuf:"bytes,5,opt,name=adminURL,proto3" json:"adminURL,omitempty"` + InterfaceName string `protobuf:"bytes,6,opt,name=interfaceName,proto3" json:"interfaceName,omitempty"` + WireguardPort int64 `protobuf:"varint,7,opt,name=wireguardPort,proto3" json:"wireguardPort,omitempty"` + Mtu int64 `protobuf:"varint,8,opt,name=mtu,proto3" json:"mtu,omitempty"` + DisableAutoConnect bool `protobuf:"varint,9,opt,name=disableAutoConnect,proto3" json:"disableAutoConnect,omitempty"` + ServerSSHAllowed bool `protobuf:"varint,10,opt,name=serverSSHAllowed,proto3" json:"serverSSHAllowed,omitempty"` + RosenpassEnabled bool `protobuf:"varint,11,opt,name=rosenpassEnabled,proto3" json:"rosenpassEnabled,omitempty"` + RosenpassPermissive bool `protobuf:"varint,12,opt,name=rosenpassPermissive,proto3" json:"rosenpassPermissive,omitempty"` + DisableNotifications bool `protobuf:"varint,13,opt,name=disable_notifications,json=disableNotifications,proto3" json:"disable_notifications,omitempty"` + LazyConnectionEnabled bool `protobuf:"varint,14,opt,name=lazyConnectionEnabled,proto3" json:"lazyConnectionEnabled,omitempty"` + BlockInbound bool `protobuf:"varint,15,opt,name=blockInbound,proto3" json:"blockInbound,omitempty"` + NetworkMonitor bool `protobuf:"varint,16,opt,name=networkMonitor,proto3" json:"networkMonitor,omitempty"` + DisableDns bool `protobuf:"varint,17,opt,name=disable_dns,json=disableDns,proto3" json:"disable_dns,omitempty"` + DisableClientRoutes bool `protobuf:"varint,18,opt,name=disable_client_routes,json=disableClientRoutes,proto3" json:"disable_client_routes,omitempty"` + DisableServerRoutes bool `protobuf:"varint,19,opt,name=disable_server_routes,json=disableServerRoutes,proto3" json:"disable_server_routes,omitempty"` + BlockLanAccess bool `protobuf:"varint,20,opt,name=block_lan_access,json=blockLanAccess,proto3" json:"block_lan_access,omitempty"` + EnableSSHRoot bool `protobuf:"varint,21,opt,name=enableSSHRoot,proto3" json:"enableSSHRoot,omitempty"` + EnableSSHSFTP bool `protobuf:"varint,24,opt,name=enableSSHSFTP,proto3" json:"enableSSHSFTP,omitempty"` + EnableSSHLocalPortForwarding bool `protobuf:"varint,22,opt,name=enableSSHLocalPortForwarding,proto3" json:"enableSSHLocalPortForwarding,omitempty"` + 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"` + DisableIpv6 bool `protobuf:"varint,27,opt,name=disable_ipv6,json=disableIpv6,proto3" json:"disable_ipv6,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() { @@ -1247,6 +1395,62 @@ func (x *GetConfigResponse) GetBlockLanAccess() bool { return false } +func (x *GetConfigResponse) GetEnableSSHRoot() bool { + if x != nil { + return x.EnableSSHRoot + } + return false +} + +func (x *GetConfigResponse) GetEnableSSHSFTP() bool { + if x != nil { + return x.EnableSSHSFTP + } + return false +} + +func (x *GetConfigResponse) GetEnableSSHLocalPortForwarding() bool { + if x != nil { + return x.EnableSSHLocalPortForwarding + } + return false +} + +func (x *GetConfigResponse) GetEnableSSHRemotePortForwarding() bool { + if x != nil { + return x.EnableSSHRemotePortForwarding + } + return false +} + +func (x *GetConfigResponse) GetDisableSSHAuth() bool { + if x != nil { + return x.DisableSSHAuth + } + return false +} + +func (x *GetConfigResponse) GetSshJWTCacheTTL() int32 { + if x != nil { + return x.SshJWTCacheTTL + } + return 0 +} + +func (x *GetConfigResponse) GetDisableIpv6() bool { + if x != nil { + return x.DisableIpv6 + } + 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"` @@ -1267,6 +1471,8 @@ type PeerState struct { Networks []string `protobuf:"bytes,16,rep,name=networks,proto3" json:"networks,omitempty"` 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 } @@ -1420,6 +1626,20 @@ func (x *PeerState) GetRelayAddress() string { return "" } +func (x *PeerState) GetSshHostKey() []byte { + if x != nil { + return x.SshHostKey + } + 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"` @@ -1430,6 +1650,8 @@ 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 } @@ -1513,6 +1735,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"` @@ -1637,10 +1873,13 @@ 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 } @@ -1696,6 +1935,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"` @@ -1764,6 +2010,136 @@ func (x *NSGroupState) GetError() string { return "" } +// SSHSessionInfo contains information about an active SSH session +type SSHSessionInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` + RemoteAddress string `protobuf:"bytes,2,opt,name=remoteAddress,proto3" json:"remoteAddress,omitempty"` + Command string `protobuf:"bytes,3,opt,name=command,proto3" json:"command,omitempty"` + JwtUsername string `protobuf:"bytes,4,opt,name=jwtUsername,proto3" json:"jwtUsername,omitempty"` + PortForwards []string `protobuf:"bytes,5,rep,name=portForwards,proto3" json:"portForwards,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHSessionInfo) Reset() { + *x = SSHSessionInfo{} + mi := &file_daemon_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHSessionInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHSessionInfo) ProtoMessage() {} + +func (x *SSHSessionInfo) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[19] + 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 SSHSessionInfo.ProtoReflect.Descriptor instead. +func (*SSHSessionInfo) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{19} +} + +func (x *SSHSessionInfo) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *SSHSessionInfo) GetRemoteAddress() string { + if x != nil { + return x.RemoteAddress + } + return "" +} + +func (x *SSHSessionInfo) GetCommand() string { + if x != nil { + return x.Command + } + return "" +} + +func (x *SSHSessionInfo) GetJwtUsername() string { + if x != nil { + return x.JwtUsername + } + return "" +} + +func (x *SSHSessionInfo) GetPortForwards() []string { + if x != nil { + return x.PortForwards + } + return nil +} + +// SSHServerState contains the latest state of the SSH server +type SSHServerState struct { + state protoimpl.MessageState `protogen:"open.v1"` + Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"` + Sessions []*SSHSessionInfo `protobuf:"bytes,2,rep,name=sessions,proto3" json:"sessions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SSHServerState) Reset() { + *x = SSHServerState{} + mi := &file_daemon_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SSHServerState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SSHServerState) ProtoMessage() {} + +func (x *SSHServerState) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[20] + 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 SSHServerState.ProtoReflect.Descriptor instead. +func (*SSHServerState) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{20} +} + +func (x *SSHServerState) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +func (x *SSHServerState) GetSessions() []*SSHSessionInfo { + if x != nil { + return x.Sessions + } + return nil +} + // FullStatus contains the full state held by the Status instance type FullStatus struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1776,13 +2152,19 @@ type FullStatus struct { NumberOfForwardingRules int32 `protobuf:"varint,8,opt,name=NumberOfForwardingRules,proto3" json:"NumberOfForwardingRules,omitempty"` Events []*SystemEvent `protobuf:"bytes,7,rep,name=events,proto3" json:"events,omitempty"` LazyConnectionEnabled bool `protobuf:"varint,9,opt,name=lazyConnectionEnabled,proto3" json:"lazyConnectionEnabled,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + SshServerState *SSHServerState `protobuf:"bytes,10,opt,name=sshServerState,proto3" json:"sshServerState,omitempty"` + // 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[19] + mi := &file_daemon_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1794,7 +2176,7 @@ func (x *FullStatus) String() string { func (*FullStatus) ProtoMessage() {} func (x *FullStatus) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[19] + mi := &file_daemon_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1807,7 +2189,7 @@ func (x *FullStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use FullStatus.ProtoReflect.Descriptor instead. func (*FullStatus) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{19} + return file_daemon_proto_rawDescGZIP(), []int{21} } func (x *FullStatus) GetManagementState() *ManagementState { @@ -1873,6 +2255,20 @@ func (x *FullStatus) GetLazyConnectionEnabled() bool { return false } +func (x *FullStatus) GetSshServerState() *SSHServerState { + if x != nil { + return x.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"` @@ -1882,7 +2278,7 @@ type ListNetworksRequest struct { func (x *ListNetworksRequest) Reset() { *x = ListNetworksRequest{} - mi := &file_daemon_proto_msgTypes[20] + mi := &file_daemon_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1894,7 +2290,7 @@ func (x *ListNetworksRequest) String() string { func (*ListNetworksRequest) ProtoMessage() {} func (x *ListNetworksRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[20] + mi := &file_daemon_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1907,7 +2303,7 @@ func (x *ListNetworksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListNetworksRequest.ProtoReflect.Descriptor instead. func (*ListNetworksRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{20} + return file_daemon_proto_rawDescGZIP(), []int{22} } type ListNetworksResponse struct { @@ -1919,7 +2315,7 @@ type ListNetworksResponse struct { func (x *ListNetworksResponse) Reset() { *x = ListNetworksResponse{} - mi := &file_daemon_proto_msgTypes[21] + mi := &file_daemon_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1931,7 +2327,7 @@ func (x *ListNetworksResponse) String() string { func (*ListNetworksResponse) ProtoMessage() {} func (x *ListNetworksResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[21] + mi := &file_daemon_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1944,7 +2340,7 @@ func (x *ListNetworksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListNetworksResponse.ProtoReflect.Descriptor instead. func (*ListNetworksResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{21} + return file_daemon_proto_rawDescGZIP(), []int{23} } func (x *ListNetworksResponse) GetRoutes() []*Network { @@ -1965,7 +2361,7 @@ type SelectNetworksRequest struct { func (x *SelectNetworksRequest) Reset() { *x = SelectNetworksRequest{} - mi := &file_daemon_proto_msgTypes[22] + mi := &file_daemon_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1977,7 +2373,7 @@ func (x *SelectNetworksRequest) String() string { func (*SelectNetworksRequest) ProtoMessage() {} func (x *SelectNetworksRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[22] + mi := &file_daemon_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1990,7 +2386,7 @@ func (x *SelectNetworksRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SelectNetworksRequest.ProtoReflect.Descriptor instead. func (*SelectNetworksRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{22} + return file_daemon_proto_rawDescGZIP(), []int{24} } func (x *SelectNetworksRequest) GetNetworkIDs() []string { @@ -2022,7 +2418,7 @@ type SelectNetworksResponse struct { func (x *SelectNetworksResponse) Reset() { *x = SelectNetworksResponse{} - mi := &file_daemon_proto_msgTypes[23] + mi := &file_daemon_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2034,7 +2430,7 @@ func (x *SelectNetworksResponse) String() string { func (*SelectNetworksResponse) ProtoMessage() {} func (x *SelectNetworksResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[23] + mi := &file_daemon_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2047,7 +2443,7 @@ func (x *SelectNetworksResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SelectNetworksResponse.ProtoReflect.Descriptor instead. func (*SelectNetworksResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{23} + return file_daemon_proto_rawDescGZIP(), []int{25} } type IPList struct { @@ -2059,7 +2455,7 @@ type IPList struct { func (x *IPList) Reset() { *x = IPList{} - mi := &file_daemon_proto_msgTypes[24] + mi := &file_daemon_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2071,7 +2467,7 @@ func (x *IPList) String() string { func (*IPList) ProtoMessage() {} func (x *IPList) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[24] + mi := &file_daemon_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2084,7 +2480,7 @@ func (x *IPList) ProtoReflect() protoreflect.Message { // Deprecated: Use IPList.ProtoReflect.Descriptor instead. func (*IPList) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{24} + return file_daemon_proto_rawDescGZIP(), []int{26} } func (x *IPList) GetIps() []string { @@ -2107,7 +2503,7 @@ type Network struct { func (x *Network) Reset() { *x = Network{} - mi := &file_daemon_proto_msgTypes[25] + mi := &file_daemon_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2119,7 +2515,7 @@ func (x *Network) String() string { func (*Network) ProtoMessage() {} func (x *Network) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[25] + mi := &file_daemon_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2132,7 +2528,7 @@ func (x *Network) ProtoReflect() protoreflect.Message { // Deprecated: Use Network.ProtoReflect.Descriptor instead. func (*Network) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{25} + return file_daemon_proto_rawDescGZIP(), []int{27} } func (x *Network) GetID() string { @@ -2184,7 +2580,7 @@ type PortInfo struct { func (x *PortInfo) Reset() { *x = PortInfo{} - mi := &file_daemon_proto_msgTypes[26] + mi := &file_daemon_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2196,7 +2592,7 @@ func (x *PortInfo) String() string { func (*PortInfo) ProtoMessage() {} func (x *PortInfo) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[26] + mi := &file_daemon_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2209,7 +2605,7 @@ func (x *PortInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use PortInfo.ProtoReflect.Descriptor instead. func (*PortInfo) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{26} + return file_daemon_proto_rawDescGZIP(), []int{28} } func (x *PortInfo) GetPortSelection() isPortInfo_PortSelection { @@ -2266,7 +2662,7 @@ type ForwardingRule struct { func (x *ForwardingRule) Reset() { *x = ForwardingRule{} - mi := &file_daemon_proto_msgTypes[27] + mi := &file_daemon_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2278,7 +2674,7 @@ func (x *ForwardingRule) String() string { func (*ForwardingRule) ProtoMessage() {} func (x *ForwardingRule) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[27] + mi := &file_daemon_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2291,7 +2687,7 @@ func (x *ForwardingRule) ProtoReflect() protoreflect.Message { // Deprecated: Use ForwardingRule.ProtoReflect.Descriptor instead. func (*ForwardingRule) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{27} + return file_daemon_proto_rawDescGZIP(), []int{29} } func (x *ForwardingRule) GetProtocol() string { @@ -2338,7 +2734,7 @@ type ForwardingRulesResponse struct { func (x *ForwardingRulesResponse) Reset() { *x = ForwardingRulesResponse{} - mi := &file_daemon_proto_msgTypes[28] + mi := &file_daemon_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2350,7 +2746,7 @@ func (x *ForwardingRulesResponse) String() string { func (*ForwardingRulesResponse) ProtoMessage() {} func (x *ForwardingRulesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[28] + mi := &file_daemon_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2363,7 +2759,7 @@ func (x *ForwardingRulesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ForwardingRulesResponse.ProtoReflect.Descriptor instead. func (*ForwardingRulesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{28} + return file_daemon_proto_rawDescGZIP(), []int{30} } func (x *ForwardingRulesResponse) GetRules() []*ForwardingRule { @@ -2377,17 +2773,17 @@ func (x *ForwardingRulesResponse) GetRules() []*ForwardingRule { type DebugBundleRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Anonymize bool `protobuf:"varint,1,opt,name=anonymize,proto3" json:"anonymize,omitempty"` - Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` SystemInfo bool `protobuf:"varint,3,opt,name=systemInfo,proto3" json:"systemInfo,omitempty"` UploadURL string `protobuf:"bytes,4,opt,name=uploadURL,proto3" json:"uploadURL,omitempty"` LogFileCount uint32 `protobuf:"varint,5,opt,name=logFileCount,proto3" json:"logFileCount,omitempty"` + CliVersion string `protobuf:"bytes,6,opt,name=cliVersion,proto3" json:"cliVersion,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DebugBundleRequest) Reset() { *x = DebugBundleRequest{} - mi := &file_daemon_proto_msgTypes[29] + mi := &file_daemon_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2399,7 +2795,7 @@ func (x *DebugBundleRequest) String() string { func (*DebugBundleRequest) ProtoMessage() {} func (x *DebugBundleRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[29] + mi := &file_daemon_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2412,7 +2808,7 @@ func (x *DebugBundleRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DebugBundleRequest.ProtoReflect.Descriptor instead. func (*DebugBundleRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{29} + return file_daemon_proto_rawDescGZIP(), []int{31} } func (x *DebugBundleRequest) GetAnonymize() bool { @@ -2422,13 +2818,6 @@ func (x *DebugBundleRequest) GetAnonymize() bool { return false } -func (x *DebugBundleRequest) GetStatus() string { - if x != nil { - return x.Status - } - return "" -} - func (x *DebugBundleRequest) GetSystemInfo() bool { if x != nil { return x.SystemInfo @@ -2450,6 +2839,13 @@ func (x *DebugBundleRequest) GetLogFileCount() uint32 { return 0 } +func (x *DebugBundleRequest) GetCliVersion() string { + if x != nil { + return x.CliVersion + } + return "" +} + type DebugBundleResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` @@ -2461,7 +2857,7 @@ type DebugBundleResponse struct { func (x *DebugBundleResponse) Reset() { *x = DebugBundleResponse{} - mi := &file_daemon_proto_msgTypes[30] + mi := &file_daemon_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2473,7 +2869,7 @@ func (x *DebugBundleResponse) String() string { func (*DebugBundleResponse) ProtoMessage() {} func (x *DebugBundleResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[30] + mi := &file_daemon_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2486,7 +2882,7 @@ func (x *DebugBundleResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DebugBundleResponse.ProtoReflect.Descriptor instead. func (*DebugBundleResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{30} + return file_daemon_proto_rawDescGZIP(), []int{32} } func (x *DebugBundleResponse) GetPath() string { @@ -2518,7 +2914,7 @@ type GetLogLevelRequest struct { func (x *GetLogLevelRequest) Reset() { *x = GetLogLevelRequest{} - mi := &file_daemon_proto_msgTypes[31] + mi := &file_daemon_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2530,7 +2926,7 @@ func (x *GetLogLevelRequest) String() string { func (*GetLogLevelRequest) ProtoMessage() {} func (x *GetLogLevelRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[31] + mi := &file_daemon_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2543,7 +2939,7 @@ func (x *GetLogLevelRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetLogLevelRequest.ProtoReflect.Descriptor instead. func (*GetLogLevelRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{31} + return file_daemon_proto_rawDescGZIP(), []int{33} } type GetLogLevelResponse struct { @@ -2555,7 +2951,7 @@ type GetLogLevelResponse struct { func (x *GetLogLevelResponse) Reset() { *x = GetLogLevelResponse{} - mi := &file_daemon_proto_msgTypes[32] + mi := &file_daemon_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2567,7 +2963,7 @@ func (x *GetLogLevelResponse) String() string { func (*GetLogLevelResponse) ProtoMessage() {} func (x *GetLogLevelResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[32] + mi := &file_daemon_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2580,7 +2976,7 @@ func (x *GetLogLevelResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetLogLevelResponse.ProtoReflect.Descriptor instead. func (*GetLogLevelResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{32} + return file_daemon_proto_rawDescGZIP(), []int{34} } func (x *GetLogLevelResponse) GetLevel() LogLevel { @@ -2599,7 +2995,7 @@ type SetLogLevelRequest struct { func (x *SetLogLevelRequest) Reset() { *x = SetLogLevelRequest{} - mi := &file_daemon_proto_msgTypes[33] + mi := &file_daemon_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2611,7 +3007,7 @@ func (x *SetLogLevelRequest) String() string { func (*SetLogLevelRequest) ProtoMessage() {} func (x *SetLogLevelRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[33] + mi := &file_daemon_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2624,7 +3020,7 @@ func (x *SetLogLevelRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetLogLevelRequest.ProtoReflect.Descriptor instead. func (*SetLogLevelRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{33} + return file_daemon_proto_rawDescGZIP(), []int{35} } func (x *SetLogLevelRequest) GetLevel() LogLevel { @@ -2642,7 +3038,7 @@ type SetLogLevelResponse struct { func (x *SetLogLevelResponse) Reset() { *x = SetLogLevelResponse{} - mi := &file_daemon_proto_msgTypes[34] + mi := &file_daemon_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2654,7 +3050,7 @@ func (x *SetLogLevelResponse) String() string { func (*SetLogLevelResponse) ProtoMessage() {} func (x *SetLogLevelResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[34] + mi := &file_daemon_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2667,7 +3063,87 @@ func (x *SetLogLevelResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetLogLevelResponse.ProtoReflect.Descriptor instead. func (*SetLogLevelResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{34} + 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} } // State represents a daemon state entry @@ -2680,7 +3156,7 @@ type State struct { func (x *State) Reset() { *x = State{} - mi := &file_daemon_proto_msgTypes[35] + mi := &file_daemon_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2692,7 +3168,7 @@ func (x *State) String() string { func (*State) ProtoMessage() {} func (x *State) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[35] + mi := &file_daemon_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2705,7 +3181,7 @@ func (x *State) ProtoReflect() protoreflect.Message { // Deprecated: Use State.ProtoReflect.Descriptor instead. func (*State) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{35} + return file_daemon_proto_rawDescGZIP(), []int{39} } func (x *State) GetName() string { @@ -2724,7 +3200,7 @@ type ListStatesRequest struct { func (x *ListStatesRequest) Reset() { *x = ListStatesRequest{} - mi := &file_daemon_proto_msgTypes[36] + mi := &file_daemon_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2736,7 +3212,7 @@ func (x *ListStatesRequest) String() string { func (*ListStatesRequest) ProtoMessage() {} func (x *ListStatesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[36] + mi := &file_daemon_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2749,7 +3225,7 @@ func (x *ListStatesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListStatesRequest.ProtoReflect.Descriptor instead. func (*ListStatesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{36} + return file_daemon_proto_rawDescGZIP(), []int{40} } // ListStatesResponse contains a list of states @@ -2762,7 +3238,7 @@ type ListStatesResponse struct { func (x *ListStatesResponse) Reset() { *x = ListStatesResponse{} - mi := &file_daemon_proto_msgTypes[37] + mi := &file_daemon_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2774,7 +3250,7 @@ func (x *ListStatesResponse) String() string { func (*ListStatesResponse) ProtoMessage() {} func (x *ListStatesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[37] + mi := &file_daemon_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2787,7 +3263,7 @@ func (x *ListStatesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListStatesResponse.ProtoReflect.Descriptor instead. func (*ListStatesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{37} + return file_daemon_proto_rawDescGZIP(), []int{41} } func (x *ListStatesResponse) GetStates() []*State { @@ -2808,7 +3284,7 @@ type CleanStateRequest struct { func (x *CleanStateRequest) Reset() { *x = CleanStateRequest{} - mi := &file_daemon_proto_msgTypes[38] + mi := &file_daemon_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2820,7 +3296,7 @@ func (x *CleanStateRequest) String() string { func (*CleanStateRequest) ProtoMessage() {} func (x *CleanStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[38] + mi := &file_daemon_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2833,7 +3309,7 @@ func (x *CleanStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CleanStateRequest.ProtoReflect.Descriptor instead. func (*CleanStateRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{38} + return file_daemon_proto_rawDescGZIP(), []int{42} } func (x *CleanStateRequest) GetStateName() string { @@ -2860,7 +3336,7 @@ type CleanStateResponse struct { func (x *CleanStateResponse) Reset() { *x = CleanStateResponse{} - mi := &file_daemon_proto_msgTypes[39] + mi := &file_daemon_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2872,7 +3348,7 @@ func (x *CleanStateResponse) String() string { func (*CleanStateResponse) ProtoMessage() {} func (x *CleanStateResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[39] + mi := &file_daemon_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2885,7 +3361,7 @@ func (x *CleanStateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CleanStateResponse.ProtoReflect.Descriptor instead. func (*CleanStateResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{39} + return file_daemon_proto_rawDescGZIP(), []int{43} } func (x *CleanStateResponse) GetCleanedStates() int32 { @@ -2906,7 +3382,7 @@ type DeleteStateRequest struct { func (x *DeleteStateRequest) Reset() { *x = DeleteStateRequest{} - mi := &file_daemon_proto_msgTypes[40] + mi := &file_daemon_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2918,7 +3394,7 @@ func (x *DeleteStateRequest) String() string { func (*DeleteStateRequest) ProtoMessage() {} func (x *DeleteStateRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[40] + mi := &file_daemon_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2931,7 +3407,7 @@ func (x *DeleteStateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteStateRequest.ProtoReflect.Descriptor instead. func (*DeleteStateRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{40} + return file_daemon_proto_rawDescGZIP(), []int{44} } func (x *DeleteStateRequest) GetStateName() string { @@ -2958,7 +3434,7 @@ type DeleteStateResponse struct { func (x *DeleteStateResponse) Reset() { *x = DeleteStateResponse{} - mi := &file_daemon_proto_msgTypes[41] + mi := &file_daemon_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2970,7 +3446,7 @@ func (x *DeleteStateResponse) String() string { func (*DeleteStateResponse) ProtoMessage() {} func (x *DeleteStateResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[41] + mi := &file_daemon_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2983,7 +3459,7 @@ func (x *DeleteStateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteStateResponse.ProtoReflect.Descriptor instead. func (*DeleteStateResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{41} + return file_daemon_proto_rawDescGZIP(), []int{45} } func (x *DeleteStateResponse) GetDeletedStates() int32 { @@ -3002,7 +3478,7 @@ type SetSyncResponsePersistenceRequest struct { func (x *SetSyncResponsePersistenceRequest) Reset() { *x = SetSyncResponsePersistenceRequest{} - mi := &file_daemon_proto_msgTypes[42] + mi := &file_daemon_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3014,7 +3490,7 @@ func (x *SetSyncResponsePersistenceRequest) String() string { func (*SetSyncResponsePersistenceRequest) ProtoMessage() {} func (x *SetSyncResponsePersistenceRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[42] + mi := &file_daemon_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3027,7 +3503,7 @@ func (x *SetSyncResponsePersistenceRequest) ProtoReflect() protoreflect.Message // Deprecated: Use SetSyncResponsePersistenceRequest.ProtoReflect.Descriptor instead. func (*SetSyncResponsePersistenceRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{42} + return file_daemon_proto_rawDescGZIP(), []int{46} } func (x *SetSyncResponsePersistenceRequest) GetEnabled() bool { @@ -3045,7 +3521,7 @@ type SetSyncResponsePersistenceResponse struct { func (x *SetSyncResponsePersistenceResponse) Reset() { *x = SetSyncResponsePersistenceResponse{} - mi := &file_daemon_proto_msgTypes[43] + mi := &file_daemon_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3057,7 +3533,7 @@ func (x *SetSyncResponsePersistenceResponse) String() string { func (*SetSyncResponsePersistenceResponse) ProtoMessage() {} func (x *SetSyncResponsePersistenceResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[43] + mi := &file_daemon_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3070,7 +3546,7 @@ func (x *SetSyncResponsePersistenceResponse) ProtoReflect() protoreflect.Message // Deprecated: Use SetSyncResponsePersistenceResponse.ProtoReflect.Descriptor instead. func (*SetSyncResponsePersistenceResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{43} + return file_daemon_proto_rawDescGZIP(), []int{47} } type TCPFlags struct { @@ -3087,7 +3563,7 @@ type TCPFlags struct { func (x *TCPFlags) Reset() { *x = TCPFlags{} - mi := &file_daemon_proto_msgTypes[44] + mi := &file_daemon_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3099,7 +3575,7 @@ func (x *TCPFlags) String() string { func (*TCPFlags) ProtoMessage() {} func (x *TCPFlags) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[44] + mi := &file_daemon_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3112,7 +3588,7 @@ func (x *TCPFlags) ProtoReflect() protoreflect.Message { // Deprecated: Use TCPFlags.ProtoReflect.Descriptor instead. func (*TCPFlags) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{44} + return file_daemon_proto_rawDescGZIP(), []int{48} } func (x *TCPFlags) GetSyn() bool { @@ -3174,7 +3650,7 @@ type TracePacketRequest struct { func (x *TracePacketRequest) Reset() { *x = TracePacketRequest{} - mi := &file_daemon_proto_msgTypes[45] + mi := &file_daemon_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3186,7 +3662,7 @@ func (x *TracePacketRequest) String() string { func (*TracePacketRequest) ProtoMessage() {} func (x *TracePacketRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[45] + mi := &file_daemon_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3199,7 +3675,7 @@ func (x *TracePacketRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TracePacketRequest.ProtoReflect.Descriptor instead. func (*TracePacketRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{45} + return file_daemon_proto_rawDescGZIP(), []int{49} } func (x *TracePacketRequest) GetSourceIp() string { @@ -3277,7 +3753,7 @@ type TraceStage struct { func (x *TraceStage) Reset() { *x = TraceStage{} - mi := &file_daemon_proto_msgTypes[46] + mi := &file_daemon_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3289,7 +3765,7 @@ func (x *TraceStage) String() string { func (*TraceStage) ProtoMessage() {} func (x *TraceStage) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[46] + mi := &file_daemon_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3302,7 +3778,7 @@ func (x *TraceStage) ProtoReflect() protoreflect.Message { // Deprecated: Use TraceStage.ProtoReflect.Descriptor instead. func (*TraceStage) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{46} + return file_daemon_proto_rawDescGZIP(), []int{50} } func (x *TraceStage) GetName() string { @@ -3343,7 +3819,7 @@ type TracePacketResponse struct { func (x *TracePacketResponse) Reset() { *x = TracePacketResponse{} - mi := &file_daemon_proto_msgTypes[47] + mi := &file_daemon_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3355,7 +3831,7 @@ func (x *TracePacketResponse) String() string { func (*TracePacketResponse) ProtoMessage() {} func (x *TracePacketResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[47] + mi := &file_daemon_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3368,7 +3844,7 @@ func (x *TracePacketResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TracePacketResponse.ProtoReflect.Descriptor instead. func (*TracePacketResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{47} + return file_daemon_proto_rawDescGZIP(), []int{51} } func (x *TracePacketResponse) GetStages() []*TraceStage { @@ -3393,7 +3869,7 @@ type SubscribeRequest struct { func (x *SubscribeRequest) Reset() { *x = SubscribeRequest{} - mi := &file_daemon_proto_msgTypes[48] + mi := &file_daemon_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3405,7 +3881,7 @@ func (x *SubscribeRequest) String() string { func (*SubscribeRequest) ProtoMessage() {} func (x *SubscribeRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[48] + mi := &file_daemon_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3418,7 +3894,7 @@ func (x *SubscribeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SubscribeRequest.ProtoReflect.Descriptor instead. func (*SubscribeRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{48} + return file_daemon_proto_rawDescGZIP(), []int{52} } type SystemEvent struct { @@ -3436,7 +3912,7 @@ type SystemEvent struct { func (x *SystemEvent) Reset() { *x = SystemEvent{} - mi := &file_daemon_proto_msgTypes[49] + mi := &file_daemon_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3448,7 +3924,7 @@ func (x *SystemEvent) String() string { func (*SystemEvent) ProtoMessage() {} func (x *SystemEvent) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[49] + mi := &file_daemon_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3461,7 +3937,7 @@ func (x *SystemEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use SystemEvent.ProtoReflect.Descriptor instead. func (*SystemEvent) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{49} + return file_daemon_proto_rawDescGZIP(), []int{53} } func (x *SystemEvent) GetId() string { @@ -3521,7 +3997,7 @@ type GetEventsRequest struct { func (x *GetEventsRequest) Reset() { *x = GetEventsRequest{} - mi := &file_daemon_proto_msgTypes[50] + mi := &file_daemon_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3533,7 +4009,7 @@ func (x *GetEventsRequest) String() string { func (*GetEventsRequest) ProtoMessage() {} func (x *GetEventsRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[50] + mi := &file_daemon_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3546,7 +4022,7 @@ func (x *GetEventsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetEventsRequest.ProtoReflect.Descriptor instead. func (*GetEventsRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{50} + return file_daemon_proto_rawDescGZIP(), []int{54} } type GetEventsResponse struct { @@ -3558,7 +4034,7 @@ type GetEventsResponse struct { func (x *GetEventsResponse) Reset() { *x = GetEventsResponse{} - mi := &file_daemon_proto_msgTypes[51] + mi := &file_daemon_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3570,7 +4046,7 @@ func (x *GetEventsResponse) String() string { func (*GetEventsResponse) ProtoMessage() {} func (x *GetEventsResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[51] + mi := &file_daemon_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3583,7 +4059,7 @@ func (x *GetEventsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetEventsResponse.ProtoReflect.Descriptor instead. func (*GetEventsResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{51} + return file_daemon_proto_rawDescGZIP(), []int{55} } func (x *GetEventsResponse) GetEvents() []*SystemEvent { @@ -3594,16 +4070,18 @@ 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 } func (x *SwitchProfileRequest) Reset() { *x = SwitchProfileRequest{} - mi := &file_daemon_proto_msgTypes[52] + mi := &file_daemon_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3615,7 +4093,7 @@ func (x *SwitchProfileRequest) String() string { func (*SwitchProfileRequest) ProtoMessage() {} func (x *SwitchProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[52] + mi := &file_daemon_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3628,7 +4106,7 @@ func (x *SwitchProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SwitchProfileRequest.ProtoReflect.Descriptor instead. func (*SwitchProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{52} + return file_daemon_proto_rawDescGZIP(), []int{56} } func (x *SwitchProfileRequest) GetProfileName() string { @@ -3646,14 +4124,18 @@ 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 } func (x *SwitchProfileResponse) Reset() { *x = SwitchProfileResponse{} - mi := &file_daemon_proto_msgTypes[53] + mi := &file_daemon_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3665,7 +4147,7 @@ func (x *SwitchProfileResponse) String() string { func (*SwitchProfileResponse) ProtoMessage() {} func (x *SwitchProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[53] + mi := &file_daemon_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3678,7 +4160,14 @@ func (x *SwitchProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SwitchProfileResponse.ProtoReflect.Descriptor instead. func (*SwitchProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{53} + return file_daemon_proto_rawDescGZIP(), []int{57} +} + +func (x *SwitchProfileResponse) GetId() string { + if x != nil { + return x.Id + } + return "" } type SetConfigRequest struct { @@ -3711,16 +4200,23 @@ type SetConfigRequest struct { ExtraIFaceBlacklist []string `protobuf:"bytes,24,rep,name=extraIFaceBlacklist,proto3" json:"extraIFaceBlacklist,omitempty"` DnsLabels []string `protobuf:"bytes,25,rep,name=dns_labels,json=dnsLabels,proto3" json:"dns_labels,omitempty"` // cleanDNSLabels clean map list of DNS labels. - CleanDNSLabels bool `protobuf:"varint,26,opt,name=cleanDNSLabels,proto3" json:"cleanDNSLabels,omitempty"` - DnsRouteInterval *durationpb.Duration `protobuf:"bytes,27,opt,name=dnsRouteInterval,proto3,oneof" json:"dnsRouteInterval,omitempty"` - Mtu *int64 `protobuf:"varint,28,opt,name=mtu,proto3,oneof" json:"mtu,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + CleanDNSLabels bool `protobuf:"varint,26,opt,name=cleanDNSLabels,proto3" json:"cleanDNSLabels,omitempty"` + DnsRouteInterval *durationpb.Duration `protobuf:"bytes,27,opt,name=dnsRouteInterval,proto3,oneof" json:"dnsRouteInterval,omitempty"` + Mtu *int64 `protobuf:"varint,28,opt,name=mtu,proto3,oneof" json:"mtu,omitempty"` + EnableSSHRoot *bool `protobuf:"varint,29,opt,name=enableSSHRoot,proto3,oneof" json:"enableSSHRoot,omitempty"` + EnableSSHSFTP *bool `protobuf:"varint,30,opt,name=enableSSHSFTP,proto3,oneof" json:"enableSSHSFTP,omitempty"` + EnableSSHLocalPortForwarding *bool `protobuf:"varint,31,opt,name=enableSSHLocalPortForwarding,proto3,oneof" json:"enableSSHLocalPortForwarding,omitempty"` + 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"` + DisableIpv6 *bool `protobuf:"varint,35,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SetConfigRequest) Reset() { *x = SetConfigRequest{} - mi := &file_daemon_proto_msgTypes[54] + mi := &file_daemon_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3732,7 +4228,7 @@ func (x *SetConfigRequest) String() string { func (*SetConfigRequest) ProtoMessage() {} func (x *SetConfigRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[54] + mi := &file_daemon_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3745,7 +4241,7 @@ func (x *SetConfigRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetConfigRequest.ProtoReflect.Descriptor instead. func (*SetConfigRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{54} + return file_daemon_proto_rawDescGZIP(), []int{58} } func (x *SetConfigRequest) GetUsername() string { @@ -3944,6 +4440,55 @@ func (x *SetConfigRequest) GetMtu() int64 { return 0 } +func (x *SetConfigRequest) GetEnableSSHRoot() bool { + if x != nil && x.EnableSSHRoot != nil { + return *x.EnableSSHRoot + } + return false +} + +func (x *SetConfigRequest) GetEnableSSHSFTP() bool { + if x != nil && x.EnableSSHSFTP != nil { + return *x.EnableSSHSFTP + } + return false +} + +func (x *SetConfigRequest) GetEnableSSHLocalPortForwarding() bool { + if x != nil && x.EnableSSHLocalPortForwarding != nil { + return *x.EnableSSHLocalPortForwarding + } + return false +} + +func (x *SetConfigRequest) GetEnableSSHRemotePortForwarding() bool { + if x != nil && x.EnableSSHRemotePortForwarding != nil { + return *x.EnableSSHRemotePortForwarding + } + return false +} + +func (x *SetConfigRequest) GetDisableSSHAuth() bool { + if x != nil && x.DisableSSHAuth != nil { + return *x.DisableSSHAuth + } + return false +} + +func (x *SetConfigRequest) GetSshJWTCacheTTL() int32 { + if x != nil && x.SshJWTCacheTTL != nil { + return *x.SshJWTCacheTTL + } + return 0 +} + +func (x *SetConfigRequest) GetDisableIpv6() bool { + if x != nil && x.DisableIpv6 != nil { + return *x.DisableIpv6 + } + return false +} + type SetConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -3952,7 +4497,7 @@ type SetConfigResponse struct { func (x *SetConfigResponse) Reset() { *x = SetConfigResponse{} - mi := &file_daemon_proto_msgTypes[55] + mi := &file_daemon_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3964,7 +4509,7 @@ func (x *SetConfigResponse) String() string { func (*SetConfigResponse) ProtoMessage() {} func (x *SetConfigResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[55] + mi := &file_daemon_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3977,20 +4522,22 @@ func (x *SetConfigResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetConfigResponse.ProtoReflect.Descriptor instead. func (*SetConfigResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{55} + return file_daemon_proto_rawDescGZIP(), []int{59} } 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 } func (x *AddProfileRequest) Reset() { *x = AddProfileRequest{} - mi := &file_daemon_proto_msgTypes[56] + mi := &file_daemon_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4002,7 +4549,7 @@ func (x *AddProfileRequest) String() string { func (*AddProfileRequest) ProtoMessage() {} func (x *AddProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[56] + mi := &file_daemon_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4015,7 +4562,7 @@ func (x *AddProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AddProfileRequest.ProtoReflect.Descriptor instead. func (*AddProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{56} + return file_daemon_proto_rawDescGZIP(), []int{60} } func (x *AddProfileRequest) GetUsername() string { @@ -4033,14 +4580,17 @@ 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 } func (x *AddProfileResponse) Reset() { *x = AddProfileResponse{} - mi := &file_daemon_proto_msgTypes[57] + mi := &file_daemon_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4052,7 +4602,7 @@ func (x *AddProfileResponse) String() string { func (*AddProfileResponse) ProtoMessage() {} func (x *AddProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[57] + mi := &file_daemon_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4065,20 +4615,136 @@ func (x *AddProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AddProfileResponse.ProtoReflect.Descriptor instead. func (*AddProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{57} + 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[58] + mi := &file_daemon_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4090,7 +4756,7 @@ func (x *RemoveProfileRequest) String() string { func (*RemoveProfileRequest) ProtoMessage() {} func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[58] + mi := &file_daemon_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4103,7 +4769,7 @@ func (x *RemoveProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveProfileRequest.ProtoReflect.Descriptor instead. func (*RemoveProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{58} + return file_daemon_proto_rawDescGZIP(), []int{64} } func (x *RemoveProfileRequest) GetUsername() string { @@ -4121,14 +4787,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[59] + mi := &file_daemon_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4140,7 +4809,7 @@ func (x *RemoveProfileResponse) String() string { func (*RemoveProfileResponse) ProtoMessage() {} func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[59] + mi := &file_daemon_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4153,7 +4822,14 @@ func (x *RemoveProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoveProfileResponse.ProtoReflect.Descriptor instead. func (*RemoveProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{59} + return file_daemon_proto_rawDescGZIP(), []int{65} +} + +func (x *RemoveProfileResponse) GetId() string { + if x != nil { + return x.Id + } + return "" } type ListProfilesRequest struct { @@ -4165,7 +4841,7 @@ type ListProfilesRequest struct { func (x *ListProfilesRequest) Reset() { *x = ListProfilesRequest{} - mi := &file_daemon_proto_msgTypes[60] + mi := &file_daemon_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4177,7 +4853,7 @@ func (x *ListProfilesRequest) String() string { func (*ListProfilesRequest) ProtoMessage() {} func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[60] + mi := &file_daemon_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4190,7 +4866,7 @@ func (x *ListProfilesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProfilesRequest.ProtoReflect.Descriptor instead. func (*ListProfilesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{60} + return file_daemon_proto_rawDescGZIP(), []int{66} } func (x *ListProfilesRequest) GetUsername() string { @@ -4209,7 +4885,7 @@ type ListProfilesResponse struct { func (x *ListProfilesResponse) Reset() { *x = ListProfilesResponse{} - mi := &file_daemon_proto_msgTypes[61] + mi := &file_daemon_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4221,7 +4897,7 @@ func (x *ListProfilesResponse) String() string { func (*ListProfilesResponse) ProtoMessage() {} func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[61] + mi := &file_daemon_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4234,7 +4910,7 @@ func (x *ListProfilesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListProfilesResponse.ProtoReflect.Descriptor instead. func (*ListProfilesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{61} + return file_daemon_proto_rawDescGZIP(), []int{67} } func (x *ListProfilesResponse) GetProfiles() []*Profile { @@ -4248,13 +4924,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[62] + mi := &file_daemon_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4266,7 +4943,7 @@ func (x *Profile) String() string { func (*Profile) ProtoMessage() {} func (x *Profile) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[62] + mi := &file_daemon_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4279,7 +4956,7 @@ func (x *Profile) ProtoReflect() protoreflect.Message { // Deprecated: Use Profile.ProtoReflect.Descriptor instead. func (*Profile) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{62} + return file_daemon_proto_rawDescGZIP(), []int{68} } func (x *Profile) GetName() string { @@ -4296,6 +4973,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 @@ -4304,7 +4988,7 @@ type GetActiveProfileRequest struct { func (x *GetActiveProfileRequest) Reset() { *x = GetActiveProfileRequest{} - mi := &file_daemon_proto_msgTypes[63] + mi := &file_daemon_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4316,7 +5000,7 @@ func (x *GetActiveProfileRequest) String() string { func (*GetActiveProfileRequest) ProtoMessage() {} func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[63] + mi := &file_daemon_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4329,20 +5013,21 @@ func (x *GetActiveProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveProfileRequest.ProtoReflect.Descriptor instead. func (*GetActiveProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{63} + 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[64] + mi := &file_daemon_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4354,7 +5039,7 @@ func (x *GetActiveProfileResponse) String() string { func (*GetActiveProfileResponse) ProtoMessage() {} func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[64] + mi := &file_daemon_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4367,7 +5052,7 @@ func (x *GetActiveProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActiveProfileResponse.ProtoReflect.Descriptor instead. func (*GetActiveProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{64} + return file_daemon_proto_rawDescGZIP(), []int{70} } func (x *GetActiveProfileResponse) GetProfileName() string { @@ -4384,6 +5069,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"` @@ -4394,7 +5086,7 @@ type LogoutRequest struct { func (x *LogoutRequest) Reset() { *x = LogoutRequest{} - mi := &file_daemon_proto_msgTypes[65] + mi := &file_daemon_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4406,7 +5098,7 @@ func (x *LogoutRequest) String() string { func (*LogoutRequest) ProtoMessage() {} func (x *LogoutRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[65] + mi := &file_daemon_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4419,7 +5111,7 @@ func (x *LogoutRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead. func (*LogoutRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{65} + return file_daemon_proto_rawDescGZIP(), []int{71} } func (x *LogoutRequest) GetProfileName() string { @@ -4444,7 +5136,7 @@ type LogoutResponse struct { func (x *LogoutResponse) Reset() { *x = LogoutResponse{} - mi := &file_daemon_proto_msgTypes[66] + mi := &file_daemon_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4456,7 +5148,7 @@ func (x *LogoutResponse) String() string { func (*LogoutResponse) ProtoMessage() {} func (x *LogoutResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[66] + mi := &file_daemon_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4469,7 +5161,79 @@ func (x *LogoutResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead. func (*LogoutResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{66} + 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 { @@ -4480,7 +5244,7 @@ type GetFeaturesRequest struct { func (x *GetFeaturesRequest) Reset() { *x = GetFeaturesRequest{} - mi := &file_daemon_proto_msgTypes[67] + mi := &file_daemon_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4492,7 +5256,7 @@ func (x *GetFeaturesRequest) String() string { func (*GetFeaturesRequest) ProtoMessage() {} func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[67] + mi := &file_daemon_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4505,20 +5269,27 @@ func (x *GetFeaturesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFeaturesRequest.ProtoReflect.Descriptor instead. func (*GetFeaturesRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{67} + return file_daemon_proto_rawDescGZIP(), []int{75} } type GetFeaturesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` 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"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + DisableNetworks bool `protobuf:"varint,3,opt,name=disable_networks,json=disableNetworks,proto3" json:"disable_networks,omitempty"` + // 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[68] + mi := &file_daemon_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4530,7 +5301,7 @@ func (x *GetFeaturesResponse) String() string { func (*GetFeaturesResponse) ProtoMessage() {} func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[68] + mi := &file_daemon_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4543,7 +5314,7 @@ func (x *GetFeaturesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetFeaturesResponse.ProtoReflect.Descriptor instead. func (*GetFeaturesResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{68} + return file_daemon_proto_rawDescGZIP(), []int{76} } func (x *GetFeaturesResponse) GetDisableProfiles() bool { @@ -4560,6 +5331,1605 @@ func (x *GetFeaturesResponse) GetDisableUpdateSettings() bool { return false } +func (x *GetFeaturesResponse) GetDisableNetworks() bool { + if x != nil { + return x.DisableNetworks + } + 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 + sizeCache protoimpl.SizeCache +} + +func (x *TriggerUpdateRequest) Reset() { + *x = TriggerUpdateRequest{} + mi := &file_daemon_proto_msgTypes[78] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TriggerUpdateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TriggerUpdateRequest) ProtoMessage() {} + +func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[78] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TriggerUpdateRequest.ProtoReflect.Descriptor instead. +func (*TriggerUpdateRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{78} +} + +type TriggerUpdateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + ErrorMsg string `protobuf:"bytes,2,opt,name=errorMsg,proto3" json:"errorMsg,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TriggerUpdateResponse) Reset() { + *x = TriggerUpdateResponse{} + mi := &file_daemon_proto_msgTypes[79] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TriggerUpdateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TriggerUpdateResponse) ProtoMessage() {} + +func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[79] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TriggerUpdateResponse.ProtoReflect.Descriptor instead. +func (*TriggerUpdateResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{79} +} + +func (x *TriggerUpdateResponse) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *TriggerUpdateResponse) GetErrorMsg() string { + if x != nil { + return x.ErrorMsg + } + return "" +} + +// GetPeerSSHHostKeyRequest for retrieving SSH host key for a specific peer +type GetPeerSSHHostKeyRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // peer IP address or FQDN to get SSH host key for + PeerAddress string `protobuf:"bytes,1,opt,name=peerAddress,proto3" json:"peerAddress,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetPeerSSHHostKeyRequest) Reset() { + *x = GetPeerSSHHostKeyRequest{} + mi := &file_daemon_proto_msgTypes[80] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetPeerSSHHostKeyRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetPeerSSHHostKeyRequest) ProtoMessage() {} + +func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[80] + 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 GetPeerSSHHostKeyRequest.ProtoReflect.Descriptor instead. +func (*GetPeerSSHHostKeyRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{80} +} + +func (x *GetPeerSSHHostKeyRequest) GetPeerAddress() string { + if x != nil { + return x.PeerAddress + } + return "" +} + +// GetPeerSSHHostKeyResponse contains the SSH host key for the requested peer +type GetPeerSSHHostKeyResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // SSH host key in SSH public key format (e.g., "ssh-ed25519 AAAAC3... hostname") + SshHostKey []byte `protobuf:"bytes,1,opt,name=sshHostKey,proto3" json:"sshHostKey,omitempty"` + // peer IP address + PeerIP string `protobuf:"bytes,2,opt,name=peerIP,proto3" json:"peerIP,omitempty"` + // peer FQDN + PeerFQDN string `protobuf:"bytes,3,opt,name=peerFQDN,proto3" json:"peerFQDN,omitempty"` + // indicates if the SSH host key was found + Found bool `protobuf:"varint,4,opt,name=found,proto3" json:"found,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetPeerSSHHostKeyResponse) Reset() { + *x = GetPeerSSHHostKeyResponse{} + mi := &file_daemon_proto_msgTypes[81] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetPeerSSHHostKeyResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetPeerSSHHostKeyResponse) ProtoMessage() {} + +func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[81] + 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 GetPeerSSHHostKeyResponse.ProtoReflect.Descriptor instead. +func (*GetPeerSSHHostKeyResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{81} +} + +func (x *GetPeerSSHHostKeyResponse) GetSshHostKey() []byte { + if x != nil { + return x.SshHostKey + } + return nil +} + +func (x *GetPeerSSHHostKeyResponse) GetPeerIP() string { + if x != nil { + return x.PeerIP + } + return "" +} + +func (x *GetPeerSSHHostKeyResponse) GetPeerFQDN() string { + if x != nil { + return x.PeerFQDN + } + return "" +} + +func (x *GetPeerSSHHostKeyResponse) GetFound() bool { + if x != nil { + return x.Found + } + return false +} + +// RequestJWTAuthRequest for initiating JWT authentication flow +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 +} + +func (x *RequestJWTAuthRequest) Reset() { + *x = RequestJWTAuthRequest{} + mi := &file_daemon_proto_msgTypes[82] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RequestJWTAuthRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RequestJWTAuthRequest) ProtoMessage() {} + +func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[82] + 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 RequestJWTAuthRequest.ProtoReflect.Descriptor instead. +func (*RequestJWTAuthRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{82} +} + +func (x *RequestJWTAuthRequest) GetHint() string { + if x != nil && x.Hint != nil { + return *x.Hint + } + return "" +} + +// RequestJWTAuthResponse contains authentication flow information +type RequestJWTAuthResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // verification URI for user authentication + 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 + UserCode string `protobuf:"bytes,3,opt,name=userCode,proto3" json:"userCode,omitempty"` + // device code for polling + DeviceCode string `protobuf:"bytes,4,opt,name=deviceCode,proto3" json:"deviceCode,omitempty"` + // expiration time in seconds + ExpiresIn int64 `protobuf:"varint,5,opt,name=expiresIn,proto3" json:"expiresIn,omitempty"` + // if a cached token is available, it will be returned here + CachedToken string `protobuf:"bytes,6,opt,name=cachedToken,proto3" json:"cachedToken,omitempty"` + // maximum age of JWT tokens in seconds (from management server) + MaxTokenAge int64 `protobuf:"varint,7,opt,name=maxTokenAge,proto3" json:"maxTokenAge,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RequestJWTAuthResponse) Reset() { + *x = RequestJWTAuthResponse{} + mi := &file_daemon_proto_msgTypes[83] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RequestJWTAuthResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RequestJWTAuthResponse) ProtoMessage() {} + +func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[83] + 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 RequestJWTAuthResponse.ProtoReflect.Descriptor instead. +func (*RequestJWTAuthResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{83} +} + +func (x *RequestJWTAuthResponse) GetVerificationURI() string { + if x != nil { + return x.VerificationURI + } + return "" +} + +func (x *RequestJWTAuthResponse) GetVerificationURIComplete() string { + if x != nil { + return x.VerificationURIComplete + } + return "" +} + +func (x *RequestJWTAuthResponse) GetUserCode() string { + if x != nil { + return x.UserCode + } + return "" +} + +func (x *RequestJWTAuthResponse) GetDeviceCode() string { + if x != nil { + return x.DeviceCode + } + return "" +} + +func (x *RequestJWTAuthResponse) GetExpiresIn() int64 { + if x != nil { + return x.ExpiresIn + } + return 0 +} + +func (x *RequestJWTAuthResponse) GetCachedToken() string { + if x != nil { + return x.CachedToken + } + return "" +} + +func (x *RequestJWTAuthResponse) GetMaxTokenAge() int64 { + if x != nil { + return x.MaxTokenAge + } + return 0 +} + +// WaitJWTTokenRequest for waiting for authentication completion +type WaitJWTTokenRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // device code from RequestJWTAuthResponse + 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 *WaitJWTTokenRequest) Reset() { + *x = WaitJWTTokenRequest{} + mi := &file_daemon_proto_msgTypes[84] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WaitJWTTokenRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WaitJWTTokenRequest) ProtoMessage() {} + +func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[84] + 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 WaitJWTTokenRequest.ProtoReflect.Descriptor instead. +func (*WaitJWTTokenRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{84} +} + +func (x *WaitJWTTokenRequest) GetDeviceCode() string { + if x != nil { + return x.DeviceCode + } + return "" +} + +func (x *WaitJWTTokenRequest) GetUserCode() string { + if x != nil { + return x.UserCode + } + return "" +} + +// WaitJWTTokenResponse contains the JWT token after authentication +type WaitJWTTokenResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // JWT token (access token or ID token) + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + // token type (e.g., "Bearer") + TokenType string `protobuf:"bytes,2,opt,name=tokenType,proto3" json:"tokenType,omitempty"` + // expiration time in seconds + ExpiresIn int64 `protobuf:"varint,3,opt,name=expiresIn,proto3" json:"expiresIn,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WaitJWTTokenResponse) Reset() { + *x = WaitJWTTokenResponse{} + mi := &file_daemon_proto_msgTypes[85] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WaitJWTTokenResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WaitJWTTokenResponse) ProtoMessage() {} + +func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[85] + 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 WaitJWTTokenResponse.ProtoReflect.Descriptor instead. +func (*WaitJWTTokenResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{85} +} + +func (x *WaitJWTTokenResponse) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *WaitJWTTokenResponse) GetTokenType() string { + if x != nil { + return x.TokenType + } + return "" +} + +func (x *WaitJWTTokenResponse) GetExpiresIn() int64 { + if x != nil { + return x.ExpiresIn + } + 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"` + 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 "" +} + +// 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"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartCPUProfileRequest) Reset() { + *x = StartCPUProfileRequest{} + mi := &file_daemon_proto_msgTypes[92] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartCPUProfileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartCPUProfileRequest) ProtoMessage() {} + +func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[92] + 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 StartCPUProfileRequest.ProtoReflect.Descriptor instead. +func (*StartCPUProfileRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{92} +} + +// StartCPUProfileResponse confirms CPU profiling has started +type StartCPUProfileResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartCPUProfileResponse) Reset() { + *x = StartCPUProfileResponse{} + mi := &file_daemon_proto_msgTypes[93] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartCPUProfileResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartCPUProfileResponse) ProtoMessage() {} + +func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[93] + 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 StartCPUProfileResponse.ProtoReflect.Descriptor instead. +func (*StartCPUProfileResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{93} +} + +// StopCPUProfileRequest for stopping CPU profiling +type StopCPUProfileRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StopCPUProfileRequest) Reset() { + *x = StopCPUProfileRequest{} + mi := &file_daemon_proto_msgTypes[94] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StopCPUProfileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StopCPUProfileRequest) ProtoMessage() {} + +func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[94] + 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 StopCPUProfileRequest.ProtoReflect.Descriptor instead. +func (*StopCPUProfileRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{94} +} + +// StopCPUProfileResponse confirms CPU profiling has stopped +type StopCPUProfileResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StopCPUProfileResponse) Reset() { + *x = StopCPUProfileResponse{} + mi := &file_daemon_proto_msgTypes[95] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StopCPUProfileResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StopCPUProfileResponse) ProtoMessage() {} + +func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[95] + 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 StopCPUProfileResponse.ProtoReflect.Descriptor instead. +func (*StopCPUProfileResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{95} +} + +type InstallerResultRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InstallerResultRequest) Reset() { + *x = InstallerResultRequest{} + mi := &file_daemon_proto_msgTypes[96] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InstallerResultRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstallerResultRequest) ProtoMessage() {} + +func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[96] + 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 InstallerResultRequest.ProtoReflect.Descriptor instead. +func (*InstallerResultRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{96} +} + +type InstallerResultResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + ErrorMsg string `protobuf:"bytes,2,opt,name=errorMsg,proto3" json:"errorMsg,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InstallerResultResponse) Reset() { + *x = InstallerResultResponse{} + mi := &file_daemon_proto_msgTypes[97] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InstallerResultResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstallerResultResponse) ProtoMessage() {} + +func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[97] + 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 InstallerResultResponse.ProtoReflect.Descriptor instead. +func (*InstallerResultResponse) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{97} +} + +func (x *InstallerResultResponse) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *InstallerResultResponse) GetErrorMsg() string { + if x != nil { + return x.ErrorMsg + } + return "" +} + +type ExposeServiceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Port uint32 `protobuf:"varint,1,opt,name=port,proto3" json:"port,omitempty"` + Protocol ExposeProtocol `protobuf:"varint,2,opt,name=protocol,proto3,enum=daemon.ExposeProtocol" json:"protocol,omitempty"` + Pin string `protobuf:"bytes,3,opt,name=pin,proto3" json:"pin,omitempty"` + Password string `protobuf:"bytes,4,opt,name=password,proto3" json:"password,omitempty"` + UserGroups []string `protobuf:"bytes,5,rep,name=user_groups,json=userGroups,proto3" json:"user_groups,omitempty"` + Domain string `protobuf:"bytes,6,opt,name=domain,proto3" json:"domain,omitempty"` + NamePrefix string `protobuf:"bytes,7,opt,name=name_prefix,json=namePrefix,proto3" json:"name_prefix,omitempty"` + ListenPort uint32 `protobuf:"varint,8,opt,name=listen_port,json=listenPort,proto3" json:"listen_port,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExposeServiceRequest) Reset() { + *x = ExposeServiceRequest{} + mi := &file_daemon_proto_msgTypes[98] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExposeServiceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExposeServiceRequest) ProtoMessage() {} + +func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[98] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead. +func (*ExposeServiceRequest) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{98} +} + +func (x *ExposeServiceRequest) GetPort() uint32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *ExposeServiceRequest) GetProtocol() ExposeProtocol { + if x != nil { + return x.Protocol + } + return ExposeProtocol_EXPOSE_HTTP +} + +func (x *ExposeServiceRequest) GetPin() string { + if x != nil { + return x.Pin + } + return "" +} + +func (x *ExposeServiceRequest) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +func (x *ExposeServiceRequest) GetUserGroups() []string { + if x != nil { + return x.UserGroups + } + return nil +} + +func (x *ExposeServiceRequest) GetDomain() string { + if x != nil { + return x.Domain + } + return "" +} + +func (x *ExposeServiceRequest) GetNamePrefix() string { + if x != nil { + return x.NamePrefix + } + return "" +} + +func (x *ExposeServiceRequest) GetListenPort() uint32 { + if x != nil { + return x.ListenPort + } + return 0 +} + +type ExposeServiceEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *ExposeServiceEvent_Ready + Event isExposeServiceEvent_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExposeServiceEvent) Reset() { + *x = ExposeServiceEvent{} + mi := &file_daemon_proto_msgTypes[99] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExposeServiceEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExposeServiceEvent) ProtoMessage() {} + +func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[99] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExposeServiceEvent.ProtoReflect.Descriptor instead. +func (*ExposeServiceEvent) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{99} +} + +func (x *ExposeServiceEvent) GetEvent() isExposeServiceEvent_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *ExposeServiceEvent) GetReady() *ExposeServiceReady { + if x != nil { + if x, ok := x.Event.(*ExposeServiceEvent_Ready); ok { + return x.Ready + } + } + return nil +} + +type isExposeServiceEvent_Event interface { + isExposeServiceEvent_Event() +} + +type ExposeServiceEvent_Ready struct { + Ready *ExposeServiceReady `protobuf:"bytes,1,opt,name=ready,proto3,oneof"` +} + +func (*ExposeServiceEvent_Ready) isExposeServiceEvent_Event() {} + +type ExposeServiceReady struct { + state protoimpl.MessageState `protogen:"open.v1"` + ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` + ServiceUrl string `protobuf:"bytes,2,opt,name=service_url,json=serviceUrl,proto3" json:"service_url,omitempty"` + Domain string `protobuf:"bytes,3,opt,name=domain,proto3" json:"domain,omitempty"` + PortAutoAssigned bool `protobuf:"varint,4,opt,name=port_auto_assigned,json=portAutoAssigned,proto3" json:"port_auto_assigned,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExposeServiceReady) Reset() { + *x = ExposeServiceReady{} + mi := &file_daemon_proto_msgTypes[100] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExposeServiceReady) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExposeServiceReady) ProtoMessage() {} + +func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[100] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExposeServiceReady.ProtoReflect.Descriptor instead. +func (*ExposeServiceReady) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{100} +} + +func (x *ExposeServiceReady) GetServiceName() string { + if x != nil { + return x.ServiceName + } + return "" +} + +func (x *ExposeServiceReady) GetServiceUrl() string { + if x != nil { + return x.ServiceUrl + } + return "" +} + +func (x *ExposeServiceReady) GetDomain() string { + if x != nil { + return x.Domain + } + return "" +} + +func (x *ExposeServiceReady) GetPortAutoAssigned() bool { + if x != nil { + return x.PortAutoAssigned + } + return false +} + +type 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"` @@ -4570,7 +6940,7 @@ type PortInfo_Range struct { func (x *PortInfo_Range) Reset() { *x = PortInfo_Range{} - mi := &file_daemon_proto_msgTypes[70] + mi := &file_daemon_proto_msgTypes[108] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4582,7 +6952,7 @@ func (x *PortInfo_Range) String() string { func (*PortInfo_Range) ProtoMessage() {} func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[70] + mi := &file_daemon_proto_msgTypes[108] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4595,7 +6965,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{26, 0} + return file_daemon_proto_rawDescGZIP(), []int{28, 0} } func (x *PortInfo_Range) GetStart() uint32 { @@ -4617,7 +6987,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\"\xe5\x0e\n" + + "\fEmptyRequest\"\xef\x12\n" + "\fLoginRequest\x12\x1a\n" + "\bsetupKey\x18\x01 \x01(\tR\bsetupKey\x12&\n" + "\fpreSharedKey\x18\x02 \x01(\tB\x02\x18\x01R\fpreSharedKey\x12$\n" + @@ -4655,7 +7025,14 @@ const file_daemon_proto_rawDesc = "" + "\vprofileName\x18\x1e \x01(\tH\x11R\vprofileName\x88\x01\x01\x12\x1f\n" + "\busername\x18\x1f \x01(\tH\x12R\busername\x88\x01\x01\x12\x15\n" + "\x03mtu\x18 \x01(\x03H\x13R\x03mtu\x88\x01\x01\x12\x17\n" + - "\x04hint\x18! \x01(\tH\x14R\x04hint\x88\x01\x01B\x13\n" + + "\x04hint\x18! \x01(\tH\x14R\x04hint\x88\x01\x01\x12)\n" + + "\renableSSHRoot\x18\" \x01(\bH\x15R\renableSSHRoot\x88\x01\x01\x12)\n" + + "\renableSSHSFTP\x18# \x01(\bH\x16R\renableSSHSFTP\x88\x01\x01\x12G\n" + + "\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\x01\x12&\n" + + "\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -4676,7 +7053,14 @@ const file_daemon_proto_rawDesc = "" + "\f_profileNameB\v\n" + "\t_usernameB\x06\n" + "\x04_mtuB\a\n" + - "\x05_hint\"\xb5\x01\n" + + "\x05_hintB\x10\n" + + "\x0e_enableSSHRootB\x10\n" + + "\x0e_enableSSHSFTPB\x1f\n" + + "\x1d_enableSSHLocalPortForwardingB \n" + + "\x1e_enableSSHRemotePortForwardingB\x11\n" + + "\x0f_disableSSHAuthB\x11\n" + + "\x0f_sshJWTCacheTTLB\x0f\n" + + "\r_disable_ipv6\"\xb5\x01\n" + "\rLoginResponse\x12$\n" + "\rneedsSSOLogin\x18\x01 \x01(\bR\rneedsSSOLogin\x12\x1a\n" + "\buserCode\x18\x02 \x01(\tR\buserCode\x12(\n" + @@ -4686,30 +7070,32 @@ 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\"p\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_username\"\f\n" + + "\t_usernameJ\x04\b\x03\x10\x04\"\f\n" + "\n" + "UpResponse\"\xa1\x01\n" + "\rStatusRequest\x12,\n" + "\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\"\xb5\x06\n" + + "\busername\x18\x02 \x01(\tR\busername\"\xaa\t\n" + "\x11GetConfigResponse\x12$\n" + "\rmanagementUrl\x18\x01 \x01(\tR\rmanagementUrl\x12\x1e\n" + "\n" + @@ -4734,7 +7120,15 @@ const file_daemon_proto_rawDesc = "" + "disableDns\x122\n" + "\x15disable_client_routes\x18\x12 \x01(\bR\x13disableClientRoutes\x122\n" + "\x15disable_server_routes\x18\x13 \x01(\bR\x13disableServerRoutes\x12(\n" + - "\x10block_lan_access\x18\x14 \x01(\bR\x0eblockLanAccess\"\xde\x05\n" + + "\x10block_lan_access\x18\x14 \x01(\bR\x0eblockLanAccess\x12$\n" + + "\renableSSHRoot\x18\x15 \x01(\bR\renableSSHRoot\x12$\n" + + "\renableSSHSFTP\x18\x18 \x01(\bR\renableSSHSFTP\x12B\n" + + "\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\x12!\n" + + "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\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" + @@ -4755,7 +7149,11 @@ const file_daemon_proto_rawDesc = "" + "\x10rosenpassEnabled\x18\x0f \x01(\bR\x10rosenpassEnabled\x12\x1a\n" + "\bnetworks\x18\x10 \x03(\tR\bnetworks\x123\n" + "\alatency\x18\x11 \x01(\v2\x19.google.protobuf.DurationR\alatency\x12\"\n" + - "\frelayAddress\x18\x12 \x01(\tR\frelayAddress\"\xf0\x01\n" + + "\frelayAddress\x18\x12 \x01(\tR\frelayAddress\x12\x1e\n" + + "\n" + + "sshHostKey\x18\x13 \x01(\fR\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" + @@ -4763,7 +7161,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" + @@ -4771,17 +7171,27 @@ 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" + "\aenabled\x18\x03 \x01(\bR\aenabled\x12\x14\n" + - "\x05error\x18\x04 \x01(\tR\x05error\"\xef\x03\n" + + "\x05error\x18\x04 \x01(\tR\x05error\"\xb2\x01\n" + + "\x0eSSHSessionInfo\x12\x1a\n" + + "\busername\x18\x01 \x01(\tR\busername\x12$\n" + + "\rremoteAddress\x18\x02 \x01(\tR\rremoteAddress\x12\x18\n" + + "\acommand\x18\x03 \x01(\tR\acommand\x12 \n" + + "\vjwtUsername\x18\x04 \x01(\tR\vjwtUsername\x12\"\n" + + "\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\"\xdb\x04\n" + "\n" + "FullStatus\x12A\n" + "\x0fmanagementState\x18\x01 \x01(\v2\x17.daemon.ManagementStateR\x0fmanagementState\x125\n" + @@ -4793,7 +7203,10 @@ const file_daemon_proto_rawDesc = "" + "dnsServers\x128\n" + "\x17NumberOfForwardingRules\x18\b \x01(\x05R\x17NumberOfForwardingRules\x12+\n" + "\x06events\x18\a \x03(\v2\x13.daemon.SystemEventR\x06events\x124\n" + - "\x15lazyConnectionEnabled\x18\t \x01(\bR\x15lazyConnectionEnabled\"\x15\n" + + "\x15lazyConnectionEnabled\x18\t \x01(\bR\x15lazyConnectionEnabled\x12>\n" + + "\x0esshServerState\x18\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" + @@ -4829,15 +7242,17 @@ 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\"\xac\x01\n" + + "\x05rules\x18\x01 \x03(\v2\x16.daemon.ForwardingRuleR\x05rules\"\xb4\x01\n" + "\x12DebugBundleRequest\x12\x1c\n" + - "\tanonymize\x18\x01 \x01(\bR\tanonymize\x12\x16\n" + - "\x06status\x18\x02 \x01(\tR\x06status\x12\x1e\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\"}\n" + "\x13DebugBundleResponse\x12\x12\n" + "\x04path\x18\x01 \x01(\tR\x04path\x12 \n" + "\vuploadedKey\x18\x02 \x01(\tR\vuploadedKey\x120\n" + @@ -4847,7 +7262,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" + @@ -4933,8 +7351,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\"\x8e\r\n" + + "\t_username\"'\n" + + "\x15SwitchProfileResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"\x98\x11\n" + "\x10SetConfigRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + "\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" + @@ -4967,7 +7386,14 @@ const file_daemon_proto_rawDesc = "" + "dns_labels\x18\x19 \x03(\tR\tdnsLabels\x12&\n" + "\x0ecleanDNSLabels\x18\x1a \x01(\bR\x0ecleanDNSLabels\x12J\n" + "\x10dnsRouteInterval\x18\x1b \x01(\v2\x19.google.protobuf.DurationH\x10R\x10dnsRouteInterval\x88\x01\x01\x12\x15\n" + - "\x03mtu\x18\x1c \x01(\x03H\x11R\x03mtu\x88\x01\x01B\x13\n" + + "\x03mtu\x18\x1c \x01(\x03H\x11R\x03mtu\x88\x01\x01\x12)\n" + + "\renableSSHRoot\x18\x1d \x01(\bH\x12R\renableSSHRoot\x88\x01\x01\x12)\n" + + "\renableSSHSFTP\x18\x1e \x01(\bH\x13R\renableSSHSFTP\x88\x01\x01\x12G\n" + + "\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\x01\x12&\n" + + "\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -4985,37 +7411,161 @@ const file_daemon_proto_rawDesc = "" + "\x16_lazyConnectionEnabledB\x10\n" + "\x0e_block_inboundB\x13\n" + "\x11_dnsRouteIntervalB\x06\n" + - "\x04_mtu\"\x13\n" + + "\x04_mtuB\x10\n" + + "\x0e_enableSSHRootB\x10\n" + + "\x0e_enableSSHSFTPB\x1f\n" + + "\x1d_enableSSHLocalPortForwardingB \n" + + "\x1e_enableSSHRemotePortForwardingB\x11\n" + + "\x0f_disableSSHAuthB\x11\n" + + "\x0f_sshJWTCacheTTLB\x0f\n" + + "\r_disable_ipv6\"\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\"x\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*b\n" + + "\x17disable_update_settings\x18\x02 \x01(\bR\x15disableUpdateSettings\x12)\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" + + "\berrorMsg\x18\x02 \x01(\tR\berrorMsg\"<\n" + + "\x18GetPeerSSHHostKeyRequest\x12 \n" + + "\vpeerAddress\x18\x01 \x01(\tR\vpeerAddress\"\x85\x01\n" + + "\x19GetPeerSSHHostKeyResponse\x12\x1e\n" + + "\n" + + "sshHostKey\x18\x01 \x01(\fR\n" + + "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" + + "\x15RequestJWTAuthRequest\x12\x17\n" + + "\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01B\a\n" + + "\x05_hint\"\x9a\x02\n" + + "\x16RequestJWTAuthResponse\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\x12 \n" + + "\vcachedToken\x18\x06 \x01(\tR\vcachedToken\x12 \n" + + "\vmaxTokenAge\x18\a \x01(\x03R\vmaxTokenAge\"Q\n" + + "\x13WaitJWTTokenRequest\x12\x1e\n" + + "\n" + + "deviceCode\x18\x01 \x01(\tR\n" + + "deviceCode\x12\x1a\n" + + "\buserCode\x18\x02 \x01(\tR\buserCode\"h\n" + + "\x14WaitJWTTokenResponse\x12\x14\n" + + "\x05token\x18\x01 \x01(\tR\x05token\x12\x1c\n" + + "\ttokenType\x18\x02 \x01(\tR\ttokenType\x12\x1c\n" + + "\texpiresIn\x18\x03 \x01(\x03R\texpiresIn\"C\n" + + "\x1fRequestExtendAuthSessionRequest\x12\x17\n" + + "\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01B\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" + + "\x16StopCPUProfileResponse\"\x18\n" + + "\x16InstallerResultRequest\"O\n" + + "\x17InstallerResultResponse\x12\x18\n" + + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x1a\n" + + "\berrorMsg\x18\x02 \x01(\tR\berrorMsg\"\x87\x02\n" + + "\x14ExposeServiceRequest\x12\x12\n" + + "\x04port\x18\x01 \x01(\rR\x04port\x122\n" + + "\bprotocol\x18\x02 \x01(\x0e2\x16.daemon.ExposeProtocolR\bprotocol\x12\x10\n" + + "\x03pin\x18\x03 \x01(\tR\x03pin\x12\x1a\n" + + "\bpassword\x18\x04 \x01(\tR\bpassword\x12\x1f\n" + + "\vuser_groups\x18\x05 \x03(\tR\n" + + "userGroups\x12\x16\n" + + "\x06domain\x18\x06 \x01(\tR\x06domain\x12\x1f\n" + + "\vname_prefix\x18\a \x01(\tR\n" + + "namePrefix\x12\x1f\n" + + "\vlisten_port\x18\b \x01(\rR\n" + + "listenPort\"Q\n" + + "\x12ExposeServiceEvent\x122\n" + + "\x05ready\x18\x01 \x01(\v2\x1a.daemon.ExposeServiceReadyH\x00R\x05readyB\a\n" + + "\x05event\"\x9e\x01\n" + + "\x12ExposeServiceReady\x12!\n" + + "\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x1f\n" + + "\vservice_url\x18\x02 \x01(\tR\n" + + "serviceUrl\x12\x16\n" + + "\x06domain\x18\x03 \x01(\tR\x06domain\x12,\n" + + "\x12port_auto_assigned\x18\x04 \x01(\bR\x10portAutoAssigned\"\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" + @@ -5024,12 +7574,22 @@ const file_daemon_proto_rawDesc = "" + "\x04WARN\x10\x04\x12\b\n" + "\x04INFO\x10\x05\x12\t\n" + "\x05DEBUG\x10\x06\x12\t\n" + - "\x05TRACE\x10\a2\x8f\x10\n" + + "\x05TRACE\x10\a*c\n" + + "\x0eExposeProtocol\x12\x0f\n" + + "\vEXPOSE_HTTP\x10\x00\x12\x10\n" + + "\fEXPOSE_HTTPS\x10\x01\x12\x0e\n" + + "\n" + + "EXPOSE_TCP\x10\x02\x12\x0e\n" + + "\n" + + "EXPOSE_UDP\x10\x03\x12\x0e\n" + + "\n" + + "EXPOSE_TLS\x10\x042\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" + @@ -5045,18 +7605,35 @@ 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" + "\x06Logout\x12\x15.daemon.LogoutRequest\x1a\x16.daemon.LogoutResponse\"\x00\x12H\n" + - "\vGetFeatures\x12\x1a.daemon.GetFeaturesRequest\x1a\x1b.daemon.GetFeaturesResponse\"\x00B\bZ\x06/protob\x06proto3" + "\vGetFeatures\x12\x1a.daemon.GetFeaturesRequest\x1a\x1b.daemon.GetFeaturesResponse\"\x00\x12N\n" + + "\rTriggerUpdate\x12\x1c.daemon.TriggerUpdateRequest\x1a\x1d.daemon.TriggerUpdateResponse\"\x00\x12Z\n" + + "\x11GetPeerSSHHostKey\x12 .daemon.GetPeerSSHHostKeyRequest\x1a!.daemon.GetPeerSSHHostKeyResponse\"\x00\x12Q\n" + + "\x0eRequestJWTAuth\x12\x1d.daemon.RequestJWTAuthRequest\x1a\x1e.daemon.RequestJWTAuthResponse\"\x00\x12K\n" + + "\fWaitJWTToken\x12\x1b.daemon.WaitJWTTokenRequest\x1a\x1c.daemon.WaitJWTTokenResponse\"\x00\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\x12W\n" + + "\x12GetInstallerResult\x12\x1e.daemon.InstallerResultRequest\x1a\x1f.daemon.InstallerResultResponse\"\x00\x12M\n" + + "\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 @@ -5070,180 +7647,263 @@ func file_daemon_proto_rawDescGZIP() []byte { return file_daemon_proto_rawDescData } -var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 72) +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 - (SystemEvent_Severity)(0), // 1: daemon.SystemEvent.Severity - (SystemEvent_Category)(0), // 2: daemon.SystemEvent.Category - (*EmptyRequest)(nil), // 3: daemon.EmptyRequest - (*LoginRequest)(nil), // 4: daemon.LoginRequest - (*LoginResponse)(nil), // 5: daemon.LoginResponse - (*WaitSSOLoginRequest)(nil), // 6: daemon.WaitSSOLoginRequest - (*WaitSSOLoginResponse)(nil), // 7: daemon.WaitSSOLoginResponse - (*UpRequest)(nil), // 8: daemon.UpRequest - (*UpResponse)(nil), // 9: daemon.UpResponse - (*StatusRequest)(nil), // 10: daemon.StatusRequest - (*StatusResponse)(nil), // 11: daemon.StatusResponse - (*DownRequest)(nil), // 12: daemon.DownRequest - (*DownResponse)(nil), // 13: daemon.DownResponse - (*GetConfigRequest)(nil), // 14: daemon.GetConfigRequest - (*GetConfigResponse)(nil), // 15: daemon.GetConfigResponse - (*PeerState)(nil), // 16: daemon.PeerState - (*LocalPeerState)(nil), // 17: daemon.LocalPeerState - (*SignalState)(nil), // 18: daemon.SignalState - (*ManagementState)(nil), // 19: daemon.ManagementState - (*RelayState)(nil), // 20: daemon.RelayState - (*NSGroupState)(nil), // 21: daemon.NSGroupState - (*FullStatus)(nil), // 22: daemon.FullStatus - (*ListNetworksRequest)(nil), // 23: daemon.ListNetworksRequest - (*ListNetworksResponse)(nil), // 24: daemon.ListNetworksResponse - (*SelectNetworksRequest)(nil), // 25: daemon.SelectNetworksRequest - (*SelectNetworksResponse)(nil), // 26: daemon.SelectNetworksResponse - (*IPList)(nil), // 27: daemon.IPList - (*Network)(nil), // 28: daemon.Network - (*PortInfo)(nil), // 29: daemon.PortInfo - (*ForwardingRule)(nil), // 30: daemon.ForwardingRule - (*ForwardingRulesResponse)(nil), // 31: daemon.ForwardingRulesResponse - (*DebugBundleRequest)(nil), // 32: daemon.DebugBundleRequest - (*DebugBundleResponse)(nil), // 33: daemon.DebugBundleResponse - (*GetLogLevelRequest)(nil), // 34: daemon.GetLogLevelRequest - (*GetLogLevelResponse)(nil), // 35: daemon.GetLogLevelResponse - (*SetLogLevelRequest)(nil), // 36: daemon.SetLogLevelRequest - (*SetLogLevelResponse)(nil), // 37: daemon.SetLogLevelResponse - (*State)(nil), // 38: daemon.State - (*ListStatesRequest)(nil), // 39: daemon.ListStatesRequest - (*ListStatesResponse)(nil), // 40: daemon.ListStatesResponse - (*CleanStateRequest)(nil), // 41: daemon.CleanStateRequest - (*CleanStateResponse)(nil), // 42: daemon.CleanStateResponse - (*DeleteStateRequest)(nil), // 43: daemon.DeleteStateRequest - (*DeleteStateResponse)(nil), // 44: daemon.DeleteStateResponse - (*SetSyncResponsePersistenceRequest)(nil), // 45: daemon.SetSyncResponsePersistenceRequest - (*SetSyncResponsePersistenceResponse)(nil), // 46: daemon.SetSyncResponsePersistenceResponse - (*TCPFlags)(nil), // 47: daemon.TCPFlags - (*TracePacketRequest)(nil), // 48: daemon.TracePacketRequest - (*TraceStage)(nil), // 49: daemon.TraceStage - (*TracePacketResponse)(nil), // 50: daemon.TracePacketResponse - (*SubscribeRequest)(nil), // 51: daemon.SubscribeRequest - (*SystemEvent)(nil), // 52: daemon.SystemEvent - (*GetEventsRequest)(nil), // 53: daemon.GetEventsRequest - (*GetEventsResponse)(nil), // 54: daemon.GetEventsResponse - (*SwitchProfileRequest)(nil), // 55: daemon.SwitchProfileRequest - (*SwitchProfileResponse)(nil), // 56: daemon.SwitchProfileResponse - (*SetConfigRequest)(nil), // 57: daemon.SetConfigRequest - (*SetConfigResponse)(nil), // 58: daemon.SetConfigResponse - (*AddProfileRequest)(nil), // 59: daemon.AddProfileRequest - (*AddProfileResponse)(nil), // 60: daemon.AddProfileResponse - (*RemoveProfileRequest)(nil), // 61: daemon.RemoveProfileRequest - (*RemoveProfileResponse)(nil), // 62: daemon.RemoveProfileResponse - (*ListProfilesRequest)(nil), // 63: daemon.ListProfilesRequest - (*ListProfilesResponse)(nil), // 64: daemon.ListProfilesResponse - (*Profile)(nil), // 65: daemon.Profile - (*GetActiveProfileRequest)(nil), // 66: daemon.GetActiveProfileRequest - (*GetActiveProfileResponse)(nil), // 67: daemon.GetActiveProfileResponse - (*LogoutRequest)(nil), // 68: daemon.LogoutRequest - (*LogoutResponse)(nil), // 69: daemon.LogoutResponse - (*GetFeaturesRequest)(nil), // 70: daemon.GetFeaturesRequest - (*GetFeaturesResponse)(nil), // 71: daemon.GetFeaturesResponse - nil, // 72: daemon.Network.ResolvedIPsEntry - (*PortInfo_Range)(nil), // 73: daemon.PortInfo.Range - nil, // 74: daemon.SystemEvent.MetadataEntry - (*durationpb.Duration)(nil), // 75: google.protobuf.Duration - (*timestamppb.Timestamp)(nil), // 76: google.protobuf.Timestamp + (ExposeProtocol)(0), // 1: daemon.ExposeProtocol + (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{ - 75, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration - 22, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus - 76, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp - 76, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp - 75, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration - 19, // 5: daemon.FullStatus.managementState:type_name -> daemon.ManagementState - 18, // 6: daemon.FullStatus.signalState:type_name -> daemon.SignalState - 17, // 7: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState - 16, // 8: daemon.FullStatus.peers:type_name -> daemon.PeerState - 20, // 9: daemon.FullStatus.relays:type_name -> daemon.RelayState - 21, // 10: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState - 52, // 11: daemon.FullStatus.events:type_name -> daemon.SystemEvent - 28, // 12: daemon.ListNetworksResponse.routes:type_name -> daemon.Network - 72, // 13: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry - 73, // 14: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range - 29, // 15: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo - 29, // 16: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo - 30, // 17: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule - 0, // 18: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel - 0, // 19: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel - 38, // 20: daemon.ListStatesResponse.states:type_name -> daemon.State - 47, // 21: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags - 49, // 22: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage - 1, // 23: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity - 2, // 24: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category - 76, // 25: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp - 74, // 26: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry - 52, // 27: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent - 75, // 28: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration - 65, // 29: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile - 27, // 30: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList - 4, // 31: daemon.DaemonService.Login:input_type -> daemon.LoginRequest - 6, // 32: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest - 8, // 33: daemon.DaemonService.Up:input_type -> daemon.UpRequest - 10, // 34: daemon.DaemonService.Status:input_type -> daemon.StatusRequest - 12, // 35: daemon.DaemonService.Down:input_type -> daemon.DownRequest - 14, // 36: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest - 23, // 37: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest - 25, // 38: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest - 25, // 39: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest - 3, // 40: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest - 32, // 41: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest - 34, // 42: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest - 36, // 43: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest - 39, // 44: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest - 41, // 45: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest - 43, // 46: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest - 45, // 47: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest - 48, // 48: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest - 51, // 49: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest - 53, // 50: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest - 55, // 51: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest - 57, // 52: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest - 59, // 53: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest - 61, // 54: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest - 63, // 55: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest - 66, // 56: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest - 68, // 57: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest - 70, // 58: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest - 5, // 59: daemon.DaemonService.Login:output_type -> daemon.LoginResponse - 7, // 60: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse - 9, // 61: daemon.DaemonService.Up:output_type -> daemon.UpResponse - 11, // 62: daemon.DaemonService.Status:output_type -> daemon.StatusResponse - 13, // 63: daemon.DaemonService.Down:output_type -> daemon.DownResponse - 15, // 64: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse - 24, // 65: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse - 26, // 66: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse - 26, // 67: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse - 31, // 68: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse - 33, // 69: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse - 35, // 70: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse - 37, // 71: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse - 40, // 72: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse - 42, // 73: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse - 44, // 74: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse - 46, // 75: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse - 50, // 76: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse - 52, // 77: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent - 54, // 78: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse - 56, // 79: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse - 58, // 80: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse - 60, // 81: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse - 62, // 82: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse - 64, // 83: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse - 67, // 84: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse - 69, // 85: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse - 71, // 86: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse - 59, // [59:87] is the sub-list for method output_type - 31, // [31:59] is the sub-list for method input_type - 31, // [31:31] is the sub-list for extension type_name - 31, // [31:31] is the sub-list for extension extendee - 0, // [0:31] 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() } @@ -5254,22 +7914,28 @@ func file_daemon_proto_init() { 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[26].OneofWrappers = []any{ + file_daemon_proto_msgTypes[28].OneofWrappers = []any{ (*PortInfo_Port)(nil), (*PortInfo_Range_)(nil), } - file_daemon_proto_msgTypes[45].OneofWrappers = []any{} - file_daemon_proto_msgTypes[46].OneofWrappers = []any{} - file_daemon_proto_msgTypes[52].OneofWrappers = []any{} - file_daemon_proto_msgTypes[54].OneofWrappers = []any{} - file_daemon_proto_msgTypes[65].OneofWrappers = []any{} + file_daemon_proto_msgTypes[49].OneofWrappers = []any{} + 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[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{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)), - NumEnums: 3, - NumMessages: 72, + 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 8d1080051..8d5294eb7 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -24,7 +24,13 @@ service DaemonService { // Status of the service. rpc Status(StatusRequest) returns (StatusResponse) {} - // Down engine work in the daemon. + // 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) {} // GetConfig of the daemon. @@ -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) {} @@ -84,9 +108,58 @@ service DaemonService { rpc Logout(LogoutRequest) returns (LogoutResponse) {} rpc GetFeatures(GetFeaturesRequest) returns (GetFeaturesResponse) {} + + // TriggerUpdate initiates installation of the pending enforced version. + // Called when the user clicks the install button in the UI (Mode 2 / enforced update). + rpc TriggerUpdate(TriggerUpdateRequest) returns (TriggerUpdateResponse) {} + + // GetPeerSSHHostKey retrieves SSH host key for a specific peer + rpc GetPeerSSHHostKey(GetPeerSSHHostKeyRequest) returns (GetPeerSSHHostKeyResponse) {} + + // RequestJWTAuth initiates JWT authentication flow for SSH + rpc RequestJWTAuth(RequestJWTAuthRequest) returns (RequestJWTAuthResponse) {} + + // 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 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 LoginRequest { // setupKey netbird setup key. string setupKey = 1; @@ -161,6 +234,14 @@ message LoginRequest { // hint is used to pre-fill the email/username field during SSO authentication optional string hint = 33; + + optional bool enableSSHRoot = 34; + optional bool enableSSHSFTP = 35; + optional bool enableSSHLocalPortForwarding = 36; + optional bool enableSSHRemotePortForwarding = 37; + optional bool disableSSHAuth = 38; + optional int32 sshJWTCacheTTL = 39; + optional bool disable_ipv6 = 40; } message LoginResponse { @@ -182,15 +263,22 @@ message WaitSSOLoginResponse { 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 {} message StatusRequest{ bool getFullPeerStatus = 1; - bool shouldRunProbes = 2; + bool shouldRunProbes = 2; // the UI do not using this yet, but CLIs could use it to wait until the status is ready - optional bool waitForReady = 3; + optional bool waitForReady = 3; } message StatusResponse{ @@ -199,6 +287,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 {} @@ -255,6 +347,27 @@ message GetConfigResponse { bool disable_server_routes = 19; bool block_lan_access = 20; + + bool enableSSHRoot = 21; + + bool enableSSHSFTP = 24; + + bool enableSSHLocalPortForwarding = 22; + + bool enableSSHRemotePortForwarding = 23; + + bool disableSSHAuth = 25; + + int32 sshJWTCacheTTL = 26; + + bool disable_ipv6 = 27; + + // 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 @@ -276,6 +389,8 @@ message PeerState { repeated string networks = 16; google.protobuf.Duration latency = 17; string relayAddress = 18; + bytes sshHostKey = 19; + string ipv6 = 20; } // LocalPeerState contains the latest state of the local peer @@ -287,6 +402,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 @@ -308,6 +425,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 { @@ -317,6 +437,21 @@ message NSGroupState { string error = 4; } +// SSHSessionInfo contains information about an active SSH session +message SSHSessionInfo { + string username = 1; + string remoteAddress = 2; + string command = 3; + string jwtUsername = 4; + repeated string portForwards = 5; +} + +// SSHServerState contains the latest state of the SSH server +message SSHServerState { + bool enabled = 1; + repeated SSHSessionInfo sessions = 2; +} + // FullStatus contains the full state held by the Status instance message FullStatus { ManagementState managementState = 1; @@ -330,6 +465,13 @@ message FullStatus { repeated SystemEvent events = 7; 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 @@ -390,10 +532,10 @@ message ForwardingRulesResponse { // DebugBundler message DebugBundleRequest { bool anonymize = 1; - string status = 2; bool systemInfo = 3; string uploadURL = 4; uint32 logFileCount = 5; + string cliVersion = 6; } message DebugBundleResponse { @@ -427,6 +569,13 @@ message SetLogLevelRequest { message SetLogLevelResponse { } +message RegisterUILogRequest { + string path = 1; +} + +message RegisterUILogResponse { +} + // State represents a daemon state entry message State { string name = 1; @@ -536,80 +685,120 @@ 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; - string profileName = 2; - // managementUrl to authenticate. - string managementUrl = 3; + string username = 1; + string profileName = 2; + // managementUrl to authenticate. + string managementUrl = 3; - // adminUrl to manage keys. - string adminURL = 4; + // adminUrl to manage keys. + string adminURL = 4; - optional bool rosenpassEnabled = 5; + optional bool rosenpassEnabled = 5; - optional string interfaceName = 6; + optional string interfaceName = 6; - optional int64 wireguardPort = 7; + optional int64 wireguardPort = 7; - optional string optionalPreSharedKey = 8; + optional string optionalPreSharedKey = 8; - optional bool disableAutoConnect = 9; + optional bool disableAutoConnect = 9; - optional bool serverSSHAllowed = 10; + optional bool serverSSHAllowed = 10; - optional bool rosenpassPermissive = 11; + optional bool rosenpassPermissive = 11; - optional bool networkMonitor = 12; + optional bool networkMonitor = 12; - optional bool disable_client_routes = 13; - optional bool disable_server_routes = 14; - optional bool disable_dns = 15; - optional bool disable_firewall = 16; - optional bool block_lan_access = 17; + optional bool disable_client_routes = 13; + optional bool disable_server_routes = 14; + optional bool disable_dns = 15; + optional bool disable_firewall = 16; + optional bool block_lan_access = 17; - optional bool disable_notifications = 18; + optional bool disable_notifications = 18; - optional bool lazyConnectionEnabled = 19; + optional bool lazyConnectionEnabled = 19; - optional bool block_inbound = 20; + optional bool block_inbound = 20; - repeated string natExternalIPs = 21; - bool cleanNATExternalIPs = 22; + repeated string natExternalIPs = 21; + bool cleanNATExternalIPs = 22; - bytes customDNSAddress = 23; + bytes customDNSAddress = 23; - repeated string extraIFaceBlacklist = 24; + repeated string extraIFaceBlacklist = 24; - repeated string dns_labels = 25; - // cleanDNSLabels clean map list of DNS labels. - bool cleanDNSLabels = 26; + repeated string dns_labels = 25; + // cleanDNSLabels clean map list of DNS labels. + bool cleanDNSLabels = 26; - optional google.protobuf.Duration dnsRouteInterval = 27; + optional google.protobuf.Duration dnsRouteInterval = 27; - optional int64 mtu = 28; + optional int64 mtu = 28; + + optional bool enableSSHRoot = 29; + optional bool enableSSHSFTP = 30; + optional bool enableSSHLocalPortForwarding = 31; + optional bool enableSSHRemotePortForwarding = 32; + optional bool disableSSHAuth = 33; + optional int32 sshJWTCacheTTL = 34; + optional bool disable_ipv6 = 35; } 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; @@ -622,6 +811,7 @@ message ListProfilesResponse { message Profile { string name = 1; bool is_active = 2; + string id = 3; } message GetActiveProfileRequest {} @@ -629,6 +819,7 @@ message GetActiveProfileRequest {} message GetActiveProfileResponse { string profileName = 1; string username = 2; + string id = 3; } message LogoutRequest { @@ -638,9 +829,220 @@ 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 {} + +message TriggerUpdateResponse { + bool success = 1; + string errorMsg = 2; +} + +// GetPeerSSHHostKeyRequest for retrieving SSH host key for a specific peer +message GetPeerSSHHostKeyRequest { + // peer IP address or FQDN to get SSH host key for + string peerAddress = 1; +} + +// GetPeerSSHHostKeyResponse contains the SSH host key for the requested peer +message GetPeerSSHHostKeyResponse { + // SSH host key in SSH public key format (e.g., "ssh-ed25519 AAAAC3... hostname") + bytes sshHostKey = 1; + // peer IP address + string peerIP = 2; + // peer FQDN + string peerFQDN = 3; + // indicates if the SSH host key was found + bool found = 4; +} + +// RequestJWTAuthRequest for initiating JWT authentication flow +message RequestJWTAuthRequest { + // hint for OIDC login_hint parameter (typically email address) + optional string hint = 1; +} + +// RequestJWTAuthResponse contains authentication flow information +message RequestJWTAuthResponse { + // verification URI for user authentication + string verificationURI = 1; + // complete verification URI (with embedded user code) + string verificationURIComplete = 2; + // user code to enter on verification URI + string userCode = 3; + // device code for polling + string deviceCode = 4; + // expiration time in seconds + int64 expiresIn = 5; + // if a cached token is available, it will be returned here + string cachedToken = 6; + // maximum age of JWT tokens in seconds (from management server) + int64 maxTokenAge = 7; +} + +// WaitJWTTokenRequest for waiting for authentication completion +message WaitJWTTokenRequest { + // device code from RequestJWTAuthResponse + string deviceCode = 1; + // user code for verification + string userCode = 2; +} + +// WaitJWTTokenResponse contains the JWT token after authentication +message WaitJWTTokenResponse { + // JWT token (access token or ID token) + string token = 1; + // token type (e.g., "Bearer") + string tokenType = 2; + // expiration time in seconds + 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; +} + +// 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 {} + +// StartCPUProfileResponse confirms CPU profiling has started +message StartCPUProfileResponse {} + +// StopCPUProfileRequest for stopping CPU profiling +message StopCPUProfileRequest {} + +// StopCPUProfileResponse confirms CPU profiling has stopped +message StopCPUProfileResponse {} + +message InstallerResultRequest { +} + +message InstallerResultResponse { + bool success = 1; + string errorMsg = 2; +} + +enum ExposeProtocol { + EXPOSE_HTTP = 0; + EXPOSE_HTTPS = 1; + EXPOSE_TCP = 2; + EXPOSE_UDP = 3; + EXPOSE_TLS = 4; +} + +message ExposeServiceRequest { + uint32 port = 1; + ExposeProtocol protocol = 2; + string pin = 3; + string password = 4; + repeated string user_groups = 5; + string domain = 6; + string name_prefix = 7; + uint32 listen_port = 8; +} + +message ExposeServiceEvent { + oneof event { + ExposeServiceReady ready = 1; + } +} + +message ExposeServiceReady { + string service_name = 1; + string service_url = 2; + string domain = 3; + bool port_auto_assigned = 4; +} + +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 bf7c9c7b3..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,7 +80,12 @@ 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) - // Down engine work in the daemon. + // 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. GetConfig(ctx context.Context, in *GetConfigRequest, opts ...grpc.CallOption) (*GetConfigResponse, error) @@ -53,17 +111,66 @@ 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) // Logout disconnects from the network and deletes the peer from the management server Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error) GetFeatures(ctx context.Context, in *GetFeaturesRequest, opts ...grpc.CallOption) (*GetFeaturesResponse, error) + // TriggerUpdate initiates installation of the pending enforced version. + // Called when the user clicks the install button in the UI (Mode 2 / enforced update). + TriggerUpdate(ctx context.Context, in *TriggerUpdateRequest, opts ...grpc.CallOption) (*TriggerUpdateResponse, error) + // GetPeerSSHHostKey retrieves SSH host key for a specific peer + GetPeerSSHHostKey(ctx context.Context, in *GetPeerSSHHostKeyRequest, opts ...grpc.CallOption) (*GetPeerSSHHostKeyResponse, error) + // RequestJWTAuth initiates JWT authentication flow for SSH + 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) + 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) (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 { @@ -75,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 } @@ -84,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 } @@ -93,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 } @@ -102,146 +212,22 @@ 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) Down(ctx context.Context, in *DownRequest, opts ...grpc.CallOption) (*DownResponse, error) { - out := new(DownResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/Down", in, out, opts...) +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 } - return out, nil -} - -func (c *daemonServiceClient) GetConfig(ctx context.Context, in *GetConfigRequest, opts ...grpc.CallOption) (*GetConfigResponse, error) { - out := new(GetConfigResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/GetConfig", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *daemonServiceClient) ListNetworks(ctx context.Context, in *ListNetworksRequest, opts ...grpc.CallOption) (*ListNetworksResponse, error) { - out := new(ListNetworksResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/ListNetworks", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *daemonServiceClient) SelectNetworks(ctx context.Context, in *SelectNetworksRequest, opts ...grpc.CallOption) (*SelectNetworksResponse, error) { - out := new(SelectNetworksResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/SelectNetworks", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *daemonServiceClient) DeselectNetworks(ctx context.Context, in *SelectNetworksRequest, opts ...grpc.CallOption) (*SelectNetworksResponse, error) { - out := new(SelectNetworksResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/DeselectNetworks", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *daemonServiceClient) ForwardingRules(ctx context.Context, in *EmptyRequest, opts ...grpc.CallOption) (*ForwardingRulesResponse, error) { - out := new(ForwardingRulesResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/ForwardingRules", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *daemonServiceClient) DebugBundle(ctx context.Context, in *DebugBundleRequest, opts ...grpc.CallOption) (*DebugBundleResponse, error) { - out := new(DebugBundleResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/DebugBundle", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *daemonServiceClient) GetLogLevel(ctx context.Context, in *GetLogLevelRequest, opts ...grpc.CallOption) (*GetLogLevelResponse, error) { - out := new(GetLogLevelResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/GetLogLevel", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *daemonServiceClient) SetLogLevel(ctx context.Context, in *SetLogLevelRequest, opts ...grpc.CallOption) (*SetLogLevelResponse, error) { - out := new(SetLogLevelResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/SetLogLevel", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *daemonServiceClient) ListStates(ctx context.Context, in *ListStatesRequest, opts ...grpc.CallOption) (*ListStatesResponse, error) { - out := new(ListStatesResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/ListStates", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *daemonServiceClient) CleanState(ctx context.Context, in *CleanStateRequest, opts ...grpc.CallOption) (*CleanStateResponse, error) { - out := new(CleanStateResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/CleanState", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *daemonServiceClient) DeleteState(ctx context.Context, in *DeleteStateRequest, opts ...grpc.CallOption) (*DeleteStateResponse, error) { - out := new(DeleteStateResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/DeleteState", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *daemonServiceClient) SetSyncResponsePersistence(ctx context.Context, in *SetSyncResponsePersistenceRequest, opts ...grpc.CallOption) (*SetSyncResponsePersistenceResponse, error) { - out := new(SetSyncResponsePersistenceResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/SetSyncResponsePersistence", in, out, opts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *daemonServiceClient) TracePacket(ctx context.Context, in *TracePacketRequest, opts ...grpc.CallOption) (*TracePacketResponse, error) { - out := new(TracePacketResponse) - err := c.cc.Invoke(ctx, "/daemon.DaemonService/TracePacket", in, out, opts...) - 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...) - if err != nil { - return nil, err - } - x := &daemonServiceSubscribeEventsClient{stream} + x := &grpc.GenericClientStream[StatusRequest, StatusResponse]{ClientStream: stream} if err := x.ClientStream.SendMsg(in); err != nil { return nil, err } @@ -251,26 +237,221 @@ 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_SubscribeStatusClient = grpc.ServerStreamingClient[StatusResponse] -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) 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, DaemonService_Down_FullMethodName, in, out, cOpts...) + if err != nil { return nil, err } - return m, nil + return out, nil } +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, DaemonService_GetConfig_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +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, DaemonService_ListNetworks_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +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, DaemonService_SelectNetworks_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +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, DaemonService_DeselectNetworks_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +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, DaemonService_ForwardingRules_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +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, DaemonService_DebugBundle_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +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, DaemonService_GetLogLevel_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +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, DaemonService_SetLogLevel_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +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, DaemonService_ListStates_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +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, DaemonService_CleanState_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +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, DaemonService_DeleteState_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +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, DaemonService_SetSyncResponsePersistence_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +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, DaemonService_TracePacket_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +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 := &grpc.GenericClientStream[StartCaptureRequest, CapturePacket]{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_StartCaptureClient = grpc.ServerStreamingClient[CapturePacket] + +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 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 } @@ -278,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 } @@ -287,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 } @@ -296,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 } @@ -305,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 } @@ -314,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 } @@ -323,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 } @@ -332,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 } @@ -341,8 +539,138 @@ 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 + } + return out, nil +} + +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, DaemonService_TriggerUpdate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +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, DaemonService_GetPeerSSHHostKey_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +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, DaemonService_RequestJWTAuth_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +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, 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 + } + return out, nil +} + +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, DaemonService_StartCPUProfile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +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, DaemonService_StopCPUProfile_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +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, 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) (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 := &grpc.GenericClientStream[ExposeServiceRequest, ExposeServiceEvent]{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_ExposeServiceClient = grpc.ServerStreamingClient[ExposeServiceEvent] + +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 } @@ -351,7 +679,7 @@ func (c *daemonServiceClient) GetFeatures(ctx context.Context, in *GetFeaturesRe // 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) @@ -362,7 +690,12 @@ type DaemonServiceServer interface { Up(context.Context, *UpRequest) (*UpResponse, error) // Status of the service. Status(context.Context, *StatusRequest) (*StatusResponse, error) - // Down engine work in the daemon. + // 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. GetConfig(context.Context, *GetConfigRequest) (*GetConfigResponse, error) @@ -388,109 +721,216 @@ 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) // Logout disconnects from the network and deletes the peer from the management server Logout(context.Context, *LogoutRequest) (*LogoutResponse, error) GetFeatures(context.Context, *GetFeaturesRequest) (*GetFeaturesResponse, error) + // TriggerUpdate initiates installation of the pending enforced version. + // Called when the user clicks the install button in the UI (Mode 2 / enforced update). + TriggerUpdate(context.Context, *TriggerUpdateRequest) (*TriggerUpdateResponse, error) + // GetPeerSSHHostKey retrieves SSH host key for a specific peer + GetPeerSSHHostKey(context.Context, *GetPeerSSHHostKeyRequest) (*GetPeerSSHHostKeyResponse, error) + // RequestJWTAuth initiates JWT authentication flow for SSH + 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) + GetInstallerResult(context.Context, *InstallerResultRequest) (*InstallerResultResponse, error) + // ExposeService exposes a local port via the NetBird reverse proxy + 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.Error(codes.Unimplemented, "method TriggerUpdate not implemented") +} +func (UnimplementedDaemonServiceServer) GetPeerSSHHostKey(context.Context, *GetPeerSSHHostKeyRequest) (*GetPeerSSHHostKeyResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetPeerSSHHostKey not implemented") +} +func (UnimplementedDaemonServiceServer) RequestJWTAuth(context.Context, *RequestJWTAuthRequest) (*RequestJWTAuthResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RequestJWTAuth not implemented") +} +func (UnimplementedDaemonServiceServer) WaitJWTToken(context.Context, *WaitJWTTokenRequest) (*WaitJWTTokenResponse, error) { + 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.Error(codes.Unimplemented, "method StartCPUProfile not implemented") +} +func (UnimplementedDaemonServiceServer) StopCPUProfile(context.Context, *StopCPUProfileRequest) (*StopCPUProfileResponse, error) { + return nil, status.Error(codes.Unimplemented, "method StopCPUProfile not implemented") +} +func (UnimplementedDaemonServiceServer) GetInstallerResult(context.Context, *InstallerResultRequest) (*InstallerResultResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetInstallerResult 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 @@ -500,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) } @@ -513,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)) @@ -531,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)) @@ -549,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)) @@ -567,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)) @@ -575,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 { @@ -585,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)) @@ -603,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)) @@ -621,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)) @@ -639,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)) @@ -657,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)) @@ -675,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)) @@ -693,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)) @@ -711,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)) @@ -729,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)) @@ -747,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)) @@ -765,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)) @@ -783,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)) @@ -801,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)) @@ -819,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)) @@ -827,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) @@ -858,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)) @@ -866,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 { @@ -876,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)) @@ -894,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)) @@ -912,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)) @@ -920,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 { @@ -930,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)) @@ -948,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)) @@ -966,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)) @@ -984,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)) @@ -1002,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)) @@ -1010,6 +1541,215 @@ func _DaemonService_GetFeatures_Handler(srv interface{}, ctx context.Context, de return interceptor(ctx, in, info, handler) } +func _DaemonService_TriggerUpdate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(TriggerUpdateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).TriggerUpdate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_TriggerUpdate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).TriggerUpdate(ctx, req.(*TriggerUpdateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DaemonService_GetPeerSSHHostKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetPeerSSHHostKeyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).GetPeerSSHHostKey(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_GetPeerSSHHostKey_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).GetPeerSSHHostKey(ctx, req.(*GetPeerSSHHostKeyRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DaemonService_RequestJWTAuth_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RequestJWTAuthRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).RequestJWTAuth(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_RequestJWTAuth_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).RequestJWTAuth(ctx, req.(*RequestJWTAuthRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DaemonService_WaitJWTToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(WaitJWTTokenRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).WaitJWTToken(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_WaitJWTToken_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).WaitJWTToken(ctx, req.(*WaitJWTTokenRequest)) + } + 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 { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).StartCPUProfile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_StartCPUProfile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).StartCPUProfile(ctx, req.(*StartCPUProfileRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DaemonService_StopCPUProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StopCPUProfileRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).StopCPUProfile(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_StopCPUProfile_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).StopCPUProfile(ctx, req.(*StopCPUProfileRequest)) + } + 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 { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).GetInstallerResult(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_GetInstallerResult_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).GetInstallerResult(ctx, req.(*InstallerResultRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DaemonService_ExposeService_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(ExposeServiceRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(DaemonServiceServer).ExposeService(m, &grpc.GenericServerStream[ExposeServiceRequest, ExposeServiceEvent]{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_ExposeServiceServer = grpc.ServerStreamingServer[ExposeServiceEvent] + +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. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -1089,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, @@ -1105,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, @@ -1125,13 +1881,72 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetFeatures", Handler: _DaemonService_GetFeatures_Handler, }, + { + MethodName: "TriggerUpdate", + Handler: _DaemonService_TriggerUpdate_Handler, + }, + { + MethodName: "GetPeerSSHHostKey", + Handler: _DaemonService_GetPeerSSHHostKey_Handler, + }, + { + MethodName: "RequestJWTAuth", + Handler: _DaemonService_RequestJWTAuth_Handler, + }, + { + 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, + }, + { + MethodName: "StopCPUProfile", + Handler: _DaemonService_StopCPUProfile_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, ServerStreams: true, }, + { + StreamName: "ExposeService", + Handler: _DaemonService_ExposeService_Handler, + ServerStreams: true, + }, }, Metadata: "daemon.proto", } diff --git a/client/proto/generate.sh b/client/proto/generate.sh index f9a2c3750..cea8ae912 100755 --- a/client/proto/generate.sh +++ b/client/proto/generate.sh @@ -1,17 +1,22 @@ #!/bin/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 -cd "$old_pwd" \ No newline at end of file +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 056d9df21..0b6ac4b53 100644 --- a/client/server/debug.go +++ b/client/server/debug.go @@ -3,25 +3,20 @@ package server import ( + "bytes" "context" - "crypto/sha256" - "encoding/json" "errors" "fmt" - "io" - "net/http" - "os" + "runtime/pprof" log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/client/internal/debug" "github.com/netbirdio/netbird/client/proto" mgmProto "github.com/netbirdio/netbird/shared/management/proto" - "github.com/netbirdio/netbird/upload-server/types" + "github.com/netbirdio/netbird/version" ) -const maxBundleUploadSize = 50 * 1024 * 1024 - // DebugBundle creates a debug bundle and returns the location. func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (resp *proto.DebugBundleResponse, err error) { s.mutex.Lock() @@ -32,16 +27,56 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) ( log.Warnf("failed to get latest sync response: %v", err) } + var clientMetrics debug.MetricsExporter + if s.connectClient != nil { + if engine := s.connectClient.Engine(); engine != nil { + if cm := engine.GetClientMetrics(); cm != nil { + clientMetrics = cm + } + } + } + + var cpuProfileData []byte + if s.cpuProfileBuf != nil && !s.cpuProfiling { + cpuProfileData = s.cpuProfileBuf.Bytes() + defer func() { + s.cpuProfileBuf = nil + }() + } + + 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") + // 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) + } + } + } + bundleGenerator := debug.NewBundleGenerator( debug.GeneratorDependencies{ InternalConfig: s.config, StatusRecorder: s.statusRecorder, SyncResponse: syncResponse, - LogFile: s.logFile, + LogPath: s.logFile, + UILogPath: s.uiLogPath, + CPUProfile: cpuProfileData, + CapturePath: capturePath, + RefreshStatus: refreshStatus, + ClientMetrics: clientMetrics, + DaemonVersion: version.NetbirdVersion(), + CliVersion: req.CliVersion, }, debug.BundleConfig{ Anonymize: req.GetAnonymize(), - ClientStatus: req.GetStatus(), IncludeSystemInfo: req.GetSystemInfo(), LogFileCount: req.GetLogFileCount(), }, @@ -55,7 +90,7 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) ( if req.GetUploadURL() == "" { return &proto.DebugBundleResponse{Path: path}, nil } - key, err := uploadDebugBundle(context.Background(), req.GetUploadURL(), s.config.ManagementURL.String(), path) + 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 @@ -66,92 +101,6 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) ( return &proto.DebugBundleResponse{Path: path, UploadedKey: key}, nil } -func uploadDebugBundle(ctx context.Context, url, managementURL, filePath string) (key string, err error) { - response, err := getUploadURL(ctx, url, managementURL) - if err != nil { - return "", err - } - - err = upload(ctx, filePath, response) - if err != nil { - return "", err - } - return response.Key, nil -} - -func upload(ctx context.Context, filePath string, response *types.GetURLResponse) error { - fileData, err := os.Open(filePath) - if err != nil { - return fmt.Errorf("open file: %w", err) - } - - defer fileData.Close() - - stat, err := fileData.Stat() - if err != nil { - return fmt.Errorf("stat file: %w", err) - } - - if stat.Size() > maxBundleUploadSize { - return fmt.Errorf("file size exceeds maximum limit of %d bytes", maxBundleUploadSize) - } - - req, err := http.NewRequestWithContext(ctx, "PUT", response.URL, fileData) - if err != nil { - return fmt.Errorf("create PUT request: %w", err) - } - - req.ContentLength = stat.Size() - req.Header.Set("Content-Type", "application/octet-stream") - - putResp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("upload failed: %v", err) - } - defer putResp.Body.Close() - - if putResp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(putResp.Body) - return fmt.Errorf("upload status %d: %s", putResp.StatusCode, string(body)) - } - 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) - 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) - if err != nil { - return nil, fmt.Errorf("get presigned URL: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("get presigned URL status %d: %s", resp.StatusCode, string(body)) - } - - urlBytes, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read response body: %w", err) - } - var response types.GetURLResponse - if err := json.Unmarshal(urlBytes, &response); err != nil { - return nil, fmt.Errorf("unmarshal response: %w", err) - } - return &response, nil -} - -func getURLHash(url string) string { - return fmt.Sprintf("%x", sha256.Sum256([]byte(url))) -} - // GetLogLevel gets the current logging level for the server. func (s *Server) GetLogLevel(_ context.Context, _ *proto.GetLogLevelRequest) (*proto.GetLogLevelResponse, error) { s.mutex.Lock() @@ -173,26 +122,32 @@ func (s *Server) SetLogLevel(_ context.Context, req *proto.SetLogLevelRequest) ( log.SetLevel(level) - if s.connectClient == nil { - return nil, fmt.Errorf("connect client not initialized") + if s.connectClient != nil { + s.connectClient.SetLogLevel(level) } - engine := s.connectClient.Engine() - if engine == nil { - return nil, fmt.Errorf("engine not initialized") - } - - fwManager := engine.GetFirewallManager() - if fwManager == nil { - return nil, fmt.Errorf("firewall manager not initialized") - } - - fwManager.SetLogLevel(level) 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). +func (s *Server) RegisterUILog(_ context.Context, req *proto.RegisterUILogRequest) (*proto.RegisterUILogResponse, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.uiLogPath = req.GetPath() + 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() @@ -215,3 +170,43 @@ func (s *Server) getLatestSyncResponse() (*mgmProto.SyncResponse, error) { return cClient.GetLatestSyncResponse() } + +// StartCPUProfile starts CPU profiling in the daemon. +func (s *Server) StartCPUProfile(_ context.Context, _ *proto.StartCPUProfileRequest) (*proto.StartCPUProfileResponse, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + if s.cpuProfiling { + return nil, fmt.Errorf("CPU profiling already in progress") + } + + s.cpuProfileBuf = &bytes.Buffer{} + s.cpuProfiling = true + if err := pprof.StartCPUProfile(s.cpuProfileBuf); err != nil { + s.cpuProfileBuf = nil + s.cpuProfiling = false + return nil, fmt.Errorf("start CPU profile: %w", err) + } + + log.Info("CPU profiling started") + return &proto.StartCPUProfileResponse{}, nil +} + +// StopCPUProfile stops CPU profiling in the daemon. +func (s *Server) StopCPUProfile(_ context.Context, _ *proto.StopCPUProfileRequest) (*proto.StopCPUProfileResponse, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + if !s.cpuProfiling { + return nil, fmt.Errorf("CPU profiling not in progress") + } + + pprof.StopCPUProfile() + s.cpuProfiling = false + + if s.cpuProfileBuf != nil { + log.Infof("CPU profiling stopped, captured %d bytes", s.cpuProfileBuf.Len()) + } + + return &proto.StopCPUProfileResponse{}, nil +} diff --git a/client/server/event.go b/client/server/event.go index 9a4e0fbf5..753a051e7 100644 --- a/client/server/event.go +++ b/client/server/event.go @@ -1,9 +1,9 @@ package server import ( - "context" - + "github.com/google/uuid" log "github.com/sirupsen/logrus" + "google.golang.org/protobuf/types/known/timestamppb" "github.com/netbirdio/netbird/client/proto" ) @@ -16,6 +16,16 @@ 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 { @@ -30,7 +40,23 @@ func (s *Server) SubscribeEvents(req *proto.SubscribeRequest, stream proto.Daemo } } -func (s *Server) GetEvents(context.Context, *proto.GetEventsRequest) (*proto.GetEventsResponse, error) { - events := s.statusRecorder.GetEventHistory() - return &proto.GetEventsResponse{Events: events}, nil +// 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 new file mode 100644 index 000000000..21e170517 --- /dev/null +++ b/client/server/jwt_cache.go @@ -0,0 +1,79 @@ +package server + +import ( + "sync" + "time" + + "github.com/awnumar/memguard" + log "github.com/sirupsen/logrus" +) + +type jwtCache struct { + mu sync.RWMutex + enclave *memguard.Enclave + expiresAt time.Time + timer *time.Timer + maxTokenSize int +} + +func newJWTCache() *jwtCache { + return &jwtCache{ + maxTokenSize: 8192, + } +} + +func (c *jwtCache) store(token string, maxAge time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + + c.cleanup() + + if c.timer != nil { + c.timer.Stop() + } + + tokenBytes := []byte(token) + c.enclave = memguard.NewEnclave(tokenBytes) + + c.expiresAt = time.Now().Add(maxAge) + + var timer *time.Timer + timer = time.AfterFunc(maxAge, func() { + c.mu.Lock() + defer c.mu.Unlock() + if c.timer != timer { + return + } + c.cleanup() + c.timer = nil + log.Debugf("JWT token cache expired after %v, securely wiped from memory", maxAge) + }) + c.timer = timer +} + +func (c *jwtCache) get() (string, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.enclave == nil || time.Now().After(c.expiresAt) { + return "", false + } + + buffer, err := c.enclave.Open() + if err != nil { + log.Debugf("Failed to open JWT token enclave: %v", err) + return "", false + } + defer buffer.Destroy() + + token := string(buffer.Bytes()) + return token, true +} + +// cleanup destroys the secure enclave, must be called with lock held +func (c *jwtCache) cleanup() { + if c.enclave != nil { + c.enclave = nil + } + c.expiresAt = time.Time{} +} 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/mdm.go b/client/server/mdm.go new file mode 100644 index 000000000..9836c6bea --- /dev/null +++ b/client/server/mdm.go @@ -0,0 +1,451 @@ +package server + +import ( + "context" + "fmt" + "net/url" + "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" +) + +// preSharedKeyRedactedSentinel is the value GetConfig returns in place +// of an actual PSK, so a UI that round-trips the field back to the +// daemon (via SetConfig / Login) can be distinguished from a deliberate +// override. Any incoming PSK that equals this sentinel is treated as +// a no-op echo, never as a conflict with the policy. +const preSharedKeyRedactedSentinel = "**********" + +// loadMDMPolicy is the indirection used by server handlers to read the +// active MDM policy. Tests override this to inject a fake policy. +var loadMDMPolicy = mdm.LoadPolicy + +// conflictCheck is a value-aware comparison between a single field in +// the incoming request and the corresponding MDM-enforced value. It +// runs only when the field was actually set in the request (presence +// already filtered upstream); ok=true reports the policy value, ok=false +// means the policy is silent on the key — both are treated as conflicts +// to be safe (an MDM key declared as managed must hold a value). +type conflictCheck struct { + key string + check func(*mdm.Policy) (match bool) +} + +// onMDMPolicyChange is invoked by the MDM reload ticker every time the +// OS-native managed-config store reports a diff vs the last observation. +// +// 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 +} + +// conflictBool builds a conflictCheck for a boolean MDM key. If p is nil +// the field is treated as matching (no override requested); otherwise the +// check returns true only when the policy contains the key and its +// boolean value equals *p. +func conflictBool(key string, p *bool) conflictCheck { + return conflictCheck{ + key: key, + check: func(pol *mdm.Policy) bool { + if p == nil { + return true // absent → match by definition + } + want, ok := pol.GetBool(key) + return ok && want == *p + }, + } +} + +func canonicalURL(s string) string { + u, err := url.ParseRequestURI(s) + if err != nil { + return s + } + if u.Port() == "" { + switch u.Scheme { + case "https": + u.Host += ":443" + case "http": + u.Host += ":80" + } + } + return u.String() +} + +// conflictURL is conflictString for URL-typed keys: both sides are +// normalized via canonicalURL before comparison. +func conflictURL(key, got string) conflictCheck { + return conflictCheck{ + key: key, + check: func(pol *mdm.Policy) bool { + if got == "" { + return true + } + want, ok := pol.GetString(key) + return ok && canonicalURL(want) == canonicalURL(got) + }, + } +} + +// conflictString builds a conflictCheck for a string MDM key. An empty +// `got` is treated as "field not set" (no override requested); otherwise +// the check returns true only when the policy contains the key and its +// value equals got. +func conflictString(key, got string) conflictCheck { + return conflictCheck{ + key: key, + check: func(pol *mdm.Policy) bool { + if got == "" { + return true + } + want, ok := pol.GetString(key) + return ok && want == got + }, + } +} + +// conflictInt64 builds a conflictCheck for an integer MDM key. If p is +// nil the field is treated as matching; otherwise the check returns +// true only when the policy contains the key and its int value equals *p. +func conflictInt64(key string, p *int64) conflictCheck { + return conflictCheck{ + key: key, + check: func(pol *mdm.Policy) bool { + if p == nil { + return true + } + want, ok := pol.GetInt(key) + return ok && want == *p + }, + } +} + +// resolveConflicts walks the per-field checks against the active MDM +// policy and returns the names of keys whose requested value diverges +// from the policy-enforced value. Keys not present in the policy are +// skipped silently (the gate fires only for keys the admin has +// actually pushed). Returns nil for an empty policy. +func resolveConflicts(policy *mdm.Policy, checks []conflictCheck) []string { + if policy.IsEmpty() { + return nil + } + var conflicts []string + for _, c := range checks { + if !policy.HasKey(c.key) { + continue + } + if !c.check(policy) { + conflicts = append(conflicts, c.key) + } + } + return conflicts +} + +// mdmManagedFieldConflicts returns the names of MDM-managed keys whose +// requested value in the SetConfigRequest differs from the MDM-enforced +// value. A field set to the same value the policy already enforces is +// 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 + } + + // PSK round-trip echo: collapse the sentinel to empty so the + // shared check treats it as "field not set". + pskGot := "" + if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != preSharedKeyRedactedSentinel { + pskGot = *msg.OptionalPreSharedKey + } + + return resolveConflicts(policy, []conflictCheck{ + conflictURL(mdm.KeyManagementURL, msg.ManagementUrl), + conflictString(mdm.KeyPreSharedKey, pskGot), + conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), + conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), + conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), + conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), + conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), + conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), + conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), + conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), + }) +} + +// 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.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 +} + +// 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.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 +} + +// 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 + } + + // Collapse the two PSK fields + the redaction sentinel down to a + // single "got" string the shared check can compare against the + // policy: OptionalPreSharedKey wins if set; PreSharedKey (deprecated) + // is the fallback; sentinel echo is treated as "field not set". + pskGot := "" + if msg.OptionalPreSharedKey != nil { + pskGot = *msg.OptionalPreSharedKey + } else if msg.PreSharedKey != "" { //nolint:staticcheck // SA1019: legacy proto field still accepted by Login + pskGot = msg.PreSharedKey //nolint:staticcheck // SA1019 + } + if pskGot == preSharedKeyRedactedSentinel { + pskGot = "" + } + + return resolveConflicts(policy, []conflictCheck{ + conflictURL(mdm.KeyManagementURL, msg.ManagementUrl), + conflictString(mdm.KeyPreSharedKey, pskGot), + conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), + conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), + conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), + conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), + conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), + conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), + conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), + conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), + }) +} + +// 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 18b16795d..c38715256 100644 --- a/client/server/network.go +++ b/client/server/network.go @@ -9,17 +9,20 @@ import ( "strings" "golang.org/x/exp/maps" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" "github.com/netbirdio/netbird/client/proto" - "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/domain" ) 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. @@ -27,6 +30,10 @@ func (s *Server) ListNetworks(context.Context, *proto.ListNetworksRequest) (*pro s.mutex.Lock() defer s.mutex.Unlock() + if s.checkNetworksDisabled() { + return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled) + } + if s.connectClient == nil { return nil, fmt.Errorf("not connected") } @@ -44,18 +51,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 { @@ -76,9 +97,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, @@ -118,6 +143,10 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ s.mutex.Lock() defer s.mutex.Unlock() + if s.checkNetworksDisabled() { + return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled) + } + if s.connectClient == nil { return nil, fmt.Errorf("not connected") } @@ -137,10 +166,23 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ routeSelector.SelectAllRoutes() } else { routes := toNetIDs(req.GetNetworkIDs()) - netIdRoutes := maps.Keys(routeManager.GetClientRoutesWithNetID()) + routesMap := routeManager.GetClientRoutesWithNetID() + routes = route.ExpandV6ExitPairs(routes, routesMap) + netIdRoutes := maps.Keys(routesMap) if err := routeSelector.SelectRoutes(routes, req.GetAppend(), netIdRoutes); err != nil { return nil, fmt.Errorf("select routes: %w", err) } + + // Exit nodes are mutually exclusive: if this selection activates an + // exit node, deselect every other available exit node so two can't be + // selected at once. Non-exit route selections are left untouched. + if requestActivatesExitNode(routes, routesMap) { + if others := otherExitNodeIDs(routesMap, routes); len(others) > 0 { + if err := routeSelector.DeselectRoutes(others, netIdRoutes); err != nil { + return nil, fmt.Errorf("deselect sibling exit nodes: %w", err) + } + } + } } routeManager.TriggerSelection(routeManager.GetClientRoutes()) @@ -164,6 +206,10 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe s.mutex.Lock() defer s.mutex.Unlock() + if s.checkNetworksDisabled() { + return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled) + } + if s.connectClient == nil { return nil, fmt.Errorf("not connected") } @@ -183,7 +229,9 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe routeSelector.DeselectAllRoutes() } else { routes := toNetIDs(req.GetNetworkIDs()) - netIdRoutes := maps.Keys(routeManager.GetClientRoutesWithNetID()) + routesMap := routeManager.GetClientRoutesWithNetID() + routes = route.ExpandV6ExitPairs(routes, routesMap) + netIdRoutes := maps.Keys(routesMap) if err := routeSelector.DeselectRoutes(routes, netIdRoutes); err != nil { return nil, fmt.Errorf("deselect routes: %w", err) } @@ -212,3 +260,38 @@ func toNetIDs(routes []string) []route.NetID { } return netIDs } + +func isExitNodeRoutes(routes []*route.Route) bool { + return len(routes) > 0 && (route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network)) +} + +// requestActivatesExitNode reports whether any requested NetID maps to an exit +// node (default route) in the current route table. +func requestActivatesExitNode(requested []route.NetID, routesMap map[route.NetID][]*route.Route) bool { + for _, id := range requested { + if isExitNodeRoutes(routesMap[id]) { + return true + } + } + return false +} + +// otherExitNodeIDs returns every available exit-node NetID that is not in the +// requested set — the siblings to deselect so a single exit node stays active. +func otherExitNodeIDs(routesMap map[route.NetID][]*route.Route, requested []route.NetID) []route.NetID { + keep := make(map[route.NetID]struct{}, len(requested)) + for _, id := range requested { + keep[id] = struct{}{} + } + var others []route.NetID + for id, routes := range routesMap { + if !isExitNodeRoutes(routes) { + continue + } + if _, ok := keep[id]; ok { + continue + } + others = append(others, id) + } + return others +} diff --git a/client/server/network_exitnode_test.go b/client/server/network_exitnode_test.go new file mode 100644 index 000000000..1c0ba0ecb --- /dev/null +++ b/client/server/network_exitnode_test.go @@ -0,0 +1,26 @@ +package server + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/route" +) + +func TestExitNodeSelectionHelpers(t *testing.T) { + routesMap := map[route.NetID][]*route.Route{ + "exitA": {{Network: netip.MustParsePrefix("0.0.0.0/0")}}, + "exitB": {{Network: netip.MustParsePrefix("::/0")}}, + "lan": {{Network: netip.MustParsePrefix("192.168.0.0/16")}}, + } + + assert.True(t, requestActivatesExitNode([]route.NetID{"exitA"}, routesMap), "v4 default route is an exit node") + assert.True(t, requestActivatesExitNode([]route.NetID{"exitB"}, routesMap), "v6 default route is an exit node") + assert.False(t, requestActivatesExitNode([]route.NetID{"lan"}, routesMap), "lan route is not an exit node") + assert.False(t, requestActivatesExitNode([]route.NetID{"missing"}, routesMap), "unknown id is not an exit node") + + others := otherExitNodeIDs(routesMap, []route.NetID{"exitB"}) + assert.ElementsMatch(t, []route.NetID{"exitA"}, others, "only the other exit node is a sibling; the lan route is ignored") +} diff --git a/client/server/panic_windows.go b/client/server/panic_windows.go index f441ec9ea..8592f12ad 100644 --- a/client/server/panic_windows.go +++ b/client/server/panic_windows.go @@ -1,5 +1,4 @@ //go:build windows -// +build windows package server 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 6699cdadc..8047006fe 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -1,6 +1,7 @@ package server import ( + "bytes" "context" "errors" "fmt" @@ -13,25 +14,28 @@ import ( "time" "github.com/cenkalti/backoff/v4" - "golang.org/x/exp/maps" - "golang.zx2c4.com/wireguard/wgctrl/wgtypes" - "google.golang.org/protobuf/types/known/durationpb" - log "github.com/sirupsen/logrus" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" "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/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" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/internal/statemanager" + "github.com/netbirdio/netbird/client/internal/updater" "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/util/capture" "github.com/netbirdio/netbird/version" ) @@ -46,9 +50,13 @@ const ( defaultMaxRetryTime = 14 * 24 * time.Hour defaultRetryMultiplier = 1.7 + // JWT token cache TTL for the client daemon (disabled by default) + defaultJWTCacheTTL = 0 + errRestoreResidualState = "failed to restore residual state: %v" errProfilesDisabled = "profiles are disabled, you cannot use this feature without profiles enabled" errUpdateSettingsDisabled = "update settings are disabled, you cannot use this feature without update settings enabled" + errNetworksDisabled = "network selection is disabled by the administrator" ) var ErrServiceNotUp = errors.New("service is not up") @@ -60,27 +68,64 @@ 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). + 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 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{} + clientGiveUpChan chan struct{} // closed when connectWithRetryRuns goroutine exits connectClient *internal.ConnectClient statusRecorder *peer.Status sessionWatcher *internal.SessionWatcher - lastProbe time.Time + probeThrottle *probeThrottle persistSyncResponse bool isSessionActive atomic.Bool + cpuProfileBuf *bytes.Buffer + cpuProfiling bool + profileManager *profilemanager.ServiceManager profilesDisabled bool updateSettingsDisabled 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 + + updateManager *updater.Manager + + jwtCache *jwtCache } type oauthAuthFlow struct { @@ -91,8 +136,8 @@ type oauthAuthFlow struct { } // New server instance constructor. -func New(ctx context.Context, logFile string, configFile string, profilesDisabled bool, updateSettingsDisabled bool) *Server { - return &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, persistSyncResponse: true, @@ -100,7 +145,17 @@ 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() + + return s } func (s *Server) Start() error { @@ -112,15 +167,41 @@ 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) } + if s.updateManager == nil { + stateMgr := statemanager.New(s.profileManager.GetStatePath()) + s.updateManager = updater.NewManager(s.statusRecorder, stateMgr) + 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.mdmTicker == nil { + s.mdmTicker = mdm.NewTicker(mdm.DefaultReloadInterval) + 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. @@ -136,10 +217,10 @@ func (s *Server) Start() error { ctx, cancel := context.WithCancel(s.rootCtx) s.actCancel = cancel - // set the default config if not exists - if err := s.setDefaultConfigIfNotExists(ctx); err != nil { - log.Errorf("failed to set default config: %v", err) - return fmt.Errorf("failed to set default config: %w", err) + // copy old default config + _, err = s.profileManager.CopyDefaultProfileIfNotExists() + if err != nil && !errors.Is(err, profilemanager.ErrorOldDefaultConfigNotFound) { + return err } activeProf, err := s.profileManager.GetActiveProfileState() @@ -147,29 +228,16 @@ func (s *Server) Start() error { return fmt.Errorf("failed to get active profile state: %w", err) } - config, err := s.getConfig(activeProf) + config, existingConfig, err := s.getConfig(activeProf) if err != nil { log.Errorf("failed to get active profile config: %v", err) - if err := s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: "default", - Username: "", - }); err != nil { - log.Errorf("failed to set active profile state: %v", err) - return fmt.Errorf("failed to set active profile state: %w", err) - } - - config, err = profilemanager.GetConfig(s.profileManager.DefaultProfilePath()) - if err != nil { - log.Errorf("failed to get default profile config: %v", err) - return fmt.Errorf("failed to get default profile config: %w", err) - } + return err } s.config = config s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String()) s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive) - s.statusRecorder.UpdateLazyConnection(config.LazyConnectionEnabled) if s.sessionWatcher == nil { s.sessionWatcher = internal.NewSessionWatcher(s.rootCtx, s.statusRecorder) @@ -177,6 +245,13 @@ func (s *Server) Start() error { } if config.DisableAutoConnect { + state.Set(internal.StatusIdle) + return nil + } + + if !existingConfig { + log.Warnf("not trying to connect when configuration was just created") + state.Set(internal.StatusNeedsLogin) return nil } @@ -184,41 +259,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) - return nil -} - -func (s *Server) setDefaultConfigIfNotExists(ctx context.Context) error { - ok, err := s.profileManager.CopyDefaultProfileIfNotExists() - if err != nil { - if err := s.profileManager.CreateDefaultProfile(); err != nil { - log.Errorf("failed to create default profile: %v", err) - return fmt.Errorf("failed to create default profile: %w", err) - } - - if err := s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{ - Name: "default", - Username: "", - }); err != nil { - log.Errorf("failed to set active profile state: %v", err) - return fmt.Errorf("failed to set active profile state: %w", err) - } - } - if ok { - state := internal.CtxGetState(ctx) - state.Set(internal.StatusNeedsLogin) - } - + 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 { @@ -253,6 +318,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 } @@ -264,18 +338,42 @@ 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 func (s *Server) loginAttempt(ctx context.Context, setupKey, jwtToken string) (internal.StatusType, error) { - var status internal.StatusType - err := internal.Login(ctx, s.config, setupKey, jwtToken) + authClient, err := auth.NewAuth(ctx, s.config.PrivateKey, s.config.ManagementURL, s.config) if err != nil { - if s, ok := gstatus.FromError(err); ok && (s.Code() == codes.InvalidArgument || s.Code() == codes.PermissionDenied) { + log.Errorf("failed to create auth client: %v", err) + return internal.StatusLoginFailed, err + } + defer authClient.Close() + + var status internal.StatusType + err, isAuthError := authClient.Login(ctx, setupKey, jwtToken) + if err != nil { + if isAuthError { log.Warnf("failed login: %v", err) status = internal.StatusNeedsLogin } else { @@ -292,54 +390,85 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques 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 := loadMDMPolicy() + if err := rejectMDMManagedFieldConflicts(mdmManagedFieldConflicts(msg, policy)); err != nil { + return nil, err } - profPath, err := profState.FilePath() + config, err := s.setConfigInputFromRequest(msg) 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 := 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 +} + +// 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 { @@ -352,7 +481,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 { @@ -371,24 +499,44 @@ 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 + config.EnableSSHRemotePortForwarding = msg.EnableSSHRemotePortForwarding + if msg.DisableSSHAuth != nil { + config.DisableSSHAuth = msg.DisableSSHAuth + } + if msg.SshJWTCacheTTL != nil { + 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) { + // 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 := loadMDMPolicy() + if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil { + return nil, err + } + } + s.mutex.Lock() if s.actCancel != nil { s.actCancel() @@ -403,7 +551,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro s.actCancel = cancel s.mutex.Unlock() - if err := restoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil { + if err := RestoreResidualState(s.rootCtx, s.profileManager.GetStatePath()); err != nil { log.Warnf(errRestoreResidualState, err) } @@ -422,30 +570,9 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro } 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) - } + if _, err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { + log.Errorf("failed to switch profile: %v", err) + return nil, err } } @@ -455,7 +582,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro 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) s.mutex.Lock() @@ -466,7 +593,12 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro s.mutex.Unlock() - config, err := s.getConfig(activeProf) + if err := persistLoginOverrides(activeProf, msg.ManagementUrl, msg.OptionalPreSharedKey); err != nil { + log.Errorf("failed to persist login overrides: %v", err) + return nil, fmt.Errorf("persist login overrides: %w", err) + } + + config, _, err := s.getConfig(activeProf) if err != nil { log.Errorf("failed to get active profile config: %v", err) return nil, fmt.Errorf("failed to get active profile config: %w", err) @@ -480,22 +612,21 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro 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, 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(context.TODO()) { + if s.oauthAuthFlow.flow != nil && s.oauthAuthFlow.flow.GetClientID(ctx) == oAuthFlow.GetClientID(ctx) { if s.oauthAuthFlow.expiresAt.After(time.Now().Add(90 * time.Second)) { log.Debugf("using previous oauth flow info") + state.Set(internal.StatusNeedsLogin) return &proto.LoginResponse{ NeedsSSOLogin: true, VerificationURI: s.oauthAuthFlow.info.VerificationURI, @@ -510,7 +641,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro } } - authInfo, err := oAuthFlow.RequestAuthInfo(context.TODO()) + authInfo, err := oAuthFlow.RequestAuthInfo(ctx) if err != nil { log.Errorf("getting a request OAuth flow failed: %v", err) return nil, err @@ -532,6 +663,11 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro }, 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.loginAttempt(ctx, msg.SetupKey, ""); err != nil { state.Set(loginStatus) return nil, err @@ -540,8 +676,43 @@ 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 +// 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 { @@ -549,6 +720,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) @@ -574,7 +760,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 @@ -589,8 +779,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin s.oauthAuthFlow.waitCancel() } - waitTimeout := time.Until(s.oauthAuthFlow.expiresAt) - waitCTX, cancel := context.WithTimeout(ctx, waitTimeout) + waitCTX, cancel := context.WithCancel(ctx) defer cancel() s.mutex.Lock() @@ -602,7 +791,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 } @@ -616,6 +828,7 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin return nil, err } + log.Infof("SSO login flow finished, returning success to caller") return &proto.WaitSSOLoginResponse{ Email: tokenInfo.Email, }, nil @@ -623,8 +836,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 { @@ -638,9 +858,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR return s.waitForUp(callerCtx) } - defer s.mutex.Unlock() - - if err := restoreResidualState(callerCtx, s.profileManager.GetStatePath()); err != nil { + if err := RestoreResidualState(callerCtx, s.profileManager.GetStatePath()); err != nil { log.Warnf(errRestoreResidualState, err) } @@ -651,10 +869,28 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR // not in the progress or already successfully established connection. status, err := state.Status() if err != nil { + s.mutex.Unlock() return nil, err } + // 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) } @@ -671,32 +907,37 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR s.actCancel = cancel if s.config == nil { + s.mutex.Unlock() return nil, fmt.Errorf("config is not defined, please call login command first") } activeProf, err := s.profileManager.GetActiveProfileState() if err != nil { + s.mutex.Unlock() log.Errorf("failed to get active profile state: %v", err) return nil, fmt.Errorf("failed to get active profile state: %w", err) } if msg != nil && msg.ProfileName != nil { - if err := s.switchProfileIfNeeded(*msg.ProfileName, msg.Username, activeProf); err != nil { + 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 } } activeProf, err = s.profileManager.GetActiveProfileState() if err != nil { + s.mutex.Unlock() log.Errorf("failed to get active profile state: %v", err) return nil, fmt.Errorf("failed to get active profile state: %w", err) } - 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) + config, _, err := s.getConfig(activeProf) if err != nil { + s.mutex.Unlock() log.Errorf("failed to get active profile config: %v", err) return nil, fmt.Errorf("failed to get active profile config: %w", err) } @@ -708,8 +949,14 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR 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) + 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) } @@ -733,34 +980,60 @@ 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) +// 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. @@ -775,9 +1048,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() @@ -785,7 +1058,7 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi log.Errorf("failed to get active profile state: %v", err) return nil, fmt.Errorf("failed to get active profile state: %w", err) } - config, err := s.getConfig(activeProf) + config, _, err := s.getConfig(activeProf) if err != nil { log.Errorf("failed to get default profile config: %v", err) return nil, fmt.Errorf("failed to get default profile config: %w", err) @@ -793,21 +1066,59 @@ func (s *Server) SwitchProfile(callerCtx context.Context, msg *proto.SwitchProfi s.config = config - return &proto.SwitchProfileResponse{}, nil + if msg != nil && msg.ProfileName != nil { + s.publishProfileListChanged(*msg.ProfileName) + } + + return &proto.SwitchProfileResponse{Id: activeProf.ID.String()}, nil } // Down engine work in the daemon. func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownResponse, error) { s.mutex.Lock() - defer s.mutex.Unlock() + + giveUpChan := s.clientGiveUpChan if err := s.cleanupConnection(); err != nil { + s.mutex.Unlock() + 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 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, 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 } @@ -818,14 +1129,36 @@ func (s *Server) cleanupConnection() error { if s.actCancel == nil { 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() + // to skip the engine shutdown entirely. + var engine *internal.Engine + if s.connectClient != nil { + engine = s.connectClient.Engine() + } + s.actCancel() if s.connectClient == nil { return nil } - if err := s.connectClient.Stop(); err != nil { - return err + // 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 { + log.Errorf("failed to stop engine during cleanup: %v", err) + } } s.connectClient = nil @@ -848,22 +1181,27 @@ 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 + } + + if err := s.validateProfileOperation(resolved.ID, true); err != nil { + return nil, err + } + + if err := s.logoutFromProfile(ctx, resolved); err != nil { + log.Errorf("failed to logout from profile %s: %v", resolved.ID, err) return nil, gstatus.Errorf(codes.Internal, "logout: %v", err) } activeProf, _ := s.profileManager.GetActiveProfileState() - if activeProf != nil && activeProf.Name == *msg.ProfileName { + if activeProf != nil && activeProf.ID == resolved.ID { if err := s.cleanupConnection(); err != nil && !errors.Is(err, ErrServiceNotUp) { log.Errorf("failed to cleanup connection: %v", err) } @@ -881,7 +1219,7 @@ func (s *Server) handleActiveProfileLogout(ctx context.Context) (*proto.LogoutRe return nil, gstatus.Errorf(codes.FailedPrecondition, "failed to get active profile state: %v", err) } - config, err := s.getConfig(activeProf) + config, _, err := s.getConfig(activeProf) if err != nil { return nil, gstatus.Errorf(codes.FailedPrecondition, "not logged in") } @@ -894,6 +1232,7 @@ func (s *Server) handleActiveProfileLogout(ctx context.Context) (*proto.LogoutRe } if err := s.cleanupConnection(); err != nil && !errors.Is(err, ErrServiceNotUp) { + // todo review to update the status in case any type of error log.Errorf("failed to cleanup connection: %v", err) return nil, err } @@ -904,45 +1243,50 @@ func (s *Server) handleActiveProfileLogout(ctx context.Context) (*proto.LogoutRe return &proto.LogoutResponse{}, nil } -// getConfig loads the config from the active profile -func (s *Server) getConfig(activeProf *profilemanager.ActiveProfileState) (*profilemanager.Config, error) { +// 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 { - return nil, fmt.Errorf("failed to get active profile file path: %w", err) + return nil, false, fmt.Errorf("failed to get active profile file path: %w", err) } - config, err := profilemanager.GetConfig(cfgPath) + _, err = os.Stat(cfgPath) + configExisted := !os.IsNotExist(err) + + log.Infof("active profile config existed: %t, err %v", configExisted, err) + + config, err := profilemanager.ReadConfig(cfgPath) if err != nil { - return nil, fmt.Errorf("failed to get config: %w", err) + return nil, false, fmt.Errorf("failed to get config: %w", err) } - return config, nil + return config, configExisted, nil } -func (s *Server) canRemoveProfile(profileName string) error { - if profileName == profilemanager.DefaultProfileName { +func (s *Server) canRemoveProfile(id profilemanager.ID) error { + if id == profilemanager.DefaultProfileName { return fmt.Errorf("remove profile with reserved name: %s", profilemanager.DefaultProfileName) } activeProf, err := s.profileManager.GetActiveProfileState() - if err == nil && activeProf.Name == profileName { - return fmt.Errorf("remove active profile: %s", profileName) + if err == nil && activeProf.ID == id { + return fmt.Errorf("remove active profile: %s", id) } return nil } -func (s *Server) validateProfileOperation(profileName string, allowActiveProfile bool) error { +func (s *Server) validateProfileOperation(id profilemanager.ID, allowActiveProfile bool) error { if s.checkProfilesDisabled() { return gstatus.Errorf(codes.Unavailable, errProfilesDisabled) } - if profileName == "" { + if id == "" { return gstatus.Errorf(codes.InvalidArgument, "profile name must be provided") } if !allowActiveProfile { - if err := s.canRemoveProfile(profileName); err != nil { + if err := s.canRemoveProfile(id); err != nil { return gstatus.Errorf(codes.InvalidArgument, "%v", err) } } @@ -950,25 +1294,20 @@ func (s *Server) validateProfileOperation(profileName string, allowActiveProfile 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 { +func (s *Server) logoutFromProfile(ctx context.Context, profile *profilemanager.Profile) error { activeProf, err := s.profileManager.GetActiveProfileState() - if err == nil && activeProf.Name == profileName && s.connectClient != nil { + if err == nil && activeProf.ID == profile.ID && 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) } return s.sendLogoutRequestWithConfig(ctx, config) @@ -995,7 +1334,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 @@ -1004,10 +1355,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 { @@ -1044,9 +1399,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() { @@ -1057,21 +1427,452 @@ 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 := toProtoFullStatus(fullStatus) + pbFullStatus := fullStatus.ToProto() pbFullStatus.Events = s.statusRecorder.GetEventHistory() + pbFullStatus.SshServerState = s.getSSHServerState() + pbFullStatus.NetworksRevision = s.statusRecorder.GetNetworksRevision() statusResponse.FullStatus = pbFullStatus } return &statusResponse, nil } -func (s *Server) runProbes(waitForProbeResult bool) { +// getSSHServerState retrieves the current SSH server state including enabled status and active sessions +func (s *Server) getSSHServerState() *proto.SSHServerState { + s.mutex.Lock() + connectClient := s.connectClient + s.mutex.Unlock() + + if connectClient == nil { + return nil + } + + engine := connectClient.Engine() + if engine == nil { + return nil + } + + enabled, sessions := engine.GetSSHServerStatus() + sshServerState := &proto.SSHServerState{ + Enabled: enabled, + } + + for _, session := range sessions { + sshServerState.Sessions = append(sshServerState.Sessions, &proto.SSHSessionInfo{ + Username: session.Username, + RemoteAddress: session.RemoteAddress, + Command: session.Command, + JwtUsername: session.JWTUsername, + PortForwards: session.PortForwards, + }) + } + + return sshServerState +} + +// GetPeerSSHHostKey retrieves SSH host key for a specific peer +func (s *Server) GetPeerSSHHostKey( + ctx context.Context, + req *proto.GetPeerSSHHostKeyRequest, +) (*proto.GetPeerSSHHostKeyResponse, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + + s.mutex.Lock() + connectClient := s.connectClient + statusRecorder := s.statusRecorder + s.mutex.Unlock() + + if connectClient == nil { + return nil, errors.New("client not initialized") + } + + engine := connectClient.Engine() + if engine == nil { + return nil, errors.New("engine not started") + } + + peerAddress := req.GetPeerAddress() + hostKey, found := engine.GetPeerSSHKey(peerAddress) + + response := &proto.GetPeerSSHHostKeyResponse{ + Found: found, + } + + if !found { + return response, nil + } + + response.SshHostKey = hostKey + + if statusRecorder == nil { + return response, nil + } + + fullStatus := statusRecorder.GetFullStatus() + for _, peerState := range fullStatus.Peers { + if peerState.IP == peerAddress || peerState.FQDN == peerAddress { + response.PeerIP = peerState.IP + response.PeerFQDN = peerState.FQDN + break + } + } + + return response, nil +} + +// getJWTCacheTTL returns the JWT cache TTL from config or default (disabled) +func (s *Server) getJWTCacheTTL() time.Duration { + s.mutex.Lock() + config := s.config + s.mutex.Unlock() + + if config == nil || config.SSHJWTCacheTTL == nil { + return defaultJWTCacheTTL + } + + seconds := *config.SSHJWTCacheTTL + if seconds == 0 { + log.Debug("SSH JWT cache disabled (configured to 0)") + return 0 + } + + ttl := time.Duration(seconds) * time.Second + log.Debugf("SSH JWT cache TTL set to %v from config", ttl) + return ttl +} + +// RequestJWTAuth initiates JWT authentication flow for SSH +func (s *Server) RequestJWTAuth( + ctx context.Context, + msg *proto.RequestJWTAuthRequest, +) (*proto.RequestJWTAuthResponse, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + + s.mutex.Lock() + config := s.config + s.mutex.Unlock() + + if config == nil { + return nil, gstatus.Errorf(codes.FailedPrecondition, "client is not configured") + } + + jwtCacheTTL := s.getJWTCacheTTL() + if jwtCacheTTL > 0 { + if cachedToken, found := s.jwtCache.get(); found { + log.Debugf("JWT token found in cache, returning cached token for SSH authentication") + + return &proto.RequestJWTAuthResponse{ + CachedToken: cachedToken, + MaxTokenAge: int64(jwtCacheTTL.Seconds()), + }, nil + } + } + + hint := "" + if msg.Hint != nil { + hint = *msg.Hint + } + + if hint == "" { + hint = profilemanager.GetLoginHint() + } + + isDesktop := isUnixRunningDesktop() + oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isDesktop, 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.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() + + return &proto.RequestJWTAuthResponse{ + VerificationURI: authInfo.VerificationURI, + VerificationURIComplete: authInfo.VerificationURIComplete, + UserCode: authInfo.UserCode, + DeviceCode: authInfo.DeviceCode, + ExpiresIn: int64(authInfo.ExpiresIn), + MaxTokenAge: int64(jwtCacheTTL.Seconds()), + }, nil +} + +// WaitJWTToken waits for JWT authentication completion +func (s *Server) WaitJWTToken( + ctx context.Context, + req *proto.WaitJWTTokenRequest, +) (*proto.WaitJWTTokenResponse, error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + + s.mutex.Lock() + oAuthFlow := s.oauthAuthFlow.flow + authInfo := s.oauthAuthFlow.info + s.mutex.Unlock() + + if oAuthFlow == nil || authInfo.DeviceCode != req.DeviceCode { + return nil, gstatus.Errorf(codes.InvalidArgument, "invalid device code or no active auth flow") + } + + tokenInfo, err := oAuthFlow.WaitToken(ctx, authInfo) + if err != nil { + return nil, gstatus.Errorf(codes.Internal, "failed to get token: %v", err) + } + + 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 { + log.Debug("JWT caching disabled, not storing token") + } + + s.mutex.Lock() + s.oauthAuthFlow = oauthAuthFlow{} + s.mutex.Unlock() + return &proto.WaitJWTTokenResponse{ + Token: tokenInfo.GetTokenToUse(), + TokenType: tokenInfo.TokenType, + ExpiresIn: int64(tokenInfo.ExpiresIn), + }, 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") + } + + hint := "" + if msg.Hint != nil { + hint = *msg.Hint + } + if hint == "" { + hint = profilemanager.GetLoginHint() + } + + isDesktop := isUnixRunningDesktop() + oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isDesktop, 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() + if !s.clientRunning { + s.mutex.Unlock() + return gstatus.Errorf(codes.FailedPrecondition, "client is not running, run 'netbird up' first") + } + connectClient := s.connectClient + s.mutex.Unlock() + + if connectClient == nil { + return gstatus.Errorf(codes.FailedPrecondition, "client not initialized") + } + + engine := connectClient.Engine() + if engine == nil { + return gstatus.Errorf(codes.FailedPrecondition, "engine not initialized") + } + + if engine.IsBlockInbound() { + return gstatus.Errorf(codes.FailedPrecondition, "expose requires inbound connections but 'block inbound' is enabled, disable it first") + } + + mgr := engine.GetExposeManager() + if mgr == nil { + return gstatus.Errorf(codes.Internal, "expose manager not available") + } + + ctx := srv.Context() + + exposeCtx, exposeCancel := context.WithTimeout(ctx, 30*time.Second) + defer exposeCancel() + + mgmReq := expose.NewRequest(req) + result, err := mgr.Expose(exposeCtx, *mgmReq) + if err != nil { + return err + } + + if err := srv.Send(&proto.ExposeServiceEvent{ + Event: &proto.ExposeServiceEvent_Ready{ + Ready: &proto.ExposeServiceReady{ + ServiceName: result.ServiceName, + ServiceUrl: result.ServiceURL, + Domain: result.Domain, + PortAutoAssigned: result.PortAutoAssigned, + }, + }, + }); err != nil { + return err + } + + err = mgr.KeepAlive(ctx, result.Domain) + if err != nil { + return err + } + return nil +} + +func isUnixRunningDesktop() bool { + if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" { + return false + } + return os.Getenv("DESKTOP_SESSION") != "" || os.Getenv("XDG_CURRENT_DESKTOP") != "" +} + +func (s *Server) runProbes(ctx context.Context, waitForProbeResult bool) { if s.connectClient == nil { return } @@ -1081,11 +1882,7 @@ func (s *Server) runProbes(waitForProbeResult bool) { return } - if time.Since(s.lastProbe) > probeThreshold { - if engine.RunHealthProbes(waitForProbeResult) { - s.lastProbe = time.Now() - } - } + s.probeThrottle.Run(ctx, engine, s.statusRecorder, waitForProbeResult) } // GetConfig of the daemon. @@ -1097,15 +1894,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) @@ -1134,27 +1930,65 @@ 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 + if cfg.EnableSSHRoot != nil { + enableSSHRoot = *cfg.EnableSSHRoot + } + + enableSSHSFTP := false + if cfg.EnableSSHSFTP != nil { + enableSSHSFTP = *cfg.EnableSSHSFTP + } + + enableSSHLocalPortForwarding := false + if cfg.EnableSSHLocalPortForwarding != nil { + enableSSHLocalPortForwarding = *cfg.EnableSSHLocalPortForwarding + } + + enableSSHRemotePortForwarding := false + if cfg.EnableSSHRemotePortForwarding != nil { + enableSSHRemotePortForwarding = *cfg.EnableSSHRemotePortForwarding + } + + disableSSHAuth := false + if cfg.DisableSSHAuth != nil { + disableSSHAuth = *cfg.DisableSSHAuth + } + + sshJWTCacheTTL := int32(0) + if cfg.SSHJWTCacheTTL != nil { + sshJWTCacheTTL = int32(*cfg.SSHJWTCacheTTL) + } + return &proto.GetConfigResponse{ - ManagementUrl: managementURL.String(), - PreSharedKey: preSharedKey, - AdminURL: adminURL.String(), - InterfaceName: cfg.WgIface, - WireguardPort: int64(cfg.WgPort), - Mtu: int64(cfg.MTU), - DisableAutoConnect: cfg.DisableAutoConnect, - ServerSSHAllowed: *cfg.ServerSSHAllowed, - RosenpassEnabled: cfg.RosenpassEnabled, - RosenpassPermissive: cfg.RosenpassPermissive, - LazyConnectionEnabled: cfg.LazyConnectionEnabled, - BlockInbound: cfg.BlockInbound, - DisableNotifications: disableNotifications, - NetworkMonitor: networkMonitor, - DisableDns: disableDNS, - DisableClientRoutes: disableClientRoutes, - DisableServerRoutes: disableServerRoutes, - BlockLanAccess: blockLANAccess, + ManagementUrl: managementURL.String(), + PreSharedKey: preSharedKey, + AdminURL: adminURL.String(), + InterfaceName: cfg.WgIface, + WireguardPort: int64(cfg.WgPort), + Mtu: int64(cfg.MTU), + DisableAutoConnect: cfg.DisableAutoConnect, + ServerSSHAllowed: *cfg.ServerSSHAllowed, + RosenpassEnabled: cfg.RosenpassEnabled, + RosenpassPermissive: cfg.RosenpassPermissive, + BlockInbound: cfg.BlockInbound, + DisableNotifications: disableNotifications, + NetworkMonitor: networkMonitor, + DisableDns: disableDNS, + DisableClientRoutes: disableClientRoutes, + DisableServerRoutes: disableServerRoutes, + DisableIpv6: disableIPv6, + BlockLanAccess: blockLANAccess, + EnableSSHRoot: enableSSHRoot, + EnableSSHSFTP: enableSSHSFTP, + EnableSSHLocalPortForwarding: enableSSHLocalPortForwarding, + EnableSSHRemotePortForwarding: enableSSHRemotePortForwarding, + DisableSSHAuth: disableSSHAuth, + SshJWTCacheTTL: sshJWTCacheTTL, + MDMManagedFields: cfg.Policy().ManagedKeys(), }, nil } @@ -1171,12 +2005,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. @@ -1184,20 +2049,71 @@ 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); err != nil { + log.Warnf("failed to logout from profile %s before removal: %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. @@ -1220,6 +2136,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, } @@ -1228,7 +2145,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() @@ -1239,9 +2158,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 } @@ -1253,37 +2186,98 @@ func (s *Server) GetFeatures(ctx context.Context, msg *proto.GetFeaturesRequest) features := &proto.GetFeaturesResponse{ DisableProfiles: s.checkProfilesDisabled(), DisableUpdateSettings: s.checkUpdateSettingsDisabled(), + 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") - s.connectClient = internal.NewConnectClient(ctx, config, statusRecorder) - s.connectClient.SetSyncResponsePersistence(s.persistSyncResponse) - if err := s.connectClient.Run(runningChan); err != nil { + client := internal.NewConnectClient(ctx, config, statusRecorder) + client.SetUpdateManager(s.updateManager) + client.SetSyncResponsePersistence(s.persistSyncResponse) + + s.mutex.Lock() + s.connectClient = client + s.mutex.Unlock() + + if err := client.Run(runningChan, s.logFile); err != nil { return err } return nil } +// 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 s.updateSettingsDisabled +} - return false +func (s *Server) startUpdateManagerForGUI() { + if s.updateManager == nil { + return + } + s.updateManager.Start(s.rootCtx) + s.updateManager.NotifyUI() } func (s *Server) onSessionExpire() { @@ -1336,93 +2330,6 @@ func parseEnvDuration(envVar string, defaultDuration time.Duration) time.Duratio return defaultDuration } -func toProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus { - pbFullStatus := proto.FullStatus{ - ManagementState: &proto.ManagementState{}, - SignalState: &proto.SignalState{}, - LocalPeerState: &proto.LocalPeerState{}, - Peers: []*proto.PeerState{}, - } - - pbFullStatus.ManagementState.URL = fullStatus.ManagementState.URL - pbFullStatus.ManagementState.Connected = fullStatus.ManagementState.Connected - if err := fullStatus.ManagementState.Error; err != nil { - pbFullStatus.ManagementState.Error = err.Error() - } - - pbFullStatus.SignalState.URL = fullStatus.SignalState.URL - pbFullStatus.SignalState.Connected = fullStatus.SignalState.Connected - if err := fullStatus.SignalState.Error; err != nil { - pbFullStatus.SignalState.Error = err.Error() - } - - pbFullStatus.LocalPeerState.IP = fullStatus.LocalPeerState.IP - pbFullStatus.LocalPeerState.PubKey = fullStatus.LocalPeerState.PubKey - pbFullStatus.LocalPeerState.KernelInterface = fullStatus.LocalPeerState.KernelInterface - pbFullStatus.LocalPeerState.Fqdn = fullStatus.LocalPeerState.FQDN - pbFullStatus.LocalPeerState.RosenpassPermissive = fullStatus.RosenpassState.Permissive - pbFullStatus.LocalPeerState.RosenpassEnabled = fullStatus.RosenpassState.Enabled - pbFullStatus.LocalPeerState.Networks = maps.Keys(fullStatus.LocalPeerState.Routes) - pbFullStatus.NumberOfForwardingRules = int32(fullStatus.NumOfForwardingRules) - pbFullStatus.LazyConnectionEnabled = fullStatus.LazyConnectionEnabled - - for _, peerState := range fullStatus.Peers { - pbPeerState := &proto.PeerState{ - IP: peerState.IP, - PubKey: peerState.PubKey, - ConnStatus: peerState.ConnStatus.String(), - ConnStatusUpdate: timestamppb.New(peerState.ConnStatusUpdate), - Relayed: peerState.Relayed, - LocalIceCandidateType: peerState.LocalIceCandidateType, - RemoteIceCandidateType: peerState.RemoteIceCandidateType, - LocalIceCandidateEndpoint: peerState.LocalIceCandidateEndpoint, - RemoteIceCandidateEndpoint: peerState.RemoteIceCandidateEndpoint, - RelayAddress: peerState.RelayServerAddress, - Fqdn: peerState.FQDN, - LastWireguardHandshake: timestamppb.New(peerState.LastWireguardHandshake), - BytesRx: peerState.BytesRx, - BytesTx: peerState.BytesTx, - RosenpassEnabled: peerState.RosenpassEnabled, - Networks: maps.Keys(peerState.GetRoutes()), - Latency: durationpb.New(peerState.Latency), - } - pbFullStatus.Peers = append(pbFullStatus.Peers, pbPeerState) - } - - for _, relayState := range fullStatus.Relays { - pbRelayState := &proto.RelayState{ - URI: relayState.URI, - Available: relayState.Err == nil, - } - if err := relayState.Err; err != nil { - pbRelayState.Error = err.Error() - } - pbFullStatus.Relays = append(pbFullStatus.Relays, pbRelayState) - } - - for _, dnsState := range fullStatus.NSGroupStates { - var err string - if dnsState.Error != nil { - err = dnsState.Error.Error() - } - - var servers []string - for _, server := range dnsState.Servers { - servers = append(servers, server.String()) - } - - pbDnsState := &proto.NSGroupState{ - Servers: servers, - Domains: dnsState.Domains, - Enabled: dnsState.Enabled, - Error: err, - } - pbFullStatus.DnsServers = append(pbFullStatus.DnsServers, pbDnsState) - } - - return &pbFullStatus -} - // sendTerminalNotification sends a terminal notification message // to inform the user that the NetBird connection session has expired. func sendTerminalNotification() error { @@ -1450,3 +2357,54 @@ 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. +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 new file mode 100644 index 000000000..0c6e03a4a --- /dev/null +++ b/client/server/server_connect_test.go @@ -0,0 +1,191 @@ +package server + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal" + "github.com/netbirdio/netbird/client/internal/peer" + "github.com/netbirdio/netbird/client/proto" +) + +func newTestServer() *Server { + return &Server{ + rootCtx: context.Background(), + statusRecorder: peer.NewRecorder(""), + } +} + +func newDummyConnectClient(ctx context.Context) *internal.ConnectClient { + return internal.NewConnectClient(ctx, nil, nil) +} + +// TestConnectSetsClientWithMutex validates that connect() sets s.connectClient +// under mutex protection so concurrent readers see a consistent value. +func TestConnectSetsClientWithMutex(t *testing.T) { + s := newTestServer() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Manually simulate what connect() does (without calling Run which panics without full setup) + client := newDummyConnectClient(ctx) + + s.mutex.Lock() + s.connectClient = client + s.mutex.Unlock() + + // Verify the assignment is visible under mutex + s.mutex.Lock() + assert.Equal(t, client, s.connectClient, "connectClient should be set") + s.mutex.Unlock() +} + +// TestConcurrentConnectClientAccess validates that concurrent reads of +// s.connectClient under mutex don't race with a write. +func TestConcurrentConnectClientAccess(t *testing.T) { + s := newTestServer() + ctx := context.Background() + client := newDummyConnectClient(ctx) + + var wg sync.WaitGroup + nilCount := 0 + setCount := 0 + var mu sync.Mutex + + // Start readers + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + s.mutex.Lock() + c := s.connectClient + s.mutex.Unlock() + + mu.Lock() + defer mu.Unlock() + if c == nil { + nilCount++ + } else { + setCount++ + } + }() + } + + // Simulate connect() writing under mutex + time.Sleep(5 * time.Millisecond) + s.mutex.Lock() + s.connectClient = client + s.mutex.Unlock() + + wg.Wait() + + assert.Equal(t, 50, nilCount+setCount, "all goroutines should complete without panic") +} + +// TestCleanupConnection_ClearsConnectClient validates that cleanupConnection +// properly nils out connectClient. +func TestCleanupConnection_ClearsConnectClient(t *testing.T) { + s := newTestServer() + _, cancel := context.WithCancel(context.Background()) + s.actCancel = cancel + + s.connectClient = newDummyConnectClient(context.Background()) + s.clientRunning = true + + err := s.cleanupConnection() + require.NoError(t, err) + + assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup") + assert.False(t, s.clientRunning, "clientRunning should be cleared after cleanup (intent = down)") +} + +// TestCleanState_NilConnectClient validates that CleanState doesn't panic +// when connectClient is nil. +func TestCleanState_NilConnectClient(t *testing.T) { + s := newTestServer() + s.connectClient = nil + s.profileManager = nil // will cause error if it tries to proceed past the nil check + + // Should not panic — the nil check should prevent calling Status() on nil + assert.NotPanics(t, func() { + _, _ = s.CleanState(context.Background(), &proto.CleanStateRequest{All: true}) + }) +} + +// TestDeleteState_NilConnectClient validates that DeleteState doesn't panic +// when connectClient is nil. +func TestDeleteState_NilConnectClient(t *testing.T) { + s := newTestServer() + s.connectClient = nil + s.profileManager = nil + + assert.NotPanics(t, func() { + _, _ = s.DeleteState(context.Background(), &proto.DeleteStateRequest{All: true}) + }) +} + +// TestDownThenUp_StaleRunningChan documents the known state issue where +// clientRunningChan from a previous connection is already closed, causing +// waitForUp() to return immediately on reconnect. +func TestDownThenUp_StaleRunningChan(t *testing.T) { + s := newTestServer() + + // Simulate state after a successful connection + s.clientRunning = true + s.clientRunningChan = make(chan struct{}) + close(s.clientRunningChan) // closed when engine started + s.clientGiveUpChan = make(chan struct{}) + s.connectClient = newDummyConnectClient(context.Background()) + + _, cancel := context.WithCancel(context.Background()) + s.actCancel = cancel + + // Simulate Down(): cleanupConnection sets connectClient = nil 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 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.False(t, s.clientRunning, "clientRunning should be cleared by cleanupConnection (intent = down)") + s.mutex.Unlock() + + // waitForUp() returns immediately due to stale closed clientRunningChan + ctx, ctxCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer ctxCancel() + + waitDone := make(chan error, 1) + go func() { + _, err := s.waitForUp(ctx) + waitDone <- err + }() + + select { + case err := <-waitDone: + assert.NoError(t, err, "waitForUp returns success on stale channel") + // But connectClient is still nil — this is the stale state issue + s.mutex.Lock() + assert.Nil(t, s.connectClient, "connectClient is nil despite waitForUp success") + s.mutex.Unlock() + case <-time.After(1 * time.Second): + t.Fatal("waitForUp should have returned immediately due to stale closed channel") + } +} + +// TestConnectClient_EngineNilOnFreshClient validates that a newly created +// ConnectClient has nil Engine (before Run is called). +func TestConnectClient_EngineNilOnFreshClient(t *testing.T) { + client := newDummyConnectClient(context.Background()) + assert.Nil(t, client.Engine(), "engine should be nil on fresh ConnectClient") +} diff --git a/client/server/server_privileged_test.go b/client/server/server_privileged_test.go new file mode 100644 index 000000000..8b6f78f04 --- /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/golang/mock/gomock" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + + "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/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/permissions" + "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, permissionsManagerMock) + 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) + 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 ae5f759ee..7717cfcf8 100644 --- a/client/server/server_test.go +++ b/client/server/server_test.go @@ -2,121 +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/controllers/network_map/controller" - "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" - 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/management/server/peers/ephemeral/manager" - 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" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" - "github.com/netbirdio/netbird/management/server/peers" - "github.com/netbirdio/netbird/management/server/permissions" - "github.com/netbirdio/netbird/management/server/settings" - "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/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) - - 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 @@ -155,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) + s := New(ctx, "console", "", false, false, false, false) err = s.Start() require.NoError(t, err) @@ -225,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) + s := New(ctx, "console", "", false, false, false, false) err = s.Start() require.NoError(t, err) @@ -256,109 +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, permissionsManagerMock) - settingsManagerMock := settings.NewMockManager(ctrl) - - ia, _ := integrations.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore) - - 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()) - accountManager, err := server.BuildManager(context.Background(), store, networkMapController, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false) - if err != nil { - return nil, "", err - } - - secretsManager := nbgrpc.NewTimeBasedAuthSecretsManager(peersUpdateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager) - mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, peersUpdateManager, secretsManager, nil, &manager.EphemeralManager{}, nil, &server.MockIntegratedValidator{}, networkMapController) - 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..9baf16136 --- /dev/null +++ b/client/server/setconfig_mdm_test.go @@ -0,0 +1,235 @@ +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" +) + +// withMDMPolicy temporarily overrides the server-package loadMDMPolicy hook +// so SetConfig observes the supplied Policy. Restores the original loader +// at test cleanup. +func withMDMPolicy(t *testing.T, policy *mdm.Policy) { + t.Helper() + prev := loadMDMPolicy + loadMDMPolicy = func() *mdm.Policy { return policy } + t.Cleanup(func() { loadMDMPolicy = prev }) +} + +// 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, + })) + + ctx = context.Background() + 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) { + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: "https://mdm.example.com:443", + })) + + s, ctx, profName, username, _ := setupServerWithProfile(t) + + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + 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) { + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: "https://mdm.example.com:443", + mdm.KeyBlockInbound: true, + mdm.KeyRosenpassEnabled: true, + })) + + s, ctx, profName, username, _ := setupServerWithProfile(t) + + blockInbound := false + rosenpassEnabled := false + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + 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_AllOrNothing(t *testing.T) { + // MDM enforces ManagementURL only; user request touches both the + // enforced field AND a non-enforced field (RosenpassEnabled). + // The whole request must be rejected — non-conflicting fields are not + // applied either. + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: "https://mdm.example.com:443", + })) + + s, ctx, profName, username, cfgPath := setupServerWithProfile(t) + + 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. + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: "https://mdm.example.com:443", + })) + + s, ctx, profName, username, _ := setupServerWithProfile(t) + + rosenpassEnabled := true + resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + 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) { + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: tc.mdmURL, + })) + + s, ctx, profName, username, _ := setupServerWithProfile(t) + + rosenpassEnabled := true + resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + 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. + withMDMPolicy(t, mdm.NewPolicy(nil)) + + s, ctx, profName, username, _ := setupServerWithProfile(t) + + 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 1260bcc78..0e55257a9 100644 --- a/client/server/setconfig_test.go +++ b/client/server/setconfig_test.go @@ -47,13 +47,13 @@ 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) + s := New(ctx, "console", "", false, false, false, false) rosenpassEnabled := true rosenpassPermissive := true @@ -69,46 +69,48 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { disableFirewall := true blockLANAccess := true disableNotifications := true - lazyConnectionEnabled := true blockInbound := true + disableIPv6 := true mtu := int64(1280) + sshJWTCacheTTL := int32(300) 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, + 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, + 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, } _, 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() @@ -136,8 +138,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 @@ -146,6 +148,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { require.Equal(t, []string{"label1", "label2"}, cfg.DNSLabels.ToPunycodeList()) require.Equal(t, 2*time.Minute, cfg.DNSRouteInterval) require.Equal(t, uint16(mtu), cfg.MTU) + require.NotNil(t, cfg.SSHJWTCacheTTL) + require.Equal(t, int(sshJWTCacheTTL), *cfg.SSHJWTCacheTTL) verifyAllFieldsCovered(t, req) } @@ -157,40 +161,47 @@ 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{ - "ManagementUrl": true, - "AdminURL": true, - "RosenpassEnabled": true, - "RosenpassPermissive": true, - "ServerSSHAllowed": true, - "InterfaceName": true, - "WireguardPort": true, - "OptionalPreSharedKey": true, - "DisableAutoConnect": true, - "NetworkMonitor": true, - "DisableClientRoutes": true, - "DisableServerRoutes": true, - "DisableDns": true, - "DisableFirewall": true, - "BlockLanAccess": true, - "DisableNotifications": true, - "LazyConnectionEnabled": true, - "BlockInbound": true, - "NatExternalIPs": true, - "CustomDNSAddress": true, - "ExtraIFaceBlacklist": true, - "DnsLabels": true, - "DnsRouteInterval": true, - "Mtu": true, + "ManagementUrl": true, + "AdminURL": true, + "RosenpassEnabled": true, + "RosenpassPermissive": true, + "ServerSSHAllowed": true, + "InterfaceName": true, + "WireguardPort": true, + "OptionalPreSharedKey": true, + "DisableAutoConnect": true, + "NetworkMonitor": true, + "DisableClientRoutes": true, + "DisableServerRoutes": true, + "DisableDns": true, + "DisableFirewall": true, + "BlockLanAccess": true, + "DisableNotifications": true, + "BlockInbound": true, + "DisableIpv6": true, + "NatExternalIPs": true, + "CustomDNSAddress": true, + "ExtraIFaceBlacklist": true, + "DnsLabels": true, + "DnsRouteInterval": true, + "Mtu": true, + "EnableSSHRoot": true, + "EnableSSHSFTP": true, + "EnableSSHLocalPortForwarding": true, + "EnableSSHRemotePortForwarding": true, + "DisableSSHAuth": true, + "SshJWTCacheTTL": true, } val := reflect.ValueOf(req).Elem() @@ -221,34 +232,41 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) { // Map of CLI flag names to their corresponding SetConfigRequest field names. // This map must be updated when adding new config-related CLI flags. flagToField := map[string]string{ - "management-url": "ManagementUrl", - "admin-url": "AdminURL", - "enable-rosenpass": "RosenpassEnabled", - "rosenpass-permissive": "RosenpassPermissive", - "allow-server-ssh": "ServerSSHAllowed", - "interface-name": "InterfaceName", - "wireguard-port": "WireguardPort", - "preshared-key": "OptionalPreSharedKey", - "disable-auto-connect": "DisableAutoConnect", - "network-monitor": "NetworkMonitor", - "disable-client-routes": "DisableClientRoutes", - "disable-server-routes": "DisableServerRoutes", - "disable-dns": "DisableDns", - "disable-firewall": "DisableFirewall", - "block-lan-access": "BlockLanAccess", - "block-inbound": "BlockInbound", - "enable-lazy-connection": "LazyConnectionEnabled", - "external-ip-map": "NatExternalIPs", - "dns-resolver-address": "CustomDNSAddress", - "extra-iface-blacklist": "ExtraIFaceBlacklist", - "extra-dns-labels": "DnsLabels", - "dns-router-interval": "DnsRouteInterval", - "mtu": "Mtu", + "management-url": "ManagementUrl", + "admin-url": "AdminURL", + "enable-rosenpass": "RosenpassEnabled", + "rosenpass-permissive": "RosenpassPermissive", + "allow-server-ssh": "ServerSSHAllowed", + "interface-name": "InterfaceName", + "wireguard-port": "WireguardPort", + "preshared-key": "OptionalPreSharedKey", + "disable-auto-connect": "DisableAutoConnect", + "network-monitor": "NetworkMonitor", + "disable-client-routes": "DisableClientRoutes", + "disable-server-routes": "DisableServerRoutes", + "disable-dns": "DisableDns", + "disable-firewall": "DisableFirewall", + "block-lan-access": "BlockLanAccess", + "block-inbound": "BlockInbound", + "disable-ipv6": "DisableIpv6", + "external-ip-map": "NatExternalIPs", + "dns-resolver-address": "CustomDNSAddress", + "extra-iface-blacklist": "ExtraIFaceBlacklist", + "extra-dns-labels": "DnsLabels", + "dns-router-interval": "DnsRouteInterval", + "mtu": "Mtu", + "enable-ssh-root": "EnableSSHRoot", + "enable-ssh-sftp": "EnableSSHSFTP", + "enable-ssh-local-port-forwarding": "EnableSSHLocalPortForwarding", + "enable-ssh-remote-port-forwarding": "EnableSSHRemotePortForwarding", + "disable-ssh-auth": "DisableSSHAuth", + "ssh-jwt-cache-ttl": "SshJWTCacheTTL", } // 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 new file mode 100644 index 000000000..877ad9690 --- /dev/null +++ b/client/server/sleep.go @@ -0,0 +1,93 @@ +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 +} + +func (a *serverAgent) Up(ctx context.Context) error { + _, err := a.s.Up(ctx, &proto.UpRequest{}) + return err +} + +func (a *serverAgent) Down(ctx context.Context) error { + _, err := a.s.Down(ctx, &proto.DownRequest{}) + return err +} + +func (a *serverAgent) Status() (internal.StatusType, error) { + return internal.CtxGetState(a.s.rootCtx).Status() +} + +// 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 + } + + 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/state.go b/client/server/state.go index 1cf85cd37..a4e91468e 100644 --- a/client/server/state.go +++ b/client/server/state.go @@ -12,7 +12,6 @@ import ( "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/routemanager/systemops" "github.com/netbirdio/netbird/client/internal/statemanager" - nbnet "github.com/netbirdio/netbird/client/net" "github.com/netbirdio/netbird/client/proto" ) @@ -39,7 +38,7 @@ func (s *Server) ListStates(_ context.Context, _ *proto.ListStatesRequest) (*pro // CleanState handles cleaning of states (performing cleanup operations) func (s *Server) CleanState(ctx context.Context, req *proto.CleanStateRequest) (*proto.CleanStateResponse, error) { - if s.connectClient.Status() == internal.StatusConnected || s.connectClient.Status() == internal.StatusConnecting { + if s.connectClient != nil && (s.connectClient.Status() == internal.StatusConnected || s.connectClient.Status() == internal.StatusConnecting) { return nil, status.Errorf(codes.FailedPrecondition, "cannot clean state while connecting or connected, run 'netbird down' first.") } @@ -47,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) } @@ -82,7 +81,7 @@ func (s *Server) CleanState(ctx context.Context, req *proto.CleanStateRequest) ( // DeleteState handles deletion of states without cleanup func (s *Server) DeleteState(ctx context.Context, req *proto.DeleteStateRequest) (*proto.DeleteStateResponse, error) { - if s.connectClient.Status() == internal.StatusConnected || s.connectClient.Status() == internal.StatusConnecting { + if s.connectClient != nil && (s.connectClient.Status() == internal.StatusConnected || s.connectClient.Status() == internal.StatusConnecting) { return nil, status.Errorf(codes.FailedPrecondition, "cannot clean state while connecting or connected, run 'netbird down' first.") } @@ -114,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 } @@ -138,10 +137,8 @@ func restoreResidualState(ctx context.Context, statePath string) error { } // clean up any remaining routes independently of the state file - if !nbnet.AdvancedRouting() { - if err := systemops.New(nil, nil).FlushMarkedRoutes(); err != nil { - merr = multierror.Append(merr, fmt.Errorf("flush marked routes: %w", err)) - } + if err := systemops.New(nil, nil).FlushMarkedRoutes(); err != nil { + merr = multierror.Append(merr, fmt.Errorf("flush marked routes: %w", err)) } return nberrors.FormatErrorOrNil(merr) diff --git a/client/server/state_generic.go b/client/server/state_generic.go index e6c7bdd44..86475ca42 100644 --- a/client/server/state_generic.go +++ b/client/server/state_generic.go @@ -6,9 +6,12 @@ import ( "github.com/netbirdio/netbird/client/internal/dns" "github.com/netbirdio/netbird/client/internal/routemanager/systemops" "github.com/netbirdio/netbird/client/internal/statemanager" + "github.com/netbirdio/netbird/client/ssh/config" ) +// registerStates registers all states that need crash recovery cleanup. func registerStates(mgr *statemanager.Manager) { mgr.RegisterState(&dns.ShutdownState{}) mgr.RegisterState(&systemops.ShutdownState{}) + mgr.RegisterState(&config.ShutdownState{}) } diff --git a/client/server/state_linux.go b/client/server/state_linux.go index 087628907..b193d4dfa 100644 --- a/client/server/state_linux.go +++ b/client/server/state_linux.go @@ -8,11 +8,14 @@ import ( "github.com/netbirdio/netbird/client/internal/dns" "github.com/netbirdio/netbird/client/internal/routemanager/systemops" "github.com/netbirdio/netbird/client/internal/statemanager" + "github.com/netbirdio/netbird/client/ssh/config" ) +// registerStates registers all states that need crash recovery cleanup. func registerStates(mgr *statemanager.Manager) { mgr.RegisterState(&dns.ShutdownState{}) mgr.RegisterState(&systemops.ShutdownState{}) mgr.RegisterState(&nftables.ShutdownState{}) mgr.RegisterState(&iptables.ShutdownState{}) + mgr.RegisterState(&config.ShutdownState{}) } 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/server/triggerupdate.go b/client/server/triggerupdate.go new file mode 100644 index 000000000..ffcb527e7 --- /dev/null +++ b/client/server/triggerupdate.go @@ -0,0 +1,24 @@ +package server + +import ( + "context" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/proto" +) + +// TriggerUpdate initiates installation of the pending enforced version. +// It is called when the user clicks the install button in the UI (Mode 2 / enforced update). +func (s *Server) TriggerUpdate(ctx context.Context, _ *proto.TriggerUpdateRequest) (*proto.TriggerUpdateResponse, error) { + if s.updateManager == nil { + return &proto.TriggerUpdateResponse{Success: false, ErrorMsg: "update manager not available"}, nil + } + + if err := s.updateManager.Install(ctx); err != nil { + log.Warnf("TriggerUpdate failed: %v", err) + return &proto.TriggerUpdateResponse{Success: false, ErrorMsg: err.Error()}, nil + } + + return &proto.TriggerUpdateResponse{Success: true}, nil +} diff --git a/client/server/updateresult.go b/client/server/updateresult.go new file mode 100644 index 000000000..8d1ef0e5f --- /dev/null +++ b/client/server/updateresult.go @@ -0,0 +1,30 @@ +package server + +import ( + "context" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/internal/updater/installer" + "github.com/netbirdio/netbird/client/proto" +) + +func (s *Server) GetInstallerResult(ctx context.Context, _ *proto.InstallerResultRequest) (*proto.InstallerResultResponse, error) { + inst := installer.New() + dir := inst.TempDir() + + rh := installer.NewResultHandler(dir) + result, err := rh.Watch(ctx) + if err != nil { + log.Errorf("failed to watch update result: %v", err) + return &proto.InstallerResultResponse{ + Success: false, + ErrorMsg: err.Error(), + }, nil + } + + return &proto.InstallerResultResponse{ + Success: result.Success, + ErrorMsg: result.Error, + }, nil +} diff --git a/client/ssh/auth/auth.go b/client/ssh/auth/auth.go new file mode 100644 index 000000000..079282fdc --- /dev/null +++ b/client/ssh/auth/auth.go @@ -0,0 +1,177 @@ +package auth + +import ( + "errors" + "fmt" + "sync" + + log "github.com/sirupsen/logrus" + + sshuserhash "github.com/netbirdio/netbird/shared/sshauth" +) + +const ( + // DefaultUserIDClaim is the default JWT claim used to extract user IDs + DefaultUserIDClaim = "sub" + // Wildcard is a special user ID that matches all users + Wildcard = "*" +) + +var ( + ErrEmptyUserID = errors.New("JWT user ID is empty") + ErrUserNotAuthorized = errors.New("user is not authorized to access this peer") + ErrNoMachineUserMapping = errors.New("no authorization mapping for OS user") + ErrUserNotMappedToOSUser = errors.New("user is not authorized to login as OS user") +) + +// Authorizer handles SSH fine-grained access control authorization +type Authorizer struct { + // UserIDClaim is the JWT claim to extract the user ID from + userIDClaim string + + // authorizedUsers is a list of hashed user IDs authorized to access this peer + authorizedUsers []sshuserhash.UserIDHash + + // machineUsers maps OS login usernames to lists of authorized user indexes + machineUsers map[string][]uint32 + + // mu protects the list of users + mu sync.RWMutex +} + +// Config contains configuration for the SSH authorizer +type Config struct { + // UserIDClaim is the JWT claim to extract the user ID from (e.g., "sub", "email") + UserIDClaim string + + // AuthorizedUsers is a list of hashed user IDs (FNV-1a 64-bit) authorized to access this peer + AuthorizedUsers []sshuserhash.UserIDHash + + // MachineUsers maps OS login usernames to indexes in AuthorizedUsers + // If a user wants to login as a specific OS user, their index must be in the corresponding list + MachineUsers map[string][]uint32 +} + +// NewAuthorizer creates a new SSH authorizer with empty configuration +func NewAuthorizer() *Authorizer { + a := &Authorizer{ + userIDClaim: DefaultUserIDClaim, + machineUsers: make(map[string][]uint32), + } + + return a +} + +// Update updates the authorizer configuration with new values +func (a *Authorizer) Update(config *Config) { + a.mu.Lock() + defer a.mu.Unlock() + + if config == nil { + // Clear authorization + a.userIDClaim = DefaultUserIDClaim + a.authorizedUsers = []sshuserhash.UserIDHash{} + a.machineUsers = make(map[string][]uint32) + log.Info("SSH authorization cleared") + return + } + + userIDClaim := config.UserIDClaim + if userIDClaim == "" { + userIDClaim = DefaultUserIDClaim + } + a.userIDClaim = userIDClaim + + // Store authorized users list + a.authorizedUsers = config.AuthorizedUsers + + // Store machine users mapping + machineUsers := make(map[string][]uint32) + for osUser, indexes := range config.MachineUsers { + if len(indexes) > 0 { + machineUsers[osUser] = indexes + } + } + a.machineUsers = machineUsers + + log.Debugf("SSH auth: updated with %d authorized users, %d machine user mappings", + len(config.AuthorizedUsers), len(machineUsers)) +} + +// Authorize validates if a user is authorized to login as the specified OS user. +// Returns a success message describing how authorization was granted, or an error. +func (a *Authorizer) Authorize(jwtUserID, osUsername string) (string, error) { + if jwtUserID == "" { + return "", fmt.Errorf("JWT user ID is empty for OS user %q: %w", osUsername, ErrEmptyUserID) + } + + // Hash the JWT user ID for comparison + hashedUserID, err := sshuserhash.HashUserID(jwtUserID) + if err != nil { + return "", fmt.Errorf("hash user ID %q for OS user %q: %w", jwtUserID, osUsername, err) + } + + a.mu.RLock() + defer a.mu.RUnlock() + + // Find the index of this user in the authorized list + userIndex, found := a.findUserIndex(hashedUserID) + if !found { + return "", fmt.Errorf("user %q (hash: %s) not in authorized list for OS user %q: %w", jwtUserID, hashedUserID, osUsername, ErrUserNotAuthorized) + } + + return a.checkMachineUserMapping(jwtUserID, osUsername, userIndex) +} + +// checkMachineUserMapping validates if a user's index is authorized for the specified OS user +// Checks wildcard mapping first, then specific OS user mappings +func (a *Authorizer) checkMachineUserMapping(jwtUserID, osUsername string, userIndex int) (string, error) { + // If wildcard exists and user's index is in the wildcard list, allow access to any OS user + if wildcardIndexes, hasWildcard := a.machineUsers[Wildcard]; hasWildcard { + if a.isIndexInList(uint32(userIndex), wildcardIndexes) { + return fmt.Sprintf("granted via wildcard (index: %d)", userIndex), nil + } + } + + // Check for specific OS username mapping + allowedIndexes, hasMachineUserMapping := a.machineUsers[osUsername] + if !hasMachineUserMapping { + // No mapping for this OS user - deny by default (fail closed) + return "", fmt.Errorf("no machine user mapping for OS user %q (JWT user: %s): %w", osUsername, jwtUserID, ErrNoMachineUserMapping) + } + + // Check if user's index is in the allowed indexes for this specific OS user + if !a.isIndexInList(uint32(userIndex), allowedIndexes) { + return "", fmt.Errorf("user %q not mapped to OS user %q (index: %d): %w", jwtUserID, osUsername, userIndex, ErrUserNotMappedToOSUser) + } + + return fmt.Sprintf("granted (index: %d)", userIndex), nil +} + +// GetUserIDClaim returns the JWT claim name used to extract user IDs +func (a *Authorizer) GetUserIDClaim() string { + a.mu.RLock() + defer a.mu.RUnlock() + return a.userIDClaim +} + +// 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) { + for i, id := range a.authorizedUsers { + if id == hashedUserID { + return i, true + } + } + return 0, false +} + +// isIndexInList checks if an index exists in a list of indexes +func (a *Authorizer) isIndexInList(index uint32, indexes []uint32) bool { + for _, idx := range indexes { + if idx == index { + return true + } + } + return false +} diff --git a/client/ssh/auth/auth_test.go b/client/ssh/auth/auth_test.go new file mode 100644 index 000000000..fa27b72e8 --- /dev/null +++ b/client/ssh/auth/auth_test.go @@ -0,0 +1,612 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/sshauth" +) + +func TestAuthorizer_Authorize_UserNotInList(t *testing.T) { + authorizer := NewAuthorizer() + + // Set up authorized users list with one user + authorizedUserHash, err := sshauth.HashUserID("authorized-user") + require.NoError(t, err) + + config := &Config{ + UserIDClaim: DefaultUserIDClaim, + AuthorizedUsers: []sshauth.UserIDHash{authorizedUserHash}, + MachineUsers: map[string][]uint32{}, + } + authorizer.Update(config) + + // Try to authorize a different user + _, err = authorizer.Authorize("unauthorized-user", "root") + assert.Error(t, err) + assert.ErrorIs(t, err, ErrUserNotAuthorized) +} + +func TestAuthorizer_Authorize_UserInList_NoMachineUserRestrictions(t *testing.T) { + authorizer := NewAuthorizer() + + user1Hash, err := sshauth.HashUserID("user1") + require.NoError(t, err) + user2Hash, err := sshauth.HashUserID("user2") + require.NoError(t, err) + + config := &Config{ + UserIDClaim: DefaultUserIDClaim, + AuthorizedUsers: []sshauth.UserIDHash{user1Hash, user2Hash}, + MachineUsers: map[string][]uint32{}, // Empty = deny all (fail closed) + } + authorizer.Update(config) + + // All attempts should fail when no machine user mappings exist (fail closed) + _, err = authorizer.Authorize("user1", "root") + assert.Error(t, err) + assert.ErrorIs(t, err, ErrNoMachineUserMapping) + + _, err = authorizer.Authorize("user2", "admin") + assert.Error(t, err) + assert.ErrorIs(t, err, ErrNoMachineUserMapping) + + _, err = authorizer.Authorize("user1", "postgres") + assert.Error(t, err) + assert.ErrorIs(t, err, ErrNoMachineUserMapping) +} + +func TestAuthorizer_Authorize_UserInList_WithMachineUserMapping_Allowed(t *testing.T) { + authorizer := NewAuthorizer() + + user1Hash, err := sshauth.HashUserID("user1") + require.NoError(t, err) + user2Hash, err := sshauth.HashUserID("user2") + require.NoError(t, err) + user3Hash, err := sshauth.HashUserID("user3") + require.NoError(t, err) + + config := &Config{ + UserIDClaim: DefaultUserIDClaim, + AuthorizedUsers: []sshauth.UserIDHash{user1Hash, user2Hash, user3Hash}, + MachineUsers: map[string][]uint32{ + "root": {0, 1}, // user1 and user2 can access root + "postgres": {1, 2}, // user2 and user3 can access postgres + "admin": {0}, // only user1 can access admin + }, + } + authorizer.Update(config) + + // user1 (index 0) should access root and admin + _, err = authorizer.Authorize("user1", "root") + assert.NoError(t, err) + + _, err = authorizer.Authorize("user1", "admin") + assert.NoError(t, err) + + // user2 (index 1) should access root and postgres + _, err = authorizer.Authorize("user2", "root") + assert.NoError(t, err) + + _, err = authorizer.Authorize("user2", "postgres") + assert.NoError(t, err) + + // user3 (index 2) should access postgres + _, err = authorizer.Authorize("user3", "postgres") + assert.NoError(t, err) +} + +func TestAuthorizer_Authorize_UserInList_WithMachineUserMapping_Denied(t *testing.T) { + authorizer := NewAuthorizer() + + // Set up authorized users list + user1Hash, err := sshauth.HashUserID("user1") + require.NoError(t, err) + user2Hash, err := sshauth.HashUserID("user2") + require.NoError(t, err) + user3Hash, err := sshauth.HashUserID("user3") + require.NoError(t, err) + + config := &Config{ + UserIDClaim: DefaultUserIDClaim, + AuthorizedUsers: []sshauth.UserIDHash{user1Hash, user2Hash, user3Hash}, + MachineUsers: map[string][]uint32{ + "root": {0, 1}, // user1 and user2 can access root + "postgres": {1, 2}, // user2 and user3 can access postgres + "admin": {0}, // only user1 can access admin + }, + } + authorizer.Update(config) + + // user1 (index 0) should NOT access postgres + _, err = authorizer.Authorize("user1", "postgres") + assert.Error(t, err) + assert.ErrorIs(t, err, ErrUserNotMappedToOSUser) + + // user2 (index 1) should NOT access admin + _, err = authorizer.Authorize("user2", "admin") + assert.Error(t, err) + assert.ErrorIs(t, err, ErrUserNotMappedToOSUser) + + // user3 (index 2) should NOT access root + _, err = authorizer.Authorize("user3", "root") + assert.Error(t, err) + assert.ErrorIs(t, err, ErrUserNotMappedToOSUser) + + // user3 (index 2) should NOT access admin + _, err = authorizer.Authorize("user3", "admin") + assert.Error(t, err) + assert.ErrorIs(t, err, ErrUserNotMappedToOSUser) +} + +func TestAuthorizer_Authorize_UserInList_OSUserNotInMapping(t *testing.T) { + authorizer := NewAuthorizer() + + // Set up authorized users list + user1Hash, err := sshauth.HashUserID("user1") + require.NoError(t, err) + + config := &Config{ + UserIDClaim: DefaultUserIDClaim, + AuthorizedUsers: []sshauth.UserIDHash{user1Hash}, + MachineUsers: map[string][]uint32{ + "root": {0}, // only root is mapped + }, + } + authorizer.Update(config) + + // user1 should NOT access an unmapped OS user (fail closed) + _, err = authorizer.Authorize("user1", "postgres") + assert.Error(t, err) + assert.ErrorIs(t, err, ErrNoMachineUserMapping) +} + +func TestAuthorizer_Authorize_EmptyJWTUserID(t *testing.T) { + authorizer := NewAuthorizer() + + // Set up authorized users list + user1Hash, err := sshauth.HashUserID("user1") + require.NoError(t, err) + + config := &Config{ + UserIDClaim: DefaultUserIDClaim, + AuthorizedUsers: []sshauth.UserIDHash{user1Hash}, + MachineUsers: map[string][]uint32{}, + } + authorizer.Update(config) + + // Empty user ID should fail + _, err = authorizer.Authorize("", "root") + assert.Error(t, err) + assert.ErrorIs(t, err, ErrEmptyUserID) +} + +func TestAuthorizer_Authorize_MultipleUsersInList(t *testing.T) { + authorizer := NewAuthorizer() + + // Set up multiple authorized users + userHashes := make([]sshauth.UserIDHash, 10) + for i := 0; i < 10; i++ { + hash, err := sshauth.HashUserID("user" + string(rune('0'+i))) + require.NoError(t, err) + userHashes[i] = hash + } + + // Create machine user mapping for all users + rootIndexes := make([]uint32, 10) + for i := 0; i < 10; i++ { + rootIndexes[i] = uint32(i) + } + + config := &Config{ + UserIDClaim: DefaultUserIDClaim, + AuthorizedUsers: userHashes, + MachineUsers: map[string][]uint32{ + "root": rootIndexes, + }, + } + authorizer.Update(config) + + // All users should be authorized for root + for i := 0; i < 10; i++ { + _, err := authorizer.Authorize("user"+string(rune('0'+i)), "root") + assert.NoError(t, err, "user%d should be authorized", i) + } + + // User not in list should fail + _, err := authorizer.Authorize("unknown-user", "root") + assert.Error(t, err) + assert.ErrorIs(t, err, ErrUserNotAuthorized) +} + +func TestAuthorizer_Update_ClearsConfiguration(t *testing.T) { + authorizer := NewAuthorizer() + + // Set up initial configuration + user1Hash, err := sshauth.HashUserID("user1") + require.NoError(t, err) + + config := &Config{ + UserIDClaim: DefaultUserIDClaim, + AuthorizedUsers: []sshauth.UserIDHash{user1Hash}, + MachineUsers: map[string][]uint32{"root": {0}}, + } + authorizer.Update(config) + + // user1 should be authorized + _, err = authorizer.Authorize("user1", "root") + assert.NoError(t, err) + + // Clear configuration + authorizer.Update(nil) + + // user1 should no longer be authorized + _, err = authorizer.Authorize("user1", "root") + assert.Error(t, err) + assert.ErrorIs(t, err, ErrUserNotAuthorized) +} + +func TestAuthorizer_Update_EmptyMachineUsersListEntries(t *testing.T) { + authorizer := NewAuthorizer() + + user1Hash, err := sshauth.HashUserID("user1") + require.NoError(t, err) + + // Machine users with empty index lists should be filtered out + config := &Config{ + UserIDClaim: DefaultUserIDClaim, + AuthorizedUsers: []sshauth.UserIDHash{user1Hash}, + MachineUsers: map[string][]uint32{ + "root": {0}, + "postgres": {}, // empty list - should be filtered out + "admin": nil, // nil list - should be filtered out + }, + } + authorizer.Update(config) + + // root should work + _, err = authorizer.Authorize("user1", "root") + assert.NoError(t, err) + + // postgres should fail (no mapping) + _, err = authorizer.Authorize("user1", "postgres") + assert.Error(t, err) + assert.ErrorIs(t, err, ErrNoMachineUserMapping) + + // admin should fail (no mapping) + _, err = authorizer.Authorize("user1", "admin") + assert.Error(t, err) + assert.ErrorIs(t, err, ErrNoMachineUserMapping) +} + +func TestAuthorizer_CustomUserIDClaim(t *testing.T) { + authorizer := NewAuthorizer() + + // Set up with custom user ID claim + user1Hash, err := sshauth.HashUserID("user@example.com") + require.NoError(t, err) + + config := &Config{ + UserIDClaim: "email", + AuthorizedUsers: []sshauth.UserIDHash{user1Hash}, + MachineUsers: map[string][]uint32{ + "root": {0}, + }, + } + authorizer.Update(config) + + // Verify the custom claim is set + assert.Equal(t, "email", authorizer.GetUserIDClaim()) + + // Authorize with email as user ID + _, err = authorizer.Authorize("user@example.com", "root") + assert.NoError(t, err) +} + +func TestAuthorizer_DefaultUserIDClaim(t *testing.T) { + authorizer := NewAuthorizer() + + // Verify default claim + assert.Equal(t, DefaultUserIDClaim, authorizer.GetUserIDClaim()) + assert.Equal(t, "sub", authorizer.GetUserIDClaim()) + + // Set up with empty user ID claim (should use default) + user1Hash, err := sshauth.HashUserID("user1") + require.NoError(t, err) + + config := &Config{ + UserIDClaim: "", // empty - should use default + AuthorizedUsers: []sshauth.UserIDHash{user1Hash}, + MachineUsers: map[string][]uint32{}, + } + authorizer.Update(config) + + // Should fall back to default + assert.Equal(t, DefaultUserIDClaim, authorizer.GetUserIDClaim()) +} + +func TestAuthorizer_MachineUserMapping_LargeIndexes(t *testing.T) { + authorizer := NewAuthorizer() + + // Create a large authorized users list + const numUsers = 1000 + userHashes := make([]sshauth.UserIDHash, numUsers) + for i := 0; i < numUsers; i++ { + hash, err := sshauth.HashUserID("user" + string(rune(i))) + require.NoError(t, err) + userHashes[i] = hash + } + + config := &Config{ + UserIDClaim: DefaultUserIDClaim, + AuthorizedUsers: userHashes, + MachineUsers: map[string][]uint32{ + "root": {0, 500, 999}, // first, middle, and last user + }, + } + authorizer.Update(config) + + // First user should have access + _, err := authorizer.Authorize("user"+string(rune(0)), "root") + assert.NoError(t, err) + + // Middle user should have access + _, err = authorizer.Authorize("user"+string(rune(500)), "root") + assert.NoError(t, err) + + // Last user should have access + _, err = authorizer.Authorize("user"+string(rune(999)), "root") + assert.NoError(t, err) + + // User not in mapping should NOT have access + _, err = authorizer.Authorize("user"+string(rune(100)), "root") + assert.Error(t, err) +} + +func TestAuthorizer_ConcurrentAuthorization(t *testing.T) { + authorizer := NewAuthorizer() + + // Set up authorized users + user1Hash, err := sshauth.HashUserID("user1") + require.NoError(t, err) + user2Hash, err := sshauth.HashUserID("user2") + require.NoError(t, err) + + config := &Config{ + UserIDClaim: DefaultUserIDClaim, + AuthorizedUsers: []sshauth.UserIDHash{user1Hash, user2Hash}, + MachineUsers: map[string][]uint32{ + "root": {0, 1}, + }, + } + authorizer.Update(config) + + // Test concurrent authorization calls (should be safe to read concurrently) + const numGoroutines = 100 + errChan := make(chan error, numGoroutines) + + for i := 0; i < numGoroutines; i++ { + go func(idx int) { + user := "user1" + if idx%2 == 0 { + user = "user2" + } + _, err := authorizer.Authorize(user, "root") + errChan <- err + }(i) + } + + // Wait for all goroutines to complete and collect errors + for i := 0; i < numGoroutines; i++ { + err := <-errChan + assert.NoError(t, err) + } +} + +func TestAuthorizer_Wildcard_AllowsAllAuthorizedUsers(t *testing.T) { + authorizer := NewAuthorizer() + + user1Hash, err := sshauth.HashUserID("user1") + require.NoError(t, err) + user2Hash, err := sshauth.HashUserID("user2") + require.NoError(t, err) + user3Hash, err := sshauth.HashUserID("user3") + require.NoError(t, err) + + // Configure with wildcard - all authorized users can access any OS user + config := &Config{ + UserIDClaim: DefaultUserIDClaim, + AuthorizedUsers: []sshauth.UserIDHash{user1Hash, user2Hash, user3Hash}, + MachineUsers: map[string][]uint32{ + "*": {0, 1, 2}, // wildcard with all user indexes + }, + } + authorizer.Update(config) + + // All authorized users should be able to access any OS user + _, err = authorizer.Authorize("user1", "root") + assert.NoError(t, err) + + _, err = authorizer.Authorize("user2", "postgres") + assert.NoError(t, err) + + _, err = authorizer.Authorize("user3", "admin") + assert.NoError(t, err) + + _, err = authorizer.Authorize("user1", "ubuntu") + assert.NoError(t, err) + + _, err = authorizer.Authorize("user2", "nginx") + assert.NoError(t, err) + + _, err = authorizer.Authorize("user3", "docker") + assert.NoError(t, err) +} + +func TestAuthorizer_Wildcard_UnauthorizedUserStillDenied(t *testing.T) { + authorizer := NewAuthorizer() + + user1Hash, err := sshauth.HashUserID("user1") + require.NoError(t, err) + + // Configure with wildcard + config := &Config{ + UserIDClaim: DefaultUserIDClaim, + AuthorizedUsers: []sshauth.UserIDHash{user1Hash}, + MachineUsers: map[string][]uint32{ + "*": {0}, + }, + } + authorizer.Update(config) + + // user1 should have access + _, err = authorizer.Authorize("user1", "root") + assert.NoError(t, err) + + // Unauthorized user should still be denied even with wildcard + _, err = authorizer.Authorize("unauthorized-user", "root") + assert.Error(t, err) + assert.ErrorIs(t, err, ErrUserNotAuthorized) +} + +func TestAuthorizer_Wildcard_TakesPrecedenceOverSpecificMappings(t *testing.T) { + authorizer := NewAuthorizer() + + user1Hash, err := sshauth.HashUserID("user1") + require.NoError(t, err) + user2Hash, err := sshauth.HashUserID("user2") + require.NoError(t, err) + + // Configure with both wildcard and specific mappings + // Wildcard takes precedence for users in the wildcard index list + config := &Config{ + UserIDClaim: DefaultUserIDClaim, + AuthorizedUsers: []sshauth.UserIDHash{user1Hash, user2Hash}, + MachineUsers: map[string][]uint32{ + "*": {0, 1}, // wildcard for both users + "root": {0}, // specific mapping that would normally restrict to user1 only + }, + } + authorizer.Update(config) + + // Both users should be able to access root via wildcard (takes precedence over specific mapping) + _, err = authorizer.Authorize("user1", "root") + assert.NoError(t, err) + + _, err = authorizer.Authorize("user2", "root") + assert.NoError(t, err) + + // Both users should be able to access any other OS user via wildcard + _, err = authorizer.Authorize("user1", "postgres") + assert.NoError(t, err) + + _, err = authorizer.Authorize("user2", "admin") + assert.NoError(t, err) +} + +func TestAuthorizer_NoWildcard_SpecificMappingsOnly(t *testing.T) { + authorizer := NewAuthorizer() + + user1Hash, err := sshauth.HashUserID("user1") + require.NoError(t, err) + user2Hash, err := sshauth.HashUserID("user2") + require.NoError(t, err) + + // Configure WITHOUT wildcard - only specific mappings + config := &Config{ + UserIDClaim: DefaultUserIDClaim, + AuthorizedUsers: []sshauth.UserIDHash{user1Hash, user2Hash}, + MachineUsers: map[string][]uint32{ + "root": {0}, // only user1 + "postgres": {1}, // only user2 + }, + } + authorizer.Update(config) + + // user1 can access root + _, err = authorizer.Authorize("user1", "root") + assert.NoError(t, err) + + // user2 can access postgres + _, err = authorizer.Authorize("user2", "postgres") + assert.NoError(t, err) + + // user1 cannot access postgres + _, err = authorizer.Authorize("user1", "postgres") + assert.Error(t, err) + assert.ErrorIs(t, err, ErrUserNotMappedToOSUser) + + // user2 cannot access root + _, err = authorizer.Authorize("user2", "root") + assert.Error(t, err) + assert.ErrorIs(t, err, ErrUserNotMappedToOSUser) + + // Neither can access unmapped OS users + _, err = authorizer.Authorize("user1", "admin") + assert.Error(t, err) + assert.ErrorIs(t, err, ErrNoMachineUserMapping) + + _, err = authorizer.Authorize("user2", "admin") + assert.Error(t, err) + assert.ErrorIs(t, err, ErrNoMachineUserMapping) +} + +func TestAuthorizer_Wildcard_WithPartialIndexes_AllowsAllUsers(t *testing.T) { + // This test covers the scenario where wildcard exists with limited indexes. + // Only users whose indexes are in the wildcard list can access any OS user via wildcard. + // Other users can only access OS users they are explicitly mapped to. + authorizer := NewAuthorizer() + + // Create two authorized user hashes (simulating the base64-encoded hashes in the config) + wasmHash, err := sshauth.HashUserID("wasm") + require.NoError(t, err) + user2Hash, err := sshauth.HashUserID("user2") + require.NoError(t, err) + + // Configure with wildcard having only index 0, and specific mappings for other OS users + config := &Config{ + UserIDClaim: "sub", + AuthorizedUsers: []sshauth.UserIDHash{wasmHash, user2Hash}, + MachineUsers: map[string][]uint32{ + "*": {0}, // wildcard with only index 0 - only wasm has wildcard access + "alice": {1}, // specific mapping for user2 + "bob": {1}, // specific mapping for user2 + }, + } + authorizer.Update(config) + + // wasm (index 0) should access any OS user via wildcard + _, err = authorizer.Authorize("wasm", "root") + assert.NoError(t, err, "wasm should access root via wildcard") + + _, err = authorizer.Authorize("wasm", "alice") + assert.NoError(t, err, "wasm should access alice via wildcard") + + _, err = authorizer.Authorize("wasm", "bob") + assert.NoError(t, err, "wasm should access bob via wildcard") + + _, err = authorizer.Authorize("wasm", "postgres") + assert.NoError(t, err, "wasm should access postgres via wildcard") + + // user2 (index 1) should only access alice and bob (explicitly mapped), NOT root or postgres + _, err = authorizer.Authorize("user2", "alice") + assert.NoError(t, err, "user2 should access alice via explicit mapping") + + _, err = authorizer.Authorize("user2", "bob") + assert.NoError(t, err, "user2 should access bob via explicit mapping") + + _, err = authorizer.Authorize("user2", "root") + assert.Error(t, err, "user2 should NOT access root (not in wildcard indexes)") + assert.ErrorIs(t, err, ErrNoMachineUserMapping) + + _, err = authorizer.Authorize("user2", "postgres") + assert.Error(t, err, "user2 should NOT access postgres (not explicitly mapped)") + assert.ErrorIs(t, err, ErrNoMachineUserMapping) + + // Unauthorized user should still be denied + _, err = authorizer.Authorize("user3", "root") + assert.Error(t, err) + assert.ErrorIs(t, err, ErrUserNotAuthorized, "unauthorized user should be denied") +} diff --git a/client/ssh/client.go b/client/ssh/client.go deleted file mode 100644 index afba347f8..000000000 --- a/client/ssh/client.go +++ /dev/null @@ -1,118 +0,0 @@ -//go:build !js - -package ssh - -import ( - "fmt" - "net" - "os" - "time" - - "golang.org/x/crypto/ssh" - "golang.org/x/term" -) - -// Client wraps crypto/ssh Client to simplify usage -type Client struct { - client *ssh.Client -} - -// Close closes the wrapped SSH Client -func (c *Client) Close() error { - return c.client.Close() -} - -// OpenTerminal starts an interactive terminal session with the remote SSH server -func (c *Client) OpenTerminal() error { - session, err := c.client.NewSession() - if err != nil { - return fmt.Errorf("failed to open new session: %v", err) - } - defer func() { - err := session.Close() - if err != nil { - return - } - }() - - fd := int(os.Stdout.Fd()) - state, err := term.MakeRaw(fd) - if err != nil { - return fmt.Errorf("failed to run raw terminal: %s", err) - } - defer func() { - err := term.Restore(fd, state) - if err != nil { - return - } - }() - - w, h, err := term.GetSize(fd) - if err != nil { - return fmt.Errorf("terminal get size: %s", err) - } - - modes := ssh.TerminalModes{ - ssh.ECHO: 1, - ssh.TTY_OP_ISPEED: 14400, - ssh.TTY_OP_OSPEED: 14400, - } - - terminal := os.Getenv("TERM") - if terminal == "" { - terminal = "xterm-256color" - } - if err := session.RequestPty(terminal, h, w, modes); err != nil { - return fmt.Errorf("failed requesting pty session with xterm: %s", err) - } - - session.Stdout = os.Stdout - session.Stderr = os.Stderr - session.Stdin = os.Stdin - - if err := session.Shell(); err != nil { - return fmt.Errorf("failed to start login shell on the remote host: %s", err) - } - - if err := session.Wait(); err != nil { - if e, ok := err.(*ssh.ExitError); ok { - if e.ExitStatus() == 130 { - return nil - } - } - return fmt.Errorf("failed running SSH session: %s", err) - } - - return nil -} - -// DialWithKey connects to the remote SSH server with a provided private key file (PEM). -func DialWithKey(addr, user string, privateKey []byte) (*Client, error) { - - signer, err := ssh.ParsePrivateKey(privateKey) - if err != nil { - return nil, err - } - - config := &ssh.ClientConfig{ - User: user, - Timeout: 5 * time.Second, - Auth: []ssh.AuthMethod{ - ssh.PublicKeys(signer), - }, - HostKeyCallback: ssh.HostKeyCallback(func(hostname string, remote net.Addr, key ssh.PublicKey) error { return nil }), - } - - return Dial("tcp", addr, config) -} - -// Dial connects to the remote SSH server. -func Dial(network, addr string, config *ssh.ClientConfig) (*Client, error) { - client, err := ssh.Dial(network, addr, config) - if err != nil { - return nil, err - } - return &Client{ - client: client, - }, nil -} diff --git a/client/ssh/client/client.go b/client/ssh/client/client.go new file mode 100644 index 000000000..ebf8eb794 --- /dev/null +++ b/client/ssh/client/client.go @@ -0,0 +1,705 @@ +package client + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" + "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" + "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" + "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" +) + +// Client wraps crypto/ssh Client for simplified SSH operations +type Client struct { + client *ssh.Client + terminalState *term.State + terminalFd int + + windowsStdoutMode uint32 // nolint:unused + windowsStdinMode uint32 // nolint:unused +} + +func (c *Client) Close() error { + return c.client.Close() +} + +func (c *Client) OpenTerminal(ctx context.Context) error { + session, err := c.client.NewSession() + if err != nil { + return fmt.Errorf("new session: %w", err) + } + defer func() { + if err := session.Close(); err != nil { + log.Debugf("session close error: %v", err) + } + }() + + if err := c.setupTerminalMode(ctx, session); err != nil { + return err + } + + c.setupSessionIO(session) + + if err := session.Shell(); err != nil { + return fmt.Errorf("start shell: %w", err) + } + + return c.waitForSession(ctx, session) +} + +// setupSessionIO connects session streams to local terminal +func (c *Client) setupSessionIO(session *ssh.Session) { + session.Stdout = os.Stdout + session.Stderr = os.Stderr + session.Stdin = os.Stdin +} + +// waitForSession waits for the session to complete with context cancellation +func (c *Client) waitForSession(ctx context.Context, session *ssh.Session) error { + done := make(chan error, 1) + go func() { + done <- session.Wait() + }() + + defer c.restoreTerminal() + + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-done: + return c.handleSessionError(err) + } +} + +// handleSessionError processes session termination errors +func (c *Client) handleSessionError(err error) error { + if err == nil { + return nil + } + + var e *ssh.ExitError + var em *ssh.ExitMissingError + if !errors.As(err, &e) && !errors.As(err, &em) { + return fmt.Errorf("session wait: %w", err) + } + + return nil +} + +// restoreTerminal restores the terminal to its original state +func (c *Client) restoreTerminal() { + if c.terminalState != nil { + _ = term.Restore(c.terminalFd, c.terminalState) + c.terminalState = nil + c.terminalFd = 0 + } + + if err := c.restoreWindowsConsoleState(); err != nil { + log.Debugf("restore Windows console state: %v", err) + } +} + +// ExecuteCommand executes a command on the remote host and returns the output +func (c *Client) ExecuteCommand(ctx context.Context, command string) ([]byte, error) { + session, cleanup, err := c.createSession(ctx) + if err != nil { + return nil, err + } + defer cleanup() + + output, err := session.CombinedOutput(command) + if err != nil { + var e *ssh.ExitError + var em *ssh.ExitMissingError + if !errors.As(err, &e) && !errors.As(err, &em) { + return output, fmt.Errorf("execute command: %w", err) + } + } + + return output, nil +} + +// ExecuteCommandWithIO executes a command with interactive I/O connected to local terminal +func (c *Client) ExecuteCommandWithIO(ctx context.Context, command string) error { + session, cleanup, err := c.createSession(ctx) + if err != nil { + return fmt.Errorf("create session: %w", err) + } + defer cleanup() + + c.setupSessionIO(session) + + if err := session.Start(command); err != nil { + return fmt.Errorf("start command: %w", err) + } + + done := make(chan error, 1) + go func() { + done <- session.Wait() + }() + + select { + case <-ctx.Done(): + _ = session.Signal(ssh.SIGTERM) + select { + case <-done: + return ctx.Err() + case <-time.After(100 * time.Millisecond): + return ctx.Err() + } + case err := <-done: + return c.handleCommandError(err) + } +} + +// ExecuteCommandWithPTY executes a command with a pseudo-terminal for interactive sessions +func (c *Client) ExecuteCommandWithPTY(ctx context.Context, command string) error { + session, cleanup, err := c.createSession(ctx) + if err != nil { + return fmt.Errorf("create session: %w", err) + } + defer cleanup() + + if err := c.setupTerminalMode(ctx, session); err != nil { + return fmt.Errorf("setup terminal mode: %w", err) + } + + c.setupSessionIO(session) + + if err := session.Start(command); err != nil { + return fmt.Errorf("start command: %w", err) + } + + defer c.restoreTerminal() + + done := make(chan error, 1) + go func() { + done <- session.Wait() + }() + + select { + case <-ctx.Done(): + _ = session.Signal(ssh.SIGTERM) + select { + case <-done: + return ctx.Err() + case <-time.After(100 * time.Millisecond): + return ctx.Err() + } + case err := <-done: + return c.handleCommandError(err) + } +} + +// handleCommandError processes command execution errors +func (c *Client) handleCommandError(err error) error { + if err == nil { + return nil + } + + var e *ssh.ExitError + var em *ssh.ExitMissingError + if errors.As(err, &e) || errors.As(err, &em) { + return err + } + + return fmt.Errorf("execute command: %w", err) +} + +// setupContextCancellation sets up context cancellation for a session +func (c *Client) setupContextCancellation(ctx context.Context, session *ssh.Session) func() { + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + _ = session.Signal(ssh.SIGTERM) + _ = session.Close() + case <-done: + } + }() + return func() { close(done) } +} + +// createSession creates a new SSH session with context cancellation setup +func (c *Client) createSession(ctx context.Context) (*ssh.Session, func(), error) { + session, err := c.client.NewSession() + if err != nil { + return nil, nil, fmt.Errorf("new session: %w", err) + } + + cancel := c.setupContextCancellation(ctx, session) + cleanup := func() { + cancel() + _ = session.Close() + } + + return session, cleanup, nil +} + +// getDefaultDaemonAddr returns the daemon address from environment or default for the OS +func getDefaultDaemonAddr() string { + if addr := os.Getenv("NB_DAEMON_ADDR"); addr != "" { + return addr + } + if runtime.GOOS == "windows" { + return DefaultDaemonAddrWindows + } + return daemonaddr.ResolveUnixDaemonAddr(DefaultDaemonAddr) +} + +// DialOptions contains options for SSH connections +type DialOptions struct { + KnownHostsFile string + IdentityFile string + DaemonAddr string + SkipCachedToken bool + InsecureSkipVerify bool + NoBrowser bool +} + +// Dial connects to the given ssh server with specified options +func Dial(ctx context.Context, addr, user string, opts DialOptions) (*Client, error) { + daemonAddr := opts.DaemonAddr + if daemonAddr == "" { + daemonAddr = getDefaultDaemonAddr() + } + opts.DaemonAddr = daemonAddr + + hostKeyCallback, err := createHostKeyCallback(opts) + if err != nil { + return nil, fmt.Errorf("create host key callback: %w", err) + } + + config := &ssh.ClientConfig{ + User: user, + Timeout: 30 * time.Second, + HostKeyCallback: hostKeyCallback, + } + + if opts.IdentityFile != "" { + authMethod, err := createSSHKeyAuth(opts.IdentityFile) + if err != nil { + return nil, fmt.Errorf("create SSH key auth: %w", err) + } + config.Auth = append(config.Auth, authMethod) + } + + return dialWithJWT(ctx, "tcp", addr, config, daemonAddr, opts.SkipCachedToken, opts.NoBrowser) +} + +// dialSSH establishes an SSH connection without JWT authentication +func dialSSH(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*Client, error) { + 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) + 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) + } + + client := ssh.NewClient(clientConn, chans, reqs) + return &Client{ + client: client, + }, nil +} + +// dialWithJWT establishes an SSH connection with optional JWT authentication based on server detection +func dialWithJWT(ctx context.Context, network, addr string, config *ssh.ClientConfig, daemonAddr string, skipCache, noBrowser bool) (*Client, error) { + host, portStr, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("parse address %s: %w", addr, err) + } + port, err := strconv.Atoi(portStr) + if err != nil { + return nil, fmt.Errorf("parse port %s: %w", portStr, err) + } + + detectionCtx, cancel := context.WithTimeout(ctx, config.Timeout) + defer cancel() + + dialer := &net.Dialer{} + serverType, err := detection.DetectSSHServerType(detectionCtx, dialer, host, port) + if err != nil { + return nil, fmt.Errorf("SSH server detection: %w", err) + } + + if !serverType.RequiresJWT() { + return dialSSH(ctx, network, addr, config) + } + + jwtCtx, cancel := context.WithTimeout(ctx, config.Timeout) + defer cancel() + + jwtToken, err := requestJWTToken(jwtCtx, daemonAddr, skipCache, noBrowser) + if err != nil { + return nil, fmt.Errorf("request JWT token: %w", err) + } + + configWithJWT := nbssh.AddJWTAuth(config, jwtToken) + return dialSSH(ctx, network, addr, configWithJWT) +} + +// requestJWTToken requests a JWT token from the NetBird daemon +func requestJWTToken(ctx context.Context, daemonAddr string, skipCache, noBrowser bool) (string, error) { + hint := profilemanager.GetLoginHint() + + conn, err := connectToDaemon(daemonAddr) + if err != nil { + return "", fmt.Errorf("connect to daemon: %w", err) + } + defer conn.Close() + + client := proto.NewDaemonServiceClient(conn) + + var browserOpener func(string) error + if !noBrowser { + browserOpener = util.OpenBrowser + } + + return nbssh.RequestJWTToken(ctx, client, os.Stdout, os.Stderr, !skipCache, hint, browserOpener) +} + +// verifyHostKeyViaDaemon verifies SSH host key by querying the NetBird daemon +func verifyHostKeyViaDaemon(hostname string, remote net.Addr, key ssh.PublicKey, daemonAddr string) error { + conn, err := connectToDaemon(daemonAddr) + if err != nil { + return err + } + defer func() { + if err := conn.Close(); err != nil { + log.Debugf("daemon connection close error: %v", err) + } + }() + + client := proto.NewDaemonServiceClient(conn) + verifier := nbssh.NewDaemonHostKeyVerifier(client) + callback := nbssh.CreateHostKeyCallback(verifier) + return callback(hostname, remote, key) +} + +func connectToDaemon(daemonAddr string) (*grpc.ClientConn, error) { + addr := strings.TrimPrefix(daemonAddr, "tcp://") + + conn, err := grpc.NewClient( + addr, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + 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) + } + + return conn, nil +} + +// getKnownHostsFiles returns paths to known_hosts files in order of preference +func getKnownHostsFiles() []string { + var files []string + + // User's known_hosts file (highest priority) + if homeDir, err := os.UserHomeDir(); err == nil { + userKnownHosts := filepath.Join(homeDir, ".ssh", "known_hosts") + files = append(files, userKnownHosts) + } + + // NetBird managed known_hosts files + if runtime.GOOS == "windows" { + programData := os.Getenv("PROGRAMDATA") + if programData == "" { + programData = `C:\ProgramData` + } + netbirdKnownHosts := filepath.Join(programData, "ssh", "ssh_known_hosts.d", "99-netbird") + files = append(files, netbirdKnownHosts) + } else { + files = append(files, "/etc/ssh/ssh_known_hosts.d/99-netbird") + files = append(files, "/etc/ssh/ssh_known_hosts") + } + + return files +} + +// createHostKeyCallback creates a host key verification callback +func createHostKeyCallback(opts DialOptions) (ssh.HostKeyCallback, error) { + if opts.InsecureSkipVerify { + return ssh.InsecureIgnoreHostKey(), nil // #nosec G106 - User explicitly requested insecure mode + } + + return func(hostname string, remote net.Addr, key ssh.PublicKey) error { + if err := tryDaemonVerification(hostname, remote, key, opts.DaemonAddr); err == nil { + return nil + } + return tryKnownHostsVerification(hostname, remote, key, opts.KnownHostsFile) + }, nil +} + +func tryDaemonVerification(hostname string, remote net.Addr, key ssh.PublicKey, daemonAddr string) error { + if daemonAddr == "" { + return fmt.Errorf("no daemon address") + } + return verifyHostKeyViaDaemon(hostname, remote, key, daemonAddr) +} + +func tryKnownHostsVerification(hostname string, remote net.Addr, key ssh.PublicKey, knownHostsFile string) error { + knownHostsFiles := getKnownHostsFilesList(knownHostsFile) + hostKeyCallbacks := buildHostKeyCallbacks(knownHostsFiles) + + for _, callback := range hostKeyCallbacks { + if err := callback(hostname, remote, key); err == nil { + return nil + } + } + return fmt.Errorf("host key verification failed: key for %s not found in any known_hosts file", hostname) +} + +func getKnownHostsFilesList(knownHostsFile string) []string { + if knownHostsFile != "" { + return []string{knownHostsFile} + } + return getKnownHostsFiles() +} + +func buildHostKeyCallbacks(knownHostsFiles []string) []ssh.HostKeyCallback { + var hostKeyCallbacks []ssh.HostKeyCallback + for _, file := range knownHostsFiles { + if callback, err := knownhosts.New(file); err == nil { + hostKeyCallbacks = append(hostKeyCallbacks, callback) + } + } + return hostKeyCallbacks +} + +// createSSHKeyAuth creates SSH key authentication from a private key file +func createSSHKeyAuth(keyFile string) (ssh.AuthMethod, error) { + keyData, err := os.ReadFile(keyFile) + if err != nil { + return nil, fmt.Errorf("read SSH key file %s: %w", keyFile, err) + } + + signer, err := ssh.ParsePrivateKey(keyData) + if err != nil { + return nil, fmt.Errorf("parse SSH private key: %w", err) + } + + return ssh.PublicKeys(signer), nil +} + +// LocalPortForward sets up local port forwarding, binding to localAddr and forwarding to remoteAddr +func (c *Client) LocalPortForward(ctx context.Context, localAddr, remoteAddr string) error { + localListener, err := net.Listen("tcp", localAddr) + if err != nil { + return fmt.Errorf("listen on %s: %w", localAddr, err) + } + + go func() { + defer func() { + if err := localListener.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + log.Debugf("local listener close error: %v", err) + } + }() + for { + localConn, err := localListener.Accept() + if err != nil { + if ctx.Err() != nil { + return + } + continue + } + + go c.handleLocalForward(ctx, localConn, remoteAddr) + } + }() + + <-ctx.Done() + if err := localListener.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + log.Debugf("local listener close error: %v", err) + } + return ctx.Err() +} + +// handleLocalForward handles a single local port forwarding connection +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) + } + }() + + channel, err := c.client.Dial("tcp", remoteAddr) + if err != nil { + var openErr *ssh.OpenChannelError + if errors.As(err, &openErr) && openErr.Reason == ssh.Prohibited { + _, _ = fmt.Fprintf(os.Stderr, "channel open failed: port forwarding is disabled\n") + } else { + log.Debugf("local port forwarding to %s failed: %v", remoteAddr, err) + } + return + } + defer func() { + if err := channel.Close(); err != nil { + log.Debugf("local port forwarding: close remote channel: %v", err) + } + }() + + 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 +func (c *Client) RemotePortForward(ctx context.Context, remoteAddr, localAddr string) error { + host, port, err := c.parseRemoteAddress(remoteAddr) + if err != nil { + return fmt.Errorf("parse remote address: %w", err) + } + + req := c.buildTCPIPForwardRequest(host, port) + if err := c.sendTCPIPForwardRequest(req); err != nil { + return fmt.Errorf("setup remote forward: %w", err) + } + + go c.handleRemoteForwardChannels(ctx, localAddr) + + <-ctx.Done() + + if err := c.cancelTCPIPForwardRequest(req); err != nil { + return fmt.Errorf("cancel tcpip-forward: %w", err) + } + return ctx.Err() +} + +// parseRemoteAddress parses host and port from remote address string +func (c *Client) parseRemoteAddress(remoteAddr string) (string, uint32, error) { + host, portStr, err := net.SplitHostPort(remoteAddr) + if err != nil { + return "", 0, fmt.Errorf("parse remote address %s: %w", remoteAddr, err) + } + + port, err := strconv.Atoi(portStr) + if err != nil { + return "", 0, fmt.Errorf("parse remote port %s: %w", portStr, err) + } + + return host, uint32(port), nil +} + +// buildTCPIPForwardRequest creates a tcpip-forward request message +func (c *Client) buildTCPIPForwardRequest(host string, port uint32) tcpipForwardMsg { + return tcpipForwardMsg{ + Host: host, + Port: port, + } +} + +// sendTCPIPForwardRequest sends the tcpip-forward request to establish remote port forwarding +func (c *Client) sendTCPIPForwardRequest(req tcpipForwardMsg) error { + ok, _, err := c.client.SendRequest("tcpip-forward", true, ssh.Marshal(&req)) + if err != nil { + return fmt.Errorf("send tcpip-forward request: %w", err) + } + if !ok { + return fmt.Errorf("remote port forwarding denied by server") + } + return nil +} + +// cancelTCPIPForwardRequest cancels the tcpip-forward request +func (c *Client) cancelTCPIPForwardRequest(req tcpipForwardMsg) error { + _, _, err := c.client.SendRequest("cancel-tcpip-forward", true, ssh.Marshal(&req)) + if err != nil { + return fmt.Errorf("send cancel-tcpip-forward request: %w", err) + } + return nil +} + +// handleRemoteForwardChannels handles incoming forwarded-tcpip channels +func (c *Client) handleRemoteForwardChannels(ctx context.Context, localAddr string) { + // Get the channel once - subsequent calls return nil! + channelRequests := c.client.HandleChannelOpen("forwarded-tcpip") + if channelRequests == nil { + log.Debugf("forwarded-tcpip channel type already being handled") + return + } + + for { + select { + case <-ctx.Done(): + return + case newChan, ok := <-channelRequests: + if !ok { + return + } + if newChan != nil { + go c.handleRemoteForwardChannel(ctx, newChan, localAddr) + } + } + } +} + +// handleRemoteForwardChannel handles a single forwarded-tcpip channel +func (c *Client) handleRemoteForwardChannel(ctx context.Context, newChan ssh.NewChannel, localAddr string) { + channel, reqs, err := newChan.Accept() + if err != nil { + return + } + defer func() { + if err := channel.Close(); err != nil { + log.Debugf("remote port forwarding: close remote channel: %v", err) + } + }() + + go ssh.DiscardRequests(reqs) + + // 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() { + if err := localConn.Close(); err != nil { + log.Debugf("remote port forwarding: close local connection: %v", err) + } + }() + + netrelay.Relay(ctx, localConn, channel, netrelay.Options{Logger: log.NewEntry(log.StandardLogger())}) +} + +// tcpipForwardMsg represents the structure for tcpip-forward requests +type tcpipForwardMsg struct { + Host string + Port uint32 +} 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 new file mode 100644 index 000000000..191362940 --- /dev/null +++ b/client/ssh/client/client_test.go @@ -0,0 +1,411 @@ +package client + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "os" + "os/user" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/ssh" + sshserver "github.com/netbirdio/netbird/client/ssh/server" + "github.com/netbirdio/netbird/client/ssh/testutil" +) + +// TestMain handles package-level setup and cleanup +func TestMain(m *testing.M) { + // Guard against infinite recursion when test binary is called as "netbird ssh exec" + // This happens when running tests as non-privileged user with fallback + if len(os.Args) > 2 && os.Args[1] == "ssh" && os.Args[2] == "exec" { + // Just exit with error to break the recursion + fmt.Fprintf(os.Stderr, "Test binary called as 'ssh exec' - preventing infinite recursion\n") + os.Exit(1) + } + + // Run tests + code := m.Run() + + // Cleanup any created test users + testutil.CleanupTestUsers() + + os.Exit(code) +} + +func TestSSHClient_DialWithKey(t *testing.T) { + // Generate host key for server + hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519) + require.NoError(t, err) + + // Create and start server + serverConfig := &sshserver.Config{ + HostKeyPEM: hostKey, + JWT: nil, + } + server := sshserver.New(serverConfig) + server.SetAllowRootLogin(true) // Allow root/admin login for tests + + serverAddr := sshserver.StartTestServer(t, server) + defer func() { + err := server.Stop() + require.NoError(t, err) + }() + + // Test Dial + 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() { + err := client.Close() + assert.NoError(t, err) + }() + + // Verify client is connected + assert.NotNil(t, client.client) +} + +func TestSSHClient_ConnectionHandling(t *testing.T) { + server, serverAddr, _ := setupTestSSHServerAndClient(t) + defer func() { + err := server.Stop() + require.NoError(t, err) + }() + + // Generate client key for multiple connections + + const numClients = 3 + clients := make([]*Client, numClients) + + currentUser := testutil.GetTestUsername(t) + for i := 0; i < numClients; i++ { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + client, err := Dial(ctx, serverAddr, currentUser, DialOptions{ + InsecureSkipVerify: true, + }) + cancel() + require.NoError(t, err, "Client %d should connect successfully", i) + clients[i] = client + } + + for i, client := range clients { + err := client.Close() + assert.NoError(t, err, "Client %d should close without error", i) + } +} + +func TestSSHClient_NoAuthMode(t *testing.T) { + hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519) + require.NoError(t, err) + + serverConfig := &sshserver.Config{ + HostKeyPEM: hostKey, + JWT: nil, + } + server := sshserver.New(serverConfig) + server.SetAllowRootLogin(true) // Allow root/admin login for tests + + serverAddr := sshserver.StartTestServer(t, server) + defer func() { + err := server.Stop() + require.NoError(t, err) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + currentUser := testutil.GetTestUsername(t) + + t.Run("any key succeeds in no-auth mode", func(t *testing.T) { + client, err := Dial(ctx, serverAddr, currentUser, DialOptions{ + InsecureSkipVerify: true, + }) + assert.NoError(t, err) + if client != nil { + require.NoError(t, client.Close(), "Client should close without error") + } + }) +} + +func TestSSHClient_TerminalState(t *testing.T) { + server, _, client := setupTestSSHServerAndClient(t) + defer func() { + err := server.Stop() + require.NoError(t, err) + }() + defer func() { + err := client.Close() + assert.NoError(t, err) + }() + + assert.Nil(t, client.terminalState) + assert.Equal(t, 0, client.terminalFd) + + client.restoreTerminal() + assert.Nil(t, client.terminalState) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + err := client.OpenTerminal(ctx) + // In test environment without a real terminal, this may complete quickly or timeout + // Both behaviors are acceptable for testing terminal state management + if err != nil { + if runtime.GOOS == "windows" { + assert.True(t, + strings.Contains(err.Error(), "context deadline exceeded") || + strings.Contains(err.Error(), "console"), + "Should timeout or have console error on Windows") + } else { + // On Unix systems in test environment, we may get various errors + // including timeouts or terminal-related errors + assert.True(t, + strings.Contains(err.Error(), "context deadline exceeded") || + strings.Contains(err.Error(), "terminal") || + strings.Contains(err.Error(), "pty"), + "Expected timeout or terminal-related error, got: %v", err) + } + } +} + +func setupTestSSHServerAndClient(t *testing.T) (*sshserver.Server, string, *Client) { + hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519) + require.NoError(t, err) + + serverConfig := &sshserver.Config{ + HostKeyPEM: hostKey, + JWT: nil, + } + server := sshserver.New(serverConfig) + server.SetAllowRootLogin(true) // Allow root/admin login for tests + + serverAddr := sshserver.StartTestServer(t, server) + + 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) + + return server, serverAddr, client +} + +func TestSSHClient_PortForwarding(t *testing.T) { + server, _, client := setupTestSSHServerAndClient(t) + defer func() { + err := server.Stop() + require.NoError(t, err) + }() + defer func() { + err := client.Close() + assert.NoError(t, err) + }() + + t.Run("local forwarding times out gracefully", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + err := client.LocalPortForward(ctx, "127.0.0.1:0", "127.0.0.1:8080") + assert.Error(t, err) + assert.True(t, + errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, context.Canceled) || + strings.Contains(err.Error(), "connection"), + "Expected context or connection error") + }) + + t.Run("remote forwarding denied", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + err := client.RemotePortForward(ctx, "127.0.0.1:0", "127.0.0.1:8080") + assert.Error(t, err) + assert.True(t, + strings.Contains(err.Error(), "denied") || + strings.Contains(err.Error(), "disabled"), + "Should be denied by default") + }) + + t.Run("invalid addresses fail", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + err := client.LocalPortForward(ctx, "invalid:address", "127.0.0.1:8080") + assert.Error(t, err) + + err = client.LocalPortForward(ctx, "127.0.0.1:0", "invalid:address") + assert.Error(t, err) + }) +} + +func TestSSHClient_PortForwardingDataTransfer(t *testing.T) { + if testing.Short() { + t.Skip("Skipping data transfer test in short mode") + } + + hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519) + require.NoError(t, err) + + serverConfig := &sshserver.Config{ + HostKeyPEM: hostKey, + JWT: nil, + } + server := sshserver.New(serverConfig) + server.SetAllowLocalPortForwarding(true) + server.SetAllowRootLogin(true) // Allow root/admin login for tests + + serverAddr := sshserver.StartTestServer(t, server) + defer func() { + err := server.Stop() + require.NoError(t, err) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Port forwarding requires the actual current user, not test user + realUser, err := getRealCurrentUser() + require.NoError(t, err) + + // Skip if running as system account that can't do port forwarding + if testutil.IsSystemAccount(realUser) { + t.Skipf("Skipping port forwarding test - running as system account: %s", realUser) + } + + client, err := Dial(ctx, serverAddr, realUser, DialOptions{ + InsecureSkipVerify: true, // Skip host key verification for test + }) + require.NoError(t, err) + defer func() { + if err := client.Close(); err != nil { + t.Logf("client close error: %v", err) + } + }() + + testServer, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer func() { + if err := testServer.Close(); err != nil { + t.Logf("test server close error: %v", err) + } + }() + + testServerAddr := testServer.Addr().String() + expectedResponse := "Hello, World!" + + go func() { + for { + conn, err := testServer.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer func() { + if err := c.Close(); err != nil { + t.Logf("connection close error: %v", err) + } + }() + buf := make([]byte, 1024) + if _, err := c.Read(buf); err != nil { + t.Logf("connection read error: %v", err) + return + } + if _, err := c.Write([]byte(expectedResponse)); err != nil { + t.Logf("connection write error: %v", err) + } + }(conn) + } + }() + + localListener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + localAddr := localListener.Addr().String() + if err := localListener.Close(); err != nil { + t.Logf("local listener close error: %v", err) + } + + ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + go func() { + err := client.LocalPortForward(ctx, localAddr, testServerAddr) + if err != nil && !errors.Is(err, context.Canceled) { + if isWindowsPrivilegeError(err) { + t.Logf("Port forward failed due to Windows privilege restrictions: %v", err) + } else { + t.Logf("Port forward error: %v", err) + } + } + }() + + time.Sleep(100 * time.Millisecond) + + conn, err := net.DialTimeout("tcp", localAddr, 2*time.Second) + require.NoError(t, err) + defer func() { + if err := conn.Close(); err != nil { + t.Logf("connection close error: %v", err) + } + }() + + _, err = conn.Write([]byte("test")) + require.NoError(t, err) + + if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Logf("set read deadline error: %v", err) + } + response := make([]byte, len(expectedResponse)) + n, err := io.ReadFull(conn, response) + require.NoError(t, err) + assert.Equal(t, len(expectedResponse), n) + assert.Equal(t, expectedResponse, string(response)) +} + +// getRealCurrentUser returns the actual current user (not test user) for features like port forwarding +func getRealCurrentUser() (string, error) { + if runtime.GOOS == "windows" { + if currentUser, err := user.Current(); err == nil { + return currentUser.Username, nil + } + } + + if username := os.Getenv("USER"); username != "" { + return username, nil + } + + if currentUser, err := user.Current(); err == nil { + return currentUser.Username, nil + } + + return "", fmt.Errorf("unable to determine current user") +} + +// isWindowsPrivilegeError checks if an error is related to Windows privilege restrictions +func isWindowsPrivilegeError(err error) bool { + if err == nil { + return false + } + + errStr := strings.ToLower(err.Error()) + return strings.Contains(errStr, "ntstatus=0xc0000062") || // STATUS_PRIVILEGE_NOT_HELD + strings.Contains(errStr, "0xc0000041") || // STATUS_PRIVILEGE_NOT_HELD (LsaRegisterLogonProcess) + strings.Contains(errStr, "0xc0000062") || // STATUS_PRIVILEGE_NOT_HELD (LsaLogonUser) + strings.Contains(errStr, "privilege") || + strings.Contains(errStr, "access denied") || + strings.Contains(errStr, "user authentication failed") +} diff --git a/client/ssh/client/terminal_unix.go b/client/ssh/client/terminal_unix.go new file mode 100644 index 000000000..aaa3418f9 --- /dev/null +++ b/client/ssh/client/terminal_unix.go @@ -0,0 +1,127 @@ +//go:build !windows + +package client + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" + "golang.org/x/term" +) + +func (c *Client) setupTerminalMode(ctx context.Context, session *ssh.Session) error { + stdinFd := int(os.Stdin.Fd()) + + if !term.IsTerminal(stdinFd) { + return c.setupNonTerminalMode(ctx, session) + } + + fd := int(os.Stdin.Fd()) + + state, err := term.MakeRaw(fd) + if err != nil { + return c.setupNonTerminalMode(ctx, session) + } + + if err := c.setupTerminal(session, fd); err != nil { + if restoreErr := term.Restore(fd, state); restoreErr != nil { + log.Debugf("restore terminal state: %v", restoreErr) + } + return err + } + + c.terminalState = state + c.terminalFd = fd + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) + + go func() { + defer signal.Stop(sigChan) + select { + case <-ctx.Done(): + if err := term.Restore(fd, state); err != nil { + log.Debugf("restore terminal state: %v", err) + } + case sig := <-sigChan: + if err := term.Restore(fd, state); err != nil { + log.Debugf("restore terminal state: %v", err) + } + signal.Reset(sig) + s, ok := sig.(syscall.Signal) + if !ok { + log.Debugf("signal %v is not a syscall.Signal: %T", sig, sig) + return + } + if err := syscall.Kill(syscall.Getpid(), s); err != nil { + log.Debugf("kill process with signal %v: %v", s, err) + } + } + }() + + return nil +} + +func (c *Client) setupNonTerminalMode(_ context.Context, session *ssh.Session) error { + return nil +} + +// restoreWindowsConsoleState is a no-op on Unix systems +func (c *Client) restoreWindowsConsoleState() error { + return nil +} + +func (c *Client) setupTerminal(session *ssh.Session, fd int) error { + w, h, err := term.GetSize(fd) + if err != nil { + 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, + } + + terminal := os.Getenv("TERM") + if terminal == "" { + terminal = "xterm-256color" + } + + if err := session.RequestPty(terminal, h, w, modes); err != nil { + return fmt.Errorf("request pty: %w", err) + } + + return nil +} diff --git a/client/ssh/client/terminal_windows.go b/client/ssh/client/terminal_windows.go new file mode 100644 index 000000000..462438317 --- /dev/null +++ b/client/ssh/client/terminal_windows.go @@ -0,0 +1,265 @@ +package client + +import ( + "context" + "errors" + "fmt" + "os" + "syscall" + "unsafe" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" +) + +const ( + enableProcessedInput = 0x0001 + enableLineInput = 0x0002 + enableEchoInput = 0x0004 // Input mode: ENABLE_ECHO_INPUT + enableVirtualTerminalProcessing = 0x0004 // Output mode: ENABLE_VIRTUAL_TERMINAL_PROCESSING (same value, different mode) + enableVirtualTerminalInput = 0x0200 +) + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procGetConsoleMode = kernel32.NewProc("GetConsoleMode") + procSetConsoleMode = kernel32.NewProc("SetConsoleMode") + procGetConsoleScreenBufferInfo = kernel32.NewProc("GetConsoleScreenBufferInfo") +) + +// ConsoleUnavailableError indicates that Windows console handles are not available +// (e.g., in CI environments where stdout/stdin are redirected) +type ConsoleUnavailableError struct { + Operation string + Err error +} + +func (e *ConsoleUnavailableError) Error() string { + return fmt.Sprintf("console unavailable for %s: %v", e.Operation, e.Err) +} + +func (e *ConsoleUnavailableError) Unwrap() error { + return e.Err +} + +type coord struct { + x, y int16 +} + +type smallRect struct { + left, top, right, bottom int16 +} + +type consoleScreenBufferInfo struct { + size coord + cursorPosition coord + attributes uint16 + window smallRect + maximumWindowSize coord +} + +func (c *Client) setupTerminalMode(_ context.Context, session *ssh.Session) error { + if err := c.saveWindowsConsoleState(); err != nil { + var consoleErr *ConsoleUnavailableError + if errors.As(err, &consoleErr) { + log.Debugf("console unavailable, not requesting PTY: %v", err) + return nil + } + return fmt.Errorf("save console state: %w", err) + } + + if err := c.enableWindowsVirtualTerminal(); err != nil { + var consoleErr *ConsoleUnavailableError + if errors.As(err, &consoleErr) { + log.Debugf("virtual terminal unavailable: %v", err) + } else { + return fmt.Errorf("failed to enable virtual terminal: %w", err) + } + } + + 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 + } + + if err := session.RequestPty("xterm-256color", h, w, modes); err != nil { + if restoreErr := c.restoreWindowsConsoleState(); restoreErr != nil { + log.Debugf("restore Windows console state: %v", restoreErr) + } + return fmt.Errorf("request pty: %w", err) + } + + return nil +} + +func (c *Client) saveWindowsConsoleState() error { + defer func() { + if r := recover(); r != nil { + log.Debugf("panic in saveWindowsConsoleState: %v", r) + } + }() + + stdout := syscall.Handle(os.Stdout.Fd()) + stdin := syscall.Handle(os.Stdin.Fd()) + + var stdoutMode, stdinMode uint32 + + ret, _, err := procGetConsoleMode.Call(uintptr(stdout), uintptr(unsafe.Pointer(&stdoutMode))) + if ret == 0 { + log.Debugf("failed to get stdout console mode: %v", err) + return &ConsoleUnavailableError{ + Operation: "get stdout console mode", + Err: err, + } + } + + ret, _, err = procGetConsoleMode.Call(uintptr(stdin), uintptr(unsafe.Pointer(&stdinMode))) + if ret == 0 { + log.Debugf("failed to get stdin console mode: %v", err) + return &ConsoleUnavailableError{ + Operation: "get stdin console mode", + Err: err, + } + } + + c.terminalFd = 1 + c.windowsStdoutMode = stdoutMode + c.windowsStdinMode = stdinMode + + log.Debugf("saved Windows console state - stdout: 0x%04x, stdin: 0x%04x", stdoutMode, stdinMode) + return nil +} + +func (c *Client) enableWindowsVirtualTerminal() (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("panic in enableWindowsVirtualTerminal: %v", r) + } + }() + + stdout := syscall.Handle(os.Stdout.Fd()) + stdin := syscall.Handle(os.Stdin.Fd()) + var mode uint32 + + ret, _, winErr := procGetConsoleMode.Call(uintptr(stdout), uintptr(unsafe.Pointer(&mode))) + if ret == 0 { + return &ConsoleUnavailableError{ + Operation: "get stdout console mode for VT", + Err: winErr, + } + } + + mode |= enableVirtualTerminalProcessing + ret, _, winErr = procSetConsoleMode.Call(uintptr(stdout), uintptr(mode)) + if ret == 0 { + return &ConsoleUnavailableError{ + Operation: "enable virtual terminal processing", + Err: winErr, + } + } + + ret, _, winErr = procGetConsoleMode.Call(uintptr(stdin), uintptr(unsafe.Pointer(&mode))) + if ret == 0 { + return &ConsoleUnavailableError{ + Operation: "get stdin console mode for VT", + Err: winErr, + } + } + + mode &= ^uint32(enableLineInput | enableEchoInput | enableProcessedInput) + mode |= enableVirtualTerminalInput + ret, _, winErr = procSetConsoleMode.Call(uintptr(stdin), uintptr(mode)) + if ret == 0 { + return &ConsoleUnavailableError{ + Operation: "set stdin raw mode", + Err: winErr, + } + } + + log.Debugf("enabled Windows virtual terminal processing") + return nil +} + +func (c *Client) getWindowsConsoleSize() (int, int) { + defer func() { + if r := recover(); r != nil { + log.Debugf("panic in getWindowsConsoleSize: %v", r) + } + }() + + stdout := syscall.Handle(os.Stdout.Fd()) + var csbi consoleScreenBufferInfo + + ret, _, err := procGetConsoleScreenBufferInfo.Call(uintptr(stdout), uintptr(unsafe.Pointer(&csbi))) + if ret == 0 { + log.Debugf("failed to get console buffer info, using defaults: %v", err) + return 80, 24 + } + + width := int(csbi.window.right - csbi.window.left + 1) + height := int(csbi.window.bottom - csbi.window.top + 1) + + log.Debugf("Windows console size: %dx%d", width, height) + return width, height +} + +func (c *Client) restoreWindowsConsoleState() error { + var err error + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("panic in restoreWindowsConsoleState: %v", r) + } + }() + + if c.terminalFd != 1 { + return nil + } + + stdout := syscall.Handle(os.Stdout.Fd()) + stdin := syscall.Handle(os.Stdin.Fd()) + + ret, _, winErr := procSetConsoleMode.Call(uintptr(stdout), uintptr(c.windowsStdoutMode)) + if ret == 0 { + log.Debugf("failed to restore stdout console mode: %v", winErr) + if err == nil { + err = fmt.Errorf("restore stdout console mode: %w", winErr) + } + } + + ret, _, winErr = procSetConsoleMode.Call(uintptr(stdin), uintptr(c.windowsStdinMode)) + if ret == 0 { + log.Debugf("failed to restore stdin console mode: %v", winErr) + if err == nil { + err = fmt.Errorf("restore stdin console mode: %w", winErr) + } + } + + c.terminalFd = 0 + c.windowsStdoutMode = 0 + c.windowsStdinMode = 0 + + log.Debugf("restored Windows console state") + return err +} diff --git a/client/ssh/common.go b/client/ssh/common.go new file mode 100644 index 000000000..6574437b5 --- /dev/null +++ b/client/ssh/common.go @@ -0,0 +1,195 @@ +package ssh + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "time" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" + + "github.com/netbirdio/netbird/client/proto" +) + +const ( + NetBirdSSHConfigFile = "99-netbird.conf" + + UnixSSHConfigDir = "/etc/ssh/ssh_config.d" + WindowsSSHConfigDir = "ssh/ssh_config.d" +) + +var ( + // ErrPeerNotFound indicates the peer was not found in the network + ErrPeerNotFound = errors.New("peer not found in network") + // ErrNoStoredKey indicates the peer has no stored SSH host key + ErrNoStoredKey = errors.New("peer has no stored SSH host key") +) + +// HostKeyVerifier provides SSH host key verification +type HostKeyVerifier interface { + VerifySSHHostKey(peerAddress string, key []byte) error +} + +// DaemonHostKeyVerifier implements HostKeyVerifier using the NetBird daemon +type DaemonHostKeyVerifier struct { + client proto.DaemonServiceClient +} + +// NewDaemonHostKeyVerifier creates a new daemon-based host key verifier +func NewDaemonHostKeyVerifier(client proto.DaemonServiceClient) *DaemonHostKeyVerifier { + return &DaemonHostKeyVerifier{ + client: client, + } +} + +// VerifySSHHostKey verifies an SSH host key by querying the NetBird daemon +func (d *DaemonHostKeyVerifier) VerifySSHHostKey(peerAddress string, presentedKey []byte) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + response, err := d.client.GetPeerSSHHostKey(ctx, &proto.GetPeerSSHHostKeyRequest{ + PeerAddress: peerAddress, + }) + if err != nil { + return err + } + + if !response.GetFound() { + return ErrPeerNotFound + } + + storedKeyData := response.GetSshHostKey() + + return VerifyHostKey(storedKeyData, presentedKey, peerAddress) +} + +// printAuthInstructions prints authentication instructions to stderr +func printAuthInstructions(stderr io.Writer, authResponse *proto.RequestJWTAuthResponse, browserWillOpen bool) { + _, _ = fmt.Fprintln(stderr, "SSH authentication required.") + + if browserWillOpen { + _, _ = fmt.Fprintln(stderr, "Please do the SSO login in your browser.") + _, _ = fmt.Fprintln(stderr, "If your browser didn't open automatically, use this URL to log in:") + _, _ = fmt.Fprintln(stderr) + } + + _, _ = fmt.Fprintf(stderr, "%s\n", authResponse.VerificationURIComplete) + + if authResponse.UserCode != "" { + _, _ = fmt.Fprintf(stderr, "Or visit: %s and enter code: %s\n", authResponse.VerificationURI, authResponse.UserCode) + } + + if browserWillOpen { + _, _ = fmt.Fprintln(stderr) + } + + _, _ = fmt.Fprintln(stderr, "Waiting for authentication...") +} + +// 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{} + if hint != "" { + req.Hint = &hint + } + authResponse, err := client.RequestJWTAuth(ctx, req) + if err != nil { + return "", fmt.Errorf("request JWT auth: %w", err) + } + + if useCache && authResponse.CachedToken != "" { + log.Debug("Using cached authentication token") + return authResponse.CachedToken, nil + } + + if stderr != nil { + printAuthInstructions(stderr, authResponse, openBrowser != nil) + } + + if openBrowser != nil { + if err := openBrowser(authResponse.VerificationURIComplete); err != nil { + log.Debugf("open browser: %v", err) + } + } + + tokenResponse, err := client.WaitJWTToken(ctx, &proto.WaitJWTTokenRequest{ + DeviceCode: authResponse.DeviceCode, + UserCode: authResponse.UserCode, + }) + if err != nil { + return "", fmt.Errorf("wait for JWT token: %w", err) + } + + if stdout != nil { + _, _ = fmt.Fprintln(stdout, "Authentication successful!") + } + return tokenResponse.Token, nil +} + +// VerifyHostKey verifies an SSH host key against stored peer key data. +// Returns nil only if the presented key matches the stored key. +// Returns ErrNoStoredKey if storedKeyData is empty. +// Returns an error if the keys don't match or if parsing fails. +func VerifyHostKey(storedKeyData []byte, presentedKey []byte, peerAddress string) error { + if len(storedKeyData) == 0 { + return ErrNoStoredKey + } + + storedPubKey, _, _, _, err := ssh.ParseAuthorizedKey(storedKeyData) + if err != nil { + return fmt.Errorf("parse stored SSH key for %s: %w", peerAddress, err) + } + + if !bytes.Equal(presentedKey, storedPubKey.Marshal()) { + return fmt.Errorf("SSH host key mismatch for %s", peerAddress) + } + + return nil +} + +// AddJWTAuth prepends JWT password authentication to existing auth methods. +// This ensures JWT auth is tried first while preserving any existing auth methods. +func AddJWTAuth(config *ssh.ClientConfig, jwtToken string) *ssh.ClientConfig { + configWithJWT := *config + configWithJWT.Auth = append([]ssh.AuthMethod{ssh.Password(jwtToken)}, config.Auth...) + return &configWithJWT +} + +// CreateHostKeyCallback creates an SSH host key verification callback using the provided verifier. +// It tries multiple addresses (hostname, IP) for the peer before failing. +func CreateHostKeyCallback(verifier HostKeyVerifier) ssh.HostKeyCallback { + return func(hostname string, remote net.Addr, key ssh.PublicKey) error { + addresses := buildAddressList(hostname, remote) + presentedKey := key.Marshal() + + for _, addr := range addresses { + if err := verifier.VerifySSHHostKey(addr, presentedKey); err != nil { + if errors.Is(err, ErrPeerNotFound) { + // Try other addresses for this peer + continue + } + return err + } + // Verified + return nil + } + + return fmt.Errorf("SSH host key verification failed: peer %s not found in network", hostname) + } +} + +// buildAddressList creates a list of addresses to check for host key verification. +// It includes the original hostname and extracts the host part from the remote address if different. +func buildAddressList(hostname string, remote net.Addr) []string { + addresses := []string{hostname} + if host, _, err := net.SplitHostPort(remote.String()); err == nil { + if host != hostname { + addresses = append(addresses, host) + } + } + return addresses +} diff --git a/client/ssh/config/manager.go b/client/ssh/config/manager.go new file mode 100644 index 000000000..e15330739 --- /dev/null +++ b/client/ssh/config/manager.go @@ -0,0 +1,313 @@ +package config + +import ( + "context" + "fmt" + "net/netip" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + log "github.com/sirupsen/logrus" + + nbssh "github.com/netbirdio/netbird/client/ssh" + "github.com/netbirdio/netbird/shared/management/domain" +) + +const ( + EnvDisableSSHConfig = "NB_DISABLE_SSH_CONFIG" + + EnvForceSSHConfig = "NB_FORCE_SSH_CONFIG" + + MaxPeersForSSHConfig = 200 + + fileWriteTimeout = 2 * time.Second +) + +func isSSHConfigDisabled() bool { + value := os.Getenv(EnvDisableSSHConfig) + if value == "" { + return false + } + + disabled, err := strconv.ParseBool(value) + if err != nil { + return true + } + return disabled +} + +func isSSHConfigForced() bool { + value := os.Getenv(EnvForceSSHConfig) + if value == "" { + return false + } + + forced, err := strconv.ParseBool(value) + if err != nil { + return true + } + return forced +} + +// shouldGenerateSSHConfig checks if SSH config should be generated based on peer count +func shouldGenerateSSHConfig(peerCount int) bool { + if isSSHConfigDisabled() { + return false + } + + if isSSHConfigForced() { + return true + } + + return peerCount <= MaxPeersForSSHConfig +} + +// writeFileWithTimeout writes data to a file with a timeout +func writeFileWithTimeout(filename string, data []byte, perm os.FileMode) error { + ctx, cancel := context.WithTimeout(context.Background(), fileWriteTimeout) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- os.WriteFile(filename, data, perm) + }() + + select { + case err := <-done: + return err + case <-ctx.Done(): + return fmt.Errorf("file write timeout after %v: %s", fileWriteTimeout, filename) + } +} + +// Manager handles SSH client configuration for NetBird peers +type Manager struct { + sshConfigDir string + sshConfigFile string +} + +// PeerSSHInfo represents a peer's SSH configuration information +type PeerSSHInfo struct { + Hostname string + IP netip.Addr + IPv6 netip.Addr + FQDN string +} + +// New creates a new SSH config manager +func New() *Manager { + sshConfigDir := getSystemSSHConfigDir() + return &Manager{ + sshConfigDir: sshConfigDir, + sshConfigFile: nbssh.NetBirdSSHConfigFile, + } +} + +// getSystemSSHConfigDir returns platform-specific SSH configuration directory +func getSystemSSHConfigDir() string { + if runtime.GOOS == "windows" { + return getWindowsSSHConfigDir() + } + return nbssh.UnixSSHConfigDir +} + +func getWindowsSSHConfigDir() string { + programData := os.Getenv("PROGRAMDATA") + if programData == "" { + programData = `C:\ProgramData` + } + return filepath.Join(programData, nbssh.WindowsSSHConfigDir) +} + +// SetupSSHClientConfig creates SSH client configuration for NetBird peers +func (m *Manager) SetupSSHClientConfig(peers []PeerSSHInfo) error { + if !shouldGenerateSSHConfig(len(peers)) { + m.logSkipReason(len(peers)) + return nil + } + + sshConfig, err := m.buildSSHConfig(peers) + if err != nil { + return fmt.Errorf("build SSH config: %w", err) + } + return m.writeSSHConfig(sshConfig) +} + +func (m *Manager) logSkipReason(peerCount int) { + if isSSHConfigDisabled() { + log.Debugf("SSH config management disabled via %s", EnvDisableSSHConfig) + } else { + log.Infof("SSH config generation skipped: too many peers (%d > %d). Use %s=true to force.", + peerCount, MaxPeersForSSHConfig, EnvForceSSHConfig) + } +} + +func (m *Manager) buildSSHConfig(peers []PeerSSHInfo) (string, error) { + sshConfig := m.buildConfigHeader() + + var allHostPatterns []string + for _, peer := range peers { + hostPatterns := m.buildHostPatterns(peer) + allHostPatterns = append(allHostPatterns, hostPatterns...) + } + + if len(allHostPatterns) > 0 { + peerConfig, err := m.buildPeerConfig(allHostPatterns) + if err != nil { + return "", err + } + sshConfig += peerConfig + } + + return sshConfig, nil +} + +func (m *Manager) buildConfigHeader() string { + return "# NetBird SSH client configuration\n" + + "# Generated automatically - do not edit manually\n" + + "#\n" + + "# To disable SSH config management, use:\n" + + "# netbird service reconfigure --service-env NB_DISABLE_SSH_CONFIG=true\n" + + "#\n\n" +} + +func (m *Manager) buildPeerConfig(allHostPatterns []string) (string, error) { + uniquePatterns := make(map[string]bool) + var deduplicatedPatterns []string + for _, pattern := range allHostPatterns { + if !uniquePatterns[pattern] { + uniquePatterns[pattern] = true + deduplicatedPatterns = append(deduplicatedPatterns, pattern) + } + } + + execPath, err := m.getNetBirdExecutablePath() + if err != nil { + return "", fmt.Errorf("get NetBird executable path: %w", err) + } + + hostList := strings.Join(deduplicatedPatterns, ",") + config := fmt.Sprintf("Match host \"%s\" exec \"%s ssh detect %%h %%p\"\n", hostList, execPath) + config += " PreferredAuthentications password,publickey,keyboard-interactive\n" + config += " PasswordAuthentication yes\n" + config += " PubkeyAuthentication yes\n" + config += " BatchMode no\n" + config += fmt.Sprintf(" ProxyCommand %s ssh proxy %%h %%p\n", execPath) + config += " StrictHostKeyChecking no\n" + + if runtime.GOOS == "windows" { + config += " UserKnownHostsFile NUL\n" + } else { + config += " UserKnownHostsFile /dev/null\n" + } + + config += " CheckHostIP no\n" + config += " LogLevel ERROR\n\n" + + return config, nil +} + +func (m *Manager) buildHostPatterns(peer PeerSSHInfo) []string { + var hostPatterns []string + if peer.IP.IsValid() { + hostPatterns = append(hostPatterns, peer.IP.String()) + } + 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.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 +} + +func (m *Manager) writeSSHConfig(sshConfig string) error { + sshConfigPath := filepath.Join(m.sshConfigDir, m.sshConfigFile) + + if err := os.MkdirAll(m.sshConfigDir, 0755); err != nil { + return fmt.Errorf("create SSH config directory %s: %w", m.sshConfigDir, 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) + return nil +} + +// RemoveSSHClientConfig removes NetBird SSH configuration +func (m *Manager) RemoveSSHClientConfig() error { + sshConfigPath := filepath.Join(m.sshConfigDir, m.sshConfigFile) + err := os.Remove(sshConfigPath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove SSH config %s: %w", sshConfigPath, err) + } + if err == nil { + log.Infof("Removed NetBird SSH config: %s", sshConfigPath) + } + return nil +} + +func (m *Manager) getNetBirdExecutablePath() (string, error) { + execPath, err := os.Executable() + if err != nil { + return "", fmt.Errorf("retrieve executable path: %w", err) + } + + realPath, err := filepath.EvalSymlinks(execPath) + if err != nil { + log.Debugf("symlink resolution failed: %v", err) + return execPath, nil + } + + return realPath, nil +} + +// GetSSHConfigDir returns the SSH config directory path +func (m *Manager) GetSSHConfigDir() string { + return m.sshConfigDir +} + +// GetSSHConfigFile returns the SSH config file name +func (m *Manager) GetSSHConfigFile() string { + return m.sshConfigFile +} diff --git a/client/ssh/config/manager_test.go b/client/ssh/config/manager_test.go new file mode 100644 index 000000000..f65d0ba6d --- /dev/null +++ b/client/ssh/config/manager_test.go @@ -0,0 +1,230 @@ +package config + +import ( + "fmt" + "net/netip" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestManager_SetupSSHClientConfig(t *testing.T) { + // Create temporary directory for test + tempDir, err := os.MkdirTemp("", "netbird-ssh-config-test") + require.NoError(t, err) + defer func() { assert.NoError(t, os.RemoveAll(tempDir)) }() + + // Override manager paths to use temp directory + manager := &Manager{ + sshConfigDir: filepath.Join(tempDir, "ssh_config.d"), + sshConfigFile: "99-netbird.conf", + } + + // Test SSH config generation with peers + peers := []PeerSSHInfo{ + { + 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) + require.NoError(t, err) + + // Read generated config + configPath := filepath.Join(manager.sshConfigDir, manager.sshConfigFile) + content, err := os.ReadFile(configPath) + require.NoError(t, err) + + configStr := string(content) + + // Verify the basic SSH config structure exists + assert.Contains(t, configStr, "# NetBird SSH client configuration") + assert.Contains(t, configStr, "Generated automatically - do not edit manually") + + // Check that peer hostnames are included + assert.Contains(t, configStr, "100.125.1.1") + assert.Contains(t, configStr, "100.125.1.2") + assert.Contains(t, configStr, "peer1.nb.internal") + assert.Contains(t, configStr, "peer2.nb.internal") + + // Check platform-specific UserKnownHostsFile + if runtime.GOOS == "windows" { + assert.Contains(t, configStr, "UserKnownHostsFile NUL") + } else { + assert.Contains(t, configStr, "UserKnownHostsFile /dev/null") + } +} + +func TestGetSystemSSHConfigDir(t *testing.T) { + configDir := getSystemSSHConfigDir() + + // Path should not be empty + assert.NotEmpty(t, configDir) + + // Should be an absolute path + assert.True(t, filepath.IsAbs(configDir)) + + // On Unix systems, should start with /etc + // On Windows, should contain ProgramData + if runtime.GOOS == "windows" { + assert.Contains(t, strings.ToLower(configDir), "programdata") + } else { + assert.Contains(t, configDir, "/etc/ssh") + } +} + +func TestManager_PeerLimit(t *testing.T) { + // Create temporary directory for test + tempDir, err := os.MkdirTemp("", "netbird-ssh-config-test") + require.NoError(t, err) + defer func() { assert.NoError(t, os.RemoveAll(tempDir)) }() + + // Override manager paths to use temp directory + manager := &Manager{ + sshConfigDir: filepath.Join(tempDir, "ssh_config.d"), + sshConfigFile: "99-netbird.conf", + } + + // Generate many peers (more than limit) + var peers []PeerSSHInfo + for i := 0; i < MaxPeersForSSHConfig+10; i++ { + peers = append(peers, PeerSSHInfo{ + Hostname: fmt.Sprintf("peer%d", i), + IP: netip.MustParseAddr(fmt.Sprintf("100.125.1.%d", i%254+1)), + FQDN: fmt.Sprintf("peer%d.nb.internal", i), + }) + } + + // Test that SSH config generation is skipped when too many peers + err = manager.SetupSSHClientConfig(peers) + require.NoError(t, err) + + // Config should not be created due to peer limit + configPath := filepath.Join(manager.sshConfigDir, manager.sshConfigFile) + _, err = os.Stat(configPath) + assert.True(t, os.IsNotExist(err), "SSH config should not be created with too many peers") +} + +func TestManager_MatchHostFormat(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", + } + + peers := []PeerSSHInfo{ + {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) + require.NoError(t, err) + + configPath := filepath.Join(manager.sshConfigDir, manager.sshConfigFile) + content, err := os.ReadFile(configPath) + require.NoError(t, err) + configStr := string(content) + + // Must use "Match host" with comma-separated patterns, not a bare "Host" directive. + // A bare "Host" followed by "Match exec" is incorrect per ssh_config(5): the Host block + // ends at the next Match keyword, making it a no-op and leaving the Match exec unscoped. + assert.NotContains(t, configStr, "\nHost ", "should not use bare Host directive") + assert.Contains(t, configStr, "Match host \"100.125.1.1,peer1.nb.internal,peer1,100.125.1.2,peer2.nb.internal,peer2\"", + "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") + + // Create temporary directory for test + tempDir, err := os.MkdirTemp("", "netbird-ssh-config-test") + require.NoError(t, err) + defer func() { assert.NoError(t, os.RemoveAll(tempDir)) }() + + // Override manager paths to use temp directory + manager := &Manager{ + sshConfigDir: filepath.Join(tempDir, "ssh_config.d"), + sshConfigFile: "99-netbird.conf", + } + + // Generate many peers (more than limit) + var peers []PeerSSHInfo + for i := 0; i < MaxPeersForSSHConfig+10; i++ { + peers = append(peers, PeerSSHInfo{ + Hostname: fmt.Sprintf("peer%d", i), + IP: netip.MustParseAddr(fmt.Sprintf("100.125.1.%d", i%254+1)), + FQDN: fmt.Sprintf("peer%d.nb.internal", i), + }) + } + + // Test that SSH config generation is forced despite many peers + err = manager.SetupSSHClientConfig(peers) + require.NoError(t, err) + + // Config should be created despite peer limit due to force flag + configPath := filepath.Join(manager.sshConfigDir, manager.sshConfigFile) + _, err = os.Stat(configPath) + require.NoError(t, err, "SSH config should be created when forced") + + // Verify config contains peer hostnames + content, err := os.ReadFile(configPath) + require.NoError(t, err) + configStr := string(content) + assert.Contains(t, configStr, "peer0.nb.internal") + assert.Contains(t, configStr, "peer1.nb.internal") +} diff --git a/client/ssh/config/shutdown_state.go b/client/ssh/config/shutdown_state.go new file mode 100644 index 000000000..22f0e0678 --- /dev/null +++ b/client/ssh/config/shutdown_state.go @@ -0,0 +1,22 @@ +package config + +// ShutdownState represents SSH configuration state that needs to be cleaned up. +type ShutdownState struct { + SSHConfigDir string + SSHConfigFile string +} + +// Name returns the state name for the state manager. +func (s *ShutdownState) Name() string { + return "ssh_config_state" +} + +// Cleanup removes SSH client configuration files. +func (s *ShutdownState) Cleanup() error { + manager := &Manager{ + sshConfigDir: s.SSHConfigDir, + sshConfigFile: s.SSHConfigFile, + } + + return manager.RemoveSSHClientConfig() +} diff --git a/client/ssh/detection/detection.go b/client/ssh/detection/detection.go new file mode 100644 index 000000000..f23ea4c37 --- /dev/null +++ b/client/ssh/detection/detection.go @@ -0,0 +1,99 @@ +package detection + +import ( + "bufio" + "context" + "fmt" + "net" + "strconv" + "strings" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + // ServerIdentifier is the base response for NetBird SSH servers + ServerIdentifier = "NetBird-SSH-Server" + // ProxyIdentifier is the base response for NetBird SSH proxy + ProxyIdentifier = "NetBird-SSH-Proxy" + // JWTRequiredMarker is appended to responses when JWT is required + JWTRequiredMarker = "NetBird-JWT-Required" + + // DefaultTimeout is the default timeout for SSH server detection + DefaultTimeout = 5 * time.Second +) + +type ServerType string + +const ( + ServerTypeNetBirdJWT ServerType = "netbird-jwt" + ServerTypeNetBirdNoJWT ServerType = "netbird-no-jwt" + ServerTypeRegular ServerType = "regular" +) + +// Dialer provides network connection capabilities +type Dialer interface { + DialContext(ctx context.Context, network, address string) (net.Conn, error) +} + +// RequiresJWT checks if the server type requires JWT authentication +func (s ServerType) RequiresJWT() bool { + return s == ServerTypeNetBirdJWT +} + +// ExitCode returns the exit code for the detect command +func (s ServerType) ExitCode() int { + switch s { + case ServerTypeNetBirdJWT: + return 0 + case ServerTypeNetBirdNoJWT: + return 1 + case ServerTypeRegular: + return 2 + default: + return 2 + } +} + +// DetectSSHServerType detects SSH server type using the provided dialer +func DetectSSHServerType(ctx context.Context, dialer Dialer, host string, port int) (ServerType, error) { + targetAddr := net.JoinHostPort(host, strconv.Itoa(port)) + + conn, err := dialer.DialContext(ctx, "tcp", targetAddr) + if err != nil { + return ServerTypeRegular, fmt.Errorf("connect to %s: %w", targetAddr, err) + } + defer conn.Close() + + if deadline, ok := ctx.Deadline(); ok { + if err := conn.SetReadDeadline(deadline); err != nil { + return ServerTypeRegular, fmt.Errorf("set read deadline: %w", err) + } + } + + reader := bufio.NewReader(conn) + serverBanner, err := reader.ReadString('\n') + if err != nil { + return ServerTypeRegular, fmt.Errorf("read SSH banner: %w", err) + } + + serverBanner = strings.TrimSpace(serverBanner) + log.Debugf("SSH server banner: %s", serverBanner) + + if !strings.HasPrefix(serverBanner, "SSH-") { + log.Debugf("Invalid SSH banner") + return ServerTypeRegular, nil + } + + if !strings.Contains(serverBanner, ServerIdentifier) { + log.Debugf("Server banner does not contain identifier '%s'", ServerIdentifier) + return ServerTypeRegular, nil + } + + if strings.Contains(serverBanner, JWTRequiredMarker) { + return ServerTypeNetBirdJWT, nil + } + + return ServerTypeNetBirdNoJWT, nil +} diff --git a/client/ssh/login.go b/client/ssh/login.go deleted file mode 100644 index cb2615e55..000000000 --- a/client/ssh/login.go +++ /dev/null @@ -1,53 +0,0 @@ -//go:build !js - -package ssh - -import ( - "fmt" - "net" - "net/netip" - "os" - "os/exec" - "runtime" - - "github.com/netbirdio/netbird/util" -) - -func isRoot() bool { - return os.Geteuid() == 0 -} - -func getLoginCmd(user string, remoteAddr net.Addr) (loginPath string, args []string, err error) { - if !isRoot() { - shell := getUserShell(user) - if shell == "" { - shell = "/bin/sh" - } - - return shell, []string{"-l"}, nil - } - - loginPath, err = exec.LookPath("login") - if err != nil { - return "", nil, err - } - - addrPort, err := netip.ParseAddrPort(remoteAddr.String()) - if err != nil { - return "", nil, err - } - - switch runtime.GOOS { - case "linux": - if util.FileExists("/etc/arch-release") && !util.FileExists("/etc/pam.d/remote") { - return loginPath, []string{"-f", user, "-p"}, nil - } - return loginPath, []string{"-f", user, "-h", addrPort.Addr().String(), "-p"}, nil - case "darwin": - return loginPath, []string{"-fp", "-h", addrPort.Addr().String(), user}, nil - case "freebsd": - return loginPath, []string{"-f", user, "-h", addrPort.Addr().String(), "-p"}, nil - default: - return "", nil, fmt.Errorf("unsupported platform: %s", runtime.GOOS) - } -} diff --git a/client/ssh/lookup.go b/client/ssh/lookup.go deleted file mode 100644 index 9a7f6ff2e..000000000 --- a/client/ssh/lookup.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build !darwin -// +build !darwin - -package ssh - -import "os/user" - -func userNameLookup(username string) (*user.User, error) { - if username == "" || (username == "root" && !isRoot()) { - return user.Current() - } - - return user.Lookup(username) -} diff --git a/client/ssh/lookup_darwin.go b/client/ssh/lookup_darwin.go deleted file mode 100644 index 913d049dc..000000000 --- a/client/ssh/lookup_darwin.go +++ /dev/null @@ -1,51 +0,0 @@ -//go:build darwin -// +build darwin - -package ssh - -import ( - "bytes" - "fmt" - "os/exec" - "os/user" - "strings" -) - -func userNameLookup(username string) (*user.User, error) { - if username == "" || (username == "root" && !isRoot()) { - return user.Current() - } - - var userObject *user.User - userObject, err := user.Lookup(username) - if err != nil && err.Error() == user.UnknownUserError(username).Error() { - return idUserNameLookup(username) - } else if err != nil { - return nil, err - } - - return userObject, nil -} - -func idUserNameLookup(username string) (*user.User, error) { - cmd := exec.Command("id", "-P", username) - out, err := cmd.CombinedOutput() - if err != nil { - return nil, fmt.Errorf("error while retrieving user with id -P command, error: %v", err) - } - colon := ":" - - if !bytes.Contains(out, []byte(username+colon)) { - return nil, fmt.Errorf("unable to find user in returned string") - } - // netbird:********:501:20::0:0:netbird:/Users/netbird:/bin/zsh - parts := strings.SplitN(string(out), colon, 10) - userObject := &user.User{ - Username: parts[0], - Uid: parts[2], - Gid: parts[3], - Name: parts[7], - HomeDir: parts[8], - } - return userObject, nil -} diff --git a/client/ssh/proxy/proxy.go b/client/ssh/proxy/proxy.go new file mode 100644 index 000000000..73b50122c --- /dev/null +++ b/client/ssh/proxy/proxy.go @@ -0,0 +1,627 @@ +package proxy + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/gliderlabs/ssh" + 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/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" +) + +const ( + // sshConnectionTimeout is the timeout for SSH TCP connection establishment + sshConnectionTimeout = 120 * time.Second + // sshHandshakeTimeout is the timeout for SSH handshake completion + sshHandshakeTimeout = 30 * time.Second + + jwtAuthErrorMsg = "JWT authentication: %w" +) + +type SSHProxy struct { + daemonAddr string + targetHost string + targetPort int + stderr io.Writer + conn *grpc.ClientConn + daemonClient proto.DaemonServiceClient + browserOpener func(string) error + + mu sync.RWMutex + backendClient *cryptossh.Client + // jwtToken is set once in runProxySSHServer before any handlers are called, + // so concurrent access is safe without additional synchronization. + jwtToken string + + forwardedChannelsOnce sync.Once +} + +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())) + if err != nil { + return nil, fmt.Errorf("connect to daemon: %w", err) + } + + return &SSHProxy{ + daemonAddr: daemonAddr, + targetHost: targetHost, + targetPort: targetPort, + stderr: stderr, + conn: grpcConn, + daemonClient: proto.NewDaemonServiceClient(grpcConn), + browserOpener: browserOpener, + }, nil +} + +func (p *SSHProxy) Close() error { + p.mu.Lock() + backendClient := p.backendClient + p.backendClient = nil + p.mu.Unlock() + + if backendClient != nil { + if err := backendClient.Close(); err != nil { + log.Debugf("close backend client: %v", err) + } + } + + if p.conn != nil { + return p.conn.Close() + } + return nil +} + +func (p *SSHProxy) Connect(ctx context.Context) error { + hint := profilemanager.GetLoginHint() + + jwtToken, err := nbssh.RequestJWTToken(ctx, p.daemonClient, nil, p.stderr, true, hint, p.browserOpener) + if err != nil { + return fmt.Errorf(jwtAuthErrorMsg, err) + } + + log.Debugf("JWT authentication successful, starting proxy to %s:%d", p.targetHost, p.targetPort) + return p.runProxySSHServer(jwtToken) +} + +func (p *SSHProxy) runProxySSHServer(jwtToken string) error { + p.jwtToken = jwtToken + serverVersion := fmt.Sprintf("%s-%s", detection.ProxyIdentifier, version.NetbirdVersion()) + + sshServer := &ssh.Server{ + Handler: p.handleSSHSession, + ChannelHandlers: map[string]ssh.ChannelHandler{ + "session": ssh.DefaultSessionHandler, + "direct-tcpip": p.directTCPIPHandler, + }, + SubsystemHandlers: map[string]ssh.SubsystemHandler{ + "sftp": func(s ssh.Session) { + p.sftpSubsystemHandler(s, jwtToken) + }, + }, + RequestHandlers: map[string]ssh.RequestHandler{ + "tcpip-forward": p.tcpipForwardHandler, + "cancel-tcpip-forward": p.cancelTcpipForwardHandler, + }, + Version: serverVersion, + } + + hostKey, err := generateHostKey() + if err != nil { + return fmt.Errorf("generate host key: %w", err) + } + sshServer.HostSigners = []ssh.Signer{hostKey} + + conn := &stdioConn{ + stdin: os.Stdin, + stdout: os.Stdout, + } + + sshServer.HandleConn(conn) + + return nil +} + +func (p *SSHProxy) handleSSHSession(session ssh.Session) { + ptyReq, winCh, isPty := session.Pty() + hasCommand := session.RawCommand() != "" + + sshClient, err := p.getOrCreateBackendClient(session.Context(), session.User()) + if err != nil { + _, _ = fmt.Fprintf(p.stderr, "SSH connection to NetBird server failed: %v\n", err) + return + } + + if !isPty && !hasCommand { + p.handleNonInteractiveSession(session, sshClient) + return + } + + serverSession, err := sshClient.NewSession() + if err != nil { + _, _ = fmt.Fprintf(p.stderr, "create server session: %v\n", err) + return + } + defer func() { _ = serverSession.Close() }() + + serverSession.Stdin = session + serverSession.Stdout = session + serverSession.Stderr = session.Stderr() + + if isPty { + if err := serverSession.RequestPty(ptyReq.Term, ptyReq.Window.Width, ptyReq.Window.Height, nil); err != nil { + log.Debugf("PTY request to backend: %v", err) + } + + go func() { + for win := range winCh { + if err := serverSession.WindowChange(win.Height, win.Width); err != nil { + log.Debugf("window change: %v", err) + } + } + }() + } + + if hasCommand { + if err := serverSession.Run(session.RawCommand()); err != nil { + log.Debugf("run command: %v", err) + p.handleProxyExitCode(session, err) + } + return + } + + if err = serverSession.Shell(); err != nil { + log.Debugf("start shell: %v", err) + return + } + if err := serverSession.Wait(); err != nil { + log.Debugf("session wait: %v", err) + p.handleProxyExitCode(session, err) + } +} + +func (p *SSHProxy) handleProxyExitCode(session ssh.Session, err error) { + var exitErr *cryptossh.ExitError + if errors.As(err, &exitErr) { + if err := session.Exit(exitErr.ExitStatus()); err != nil { + log.Debugf("set exit status: %v", err) + } + } +} + +func (p *SSHProxy) handleNonInteractiveSession(session ssh.Session, sshClient *cryptossh.Client) { + serverSession, err := sshClient.NewSession() + if err != nil { + _, _ = fmt.Fprintf(p.stderr, "create server session: %v\n", err) + return + } + defer func() { _ = serverSession.Close() }() + + serverSession.Stdin = session + serverSession.Stdout = session + serverSession.Stderr = session.Stderr() + + if err := serverSession.Shell(); err != nil { + log.Debugf("start shell: %v", err) + return + } + + done := make(chan error, 1) + go func() { + done <- serverSession.Wait() + }() + + select { + case <-session.Context().Done(): + return + case err := <-done: + if err != nil { + log.Debugf("shell session: %v", err) + p.handleProxyExitCode(session, err) + } + } +} + +func generateHostKey() (ssh.Signer, error) { + keyPEM, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + if err != nil { + return nil, fmt.Errorf("generate ED25519 key: %w", err) + } + + signer, err := cryptossh.ParsePrivateKey(keyPEM) + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + + return signer, nil +} + +type stdioConn struct { + stdin io.Reader + stdout io.Writer + closed bool + mu sync.Mutex +} + +func (c *stdioConn) Read(b []byte) (n int, err error) { + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return 0, io.EOF + } + c.mu.Unlock() + return c.stdin.Read(b) +} + +func (c *stdioConn) Write(b []byte) (n int, err error) { + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return 0, io.ErrClosedPipe + } + c.mu.Unlock() + return c.stdout.Write(b) +} + +func (c *stdioConn) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + c.closed = true + return nil +} + +func (c *stdioConn) LocalAddr() net.Addr { + return &net.UnixAddr{Name: "stdio", Net: "unix"} +} + +func (c *stdioConn) RemoteAddr() net.Addr { + return &net.UnixAddr{Name: "stdio", Net: "unix"} +} + +func (c *stdioConn) SetDeadline(_ time.Time) error { + return nil +} + +func (c *stdioConn) SetReadDeadline(_ time.Time) error { + return nil +} + +func (c *stdioConn) SetWriteDeadline(_ time.Time) error { + return nil +} + +// directTCPIPHandler handles local port forwarding (direct-tcpip channel). +func (p *SSHProxy) directTCPIPHandler(_ *ssh.Server, _ *cryptossh.ServerConn, newChan cryptossh.NewChannel, sshCtx ssh.Context) { + var payload struct { + DestAddr string + DestPort uint32 + OriginAddr string + OriginPort uint32 + } + if err := cryptossh.Unmarshal(newChan.ExtraData(), &payload); err != nil { + _, _ = fmt.Fprintf(p.stderr, "parse direct-tcpip payload: %v\n", err) + _ = newChan.Reject(cryptossh.ConnectionFailed, "invalid payload") + return + } + + dest := net.JoinHostPort(payload.DestAddr, strconv.Itoa(int(payload.DestPort))) + log.Debugf("local port forwarding: %s", dest) + + backendClient, err := p.getOrCreateBackendClient(sshCtx, sshCtx.User()) + if err != nil { + _, _ = fmt.Fprintf(p.stderr, "backend connection for port forwarding: %v\n", err) + _ = newChan.Reject(cryptossh.ConnectionFailed, "backend connection failed") + return + } + + backendChan, backendReqs, err := backendClient.OpenChannel("direct-tcpip", newChan.ExtraData()) + if err != nil { + _, _ = fmt.Fprintf(p.stderr, "open backend channel for %s: %v\n", dest, err) + var openErr *cryptossh.OpenChannelError + if errors.As(err, &openErr) { + _ = newChan.Reject(openErr.Reason, openErr.Message) + } else { + _ = newChan.Reject(cryptossh.ConnectionFailed, err.Error()) + } + return + } + go cryptossh.DiscardRequests(backendReqs) + + clientChan, clientReqs, err := newChan.Accept() + if err != nil { + log.Debugf("local port forwarding: accept channel: %v", err) + _ = backendChan.Close() + return + } + go cryptossh.DiscardRequests(clientReqs) + + netrelay.Relay(sshCtx, clientChan, backendChan, netrelay.Options{Logger: log.NewEntry(log.StandardLogger())}) +} + +func (p *SSHProxy) sftpSubsystemHandler(s ssh.Session, jwtToken string) { + ctx, cancel := context.WithCancel(s.Context()) + defer cancel() + + targetAddr := net.JoinHostPort(p.targetHost, strconv.Itoa(p.targetPort)) + + sshClient, err := p.dialBackend(ctx, targetAddr, s.User(), jwtToken) + if err != nil { + _, _ = fmt.Fprintf(s, "SSH connection failed: %v\n", err) + _ = s.Exit(1) + return + } + defer func() { + if err := sshClient.Close(); err != nil { + log.Debugf("close SSH client: %v", err) + } + }() + + serverSession, err := sshClient.NewSession() + if err != nil { + _, _ = fmt.Fprintf(s, "create server session: %v\n", err) + _ = s.Exit(1) + return + } + defer func() { + if err := serverSession.Close(); err != nil { + log.Debugf("close server session: %v", err) + } + }() + + stdin, stdout, err := p.setupSFTPPipes(serverSession) + if err != nil { + log.Debugf("setup SFTP pipes: %v", err) + _ = s.Exit(1) + return + } + + if err := serverSession.RequestSubsystem("sftp"); err != nil { + _, _ = fmt.Fprintf(s, "SFTP subsystem request failed: %v\n", err) + _ = s.Exit(1) + return + } + + p.runSFTPBridge(ctx, s, stdin, stdout, serverSession) +} + +func (p *SSHProxy) setupSFTPPipes(serverSession *cryptossh.Session) (io.WriteCloser, io.Reader, error) { + stdin, err := serverSession.StdinPipe() + if err != nil { + return nil, nil, fmt.Errorf("get stdin pipe: %w", err) + } + + stdout, err := serverSession.StdoutPipe() + if err != nil { + return nil, nil, fmt.Errorf("get stdout pipe: %w", err) + } + + return stdin, stdout, nil +} + +func (p *SSHProxy) runSFTPBridge(ctx context.Context, s ssh.Session, stdin io.WriteCloser, stdout io.Reader, serverSession *cryptossh.Session) { + copyErrCh := make(chan error, 2) + + go func() { + _, err := io.Copy(stdin, s) + if err != nil { + log.Debugf("SFTP client to server copy: %v", err) + } + if err := stdin.Close(); err != nil { + log.Debugf("close stdin: %v", err) + } + copyErrCh <- err + }() + + go func() { + _, err := io.Copy(s, stdout) + if err != nil { + log.Debugf("SFTP server to client copy: %v", err) + } + copyErrCh <- err + }() + + go func() { + <-ctx.Done() + if err := serverSession.Close(); err != nil { + log.Debugf("force close server session on context cancellation: %v", err) + } + }() + + for i := 0; i < 2; i++ { + if err := <-copyErrCh; err != nil && !errors.Is(err, io.EOF) { + log.Debugf("SFTP copy error: %v", err) + } + } + + if err := serverSession.Wait(); err != nil { + log.Debugf("SFTP session ended: %v", err) + } +} + +// tcpipForwardHandler handles remote port forwarding (tcpip-forward request). +func (p *SSHProxy) tcpipForwardHandler(sshCtx ssh.Context, _ *ssh.Server, req *cryptossh.Request) (bool, []byte) { + var reqPayload struct { + Host string + Port uint32 + } + if err := cryptossh.Unmarshal(req.Payload, &reqPayload); err != nil { + _, _ = fmt.Fprintf(p.stderr, "parse tcpip-forward payload: %v\n", err) + return false, nil + } + + log.Debugf("tcpip-forward request for %s:%d", reqPayload.Host, reqPayload.Port) + + backendClient, err := p.getOrCreateBackendClient(sshCtx, sshCtx.User()) + if err != nil { + _, _ = fmt.Fprintf(p.stderr, "backend connection for remote port forwarding: %v\n", err) + return false, nil + } + + ok, payload, err := backendClient.SendRequest(req.Type, req.WantReply, req.Payload) + if err != nil { + _, _ = fmt.Fprintf(p.stderr, "forward tcpip-forward request for %s:%d: %v\n", reqPayload.Host, reqPayload.Port, err) + return false, nil + } + + if ok { + actualPort := reqPayload.Port + if reqPayload.Port == 0 && len(payload) >= 4 { + actualPort = binary.BigEndian.Uint32(payload) + } + log.Debugf("remote port forwarding established for %s:%d", reqPayload.Host, actualPort) + p.forwardedChannelsOnce.Do(func() { + go p.handleForwardedChannels(sshCtx, backendClient) + }) + } + + return ok, payload +} + +// cancelTcpipForwardHandler handles cancel-tcpip-forward request. +func (p *SSHProxy) cancelTcpipForwardHandler(_ ssh.Context, _ *ssh.Server, req *cryptossh.Request) (bool, []byte) { + var reqPayload struct { + Host string + Port uint32 + } + if err := cryptossh.Unmarshal(req.Payload, &reqPayload); err != nil { + _, _ = fmt.Fprintf(p.stderr, "parse cancel-tcpip-forward payload: %v\n", err) + return false, nil + } + + log.Debugf("cancel-tcpip-forward request for %s:%d", reqPayload.Host, reqPayload.Port) + + backendClient := p.getBackendClient() + if backendClient == nil { + return false, nil + } + + ok, payload, err := backendClient.SendRequest(req.Type, req.WantReply, req.Payload) + if err != nil { + _, _ = fmt.Fprintf(p.stderr, "cancel-tcpip-forward for %s:%d: %v\n", reqPayload.Host, reqPayload.Port, err) + return false, nil + } + + return ok, payload +} + +// getOrCreateBackendClient returns the existing backend client or creates a new one. +func (p *SSHProxy) getOrCreateBackendClient(ctx context.Context, user string) (*cryptossh.Client, error) { + p.mu.Lock() + defer p.mu.Unlock() + + if p.backendClient != nil { + return p.backendClient, nil + } + + targetAddr := net.JoinHostPort(p.targetHost, strconv.Itoa(p.targetPort)) + log.Debugf("connecting to backend %s", targetAddr) + + client, err := p.dialBackend(ctx, targetAddr, user, p.jwtToken) + if err != nil { + return nil, err + } + + log.Debugf("backend connection established to %s", targetAddr) + p.backendClient = client + return client, nil +} + +// getBackendClient returns the existing backend client or nil. +func (p *SSHProxy) getBackendClient() *cryptossh.Client { + p.mu.RLock() + defer p.mu.RUnlock() + return p.backendClient +} + +// handleForwardedChannels handles forwarded-tcpip channels from the backend for remote port forwarding. +// When the backend receives incoming connections on the forwarded port, it sends them as +// "forwarded-tcpip" channels which we need to proxy to the client. +func (p *SSHProxy) handleForwardedChannels(sshCtx ssh.Context, backendClient *cryptossh.Client) { + sshConn, ok := sshCtx.Value(ssh.ContextKeyConn).(*cryptossh.ServerConn) + if !ok || sshConn == nil { + log.Debugf("no SSH connection in context for forwarded channels") + return + } + + channelChan := backendClient.HandleChannelOpen("forwarded-tcpip") + for { + select { + case <-sshCtx.Done(): + return + case newChannel, ok := <-channelChan: + if !ok { + return + } + go p.handleForwardedChannel(sshCtx, sshConn, newChannel) + } + } +} + +// handleForwardedChannel handles a single forwarded-tcpip channel from the backend. +func (p *SSHProxy) handleForwardedChannel(sshCtx ssh.Context, sshConn *cryptossh.ServerConn, newChannel cryptossh.NewChannel) { + backendChan, backendReqs, err := newChannel.Accept() + if err != nil { + log.Debugf("remote port forwarding: accept from backend: %v", err) + return + } + go cryptossh.DiscardRequests(backendReqs) + + clientChan, clientReqs, err := sshConn.OpenChannel("forwarded-tcpip", newChannel.ExtraData()) + if err != nil { + log.Debugf("remote port forwarding: open to client: %v", err) + _ = backendChan.Close() + return + } + go cryptossh.DiscardRequests(clientReqs) + + 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) { + config := &cryptossh.ClientConfig{ + User: user, + Auth: []cryptossh.AuthMethod{cryptossh.Password(jwtToken)}, + Timeout: sshHandshakeTimeout, + HostKeyCallback: p.verifyHostKey, + } + + dialer := &net.Dialer{ + Timeout: sshConnectionTimeout, + } + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + 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) + } + + return cryptossh.NewClient(clientConn, chans, reqs), nil +} + +func (p *SSHProxy) verifyHostKey(hostname string, remote net.Addr, key cryptossh.PublicKey) error { + verifier := nbssh.NewDaemonHostKeyVerifier(p.daemonClient) + callback := nbssh.CreateHostKeyCallback(verifier) + return callback(hostname, remote, key) +} 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 new file mode 100644 index 000000000..2795c786b --- /dev/null +++ b/client/ssh/proxy/proxy_test.go @@ -0,0 +1,164 @@ +package proxy + +import ( + "context" + "fmt" + "net" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + cryptossh "golang.org/x/crypto/ssh" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/netbirdio/netbird/client/proto" + nbssh "github.com/netbirdio/netbird/client/ssh" + "github.com/netbirdio/netbird/client/ssh/testutil" +) + +func TestMain(m *testing.M) { + if len(os.Args) > 2 && os.Args[1] == "ssh" { + if os.Args[2] == "exec" { + if len(os.Args) > 3 { + cmd := os.Args[3] + if cmd == "echo" && len(os.Args) > 4 { + fmt.Fprintln(os.Stdout, os.Args[4]) + os.Exit(0) + } + } + fmt.Fprintf(os.Stderr, "Test binary called as 'ssh exec' with args: %v - preventing infinite recursion\n", os.Args) + os.Exit(1) + } + } + + code := m.Run() + + testutil.CleanupTestUsers() + + os.Exit(code) +} + +func TestSSHProxy_verifyHostKey(t *testing.T) { + t.Run("calls daemon to verify host key", func(t *testing.T) { + mockDaemon := startMockDaemon(t) + defer mockDaemon.stop() + + grpcConn, err := grpc.NewClient(mockDaemon.addr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer func() { _ = grpcConn.Close() }() + + proxy := &SSHProxy{ + daemonAddr: mockDaemon.addr, + daemonClient: proto.NewDaemonServiceClient(grpcConn), + } + + testKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + testPubKey, err := nbssh.GeneratePublicKey(testKey) + require.NoError(t, err) + + mockDaemon.setHostKey("test-host", testPubKey) + + err = proxy.verifyHostKey("test-host", &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 22}, mustParsePublicKey(t, testPubKey)) + assert.NoError(t, err) + }) + + t.Run("rejects unknown host key", func(t *testing.T) { + mockDaemon := startMockDaemon(t) + defer mockDaemon.stop() + + grpcConn, err := grpc.NewClient(mockDaemon.addr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer func() { _ = grpcConn.Close() }() + + proxy := &SSHProxy{ + daemonAddr: mockDaemon.addr, + daemonClient: proto.NewDaemonServiceClient(grpcConn), + } + + unknownKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + unknownPubKey, err := nbssh.GeneratePublicKey(unknownKey) + require.NoError(t, err) + + err = proxy.verifyHostKey("unknown-host", &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 22}, mustParsePublicKey(t, unknownPubKey)) + assert.Error(t, err) + assert.Contains(t, err.Error(), "peer unknown-host not found in network") + }) +} + +type mockDaemonServer struct { + proto.UnimplementedDaemonServiceServer + hostKeys map[string][]byte + jwtToken string +} + +func (m *mockDaemonServer) GetPeerSSHHostKey(ctx context.Context, req *proto.GetPeerSSHHostKeyRequest) (*proto.GetPeerSSHHostKeyResponse, error) { + key, found := m.hostKeys[req.PeerAddress] + return &proto.GetPeerSSHHostKeyResponse{ + Found: found, + SshHostKey: key, + }, nil +} + +func (m *mockDaemonServer) RequestJWTAuth(ctx context.Context, req *proto.RequestJWTAuthRequest) (*proto.RequestJWTAuthResponse, error) { + return &proto.RequestJWTAuthResponse{ + CachedToken: m.jwtToken, + }, nil +} + +func (m *mockDaemonServer) WaitJWTToken(ctx context.Context, req *proto.WaitJWTTokenRequest) (*proto.WaitJWTTokenResponse, error) { + return &proto.WaitJWTTokenResponse{ + Token: m.jwtToken, + }, nil +} + +type mockDaemon struct { + addr string + server *grpc.Server + impl *mockDaemonServer +} + +func startMockDaemon(t *testing.T) *mockDaemon { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + impl := &mockDaemonServer{ + hostKeys: make(map[string][]byte), + jwtToken: "test-jwt-token", + } + + grpcServer := grpc.NewServer() + proto.RegisterDaemonServiceServer(grpcServer, impl) + + go func() { + _ = grpcServer.Serve(listener) + }() + + return &mockDaemon{ + addr: listener.Addr().String(), + server: grpcServer, + impl: impl, + } +} + +func (m *mockDaemon) setHostKey(addr string, pubKey []byte) { + m.impl.hostKeys[addr] = pubKey +} + +func (m *mockDaemon) stop() { + if m.server != nil { + m.server.Stop() + } +} + +func mustParsePublicKey(t *testing.T, pubKeyBytes []byte) cryptossh.PublicKey { + t.Helper() + pubKey, _, _, _, err := cryptossh.ParseAuthorizedKey(pubKeyBytes) + require.NoError(t, err) + return pubKey +} diff --git a/client/ssh/server.go b/client/ssh/server.go deleted file mode 100644 index 8c5db2547..000000000 --- a/client/ssh/server.go +++ /dev/null @@ -1,280 +0,0 @@ -//go:build !js - -package ssh - -import ( - "fmt" - "io" - "net" - "os" - "os/exec" - "os/user" - "runtime" - "strings" - "sync" - "time" - - "github.com/creack/pty" - "github.com/gliderlabs/ssh" - log "github.com/sirupsen/logrus" -) - -// DefaultSSHPort is the default SSH port of the NetBird's embedded SSH server -const DefaultSSHPort = 44338 - -// TerminalTimeout is the timeout for terminal session to be ready -const TerminalTimeout = 10 * time.Second - -// TerminalBackoffDelay is the delay between terminal session readiness checks -const TerminalBackoffDelay = 500 * time.Millisecond - -// DefaultSSHServer is a function that creates DefaultServer -func DefaultSSHServer(hostKeyPEM []byte, addr string) (Server, error) { - return newDefaultServer(hostKeyPEM, addr) -} - -// Server is an interface of SSH server -type Server interface { - // Stop stops SSH server. - Stop() error - // Start starts SSH server. Blocking - Start() error - // RemoveAuthorizedKey removes SSH key of a given peer from the authorized keys - RemoveAuthorizedKey(peer string) - // AddAuthorizedKey add a given peer key to server authorized keys - AddAuthorizedKey(peer, newKey string) error -} - -// DefaultServer is the embedded NetBird SSH server -type DefaultServer struct { - listener net.Listener - // authorizedKeys is ssh pub key indexed by peer WireGuard public key - authorizedKeys map[string]ssh.PublicKey - mu sync.Mutex - hostKeyPEM []byte - sessions []ssh.Session -} - -// newDefaultServer creates new server with provided host key -func newDefaultServer(hostKeyPEM []byte, addr string) (*DefaultServer, error) { - ln, err := net.Listen("tcp", addr) - if err != nil { - return nil, err - } - allowedKeys := make(map[string]ssh.PublicKey) - return &DefaultServer{listener: ln, mu: sync.Mutex{}, hostKeyPEM: hostKeyPEM, authorizedKeys: allowedKeys, sessions: make([]ssh.Session, 0)}, nil -} - -// RemoveAuthorizedKey removes SSH key of a given peer from the authorized keys -func (srv *DefaultServer) RemoveAuthorizedKey(peer string) { - srv.mu.Lock() - defer srv.mu.Unlock() - - delete(srv.authorizedKeys, peer) -} - -// AddAuthorizedKey add a given peer key to server authorized keys -func (srv *DefaultServer) AddAuthorizedKey(peer, newKey string) error { - srv.mu.Lock() - defer srv.mu.Unlock() - - parsedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(newKey)) - if err != nil { - return err - } - - srv.authorizedKeys[peer] = parsedKey - return nil -} - -// Stop stops SSH server. -func (srv *DefaultServer) Stop() error { - srv.mu.Lock() - defer srv.mu.Unlock() - err := srv.listener.Close() - if err != nil { - return err - } - for _, session := range srv.sessions { - err := session.Close() - if err != nil { - log.Warnf("failed closing SSH session from %v", err) - } - } - - return nil -} - -func (srv *DefaultServer) publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool { - srv.mu.Lock() - defer srv.mu.Unlock() - - for _, allowed := range srv.authorizedKeys { - if ssh.KeysEqual(allowed, key) { - return true - } - } - - return false -} - -func prepareUserEnv(user *user.User, shell string) []string { - return []string{ - fmt.Sprint("SHELL=" + shell), - fmt.Sprint("USER=" + user.Username), - fmt.Sprint("HOME=" + user.HomeDir), - } -} - -func acceptEnv(s string) bool { - split := strings.Split(s, "=") - if len(split) != 2 { - return false - } - return split[0] == "TERM" || split[0] == "LANG" || strings.HasPrefix(split[0], "LC_") -} - -// sessionHandler handles SSH session post auth -func (srv *DefaultServer) sessionHandler(session ssh.Session) { - srv.mu.Lock() - srv.sessions = append(srv.sessions, session) - srv.mu.Unlock() - - defer func() { - err := session.Close() - if err != nil { - return - } - }() - - log.Infof("Establishing SSH session for %s from host %s", session.User(), session.RemoteAddr().String()) - - localUser, err := userNameLookup(session.User()) - if err != nil { - _, err = fmt.Fprintf(session, "remote SSH server couldn't find local user %s\n", session.User()) //nolint - err = session.Exit(1) - if err != nil { - return - } - log.Warnf("failed SSH session from %v, user %s", session.RemoteAddr(), session.User()) - return - } - - ptyReq, winCh, isPty := session.Pty() - if isPty { - loginCmd, loginArgs, err := getLoginCmd(localUser.Username, session.RemoteAddr()) - if err != nil { - log.Warnf("failed logging-in user %s from remote IP %s", localUser.Username, session.RemoteAddr().String()) - return - } - cmd := exec.Command(loginCmd, loginArgs...) - go func() { - <-session.Context().Done() - if cmd.Process == nil { - return - } - err := cmd.Process.Kill() - if err != nil { - log.Debugf("failed killing SSH process %v", err) - return - } - }() - cmd.Dir = localUser.HomeDir - cmd.Env = append(cmd.Env, fmt.Sprintf("TERM=%s", ptyReq.Term)) - cmd.Env = append(cmd.Env, prepareUserEnv(localUser, getUserShell(localUser.Uid))...) - for _, v := range session.Environ() { - if acceptEnv(v) { - cmd.Env = append(cmd.Env, v) - } - } - - log.Debugf("Login command: %s", cmd.String()) - file, err := pty.Start(cmd) - if err != nil { - log.Errorf("failed starting SSH server: %v", err) - } - - go func() { - for win := range winCh { - setWinSize(file, win.Width, win.Height) - } - }() - - srv.stdInOut(file, session) - - err = cmd.Wait() - if err != nil { - return - } - } else { - _, err := io.WriteString(session, "only PTY is supported.\n") - if err != nil { - return - } - err = session.Exit(1) - if err != nil { - return - } - } - log.Debugf("SSH session ended") -} - -func (srv *DefaultServer) stdInOut(file *os.File, session ssh.Session) { - go func() { - // stdin - _, err := io.Copy(file, session) - if err != nil { - _ = session.Exit(1) - return - } - }() - - // AWS Linux 2 machines need some time to open the terminal so we need to wait for it - timer := time.NewTimer(TerminalTimeout) - for { - select { - case <-timer.C: - _, _ = session.Write([]byte("Reached timeout while opening connection\n")) - _ = session.Exit(1) - return - default: - // stdout - writtenBytes, err := io.Copy(session, file) - if err != nil && writtenBytes != 0 { - _ = session.Exit(0) - return - } - time.Sleep(TerminalBackoffDelay) - } - } -} - -// Start starts SSH server. Blocking -func (srv *DefaultServer) Start() error { - log.Infof("starting SSH server on addr: %s", srv.listener.Addr().String()) - - publicKeyOption := ssh.PublicKeyAuth(srv.publicKeyHandler) - hostKeyPEM := ssh.HostKeyPEM(srv.hostKeyPEM) - err := ssh.Serve(srv.listener, srv.sessionHandler, publicKeyOption, hostKeyPEM) - if err != nil { - return err - } - - return nil -} - -func getUserShell(userID string) string { - if runtime.GOOS == "linux" { - output, _ := exec.Command("getent", "passwd", userID).Output() - line := strings.SplitN(string(output), ":", 10) - if len(line) > 6 { - return strings.TrimSpace(line[6]) - } - } - - shell := os.Getenv("SHELL") - if shell == "" { - shell = "/bin/sh" - } - return shell -} diff --git a/client/ssh/server/command_execution.go b/client/ssh/server/command_execution.go new file mode 100644 index 000000000..b0a85fe4b --- /dev/null +++ b/client/ssh/server/command_execution.go @@ -0,0 +1,205 @@ +package server + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "time" + + "github.com/gliderlabs/ssh" + log "github.com/sirupsen/logrus" +) + +// handleExecution executes an SSH command or shell with privilege validation +func (s *Server) handleExecution(logger *log.Entry, session ssh.Session, privilegeResult PrivilegeCheckResult, ptyReq ssh.Pty, winCh <-chan ssh.Window) { + hasPty := winCh != nil + + commandType := "command" + if hasPty { + commandType = "Pty command" + } + + logger.Infof("executing %s: %s", commandType, safeLogCommand(session.Command())) + + execCmd, cleanup, err := s.createCommand(logger, privilegeResult, session, hasPty) + if err != nil { + logger.Errorf("%s creation failed: %v", commandType, err) + + errorMsg := fmt.Sprintf("Cannot create %s - platform may not support user switching", commandType) + if hasPty { + errorMsg += " with Pty" + } + errorMsg += "\n" + + if _, writeErr := fmt.Fprint(session.Stderr(), errorMsg); writeErr != nil { + logger.Debugf(errWriteSession, writeErr) + } + if err := session.Exit(1); err != nil { + logSessionExitError(logger, err) + } + return + } + + if !hasPty { + if s.executeCommand(logger, session, execCmd, cleanup) { + logger.Debugf("%s execution completed", commandType) + } + return + } + + defer cleanup() + + if s.executeCommandWithPty(logger, session, execCmd, privilegeResult, ptyReq, winCh) { + logger.Debugf("%s execution completed", commandType) + } +} + +func (s *Server) createCommand(logger *log.Entry, privilegeResult PrivilegeCheckResult, session ssh.Session, hasPty bool) (*exec.Cmd, func(), error) { + localUser := privilegeResult.User + if localUser == nil { + return nil, nil, errors.New("no user in privilege result") + } + + // If PTY requested but su doesn't support --pty, skip su and use executor + // This ensures PTY functionality is provided (executor runs within our allocated PTY) + if hasPty && !s.suSupportsPty { + logger.Debugf("PTY requested but su doesn't support --pty, using executor for PTY functionality") + cmd, cleanup, err := s.createExecutorCommand(logger, session, localUser, hasPty) + if err != nil { + return nil, nil, fmt.Errorf("create command with privileges: %w", err) + } + cmd.Env = s.prepareCommandEnv(logger, localUser, session) + return cmd, cleanup, nil + } + + // Try su first for system integration (PAM/audit) when privileged + cmd, err := s.createSuCommand(logger, session, localUser, hasPty) + if err != nil || privilegeResult.UsedFallback { + logger.Debugf("su command failed, falling back to executor: %v", err) + cmd, cleanup, err := s.createExecutorCommand(logger, session, localUser, hasPty) + if err != nil { + return nil, nil, fmt.Errorf("create command with privileges: %w", err) + } + cmd.Env = s.prepareCommandEnv(logger, localUser, session) + return cmd, cleanup, nil + } + + cmd.Env = s.prepareCommandEnv(logger, localUser, session) + return cmd, func() {}, nil +} + +// executeCommand executes the command and handles I/O and exit codes +func (s *Server) executeCommand(logger *log.Entry, session ssh.Session, execCmd *exec.Cmd, cleanup func()) bool { + defer cleanup() + + s.setupProcessGroup(execCmd) + + stdinPipe, err := execCmd.StdinPipe() + if err != nil { + logger.Errorf("create stdin pipe: %v", err) + if err := session.Exit(1); err != nil { + logSessionExitError(logger, err) + } + return false + } + + execCmd.Stdout = session + execCmd.Stderr = session.Stderr() + + if execCmd.Dir != "" { + if _, err := os.Stat(execCmd.Dir); err != nil { + logger.Warnf("working directory does not exist: %s (%v)", execCmd.Dir, err) + execCmd.Dir = "/" + } + } + + if err := execCmd.Start(); err != nil { + logger.Errorf("command start failed: %v", err) + // no user message for exec failure, just exit + if err := session.Exit(1); err != nil { + logSessionExitError(logger, err) + } + return false + } + + go s.handleCommandIO(logger, stdinPipe, session) + return s.waitForCommandCleanup(logger, session, execCmd) +} + +// handleCommandIO manages stdin/stdout copying in a goroutine +func (s *Server) handleCommandIO(logger *log.Entry, stdinPipe io.WriteCloser, session ssh.Session) { + defer func() { + if err := stdinPipe.Close(); err != nil { + logger.Debugf("stdin pipe close error: %v", err) + } + }() + if _, err := io.Copy(stdinPipe, session); err != nil { + logger.Debugf("stdin copy error: %v", err) + } +} + +// waitForCommandCleanup waits for command completion with session disconnect handling +func (s *Server) waitForCommandCleanup(logger *log.Entry, session ssh.Session, execCmd *exec.Cmd) bool { + ctx := session.Context() + done := make(chan error, 1) + go func() { + done <- execCmd.Wait() + }() + + select { + case <-ctx.Done(): + logger.Debugf("session cancelled, terminating command") + s.killProcessGroup(execCmd) + + select { + case err := <-done: + logger.Tracef("command terminated after session cancellation: %v", err) + case <-time.After(5 * time.Second): + logger.Warnf("command did not terminate within 5 seconds after session cancellation") + } + + if err := session.Exit(130); err != nil { + logSessionExitError(logger, err) + } + return false + + case err := <-done: + return s.handleCommandCompletion(logger, session, err) + } +} + +// handleCommandCompletion handles command completion +func (s *Server) handleCommandCompletion(logger *log.Entry, session ssh.Session, err error) bool { + if err != nil { + logger.Debugf("command execution failed: %v", err) + s.handleSessionExit(session, err, logger) + return false + } + + s.handleSessionExit(session, nil, logger) + return true +} + +// handleSessionExit handles command errors and sets appropriate exit codes +func (s *Server) handleSessionExit(session ssh.Session, err error, logger *log.Entry) { + if err == nil { + if err := session.Exit(0); err != nil { + logSessionExitError(logger, err) + } + return + } + + var exitError *exec.ExitError + if errors.As(err, &exitError) { + if err := session.Exit(exitError.ExitCode()); err != nil { + logSessionExitError(logger, err) + } + } else { + logger.Debugf("non-exit error in command execution: %v", err) + if err := session.Exit(1); err != nil { + logSessionExitError(logger, err) + } + } +} diff --git a/client/ssh/server/command_execution_js.go b/client/ssh/server/command_execution_js.go new file mode 100644 index 000000000..3aeaa135c --- /dev/null +++ b/client/ssh/server/command_execution_js.go @@ -0,0 +1,57 @@ +//go:build js + +package server + +import ( + "context" + "errors" + "os/exec" + "os/user" + + "github.com/gliderlabs/ssh" + log "github.com/sirupsen/logrus" +) + +var errNotSupported = errors.New("SSH server command execution not supported on WASM/JS platform") + +// createSuCommand is not supported on JS/WASM +func (s *Server) createSuCommand(_ *log.Entry, _ ssh.Session, _ *user.User, _ bool) (*exec.Cmd, error) { + return nil, errNotSupported +} + +// createExecutorCommand is not supported on JS/WASM +func (s *Server) createExecutorCommand(_ *log.Entry, _ ssh.Session, _ *user.User, _ bool) (*exec.Cmd, func(), error) { + return nil, nil, errNotSupported +} + +// prepareCommandEnv is not supported on JS/WASM +func (s *Server) prepareCommandEnv(_ *log.Entry, _ *user.User, _ ssh.Session) []string { + return nil +} + +// setupProcessGroup is not supported on JS/WASM +func (s *Server) setupProcessGroup(_ *exec.Cmd) { +} + +// killProcessGroup is not supported on JS/WASM +func (s *Server) killProcessGroup(*exec.Cmd) { +} + +// detectSuPtySupport always returns false on JS/WASM +func (s *Server) detectSuPtySupport(context.Context) bool { + return false +} + +// detectUtilLinuxLogin always returns false on JS/WASM +func (s *Server) detectUtilLinuxLogin(context.Context) bool { + return false +} + +// executeCommandWithPty is not supported on JS/WASM +func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, execCmd *exec.Cmd, privilegeResult PrivilegeCheckResult, ptyReq ssh.Pty, winCh <-chan ssh.Window) bool { + logger.Errorf("PTY command execution not supported on JS/WASM") + if err := session.Exit(1); err != nil { + logSessionExitError(logger, err) + } + return false +} diff --git a/client/ssh/server/command_execution_unix.go b/client/ssh/server/command_execution_unix.go new file mode 100644 index 000000000..279b89341 --- /dev/null +++ b/client/ssh/server/command_execution_unix.go @@ -0,0 +1,364 @@ +//go:build unix + +package server + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "os/user" + "path/filepath" + "runtime" + "strings" + "sync" + "syscall" + "time" + + "github.com/creack/pty" + "github.com/gliderlabs/ssh" + log "github.com/sirupsen/logrus" +) + +// ptyManager manages Pty file operations with thread safety +type ptyManager struct { + file *os.File + mu sync.RWMutex + closed bool + closeErr error + once sync.Once +} + +func newPtyManager(file *os.File) *ptyManager { + return &ptyManager{file: file} +} + +func (pm *ptyManager) Close() error { + pm.once.Do(func() { + pm.mu.Lock() + pm.closed = true + pm.closeErr = pm.file.Close() + pm.mu.Unlock() + }) + pm.mu.RLock() + defer pm.mu.RUnlock() + return pm.closeErr +} + +func (pm *ptyManager) Setsize(ws *pty.Winsize) error { + pm.mu.RLock() + defer pm.mu.RUnlock() + if pm.closed { + return errors.New("pty is closed") + } + return pty.Setsize(pm.file, ws) +} + +func (pm *ptyManager) File() *os.File { + return pm.file +} + +// detectSuPtySupport checks if su supports the --pty flag +func (s *Server) detectSuPtySupport(ctx context.Context) bool { + ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer cancel() + + cmd := exec.CommandContext(ctx, "su", "--help") + output, err := cmd.CombinedOutput() + if err != nil { + log.Debugf("su --help failed (may not support --help): %v", err) + return false + } + + supported := strings.Contains(string(output), "--pty") + log.Debugf("su --pty support detected: %v", supported) + return supported +} + +// detectUtilLinuxLogin checks if login is from util-linux (vs shadow-utils). +// util-linux login uses vhangup() which requires setsid wrapper to avoid killing parent. +// See https://bugs.debian.org/1078023 for details. +func (s *Server) detectUtilLinuxLogin(ctx context.Context) bool { + if runtime.GOOS != "linux" { + return false + } + + ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer cancel() + + cmd := exec.CommandContext(ctx, "login", "--version") + output, err := cmd.CombinedOutput() + if err != nil { + log.Debugf("login --version failed (likely shadow-utils): %v", err) + return false + } + + isUtilLinux := strings.Contains(string(output), "util-linux") + log.Debugf("util-linux login detected: %v", isUtilLinux) + return isUtilLinux +} + +// createSuCommand creates a command using su - for privilege switching. +func (s *Server) createSuCommand(logger *log.Entry, session ssh.Session, localUser *user.User, hasPty bool) (*exec.Cmd, error) { + if err := validateUsername(localUser.Username); err != nil { + return nil, fmt.Errorf("invalid username %q: %w", localUser.Username, err) + } + + suPath, err := exec.LookPath("su") + if err != nil { + return nil, fmt.Errorf("su command not available: %w", err) + } + + args := []string{"-"} + if hasPty && s.suSupportsPty { + args = append(args, "--pty") + } + args = append(args, localUser.Username) + + command := session.RawCommand() + if command != "" { + args = append(args, "-c", command) + } + + logger.Debugf("creating su command: %s %v", suPath, args) + cmd := exec.CommandContext(session.Context(), suPath, args...) + cmd.Dir = localUser.HomeDir + + return cmd, nil +} + +// getShellCommandArgs returns the shell command and arguments for executing a command string. +func (s *Server) getShellCommandArgs(shell, cmdString string) []string { + if cmdString == "" { + return []string{shell} + } + return []string{shell, "-c", cmdString} +} + +// createShellCommand creates an exec.Cmd configured as a login shell by setting argv[0] to "-shellname". +func (s *Server) createShellCommand(ctx context.Context, shell string, args []string) *exec.Cmd { + cmd := exec.CommandContext(ctx, shell, args[1:]...) + cmd.Args[0] = "-" + filepath.Base(shell) + return cmd +} + +// prepareCommandEnv prepares environment variables for command execution on Unix +func (s *Server) prepareCommandEnv(_ *log.Entry, localUser *user.User, session ssh.Session) []string { + env := prepareUserEnv(localUser, getUserShell(localUser.Uid)) + env = append(env, prepareSSHEnv(session)...) + for _, v := range session.Environ() { + if acceptEnv(v) { + env = append(env, v) + } + } + return env +} + +// executeCommandWithPty executes a command with PTY allocation +func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, execCmd *exec.Cmd, privilegeResult PrivilegeCheckResult, ptyReq ssh.Pty, winCh <-chan ssh.Window) bool { + termType := ptyReq.Term + if termType == "" { + termType = "xterm-256color" + } + execCmd.Env = append(execCmd.Env, fmt.Sprintf("TERM=%s", termType)) + + return s.runPtyCommand(logger, session, execCmd, ptyReq, winCh) +} + +func (s *Server) handlePtyLogin(logger *log.Entry, session ssh.Session, privilegeResult PrivilegeCheckResult, ptyReq ssh.Pty, winCh <-chan ssh.Window) bool { + execCmd, err := s.createPtyCommand(privilegeResult, ptyReq, session) + if err != nil { + logger.Errorf("Pty command creation failed: %v", err) + errorMsg := "User switching failed - login command not available\r\n" + if _, writeErr := fmt.Fprint(session.Stderr(), errorMsg); writeErr != nil { + logger.Debugf(errWriteSession, writeErr) + } + if err := session.Exit(1); err != nil { + logSessionExitError(logger, err) + } + return false + } + + logger.Infof("starting interactive shell: %s", strings.Join(execCmd.Args, " ")) + return s.runPtyCommand(logger, session, execCmd, ptyReq, winCh) +} + +// runPtyCommand runs a command with PTY management (common code for interactive and command execution) +func (s *Server) runPtyCommand(logger *log.Entry, session ssh.Session, execCmd *exec.Cmd, ptyReq ssh.Pty, winCh <-chan ssh.Window) bool { + ptmx, err := s.startPtyCommandWithSize(execCmd, ptyReq) + if err != nil { + logger.Errorf("Pty start failed: %v", err) + if err := session.Exit(1); err != nil { + logSessionExitError(logger, err) + } + return false + } + + ptyMgr := newPtyManager(ptmx) + defer func() { + if err := ptyMgr.Close(); err != nil { + logger.Debugf("Pty close error: %v", err) + } + }() + + go s.handlePtyWindowResize(logger, session, ptyMgr, winCh) + s.handlePtyIO(logger, session, ptyMgr) + s.waitForPtyCompletion(logger, session, execCmd, ptyMgr) + return true +} + +func (s *Server) startPtyCommandWithSize(execCmd *exec.Cmd, ptyReq ssh.Pty) (*os.File, error) { + winSize := &pty.Winsize{ + Cols: uint16(ptyReq.Window.Width), + Rows: uint16(ptyReq.Window.Height), + } + if winSize.Cols == 0 { + winSize.Cols = 80 + } + if winSize.Rows == 0 { + winSize.Rows = 24 + } + + ptmx, err := pty.StartWithSize(execCmd, winSize) + if err != nil { + return nil, fmt.Errorf("start Pty: %w", err) + } + + return ptmx, nil +} + +func (s *Server) handlePtyWindowResize(logger *log.Entry, session ssh.Session, ptyMgr *ptyManager, winCh <-chan ssh.Window) { + for { + select { + case <-session.Context().Done(): + return + case win, ok := <-winCh: + if !ok { + return + } + if err := ptyMgr.Setsize(&pty.Winsize{Rows: uint16(win.Height), Cols: uint16(win.Width)}); err != nil { + logger.Debugf("Pty resize to %dx%d: %v", win.Width, win.Height, err) + } + } + } +} + +func (s *Server) handlePtyIO(logger *log.Entry, session ssh.Session, ptyMgr *ptyManager) { + ptmx := ptyMgr.File() + + go func() { + if _, err := io.Copy(ptmx, session); err != nil { + if !errors.Is(err, io.EOF) && !errors.Is(err, syscall.EIO) { + logger.Warnf("Pty input copy error: %v", err) + } + } + }() + + go func() { + if _, err := io.Copy(session, ptmx); err != nil { + if !errors.Is(err, io.EOF) && !errors.Is(err, syscall.EIO) { + logger.Warnf("Pty output copy error: %v", err) + } + } + }() +} + +func (s *Server) waitForPtyCompletion(logger *log.Entry, session ssh.Session, execCmd *exec.Cmd, ptyMgr *ptyManager) { + ctx := session.Context() + done := make(chan error, 1) + go func() { + done <- execCmd.Wait() + }() + + select { + case <-ctx.Done(): + s.handlePtySessionCancellation(logger, session, execCmd, ptyMgr, done) + case err := <-done: + s.handlePtyCommandCompletion(logger, session, ptyMgr, err) + } +} + +func (s *Server) handlePtySessionCancellation(logger *log.Entry, session ssh.Session, execCmd *exec.Cmd, ptyMgr *ptyManager, done <-chan error) { + logger.Debugf("Pty session cancelled, terminating command") + if err := ptyMgr.Close(); err != nil { + logger.Debugf("Pty close during session cancellation: %v", err) + } + + s.killProcessGroup(execCmd) + + select { + case err := <-done: + if err != nil { + logger.Debugf("Pty command terminated after session cancellation with error: %v", err) + } else { + logger.Debugf("Pty command terminated after session cancellation") + } + case <-time.After(5 * time.Second): + logger.Warnf("Pty command did not terminate within 5 seconds after session cancellation") + } + + if err := session.Exit(130); err != nil { + logSessionExitError(logger, err) + } +} + +func (s *Server) handlePtyCommandCompletion(logger *log.Entry, session ssh.Session, ptyMgr *ptyManager, err error) { + if err != nil { + logger.Debugf("Pty command execution failed: %v", err) + s.handleSessionExit(session, err, logger) + } else { + logger.Debugf("Pty command completed successfully") + if err := session.Exit(0); err != nil { + logSessionExitError(logger, err) + } + } + + // Close PTY to unblock io.Copy goroutines + if err := ptyMgr.Close(); err != nil { + logger.Debugf("Pty close after completion: %v", err) + } +} + +func (s *Server) setupProcessGroup(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } +} + +func (s *Server) killProcessGroup(cmd *exec.Cmd) { + if cmd.Process == nil { + return + } + + logger := log.WithField("pid", cmd.Process.Pid) + pgid := cmd.Process.Pid + + if err := syscall.Kill(-pgid, syscall.SIGTERM); err != nil { + logger.Debugf("kill process group SIGTERM: %v", err) + return + } + + const gracePeriod = 500 * time.Millisecond + const checkInterval = 50 * time.Millisecond + + ticker := time.NewTicker(checkInterval) + defer ticker.Stop() + + timeout := time.After(gracePeriod) + + for { + select { + case <-timeout: + if err := syscall.Kill(-pgid, syscall.SIGKILL); err != nil { + logger.Debugf("kill process group SIGKILL: %v", err) + } + return + case <-ticker.C: + if err := syscall.Kill(-pgid, 0); err != nil { + return + } + } + } +} diff --git a/client/ssh/server/command_execution_windows.go b/client/ssh/server/command_execution_windows.go new file mode 100644 index 000000000..e1ba777f6 --- /dev/null +++ b/client/ssh/server/command_execution_windows.go @@ -0,0 +1,408 @@ +package server + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "strings" + "unsafe" + + "github.com/gliderlabs/ssh" + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + + "github.com/netbirdio/netbird/client/ssh/server/winpty" +) + +// getUserEnvironment retrieves the Windows environment for the target user. +// Follows OpenSSH's resilient approach with graceful degradation on failures. +func (s *Server) getUserEnvironment(logger *log.Entry, username, domain string) ([]string, error) { + userToken, err := s.getUserToken(logger, username, domain) + if err != nil { + return nil, fmt.Errorf("get user token: %w", err) + } + defer func() { + if err := windows.CloseHandle(userToken); err != nil { + logger.Debugf("close user token: %v", err) + } + }() + + return s.getUserEnvironmentWithToken(logger, userToken, username, domain) +} + +// getUserEnvironmentWithToken retrieves the Windows environment using an existing token. +func (s *Server) getUserEnvironmentWithToken(logger *log.Entry, userToken windows.Handle, username, domain string) ([]string, error) { + userProfile, err := s.loadUserProfile(userToken, username, domain) + if err != nil { + logger.Debugf("failed to load user profile for %s\\%s: %v", domain, username, err) + userProfile = fmt.Sprintf("C:\\Users\\%s", username) + } + + envMap := make(map[string]string) + + if err := s.loadSystemEnvironment(envMap); err != nil { + logger.Debugf("failed to load system environment from registry: %v", err) + } + + s.setUserEnvironmentVariables(envMap, userProfile, username, domain) + + var env []string + for key, value := range envMap { + env = append(env, key+"="+value) + } + + return env, nil +} + +// getUserToken creates a user token for the specified user. +func (s *Server) getUserToken(logger *log.Entry, username, domain string) (windows.Handle, error) { + privilegeDropper := NewPrivilegeDropper(WithLogger(logger)) + token, err := privilegeDropper.createToken(username, domain) + if err != nil { + return 0, fmt.Errorf("generate S4U user token: %w", err) + } + return token, nil +} + +// loadUserProfile loads the Windows user profile and returns the profile path. +func (s *Server) loadUserProfile(userToken windows.Handle, username, domain string) (string, error) { + usernamePtr, err := windows.UTF16PtrFromString(username) + if err != nil { + return "", fmt.Errorf("convert username to UTF-16: %w", err) + } + + var domainUTF16 *uint16 + if domain != "" && domain != "." { + domainUTF16, err = windows.UTF16PtrFromString(domain) + if err != nil { + return "", fmt.Errorf("convert domain to UTF-16: %w", err) + } + } + + type profileInfo struct { + dwSize uint32 + dwFlags uint32 + lpUserName *uint16 + lpProfilePath *uint16 + lpDefaultPath *uint16 + lpServerName *uint16 + lpPolicyPath *uint16 + hProfile windows.Handle + } + + const PI_NOUI = 0x00000001 + + profile := profileInfo{ + dwSize: uint32(unsafe.Sizeof(profileInfo{})), + dwFlags: PI_NOUI, + lpUserName: usernamePtr, + lpServerName: domainUTF16, + } + + userenv := windows.NewLazySystemDLL("userenv.dll") + loadUserProfileW := userenv.NewProc("LoadUserProfileW") + + ret, _, err := loadUserProfileW.Call( + uintptr(userToken), + uintptr(unsafe.Pointer(&profile)), + ) + + if ret == 0 { + return "", fmt.Errorf("LoadUserProfileW: %w", err) + } + + if profile.lpProfilePath == nil { + return "", fmt.Errorf("LoadUserProfileW returned null profile path") + } + + profilePath := windows.UTF16PtrToString(profile.lpProfilePath) + return profilePath, nil +} + +// loadSystemEnvironment loads system-wide environment variables from registry. +func (s *Server) loadSystemEnvironment(envMap map[string]string) error { + key, err := registry.OpenKey(registry.LOCAL_MACHINE, + `SYSTEM\CurrentControlSet\Control\Session Manager\Environment`, + registry.QUERY_VALUE) + if err != nil { + return fmt.Errorf("open system environment registry key: %w", err) + } + defer func() { + if err := key.Close(); err != nil { + log.Debugf("close registry key: %v", err) + } + }() + + return s.readRegistryEnvironment(key, envMap) +} + +// readRegistryEnvironment reads environment variables from a registry key. +func (s *Server) readRegistryEnvironment(key registry.Key, envMap map[string]string) error { + names, err := key.ReadValueNames(0) + if err != nil { + return fmt.Errorf("read registry value names: %w", err) + } + + for _, name := range names { + value, valueType, err := key.GetStringValue(name) + if err != nil { + log.Debugf("failed to read registry value %s: %v", name, err) + continue + } + + finalValue := s.expandRegistryValue(value, valueType, name) + s.setEnvironmentVariable(envMap, name, finalValue) + } + + return nil +} + +// expandRegistryValue expands registry values if they contain environment variables. +func (s *Server) expandRegistryValue(value string, valueType uint32, name string) string { + if valueType != registry.EXPAND_SZ { + return value + } + + sourcePtr := windows.StringToUTF16Ptr(value) + expandedBuffer := make([]uint16, 1024) + expandedLen, err := windows.ExpandEnvironmentStrings(sourcePtr, &expandedBuffer[0], uint32(len(expandedBuffer))) + if err != nil { + log.Debugf("failed to expand environment string for %s: %v", name, err) + return value + } + + // If buffer was too small, retry with larger buffer + if expandedLen > uint32(len(expandedBuffer)) { + expandedBuffer = make([]uint16, expandedLen) + expandedLen, err = windows.ExpandEnvironmentStrings(sourcePtr, &expandedBuffer[0], uint32(len(expandedBuffer))) + if err != nil { + log.Debugf("failed to expand environment string for %s on retry: %v", name, err) + return value + } + } + + if expandedLen > 0 && expandedLen <= uint32(len(expandedBuffer)) { + return windows.UTF16ToString(expandedBuffer[:expandedLen-1]) + } + return value +} + +// setEnvironmentVariable sets an environment variable with special handling for PATH. +func (s *Server) setEnvironmentVariable(envMap map[string]string, name, value string) { + upperName := strings.ToUpper(name) + + if upperName == "PATH" { + if existing, exists := envMap["PATH"]; exists && existing != value { + envMap["PATH"] = existing + ";" + value + } else { + envMap["PATH"] = value + } + } else { + envMap[upperName] = value + } +} + +// setUserEnvironmentVariables sets critical user-specific environment variables. +func (s *Server) setUserEnvironmentVariables(envMap map[string]string, userProfile, username, domain string) { + envMap["USERPROFILE"] = userProfile + + if len(userProfile) >= 2 && userProfile[1] == ':' { + envMap["HOMEDRIVE"] = userProfile[:2] + envMap["HOMEPATH"] = userProfile[2:] + } + + envMap["APPDATA"] = filepath.Join(userProfile, "AppData", "Roaming") + envMap["LOCALAPPDATA"] = filepath.Join(userProfile, "AppData", "Local") + + tempDir := filepath.Join(userProfile, "AppData", "Local", "Temp") + envMap["TEMP"] = tempDir + envMap["TMP"] = tempDir + + envMap["USERNAME"] = username + if domain != "" && domain != "." { + envMap["USERDOMAIN"] = domain + envMap["USERDNSDOMAIN"] = domain + } + + systemVars := []string{ + "PROCESSOR_ARCHITECTURE", "PROCESSOR_IDENTIFIER", "PROCESSOR_LEVEL", "PROCESSOR_REVISION", + "SYSTEMDRIVE", "SYSTEMROOT", "WINDIR", "COMPUTERNAME", "OS", "PATHEXT", + "PROGRAMFILES", "PROGRAMDATA", "ALLUSERSPROFILE", "COMSPEC", + } + + for _, sysVar := range systemVars { + if sysValue := os.Getenv(sysVar); sysValue != "" { + envMap[sysVar] = sysValue + } + } +} + +// 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) + 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) + env := prepareUserEnv(localUser, getUserShell(localUser.Uid)) + env = append(env, prepareSSHEnv(session)...) + for _, v := range session.Environ() { + if acceptEnv(v) { + env = append(env, v) + } + } + return env + } + + env := userEnv + env = append(env, prepareSSHEnv(session)...) + for _, v := range session.Environ() { + if acceptEnv(v) { + env = append(env, v) + } + } + return env +} + +func (s *Server) handlePtyLogin(logger *log.Entry, session ssh.Session, privilegeResult PrivilegeCheckResult, ptyReq ssh.Pty, _ <-chan ssh.Window) bool { + if privilegeResult.User == nil { + logger.Errorf("no user in privilege result") + return false + } + + shell := getUserShell(privilegeResult.User.Uid) + logger.Infof("starting interactive shell: %s", shell) + + s.executeCommandWithPty(logger, session, nil, privilegeResult, ptyReq, nil) + return true +} + +// getShellCommandArgs returns the shell command and arguments for executing a command string +func (s *Server) getShellCommandArgs(shell, cmdString string) []string { + if cmdString == "" { + return []string{shell, "-NoLogo"} + } + return []string{shell, "-Command", cmdString} +} + +type PtyExecutionRequest struct { + Shell string + Command string + Width int + Height int + Username string + Domain string +} + +func executePtyCommandWithUserToken(logger *log.Entry, session ssh.Session, req PtyExecutionRequest) error { + logger.Tracef("executing Windows ConPty command with user switching: shell=%s, command=%s, user=%s\\%s, size=%dx%d", + req.Shell, req.Command, req.Domain, req.Username, req.Width, req.Height) + + privilegeDropper := NewPrivilegeDropper(WithLogger(logger)) + userToken, err := privilegeDropper.createToken(req.Username, req.Domain) + if err != nil { + return fmt.Errorf("create user token: %w", err) + } + defer func() { + if err := windows.CloseHandle(userToken); err != nil { + logger.Debugf("close user token: %v", err) + } + }() + + server := &Server{} + userEnv, err := server.getUserEnvironmentWithToken(logger, userToken, req.Username, req.Domain) + if err != nil { + logger.Debugf("failed to get user environment for %s\\%s, using system environment: %v", req.Domain, req.Username, err) + userEnv = os.Environ() + } + + workingDir := getUserHomeFromEnv(userEnv) + if workingDir == "" { + workingDir = fmt.Sprintf(`C:\Users\%s`, req.Username) + } + + ptyConfig := winpty.PtyConfig{ + Shell: req.Shell, + Command: req.Command, + Width: req.Width, + Height: req.Height, + WorkingDir: workingDir, + } + + userConfig := winpty.UserConfig{ + Token: userToken, + Environment: userEnv, + } + + logger.Debugf("executePtyCommandWithUserToken: calling winpty execution with working dir: %s", workingDir) + return winpty.ExecutePtyWithUserToken(session, ptyConfig, userConfig) +} + +func getUserHomeFromEnv(env []string) string { + for _, envVar := range env { + if len(envVar) > 12 && envVar[:12] == "USERPROFILE=" { + return envVar[12:] + } + } + return "" +} + +func (s *Server) setupProcessGroup(_ *exec.Cmd) { + // Windows doesn't support process groups in the same way as Unix + // Process creation groups are handled differently +} + +func (s *Server) killProcessGroup(cmd *exec.Cmd) { + if cmd.Process == nil { + return + } + + if err := cmd.Process.Kill(); err != nil { + log.Debugf("kill process %d failed: %v", cmd.Process.Pid, err) + } +} + +// detectSuPtySupport always returns false on Windows as su is not available +func (s *Server) detectSuPtySupport(context.Context) bool { + return false +} + +// detectUtilLinuxLogin always returns false on Windows +func (s *Server) detectUtilLinuxLogin(context.Context) bool { + return false +} + +// executeCommandWithPty executes a command with PTY allocation on Windows using ConPty +func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, _ *exec.Cmd, privilegeResult PrivilegeCheckResult, ptyReq ssh.Pty, _ <-chan ssh.Window) bool { + localUser := privilegeResult.User + if localUser == nil { + logger.Errorf("no user in privilege result") + return false + } + + username, domain := s.parseUsername(localUser.Username) + shell := getUserShell(localUser.Uid) + + req := PtyExecutionRequest{ + Shell: shell, + Command: session.RawCommand(), + Width: ptyReq.Window.Width, + Height: ptyReq.Window.Height, + Username: username, + Domain: domain, + } + + if err := executePtyCommandWithUserToken(logger, session, req); err != nil { + logger.Errorf("ConPty execution failed: %v", err) + if err := session.Exit(1); err != nil { + logSessionExitError(logger, err) + } + return false + } + + logger.Debug("ConPty execution completed") + return true +} diff --git a/client/ssh/server/compatibility_test.go b/client/ssh/server/compatibility_test.go new file mode 100644 index 000000000..7fe2d6c5e --- /dev/null +++ b/client/ssh/server/compatibility_test.go @@ -0,0 +1,932 @@ +package server + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "errors" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" + + nbssh "github.com/netbirdio/netbird/client/ssh" + "github.com/netbirdio/netbird/client/ssh/testutil" +) + +func TestMain(m *testing.M) { + // On platforms where su doesn't support --pty (macOS, FreeBSD, Windows), the SSH server + // spawns an executor subprocess via os.Executable(). During tests, this invokes the test + // binary with "ssh exec" args. We handle that here to properly execute commands and + // propagate exit codes. + if len(os.Args) > 2 && os.Args[1] == "ssh" && os.Args[2] == "exec" { + runTestExecutor() + return + } + + code := m.Run() + testutil.CleanupTestUsers() + os.Exit(code) +} + +// runTestExecutor emulates the netbird executor for tests. +// Parses --shell and --cmd args, runs the command, and exits with the correct code. +func runTestExecutor() { + if os.Getenv("_NETBIRD_TEST_EXECUTOR") != "" { + fmt.Fprintf(os.Stderr, "executor recursion detected\n") + os.Exit(1) + } + os.Setenv("_NETBIRD_TEST_EXECUTOR", "1") + + shell := "/bin/sh" + var command string + for i := 3; i < len(os.Args); i++ { + switch os.Args[i] { + case "--shell": + if i+1 < len(os.Args) { + shell = os.Args[i+1] + i++ + } + case "--cmd": + if i+1 < len(os.Args) { + command = os.Args[i+1] + i++ + } + } + } + + var cmd *exec.Cmd + if command == "" { + cmd = exec.Command(shell) + } else { + cmd = exec.Command(shell, "-c", command) + } + cmd.Args[0] = "-" + filepath.Base(shell) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + os.Exit(1) + } + os.Exit(0) +} + +// TestSSHServerCompatibility tests that our SSH server is compatible with the system SSH client +func TestSSHServerCompatibility(t *testing.T) { + if testing.Short() { + t.Skip("Skipping SSH compatibility tests in short mode") + } + + // Check if ssh binary is available + if !isSSHClientAvailable() { + t.Skip("SSH client not available on this system") + } + + // Set up SSH server - use our existing key generation for server + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + + // Generate OpenSSH-compatible keys for client + clientPrivKeyOpenSSH, _, err := generateOpenSSHKey(t) + require.NoError(t, err) + + serverConfig := &Config{ + HostKeyPEM: hostKey, + JWT: nil, + } + server := New(serverConfig) + server.SetAllowRootLogin(true) + + serverAddr := StartTestServer(t, server) + defer func() { + err := server.Stop() + require.NoError(t, err) + }() + + // Create temporary key files for SSH client + clientKeyFile, cleanupKey := createTempKeyFileFromBytes(t, clientPrivKeyOpenSSH) + defer cleanupKey() + + // Extract host and port from server address + host, portStr, err := net.SplitHostPort(serverAddr) + require.NoError(t, err) + + // Get appropriate user for SSH connection (handle system accounts) + username := testutil.GetTestUsername(t) + + t.Run("basic command execution", func(t *testing.T) { + testSSHCommandExecutionWithUser(t, host, portStr, clientKeyFile, username) + }) + + t.Run("interactive command", func(t *testing.T) { + testSSHInteractiveCommand(t, host, portStr, clientKeyFile) + }) + + t.Run("port forwarding", func(t *testing.T) { + testSSHPortForwarding(t, host, portStr, clientKeyFile) + }) +} + +// testSSHCommandExecutionWithUser tests basic command execution with system SSH client using specified user. +func testSSHCommandExecutionWithUser(t *testing.T, host, port, keyFile, username string) { + cmd := exec.Command("ssh", + "-i", keyFile, + "-p", port, + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=5", + fmt.Sprintf("%s@%s", username, host), + "echo", "hello_world") + + output, err := cmd.CombinedOutput() + + if err != nil { + t.Logf("SSH command failed: %v", err) + t.Logf("Output: %s", string(output)) + return + } + + assert.Contains(t, string(output), "hello_world", "SSH command should execute successfully") +} + +// testSSHInteractiveCommand tests interactive shell session. +func testSSHInteractiveCommand(t *testing.T, host, port, keyFile string) { + // Get appropriate user for SSH connection + username := testutil.GetTestUsername(t) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "ssh", + "-i", keyFile, + "-p", port, + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=5", + fmt.Sprintf("%s@%s", username, host)) + + stdin, err := cmd.StdinPipe() + if err != nil { + t.Skipf("Cannot create stdin pipe: %v", err) + return + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Skipf("Cannot create stdout pipe: %v", err) + return + } + + err = cmd.Start() + if err != nil { + t.Logf("Cannot start SSH session: %v", err) + return + } + + go func() { + defer func() { + if err := stdin.Close(); err != nil { + t.Logf("stdin close error: %v", err) + } + }() + time.Sleep(100 * time.Millisecond) + if _, err := stdin.Write([]byte("echo interactive_test\n")); err != nil { + t.Logf("stdin write error: %v", err) + } + time.Sleep(100 * time.Millisecond) + if _, err := stdin.Write([]byte("exit\n")); err != nil { + t.Logf("stdin write error: %v", err) + } + }() + + output, err := io.ReadAll(stdout) + if err != nil { + t.Logf("Cannot read SSH output: %v", err) + } + + err = cmd.Wait() + if err != nil { + t.Logf("SSH interactive session error: %v", err) + t.Logf("Output: %s", string(output)) + return + } + + assert.Contains(t, string(output), "interactive_test", "Interactive SSH session should work") +} + +// testSSHPortForwarding tests port forwarding compatibility. +func testSSHPortForwarding(t *testing.T, host, port, keyFile string) { + // Get appropriate user for SSH connection + username := testutil.GetTestUsername(t) + + testServer, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer testServer.Close() + + testServerAddr := testServer.Addr().String() + expectedResponse := "HTTP/1.1 200 OK\r\nContent-Length: 21\r\n\r\nCompatibility Test OK" + + go func() { + for { + conn, err := testServer.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer func() { + if err := c.Close(); err != nil { + t.Logf("test server connection close error: %v", err) + } + }() + buf := make([]byte, 1024) + if _, err := c.Read(buf); err != nil { + t.Logf("Test server read error: %v", err) + } + if _, err := c.Write([]byte(expectedResponse)); err != nil { + t.Logf("Test server write error: %v", err) + } + }(conn) + } + }() + + localListener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + localAddr := localListener.Addr().String() + localListener.Close() + + _, localPort, err := net.SplitHostPort(localAddr) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + forwardSpec := fmt.Sprintf("%s:%s", localPort, testServerAddr) + cmd := exec.CommandContext(ctx, "ssh", + "-i", keyFile, + "-p", port, + "-L", forwardSpec, + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=5", + "-N", + fmt.Sprintf("%s@%s", username, host)) + + err = cmd.Start() + if err != nil { + t.Logf("Cannot start SSH port forwarding: %v", err) + return + } + + defer func() { + if cmd.Process != nil { + if err := cmd.Process.Kill(); err != nil { + t.Logf("process kill error: %v", err) + } + } + if err := cmd.Wait(); err != nil { + t.Logf("process wait after kill: %v", err) + } + }() + + time.Sleep(500 * time.Millisecond) + + conn, err := net.DialTimeout("tcp", localAddr, 3*time.Second) + if err != nil { + t.Logf("Cannot connect to forwarded port: %v", err) + return + } + defer func() { + if err := conn.Close(); err != nil { + t.Logf("forwarded connection close error: %v", err) + } + }() + + request := "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" + _, err = conn.Write([]byte(request)) + require.NoError(t, err) + + if err := conn.SetReadDeadline(time.Now().Add(3 * time.Second)); err != nil { + log.Debugf("failed to set read deadline: %v", err) + } + response := make([]byte, len(expectedResponse)) + n, err := io.ReadFull(conn, response) + if err != nil { + t.Logf("Cannot read forwarded response: %v", err) + return + } + + assert.Equal(t, len(expectedResponse), n, "Should read expected number of bytes") + assert.Equal(t, expectedResponse, string(response), "Should get correct HTTP response through SSH port forwarding") +} + +// isSSHClientAvailable checks if the ssh binary is available +func isSSHClientAvailable() bool { + _, err := exec.LookPath("ssh") + return err == nil +} + +// generateOpenSSHKey generates an ED25519 key in OpenSSH format that the system SSH client can use. +func generateOpenSSHKey(t *testing.T) ([]byte, []byte, error) { + // Check if ssh-keygen is available + if _, err := exec.LookPath("ssh-keygen"); err != nil { + // Fall back to our existing key generation and try to convert + return generateOpenSSHKeyFallback() + } + + // Create temporary file for ssh-keygen + tempFile, err := os.CreateTemp("", "ssh_keygen_*") + if err != nil { + return nil, nil, fmt.Errorf("create temp file: %w", err) + } + keyPath := tempFile.Name() + tempFile.Close() + + // Remove the temp file so ssh-keygen can create it + if err := os.Remove(keyPath); err != nil { + t.Logf("failed to remove key file: %v", err) + } + + // Clean up temp files + defer func() { + if err := os.Remove(keyPath); err != nil { + t.Logf("failed to cleanup key file: %v", err) + } + if err := os.Remove(keyPath + ".pub"); err != nil { + t.Logf("failed to cleanup public key file: %v", err) + } + }() + + // Generate key using ssh-keygen + cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", keyPath, "-N", "", "-q") + output, err := cmd.CombinedOutput() + if err != nil { + return nil, nil, fmt.Errorf("ssh-keygen failed: %w, output: %s", err, string(output)) + } + + // Read private key + privKeyBytes, err := os.ReadFile(keyPath) + if err != nil { + return nil, nil, fmt.Errorf("read private key: %w", err) + } + + // Read public key + pubKeyBytes, err := os.ReadFile(keyPath + ".pub") + if err != nil { + return nil, nil, fmt.Errorf("read public key: %w", err) + } + + return privKeyBytes, pubKeyBytes, nil +} + +// generateOpenSSHKeyFallback falls back to generating keys using our existing method +func generateOpenSSHKeyFallback() ([]byte, []byte, error) { + // Generate shared.ED25519 key pair using our existing method + _, privKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("generate key: %w", err) + } + + // Convert to SSH format + sshPrivKey, err := ssh.NewSignerFromKey(privKey) + if err != nil { + return nil, nil, fmt.Errorf("create signer: %w", err) + } + + // For the fallback, just use our PKCS#8 format and hope it works + // This won't be in OpenSSH format but might still work with some SSH clients + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + if err != nil { + return nil, nil, fmt.Errorf("generate fallback key: %w", err) + } + + // Get public key in SSH format + sshPubKey := ssh.MarshalAuthorizedKey(sshPrivKey.PublicKey()) + + return hostKey, sshPubKey, nil +} + +// createTempKeyFileFromBytes creates a temporary SSH private key file from raw bytes +func createTempKeyFileFromBytes(t *testing.T, keyBytes []byte) (string, func()) { + t.Helper() + + tempFile, err := os.CreateTemp("", "ssh_test_key_*") + require.NoError(t, err) + + _, err = tempFile.Write(keyBytes) + require.NoError(t, err) + + err = tempFile.Close() + require.NoError(t, err) + + // Set proper permissions for SSH key (readable by owner only) + err = os.Chmod(tempFile.Name(), 0600) + require.NoError(t, err) + + cleanup := func() { + _ = os.Remove(tempFile.Name()) + } + + return tempFile.Name(), cleanup +} + +// createTempKeyFile creates a temporary SSH private key file (for backward compatibility) +func createTempKeyFile(t *testing.T, privateKey []byte) (string, func()) { + return createTempKeyFileFromBytes(t, privateKey) +} + +// TestSSHPtyModes tests different PTY allocation modes (-T, -t, -tt flags) +// This ensures our implementation matches OpenSSH behavior for: +// - ssh host command (no PTY - default when no TTY) +// - ssh -T host command (explicit no PTY) +// - ssh -t host command (force PTY) +// - ssh -T host (no PTY shell - our implementation) +func TestSSHPtyModes(t *testing.T) { + if testing.Short() { + t.Skip("Skipping SSH PTY mode tests in short mode") + } + + if !isSSHClientAvailable() { + t.Skip("SSH client not available on this system") + } + + if runtime.GOOS == "windows" && testutil.IsCI() { + t.Skip("Skipping Windows SSH PTY tests in CI due to S4U authentication issues") + } + + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + + clientPrivKeyOpenSSH, _, err := generateOpenSSHKey(t) + require.NoError(t, err) + + serverConfig := &Config{ + HostKeyPEM: hostKey, + JWT: nil, + } + server := New(serverConfig) + server.SetAllowRootLogin(true) + + serverAddr := StartTestServer(t, server) + defer func() { + err := server.Stop() + require.NoError(t, err) + }() + + clientKeyFile, cleanupKey := createTempKeyFileFromBytes(t, clientPrivKeyOpenSSH) + defer cleanupKey() + + host, portStr, err := net.SplitHostPort(serverAddr) + require.NoError(t, err) + + username := testutil.GetTestUsername(t) + + baseArgs := []string{ + "-i", clientKeyFile, + "-p", portStr, + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=5", + "-o", "BatchMode=yes", + } + + t.Run("command_default_no_pty", func(t *testing.T) { + args := append(slices.Clone(baseArgs), fmt.Sprintf("%s@%s", username, host), "echo", "no_pty_default") + cmd := exec.Command("ssh", args...) + + output, err := cmd.CombinedOutput() + require.NoError(t, err, "Command (default no PTY) failed: %s", output) + assert.Contains(t, string(output), "no_pty_default") + }) + + t.Run("command_explicit_no_pty", func(t *testing.T) { + args := append(slices.Clone(baseArgs), "-T", fmt.Sprintf("%s@%s", username, host), "echo", "explicit_no_pty") + cmd := exec.Command("ssh", args...) + + output, err := cmd.CombinedOutput() + require.NoError(t, err, "Command (-T explicit no PTY) failed: %s", output) + assert.Contains(t, string(output), "explicit_no_pty") + }) + + t.Run("command_force_pty", func(t *testing.T) { + args := append(slices.Clone(baseArgs), "-tt", fmt.Sprintf("%s@%s", username, host), "echo", "force_pty") + cmd := exec.Command("ssh", args...) + + output, err := cmd.CombinedOutput() + require.NoError(t, err, "Command (-tt force PTY) failed: %s", output) + assert.Contains(t, string(output), "force_pty") + }) + + t.Run("shell_explicit_no_pty", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + args := append(slices.Clone(baseArgs), "-T", fmt.Sprintf("%s@%s", username, host)) + cmd := exec.CommandContext(ctx, "ssh", args...) + + stdin, err := cmd.StdinPipe() + require.NoError(t, err) + + stdout, err := cmd.StdoutPipe() + require.NoError(t, err) + + require.NoError(t, cmd.Start(), "Shell (-T no PTY) start failed") + + go func() { + defer stdin.Close() + time.Sleep(100 * time.Millisecond) + _, err := stdin.Write([]byte("echo shell_no_pty_test\n")) + assert.NoError(t, err, "write echo command") + time.Sleep(100 * time.Millisecond) + _, err = stdin.Write([]byte("exit 0\n")) + assert.NoError(t, err, "write exit command") + }() + + output, _ := io.ReadAll(stdout) + err = cmd.Wait() + + require.NoError(t, err, "Shell (-T no PTY) failed: %s", output) + assert.Contains(t, string(output), "shell_no_pty_test") + }) + + t.Run("exit_code_preserved_no_pty", func(t *testing.T) { + args := append(slices.Clone(baseArgs), "-T", fmt.Sprintf("%s@%s", username, host), "exit", "42") + cmd := exec.Command("ssh", args...) + + err := cmd.Run() + require.Error(t, err, "Command should exit with non-zero") + + var exitErr *exec.ExitError + require.True(t, errors.As(err, &exitErr), "Should be an exit error: %v", err) + assert.Equal(t, 42, exitErr.ExitCode(), "Exit code should be preserved with -T") + }) + + t.Run("exit_code_preserved_with_pty", func(t *testing.T) { + args := append(slices.Clone(baseArgs), "-tt", fmt.Sprintf("%s@%s", username, host), "sh -c 'exit 43'") + cmd := exec.Command("ssh", args...) + + err := cmd.Run() + require.Error(t, err, "PTY command should exit with non-zero") + + var exitErr *exec.ExitError + require.True(t, errors.As(err, &exitErr), "Should be an exit error: %v", err) + assert.Equal(t, 43, exitErr.ExitCode(), "Exit code should be preserved with -tt") + }) + + t.Run("stderr_works_no_pty", func(t *testing.T) { + args := append(slices.Clone(baseArgs), "-T", fmt.Sprintf("%s@%s", username, host), + "sh -c 'echo stdout_msg; echo stderr_msg >&2'") + cmd := exec.Command("ssh", args...) + + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + require.NoError(t, cmd.Run(), "stderr test failed") + assert.Contains(t, stdout.String(), "stdout_msg", "stdout should have stdout_msg") + assert.Contains(t, stderr.String(), "stderr_msg", "stderr should have stderr_msg") + assert.NotContains(t, stdout.String(), "stderr_msg", "stdout should NOT have stderr_msg") + }) + + t.Run("stderr_merged_with_pty", func(t *testing.T) { + args := append(slices.Clone(baseArgs), "-tt", fmt.Sprintf("%s@%s", username, host), + "sh -c 'echo stdout_msg; echo stderr_msg >&2'") + cmd := exec.Command("ssh", args...) + + output, err := cmd.CombinedOutput() + require.NoError(t, err, "PTY stderr test failed: %s", output) + assert.Contains(t, string(output), "stdout_msg") + assert.Contains(t, string(output), "stderr_msg") + }) +} + +// TestSSHServerFeatureCompatibility tests specific SSH features for compatibility +func TestSSHServerFeatureCompatibility(t *testing.T) { + if testing.Short() { + t.Skip("Skipping SSH feature compatibility tests in short mode") + } + + if runtime.GOOS == "windows" && testutil.IsCI() { + t.Skip("Skipping Windows SSH compatibility tests in CI due to S4U authentication issues") + } + + if !isSSHClientAvailable() { + t.Skip("SSH client not available on this system") + } + + // Test various SSH features + testCases := []struct { + name string + testFunc func(t *testing.T, host, port, keyFile string) + description string + }{ + { + name: "command_with_flags", + testFunc: testCommandWithFlags, + description: "Commands with flags should work like standard SSH", + }, + { + name: "environment_variables", + testFunc: testEnvironmentVariables, + description: "Environment variables should be available", + }, + { + name: "exit_codes", + testFunc: testExitCodes, + description: "Exit codes should be properly handled", + }, + } + + // Set up SSH server + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + + clientPrivKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + + serverConfig := &Config{ + HostKeyPEM: hostKey, + JWT: nil, + } + server := New(serverConfig) + server.SetAllowRootLogin(true) + + serverAddr := StartTestServer(t, server) + defer func() { + err := server.Stop() + require.NoError(t, err) + }() + + clientKeyFile, cleanupKey := createTempKeyFile(t, clientPrivKey) + defer cleanupKey() + + host, portStr, err := net.SplitHostPort(serverAddr) + require.NoError(t, err) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tc.testFunc(t, host, portStr, clientKeyFile) + }) + } +} + +// testCommandWithFlags tests that commands with flags work properly +func testCommandWithFlags(t *testing.T, host, port, keyFile string) { + // Get appropriate user for SSH connection + username := testutil.GetTestUsername(t) + + // Test ls with flags + cmd := exec.Command("ssh", + "-i", keyFile, + "-p", port, + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=5", + fmt.Sprintf("%s@%s", username, host), + "ls", "-la", "/tmp") + + output, err := cmd.CombinedOutput() + if err != nil { + t.Logf("Command with flags failed: %v", err) + t.Logf("Output: %s", string(output)) + return + } + + // Should not be empty and should not contain error messages + assert.NotEmpty(t, string(output), "ls -la should produce output") + assert.NotContains(t, strings.ToLower(string(output)), "command not found", "Command should be executed") +} + +// testEnvironmentVariables tests that environment is properly set up +func testEnvironmentVariables(t *testing.T, host, port, keyFile string) { + // Get appropriate user for SSH connection + username := testutil.GetTestUsername(t) + + cmd := exec.Command("ssh", + "-i", keyFile, + "-p", port, + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=5", + fmt.Sprintf("%s@%s", username, host), + "echo", "$HOME") + + output, err := cmd.CombinedOutput() + if err != nil { + t.Logf("Environment test failed: %v", err) + t.Logf("Output: %s", string(output)) + return + } + + // HOME environment variable should be available + homeOutput := strings.TrimSpace(string(output)) + assert.NotEmpty(t, homeOutput, "HOME environment variable should be set") + assert.NotEqual(t, "$HOME", homeOutput, "Environment variable should be expanded") +} + +// testExitCodes tests that exit codes are properly handled +func testExitCodes(t *testing.T, host, port, keyFile string) { + // Get appropriate user for SSH connection + username := testutil.GetTestUsername(t) + + // Test successful command (exit code 0) + cmd := exec.Command("ssh", + "-i", keyFile, + "-p", port, + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=5", + fmt.Sprintf("%s@%s", username, host), + "true") // always succeeds + + err := cmd.Run() + assert.NoError(t, err, "Command with exit code 0 should succeed") + + // Test failing command (exit code 1) + cmd = exec.Command("ssh", + "-i", keyFile, + "-p", port, + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=5", + fmt.Sprintf("%s@%s", username, host), + "false") // always fails + + err = cmd.Run() + assert.Error(t, err, "Command with exit code 1 should fail") + + // Check if it's the right kind of error + if exitError, ok := err.(*exec.ExitError); ok { + assert.Equal(t, 1, exitError.ExitCode(), "Exit code should be preserved") + } +} + +// TestSSHServerSecurityFeatures tests security-related SSH features +func TestSSHServerSecurityFeatures(t *testing.T) { + if testing.Short() { + t.Skip("Skipping SSH security tests in short mode") + } + + if !isSSHClientAvailable() { + t.Skip("SSH client not available on this system") + } + + // Get appropriate user for SSH connection + username := testutil.GetTestUsername(t) + + // Set up SSH server with specific security settings + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + + clientPrivKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + + serverConfig := &Config{ + HostKeyPEM: hostKey, + JWT: nil, + } + server := New(serverConfig) + server.SetAllowRootLogin(true) + + serverAddr := StartTestServer(t, server) + defer func() { + err := server.Stop() + require.NoError(t, err) + }() + + clientKeyFile, cleanupKey := createTempKeyFile(t, clientPrivKey) + defer cleanupKey() + + host, portStr, err := net.SplitHostPort(serverAddr) + require.NoError(t, err) + + t.Run("key_authentication", func(t *testing.T) { + // Test that key authentication works + cmd := exec.Command("ssh", + "-i", clientKeyFile, + "-p", portStr, + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=5", + "-o", "PasswordAuthentication=no", + fmt.Sprintf("%s@%s", username, host), + "echo", "auth_success") + + output, err := cmd.CombinedOutput() + if err != nil { + t.Logf("Key authentication failed: %v", err) + t.Logf("Output: %s", string(output)) + return + } + + assert.Contains(t, string(output), "auth_success", "Key authentication should work") + }) + + t.Run("any_key_accepted_in_no_auth_mode", func(t *testing.T) { + // Create a different key that shouldn't be accepted + wrongKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + + wrongKeyFile, cleanupWrongKey := createTempKeyFile(t, wrongKey) + defer cleanupWrongKey() + + // Test that wrong key is rejected + cmd := exec.Command("ssh", + "-i", wrongKeyFile, + "-p", portStr, + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=5", + "-o", "PasswordAuthentication=no", + fmt.Sprintf("%s@%s", username, host), + "echo", "should_not_work") + + err = cmd.Run() + assert.NoError(t, err, "Any key should work in no-auth mode") + }) +} + +// TestCrossPlatformCompatibility tests cross-platform behavior +func TestCrossPlatformCompatibility(t *testing.T) { + if testing.Short() { + t.Skip("Skipping cross-platform compatibility tests in short mode") + } + + if !isSSHClientAvailable() { + t.Skip("SSH client not available on this system") + } + + // Get appropriate user for SSH connection + username := testutil.GetTestUsername(t) + + // Set up SSH server + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + + clientPrivKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + + serverConfig := &Config{ + HostKeyPEM: hostKey, + JWT: nil, + } + server := New(serverConfig) + server.SetAllowRootLogin(true) + + serverAddr := StartTestServer(t, server) + defer func() { + err := server.Stop() + require.NoError(t, err) + }() + + clientKeyFile, cleanupKey := createTempKeyFile(t, clientPrivKey) + defer cleanupKey() + + host, portStr, err := net.SplitHostPort(serverAddr) + require.NoError(t, err) + + // Test platform-specific commands + var testCommand string + + switch runtime.GOOS { + case "windows": + testCommand = "echo %OS%" + default: + testCommand = "uname" + } + + cmd := exec.Command("ssh", + "-i", clientKeyFile, + "-p", portStr, + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=5", + fmt.Sprintf("%s@%s", username, host), + testCommand) + + output, err := cmd.CombinedOutput() + if err != nil { + t.Logf("Platform-specific command failed: %v", err) + t.Logf("Output: %s", string(output)) + return + } + + outputStr := strings.TrimSpace(string(output)) + t.Logf("Platform command output: %s", outputStr) + assert.NotEmpty(t, outputStr, "Platform-specific command should produce output") +} diff --git a/client/ssh/server/executor_unix.go b/client/ssh/server/executor_unix.go new file mode 100644 index 000000000..ee0b0ff78 --- /dev/null +++ b/client/ssh/server/executor_unix.go @@ -0,0 +1,283 @@ +//go:build unix + +package server + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "syscall" + + log "github.com/sirupsen/logrus" +) + +// Exit codes for executor process communication +const ( + ExitCodeSuccess = 0 + ExitCodePrivilegeDropFail = 10 + ExitCodeShellExecFail = 11 + ExitCodeValidationFail = 12 +) + +// ExecutorConfig holds configuration for the executor process +type ExecutorConfig struct { + UID uint32 + GID uint32 + Groups []uint32 + WorkingDir string + Shell string + Command string + PTY bool +} + +// PrivilegeDropper handles secure privilege dropping in child processes +type PrivilegeDropper struct { + logger *log.Entry +} + +// PrivilegeDropperOption is a functional option for configuring PrivilegeDropper +type PrivilegeDropperOption func(*PrivilegeDropper) + +// NewPrivilegeDropper creates a new privilege dropper +func NewPrivilegeDropper(opts ...PrivilegeDropperOption) *PrivilegeDropper { + pd := &PrivilegeDropper{} + for _, opt := range opts { + opt(pd) + } + return pd +} + +// WithLogger sets the logger for the PrivilegeDropper +func WithLogger(logger *log.Entry) PrivilegeDropperOption { + return func(pd *PrivilegeDropper) { + pd.logger = logger + } +} + +// log returns the logger, falling back to standard logger if none set +func (pd *PrivilegeDropper) log() *log.Entry { + if pd.logger != nil { + return pd.logger + } + return log.NewEntry(log.StandardLogger()) +} + +// CreateExecutorCommand creates a command that spawns netbird ssh exec for privilege dropping +func (pd *PrivilegeDropper) CreateExecutorCommand(ctx context.Context, config ExecutorConfig) (*exec.Cmd, error) { + netbirdPath, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("get netbird executable path: %w", err) + } + + if err := pd.validatePrivileges(config.UID, config.GID); err != nil { + return nil, fmt.Errorf("invalid privileges: %w", err) + } + + args := []string{ + "ssh", "exec", + "--uid", fmt.Sprintf("%d", config.UID), + "--gid", fmt.Sprintf("%d", config.GID), + "--working-dir", config.WorkingDir, + "--shell", config.Shell, + } + + for _, group := range config.Groups { + args = append(args, "--groups", fmt.Sprintf("%d", group)) + } + + if config.PTY { + args = append(args, "--pty") + } + + if config.Command != "" { + args = append(args, "--cmd", config.Command) + } + + // Log executor args safely - show all args except hide the command value + safeArgs := make([]string, len(args)) + copy(safeArgs, args) + for i := 0; i < len(safeArgs)-1; i++ { + if safeArgs[i] == "--cmd" { + cmdParts := strings.Fields(safeArgs[i+1]) + safeArgs[i+1] = safeLogCommand(cmdParts) + break + } + } + pd.log().Tracef("creating executor command: %s %v", netbirdPath, safeArgs) + return exec.CommandContext(ctx, netbirdPath, args...), nil +} + +// DropPrivileges performs privilege dropping with thread locking for security +func (pd *PrivilegeDropper) DropPrivileges(targetUID, targetGID uint32, supplementaryGroups []uint32) error { + if err := pd.validatePrivileges(targetUID, targetGID); err != nil { + return fmt.Errorf("invalid privileges: %w", err) + } + + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + originalUID := os.Geteuid() + originalGID := os.Getegid() + + if originalUID != int(targetUID) || originalGID != int(targetGID) { + if err := pd.setGroupsAndIDs(targetUID, targetGID, supplementaryGroups); err != nil { + return fmt.Errorf("set groups and IDs: %w", err) + } + } + + if err := pd.validatePrivilegeDropSuccess(targetUID, targetGID, originalUID, originalGID); err != nil { + return err + } + + log.Tracef("successfully dropped privileges to UID=%d, GID=%d", targetUID, targetGID) + return nil +} + +// setGroupsAndIDs sets the supplementary groups, GID, and UID +func (pd *PrivilegeDropper) setGroupsAndIDs(targetUID, targetGID uint32, supplementaryGroups []uint32) error { + groups := make([]int, len(supplementaryGroups)) + for i, g := range supplementaryGroups { + groups[i] = int(g) + } + + if runtime.GOOS == "darwin" || runtime.GOOS == "freebsd" { + if len(groups) == 0 || groups[0] != int(targetGID) { + groups = append([]int{int(targetGID)}, groups...) + } + } + + if err := syscall.Setgroups(groups); err != nil { + return fmt.Errorf("setgroups to %v: %w", groups, err) + } + + if err := syscall.Setgid(int(targetGID)); err != nil { + return fmt.Errorf("setgid to %d: %w", targetGID, err) + } + + if err := syscall.Setuid(int(targetUID)); err != nil { + return fmt.Errorf("setuid to %d: %w", targetUID, err) + } + + return nil +} + +// validatePrivilegeDropSuccess validates that privilege dropping was successful +func (pd *PrivilegeDropper) validatePrivilegeDropSuccess(targetUID, targetGID uint32, originalUID, originalGID int) error { + if err := pd.validatePrivilegeDropReversibility(targetUID, targetGID, originalUID, originalGID); err != nil { + return err + } + + if err := pd.validateCurrentPrivileges(targetUID, targetGID); err != nil { + return err + } + + return nil +} + +// validatePrivilegeDropReversibility ensures privileges cannot be restored +func (pd *PrivilegeDropper) validatePrivilegeDropReversibility(targetUID, targetGID uint32, originalUID, originalGID int) error { + if originalGID != int(targetGID) { + if err := syscall.Setegid(originalGID); err == nil { + return fmt.Errorf("privilege drop validation failed: able to restore original GID %d", originalGID) + } + } + if originalUID != int(targetUID) { + if err := syscall.Seteuid(originalUID); err == nil { + return fmt.Errorf("privilege drop validation failed: able to restore original UID %d", originalUID) + } + } + return nil +} + +// validateCurrentPrivileges validates the current UID and GID match the target +func (pd *PrivilegeDropper) validateCurrentPrivileges(targetUID, targetGID uint32) error { + currentUID := os.Geteuid() + if currentUID != int(targetUID) { + return fmt.Errorf("privilege drop validation failed: current UID %d, expected %d", currentUID, targetUID) + } + + currentGID := os.Getegid() + if currentGID != int(targetGID) { + return fmt.Errorf("privilege drop validation failed: current GID %d, expected %d", currentGID, targetGID) + } + + return nil +} + +// ExecuteWithPrivilegeDrop executes a command with privilege dropping, using exit codes to signal specific failures +func (pd *PrivilegeDropper) ExecuteWithPrivilegeDrop(ctx context.Context, config ExecutorConfig) { + log.Tracef("dropping privileges to UID=%d, GID=%d, groups=%v", config.UID, config.GID, config.Groups) + + // TODO: Implement Pty support for executor path + if config.PTY { + config.PTY = false + } + + if err := pd.DropPrivileges(config.UID, config.GID, config.Groups); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "privilege drop failed: %v\n", err) + os.Exit(ExitCodePrivilegeDropFail) + } + + if config.WorkingDir != "" { + if err := os.Chdir(config.WorkingDir); err != nil { + log.Debugf("failed to change to working directory %s, continuing with current directory: %v", config.WorkingDir, err) + } + } + + var execCmd *exec.Cmd + if config.Command == "" { + execCmd = exec.CommandContext(ctx, config.Shell) + } else { + execCmd = exec.CommandContext(ctx, config.Shell, "-c", config.Command) + } + execCmd.Args[0] = "-" + filepath.Base(config.Shell) + execCmd.Stdin = os.Stdin + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + + if config.Command == "" { + log.Tracef("executing login shell: %s", execCmd.Path) + } else { + cmdParts := strings.Fields(config.Command) + safeCmd := safeLogCommand(cmdParts) + log.Tracef("executing %s -c %s", execCmd.Path, safeCmd) + } + if err := execCmd.Run(); err != nil { + var exitError *exec.ExitError + if errors.As(err, &exitError) { + // Normal command exit with non-zero code - not an SSH execution error + log.Tracef("command exited with code %d", exitError.ExitCode()) + os.Exit(exitError.ExitCode()) + } + + // Actual execution failure (command not found, permission denied, etc.) + log.Debugf("command execution failed: %v", err) + os.Exit(ExitCodeShellExecFail) + } + + os.Exit(ExitCodeSuccess) +} + +// validatePrivileges validates that privilege dropping to the target UID/GID is allowed +func (pd *PrivilegeDropper) validatePrivileges(uid, gid uint32) error { + currentUID := uint32(os.Geteuid()) + currentGID := uint32(os.Getegid()) + + // Allow same-user operations (no privilege dropping needed) + if uid == currentUID && gid == currentGID { + return nil + } + + // Only root can drop privileges to other users + if currentUID != 0 { + return fmt.Errorf("cannot drop privileges from non-root user (UID %d) to UID %d", currentUID, uid) + } + + // Root can drop to any user (including root itself) + return nil +} 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 new file mode 100644 index 000000000..171e78b83 --- /dev/null +++ b/client/ssh/server/executor_unix_test.go @@ -0,0 +1,207 @@ +//go:build unix + +package server + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/user" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPrivilegeDropper_ValidatePrivileges(t *testing.T) { + pd := NewPrivilegeDropper() + + currentUID := uint32(os.Geteuid()) + currentGID := uint32(os.Getegid()) + + tests := []struct { + name string + uid uint32 + gid uint32 + wantErr bool + }{ + { + name: "same user - no privilege drop needed", + uid: currentUID, + gid: currentGID, + wantErr: false, + }, + { + name: "non-root to different user should fail", + uid: currentUID + 1, // Use a different UID to ensure it's actually different + gid: currentGID + 1, // Use a different GID to ensure it's actually different + wantErr: currentUID != 0, // Only fail if current user is not root + }, + { + name: "root can drop to any user", + uid: 1000, + gid: 1000, + wantErr: false, + }, + { + name: "root can stay as root", + uid: 0, + gid: 0, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Skip non-root tests when running as root, and root tests when not root + if tt.name == "non-root to different user should fail" && currentUID == 0 { + t.Skip("Skipping non-root test when running as root") + } + if (tt.name == "root can drop to any user" || tt.name == "root can stay as root") && currentUID != 0 { + t.Skip("Skipping root test when not running as root") + } + + err := pd.validatePrivileges(tt.uid, tt.gid) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +// 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) { + if os.Geteuid() != 0 { + t.Skip("This test requires root privileges") + } + + // Find a non-root user to test with + testUser, err := findNonRootUser() + if err != nil { + t.Skip("No suitable non-root user found for testing") + } + + // Verify the user actually exists by looking it up again + _, err = user.LookupId(testUser.Uid) + if err != nil { + t.Skipf("Test user %s (UID %s) does not exist on this system: %v", testUser.Username, testUser.Uid, err) + } + + uid64, err := strconv.ParseUint(testUser.Uid, 10, 32) + require.NoError(t, err) + targetUID := uint32(uid64) + + gid64, err := strconv.ParseUint(testUser.Gid, 10, 32) + require.NoError(t, err) + targetGID := uint32(gid64) + + // Test in a child process to avoid affecting the test runner + if os.Getenv("TEST_PRIVILEGE_DROP") == "1" { + pd := NewPrivilegeDropper() + + // This should succeed + err := pd.DropPrivileges(targetUID, targetGID, []uint32{targetGID}) + require.NoError(t, err) + + // Verify we are now running as the target user + currentUID := uint32(os.Geteuid()) + currentGID := uint32(os.Getegid()) + + assert.Equal(t, targetUID, currentUID, "UID should match target") + assert.Equal(t, targetGID, currentGID, "GID should match target") + assert.NotEqual(t, uint32(0), currentUID, "Should not be running as root") + assert.NotEqual(t, uint32(0), currentGID, "Should not be running as root group") + + return + } + + // Fork a child process to test privilege dropping + cmd := os.Args[0] + args := []string{"-test.run=TestPrivilegeDropper_ActualPrivilegeDrop"} + + env := append(os.Environ(), "TEST_PRIVILEGE_DROP=1") + + execCmd := exec.Command(cmd, args...) + execCmd.Env = env + + err = execCmd.Run() + require.NoError(t, err, "Child process should succeed") +} + +// findNonRootUser finds any non-root user on the system for testing +func findNonRootUser() (*user.User, error) { + // Try common non-root users, but avoid "nobody" on macOS due to negative UID issues + commonUsers := []string{"daemon", "bin", "sys", "sync", "games", "man", "lp", "mail", "news", "uucp", "proxy", "www-data", "backup", "list", "irc"} + + for _, username := range commonUsers { + if u, err := user.Lookup(username); err == nil { + // Parse as signed integer first to handle negative UIDs + uid64, err := strconv.ParseInt(u.Uid, 10, 32) + if err != nil { + continue + } + // Skip negative UIDs (like nobody=-2 on macOS) and root + if uid64 > 0 && uid64 != 0 { + return u, nil + } + } + } + + // If no common users found, try to find any regular user with UID > 100 + // This helps on macOS where regular users start at UID 501 + allUsers := []string{"vma", "user", "test", "admin"} + for _, username := range allUsers { + if u, err := user.Lookup(username); err == nil { + uid64, err := strconv.ParseInt(u.Uid, 10, 32) + if err != nil { + continue + } + if uid64 > 100 { // Regular user + return u, nil + } + } + } + + // If no common users found, return an error + return nil, fmt.Errorf("no suitable non-root user found on this system") +} + +func TestPrivilegeDropper_ExecuteWithPrivilegeDrop_Validation(t *testing.T) { + pd := NewPrivilegeDropper() + currentUID := uint32(os.Geteuid()) + + if currentUID == 0 { + // When running as root, test that root can create commands for any user + config := ExecutorConfig{ + UID: 1000, // Target non-root user + GID: 1000, + Groups: []uint32{1000}, + WorkingDir: "/tmp", + Shell: "/bin/sh", + Command: "echo test", + } + + cmd, err := pd.CreateExecutorCommand(context.Background(), config) + assert.NoError(t, err, "Root should be able to create commands for any user") + assert.NotNil(t, cmd) + } else { + // When running as non-root, test that we can't drop to a different user + config := ExecutorConfig{ + UID: 0, // Try to target root + GID: 0, + Groups: []uint32{0}, + WorkingDir: "/tmp", + Shell: "/bin/sh", + Command: "echo test", + } + + _, err := pd.CreateExecutorCommand(context.Background(), config) + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot drop privileges") + } +} diff --git a/client/ssh/server/executor_windows.go b/client/ssh/server/executor_windows.go new file mode 100644 index 000000000..51c995ec3 --- /dev/null +++ b/client/ssh/server/executor_windows.go @@ -0,0 +1,592 @@ +//go:build windows + +package server + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "os/user" + "strings" + "syscall" + "unsafe" + + "github.com/gliderlabs/ssh" + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +const ( + ExitCodeSuccess = 0 + ExitCodeLogonFail = 10 + ExitCodeCreateProcessFail = 11 + ExitCodeWorkingDirFail = 12 + ExitCodeShellExecFail = 13 + ExitCodeValidationFail = 14 +) + +type WindowsExecutorConfig struct { + Username string + Domain string + WorkingDir string + Shell string + Command string + Args []string + Pty bool + PtyWidth int + PtyHeight int +} + +type PrivilegeDropper struct { + logger *log.Entry +} + +// PrivilegeDropperOption is a functional option for configuring PrivilegeDropper +type PrivilegeDropperOption func(*PrivilegeDropper) + +func NewPrivilegeDropper(opts ...PrivilegeDropperOption) *PrivilegeDropper { + pd := &PrivilegeDropper{} + for _, opt := range opts { + opt(pd) + } + return pd +} + +// WithLogger sets the logger for the PrivilegeDropper +func WithLogger(logger *log.Entry) PrivilegeDropperOption { + return func(pd *PrivilegeDropper) { + pd.logger = logger + } +} + +// log returns the logger, falling back to standard logger if none set +func (pd *PrivilegeDropper) log() *log.Entry { + if pd.logger != nil { + return pd.logger + } + return log.NewEntry(log.StandardLogger()) +} + +var ( + advapi32 = windows.NewLazyDLL("advapi32.dll") + procAllocateLocallyUniqueId = advapi32.NewProc("AllocateLocallyUniqueId") +) + +const ( + logon32LogonNetwork = 3 // Network logon - no password required for authenticated users + + // Common error messages + commandFlag = "-Command" + convertUsernameError = "convert username to UTF16: %w" + convertDomainError = "convert domain to UTF16: %w" +) + +// CreateWindowsExecutorCommand creates a Windows command with privilege dropping. +// The caller must close the returned token handle after starting the process. +func (pd *PrivilegeDropper) CreateWindowsExecutorCommand(ctx context.Context, config WindowsExecutorConfig) (*exec.Cmd, windows.Token, error) { + if config.Username == "" { + return nil, 0, errors.New("username cannot be empty") + } + if config.Shell == "" { + return nil, 0, errors.New("shell cannot be empty") + } + + shell := config.Shell + + var shellArgs []string + if config.Command != "" { + shellArgs = []string{shell, commandFlag, config.Command} + } else { + shellArgs = []string{shell} + } + + pd.log().Tracef("creating Windows direct shell command: %s %v", shellArgs[0], shellArgs) + + cmd, token, err := pd.CreateWindowsProcessAsUser( + ctx, shellArgs[0], shellArgs, config.Username, config.Domain, config.WorkingDir) + if err != nil { + return nil, 0, fmt.Errorf("create Windows process as user: %w", err) + } + + return cmd, token, nil +} + +const ( + // StatusSuccess represents successful LSA operation + StatusSuccess = 0 + + // KerbS4ULogonType message type for domain users with Kerberos + KerbS4ULogonType = 12 + // Msv10s4ulogontype message type for local users with MSV1_0 + Msv10s4ulogontype = 12 + + // MicrosoftKerberosNameA is the authentication package name for Kerberos + MicrosoftKerberosNameA = "Kerberos" + // Msv10packagename is the authentication package name for MSV1_0 + Msv10packagename = "MICROSOFT_AUTHENTICATION_PACKAGE_V1_0" + + NameSamCompatible = 2 + NameUserPrincipal = 8 + NameCanonical = 7 + + maxUPNLen = 1024 +) + +// kerbS4ULogon structure for S4U authentication (domain users) +type kerbS4ULogon struct { + MessageType uint32 + Flags uint32 + ClientUpn unicodeString + ClientRealm unicodeString +} + +// msv10s4ulogon structure for S4U authentication (local users) +type msv10s4ulogon struct { + MessageType uint32 + Flags uint32 + UserPrincipalName unicodeString + DomainName unicodeString +} + +// unicodeString structure +type unicodeString struct { + Length uint16 + MaximumLength uint16 + Buffer *uint16 +} + +// lsaString structure +type lsaString struct { + Length uint16 + MaximumLength uint16 + Buffer *byte +} + +// tokenSource structure +type tokenSource struct { + SourceName [8]byte + SourceIdentifier windows.LUID +} + +// quotaLimits structure +type quotaLimits struct { + PagedPoolLimit uint32 + NonPagedPoolLimit uint32 + MinimumWorkingSetSize uint32 + MaximumWorkingSetSize uint32 + PagefileLimit uint32 + TimeLimit int64 +} + +var ( + secur32 = windows.NewLazyDLL("secur32.dll") + procLsaRegisterLogonProcess = secur32.NewProc("LsaRegisterLogonProcess") + procLsaLookupAuthenticationPackage = secur32.NewProc("LsaLookupAuthenticationPackage") + procLsaLogonUser = secur32.NewProc("LsaLogonUser") + procLsaFreeReturnBuffer = secur32.NewProc("LsaFreeReturnBuffer") + procLsaDeregisterLogonProcess = secur32.NewProc("LsaDeregisterLogonProcess") + procTranslateNameW = secur32.NewProc("TranslateNameW") +) + +// newLsaString creates an LsaString from a Go string +func newLsaString(s string) lsaString { + b := append([]byte(s), 0) + return lsaString{ + Length: uint16(len(s)), + MaximumLength: uint16(len(b)), + Buffer: &b[0], + } +} + +// generateS4UUserToken creates a Windows token using S4U authentication +// This is the exact approach OpenSSH for Windows uses for public key authentication +func generateS4UUserToken(logger *log.Entry, username, domain string) (windows.Handle, error) { + userCpn := buildUserCpn(username, domain) + + pd := NewPrivilegeDropper(WithLogger(logger)) + isDomainUser := !pd.isLocalUser(domain) + + lsaHandle, err := initializeLsaConnection() + if err != nil { + return 0, err + } + defer cleanupLsaConnection(lsaHandle) + + authPackageId, err := lookupAuthenticationPackage(lsaHandle, isDomainUser) + if err != nil { + return 0, err + } + + logonInfo, logonInfoSize, err := prepareS4ULogonStructure(logger, username, domain, isDomainUser) + if err != nil { + return 0, err + } + + return performS4ULogon(logger, lsaHandle, authPackageId, logonInfo, logonInfoSize, userCpn, isDomainUser) +} + +// buildUserCpn constructs the user principal name +func buildUserCpn(username, domain string) string { + if domain != "" && domain != "." { + return fmt.Sprintf(`%s\%s`, domain, username) + } + return username +} + +// initializeLsaConnection establishes connection to LSA +func initializeLsaConnection() (windows.Handle, error) { + + processName := newLsaString("NetBird") + var mode uint32 + var lsaHandle windows.Handle + ret, _, _ := procLsaRegisterLogonProcess.Call( + uintptr(unsafe.Pointer(&processName)), + uintptr(unsafe.Pointer(&lsaHandle)), + uintptr(unsafe.Pointer(&mode)), + ) + if ret != StatusSuccess { + return 0, fmt.Errorf("LsaRegisterLogonProcess: 0x%x", ret) + } + + return lsaHandle, nil +} + +// cleanupLsaConnection closes the LSA connection +func cleanupLsaConnection(lsaHandle windows.Handle) { + if ret, _, _ := procLsaDeregisterLogonProcess.Call(uintptr(lsaHandle)); ret != StatusSuccess { + log.Debugf("LsaDeregisterLogonProcess failed: 0x%x", ret) + } +} + +// lookupAuthenticationPackage finds the correct authentication package +func lookupAuthenticationPackage(lsaHandle windows.Handle, isDomainUser bool) (uint32, error) { + var authPackageName lsaString + if isDomainUser { + authPackageName = newLsaString(MicrosoftKerberosNameA) + } else { + authPackageName = newLsaString(Msv10packagename) + } + + var authPackageId uint32 + ret, _, _ := procLsaLookupAuthenticationPackage.Call( + uintptr(lsaHandle), + uintptr(unsafe.Pointer(&authPackageName)), + uintptr(unsafe.Pointer(&authPackageId)), + ) + if ret != StatusSuccess { + return 0, fmt.Errorf("LsaLookupAuthenticationPackage: 0x%x", ret) + } + + return authPackageId, nil +} + +// lookupPrincipalName converts DOMAIN\username to username@domain.fqdn (UPN format) +func lookupPrincipalName(username, domain string) (string, error) { + samAccountName := fmt.Sprintf(`%s\%s`, domain, username) + samAccountNameUtf16, err := windows.UTF16PtrFromString(samAccountName) + if err != nil { + return "", fmt.Errorf("convert SAM account name to UTF-16: %w", err) + } + + upnBuf := make([]uint16, maxUPNLen+1) + upnSize := uint32(len(upnBuf)) + + ret, _, _ := procTranslateNameW.Call( + uintptr(unsafe.Pointer(samAccountNameUtf16)), + uintptr(NameSamCompatible), + uintptr(NameUserPrincipal), + uintptr(unsafe.Pointer(&upnBuf[0])), + uintptr(unsafe.Pointer(&upnSize)), + ) + + if ret != 0 { + upn := windows.UTF16ToString(upnBuf[:upnSize]) + log.Debugf("Translated %s to explicit UPN: %s", samAccountName, upn) + return upn, nil + } + + upnSize = uint32(len(upnBuf)) + ret, _, _ = procTranslateNameW.Call( + uintptr(unsafe.Pointer(samAccountNameUtf16)), + uintptr(NameSamCompatible), + uintptr(NameCanonical), + uintptr(unsafe.Pointer(&upnBuf[0])), + uintptr(unsafe.Pointer(&upnSize)), + ) + + if ret != 0 { + canonical := windows.UTF16ToString(upnBuf[:upnSize]) + slashIdx := strings.IndexByte(canonical, '/') + if slashIdx > 0 { + fqdn := canonical[:slashIdx] + upn := fmt.Sprintf("%s@%s", username, fqdn) + log.Debugf("Translated %s to implicit UPN: %s (from canonical: %s)", samAccountName, upn, canonical) + return upn, nil + } + } + + log.Debugf("Could not translate %s to UPN, using SAM format", samAccountName) + return samAccountName, nil +} + +// prepareS4ULogonStructure creates the appropriate S4U logon structure +func prepareS4ULogonStructure(logger *log.Entry, username, domain string, isDomainUser bool) (unsafe.Pointer, uintptr, error) { + if isDomainUser { + return prepareDomainS4ULogon(logger, username, domain) + } + return prepareLocalS4ULogon(logger, username) +} + +// prepareDomainS4ULogon creates S4U logon structure for domain users +func prepareDomainS4ULogon(logger *log.Entry, username, domain string) (unsafe.Pointer, uintptr, error) { + upn, err := lookupPrincipalName(username, domain) + if err != nil { + return nil, 0, fmt.Errorf("lookup principal name: %w", err) + } + + logger.Debugf("using KerbS4ULogon for domain user with UPN: %s", upn) + + upnUtf16, err := windows.UTF16FromString(upn) + if err != nil { + return nil, 0, fmt.Errorf(convertUsernameError, err) + } + + structSize := unsafe.Sizeof(kerbS4ULogon{}) + upnByteSize := len(upnUtf16) * 2 + logonInfoSize := structSize + uintptr(upnByteSize) + + buffer := make([]byte, logonInfoSize) + logonInfo := unsafe.Pointer(&buffer[0]) + + s4uLogon := (*kerbS4ULogon)(logonInfo) + s4uLogon.MessageType = KerbS4ULogonType + s4uLogon.Flags = 0 + + upnOffset := structSize + upnBuffer := (*uint16)(unsafe.Pointer(uintptr(logonInfo) + upnOffset)) + copy((*[1025]uint16)(unsafe.Pointer(upnBuffer))[:len(upnUtf16)], upnUtf16) + + s4uLogon.ClientUpn = unicodeString{ + Length: uint16((len(upnUtf16) - 1) * 2), + MaximumLength: uint16(len(upnUtf16) * 2), + Buffer: upnBuffer, + } + s4uLogon.ClientRealm = unicodeString{} + + return logonInfo, logonInfoSize, nil +} + +// prepareLocalS4ULogon creates S4U logon structure for local users +func prepareLocalS4ULogon(logger *log.Entry, username string) (unsafe.Pointer, uintptr, error) { + logger.Debugf("using Msv1_0S4ULogon for local user: %s", username) + + usernameUtf16, err := windows.UTF16FromString(username) + if err != nil { + return nil, 0, fmt.Errorf(convertUsernameError, err) + } + + domainUtf16, err := windows.UTF16FromString(".") + if err != nil { + return nil, 0, fmt.Errorf(convertDomainError, err) + } + + structSize := unsafe.Sizeof(msv10s4ulogon{}) + usernameByteSize := len(usernameUtf16) * 2 + domainByteSize := len(domainUtf16) * 2 + logonInfoSize := structSize + uintptr(usernameByteSize) + uintptr(domainByteSize) + + buffer := make([]byte, logonInfoSize) + logonInfo := unsafe.Pointer(&buffer[0]) + + s4uLogon := (*msv10s4ulogon)(logonInfo) + s4uLogon.MessageType = Msv10s4ulogontype + s4uLogon.Flags = 0x0 + + usernameOffset := structSize + usernameBuffer := (*uint16)(unsafe.Pointer(uintptr(logonInfo) + usernameOffset)) + copy((*[256]uint16)(unsafe.Pointer(usernameBuffer))[:len(usernameUtf16)], usernameUtf16) + + s4uLogon.UserPrincipalName = unicodeString{ + Length: uint16((len(usernameUtf16) - 1) * 2), + MaximumLength: uint16(len(usernameUtf16) * 2), + Buffer: usernameBuffer, + } + + domainOffset := usernameOffset + uintptr(usernameByteSize) + domainBuffer := (*uint16)(unsafe.Pointer(uintptr(logonInfo) + domainOffset)) + copy((*[16]uint16)(unsafe.Pointer(domainBuffer))[:len(domainUtf16)], domainUtf16) + + s4uLogon.DomainName = unicodeString{ + Length: uint16((len(domainUtf16) - 1) * 2), + MaximumLength: uint16(len(domainUtf16) * 2), + Buffer: domainBuffer, + } + + return logonInfo, logonInfoSize, nil +} + +// performS4ULogon executes the S4U logon operation +func performS4ULogon(logger *log.Entry, lsaHandle windows.Handle, authPackageId uint32, logonInfo unsafe.Pointer, logonInfoSize uintptr, userCpn string, isDomainUser bool) (windows.Handle, error) { + var tokenSource tokenSource + copy(tokenSource.SourceName[:], "netbird") + if ret, _, _ := procAllocateLocallyUniqueId.Call(uintptr(unsafe.Pointer(&tokenSource.SourceIdentifier))); ret == 0 { + logger.Debugf("AllocateLocallyUniqueId failed") + } + + originName := newLsaString("netbird") + + var profile uintptr + var profileSize uint32 + var logonId windows.LUID + var token windows.Handle + var quotas quotaLimits + var subStatus int32 + + ret, _, _ := procLsaLogonUser.Call( + uintptr(lsaHandle), + uintptr(unsafe.Pointer(&originName)), + logon32LogonNetwork, + uintptr(authPackageId), + uintptr(logonInfo), + logonInfoSize, + 0, + uintptr(unsafe.Pointer(&tokenSource)), + uintptr(unsafe.Pointer(&profile)), + uintptr(unsafe.Pointer(&profileSize)), + uintptr(unsafe.Pointer(&logonId)), + uintptr(unsafe.Pointer(&token)), + uintptr(unsafe.Pointer("as)), + uintptr(unsafe.Pointer(&subStatus)), + ) + + if profile != 0 { + if ret, _, _ := procLsaFreeReturnBuffer.Call(profile); ret != StatusSuccess { + logger.Debugf("LsaFreeReturnBuffer failed: 0x%x", ret) + } + } + + if ret != StatusSuccess { + return 0, fmt.Errorf("LsaLogonUser S4U for %s: NTSTATUS=0x%x, SubStatus=0x%x", userCpn, ret, subStatus) + } + + logger.Debugf("created S4U %s token for user %s", + map[bool]string{true: "domain", false: "local"}[isDomainUser], userCpn) + return token, nil +} + +// createToken implements NetBird trust-based authentication using S4U +func (pd *PrivilegeDropper) createToken(username, domain string) (windows.Handle, error) { + fullUsername := buildUserCpn(username, domain) + + if err := userExists(fullUsername, username, domain); err != nil { + return 0, err + } + + isLocalUser := pd.isLocalUser(domain) + + if isLocalUser { + return pd.authenticateLocalUser(username, fullUsername) + } + return pd.authenticateDomainUser(username, domain, fullUsername) +} + +// userExists checks if the target useVerifier exists on the system +func userExists(fullUsername, username, domain string) error { + if _, err := lookupUser(fullUsername); err != nil { + log.Debugf("User %s not found: %v", fullUsername, err) + if domain != "" && domain != "." { + _, err = lookupUser(username) + } + if err != nil { + return fmt.Errorf("target user %s not found: %w", fullUsername, err) + } + } + return nil +} + +// isLocalUser determines if this is a local user vs domain user +func (pd *PrivilegeDropper) isLocalUser(domain string) bool { + hostname, err := os.Hostname() + if err != nil { + hostname = "localhost" + } + + return domain == "" || domain == "." || + strings.EqualFold(domain, hostname) +} + +// authenticateLocalUser handles authentication for local users +func (pd *PrivilegeDropper) authenticateLocalUser(username, fullUsername string) (windows.Handle, error) { + pd.log().Debugf("using S4U authentication for local user %s", fullUsername) + token, err := generateS4UUserToken(pd.log(), username, ".") + if err != nil { + return 0, fmt.Errorf("S4U authentication for local user %s: %w", fullUsername, err) + } + return token, nil +} + +// authenticateDomainUser handles authentication for domain users +func (pd *PrivilegeDropper) authenticateDomainUser(username, domain, fullUsername string) (windows.Handle, error) { + pd.log().Debugf("using S4U authentication for domain user %s", fullUsername) + token, err := generateS4UUserToken(pd.log(), username, domain) + if err != nil { + return 0, fmt.Errorf("S4U authentication for domain user %s: %w", fullUsername, err) + } + pd.log().Debugf("successfully created S4U token for domain user %s", fullUsername) + return token, nil +} + +// CreateWindowsProcessAsUser creates a process as user with safe argument passing (for SFTP and executables). +// The caller must close the returned token handle after starting the process. +func (pd *PrivilegeDropper) CreateWindowsProcessAsUser(ctx context.Context, executablePath string, args []string, username, domain, workingDir string) (*exec.Cmd, windows.Token, error) { + token, err := pd.createToken(username, domain) + if err != nil { + return nil, 0, fmt.Errorf("user authentication: %w", err) + } + + defer func() { + if err := windows.CloseHandle(token); err != nil { + pd.log().Debugf("close impersonation token: %v", err) + } + }() + + cmd, primaryToken, err := pd.createProcessWithToken(ctx, windows.Token(token), executablePath, args, workingDir) + if err != nil { + return nil, 0, err + } + + return cmd, primaryToken, nil +} + +// createProcessWithToken creates process with the specified token and executable path. +// The caller must close the returned token handle after starting the process. +func (pd *PrivilegeDropper) createProcessWithToken(ctx context.Context, sourceToken windows.Token, executablePath string, args []string, workingDir string) (*exec.Cmd, windows.Token, error) { + cmd := exec.CommandContext(ctx, executablePath, args[1:]...) + cmd.Dir = workingDir + + var primaryToken windows.Token + err := windows.DuplicateTokenEx( + sourceToken, + windows.TOKEN_ALL_ACCESS, + nil, + windows.SecurityIdentification, + windows.TokenPrimary, + &primaryToken, + ) + if err != nil { + return nil, 0, fmt.Errorf("duplicate token to primary token: %w", err) + } + + cmd.SysProcAttr = &syscall.SysProcAttr{ + Token: syscall.Token(primaryToken), + } + + return cmd, primaryToken, nil +} + +// createSuCommand creates a command using su - for privilege switching (Windows stub). +func (s *Server) createSuCommand(*log.Entry, ssh.Session, *user.User, bool) (*exec.Cmd, error) { + return nil, fmt.Errorf("su command not available on Windows") +} diff --git a/client/ssh/server/getent_cgo_unix.go b/client/ssh/server/getent_cgo_unix.go new file mode 100644 index 000000000..4afbfc627 --- /dev/null +++ b/client/ssh/server/getent_cgo_unix.go @@ -0,0 +1,24 @@ +//go:build cgo && !osusergo && !windows + +package server + +import "os/user" + +// lookupWithGetent with CGO delegates directly to os/user.Lookup. +// When CGO is enabled, os/user uses libc (getpwnam_r) which goes through +// the NSS stack natively. If it fails, the user truly doesn't exist and +// getent would also fail. +func lookupWithGetent(username string) (*user.User, error) { + return user.Lookup(username) +} + +// currentUserWithGetent with CGO delegates directly to os/user.Current. +func currentUserWithGetent() (*user.User, error) { + return user.Current() +} + +// groupIdsWithFallback with CGO delegates directly to user.GroupIds. +// libc's getgrouplist handles NSS groups natively. +func groupIdsWithFallback(u *user.User) ([]string, error) { + return u.GroupIds() +} diff --git a/client/ssh/server/getent_nocgo_unix.go b/client/ssh/server/getent_nocgo_unix.go new file mode 100644 index 000000000..314daae4c --- /dev/null +++ b/client/ssh/server/getent_nocgo_unix.go @@ -0,0 +1,74 @@ +//go:build (!cgo || osusergo) && !windows + +package server + +import ( + "os" + "os/user" + "strconv" + + log "github.com/sirupsen/logrus" +) + +// lookupWithGetent looks up a user by name, falling back to getent if os/user fails. +// Without CGO, os/user only reads /etc/passwd and misses NSS-provided users. +// getent goes through the host's NSS stack. +func lookupWithGetent(username string) (*user.User, error) { + u, err := user.Lookup(username) + if err == nil { + return u, nil + } + + stdErr := err + log.Debugf("os/user.Lookup(%q) failed, trying getent: %v", username, err) + + u, _, getentErr := runGetent(username) + if getentErr != nil { + log.Debugf("getent fallback for %q also failed: %v", username, getentErr) + return nil, stdErr + } + + return u, nil +} + +// currentUserWithGetent gets the current user, falling back to getent if os/user fails. +func currentUserWithGetent() (*user.User, error) { + u, err := user.Current() + if err == nil { + return u, nil + } + + stdErr := err + uid := strconv.Itoa(os.Getuid()) + log.Debugf("os/user.Current() failed, trying getent with UID %s: %v", uid, err) + + u, _, getentErr := runGetent(uid) + if getentErr != nil { + return nil, stdErr + } + + return u, nil +} + +// groupIdsWithFallback gets group IDs for a user via the id command first, +// falling back to user.GroupIds(). +// NOTE: unlike lookupWithGetent/currentUserWithGetent which try stdlib first, +// this intentionally tries `id -G` first because without CGO, user.GroupIds() +// only reads /etc/group and silently returns incomplete results for NSS users +// (no error, just missing groups). The id command goes through NSS and returns +// the full set. +func groupIdsWithFallback(u *user.User) ([]string, error) { + ids, err := runIdGroups(u.Username) + if err == nil { + return ids, nil + } + + log.Debugf("id -G %q failed, falling back to user.GroupIds(): %v", u.Username, err) + + ids, stdErr := u.GroupIds() + if stdErr != nil { + return nil, stdErr + } + + return ids, nil +} diff --git a/client/ssh/server/getent_test.go b/client/ssh/server/getent_test.go new file mode 100644 index 000000000..5eac2fdbe --- /dev/null +++ b/client/ssh/server/getent_test.go @@ -0,0 +1,172 @@ +package server + +import ( + "os/user" + "runtime" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLookupWithGetent_CurrentUser(t *testing.T) { + // The current user should always be resolvable on any platform + current, err := user.Current() + require.NoError(t, err) + + u, err := lookupWithGetent(current.Username) + require.NoError(t, err) + assert.Equal(t, current.Username, u.Username) + assert.Equal(t, current.Uid, u.Uid) + assert.Equal(t, current.Gid, u.Gid) +} + +func TestLookupWithGetent_NonexistentUser(t *testing.T) { + _, err := lookupWithGetent("nonexistent_user_xyzzy_12345") + require.Error(t, err, "should fail for nonexistent user") +} + +func TestCurrentUserWithGetent(t *testing.T) { + stdUser, err := user.Current() + require.NoError(t, err) + + u, err := currentUserWithGetent() + require.NoError(t, err) + assert.Equal(t, stdUser.Uid, u.Uid) + assert.Equal(t, stdUser.Username, u.Username) +} + +func TestGroupIdsWithFallback_CurrentUser(t *testing.T) { + current, err := user.Current() + require.NoError(t, err) + + groups, err := groupIdsWithFallback(current) + require.NoError(t, err) + require.NotEmpty(t, groups, "current user should have at least one group") + + if runtime.GOOS != "windows" { + for _, gid := range groups { + _, err := strconv.ParseUint(gid, 10, 32) + assert.NoError(t, err, "group ID %q should be a valid uint32", gid) + } + } +} + +func TestGetShellFromGetent_CurrentUser(t *testing.T) { + if runtime.GOOS == "windows" { + // Windows stub always returns empty, which is correct + shell := getShellFromGetent("1000") + assert.Empty(t, shell, "Windows stub should return empty") + return + } + + current, err := user.Current() + require.NoError(t, err) + + // getent may not be available on all systems (e.g., macOS without Homebrew getent) + shell := getShellFromGetent(current.Uid) + if shell == "" { + t.Log("getShellFromGetent returned empty, getent may not be available") + return + } + assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) +} + +func TestLookupWithGetent_RootUser(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no root user on Windows") + } + + u, err := lookupWithGetent("root") + if err != nil { + t.Skip("root user not available on this system") + } + assert.Equal(t, "0", u.Uid, "root should have UID 0") +} + +// TestIntegration_FullLookupChain exercises the complete user lookup chain +// against the real system, testing that all wrappers (lookupWithGetent, +// currentUserWithGetent, groupIdsWithFallback, getShellFromGetent) produce +// consistent and correct results when composed together. +func TestIntegration_FullLookupChain(t *testing.T) { + // Step 1: currentUserWithGetent must resolve the running user. + current, err := currentUserWithGetent() + require.NoError(t, err, "currentUserWithGetent must resolve the running user") + require.NotEmpty(t, current.Uid) + require.NotEmpty(t, current.Username) + + // Step 2: lookupWithGetent by the same username must return matching identity. + byName, err := lookupWithGetent(current.Username) + require.NoError(t, err) + assert.Equal(t, current.Uid, byName.Uid, "lookup by name should return same UID") + assert.Equal(t, current.Gid, byName.Gid, "lookup by name should return same GID") + assert.Equal(t, current.HomeDir, byName.HomeDir, "lookup by name should return same home") + + // Step 3: groupIdsWithFallback must return at least the primary GID. + groups, err := groupIdsWithFallback(current) + require.NoError(t, err) + require.NotEmpty(t, groups, "user must have at least one group") + + foundPrimary := false + for _, gid := range groups { + if runtime.GOOS != "windows" { + _, err := strconv.ParseUint(gid, 10, 32) + require.NoError(t, err, "group ID %q must be a valid uint32", gid) + } + if gid == current.Gid { + foundPrimary = true + } + } + assert.True(t, foundPrimary, "primary GID %s should appear in supplementary groups", current.Gid) + + // Step 4: getShellFromGetent should either return a valid shell path or empty + // (empty is OK when getent is not available, e.g. macOS without Homebrew getent). + if runtime.GOOS != "windows" { + shell := getShellFromGetent(current.Uid) + if shell != "" { + assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) + } + } +} + +// TestIntegration_LookupAndGroupsConsistency verifies that a user resolved via +// lookupWithGetent can have their groups resolved via groupIdsWithFallback, +// testing the handoff between the two functions as used by the SSH server. +func TestIntegration_LookupAndGroupsConsistency(t *testing.T) { + current, err := user.Current() + require.NoError(t, err) + + // Simulate the SSH server flow: lookup user, then get their groups. + resolved, err := lookupWithGetent(current.Username) + require.NoError(t, err) + + groups, err := groupIdsWithFallback(resolved) + require.NoError(t, err) + require.NotEmpty(t, groups, "resolved user must have groups") + + // On Unix, all returned GIDs must be valid numeric values. + // On Windows, group IDs are SIDs (e.g., "S-1-5-32-544"). + if runtime.GOOS != "windows" { + for _, gid := range groups { + _, err := strconv.ParseUint(gid, 10, 32) + assert.NoError(t, err, "group ID %q should be numeric", gid) + } + } +} + +// TestIntegration_ShellLookupChain tests the full shell resolution chain +// (getShellFromPasswd -> getShellFromGetent -> $SHELL -> default) on Unix. +func TestIntegration_ShellLookupChain(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix shell lookup not applicable on Windows") + } + + current, err := user.Current() + require.NoError(t, err) + + // getUserShell is the top-level function used by the SSH server. + shell := getUserShell(current.Uid) + require.NotEmpty(t, shell, "getUserShell must always return a shell") + assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) +} diff --git a/client/ssh/server/getent_unix.go b/client/ssh/server/getent_unix.go new file mode 100644 index 000000000..a3a9641f8 --- /dev/null +++ b/client/ssh/server/getent_unix.go @@ -0,0 +1,127 @@ +//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). A leading hyphen is +// rejected so the input can never be parsed as a command-line flag. +func validateGetentInput(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 isAllowedGetentChar(r) { + continue + } + return false + } + return true +} + +func isAllowedGetentChar(r rune) bool { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' { + return true + } + switch r { + case '.', '_', '-', '@', '+', '$': + return true + } + return false +} + +// runIdGroups runs `id -G ` and returns the space-separated group IDs. +func runIdGroups(username string) ([]string, error) { + if !validateGetentInput(username) { + return nil, fmt.Errorf("invalid username for id command: %q", username) + } + + ctx, cancel := context.WithTimeout(context.Background(), getentTimeout) + defer cancel() + + out, err := exec.CommandContext(ctx, "id", "-G", username).Output() + if err != nil { + return nil, fmt.Errorf("id -G %s: %w", username, err) + } + + trimmed := strings.TrimSpace(string(out)) + if trimmed == "" { + return nil, fmt.Errorf("id -G %s: empty output", username) + } + return strings.Fields(trimmed), nil +} diff --git a/client/ssh/server/getent_unix_test.go b/client/ssh/server/getent_unix_test.go new file mode 100644 index 000000000..a73214e17 --- /dev/null +++ b/client/ssh/server/getent_unix_test.go @@ -0,0 +1,413 @@ +//go:build !windows + +package server + +import ( + "os/exec" + "os/user" + "runtime" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseGetentPasswd(t *testing.T) { + tests := []struct { + name string + input string + wantUser *user.User + wantShell string + wantErr bool + errContains string + }{ + { + name: "standard entry", + input: "alice:x:1001:1001:Alice Smith:/home/alice:/bin/bash\n", + wantUser: &user.User{ + Username: "alice", + Uid: "1001", + Gid: "1001", + Name: "Alice Smith", + HomeDir: "/home/alice", + }, + wantShell: "/bin/bash", + }, + { + name: "root entry", + input: "root:x:0:0:root:/root:/bin/bash", + wantUser: &user.User{ + Username: "root", + Uid: "0", + Gid: "0", + Name: "root", + HomeDir: "/root", + }, + wantShell: "/bin/bash", + }, + { + name: "empty gecos field", + input: "svc:x:999:999::/var/lib/svc:/usr/sbin/nologin", + wantUser: &user.User{ + Username: "svc", + Uid: "999", + Gid: "999", + Name: "", + HomeDir: "/var/lib/svc", + }, + wantShell: "/usr/sbin/nologin", + }, + { + name: "gecos with commas", + input: "john:x:1002:1002:John Doe,Room 101,555-1234,555-4321:/home/john:/bin/zsh", + wantUser: &user.User{ + Username: "john", + Uid: "1002", + Gid: "1002", + Name: "John Doe,Room 101,555-1234,555-4321", + HomeDir: "/home/john", + }, + wantShell: "/bin/zsh", + }, + { + name: "remote user with large UID", + input: "remoteuser:*:50001:50001:Remote User:/home/remoteuser:/bin/bash\n", + wantUser: &user.User{ + Username: "remoteuser", + Uid: "50001", + Gid: "50001", + Name: "Remote User", + HomeDir: "/home/remoteuser", + }, + wantShell: "/bin/bash", + }, + { + name: "no shell field (only 6 fields)", + input: "minimal:x:1000:1000::/home/minimal", + wantUser: &user.User{ + Username: "minimal", + Uid: "1000", + Gid: "1000", + Name: "", + HomeDir: "/home/minimal", + }, + wantShell: "", + }, + { + name: "too few fields", + input: "bad:x:1000", + wantErr: true, + errContains: "need 6+ fields", + }, + { + name: "empty username", + input: ":x:1000:1000::/home/test:/bin/bash", + wantErr: true, + errContains: "missing required fields", + }, + { + name: "empty UID", + input: "test:x::1000::/home/test:/bin/bash", + wantErr: true, + errContains: "missing required fields", + }, + { + name: "empty GID", + input: "test:x:1000:::/home/test:/bin/bash", + wantErr: true, + errContains: "missing required fields", + }, + { + name: "empty input", + input: "", + wantErr: true, + errContains: "need 6+ fields", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, shell, err := parseGetentPasswd(tt.input) + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantUser.Username, u.Username, "username") + assert.Equal(t, tt.wantUser.Uid, u.Uid, "UID") + assert.Equal(t, tt.wantUser.Gid, u.Gid, "GID") + assert.Equal(t, tt.wantUser.Name, u.Name, "name/gecos") + assert.Equal(t, tt.wantUser.HomeDir, u.HomeDir, "home directory") + assert.Equal(t, tt.wantShell, shell, "shell") + }) + } +} + +func TestValidateGetentInput(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {"normal username", "alice", true}, + {"numeric UID", "1001", true}, + {"dots and underscores", "alice.bob_test", true}, + {"hyphen", "alice-bob", true}, + {"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}, + {"empty", "", false}, + {"null byte", "alice\x00bob", false}, + {"newline", "alice\nbob", false}, + {"tab", "alice\tbob", false}, + {"control char", "alice\x01bob", false}, + {"DEL char", "alice\x7fbob", false}, + {"space rejected", "alice bob", false}, + {"semicolon rejected", "alice;bob", false}, + {"backtick rejected", "alice`bob", false}, + {"pipe rejected", "alice|bob", false}, + {"33 chars exceeds non-linux max", makeLongString(33), runtime.GOOS == "linux"}, + {"256 chars at linux max", makeLongString(256), runtime.GOOS == "linux"}, + {"257 chars exceeds all limits", makeLongString(257), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, validateGetentInput(tt.input)) + }) + } +} + +func makeLongString(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = 'a' + } + return string(b) +} + +func TestRunGetent_RootUser(t *testing.T) { + if _, err := exec.LookPath("getent"); err != nil { + t.Skip("getent not available on this system") + } + + u, shell, err := runGetent("root") + require.NoError(t, err) + assert.Equal(t, "root", u.Username) + assert.Equal(t, "0", u.Uid) + assert.Equal(t, "0", u.Gid) + assert.NotEmpty(t, shell, "root should have a shell") +} + +func TestRunGetent_ByUID(t *testing.T) { + if _, err := exec.LookPath("getent"); err != nil { + t.Skip("getent not available on this system") + } + + u, _, err := runGetent("0") + require.NoError(t, err) + assert.Equal(t, "root", u.Username) + assert.Equal(t, "0", u.Uid) +} + +func TestRunGetent_NonexistentUser(t *testing.T) { + if _, err := exec.LookPath("getent"); err != nil { + t.Skip("getent not available on this system") + } + + _, _, err := runGetent("nonexistent_user_xyzzy_12345") + assert.Error(t, err) +} + +func TestRunGetent_InvalidInput(t *testing.T) { + _, _, err := runGetent("") + assert.Error(t, err) + + _, _, err = runGetent("user\x00name") + assert.Error(t, err) +} + +func TestRunGetent_NotAvailable(t *testing.T) { + if _, err := exec.LookPath("getent"); err == nil { + t.Skip("getent is available, can't test missing case") + } + + _, _, err := runGetent("root") + assert.Error(t, err, "should fail when getent is not installed") +} + +func TestRunIdGroups_CurrentUser(t *testing.T) { + if _, err := exec.LookPath("id"); err != nil { + t.Skip("id not available on this system") + } + + current, err := user.Current() + require.NoError(t, err) + + groups, err := runIdGroups(current.Username) + require.NoError(t, err) + require.NotEmpty(t, groups, "current user should have at least one group") + + for _, gid := range groups { + _, err := strconv.ParseUint(gid, 10, 32) + assert.NoError(t, err, "group ID %q should be a valid uint32", gid) + } +} + +func TestRunIdGroups_NonexistentUser(t *testing.T) { + if _, err := exec.LookPath("id"); err != nil { + t.Skip("id not available on this system") + } + + _, err := runIdGroups("nonexistent_user_xyzzy_12345") + assert.Error(t, err) +} + +func TestRunIdGroups_InvalidInput(t *testing.T) { + _, err := runIdGroups("") + assert.Error(t, err) + + _, err = runIdGroups("user\x00name") + assert.Error(t, err) +} + +func TestGetentResultsMatchStdlib(t *testing.T) { + if _, err := exec.LookPath("getent"); err != nil { + t.Skip("getent not available on this system") + } + + current, err := user.Current() + require.NoError(t, err) + + getentUser, _, err := runGetent(current.Username) + require.NoError(t, err) + + assert.Equal(t, current.Username, getentUser.Username, "username should match") + assert.Equal(t, current.Uid, getentUser.Uid, "UID should match") + assert.Equal(t, current.Gid, getentUser.Gid, "GID should match") + assert.Equal(t, current.HomeDir, getentUser.HomeDir, "home directory should match") +} + +func TestGetentResultsMatchStdlib_ByUID(t *testing.T) { + if _, err := exec.LookPath("getent"); err != nil { + t.Skip("getent not available on this system") + } + + current, err := user.Current() + require.NoError(t, err) + + getentUser, _, err := runGetent(current.Uid) + require.NoError(t, err) + + assert.Equal(t, current.Username, getentUser.Username, "username should match when looked up by UID") + assert.Equal(t, current.Uid, getentUser.Uid, "UID should match") +} + +func TestIdGroupsMatchStdlib(t *testing.T) { + if _, err := exec.LookPath("id"); err != nil { + t.Skip("id not available on this system") + } + + current, err := user.Current() + require.NoError(t, err) + + stdGroups, err := current.GroupIds() + if err != nil { + t.Skip("os/user.GroupIds() not working, likely CGO_ENABLED=0") + } + + idGroups, err := runIdGroups(current.Username) + require.NoError(t, err) + + // Deduplicate both lists: id -G can return duplicates (e.g., root in Docker) + // and ElementsMatch treats duplicates as distinct. + assert.ElementsMatch(t, uniqueStrings(stdGroups), uniqueStrings(idGroups), "id -G should return same groups as os/user") +} + +func uniqueStrings(ss []string) []string { + seen := make(map[string]struct{}, len(ss)) + out := make([]string, 0, len(ss)) + for _, s := range ss { + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + return out +} + +// TestGetShellFromPasswd_CurrentUser verifies that getShellFromPasswd correctly +// reads the current user's shell from /etc/passwd by comparing it against what +// getent reports (which goes through NSS). +func TestGetShellFromPasswd_CurrentUser(t *testing.T) { + current, err := user.Current() + require.NoError(t, err) + + shell := getShellFromPasswd(current.Uid) + if shell == "" { + t.Skip("current user not found in /etc/passwd (may be an NSS-only user)") + } + + assert.True(t, shell[0] == '/', "shell should be an absolute path, got %q", shell) + + if _, err := exec.LookPath("getent"); err == nil { + _, getentShell, getentErr := runGetent(current.Uid) + if getentErr == nil && getentShell != "" { + assert.Equal(t, getentShell, shell, "shell from /etc/passwd should match getent") + } + } +} + +// TestGetShellFromPasswd_RootUser verifies that getShellFromPasswd can read +// root's shell from /etc/passwd. Root is guaranteed to be in /etc/passwd on +// any standard Unix system. +func TestGetShellFromPasswd_RootUser(t *testing.T) { + shell := getShellFromPasswd("0") + require.NotEmpty(t, shell, "root (UID 0) must be in /etc/passwd") + assert.True(t, shell[0] == '/', "root shell should be an absolute path, got %q", shell) +} + +// TestGetShellFromPasswd_NonexistentUID verifies that getShellFromPasswd +// returns empty for a UID that doesn't exist in /etc/passwd. +func TestGetShellFromPasswd_NonexistentUID(t *testing.T) { + shell := getShellFromPasswd("4294967294") + assert.Empty(t, shell, "nonexistent UID should return empty shell") +} + +// TestGetShellFromPasswd_MatchesGetentForKnownUsers reads /etc/passwd directly +// and cross-validates every entry against getent to ensure parseGetentPasswd +// and getShellFromPasswd agree on shell values. +func TestGetShellFromPasswd_MatchesGetentForKnownUsers(t *testing.T) { + if _, err := exec.LookPath("getent"); err != nil { + t.Skip("getent not available") + } + + // Pick a few well-known system UIDs that are virtually always in /etc/passwd. + uids := []string{"0"} // root + + current, err := user.Current() + require.NoError(t, err) + uids = append(uids, current.Uid) + + for _, uid := range uids { + passwdShell := getShellFromPasswd(uid) + if passwdShell == "" { + continue + } + + _, getentShell, err := runGetent(uid) + if err != nil { + continue + } + + assert.Equal(t, getentShell, passwdShell, "shell mismatch for UID %s", uid) + } +} diff --git a/client/ssh/server/getent_windows.go b/client/ssh/server/getent_windows.go new file mode 100644 index 000000000..3e76b3e8e --- /dev/null +++ b/client/ssh/server/getent_windows.go @@ -0,0 +1,26 @@ +//go:build windows + +package server + +import "os/user" + +// lookupWithGetent on Windows just delegates to os/user.Lookup. +// Windows does not use NSS/getent; its user lookup works without CGO. +func lookupWithGetent(username string) (*user.User, error) { + return user.Lookup(username) +} + +// currentUserWithGetent on Windows just delegates to os/user.Current. +func currentUserWithGetent() (*user.User, error) { + return user.Current() +} + +// getShellFromGetent is a no-op on Windows; shell resolution uses PowerShell detection. +func getShellFromGetent(_ string) string { + return "" +} + +// groupIdsWithFallback on Windows just delegates to u.GroupIds(). +func groupIdsWithFallback(u *user.User) ([]string, error) { + return u.GroupIds() +} diff --git a/client/ssh/server/jwt_test.go b/client/ssh/server/jwt_test.go new file mode 100644 index 000000000..b2f3ac6a0 --- /dev/null +++ b/client/ssh/server/jwt_test.go @@ -0,0 +1,753 @@ +package server + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "io" + "math/big" + "net" + "net/http" + "net/http/httptest" + "runtime" + "strconv" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + cryptossh "golang.org/x/crypto/ssh" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbssh "github.com/netbirdio/netbird/client/ssh" + sshauth "github.com/netbirdio/netbird/client/ssh/auth" + "github.com/netbirdio/netbird/client/ssh/client" + "github.com/netbirdio/netbird/client/ssh/detection" + "github.com/netbirdio/netbird/client/ssh/testutil" + nbjwt "github.com/netbirdio/netbird/shared/auth/jwt" + sshuserhash "github.com/netbirdio/netbird/shared/sshauth" +) + +func TestJWTEnforcement(t *testing.T) { + if testing.Short() { + t.Skip("Skipping JWT enforcement tests in short mode") + } + + // Set up SSH server + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + + t.Run("blocks_without_jwt", func(t *testing.T) { + jwtConfig := &JWTConfig{ + Issuer: "test-issuer", + Audiences: []string{"test-audience"}, + KeysLocation: "test-keys", + } + serverConfig := &Config{ + HostKeyPEM: hostKey, + JWT: jwtConfig, + } + server := New(serverConfig) + server.SetAllowRootLogin(true) + + serverAddr := StartTestServer(t, server) + defer func() { require.NoError(t, server.Stop()) }() + + host, portStr, err := net.SplitHostPort(serverAddr) + require.NoError(t, err) + port, err := strconv.Atoi(portStr) + require.NoError(t, err) + dialer := &net.Dialer{} + serverType, err := detection.DetectSSHServerType(context.Background(), dialer, host, port) + if err != nil { + t.Logf("Detection failed: %v", err) + } + t.Logf("Detected server type: %s", serverType) + + config := &cryptossh.ClientConfig{ + User: testutil.GetTestUsername(t), + Auth: []cryptossh.AuthMethod{}, + HostKeyCallback: cryptossh.InsecureIgnoreHostKey(), + Timeout: 2 * time.Second, + } + + _, err = cryptossh.Dial("tcp", net.JoinHostPort(host, portStr), config) + assert.Error(t, err, "SSH connection should fail when JWT is required but not provided") + }) + + t.Run("allows_when_disabled", func(t *testing.T) { + serverConfigNoJWT := &Config{ + HostKeyPEM: hostKey, + JWT: nil, + } + serverNoJWT := New(serverConfigNoJWT) + require.False(t, serverNoJWT.jwtEnabled, "JWT should be disabled without config") + serverNoJWT.SetAllowRootLogin(true) + + serverAddrNoJWT := StartTestServer(t, serverNoJWT) + defer func() { require.NoError(t, serverNoJWT.Stop()) }() + + hostNoJWT, portStrNoJWT, err := net.SplitHostPort(serverAddrNoJWT) + require.NoError(t, err) + portNoJWT, err := strconv.Atoi(portStrNoJWT) + require.NoError(t, err) + + dialer := &net.Dialer{} + serverType, err := detection.DetectSSHServerType(context.Background(), dialer, hostNoJWT, portNoJWT) + require.NoError(t, err) + assert.Equal(t, detection.ServerTypeNetBirdNoJWT, serverType) + assert.False(t, serverType.RequiresJWT()) + + client, err := connectWithNetBirdClient(t, hostNoJWT, portNoJWT) + require.NoError(t, err) + defer client.Close() + }) + +} + +// setupJWKSServer creates a test HTTP server serving JWKS and returns the server, private key, and URL +func setupJWKSServer(t *testing.T) (*httptest.Server, *rsa.PrivateKey, string) { + 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 +} + +// generateTestJWKS creates a test RSA key pair and returns private key and JWKS JSON +func generateTestJWKS(t *testing.T) (*rsa.PrivateKey, []byte) { + 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: base64RawURLEncode(n), + E: base64RawURLEncode(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 base64RawURLEncode(data []byte) string { + return base64.RawURLEncoding.EncodeToString(data) +} + +// generateValidJWT creates a valid JWT token for testing +func generateValidJWT(t *testing.T, privateKey *rsa.PrivateKey, issuer, audience string) string { + claims := jwt.MapClaims{ + "iss": issuer, + "aud": audience, + "sub": "test-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 +} + +// connectWithNetBirdClient connects to SSH server using NetBird's SSH client +func connectWithNetBirdClient(t *testing.T, host string, port int) (*client.Client, error) { + t.Helper() + addr := net.JoinHostPort(host, strconv.Itoa(port)) + + ctx := context.Background() + return client.Dial(ctx, addr, testutil.GetTestUsername(t), client.DialOptions{ + InsecureSkipVerify: true, + }) +} + +// TestJWTDetection tests that server detection correctly identifies JWT-enabled servers +func TestJWTDetection(t *testing.T) { + if testing.Short() { + t.Skip("Skipping JWT detection test in short mode") + } + + jwksServer, _, jwksURL := setupJWKSServer(t) + defer jwksServer.Close() + + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + + const ( + issuer = "https://test-issuer.example.com" + audience = "test-audience" + ) + + jwtConfig := &JWTConfig{ + Issuer: issuer, + Audiences: []string{audience}, + KeysLocation: jwksURL, + } + serverConfig := &Config{ + HostKeyPEM: hostKey, + JWT: jwtConfig, + } + server := New(serverConfig) + server.SetAllowRootLogin(true) + + serverAddr := StartTestServer(t, server) + defer func() { require.NoError(t, server.Stop()) }() + + host, portStr, err := net.SplitHostPort(serverAddr) + require.NoError(t, err) + port, err := strconv.Atoi(portStr) + require.NoError(t, err) + + dialer := &net.Dialer{} + serverType, err := detection.DetectSSHServerType(context.Background(), dialer, host, port) + require.NoError(t, err) + assert.Equal(t, detection.ServerTypeNetBirdJWT, serverType) + assert.True(t, serverType.RequiresJWT()) +} + +func TestJWTFailClose(t *testing.T) { + if testing.Short() { + t.Skip("Skipping JWT fail-close tests in short mode") + } + + jwksServer, privateKey, jwksURL := setupJWKSServer(t) + defer jwksServer.Close() + + const ( + issuer = "https://test-issuer.example.com" + audience = "test-audience" + ) + + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + + testCases := []struct { + name string + tokenClaims jwt.MapClaims + }{ + { + name: "blocks_token_missing_iat", + tokenClaims: jwt.MapClaims{ + "iss": issuer, + "aud": audience, + "sub": "test-user", + "exp": time.Now().Add(time.Hour).Unix(), + }, + }, + { + name: "blocks_token_missing_sub", + tokenClaims: jwt.MapClaims{ + "iss": issuer, + "aud": audience, + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + }, + }, + { + name: "blocks_token_missing_iss", + tokenClaims: jwt.MapClaims{ + "aud": audience, + "sub": "test-user", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + }, + }, + { + name: "blocks_token_missing_aud", + tokenClaims: jwt.MapClaims{ + "iss": issuer, + "sub": "test-user", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + }, + }, + { + name: "blocks_token_wrong_issuer", + tokenClaims: jwt.MapClaims{ + "iss": "wrong-issuer", + "aud": audience, + "sub": "test-user", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + }, + }, + { + name: "blocks_token_wrong_audience", + tokenClaims: jwt.MapClaims{ + "iss": issuer, + "aud": "wrong-audience", + "sub": "test-user", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + }, + }, + { + name: "blocks_expired_token", + tokenClaims: jwt.MapClaims{ + "iss": issuer, + "aud": audience, + "sub": "test-user", + "exp": time.Now().Add(-time.Hour).Unix(), + "iat": time.Now().Add(-2 * time.Hour).Unix(), + }, + }, + { + name: "blocks_token_exceeding_max_age", + tokenClaims: jwt.MapClaims{ + "iss": issuer, + "aud": audience, + "sub": "test-user", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Add(-2 * time.Hour).Unix(), + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + jwtConfig := &JWTConfig{ + Issuer: issuer, + Audiences: []string{audience}, + KeysLocation: jwksURL, + MaxTokenAge: 3600, + } + serverConfig := &Config{ + HostKeyPEM: hostKey, + JWT: jwtConfig, + } + server := New(serverConfig) + server.SetAllowRootLogin(true) + + serverAddr := StartTestServer(t, server) + defer func() { require.NoError(t, server.Stop()) }() + + host, portStr, err := net.SplitHostPort(serverAddr) + require.NoError(t, err) + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, tc.tokenClaims) + token.Header["kid"] = "test-key-id" + tokenString, err := token.SignedString(privateKey) + require.NoError(t, err) + + config := &cryptossh.ClientConfig{ + User: testutil.GetTestUsername(t), + Auth: []cryptossh.AuthMethod{ + cryptossh.Password(tokenString), + }, + HostKeyCallback: cryptossh.InsecureIgnoreHostKey(), + Timeout: 2 * time.Second, + } + + conn, err := cryptossh.Dial("tcp", net.JoinHostPort(host, portStr), config) + if conn != nil { + defer func() { + if err := conn.Close(); err != nil { + t.Logf("close connection: %v", err) + } + }() + } + + assert.Error(t, err, "Authentication should fail (fail-close)") + }) + } +} + +// TestJWTAuthentication tests JWT authentication with valid/invalid tokens and enforcement for various connection types +func TestJWTAuthentication(t *testing.T) { + if testing.Short() { + t.Skip("Skipping JWT authentication tests in short mode") + } + + jwksServer, privateKey, jwksURL := setupJWKSServer(t) + defer jwksServer.Close() + + const ( + issuer = "https://test-issuer.example.com" + audience = "test-audience" + ) + + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + + testCases := []struct { + name string + token string + wantAuthOK bool + setupServer func(*Server) + testOperation func(*testing.T, *cryptossh.Client, string) error + wantOpSuccess bool + }{ + { + name: "allows_shell_with_jwt", + token: "valid", + wantAuthOK: true, + setupServer: func(s *Server) { + s.SetAllowRootLogin(true) + }, + testOperation: func(t *testing.T, conn *cryptossh.Client, _ string) error { + session, err := conn.NewSession() + require.NoError(t, err) + defer session.Close() + return session.Shell() + }, + wantOpSuccess: true, + }, + { + name: "rejects_invalid_token", + token: "invalid", + wantAuthOK: false, + setupServer: func(s *Server) { + s.SetAllowRootLogin(true) + }, + testOperation: func(t *testing.T, conn *cryptossh.Client, _ string) error { + session, err := conn.NewSession() + require.NoError(t, err) + defer session.Close() + + output, err := session.CombinedOutput("echo test") + if err != nil { + t.Logf("Command output: %s", string(output)) + return err + } + return nil + }, + wantOpSuccess: false, + }, + { + name: "blocks_shell_without_jwt", + token: "", + wantAuthOK: false, + setupServer: func(s *Server) { + s.SetAllowRootLogin(true) + }, + testOperation: func(t *testing.T, conn *cryptossh.Client, _ string) error { + session, err := conn.NewSession() + require.NoError(t, err) + defer session.Close() + + output, err := session.CombinedOutput("echo test") + if err != nil { + t.Logf("Command output: %s", string(output)) + return err + } + return nil + }, + wantOpSuccess: false, + }, + { + name: "blocks_command_without_jwt", + token: "", + wantAuthOK: false, + setupServer: func(s *Server) { + s.SetAllowRootLogin(true) + }, + testOperation: func(t *testing.T, conn *cryptossh.Client, _ string) error { + session, err := conn.NewSession() + require.NoError(t, err) + defer session.Close() + + output, err := session.CombinedOutput("ls") + if err != nil { + t.Logf("Command output: %s", string(output)) + return err + } + return nil + }, + wantOpSuccess: false, + }, + { + name: "allows_sftp_with_jwt", + token: "valid", + wantAuthOK: true, + setupServer: func(s *Server) { + s.SetAllowRootLogin(true) + s.SetAllowSFTP(true) + }, + testOperation: func(t *testing.T, conn *cryptossh.Client, _ string) error { + session, err := conn.NewSession() + require.NoError(t, err) + defer session.Close() + + session.Stdout = io.Discard + session.Stderr = io.Discard + return session.RequestSubsystem("sftp") + }, + wantOpSuccess: true, + }, + { + name: "blocks_sftp_without_jwt", + token: "", + wantAuthOK: false, + setupServer: func(s *Server) { + s.SetAllowRootLogin(true) + s.SetAllowSFTP(true) + }, + testOperation: func(t *testing.T, conn *cryptossh.Client, _ string) error { + session, err := conn.NewSession() + require.NoError(t, err) + defer session.Close() + + session.Stdout = io.Discard + session.Stderr = io.Discard + err = session.RequestSubsystem("sftp") + if err == nil { + err = session.Wait() + } + return err + }, + wantOpSuccess: false, + }, + { + name: "allows_port_forward_with_jwt", + token: "valid", + wantAuthOK: true, + setupServer: func(s *Server) { + s.SetAllowRootLogin(true) + s.SetAllowRemotePortForwarding(true) + }, + testOperation: func(t *testing.T, conn *cryptossh.Client, _ string) error { + ln, err := conn.Listen("tcp", "127.0.0.1:0") + if ln != nil { + defer ln.Close() + } + return err + }, + wantOpSuccess: true, + }, + { + name: "blocks_port_forward_without_jwt", + token: "", + wantAuthOK: false, + setupServer: func(s *Server) { + s.SetAllowRootLogin(true) + s.SetAllowLocalPortForwarding(true) + }, + testOperation: func(t *testing.T, conn *cryptossh.Client, _ string) error { + ln, err := conn.Listen("tcp", "127.0.0.1:0") + if ln != nil { + defer ln.Close() + } + return err + }, + wantOpSuccess: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // TODO: Skip port forwarding tests on Windows - user switching not supported + // These features are tested on Linux/Unix platforms + if runtime.GOOS == "windows" && + (tc.name == "allows_port_forward_with_jwt" || + tc.name == "blocks_port_forward_without_jwt") { + t.Skip("Skipping port forwarding test on Windows - covered by Linux tests") + } + + jwtConfig := &JWTConfig{ + Issuer: issuer, + Audiences: []string{audience}, + KeysLocation: jwksURL, + } + serverConfig := &Config{ + HostKeyPEM: hostKey, + JWT: jwtConfig, + } + server := New(serverConfig) + if tc.setupServer != nil { + tc.setupServer(server) + } + + // Always set up authorization for test-user to ensure tests fail at JWT validation stage + testUserHash, err := sshuserhash.HashUserID("test-user") + require.NoError(t, err) + + // Get current OS username for machine user mapping + currentUser := testutil.GetTestUsername(t) + + authConfig := &sshauth.Config{ + UserIDClaim: sshauth.DefaultUserIDClaim, + AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash}, + MachineUsers: map[string][]uint32{ + currentUser: {0}, // Allow test-user (index 0) to access current OS user + }, + } + server.UpdateSSHAuth(authConfig) + + serverAddr := StartTestServer(t, server) + defer func() { require.NoError(t, server.Stop()) }() + + host, portStr, err := net.SplitHostPort(serverAddr) + require.NoError(t, err) + + var authMethods []cryptossh.AuthMethod + switch tc.token { + case "valid": + token := generateValidJWT(t, privateKey, issuer, audience) + authMethods = []cryptossh.AuthMethod{ + cryptossh.Password(token), + } + case "invalid": + invalidToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.invalid" + authMethods = []cryptossh.AuthMethod{ + cryptossh.Password(invalidToken), + } + } + + config := &cryptossh.ClientConfig{ + User: testutil.GetTestUsername(t), + Auth: authMethods, + HostKeyCallback: cryptossh.InsecureIgnoreHostKey(), + Timeout: 2 * time.Second, + } + + conn, err := cryptossh.Dial("tcp", net.JoinHostPort(host, portStr), config) + if tc.wantAuthOK { + require.NoError(t, err, "JWT authentication should succeed") + } else if err != nil { + t.Logf("Connection failed as expected: %v", err) + return + } + if conn != nil { + defer func() { + if err := conn.Close(); err != nil { + t.Logf("close connection: %v", err) + } + }() + } + + err = tc.testOperation(t, conn, serverAddr) + if tc.wantOpSuccess { + require.NoError(t, err, "Operation should succeed") + } else { + assert.Error(t, err, "Operation should fail") + } + }) + } +} + +// TestJWTMultipleAudiences tests JWT validation with multiple audiences (dashboard and CLI). +func TestJWTMultipleAudiences(t *testing.T) { + if testing.Short() { + t.Skip("Skipping JWT multiple audiences tests in short mode") + } + + jwksServer, privateKey, jwksURL := setupJWKSServer(t) + defer jwksServer.Close() + + const ( + issuer = "https://test-issuer.example.com" + dashboardAudience = "dashboard-audience" + cliAudience = "cli-audience" + ) + + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + + testCases := []struct { + name string + audience string + wantAuthOK bool + }{ + { + name: "accepts_dashboard_audience", + audience: dashboardAudience, + wantAuthOK: true, + }, + { + name: "accepts_cli_audience", + audience: cliAudience, + wantAuthOK: true, + }, + { + name: "rejects_unknown_audience", + audience: "unknown-audience", + wantAuthOK: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + jwtConfig := &JWTConfig{ + Issuer: issuer, + Audiences: []string{dashboardAudience, cliAudience}, + KeysLocation: jwksURL, + } + serverConfig := &Config{ + HostKeyPEM: hostKey, + JWT: jwtConfig, + } + server := New(serverConfig) + server.SetAllowRootLogin(true) + + testUserHash, err := sshuserhash.HashUserID("test-user") + require.NoError(t, err) + + currentUser := testutil.GetTestUsername(t) + authConfig := &sshauth.Config{ + UserIDClaim: sshauth.DefaultUserIDClaim, + AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash}, + MachineUsers: map[string][]uint32{ + currentUser: {0}, + }, + } + server.UpdateSSHAuth(authConfig) + + serverAddr := StartTestServer(t, server) + defer func() { require.NoError(t, server.Stop()) }() + + host, portStr, err := net.SplitHostPort(serverAddr) + require.NoError(t, err) + + token := generateValidJWT(t, privateKey, issuer, tc.audience) + config := &cryptossh.ClientConfig{ + User: testutil.GetTestUsername(t), + Auth: []cryptossh.AuthMethod{ + cryptossh.Password(token), + }, + HostKeyCallback: cryptossh.InsecureIgnoreHostKey(), + Timeout: 2 * time.Second, + } + + conn, err := cryptossh.Dial("tcp", net.JoinHostPort(host, portStr), config) + if tc.wantAuthOK { + require.NoError(t, err, "JWT authentication should succeed for audience %s", tc.audience) + defer func() { + if err := conn.Close(); err != nil { + t.Logf("close connection: %v", err) + } + }() + + session, err := conn.NewSession() + require.NoError(t, err) + defer session.Close() + + err = session.Shell() + require.NoError(t, err, "Shell should work with valid audience") + } else { + assert.Error(t, err, "JWT authentication should fail for unknown audience") + } + }) + } +} diff --git a/client/ssh/server/port_forwarding.go b/client/ssh/server/port_forwarding.go new file mode 100644 index 000000000..a47fdb48a --- /dev/null +++ b/client/ssh/server/port_forwarding.go @@ -0,0 +1,384 @@ +// Package server implements port forwarding for the SSH server. +// +// Security note: Port forwarding runs in the main server process without privilege separation. +// The attack surface is primarily io.Copy through well-tested standard library code, making it +// lower risk than shell execution which uses privilege-separated child processes. We enforce +// user-level port restrictions: non-privileged users cannot bind to ports < 1024. +package server + +import ( + "encoding/binary" + "fmt" + "net" + "runtime" + "strconv" + + "github.com/gliderlabs/ssh" + log "github.com/sirupsen/logrus" + cryptossh "golang.org/x/crypto/ssh" + + "github.com/netbirdio/netbird/util/netrelay" +) + +const privilegedPortThreshold = 1024 + +// sessionKey uniquely identifies an SSH session +type sessionKey string + +// forwardKey uniquely identifies a port forwarding listener +type forwardKey string + +// tcpipForwardMsg represents the structure for tcpip-forward SSH requests +type tcpipForwardMsg struct { + Host string + Port uint32 +} + +// SetAllowLocalPortForwarding configures local port forwarding +func (s *Server) SetAllowLocalPortForwarding(allow bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.allowLocalPortForwarding = allow +} + +// SetAllowRemotePortForwarding configures remote port forwarding +func (s *Server) SetAllowRemotePortForwarding(allow bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.allowRemotePortForwarding = allow +} + +// configurePortForwarding sets up port forwarding callbacks +func (s *Server) configurePortForwarding(server *ssh.Server) { + allowLocal := s.allowLocalPortForwarding + allowRemote := s.allowRemotePortForwarding + + 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: 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: %v", net.JoinHostPort(dstHost, strconv.Itoa(int(dstPort))), err) + return false + } + + return true + } + + 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: 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: %v", net.JoinHostPort(bindHost, strconv.Itoa(int(bindPort))), err) + return false + } + + return true + } + + log.Debugf("SSH server configured with local_forwarding=%v, remote_forwarding=%v", allowLocal, allowRemote) +} + +// checkPortForwardingPrivileges validates privilege requirements for port forwarding operations. +// For remote port forwarding (binding), it enforces that non-privileged users cannot bind to +// ports below 1024, mirroring the restriction they would face if binding directly. +// +// Note: FeatureSupportsUserSwitch is true because we accept requests from any authenticated user, +// though we don't actually switch users - port forwarding runs in the server process. The resolved +// user is used for privileged port access checks. +func (s *Server) checkPortForwardingPrivileges(ctx ssh.Context, forwardType string, port uint32) error { + if ctx == nil { + return fmt.Errorf("%s port forwarding denied: no context", forwardType) + } + + result := s.CheckPrivileges(PrivilegeCheckRequest{ + RequestedUsername: ctx.User(), + FeatureSupportsUserSwitch: true, + FeatureName: forwardType + " port forwarding", + }) + + if !result.Allowed { + return result.Error + } + + if err := s.checkPrivilegedPortAccess(forwardType, port, result); err != nil { + return err + } + + return nil +} + +// checkPrivilegedPortAccess enforces that non-privileged users cannot bind to privileged ports. +// This applies to remote port forwarding where the server binds a port on behalf of the user. +// On Windows, there is no privileged port restriction, so this check is skipped. +func (s *Server) checkPrivilegedPortAccess(forwardType string, port uint32, result PrivilegeCheckResult) error { + if runtime.GOOS == "windows" { + return nil + } + + isBindOperation := forwardType == "remote" || forwardType == "tcpip-forward" + if !isBindOperation { + return nil + } + + // Port 0 means "pick any available port", which will be >= 1024 + if port == 0 || port >= privilegedPortThreshold { + return nil + } + + if result.User != nil && isPrivilegedUsername(result.User.Username) { + return nil + } + + username := "unknown" + if result.User != nil { + username = result.User.Username + } + return fmt.Errorf("user %s cannot bind to privileged port %d (requires root)", username, port) +} + +// tcpipForwardHandler handles tcpip-forward requests for remote port forwarding. +func (s *Server) tcpipForwardHandler(ctx ssh.Context, _ *ssh.Server, req *cryptossh.Request) (bool, []byte) { + logger := s.getRequestLogger(ctx) + + if !s.isRemotePortForwardingAllowed() { + logger.Warnf("tcpip-forward request denied: remote port forwarding disabled") + return false, nil + } + + payload, err := s.parseTcpipForwardRequest(req) + if err != nil { + logger.Errorf("tcpip-forward unmarshal error: %v", err) + return false, nil + } + + if err := s.checkPortForwardingPrivileges(ctx, "tcpip-forward", payload.Port); err != nil { + logger.Warnf("tcpip-forward denied: %v", err) + return false, nil + } + + sshConn, err := s.getSSHConnection(ctx) + if err != nil { + logger.Warnf("tcpip-forward request denied: %v", err) + return false, nil + } + + return s.setupDirectForward(ctx, logger, sshConn, payload) +} + +// cancelTcpipForwardHandler handles cancel-tcpip-forward requests. +func (s *Server) cancelTcpipForwardHandler(ctx ssh.Context, _ *ssh.Server, req *cryptossh.Request) (bool, []byte) { + logger := s.getRequestLogger(ctx) + + var payload tcpipForwardMsg + if err := cryptossh.Unmarshal(req.Payload, &payload); err != nil { + logger.Errorf("cancel-tcpip-forward unmarshal error: %v", err) + return false, nil + } + + hostPort := net.JoinHostPort(payload.Host, strconv.Itoa(int(payload.Port))) + key := forwardKey(hostPort) + if s.removeRemoteForwardListener(key) { + forwardAddr := "-R " + hostPort + s.removeConnectionPortForward(ctx.RemoteAddr(), forwardAddr) + logger.Infof("remote port forwarding cancelled: %s", hostPort) + return true, nil + } + + logger.Warnf("cancel-tcpip-forward failed: no listener found for %s", net.JoinHostPort(payload.Host, strconv.Itoa(int(payload.Port)))) + return false, nil +} + +// handleRemoteForwardListener handles incoming connections for remote port forwarding. +func (s *Server) handleRemoteForwardListener(ctx ssh.Context, ln net.Listener, host string, port uint32) { + logger := s.getRequestLogger(ctx) + + defer func() { + if err := ln.Close(); err != nil { + logger.Debugf("remote forward listener close error for %s: %v", net.JoinHostPort(host, strconv.Itoa(int(port))), err) + } + }() + + acceptChan := make(chan acceptResult, 1) + + go func() { + for { + conn, err := ln.Accept() + select { + case acceptChan <- acceptResult{conn: conn, err: err}: + if err != nil { + return + } + case <-ctx.Done(): + return + } + } + }() + + for { + select { + case result := <-acceptChan: + if result.err != nil { + logger.Debugf("remote forward accept error: %v", result.err) + return + } + go s.handleRemoteForwardConnection(ctx, result.conn, host, port) + case <-ctx.Done(): + logger.Debugf("remote forward listener shutting down for %s", net.JoinHostPort(host, strconv.Itoa(int(port)))) + return + } + } +} + +// getRequestLogger creates a logger with session/conn and jwt_user context +func (s *Server) getRequestLogger(ctx ssh.Context) *log.Entry { + sessionKey := s.findSessionKeyByContext(ctx) + + s.mu.RLock() + defer s.mu.RUnlock() + + if state, exists := s.sessions[sessionKey]; exists { + logger := log.WithField("session", sessionKey) + if state.jwtUsername != "" { + logger = logger.WithField("jwt_user", state.jwtUsername) + } + return logger + } + + if ctx.RemoteAddr() != nil { + if connState, exists := s.connections[connKey(ctx.RemoteAddr().String())]; exists { + return s.connLogger(connState) + } + } + + remoteAddr := "unknown" + if ctx.RemoteAddr() != nil { + remoteAddr = ctx.RemoteAddr().String() + } + return log.WithField("session", fmt.Sprintf("%s@%s", ctx.User(), remoteAddr)) +} + +// isRemotePortForwardingAllowed checks if remote port forwarding is enabled +func (s *Server) isRemotePortForwardingAllowed() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.allowRemotePortForwarding +} + +// parseTcpipForwardRequest parses the SSH request payload +func (s *Server) parseTcpipForwardRequest(req *cryptossh.Request) (*tcpipForwardMsg, error) { + var payload tcpipForwardMsg + err := cryptossh.Unmarshal(req.Payload, &payload) + return &payload, err +} + +// getSSHConnection extracts SSH connection from context +func (s *Server) getSSHConnection(ctx ssh.Context) (*cryptossh.ServerConn, error) { + if ctx == nil { + return nil, fmt.Errorf("no context") + } + sshConnValue := ctx.Value(ssh.ContextKeyConn) + if sshConnValue == nil { + return nil, fmt.Errorf("no SSH connection in context") + } + sshConn, ok := sshConnValue.(*cryptossh.ServerConn) + if !ok || sshConn == nil { + return nil, fmt.Errorf("invalid SSH connection in context") + } + return sshConn, nil +} + +// setupDirectForward sets up a direct port forward +func (s *Server) setupDirectForward(ctx ssh.Context, logger *log.Entry, sshConn *cryptossh.ServerConn, payload *tcpipForwardMsg) (bool, []byte) { + bindAddr := net.JoinHostPort(payload.Host, strconv.FormatUint(uint64(payload.Port), 10)) + + ln, err := net.Listen("tcp", bindAddr) + if err != nil { + logger.Errorf("tcpip-forward listen failed on %s: %v", bindAddr, err) + return false, nil + } + + actualPort := payload.Port + if payload.Port == 0 { + tcpAddr := ln.Addr().(*net.TCPAddr) + actualPort = uint32(tcpAddr.Port) + logger.Debugf("tcpip-forward allocated port %d for %s", actualPort, payload.Host) + } + + key := forwardKey(net.JoinHostPort(payload.Host, strconv.Itoa(int(payload.Port)))) + s.storeRemoteForwardListener(key, ln) + + 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", net.JoinHostPort(payload.Host, strconv.Itoa(int(actualPort)))) + return true, response +} + +// acceptResult holds the result of a listener Accept() call +type acceptResult struct { + conn net.Conn + err error +} + +// handleRemoteForwardConnection handles a single remote port forwarding connection +func (s *Server) handleRemoteForwardConnection(ctx ssh.Context, conn net.Conn, host string, port uint32) { + logger := s.getRequestLogger(ctx) + + sshConn, ok := ctx.Value(ssh.ContextKeyConn).(*cryptossh.ServerConn) + if !ok || sshConn == nil { + logger.Debugf("remote forward: no SSH connection in context") + _ = conn.Close() + return + } + + remoteAddr, ok := conn.RemoteAddr().(*net.TCPAddr) + if !ok { + logger.Warnf("remote forward: non-TCP connection type: %T", conn.RemoteAddr()) + _ = conn.Close() + return + } + + channel, err := s.openForwardChannel(sshConn, host, port, remoteAddr) + if err != nil { + logger.Debugf("open forward channel for %s: %v", net.JoinHostPort(host, strconv.Itoa(int(port))), err) + _ = conn.Close() + return + } + + netrelay.Relay(ctx, conn, channel, netrelay.Options{Logger: logger}) +} + +// openForwardChannel creates an SSH forwarded-tcpip channel +func (s *Server) openForwardChannel(sshConn *cryptossh.ServerConn, host string, port uint32, remoteAddr *net.TCPAddr) (cryptossh.Channel, error) { + payload := struct { + ConnectedAddress string + ConnectedPort uint32 + OriginatorAddress string + OriginatorPort uint32 + }{ + ConnectedAddress: host, + ConnectedPort: port, + OriginatorAddress: remoteAddr.IP.String(), + OriginatorPort: uint32(remoteAddr.Port), + } + + channel, reqs, err := sshConn.OpenChannel("forwarded-tcpip", cryptossh.Marshal(&payload)) + if err != nil { + return nil, fmt.Errorf("open SSH channel: %w", err) + } + + go cryptossh.DiscardRequests(reqs) + return channel, nil +} diff --git a/client/ssh/server/server.go b/client/ssh/server/server.go new file mode 100644 index 000000000..6735e0f3b --- /dev/null +++ b/client/ssh/server/server.go @@ -0,0 +1,966 @@ +package server + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/netip" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/gliderlabs/ssh" + gojwt "github.com/golang-jwt/jwt/v5" + log "github.com/sirupsen/logrus" + cryptossh "golang.org/x/crypto/ssh" + "golang.org/x/exp/maps" + "golang.zx2c4.com/wireguard/tun/netstack" + + "github.com/netbirdio/netbird/client/iface/wgaddr" + sshauth "github.com/netbirdio/netbird/client/ssh/auth" + "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" +) + +// DefaultSSHPort is the default SSH port of the NetBird's embedded SSH server +const DefaultSSHPort = 22 + +// InternalSSHPort is the port SSH server listens on and is redirected to +const InternalSSHPort = 22022 + +const ( + errWriteSession = "write session error: %v" + errExitSession = "exit session error: %v" + + msgPrivilegedUserDisabled = "privileged user login is disabled" + + cmdInteractiveShell = "" + cmdPortForwarding = "" + cmdSFTP = "" + cmdNonInteractive = "" + + // DefaultJWTMaxTokenAge is the default maximum age for JWT tokens accepted by the SSH server. + // Set to 10 minutes to accommodate identity providers like Azure Entra ID + // that backdate the iat claim by up to 5 minutes. + DefaultJWTMaxTokenAge = 10 * 60 +) + +// 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") +) + +// PrivilegedUserError represents an error when privileged user login is disabled +type PrivilegedUserError struct { + Username string +} + +func (e *PrivilegedUserError) Error() string { + return fmt.Sprintf("%s for user: %s", msgPrivilegedUserDisabled, e.Username) +} + +func (e *PrivilegedUserError) Is(target error) bool { + return target == ErrPrivilegedUserDisabled +} + +// UserNotFoundError represents an error when a user cannot be found +type UserNotFoundError struct { + Username string + Cause error +} + +func (e *UserNotFoundError) Error() string { + if e.Cause != nil { + return fmt.Sprintf("user %s not found: %v", e.Username, e.Cause) + } + return fmt.Sprintf("user %s not found", e.Username) +} + +func (e *UserNotFoundError) Is(target error) bool { + return target == ErrUserNotFound +} + +func (e *UserNotFoundError) Unwrap() error { + return e.Cause +} + +// logSessionExitError logs session exit errors, ignoring EOF (normal close) errors +func logSessionExitError(logger *log.Entry, err error) { + if err != nil && !errors.Is(err, io.EOF) { + logger.Warnf(errExitSession, err) + } +} + +// safeLogCommand returns a safe representation of the command for logging. +func safeLogCommand(cmd []string) string { + if len(cmd) == 0 { + return cmdInteractiveShell + } + if len(cmd) == 1 { + return cmd[0] + } + return fmt.Sprintf("%s [%d args]", cmd[0], len(cmd)-1) +} + +// connState tracks the state of an SSH connection for port forwarding and status display. +type connState struct { + username string + remoteAddr net.Addr + portForwards []string + jwtUsername string +} + +// authKey uniquely identifies an authentication attempt by username and remote address. +// Used to temporarily store JWT username between passwordHandler and sessionHandler. +type authKey string + +// connKey uniquely identifies an SSH connection by its remote address. +// Used to track authenticated connections for status display and port forwarding. +type connKey string + +func newAuthKey(username string, remoteAddr net.Addr) authKey { + return authKey(fmt.Sprintf("%s@%s", username, remoteAddr.String())) +} + +// sessionState tracks an active SSH session (shell, command, or subsystem like SFTP). +type sessionState struct { + session ssh.Session + sessionType string + jwtUsername string +} + +type Server struct { + 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. + sessions map[sessionKey]*sessionState + + // pendingAuthJWT temporarily stores JWT username during the auth→session handoff. + // Populated in passwordHandler, consumed in sessionHandler/sftpSubsystemHandler. + pendingAuthJWT map[authKey]string + + // connections tracks all SSH connections by their remote address. + // Populated at authentication time, stores JWT username and port forwards for status display. + connections map[connKey]*connState + + allowLocalPortForwarding bool + allowRemotePortForwarding bool + allowRootLogin bool + allowSFTP bool + jwtEnabled bool + + netstackNet *netstack.Net + + wgAddress wgaddr.Address + + remoteForwardListeners map[forwardKey]net.Listener + + jwtValidator *jwt.Validator + jwtExtractor *jwt.ClaimsExtractor + jwtConfig *JWTConfig + + authorizer *sshauth.Authorizer + + suSupportsPty bool + loginIsUtilLinux bool +} + +type JWTConfig struct { + Issuer string + KeysLocation string + MaxTokenAge int64 + Audiences []string +} + +// Config contains all SSH server configuration options +type Config struct { + // JWT authentication configuration. If nil, JWT authentication is disabled + JWT *JWTConfig + + // HostKey is the SSH server host key in PEM format + HostKeyPEM []byte +} + +// SessionInfo contains information about an active SSH session +type SessionInfo struct { + Username string + RemoteAddress string + Command string + JWTUsername string + PortForwards []string +} + +// New creates an SSH server instance with the provided host key and optional JWT configuration +// If jwtConfig is nil, JWT authentication is disabled +func New(config *Config) *Server { + s := &Server{ + mu: sync.RWMutex{}, + hostKeyPEM: config.HostKeyPEM, + sessions: make(map[sessionKey]*sessionState), + pendingAuthJWT: make(map[authKey]string), + remoteForwardListeners: make(map[forwardKey]net.Listener), + connections: make(map[connKey]*connState), + jwtEnabled: config.JWT != nil, + jwtConfig: config.JWT, + authorizer: sshauth.NewAuthorizer(), // Initialize with empty config + } + + return s +} + +// Start runs the SSH server +func (s *Server) Start(ctx context.Context, addr netip.AddrPort) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.sshServer != nil { + return errors.New("SSH server is already running") + } + + s.suSupportsPty = s.detectSuPtySupport(ctx) + s.loginIsUtilLinux = s.detectUtilLinuxLogin(ctx) + + ln, addrDesc, err := s.createListener(ctx, addr) + if err != nil { + return fmt.Errorf("create listener: %w", err) + } + + sshServer, err := s.createSSHServer(ln.Addr()) + if err != nil { + s.closeListener(ln) + return fmt.Errorf("create SSH server: %w", err) + } + + s.listener = ln + s.sshServer = sshServer + log.Infof("SSH server started on %s", addrDesc) + + go func() { + if err := sshServer.Serve(ln); err != nil && !errors.Is(err, ssh.ErrServerClosed) { + log.Errorf("SSH server error: %v", err) + } + }() + 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) + if err != nil { + return nil, "", fmt.Errorf("listen on netstack: %w", err) + } + return ln, fmt.Sprintf("netstack %s", addr), nil + } + + tcpAddr := net.TCPAddrFromAddrPort(addr) + lc := net.ListenConfig{} + ln, err := lc.Listen(ctx, "tcp", tcpAddr.String()) + if err != nil { + return nil, "", fmt.Errorf("listen: %w", err) + } + return ln, addr.String(), nil +} + +func (s *Server) closeListener(ln net.Listener) { + if ln == nil { + return + } + if err := ln.Close(); err != nil { + log.Debugf("listener close error: %v", err) + } +} + +// Stop closes the SSH server +func (s *Server) Stop() error { + s.mu.Lock() + sshServer := s.sshServer + if sshServer == nil { + s.mu.Unlock() + return nil + } + 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. + if err := sshServer.Close(); err != nil { + 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) + maps.Clear(s.connections) + + for _, listener := range s.remoteForwardListeners { + if err := listener.Close(); err != nil { + log.Debugf("close remote forward listener: %v", err) + } + } + maps.Clear(s.remoteForwardListeners) + s.mu.Unlock() + + return nil +} + +// Addr returns the address the SSH server is listening on, or nil if the server is not running +func (s *Server) Addr() net.Addr { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.listener == nil { + return nil + } + + return s.listener.Addr() +} + +// GetStatus returns the current status of the SSH server and active sessions. +func (s *Server) GetStatus() (enabled bool, sessions []SessionInfo) { + s.mu.RLock() + defer s.mu.RUnlock() + + enabled = s.sshServer != nil + reportedAddrs := make(map[string]bool) + + for _, state := range s.sessions { + info := s.buildSessionInfo(state) + reportedAddrs[info.RemoteAddress] = true + sessions = append(sessions, info) + } + + // Add authenticated connections without sessions (e.g., -N or port-forwarding only) + for key, connState := range s.connections { + remoteAddr := string(key) + if reportedAddrs[remoteAddr] { + continue + } + cmd := cmdNonInteractive + if len(connState.portForwards) > 0 { + cmd = cmdPortForwarding + } + sessions = append(sessions, SessionInfo{ + Username: connState.username, + RemoteAddress: remoteAddr, + Command: cmd, + JWTUsername: connState.jwtUsername, + PortForwards: connState.portForwards, + }) + } + + return enabled, sessions +} + +func (s *Server) buildSessionInfo(state *sessionState) SessionInfo { + session := state.session + cmd := state.sessionType + if cmd == "" { + cmd = safeLogCommand(session.Command()) + } + + remoteAddr := session.RemoteAddr().String() + info := SessionInfo{ + Username: session.User(), + RemoteAddress: remoteAddr, + Command: cmd, + JWTUsername: state.jwtUsername, + } + + connState, exists := s.connections[connKey(remoteAddr)] + if !exists { + return info + } + + info.PortForwards = connState.portForwards + if len(connState.portForwards) > 0 && (cmd == cmdInteractiveShell || cmd == cmdNonInteractive) { + info.Command = cmdPortForwarding + } + + return info +} + +// SetNetstackNet sets the netstack network for userspace networking +func (s *Server) SetNetstackNet(net *netstack.Net) { + s.mu.Lock() + defer s.mu.Unlock() + s.netstackNet = net +} + +// SetNetworkValidation configures network-based connection filtering +func (s *Server) SetNetworkValidation(addr wgaddr.Address) { + s.mu.Lock() + defer s.mu.Unlock() + s.wgAddress = addr +} + +// UpdateSSHAuth updates the SSH fine-grained access control configuration +// This should be called when network map updates include new SSH auth configuration +func (s *Server) UpdateSSHAuth(config *sshauth.Config) { + s.mu.Lock() + defer s.mu.Unlock() + + // Reset JWT validator/extractor to pick up new userIDClaim + s.jwtValidator = nil + s.jwtExtractor = nil + + s.authorizer.Update(config) +} + +// ensureJWTValidator initializes the JWT validator and extractor if not already initialized +func (s *Server) ensureJWTValidator() error { + s.mu.RLock() + if s.jwtValidator != nil && s.jwtExtractor != nil { + s.mu.RUnlock() + return nil + } + config := s.jwtConfig + authorizer := s.authorizer + s.mu.RUnlock() + + if config == nil { + return fmt.Errorf("JWT config not set") + } + + if len(config.Audiences) == 0 { + return fmt.Errorf("JWT config has no audiences configured") + } + + log.Debugf("Initializing JWT validator (issuer: %s, audiences: %v)", config.Issuer, config.Audiences) + validator := jwt.NewValidator( + config.Issuer, + config.Audiences, + config.KeysLocation, + true, + ) + + // Use custom userIDClaim from authorizer if available + extractorOptions := []jwt.ClaimsExtractorOption{ + jwt.WithAudience(config.Audiences[0]), + } + if authorizer.GetUserIDClaim() != "" { + extractorOptions = append(extractorOptions, jwt.WithUserIDClaim(authorizer.GetUserIDClaim())) + log.Debugf("Using custom user ID claim: %s", authorizer.GetUserIDClaim()) + } + + extractor := jwt.NewClaimsExtractor(extractorOptions...) + + s.mu.Lock() + defer s.mu.Unlock() + + if s.jwtValidator != nil && s.jwtExtractor != nil { + return nil + } + + s.jwtValidator = validator + s.jwtExtractor = extractor + + log.Infof("JWT validator initialized successfully") + return nil +} + +func (s *Server) validateJWTToken(tokenString string) (*gojwt.Token, error) { + s.mu.RLock() + jwtValidator := s.jwtValidator + jwtConfig := s.jwtConfig + s.mu.RUnlock() + + if jwtValidator == nil { + return nil, fmt.Errorf("JWT validator not initialized") + } + + token, err := jwtValidator.ValidateAndParse(context.Background(), tokenString) + if err != nil { + if jwtConfig != nil { + if claims, parseErr := s.parseTokenWithoutValidation(tokenString); parseErr == nil { + return nil, fmt.Errorf("validate token (expected issuer=%s, audiences=%v, actual issuer=%v, audience=%v): %w", + jwtConfig.Issuer, jwtConfig.Audiences, claims["iss"], claims["aud"], err) + } + } + return nil, fmt.Errorf("validate token: %w", err) + } + + if err := s.checkTokenAge(token, jwtConfig); err != nil { + return nil, err + } + + return token, nil +} + +func (s *Server) checkTokenAge(token *gojwt.Token, jwtConfig *JWTConfig) error { + if jwtConfig == nil { + return nil + } + + maxTokenAge := jwtConfig.MaxTokenAge + if maxTokenAge <= 0 { + maxTokenAge = DefaultJWTMaxTokenAge + } + + claims, ok := token.Claims.(gojwt.MapClaims) + if !ok { + userID := extractUserID(token) + return fmt.Errorf("token has invalid claims format (user=%s)", userID) + } + + iat, ok := claims["iat"].(float64) + if !ok { + userID := extractUserID(token) + return fmt.Errorf("token missing iat claim (user=%s)", userID) + } + + issuedAt := time.Unix(int64(iat), 0) + tokenAge := time.Since(issuedAt) + maxAge := time.Duration(maxTokenAge) * time.Second + if tokenAge > maxAge { + userID := getUserIDFromClaims(claims) + return fmt.Errorf("token expired for user=%s: age=%v, max=%v", userID, tokenAge, maxAge) + } + + return nil +} + +func (s *Server) extractAndValidateUser(token *gojwt.Token) (*auth.UserAuth, error) { + s.mu.RLock() + jwtExtractor := s.jwtExtractor + s.mu.RUnlock() + + if jwtExtractor == nil { + userID := extractUserID(token) + return nil, fmt.Errorf("JWT extractor not initialized (user=%s)", userID) + } + + userAuth, err := jwtExtractor.ToUserAuth(token) + if err != nil { + userID := extractUserID(token) + return nil, fmt.Errorf("extract user from token (user=%s): %w", userID, err) + } + + if !s.hasSSHAccess(&userAuth) { + return nil, fmt.Errorf("user %s does not have SSH access permissions", userAuth.UserId) + } + + return &userAuth, nil +} + +func (s *Server) hasSSHAccess(userAuth *auth.UserAuth) bool { + return userAuth.UserId != "" +} + +func extractUserID(token *gojwt.Token) string { + if token == nil { + return "unknown" + } + claims, ok := token.Claims.(gojwt.MapClaims) + if !ok { + return "unknown" + } + return getUserIDFromClaims(claims) +} + +func getUserIDFromClaims(claims gojwt.MapClaims) string { + if sub, ok := claims["sub"].(string); ok && sub != "" { + return sub + } + if userID, ok := claims["user_id"].(string); ok && userID != "" { + return userID + } + if email, ok := claims["email"].(string); ok && email != "" { + return email + } + return "unknown" +} + +func (s *Server) parseTokenWithoutValidation(tokenString string) (map[string]interface{}, error) { + parts := strings.Split(tokenString, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid token format") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("decode payload: %w", err) + } + + var claims map[string]interface{} + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, fmt.Errorf("parse claims: %w", err) + } + + return claims, nil +} + +func (s *Server) passwordHandler(ctx ssh.Context, password string) bool { + osUsername := ctx.User() + remoteAddr := ctx.RemoteAddr() + logger := s.getRequestLogger(ctx) + + if err := s.ensureJWTValidator(); err != nil { + logger.Errorf("JWT validator initialization failed: %v", err) + return false + } + + token, err := s.validateJWTToken(password) + if err != nil { + logger.Warnf("JWT authentication failed: %v", err) + return false + } + + userAuth, err := s.extractAndValidateUser(token) + if err != nil { + logger.Warnf("user validation failed: %v", err) + return false + } + + logger = logger.WithField("jwt_user", userAuth.UserId) + + s.mu.RLock() + authorizer := s.authorizer + s.mu.RUnlock() + + msg, err := authorizer.Authorize(userAuth.UserId, osUsername) + if err != nil { + logger.Warnf("SSH auth denied: %v", err) + return false + } + + logger.Infof("SSH auth %s", msg) + + key := newAuthKey(osUsername, remoteAddr) + remoteAddrStr := ctx.RemoteAddr().String() + s.mu.Lock() + s.pendingAuthJWT[key] = userAuth.UserId + s.connections[connKey(remoteAddrStr)] = &connState{ + username: ctx.User(), + remoteAddr: ctx.RemoteAddr(), + jwtUsername: userAuth.UserId, + } + s.mu.Unlock() + + return true +} + +func (s *Server) addConnectionPortForward(username string, remoteAddr net.Addr, forwardAddr string) { + s.mu.Lock() + defer s.mu.Unlock() + + key := connKey(remoteAddr.String()) + if state, exists := s.connections[key]; exists { + if !slices.Contains(state.portForwards, forwardAddr) { + state.portForwards = append(state.portForwards, forwardAddr) + } + return + } + + // Connection not in connections (non-JWT auth path) + s.connections[key] = &connState{ + username: username, + remoteAddr: remoteAddr, + portForwards: []string{forwardAddr}, + jwtUsername: s.pendingAuthJWT[newAuthKey(username, remoteAddr)], + } +} + +func (s *Server) removeConnectionPortForward(remoteAddr net.Addr, forwardAddr string) { + s.mu.Lock() + defer s.mu.Unlock() + + state, exists := s.connections[connKey(remoteAddr.String())] + if !exists { + return + } + + state.portForwards = slices.DeleteFunc(state.portForwards, func(addr string) bool { + return addr == forwardAddr + }) +} + +// trackedConn wraps a net.Conn to detect when it closes +type trackedConn struct { + net.Conn + server *Server + remoteAddr string + onceClose sync.Once +} + +func (c *trackedConn) Close() error { + err := c.Conn.Close() + c.onceClose.Do(func() { + c.server.handleConnectionClose(c.remoteAddr) + }) + return err +} + +func (s *Server) handleConnectionClose(remoteAddr string) { + s.mu.Lock() + defer s.mu.Unlock() + + key := connKey(remoteAddr) + state, exists := s.connections[key] + if exists && len(state.portForwards) > 0 { + s.connLogger(state).Info("port forwarding connection closed") + } + delete(s.connections, key) +} + +func (s *Server) connLogger(state *connState) *log.Entry { + logger := log.WithField("session", fmt.Sprintf("%s@%s", state.username, state.remoteAddr)) + if state.jwtUsername != "" { + logger = logger.WithField("jwt_user", state.jwtUsername) + } + return logger +} + +func (s *Server) findSessionKeyByContext(ctx ssh.Context) sessionKey { + if ctx == nil { + return "unknown" + } + + sshConn := ctx.Value(ssh.ContextKeyConn) + if sshConn == nil { + return "unknown" + } + + s.mu.RLock() + defer s.mu.RUnlock() + + for sessionKey, state := range s.sessions { + if state.session.Context().Value(ssh.ContextKeyConn) == sshConn { + return sessionKey + } + } + + if ctx.User() != "" && ctx.RemoteAddr() != nil { + return sessionKey(fmt.Sprintf("%s@%s", ctx.User(), ctx.RemoteAddr().String())) + } + + return "unknown" +} + +func (s *Server) connectionValidator(_ ssh.Context, conn net.Conn) net.Conn { + s.mu.RLock() + wgAddr := s.wgAddress + s.mu.RUnlock() + + if !wgAddr.Network.IsValid() || !wgAddr.IP.IsValid() { + return conn + } + + remoteAddr := conn.RemoteAddr() + tcpAddr, ok := remoteAddr.(*net.TCPAddr) + if !ok { + log.Warnf("SSH connection rejected: non-TCP address %s", remoteAddr) + return nil + } + + remoteIP, ok := netip.AddrFromSlice(tcpAddr.IP) + if !ok { + 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 == wgAddr.IP || wgAddr.IPv6.IsValid() && remoteIP == wgAddr.IPv6 { + log.Warnf("SSH connection rejected from own IP %s", remoteIP) + return nil + } + + 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 + } + + log.Infof("SSH connection from NetBird peer %s allowed", tcpAddr) + return &trackedConn{ + Conn: conn, + server: s, + remoteAddr: conn.RemoteAddr().String(), + } +} + +func (s *Server) createSSHServer(addr net.Addr) (*ssh.Server, error) { + if err := enableUserSwitching(); err != nil { + log.Warnf("failed to enable user switching: %v", err) + } + + serverVersion := fmt.Sprintf("%s-%s", detection.ServerIdentifier, version.NetbirdVersion()) + if s.jwtEnabled { + serverVersion += " " + detection.JWTRequiredMarker + } + + server := &ssh.Server{ + Addr: addr.String(), + Handler: s.sessionHandler, + SubsystemHandlers: map[string]ssh.SubsystemHandler{ + "sftp": s.sftpSubsystemHandler, + }, + HostSigners: []ssh.Signer{}, + ChannelHandlers: map[string]ssh.ChannelHandler{ + "session": ssh.DefaultSessionHandler, + "direct-tcpip": s.directTCPIPHandler, + }, + RequestHandlers: map[string]ssh.RequestHandler{ + "tcpip-forward": s.tcpipForwardHandler, + "cancel-tcpip-forward": s.cancelTcpipForwardHandler, + }, + ConnCallback: s.connectionValidator, + Version: serverVersion, + } + + if s.jwtEnabled { + server.PasswordHandler = s.passwordHandler + } + + hostKeyPEM := ssh.HostKeyPEM(s.hostKeyPEM) + if err := server.SetOption(hostKeyPEM); err != nil { + return nil, fmt.Errorf("set host key: %w", err) + } + + s.configurePortForwarding(server) + return server, nil +} + +func (s *Server) storeRemoteForwardListener(key forwardKey, ln net.Listener) { + s.mu.Lock() + defer s.mu.Unlock() + s.remoteForwardListeners[key] = ln +} + +func (s *Server) removeRemoteForwardListener(key forwardKey) bool { + s.mu.Lock() + defer s.mu.Unlock() + + ln, exists := s.remoteForwardListeners[key] + if !exists { + return false + } + + delete(s.remoteForwardListeners, key) + if err := ln.Close(); err != nil { + log.Debugf("remote forward listener close error: %v", err) + } + + return true +} + +func (s *Server) directTCPIPHandler(srv *ssh.Server, conn *cryptossh.ServerConn, newChan cryptossh.NewChannel, ctx ssh.Context) { + logger := s.getRequestLogger(ctx) + + var payload struct { + Host string + Port uint32 + OriginatorAddr string + OriginatorPort uint32 + } + + if err := cryptossh.Unmarshal(newChan.ExtraData(), &payload); err != nil { + if err := newChan.Reject(cryptossh.ConnectionFailed, "parse payload"); err != nil { + logger.Debugf("channel reject error: %v", err) + } + return + } + + s.mu.RLock() + allowLocal := s.allowLocalPortForwarding + s.mu.RUnlock() + + if !allowLocal { + 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: %v", net.JoinHostPort(payload.Host, strconv.Itoa(int(payload.Port))), err) + _ = newChan.Reject(cryptossh.Prohibited, "insufficient privileges") + return + } + + 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", hostPort) + + 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 new file mode 100644 index 000000000..f70e29963 --- /dev/null +++ b/client/ssh/server/server_config_test.go @@ -0,0 +1,554 @@ +package server + +import ( + "context" + "fmt" + "net" + "os/user" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/ssh" + sshclient "github.com/netbirdio/netbird/client/ssh/client" +) + +func TestServer_RootLoginRestriction(t *testing.T) { + // Generate host key for server + hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519) + require.NoError(t, err) + + tests := []struct { + name string + allowRoot bool + username string + expectError bool + description string + }{ + { + name: "root login allowed", + allowRoot: true, + username: "root", + expectError: false, + description: "Root login should succeed when allowed", + }, + { + name: "root login denied", + allowRoot: false, + username: "root", + expectError: true, + description: "Root login should fail when disabled", + }, + { + name: "regular user login always allowed", + allowRoot: false, + username: "testuser", + expectError: false, + description: "Regular user login should work regardless of root setting", + }, + } + + // Add Windows Administrator tests if on Windows + if runtime.GOOS == "windows" { + tests = append(tests, []struct { + name string + allowRoot bool + username string + expectError bool + description string + }{ + { + name: "Administrator login allowed", + allowRoot: true, + username: "Administrator", + expectError: false, + description: "Administrator login should succeed when allowed", + }, + { + name: "Administrator login denied", + allowRoot: false, + username: "Administrator", + expectError: true, + description: "Administrator login should fail when disabled", + }, + { + name: "administrator login denied (lowercase)", + allowRoot: false, + username: "administrator", + expectError: true, + description: "administrator login should fail when disabled (case insensitive)", + }, + }...) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Mock privileged environment to test root access controls + // Set up mock users based on platform + mockUsers := map[string]*user.User{ + "root": createTestUser("root", "0", "0", "/root"), + "testuser": createTestUser("testuser", "1000", "1000", "/home/testuser"), + } + + // Add Windows-specific users for Administrator tests + if runtime.GOOS == "windows" { + mockUsers["Administrator"] = createTestUser("Administrator", "500", "544", "C:\\Users\\Administrator") + mockUsers["administrator"] = createTestUser("administrator", "500", "544", "C:\\Users\\administrator") + } + + cleanup := setupTestDependencies( + createTestUser("root", "0", "0", "/root"), // Running as root + nil, + runtime.GOOS, + 0, // euid 0 (root) + mockUsers, + nil, + ) + defer cleanup() + + // Create server with specific configuration + serverConfig := &Config{ + HostKeyPEM: hostKey, + JWT: nil, + } + server := New(serverConfig) + server.SetAllowRootLogin(tt.allowRoot) + + // Test the userNameLookup method directly + user, err := server.userNameLookup(tt.username) + + if tt.expectError { + assert.Error(t, err, tt.description) + if tt.username == "root" || strings.ToLower(tt.username) == "administrator" { + // Check for appropriate error message based on platform capabilities + errorMsg := err.Error() + // Either privileged user restriction OR user switching limitation + hasPrivilegedError := strings.Contains(errorMsg, "privileged user") + hasSwitchingError := strings.Contains(errorMsg, "cannot switch") || strings.Contains(errorMsg, "user switching not supported") + assert.True(t, hasPrivilegedError || hasSwitchingError, + "Expected privileged user or user switching error, got: %s", errorMsg) + } + } else { + if tt.username == "root" || strings.ToLower(tt.username) == "administrator" { + // For privileged users, we expect either success or a different error + // (like user not found), but not the "login disabled" error + if err != nil { + assert.NotContains(t, err.Error(), "privileged user login is disabled") + } + } else { + // For regular users, lookup should generally succeed or fall back gracefully + // Note: may return current user as fallback + assert.NotNil(t, user) + } + } + }) + } +} + +func TestServer_PortForwardingRestriction(t *testing.T) { + // Test that the port forwarding callbacks properly respect configuration flags + // This is a unit test of the callback logic, not a full integration test + + // Generate host key for server + hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519) + require.NoError(t, err) + + tests := []struct { + name string + allowLocalForwarding bool + allowRemoteForwarding bool + description string + }{ + { + name: "all forwarding allowed", + allowLocalForwarding: true, + allowRemoteForwarding: true, + description: "Both local and remote forwarding should be allowed", + }, + { + name: "local forwarding disabled", + allowLocalForwarding: false, + allowRemoteForwarding: true, + description: "Local forwarding should be denied when disabled", + }, + { + name: "remote forwarding disabled", + allowLocalForwarding: true, + allowRemoteForwarding: false, + description: "Remote forwarding should be denied when disabled", + }, + { + name: "all forwarding disabled", + allowLocalForwarding: false, + allowRemoteForwarding: false, + description: "Both forwarding types should be denied when disabled", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create server with specific configuration + serverConfig := &Config{ + HostKeyPEM: hostKey, + JWT: nil, + } + server := New(serverConfig) + server.SetAllowLocalPortForwarding(tt.allowLocalForwarding) + server.SetAllowRemotePortForwarding(tt.allowRemoteForwarding) + + // We need to access the internal configuration to simulate the callback tests + // Since the callbacks are created inside the Start method, we'll test the logic directly + + // Test the configuration values are set correctly + server.mu.RLock() + allowLocal := server.allowLocalPortForwarding + allowRemote := server.allowRemotePortForwarding + server.mu.RUnlock() + + assert.Equal(t, tt.allowLocalForwarding, allowLocal, "Local forwarding configuration should be set correctly") + assert.Equal(t, tt.allowRemoteForwarding, allowRemote, "Remote forwarding configuration should be set correctly") + + // Simulate the callback logic + localResult := allowLocal // This would be the callback return value + remoteResult := allowRemote // This would be the callback return value + + assert.Equal(t, tt.allowLocalForwarding, localResult, + "Local port forwarding callback should return correct value") + assert.Equal(t, tt.allowRemoteForwarding, remoteResult, + "Remote port forwarding callback should return correct value") + }) + } +} + +func TestServer_PrivilegedPortAccess(t *testing.T) { + hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519) + require.NoError(t, err) + + serverConfig := &Config{ + HostKeyPEM: hostKey, + } + server := New(serverConfig) + server.SetAllowRemotePortForwarding(true) + + tests := []struct { + name string + forwardType string + port uint32 + username string + expectError bool + errorMsg string + skipOnWindows bool + }{ + { + name: "non-root user remote forward privileged port", + forwardType: "remote", + port: 80, + username: "testuser", + expectError: true, + errorMsg: "cannot bind to privileged port", + skipOnWindows: true, + }, + { + name: "non-root user tcpip-forward privileged port", + forwardType: "tcpip-forward", + port: 443, + username: "testuser", + expectError: true, + errorMsg: "cannot bind to privileged port", + skipOnWindows: true, + }, + { + name: "non-root user remote forward unprivileged port", + forwardType: "remote", + port: 8080, + username: "testuser", + expectError: false, + }, + { + name: "non-root user remote forward port 0", + forwardType: "remote", + port: 0, + username: "testuser", + expectError: false, + }, + { + name: "root user remote forward privileged port", + forwardType: "remote", + port: 22, + username: "root", + expectError: false, + }, + { + name: "local forward privileged port allowed for non-root", + forwardType: "local", + port: 80, + username: "testuser", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.skipOnWindows && runtime.GOOS == "windows" { + t.Skip("Windows does not have privileged port restrictions") + } + + result := PrivilegeCheckResult{ + Allowed: true, + User: &user.User{Username: tt.username}, + } + + err := server.checkPrivilegedPortAccess(tt.forwardType, tt.port, result) + + if tt.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestServer_PortConflictHandling(t *testing.T) { + // Test that multiple sessions requesting the same local port are handled naturally by the OS + // Get current user for SSH connection + currentUser, err := user.Current() + require.NoError(t, err, "Should be able to get current user") + + // Generate host key for server + hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519) + require.NoError(t, err) + + // Create server + serverConfig := &Config{ + HostKeyPEM: hostKey, + JWT: nil, + } + server := New(serverConfig) + server.SetAllowRootLogin(true) + + serverAddr := StartTestServer(t, server) + defer func() { + err := server.Stop() + require.NoError(t, err) + }() + + // Get a free port for testing + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + testPort := ln.Addr().(*net.TCPAddr).Port + err = ln.Close() + require.NoError(t, err) + + // Connect first client + ctx1, cancel1 := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel1() + + client1, err := sshclient.Dial(ctx1, serverAddr, currentUser.Username, sshclient.DialOptions{ + InsecureSkipVerify: true, + }) + require.NoError(t, err) + defer func() { + err := client1.Close() + assert.NoError(t, err) + }() + + // Connect second client + ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel2() + + client2, err := sshclient.Dial(ctx2, serverAddr, currentUser.Username, sshclient.DialOptions{ + InsecureSkipVerify: true, + }) + require.NoError(t, err) + defer func() { + err := client2.Close() + assert.NoError(t, err) + }() + + // First client binds to the test port + localAddr1 := fmt.Sprintf("127.0.0.1:%d", testPort) + remoteAddr := "127.0.0.1:80" + + // Start first client's port forwarding + done1 := make(chan error, 1) + go func() { + // This should succeed and hold the port + err := client1.LocalPortForward(ctx1, localAddr1, remoteAddr) + done1 <- err + }() + + // Give first client time to bind + time.Sleep(200 * time.Millisecond) + + // Second client tries to bind to same port + localAddr2 := fmt.Sprintf("127.0.0.1:%d", testPort) + + shortCtx, shortCancel := context.WithTimeout(context.Background(), 1*time.Second) + defer shortCancel() + + err = client2.LocalPortForward(shortCtx, localAddr2, remoteAddr) + // Second client should fail due to "address already in use" + assert.Error(t, err, "Second client should fail to bind to same port") + if err != nil { + // The error should indicate the address is already in use + errMsg := strings.ToLower(err.Error()) + if runtime.GOOS == "windows" { + assert.Contains(t, errMsg, "only one usage of each socket address", + "Error should indicate port conflict") + } else { + assert.Contains(t, errMsg, "address already in use", + "Error should indicate port conflict") + } + } + + // Cancel first client's context and wait for it to finish + cancel1() + select { + case err1 := <-done1: + // Should get context cancelled or deadline exceeded + assert.Error(t, err1, "First client should exit when context cancelled") + case <-time.After(2 * time.Second): + t.Error("First client did not exit within timeout") + } +} + +func TestServer_IsPrivilegedUser(t *testing.T) { + + tests := []struct { + username string + expected bool + description string + }{ + { + username: "root", + expected: true, + description: "root should be considered privileged", + }, + { + username: "regular", + expected: false, + description: "regular user should not be privileged", + }, + { + username: "", + 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", + }, + }...) + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + result := isPrivilegedUsername(tt.username) + assert.Equal(t, tt.expected, result, tt.description) + }) + } +} + +func TestServer_NonPtyShellSession(t *testing.T) { + // Test that non-PTY shell sessions (ssh -T) work regardless of port forwarding settings. + currentUser, err := user.Current() + require.NoError(t, err, "Should be able to get current user") + + hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519) + require.NoError(t, err) + + tests := []struct { + name string + allowLocalForwarding bool + allowRemoteForwarding bool + }{ + { + name: "shell_with_local_forwarding_enabled", + allowLocalForwarding: true, + allowRemoteForwarding: false, + }, + { + name: "shell_with_remote_forwarding_enabled", + allowLocalForwarding: false, + allowRemoteForwarding: true, + }, + { + name: "shell_with_both_forwarding_enabled", + allowLocalForwarding: true, + allowRemoteForwarding: true, + }, + { + name: "shell_with_forwarding_disabled", + allowLocalForwarding: false, + allowRemoteForwarding: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + serverConfig := &Config{ + HostKeyPEM: hostKey, + JWT: nil, + } + server := New(serverConfig) + server.SetAllowRootLogin(true) + server.SetAllowLocalPortForwarding(tt.allowLocalForwarding) + server.SetAllowRemotePortForwarding(tt.allowRemoteForwarding) + + serverAddr := StartTestServer(t, server) + defer func() { + _ = server.Stop() + }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + client, err := sshclient.Dial(ctx, serverAddr, currentUser.Username, sshclient.DialOptions{ + InsecureSkipVerify: true, + }) + require.NoError(t, err) + defer func() { + _ = client.Close() + }() + + // Execute without PTY and no command - simulates ssh -T (shell without PTY) + // Should always succeed regardless of port forwarding settings + _, err = client.ExecuteCommand(ctx, "") + assert.NoError(t, err, "Non-PTY shell session should be allowed") + }) + } +} diff --git a/client/ssh/server/server_test.go b/client/ssh/server/server_test.go new file mode 100644 index 000000000..89fab717f --- /dev/null +++ b/client/ssh/server/server_test.go @@ -0,0 +1,443 @@ +package server + +import ( + "context" + "fmt" + "net" + "net/netip" + "os/user" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + cryptossh "golang.org/x/crypto/ssh" + + nbssh "github.com/netbirdio/netbird/client/ssh" +) + +func TestServer_StartStop(t *testing.T) { + key, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + + serverConfig := &Config{ + HostKeyPEM: key, + JWT: nil, + } + server := New(serverConfig) + + err = server.Stop() + assert.NoError(t, err) +} + +func TestSSHServerIntegration(t *testing.T) { + // Generate host key for server + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + + // Generate client key pair + clientPrivKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + + // Create server with random port + serverConfig := &Config{ + HostKeyPEM: hostKey, + JWT: nil, + } + server := New(serverConfig) + + // Start server in background + serverAddr := "127.0.0.1:0" + started := make(chan string, 1) + errChan := make(chan error, 1) + + go func() { + // Get a free port + ln, err := net.Listen("tcp", serverAddr) + if err != nil { + errChan <- err + return + } + actualAddr := ln.Addr().String() + if err := ln.Close(); err != nil { + errChan <- fmt.Errorf("close temp listener: %w", err) + return + } + + addrPort, _ := netip.ParseAddrPort(actualAddr) + if err := server.Start(context.Background(), addrPort); err != nil { + errChan <- err + return + } + started <- actualAddr + }() + + select { + case actualAddr := <-started: + serverAddr = actualAddr + case err := <-errChan: + t.Fatalf("Server failed to start: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("Server start timeout") + } + + defer func() { + err := server.Stop() + require.NoError(t, err) + }() + + // Parse client private key + signer, err := cryptossh.ParsePrivateKey(clientPrivKey) + require.NoError(t, err) + + // Parse server host key for verification + hostPrivParsed, err := cryptossh.ParsePrivateKey(hostKey) + require.NoError(t, err) + hostPubKey := hostPrivParsed.PublicKey() + + // Get current user for SSH connection + currentUser, err := user.Current() + require.NoError(t, err, "Should be able to get current user for test") + + // Create SSH client config + config := &cryptossh.ClientConfig{ + User: currentUser.Username, + Auth: []cryptossh.AuthMethod{ + cryptossh.PublicKeys(signer), + }, + HostKeyCallback: cryptossh.FixedHostKey(hostPubKey), + Timeout: 3 * time.Second, + } + + // Connect to SSH server + client, err := cryptossh.Dial("tcp", serverAddr, config) + require.NoError(t, err) + defer func() { + if err := client.Close(); err != nil { + t.Logf("close client: %v", err) + } + }() + + // Test creating a session + session, err := client.NewSession() + require.NoError(t, err) + defer func() { + if err := session.Close(); err != nil { + t.Logf("close session: %v", err) + } + }() + + // Note: Since we don't have a real shell environment in tests, + // we can't test actual command execution, but we can verify + // the connection and authentication work + t.Log("SSH connection and authentication successful") +} + +func TestSSHServerMultipleConnections(t *testing.T) { + // Generate host key for server + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + + // Generate client key pair + clientPrivKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + + // Create server + serverConfig := &Config{ + HostKeyPEM: hostKey, + JWT: nil, + } + server := New(serverConfig) + + // Start server + serverAddr := "127.0.0.1:0" + started := make(chan string, 1) + errChan := make(chan error, 1) + + go func() { + ln, err := net.Listen("tcp", serverAddr) + if err != nil { + errChan <- err + return + } + actualAddr := ln.Addr().String() + if err := ln.Close(); err != nil { + errChan <- fmt.Errorf("close temp listener: %w", err) + return + } + + addrPort, _ := netip.ParseAddrPort(actualAddr) + if err := server.Start(context.Background(), addrPort); err != nil { + errChan <- err + return + } + started <- actualAddr + }() + + select { + case actualAddr := <-started: + serverAddr = actualAddr + case err := <-errChan: + t.Fatalf("Server failed to start: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("Server start timeout") + } + + defer func() { + err := server.Stop() + require.NoError(t, err) + }() + + // Parse client private key + signer, err := cryptossh.ParsePrivateKey(clientPrivKey) + require.NoError(t, err) + + // Parse server host key + hostPrivParsed, err := cryptossh.ParsePrivateKey(hostKey) + require.NoError(t, err) + hostPubKey := hostPrivParsed.PublicKey() + + // Get current user for SSH connection + currentUser, err := user.Current() + require.NoError(t, err, "Should be able to get current user for test") + + config := &cryptossh.ClientConfig{ + User: currentUser.Username, + Auth: []cryptossh.AuthMethod{ + cryptossh.PublicKeys(signer), + }, + HostKeyCallback: cryptossh.FixedHostKey(hostPubKey), + Timeout: 3 * time.Second, + } + + // Test multiple concurrent connections + const numConnections = 5 + results := make(chan error, numConnections) + + for i := 0; i < numConnections; i++ { + go func(id int) { + client, err := cryptossh.Dial("tcp", serverAddr, config) + if err != nil { + results <- fmt.Errorf("connection %d failed: %w", id, err) + return + } + defer func() { + _ = client.Close() // Ignore error in test goroutine + }() + + session, err := client.NewSession() + if err != nil { + results <- fmt.Errorf("session %d failed: %w", id, err) + return + } + defer func() { + _ = session.Close() // Ignore error in test goroutine + }() + + results <- nil + }(i) + } + + // Wait for all connections to complete + for i := 0; i < numConnections; i++ { + select { + case err := <-results: + assert.NoError(t, err) + case <-time.After(10 * time.Second): + t.Fatalf("Connection %d timed out", i) + } + } +} + +func TestSSHServerNoAuthMode(t *testing.T) { + // Generate host key for server + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + + // Create server + serverConfig := &Config{ + HostKeyPEM: hostKey, + JWT: nil, + } + server := New(serverConfig) + + // Start server + serverAddr := "127.0.0.1:0" + started := make(chan string, 1) + errChan := make(chan error, 1) + + go func() { + ln, err := net.Listen("tcp", serverAddr) + if err != nil { + errChan <- err + return + } + actualAddr := ln.Addr().String() + if err := ln.Close(); err != nil { + errChan <- fmt.Errorf("close temp listener: %w", err) + return + } + + addrPort, _ := netip.ParseAddrPort(actualAddr) + if err := server.Start(context.Background(), addrPort); err != nil { + errChan <- err + return + } + started <- actualAddr + }() + + select { + case actualAddr := <-started: + serverAddr = actualAddr + case err := <-errChan: + t.Fatalf("Server failed to start: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("Server start timeout") + } + + defer func() { + err := server.Stop() + require.NoError(t, err) + }() + + // Generate a client private key for SSH protocol (server doesn't check it) + clientPrivKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + clientSigner, err := cryptossh.ParsePrivateKey(clientPrivKey) + require.NoError(t, err) + + // Parse server host key + hostPrivParsed, err := cryptossh.ParsePrivateKey(hostKey) + require.NoError(t, err) + hostPubKey := hostPrivParsed.PublicKey() + + // Get current user for SSH connection + currentUser, err := user.Current() + require.NoError(t, err, "Should be able to get current user for test") + + // Try to connect with client key + config := &cryptossh.ClientConfig{ + User: currentUser.Username, + Auth: []cryptossh.AuthMethod{ + cryptossh.PublicKeys(clientSigner), + }, + HostKeyCallback: cryptossh.FixedHostKey(hostPubKey), + Timeout: 3 * time.Second, + } + + // This should succeed in no-auth mode (server doesn't verify keys) + conn, err := cryptossh.Dial("tcp", serverAddr, config) + assert.NoError(t, err, "Connection should succeed in no-auth mode") + if conn != nil { + assert.NoError(t, conn.Close()) + } +} + +func TestSSHServerStartStopCycle(t *testing.T) { + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + + serverConfig := &Config{ + HostKeyPEM: hostKey, + JWT: nil, + } + server := New(serverConfig) + serverAddr := "127.0.0.1:0" + + // Test multiple start/stop cycles + for i := 0; i < 3; i++ { + t.Logf("Start/stop cycle %d", i+1) + + started := make(chan string, 1) + errChan := make(chan error, 1) + + go func() { + ln, err := net.Listen("tcp", serverAddr) + if err != nil { + errChan <- err + return + } + actualAddr := ln.Addr().String() + if err := ln.Close(); err != nil { + errChan <- fmt.Errorf("close temp listener: %w", err) + return + } + + addrPort, _ := netip.ParseAddrPort(actualAddr) + if err := server.Start(context.Background(), addrPort); err != nil { + errChan <- err + return + } + started <- actualAddr + }() + + select { + case <-started: + case err := <-errChan: + t.Fatalf("Cycle %d: Server failed to start: %v", i+1, err) + case <-time.After(5 * time.Second): + t.Fatalf("Cycle %d: Server start timeout", i+1) + } + + err = server.Stop() + require.NoError(t, err, "Cycle %d: Stop should succeed", i+1) + } +} + +func TestSSHServer_WindowsShellHandling(t *testing.T) { + if testing.Short() { + t.Skip("Skipping Windows shell test in short mode") + } + + server := &Server{} + + if runtime.GOOS == "windows" { + // Test Windows cmd.exe shell behavior + args := server.getShellCommandArgs("cmd.exe", "echo test") + assert.Equal(t, "cmd.exe", args[0]) + assert.Equal(t, "-Command", args[1]) + assert.Equal(t, "echo test", args[2]) + + // Test PowerShell behavior + args = server.getShellCommandArgs("powershell.exe", "echo test") + assert.Equal(t, "powershell.exe", args[0]) + assert.Equal(t, "-Command", args[1]) + assert.Equal(t, "echo test", args[2]) + } else { + args := server.getShellCommandArgs("/bin/sh", "echo test") + assert.Equal(t, "/bin/sh", args[0]) + assert.Equal(t, "-c", args[1]) + assert.Equal(t, "echo test", args[2]) + + args = server.getShellCommandArgs("/bin/sh", "") + assert.Equal(t, "/bin/sh", args[0]) + assert.Len(t, args, 1) + } +} + +func TestSSHServer_PortForwardingConfiguration(t *testing.T) { + hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519) + require.NoError(t, err) + + serverConfig1 := &Config{ + HostKeyPEM: hostKey, + JWT: nil, + } + server1 := New(serverConfig1) + + serverConfig2 := &Config{ + HostKeyPEM: hostKey, + JWT: nil, + } + server2 := New(serverConfig2) + + assert.False(t, server1.allowLocalPortForwarding, "Local port forwarding should be disabled by default for security") + assert.False(t, server1.allowRemotePortForwarding, "Remote port forwarding should be disabled by default for security") + + server2.SetAllowLocalPortForwarding(true) + server2.SetAllowRemotePortForwarding(true) + + assert.True(t, server2.allowLocalPortForwarding, "Local port forwarding should be enabled when explicitly set") + assert.True(t, server2.allowRemotePortForwarding, "Remote port forwarding should be enabled when explicitly set") +} diff --git a/client/ssh/server/session_handlers.go b/client/ssh/server/session_handlers.go new file mode 100644 index 000000000..0e531bb96 --- /dev/null +++ b/client/ssh/server/session_handlers.go @@ -0,0 +1,139 @@ +package server + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "time" + + "github.com/gliderlabs/ssh" + log "github.com/sirupsen/logrus" +) + +// associateJWTUsername extracts pending JWT username for the session and associates it with the session state. +// Returns the JWT username (empty if none) for logging purposes. +func (s *Server) associateJWTUsername(sess ssh.Session, sessionKey sessionKey) string { + key := newAuthKey(sess.User(), sess.RemoteAddr()) + + s.mu.Lock() + defer s.mu.Unlock() + + jwtUsername := s.pendingAuthJWT[key] + if jwtUsername == "" { + return "" + } + + if state, exists := s.sessions[sessionKey]; exists { + state.jwtUsername = jwtUsername + } + delete(s.pendingAuthJWT, key) + return jwtUsername +} + +// sessionHandler handles SSH sessions +func (s *Server) sessionHandler(session ssh.Session) { + sessionKey := s.registerSession(session, "") + jwtUsername := s.associateJWTUsername(session, sessionKey) + + logger := log.WithField("session", sessionKey) + if jwtUsername != "" { + logger = logger.WithField("jwt_user", jwtUsername) + } + logger.Info("SSH session started") + sessionStart := time.Now() + + defer s.unregisterSession(sessionKey) + defer func() { + duration := time.Since(sessionStart).Round(time.Millisecond) + if err := session.Close(); err != nil && !errors.Is(err, io.EOF) { + logger.Warnf("close session after %v: %v", duration, err) + } + logger.Infof("SSH session closed after %v", duration) + }() + + privilegeResult, err := s.userPrivilegeCheck(session.User()) + if err != nil { + s.handlePrivError(logger, session, err) + return + } + + ptyReq, winCh, isPty := session.Pty() + hasCommand := session.RawCommand() != "" + + if isPty && !hasCommand { + // ssh - PTY interactive session (login) + s.handlePtyLogin(logger, session, privilegeResult, ptyReq, winCh) + } else { + // ssh , ssh -t , ssh -T - command or shell execution + s.handleExecution(logger, session, privilegeResult, ptyReq, winCh) + } +} + +func (s *Server) registerSession(session ssh.Session, sessionType string) sessionKey { + sessionID := session.Context().Value(ssh.ContextKeySessionID) + if sessionID == nil { + sessionID = fmt.Sprintf("%p", session) + } + + hasher := sha256.New() + hasher.Write([]byte(fmt.Sprintf("%v", sessionID))) + hash := hasher.Sum(nil) + shortID := hex.EncodeToString(hash[:4]) + + remoteAddr := session.RemoteAddr().String() + username := session.User() + sessionKey := sessionKey(fmt.Sprintf("%s@%s-%s", username, remoteAddr, shortID)) + + s.mu.Lock() + s.sessions[sessionKey] = &sessionState{ + session: session, + sessionType: sessionType, + } + s.mu.Unlock() + + return sessionKey +} + +func (s *Server) unregisterSession(sessionKey sessionKey) { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.sessions, sessionKey) +} + +func (s *Server) handlePrivError(logger *log.Entry, session ssh.Session, err error) { + logger.Warnf("user privilege check failed: %v", err) + + errorMsg := s.buildUserLookupErrorMessage(err) + + if _, writeErr := fmt.Fprint(session, errorMsg); writeErr != nil { + logger.Debugf(errWriteSession, writeErr) + } + if exitErr := session.Exit(1); exitErr != nil { + logSessionExitError(logger, exitErr) + } +} + +// buildUserLookupErrorMessage creates appropriate user-facing error messages based on error type +func (s *Server) buildUserLookupErrorMessage(err error) string { + var privilegedErr *PrivilegedUserError + + switch { + case errors.As(err, &privilegedErr): + if privilegedErr.Username == "root" { + return "root login is disabled on this SSH server\n" + } + return "privileged user access is disabled on this SSH server\n" + + case errors.Is(err, ErrPrivilegeRequired): + return "Windows user switching failed - NetBird must run with elevated privileges for user switching\n" + + case errors.Is(err, ErrPrivilegedUserSwitch): + return "Cannot switch to privileged user - current user lacks required privileges\n" + + default: + return "User authentication failed\n" + } +} diff --git a/client/ssh/server/session_handlers_js.go b/client/ssh/server/session_handlers_js.go new file mode 100644 index 000000000..4a6cf3d92 --- /dev/null +++ b/client/ssh/server/session_handlers_js.go @@ -0,0 +1,22 @@ +//go:build js + +package server + +import ( + "fmt" + + "github.com/gliderlabs/ssh" + log "github.com/sirupsen/logrus" +) + +// handlePtyLogin is not supported on JS/WASM +func (s *Server) handlePtyLogin(logger *log.Entry, session ssh.Session, _ PrivilegeCheckResult, _ ssh.Pty, _ <-chan ssh.Window) bool { + errorMsg := "PTY sessions are not supported on WASM/JS platform\n" + if _, err := fmt.Fprint(session.Stderr(), errorMsg); err != nil { + logger.Debugf(errWriteSession, err) + } + if err := session.Exit(1); err != nil { + logSessionExitError(logger, err) + } + return false +} diff --git a/client/ssh/server/sftp.go b/client/ssh/server/sftp.go new file mode 100644 index 000000000..199444abb --- /dev/null +++ b/client/ssh/server/sftp.go @@ -0,0 +1,89 @@ +package server + +import ( + "fmt" + "io" + + "github.com/gliderlabs/ssh" + "github.com/pkg/sftp" + log "github.com/sirupsen/logrus" +) + +// SetAllowSFTP enables or disables SFTP support +func (s *Server) SetAllowSFTP(allow bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.allowSFTP = allow +} + +// sftpSubsystemHandler handles SFTP subsystem requests +func (s *Server) sftpSubsystemHandler(sess ssh.Session) { + sessionKey := s.registerSession(sess, cmdSFTP) + defer s.unregisterSession(sessionKey) + + jwtUsername := s.associateJWTUsername(sess, sessionKey) + + logger := log.WithField("session", sessionKey) + if jwtUsername != "" { + logger = logger.WithField("jwt_user", jwtUsername) + } + logger.Info("SFTP session started") + defer logger.Info("SFTP session closed") + + s.mu.RLock() + allowSFTP := s.allowSFTP + s.mu.RUnlock() + + if !allowSFTP { + logger.Debug("SFTP subsystem request denied: SFTP disabled") + if err := sess.Exit(1); err != nil { + logger.Debugf("SFTP session exit: %v", err) + } + return + } + + result := s.CheckPrivileges(PrivilegeCheckRequest{ + RequestedUsername: sess.User(), + FeatureSupportsUserSwitch: true, + FeatureName: FeatureSFTP, + }) + + if !result.Allowed { + logger.Warnf("SFTP access denied: %v", result.Error) + if err := sess.Exit(1); err != nil { + logger.Debugf("exit SFTP session: %v", err) + } + return + } + + if !result.RequiresUserSwitching { + if err := s.executeSftpDirect(sess); err != nil { + logger.Errorf("SFTP direct execution: %v", err) + } + return + } + + if err := s.executeSftpWithPrivilegeDrop(sess, result.User); err != nil { + logger.Errorf("SFTP privilege drop execution: %v", err) + } +} + +// executeSftpDirect executes SFTP directly without privilege dropping +func (s *Server) executeSftpDirect(sess ssh.Session) error { + sftpServer, err := sftp.NewServer(sess) + if err != nil { + return fmt.Errorf("SFTP server creation: %w", err) + } + + defer func() { + if err := sftpServer.Close(); err != nil { + log.Debugf("failed to close sftp server: %v", err) + } + }() + + if err := sftpServer.Serve(); err != nil && err != io.EOF { + return fmt.Errorf("serve: %w", err) + } + + return nil +} diff --git a/client/ssh/server/sftp_js.go b/client/ssh/server/sftp_js.go new file mode 100644 index 000000000..3b27aeff4 --- /dev/null +++ b/client/ssh/server/sftp_js.go @@ -0,0 +1,12 @@ +//go:build js + +package server + +import ( + "os/user" +) + +// parseUserCredentials is not supported on JS/WASM +func (s *Server) parseUserCredentials(_ *user.User) (uint32, uint32, []uint32, error) { + return 0, 0, nil, errNotSupported +} diff --git a/client/ssh/server/sftp_test.go b/client/ssh/server/sftp_test.go new file mode 100644 index 000000000..32a3643e4 --- /dev/null +++ b/client/ssh/server/sftp_test.go @@ -0,0 +1,228 @@ +package server + +import ( + "context" + "fmt" + "net" + "net/netip" + "os" + "os/user" + "testing" + "time" + + "github.com/pkg/sftp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + cryptossh "golang.org/x/crypto/ssh" + + "github.com/netbirdio/netbird/client/ssh" +) + +func TestSSHServer_SFTPSubsystem(t *testing.T) { + // Skip SFTP test when running as root due to protocol issues in some environments + if os.Geteuid() == 0 { + t.Skip("Skipping SFTP test when running as root - may have protocol compatibility issues") + } + + // Get current user for SSH connection + currentUser, err := user.Current() + require.NoError(t, err, "Should be able to get current user") + + // Generate host key for server + hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519) + require.NoError(t, err) + + // Generate client key pair + clientPrivKey, err := ssh.GeneratePrivateKey(ssh.ED25519) + require.NoError(t, err) + + // Create server with SFTP enabled + serverConfig := &Config{ + HostKeyPEM: hostKey, + JWT: nil, + } + server := New(serverConfig) + server.SetAllowSFTP(true) + server.SetAllowRootLogin(true) + + // Start server + serverAddr := "127.0.0.1:0" + started := make(chan string, 1) + errChan := make(chan error, 1) + + go func() { + ln, err := net.Listen("tcp", serverAddr) + if err != nil { + errChan <- err + return + } + actualAddr := ln.Addr().String() + if err := ln.Close(); err != nil { + errChan <- fmt.Errorf("close temp listener: %w", err) + return + } + + addrPort, _ := netip.ParseAddrPort(actualAddr) + if err := server.Start(context.Background(), addrPort); err != nil { + errChan <- err + return + } + started <- actualAddr + }() + + select { + case actualAddr := <-started: + serverAddr = actualAddr + case err := <-errChan: + t.Fatalf("Server failed to start: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("Server start timeout") + } + + defer func() { + err := server.Stop() + require.NoError(t, err) + }() + + // Parse client private key + signer, err := cryptossh.ParsePrivateKey(clientPrivKey) + require.NoError(t, err) + + // Parse server host key + hostPrivParsed, err := cryptossh.ParsePrivateKey(hostKey) + require.NoError(t, err) + hostPubKey := hostPrivParsed.PublicKey() + + // (currentUser already obtained at function start) + + // Create SSH client connection + clientConfig := &cryptossh.ClientConfig{ + User: currentUser.Username, + Auth: []cryptossh.AuthMethod{ + cryptossh.PublicKeys(signer), + }, + HostKeyCallback: cryptossh.FixedHostKey(hostPubKey), + Timeout: 5 * time.Second, + } + + conn, err := cryptossh.Dial("tcp", serverAddr, clientConfig) + require.NoError(t, err, "SSH connection should succeed") + defer func() { + if err := conn.Close(); err != nil { + t.Logf("connection close error: %v", err) + } + }() + + // Create SFTP client + sftpClient, err := sftp.NewClient(conn) + require.NoError(t, err, "SFTP client creation should succeed") + defer func() { + if err := sftpClient.Close(); err != nil { + t.Logf("SFTP client close error: %v", err) + } + }() + + // Test basic SFTP operations + workingDir, err := sftpClient.Getwd() + assert.NoError(t, err, "Should be able to get working directory") + assert.NotEmpty(t, workingDir, "Working directory should not be empty") + + // Test directory listing + files, err := sftpClient.ReadDir(".") + assert.NoError(t, err, "Should be able to list current directory") + assert.NotNil(t, files, "File list should not be nil") +} + +func TestSSHServer_SFTPDisabled(t *testing.T) { + // Get current user for SSH connection + currentUser, err := user.Current() + require.NoError(t, err, "Should be able to get current user") + + // Generate host key for server + hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519) + require.NoError(t, err) + + // Generate client key pair + clientPrivKey, err := ssh.GeneratePrivateKey(ssh.ED25519) + require.NoError(t, err) + + // Create server with SFTP disabled + serverConfig := &Config{ + HostKeyPEM: hostKey, + JWT: nil, + } + server := New(serverConfig) + server.SetAllowSFTP(false) + + // Start server + serverAddr := "127.0.0.1:0" + started := make(chan string, 1) + errChan := make(chan error, 1) + + go func() { + ln, err := net.Listen("tcp", serverAddr) + if err != nil { + errChan <- err + return + } + actualAddr := ln.Addr().String() + if err := ln.Close(); err != nil { + errChan <- fmt.Errorf("close temp listener: %w", err) + return + } + + addrPort, _ := netip.ParseAddrPort(actualAddr) + if err := server.Start(context.Background(), addrPort); err != nil { + errChan <- err + return + } + started <- actualAddr + }() + + select { + case actualAddr := <-started: + serverAddr = actualAddr + case err := <-errChan: + t.Fatalf("Server failed to start: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("Server start timeout") + } + + defer func() { + err := server.Stop() + require.NoError(t, err) + }() + + // Parse client private key + signer, err := cryptossh.ParsePrivateKey(clientPrivKey) + require.NoError(t, err) + + // Parse server host key + hostPrivParsed, err := cryptossh.ParsePrivateKey(hostKey) + require.NoError(t, err) + hostPubKey := hostPrivParsed.PublicKey() + + // (currentUser already obtained at function start) + + // Create SSH client connection + clientConfig := &cryptossh.ClientConfig{ + User: currentUser.Username, + Auth: []cryptossh.AuthMethod{ + cryptossh.PublicKeys(signer), + }, + HostKeyCallback: cryptossh.FixedHostKey(hostPubKey), + Timeout: 5 * time.Second, + } + + conn, err := cryptossh.Dial("tcp", serverAddr, clientConfig) + require.NoError(t, err, "SSH connection should succeed") + defer func() { + if err := conn.Close(); err != nil { + t.Logf("connection close error: %v", err) + } + }() + + // Try to create SFTP client - should fail when SFTP is disabled + _, err = sftp.NewClient(conn) + assert.Error(t, err, "SFTP client creation should fail when SFTP is disabled") +} diff --git a/client/ssh/server/sftp_unix.go b/client/ssh/server/sftp_unix.go new file mode 100644 index 000000000..44202bead --- /dev/null +++ b/client/ssh/server/sftp_unix.go @@ -0,0 +1,71 @@ +//go:build !windows + +package server + +import ( + "errors" + "fmt" + "os" + "os/exec" + "os/user" + "strconv" + + "github.com/gliderlabs/ssh" + log "github.com/sirupsen/logrus" +) + +// executeSftpWithPrivilegeDrop executes SFTP using Unix privilege dropping +func (s *Server) executeSftpWithPrivilegeDrop(sess ssh.Session, targetUser *user.User) error { + uid, gid, groups, err := s.parseUserCredentials(targetUser) + if err != nil { + return fmt.Errorf("parse user credentials: %w", err) + } + + sftpCmd, err := s.createSftpExecutorCommand(sess, uid, gid, groups, targetUser.HomeDir) + if err != nil { + return fmt.Errorf("create executor: %w", err) + } + + sftpCmd.Stdin = sess + sftpCmd.Stdout = sess + sftpCmd.Stderr = sess.Stderr() + + log.Tracef("starting SFTP with privilege dropping to user %s (UID=%d, GID=%d)", targetUser.Username, uid, gid) + + if err := sftpCmd.Start(); err != nil { + return fmt.Errorf("starting SFTP executor: %w", err) + } + + if err := sftpCmd.Wait(); err != nil { + var exitError *exec.ExitError + if errors.As(err, &exitError) { + log.Tracef("SFTP process exited with code %d", exitError.ExitCode()) + return nil + } + return fmt.Errorf("exec: %w", err) + } + + return nil +} + +// createSftpExecutorCommand creates a command that spawns netbird ssh sftp for privilege dropping +func (s *Server) createSftpExecutorCommand(sess ssh.Session, uid, gid uint32, groups []uint32, workingDir string) (*exec.Cmd, error) { + netbirdPath, err := os.Executable() + if err != nil { + return nil, err + } + + args := []string{ + "ssh", "sftp", + "--uid", strconv.FormatUint(uint64(uid), 10), + "--gid", strconv.FormatUint(uint64(gid), 10), + "--working-dir", workingDir, + } + + for _, group := range groups { + args = append(args, "--groups", strconv.FormatUint(uint64(group), 10)) + } + + log.Tracef("creating SFTP executor command: %s %v", netbirdPath, args) + return exec.CommandContext(sess.Context(), netbirdPath, args...), nil +} diff --git a/client/ssh/server/sftp_windows.go b/client/ssh/server/sftp_windows.go new file mode 100644 index 000000000..dc532b9e7 --- /dev/null +++ b/client/ssh/server/sftp_windows.go @@ -0,0 +1,91 @@ +//go:build windows + +package server + +import ( + "errors" + "fmt" + "os" + "os/exec" + "os/user" + + "github.com/gliderlabs/ssh" + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +// 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) + + netbirdPath, err := os.Executable() + if err != nil { + return nil, 0, fmt.Errorf("get netbird executable path: %w", err) + } + + args := []string{ + "ssh", "sftp", + "--working-dir", targetUser.HomeDir, + "--windows-username", username, + "--windows-domain", domain, + } + + pd := NewPrivilegeDropper() + token, err := pd.createToken(username, domain) + if err != nil { + return nil, 0, fmt.Errorf("create token: %w", err) + } + + defer func() { + if err := windows.CloseHandle(token); err != nil { + log.Warnf("failed to close impersonation token: %v", err) + } + }() + + cmd, primaryToken, err := pd.createProcessWithToken(sess.Context(), windows.Token(token), netbirdPath, append([]string{netbirdPath}, args...), targetUser.HomeDir) + if err != nil { + return nil, 0, fmt.Errorf("create SFTP command: %w", err) + } + + log.Debugf("Created Windows SFTP command with user switching for %s", targetUser.Username) + return cmd, primaryToken, nil +} + +// executeSftpCommand executes a Windows SFTP command with proper I/O handling +func (s *Server) executeSftpCommand(sess ssh.Session, sftpCmd *exec.Cmd, token windows.Token) error { + defer func() { + if err := windows.CloseHandle(windows.Handle(token)); err != nil { + log.Debugf("close primary token: %v", err) + } + }() + + sftpCmd.Stdin = sess + sftpCmd.Stdout = sess + sftpCmd.Stderr = sess.Stderr() + + if err := sftpCmd.Start(); err != nil { + return fmt.Errorf("starting sftp executor: %w", err) + } + + if err := sftpCmd.Wait(); err != nil { + var exitError *exec.ExitError + if errors.As(err, &exitError) { + log.Tracef("sftp process exited with code %d", exitError.ExitCode()) + return nil + } + + return fmt.Errorf("exec sftp: %w", err) + } + + return nil +} + +// executeSftpWithPrivilegeDrop executes SFTP using Windows privilege dropping +func (s *Server) executeSftpWithPrivilegeDrop(sess ssh.Session, targetUser *user.User) error { + sftpCmd, token, err := s.createSftpCommand(targetUser, sess) + if err != nil { + return fmt.Errorf("create sftp: %w", err) + } + return s.executeSftpCommand(sess, sftpCmd, token) +} diff --git a/client/ssh/server/shell.go b/client/ssh/server/shell.go new file mode 100644 index 000000000..1e8ff5e31 --- /dev/null +++ b/client/ssh/server/shell.go @@ -0,0 +1,184 @@ +package server + +import ( + "bufio" + "fmt" + "net" + "os" + "os/exec" + "os/user" + "runtime" + "strconv" + "strings" + + "github.com/gliderlabs/ssh" + log "github.com/sirupsen/logrus" +) + +const ( + defaultUnixShell = "/bin/sh" + + pwshExe = "pwsh.exe" // #nosec G101 - This is not a credential, just executable name + powershellExe = "powershell.exe" +) + +// getUserShell returns the appropriate shell for the given user ID +// Handles all platform-specific logic and fallbacks consistently +func getUserShell(userID string) string { + switch runtime.GOOS { + case "windows": + return getWindowsUserShell() + default: + return getUnixUserShell(userID) + } +} + +// getWindowsUserShell returns the best shell for Windows users. +// We intentionally do not support cmd.exe or COMSPEC fallbacks to avoid command injection +// vulnerabilities that arise from cmd.exe's complex command line parsing and special characters. +// PowerShell provides safer argument handling and is available on all modern Windows systems. +// Order: pwsh.exe -> powershell.exe +func getWindowsUserShell() string { + if path, err := exec.LookPath(pwshExe); err == nil { + return path + } + if path, err := exec.LookPath(powershellExe); err == nil { + return path + } + + return `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe` +} + +// getUnixUserShell returns the shell for Unix-like systems. +// Tries /etc/passwd first (fast, no subprocess), falls back to getent for NSS users. +func getUnixUserShell(userID string) string { + if shell := getShellFromPasswd(userID); shell != "" { + return shell + } + + if shell := getShellFromGetent(userID); shell != "" { + return shell + } + + if shell := os.Getenv("SHELL"); shell != "" { + return shell + } + + return defaultUnixShell +} + +// getShellFromPasswd reads the shell from /etc/passwd for the given user ID +func getShellFromPasswd(userID string) string { + file, err := os.Open("/etc/passwd") + if err != nil { + return "" + } + defer func() { + if err := file.Close(); err != nil { + log.Warnf("close /etc/passwd file: %v", err) + } + }() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + fields := strings.Split(line, ":") + if len(fields) < 7 { + continue + } + + // field 2 is UID + if fields[2] == userID { + shell := strings.TrimSpace(fields[6]) + return shell + } + } + + if err := scanner.Err(); err != nil { + log.Warnf("error reading /etc/passwd: %v", err) + } + + return "" +} + +// prepareUserEnv prepares environment variables for user execution +func prepareUserEnv(user *user.User, shell string) []string { + pathValue := "/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games" + if runtime.GOOS == "windows" { + pathValue = `C:\Windows\System32;C:\Windows;C:\Windows\System32\Wbem;C:\Windows\System32\WindowsPowerShell\v1.0` + } + + return []string{ + fmt.Sprint("SHELL=" + shell), + fmt.Sprint("USER=" + user.Username), + fmt.Sprint("LOGNAME=" + user.Username), + fmt.Sprint("HOME=" + user.HomeDir), + "PATH=" + pathValue, + } +} + +// acceptEnv checks if environment variable from SSH client should be accepted +// This is a whitelist of variables that SSH clients can send to the server +func acceptEnv(envVar string) bool { + varName := envVar + if idx := strings.Index(envVar, "="); idx != -1 { + varName = envVar[:idx] + } + + exactMatches := []string{ + "LANG", + "LANGUAGE", + "TERM", + "COLORTERM", + "EDITOR", + "VISUAL", + "PAGER", + "LESS", + "LESSCHARSET", + "TZ", + } + + prefixMatches := []string{ + "LC_", + } + + for _, exact := range exactMatches { + if varName == exact { + return true + } + } + + for _, prefix := range prefixMatches { + if strings.HasPrefix(varName, prefix) { + return true + } + } + + return false +} + +// prepareSSHEnv prepares SSH protocol-specific environment variables +// These variables provide information about the SSH connection itself +func prepareSSHEnv(session ssh.Session) []string { + remoteAddr := session.RemoteAddr() + localAddr := session.LocalAddr() + + remoteHost, remotePort, err := net.SplitHostPort(remoteAddr.String()) + if err != nil { + remoteHost = remoteAddr.String() + remotePort = "0" + } + + localHost, localPort, err := net.SplitHostPort(localAddr.String()) + if err != nil { + localHost = localAddr.String() + localPort = strconv.Itoa(InternalSSHPort) + } + + return []string{ + // SSH_CLIENT format: "client_ip client_port server_port" + fmt.Sprintf("SSH_CLIENT=%s %s %s", remoteHost, remotePort, localPort), + // SSH_CONNECTION format: "client_ip client_port server_ip server_port" + fmt.Sprintf("SSH_CONNECTION=%s %s %s %s", remoteHost, remotePort, localHost, localPort), + } +} diff --git a/client/ssh/server/test.go b/client/ssh/server/test.go new file mode 100644 index 000000000..e2be0551c --- /dev/null +++ b/client/ssh/server/test.go @@ -0,0 +1,49 @@ +// This file is intentionally named test.go (not test_test.go) so the exported +// StartTestServer helper is visible to the ssh/proxy and ssh/client external +// test packages, not just this package's own tests. The //go:build !js tag +// keeps its "testing" import — and the whole testing/flag/regexp transitive +// chain it drags in — out of the wasm client, which links ssh/server through +// the engine but never runs Go tests under GOOS=js. +//go:build !js + +package server + +import ( + "context" + "fmt" + "net/netip" + "testing" + "time" +) + +// StartTestServer starts the SSH server and returns the address it's listening on. +func StartTestServer(t *testing.T, server *Server) string { + started := make(chan string, 1) + errChan := make(chan error, 1) + + go func() { + addrPort := netip.MustParseAddrPort("127.0.0.1:0") + if err := server.Start(context.Background(), addrPort); err != nil { + errChan <- err + return + } + + actualAddr := server.Addr() + if actualAddr == nil { + errChan <- fmt.Errorf("server started but no listener address available") + return + } + + started <- actualAddr.String() + }() + + select { + case actualAddr := <-started: + return actualAddr + case err := <-errChan: + t.Fatalf("Server failed to start: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("Server start timeout") + } + return "" +} diff --git a/client/ssh/server/user_utils.go b/client/ssh/server/user_utils.go new file mode 100644 index 000000000..bc2aa2d7d --- /dev/null +++ b/client/ssh/server/user_utils.go @@ -0,0 +1,411 @@ +package server + +import ( + "errors" + "fmt" + "os" + "os/user" + "runtime" + "strings" + + log "github.com/sirupsen/logrus" +) + +var ( + ErrPrivilegeRequired = errors.New("SeAssignPrimaryTokenPrivilege required for user switching - NetBird must run with elevated privileges") + 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 + getCurrentOS = func() string { return runtime.GOOS } + getIsProcessPrivileged = isCurrentProcessPrivileged + + getEuid = os.Geteuid +) + +const ( + // FeatureSSHLogin represents SSH login operations for privilege checking + FeatureSSHLogin = "SSH login" + // FeatureSFTP represents SFTP operations for privilege checking + FeatureSFTP = "SFTP" +) + +// PrivilegeCheckRequest represents a privilege check request +type PrivilegeCheckRequest struct { + // Username being requested (empty = current user) + RequestedUsername string + FeatureSupportsUserSwitch bool // Does this feature/operation support user switching? + FeatureName string +} + +// PrivilegeCheckResult represents the result of a privilege check +type PrivilegeCheckResult struct { + // Allowed indicates whether the privilege check passed + Allowed bool + // User is the effective user to use for the operation (nil if not allowed) + User *user.User + // Error contains the reason for denial (nil if allowed) + Error error + // UsedFallback indicates we fell back to current user instead of requested user. + // This happens on Unix when running as an unprivileged user (e.g., in containers) + // where there's no point in user switching since we lack privileges anyway. + // When true, all privilege checks have already been performed and no additional + // privilege dropping or root checks are needed - the current user is the target. + UsedFallback bool + // RequiresUserSwitching indicates whether user switching will actually occur + // (false for fallback cases where no actual switching happens) + RequiresUserSwitching 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 { + context, err := s.buildPrivilegeCheckContext(req.FeatureName) + if err != nil { + return PrivilegeCheckResult{Allowed: false, Error: err} + } + + // Handle empty username case - but still check root access controls + if req.RequestedUsername == "" { + if isPrivilegedUsername(context.currentUser.Username) && !context.allowRoot { + return PrivilegeCheckResult{ + Allowed: false, + Error: &PrivilegedUserError{Username: context.currentUser.Username}, + } + } + return PrivilegeCheckResult{ + Allowed: true, + User: context.currentUser, + RequiresUserSwitching: false, + } + } + + return s.checkUserRequest(context, req) +} + +// buildPrivilegeCheckContext gathers all the context needed for privilege checking +func (s *Server) buildPrivilegeCheckContext(featureName string) (*privilegeCheckContext, error) { + currentUser, err := getCurrentUser() + if err != nil { + return nil, fmt.Errorf("get current user for %s: %w", featureName, err) + } + + s.mu.RLock() + allowRoot := s.allowRootLogin + s.mu.RUnlock() + + return &privilegeCheckContext{ + currentUser: currentUser, + currentUserPrivileged: getIsProcessPrivileged(), + allowRoot: allowRoot, + }, nil +} + +// checkUserRequest handles normal privilege checking flow for specific usernames +func (s *Server) checkUserRequest(ctx *privilegeCheckContext, req PrivilegeCheckRequest) PrivilegeCheckResult { + if !ctx.currentUserPrivileged && isPlatformUnix() { + log.Debugf("Unix non-privileged shortcut: falling back to current user %s for %s (requested: %s)", + ctx.currentUser.Username, req.FeatureName, req.RequestedUsername) + return PrivilegeCheckResult{ + Allowed: true, + User: ctx.currentUser, + UsedFallback: true, + RequiresUserSwitching: false, + } + } + + resolvedUser, err := s.resolveRequestedUser(req.RequestedUsername) + if err != nil { + // Calculate if user switching would be required even if lookup failed + needsUserSwitching := !isSameUser(req.RequestedUsername, ctx.currentUser.Username) + return PrivilegeCheckResult{ + Allowed: false, + Error: err, + RequiresUserSwitching: needsUserSwitching, + } + } + + needsUserSwitching := !isSameResolvedUser(resolvedUser, ctx.currentUser) + + if isPrivilegedUsername(resolvedUser.Username) && !ctx.allowRoot { + return PrivilegeCheckResult{ + Allowed: false, + Error: &PrivilegedUserError{Username: resolvedUser.Username}, + RequiresUserSwitching: needsUserSwitching, + } + } + + if needsUserSwitching && !req.FeatureSupportsUserSwitch { + return PrivilegeCheckResult{ + Allowed: false, + Error: fmt.Errorf("%s: user switching not supported by this feature", req.FeatureName), + RequiresUserSwitching: needsUserSwitching, + } + } + + return PrivilegeCheckResult{ + Allowed: true, + User: resolvedUser, + RequiresUserSwitching: needsUserSwitching, + } +} + +// resolveRequestedUser resolves a username to its canonical user identity +func (s *Server) resolveRequestedUser(requestedUsername string) (*user.User, error) { + if requestedUsername == "" { + return getCurrentUser() + } + + if err := validateUsername(requestedUsername); err != nil { + return nil, fmt.Errorf("invalid username %q: %w", requestedUsername, err) + } + + u, err := lookupUser(requestedUsername) + if err != nil { + return nil, &UserNotFoundError{Username: requestedUsername, Cause: err} + } + return u, nil +} + +// isSameResolvedUser compares two resolved user identities +func isSameResolvedUser(user1, user2 *user.User) bool { + if user1 == nil || user2 == nil { + return user1 == user2 + } + 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 +func isSameUser(requestedUsername, currentUsername string) bool { + // Empty requested username means current user + if requestedUsername == "" { + return true + } + + // Exact match (most common case) + if getCurrentOS() == "windows" { + if strings.EqualFold(requestedUsername, currentUsername) { + return true + } + } else { + if requestedUsername == currentUsername { + return true + } + } + + // Windows domain resolution: only allow domain stripping when comparing + // a bare username against the current user's domain-qualified name + if getCurrentOS() == "windows" { + return isWindowsSameUser(requestedUsername, currentUsername) + } + + return false +} + +// isWindowsSameUser handles Windows-specific user comparison with domain logic +func isWindowsSameUser(requestedUsername, currentUsername string) bool { + // Extract domain and username parts + extractParts := func(name string) (domain, user string) { + // Handle DOMAIN\username format + if idx := strings.LastIndex(name, `\`); idx != -1 { + return name[:idx], name[idx+1:] + } + // Handle user@domain.com format + if idx := strings.Index(name, "@"); idx != -1 { + return name[idx+1:], name[:idx] + } + // No domain specified - local machine + return "", name + } + + reqDomain, reqUser := extractParts(requestedUsername) + curDomain, curUser := extractParts(currentUsername) + + // Case-insensitive username comparison + if !strings.EqualFold(reqUser, curUser) { + return false + } + + // If requested username has no domain, it refers to local machine user + // Allow this to match the current user regardless of current user's domain + if reqDomain == "" { + return true + } + + // If both have domains, they must match exactly (case-insensitive) + 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 { + 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 +} + +// 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. +func isCurrentProcessPrivileged() bool { + if getCurrentOS() == "windows" { + return isWindowsElevated() + } + 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_js.go b/client/ssh/server/user_utils_js.go new file mode 100644 index 000000000..163b24c6c --- /dev/null +++ b/client/ssh/server/user_utils_js.go @@ -0,0 +1,8 @@ +//go:build js + +package server + +// validateUsername is not supported on JS/WASM +func validateUsername(_ string) error { + return errNotSupported +} diff --git a/client/ssh/server/user_utils_test.go b/client/ssh/server/user_utils_test.go new file mode 100644 index 000000000..637dc10d0 --- /dev/null +++ b/client/ssh/server/user_utils_test.go @@ -0,0 +1,908 @@ +package server + +import ( + "errors" + "os/user" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" +) + +// Test helper functions +func createTestUser(username, uid, gid, homeDir string) *user.User { + return &user.User{ + Uid: uid, + Gid: gid, + Username: username, + Name: username, + HomeDir: homeDir, + } +} + +// Test dependency injection setup - injects platform dependencies to test real logic +func setupTestDependencies(currentUser *user.User, currentUserErr error, os string, euid int, lookupUsers map[string]*user.User, lookupErrors map[string]error) func() { + // Store originals + originalGetCurrentUser := getCurrentUser + originalLookupUser := lookupUser + originalGetCurrentOS := getCurrentOS + originalGetEuid := getEuid + + // Reset caches to ensure clean test state + + // Set test values - inject platform dependencies + getCurrentUser = func() (*user.User, error) { + return currentUser, currentUserErr + } + + lookupUser = func(username string) (*user.User, error) { + if err, exists := lookupErrors[username]; exists { + return nil, err + } + if userObj, exists := lookupUsers[username]; exists { + return userObj, nil + } + return nil, errors.New("user: unknown user " + username) + } + + getCurrentOS = func() string { + return os + } + + getEuid = func() int { + return euid + } + + // Mock privilege detection based on the test user + getIsProcessPrivileged = func() bool { + if currentUser == nil { + return false + } + // Check both username and SID for Windows systems + if os == "windows" && isWindowsPrivilegedSID(currentUser.Uid) { + return true + } + return isPrivilegedUsername(currentUser.Username) + } + + // Return cleanup function + return func() { + getCurrentUser = originalGetCurrentUser + lookupUser = originalLookupUser + getCurrentOS = originalGetCurrentOS + getEuid = originalGetEuid + + getIsProcessPrivileged = isCurrentProcessPrivileged + + // Reset caches after test + } +} + +func TestCheckPrivileges_ComprehensiveMatrix(t *testing.T) { + tests := []struct { + name string + os string + euid int + currentUser *user.User + requestedUsername string + featureSupportsUserSwitch bool + allowRoot bool + lookupUsers map[string]*user.User + expectedAllowed bool + expectedRequiresSwitch bool + }{ + { + name: "linux_root_can_switch_to_alice", + os: "linux", + euid: 0, // Root process + currentUser: createTestUser("root", "0", "0", "/root"), + requestedUsername: "alice", + featureSupportsUserSwitch: true, + allowRoot: true, + lookupUsers: map[string]*user.User{ + "alice": createTestUser("alice", "1000", "1000", "/home/alice"), + }, + expectedAllowed: true, + expectedRequiresSwitch: true, + }, + { + name: "linux_non_root_fallback_to_current_user", + os: "linux", + euid: 1000, // Non-root process + currentUser: createTestUser("alice", "1000", "1000", "/home/alice"), + requestedUsername: "bob", + featureSupportsUserSwitch: true, + allowRoot: true, + expectedAllowed: true, // Should fallback to current user (alice) + expectedRequiresSwitch: false, // Fallback means no actual switching + }, + { + name: "windows_admin_can_switch_to_alice", + os: "windows", + euid: 1000, // Irrelevant on Windows + currentUser: createTestUser("Administrator", "S-1-5-21-123456789-123456789-123456789-500", "S-1-5-32-544", "C:\\Users\\Administrator"), + requestedUsername: "alice", + featureSupportsUserSwitch: true, + allowRoot: true, + lookupUsers: map[string]*user.User{ + "alice": createTestUser("alice", "S-1-5-21-123456789-123456789-123456789-1001", "S-1-5-21-123456789-123456789-123456789-513", "C:\\Users\\alice"), + }, + expectedAllowed: true, + expectedRequiresSwitch: true, + }, + { + name: "windows_non_admin_no_fallback_hard_failure", + os: "windows", + euid: 1000, // Irrelevant on Windows + currentUser: createTestUser("alice", "1001", "1001", "C:\\Users\\alice"), + requestedUsername: "bob", + featureSupportsUserSwitch: true, + allowRoot: true, + lookupUsers: map[string]*user.User{ + "bob": createTestUser("bob", "S-1-5-21-123456789-123456789-123456789-1002", "S-1-5-21-123456789-123456789-123456789-513", "C:\\Users\\bob"), + }, + expectedAllowed: true, // Let OS decide - deferred security check + expectedRequiresSwitch: true, // Different user was requested + }, + // Comprehensive test matrix: non-root linux with different allowRoot settings + { + name: "linux_non_root_request_root_allowRoot_false", + os: "linux", + euid: 1000, + currentUser: createTestUser("alice", "1000", "1000", "/home/alice"), + requestedUsername: "root", + featureSupportsUserSwitch: true, + allowRoot: false, + expectedAllowed: true, // Fallback allows access regardless of root setting + expectedRequiresSwitch: false, // Fallback case, no switching + }, + { + name: "linux_non_root_request_root_allowRoot_true", + os: "linux", + euid: 1000, + currentUser: createTestUser("alice", "1000", "1000", "/home/alice"), + requestedUsername: "root", + featureSupportsUserSwitch: true, + allowRoot: true, + expectedAllowed: true, // Should fallback to alice (non-privileged process) + expectedRequiresSwitch: false, // Fallback means no actual switching + }, + // Windows admin test matrix + { + name: "windows_admin_request_root_allowRoot_false", + os: "windows", + euid: 1000, + currentUser: createTestUser("Administrator", "S-1-5-21-123456789-123456789-123456789-500", "S-1-5-32-544", "C:\\Users\\Administrator"), + requestedUsername: "root", + featureSupportsUserSwitch: true, + allowRoot: false, + expectedAllowed: false, // Root not allowed + expectedRequiresSwitch: true, + }, + { + name: "windows_admin_request_root_allowRoot_true", + os: "windows", + euid: 1000, + currentUser: createTestUser("Administrator", "S-1-5-21-123456789-123456789-123456789-500", "S-1-5-32-544", "C:\\Users\\Administrator"), + requestedUsername: "root", + featureSupportsUserSwitch: true, + allowRoot: true, + lookupUsers: map[string]*user.User{ + "root": createTestUser("root", "0", "0", "/root"), + }, + expectedAllowed: true, // Windows user switching should work like Unix + expectedRequiresSwitch: true, + }, + // Windows non-admin test matrix + { + name: "windows_non_admin_request_root_allowRoot_false", + os: "windows", + euid: 1000, + currentUser: createTestUser("alice", "S-1-5-21-123456789-123456789-123456789-1001", "S-1-5-21-123456789-123456789-123456789-513", "C:\\Users\\alice"), + requestedUsername: "root", + featureSupportsUserSwitch: true, + allowRoot: false, + expectedAllowed: false, // Root not allowed (allowRoot=false takes precedence) + expectedRequiresSwitch: true, + }, + { + name: "windows_system_account_allowRoot_false", + os: "windows", + euid: 1000, + currentUser: createTestUser("NETBIRD\\WIN2K19-C2$", "S-1-5-18", "S-1-5-18", "C:\\Windows\\System32"), + requestedUsername: "root", + featureSupportsUserSwitch: true, + allowRoot: false, + expectedAllowed: false, // Root not allowed + expectedRequiresSwitch: true, + }, + { + name: "windows_system_account_allowRoot_true", + os: "windows", + euid: 1000, + currentUser: createTestUser("NETBIRD\\WIN2K19-C2$", "S-1-5-18", "S-1-5-18", "C:\\Windows\\System32"), + requestedUsername: "root", + featureSupportsUserSwitch: true, + allowRoot: true, + lookupUsers: map[string]*user.User{ + "root": createTestUser("root", "0", "0", "/root"), + }, + expectedAllowed: true, // SYSTEM can switch to root + expectedRequiresSwitch: true, + }, + { + name: "windows_non_admin_request_root_allowRoot_true", + os: "windows", + euid: 1000, + currentUser: createTestUser("alice", "S-1-5-21-123456789-123456789-123456789-1001", "S-1-5-21-123456789-123456789-123456789-513", "C:\\Users\\alice"), + requestedUsername: "root", + featureSupportsUserSwitch: true, + allowRoot: true, + lookupUsers: map[string]*user.User{ + "root": createTestUser("root", "0", "0", "/root"), + }, + expectedAllowed: true, // Let OS decide - deferred security check + expectedRequiresSwitch: true, + }, + + // Feature doesn't support user switching scenarios + { + name: "linux_root_feature_no_user_switching_same_user", + os: "linux", + euid: 0, + currentUser: createTestUser("root", "0", "0", "/root"), + requestedUsername: "root", // Same user + featureSupportsUserSwitch: false, + allowRoot: true, + lookupUsers: map[string]*user.User{ + "root": createTestUser("root", "0", "0", "/root"), + }, + expectedAllowed: true, // Same user should work regardless of feature support + expectedRequiresSwitch: false, + }, + { + name: "linux_root_feature_no_user_switching_different_user", + os: "linux", + euid: 0, + currentUser: createTestUser("root", "0", "0", "/root"), + requestedUsername: "alice", + featureSupportsUserSwitch: false, // Feature doesn't support switching + allowRoot: true, + lookupUsers: map[string]*user.User{ + "alice": createTestUser("alice", "1000", "1000", "/home/alice"), + }, + expectedAllowed: false, // Should deny because feature doesn't support switching + expectedRequiresSwitch: true, + }, + + // Empty username (current user) scenarios + { + name: "linux_non_root_current_user_empty_username", + os: "linux", + euid: 1000, + currentUser: createTestUser("alice", "1000", "1000", "/home/alice"), + requestedUsername: "", // Empty = current user + featureSupportsUserSwitch: true, + allowRoot: false, + expectedAllowed: true, // Current user should always work + expectedRequiresSwitch: false, + }, + { + name: "linux_root_current_user_empty_username_root_not_allowed", + os: "linux", + euid: 0, + currentUser: createTestUser("root", "0", "0", "/root"), + requestedUsername: "", // Empty = current user (root) + featureSupportsUserSwitch: true, + allowRoot: false, // Root not allowed + expectedAllowed: false, // Should deny root even when it's current user + expectedRequiresSwitch: false, + }, + + // User not found scenarios + { + name: "linux_root_user_not_found", + os: "linux", + euid: 0, + currentUser: createTestUser("root", "0", "0", "/root"), + requestedUsername: "nonexistent", + featureSupportsUserSwitch: true, + allowRoot: true, + lookupUsers: map[string]*user.User{}, // No users defined = user not found + expectedAllowed: false, // Should fail due to user not found + expectedRequiresSwitch: true, + }, + + // Windows feature doesn't support user switching + { + name: "windows_admin_feature_no_user_switching_different_user", + os: "windows", + euid: 1000, + currentUser: createTestUser("Administrator", "S-1-5-21-123456789-123456789-123456789-500", "S-1-5-32-544", "C:\\Users\\Administrator"), + requestedUsername: "alice", + featureSupportsUserSwitch: false, // Feature doesn't support switching + allowRoot: true, + lookupUsers: map[string]*user.User{ + "alice": createTestUser("alice", "S-1-5-21-123456789-123456789-123456789-1001", "S-1-5-21-123456789-123456789-123456789-513", "C:\\Users\\alice"), + }, + expectedAllowed: false, // Should deny because feature doesn't support switching + expectedRequiresSwitch: true, + }, + + // Windows regular user scenarios (non-admin) + { + name: "windows_regular_user_same_user", + os: "windows", + euid: 1000, + currentUser: createTestUser("alice", "S-1-5-21-123456789-123456789-123456789-1001", "S-1-5-21-123456789-123456789-123456789-513", "C:\\Users\\alice"), + requestedUsername: "alice", // Same user + featureSupportsUserSwitch: true, + allowRoot: false, + lookupUsers: map[string]*user.User{ + "alice": createTestUser("alice", "S-1-5-21-123456789-123456789-123456789-1001", "S-1-5-21-123456789-123456789-123456789-513", "C:\\Users\\alice"), + }, + expectedAllowed: true, // Regular user accessing themselves should work + expectedRequiresSwitch: false, // No switching for same user + }, + { + name: "windows_regular_user_empty_username", + os: "windows", + euid: 1000, + currentUser: createTestUser("alice", "S-1-5-21-123456789-123456789-123456789-1001", "S-1-5-21-123456789-123456789-123456789-513", "C:\\Users\\alice"), + requestedUsername: "", // Empty = current user + featureSupportsUserSwitch: true, + allowRoot: false, + expectedAllowed: true, // Current user should always work + expectedRequiresSwitch: false, // No switching for current user + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Inject platform dependencies to test real logic + cleanup := setupTestDependencies(tt.currentUser, nil, tt.os, tt.euid, tt.lookupUsers, nil) + defer cleanup() + + server := &Server{allowRootLogin: tt.allowRoot} + + result := server.CheckPrivileges(PrivilegeCheckRequest{ + RequestedUsername: tt.requestedUsername, + FeatureSupportsUserSwitch: tt.featureSupportsUserSwitch, + FeatureName: "SSH login", + }) + + assert.Equal(t, tt.expectedAllowed, result.Allowed) + assert.Equal(t, tt.expectedRequiresSwitch, result.RequiresUserSwitching) + }) + } +} + +func TestUsedFallback_MeansNoPrivilegeDropping(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Fallback mechanism is Unix-specific") + } + + // Create test scenario where fallback should occur + server := &Server{allowRootLogin: true} + + // Mock dependencies to simulate non-privileged user + originalGetCurrentUser := getCurrentUser + originalGetIsProcessPrivileged := getIsProcessPrivileged + + defer func() { + getCurrentUser = originalGetCurrentUser + getIsProcessPrivileged = originalGetIsProcessPrivileged + + }() + + // Set up mocks for fallback scenario + getCurrentUser = func() (*user.User, error) { + return createTestUser("netbird", "1000", "1000", "/var/lib/netbird"), nil + } + getIsProcessPrivileged = func() bool { return false } // Non-privileged + + // Request different user - should fallback + result := server.CheckPrivileges(PrivilegeCheckRequest{ + RequestedUsername: "alice", + FeatureSupportsUserSwitch: true, + FeatureName: "SSH login", + }) + + // Verify fallback occurred + assert.True(t, result.Allowed, "Should allow with fallback") + assert.True(t, result.UsedFallback, "Should indicate fallback was used") + assert.Equal(t, "netbird", result.User.Username, "Should return current user") + assert.False(t, result.RequiresUserSwitching, "Should not require switching when fallback is used") + + // Key assertion: When UsedFallback is true, no privilege dropping should be needed + // because all privilege checks have already been performed and we're using current user + t.Logf("UsedFallback=true means: current user (%s) is the target, no privilege dropping needed", + result.User.Username) +} + +func TestPrivilegedUsernameDetection(t *testing.T) { + tests := []struct { + name string + username string + platform string + privileged bool + }{ + // Unix/Linux tests + {"unix_root", "root", "linux", true}, + {"unix_regular_user", "alice", "linux", false}, + {"unix_root_capital", "Root", "linux", false}, // Case-sensitive + + // Windows tests + {"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 { + t.Run(tt.name, func(t *testing.T) { + // Mock the platform for this test + 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) + }) + } +} + +func TestIsSameUser(t *testing.T) { + tests := []struct { + name string + user1 string + user2 string + os string + expected bool + }{ + // Basic cases + {"same_username", "alice", "alice", "linux", true}, + {"different_username", "alice", "bob", "linux", false}, + + // Linux (no domain processing) + {"linux_domain_vs_bare", "DOMAIN\\alice", "alice", "linux", false}, + {"linux_email_vs_bare", "alice@domain.com", "alice", "linux", false}, + {"linux_same_literal", "DOMAIN\\alice", "DOMAIN\\alice", "linux", true}, + + // Windows (with domain processing) - Note: parameter order is (requested, current, os, expected) + {"windows_domain_vs_bare", "alice", "DOMAIN\\alice", "windows", true}, // bare username matches domain current user + {"windows_email_vs_bare", "alice", "alice@domain.com", "windows", true}, // bare username matches email current user + {"windows_different_domains_same_user", "DOMAIN1\\alice", "DOMAIN2\\alice", "windows", false}, // SECURITY: different domains = different users + {"windows_case_insensitive", "Alice", "alice", "windows", true}, + {"windows_different_users", "DOMAIN\\alice", "DOMAIN\\bob", "windows", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set up OS mock + cleanup := setupTestDependencies(nil, nil, tt.os, 1000, nil, nil) + defer cleanup() + + result := isSameUser(tt.user1, tt.user2) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestUsernameValidation_Unix(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix-specific username validation tests") + } + + tests := []struct { + name string + username string + wantErr bool + errMsg string + }{ + // Valid usernames (Unix/POSIX) + {"valid_alphanumeric", "user123", false, ""}, + {"valid_with_dots", "user.name", false, ""}, + {"valid_with_hyphens", "user-name", false, ""}, + {"valid_with_underscores", "user_name", false, ""}, + {"valid_uppercase", "UserName", false, ""}, + {"valid_starting_with_digit", "123user", false, ""}, + {"valid_starting_with_dot", ".hidden", false, ""}, + + // Invalid usernames (Unix/POSIX) + {"empty_username", "", true, "username cannot be empty"}, + {"username_too_long", "thisusernameiswaytoolongandexceedsthe32characterlimit", true, "username too long"}, + {"username_starting_with_hyphen", "-user", true, "invalid characters"}, // POSIX restriction + {"username_with_spaces", "user name", true, "invalid characters"}, + {"username_with_shell_metacharacters", "user;rm", true, "invalid characters"}, + {"username_with_command_injection", "user`rm -rf /`", true, "invalid characters"}, + {"username_with_pipe", "user|rm", true, "invalid characters"}, + {"username_with_ampersand", "user&rm", true, "invalid characters"}, + {"username_with_quotes", "user\"name", true, "invalid characters"}, + {"username_with_newline", "user\nname", true, "invalid characters"}, + {"reserved_dot", ".", true, "cannot be '.' or '..'"}, + {"reserved_dotdot", "..", true, "cannot be '.' or '..'"}, + {"username_with_at_symbol", "user@domain", true, "invalid characters"}, // Not allowed in bare Unix usernames + {"username_with_backslash", "user\\name", true, "invalid characters"}, // Not allowed in Unix usernames + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateUsername(tt.username) + if tt.wantErr { + assert.Error(t, err, "Should reject invalid username") + if tt.errMsg != "" { + assert.Contains(t, err.Error(), tt.errMsg, "Error message should contain expected text") + } + } else { + assert.NoError(t, err, "Should accept valid username") + } + }) + } +} + +func TestUsernameValidation_Windows(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-specific username validation tests") + } + + tests := []struct { + name string + username string + wantErr bool + errMsg string + }{ + // Valid usernames (Windows) + {"valid_alphanumeric", "user123", false, ""}, + {"valid_with_dots", "user.name", false, ""}, + {"valid_with_hyphens", "user-name", false, ""}, + {"valid_with_underscores", "user_name", false, ""}, + {"valid_uppercase", "UserName", false, ""}, + {"valid_starting_with_digit", "123user", false, ""}, + {"valid_starting_with_dot", ".hidden", false, ""}, + {"valid_starting_with_hyphen", "-user", false, ""}, // Windows allows this + {"valid_domain_username", "DOMAIN\\user", false, ""}, // Windows domain format + {"valid_email_username", "user@domain.com", false, ""}, // Windows email format + {"valid_machine_username", "MACHINE\\user", false, ""}, // Windows machine format + + // Invalid usernames (Windows) + {"empty_username", "", true, "username cannot be empty"}, + {"username_too_long", "thisusernameiswaytoolongandexceedsthe32characterlimit", true, "username too long"}, + {"username_with_spaces", "user name", true, "invalid characters"}, + {"username_with_shell_metacharacters", "user;rm", true, "invalid characters"}, + {"username_with_command_injection", "user`rm -rf /`", true, "invalid characters"}, + {"username_with_pipe", "user|rm", true, "invalid characters"}, + {"username_with_ampersand", "user&rm", true, "invalid characters"}, + {"username_with_quotes", "user\"name", true, "invalid characters"}, + {"username_with_newline", "user\nname", true, "invalid characters"}, + {"username_with_brackets", "user[name]", true, "invalid characters"}, + {"username_with_colon", "user:name", true, "invalid characters"}, + {"username_with_semicolon", "user;name", true, "invalid characters"}, + {"username_with_equals", "user=name", true, "invalid characters"}, + {"username_with_comma", "user,name", true, "invalid characters"}, + {"username_with_plus", "user+name", true, "invalid characters"}, + {"username_with_asterisk", "user*name", true, "invalid characters"}, + {"username_with_question", "user?name", true, "invalid characters"}, + {"username_with_angles", "user", true, "invalid characters"}, + {"reserved_dot", ".", true, "cannot be '.' or '..'"}, + {"reserved_dotdot", "..", true, "cannot be '.' or '..'"}, + {"username_ending_with_period", "user.", true, "cannot end with a period"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateUsername(tt.username) + if tt.wantErr { + assert.Error(t, err, "Should reject invalid username") + if tt.errMsg != "" { + assert.Contains(t, err.Error(), tt.errMsg, "Error message should contain expected text") + } + } else { + assert.NoError(t, err, "Should accept valid username") + } + }) + } +} + +// Test real-world integration scenarios with actual platform capabilities +func TestCheckPrivileges_RealWorldScenarios(t *testing.T) { + tests := []struct { + name string + feature string + featureSupportsUserSwitch bool + requestedUsername string + allowRoot bool + expectedBehaviorPattern string + }{ + {"SSH_login_current_user", "SSH login", true, "", true, "should_allow_current_user"}, + {"SFTP_current_user", "SFTP", true, "", true, "should_allow_current_user"}, + {"port_forwarding_current_user", "port forwarding", false, "", true, "should_allow_current_user"}, + {"SSH_login_root_not_allowed", "SSH login", true, "root", false, "should_deny_root"}, + {"port_forwarding_different_user", "port forwarding", false, "differentuser", true, "should_deny_switching"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Mock privileged environment to ensure consistent test behavior across environments + cleanup := setupTestDependencies( + createTestUser("root", "0", "0", "/root"), // Running as root + nil, + runtime.GOOS, + 0, // euid 0 (root) + map[string]*user.User{ + "root": createTestUser("root", "0", "0", "/root"), + "differentuser": createTestUser("differentuser", "1000", "1000", "/home/differentuser"), + }, + nil, + ) + defer cleanup() + + server := &Server{allowRootLogin: tt.allowRoot} + + result := server.CheckPrivileges(PrivilegeCheckRequest{ + RequestedUsername: tt.requestedUsername, + FeatureSupportsUserSwitch: tt.featureSupportsUserSwitch, + FeatureName: tt.feature, + }) + + switch tt.expectedBehaviorPattern { + case "should_allow_current_user": + assert.True(t, result.Allowed, "Should allow current user access") + assert.False(t, result.RequiresUserSwitching, "Current user should not require switching") + case "should_deny_root": + assert.False(t, result.Allowed, "Should deny root when not allowed") + assert.Contains(t, result.Error.Error(), "root", "Should mention root in error") + case "should_deny_switching": + assert.False(t, result.Allowed, "Should deny when feature doesn't support switching") + assert.Contains(t, result.Error.Error(), "user switching not supported", "Should mention switching in error") + } + }) + } +} + +// Test with actual platform capabilities - no mocking +func TestCheckPrivileges_ActualPlatform(t *testing.T) { + // This test uses the REAL platform capabilities + server := &Server{allowRootLogin: true} + + // Test current user access - should always work + result := server.CheckPrivileges(PrivilegeCheckRequest{ + RequestedUsername: "", // Current user + FeatureSupportsUserSwitch: true, + FeatureName: "SSH login", + }) + + assert.True(t, result.Allowed, "Current user should always be allowed") + assert.False(t, result.RequiresUserSwitching, "Current user should not require switching") + assert.NotNil(t, result.User, "Should return current user") + + // Test user switching capability based on actual platform + actualIsPrivileged := isCurrentProcessPrivileged() // REAL check + actualOS := runtime.GOOS // REAL check + + t.Logf("Platform capabilities: OS=%s, isPrivileged=%v, supportsUserSwitching=%v", + actualOS, actualIsPrivileged, actualIsPrivileged) + + // Test requesting different user + result = server.CheckPrivileges(PrivilegeCheckRequest{ + RequestedUsername: "nonexistentuser", + FeatureSupportsUserSwitch: true, + FeatureName: "SSH login", + }) + + switch { + case actualOS == "windows": + // Windows supports user switching but should fail on nonexistent user + assert.False(t, result.Allowed, "Windows should deny nonexistent user") + assert.True(t, result.RequiresUserSwitching, "Should indicate switching is needed") + assert.Contains(t, result.Error.Error(), "not found", + "Should indicate user not found") + case !actualIsPrivileged: + // Non-privileged Unix processes should fallback to current user + assert.True(t, result.Allowed, "Non-privileged Unix process should fallback to current user") + assert.False(t, result.RequiresUserSwitching, "Fallback means no switching actually happens") + assert.True(t, result.UsedFallback, "Should indicate fallback was used") + assert.NotNil(t, result.User, "Should return current user") + default: + // Privileged Unix processes should attempt user lookup + assert.False(t, result.Allowed, "Should fail due to nonexistent user") + assert.True(t, result.RequiresUserSwitching, "Should indicate switching is needed") + assert.Contains(t, result.Error.Error(), "nonexistentuser", + "Should indicate user not found") + } +} + +// Test platform detection logic with dependency injection +func TestPlatformLogic_DependencyInjection(t *testing.T) { + tests := []struct { + name string + os string + euid int + currentUser *user.User + expectedIsProcessPrivileged bool + expectedSupportsUserSwitching bool + }{ + { + name: "linux_root_process", + os: "linux", + euid: 0, + currentUser: createTestUser("root", "0", "0", "/root"), + expectedIsProcessPrivileged: true, + expectedSupportsUserSwitching: true, + }, + { + name: "linux_non_root_process", + os: "linux", + euid: 1000, + currentUser: createTestUser("alice", "1000", "1000", "/home/alice"), + expectedIsProcessPrivileged: false, + expectedSupportsUserSwitching: false, + }, + { + name: "windows_admin_process", + os: "windows", + euid: 1000, // euid ignored on Windows + currentUser: createTestUser("Administrator", "S-1-5-21-123456789-123456789-123456789-500", "S-1-5-32-544", "C:\\Users\\Administrator"), + expectedIsProcessPrivileged: true, + expectedSupportsUserSwitching: true, // Windows supports user switching when privileged + }, + { + name: "windows_regular_process", + os: "windows", + euid: 1000, // euid ignored on Windows + currentUser: createTestUser("alice", "1001", "1001", "C:\\Users\\alice"), + expectedIsProcessPrivileged: false, + expectedSupportsUserSwitching: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Inject platform dependencies and test REAL logic + cleanup := setupTestDependencies(tt.currentUser, nil, tt.os, tt.euid, nil, nil) + defer cleanup() + + // Test the actual functions with injected dependencies + actualIsPrivileged := isCurrentProcessPrivileged() + actualSupportsUserSwitching := actualIsPrivileged + + assert.Equal(t, tt.expectedIsProcessPrivileged, actualIsPrivileged, + "isCurrentProcessPrivileged() result mismatch") + assert.Equal(t, tt.expectedSupportsUserSwitching, actualSupportsUserSwitching, + "supportsUserSwitching() result mismatch") + + t.Logf("Platform: %s, EUID: %d, User: %s", tt.os, tt.euid, tt.currentUser.Username) + t.Logf("Results: isPrivileged=%v, supportsUserSwitching=%v", + actualIsPrivileged, actualSupportsUserSwitching) + }) + } +} + +func TestCheckPrivileges_WindowsElevatedUserSwitching(t *testing.T) { + // Test Windows elevated user switching scenarios with simplified privilege logic + tests := []struct { + name string + currentUser *user.User + requestedUsername string + allowRoot bool + expectedAllowed bool + expectedErrorContains string + }{ + { + name: "windows_admin_can_switch_to_alice", + currentUser: createTestUser("administrator", "S-1-5-21-123456789-123456789-123456789-500", "S-1-5-32-544", "C:\\\\Users\\\\Administrator"), + requestedUsername: "alice", + allowRoot: true, + expectedAllowed: true, + }, + { + name: "windows_non_admin_can_try_switch", + currentUser: createTestUser("alice", "S-1-5-21-123456789-123456789-123456789-1001", "S-1-5-21-123456789-123456789-123456789-513", "C:\\\\Users\\\\alice"), + requestedUsername: "bob", + allowRoot: true, + expectedAllowed: true, // Privilege check allows it, OS will reject during execution + }, + { + name: "windows_system_can_switch_to_alice", + currentUser: createTestUser("SYSTEM", "S-1-5-18", "S-1-5-18", "C:\\\\Windows\\\\system32\\\\config\\\\systemprofile"), + requestedUsername: "alice", + allowRoot: true, + expectedAllowed: true, + }, + { + name: "windows_admin_root_not_allowed", + currentUser: createTestUser("administrator", "S-1-5-21-123456789-123456789-123456789-500", "S-1-5-32-544", "C:\\\\Users\\\\Administrator"), + requestedUsername: "root", + allowRoot: false, + expectedAllowed: false, + expectedErrorContains: "privileged user login is disabled", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup test dependencies with Windows OS and specified privileges + lookupUsers := map[string]*user.User{ + tt.requestedUsername: createTestUser(tt.requestedUsername, "1002", "1002", "C:\\\\Users\\\\"+tt.requestedUsername), + } + cleanup := setupTestDependencies(tt.currentUser, nil, "windows", 1000, lookupUsers, nil) + defer cleanup() + + server := &Server{allowRootLogin: tt.allowRoot} + + result := server.CheckPrivileges(PrivilegeCheckRequest{ + RequestedUsername: tt.requestedUsername, + FeatureSupportsUserSwitch: true, + FeatureName: "SSH login", + }) + + assert.Equal(t, tt.expectedAllowed, result.Allowed, + "Privilege check result should match expected for %s", tt.name) + + if !tt.expectedAllowed && tt.expectedErrorContains != "" { + assert.NotNil(t, result.Error, "Should have error when not allowed") + assert.Contains(t, result.Error.Error(), tt.expectedErrorContains, + "Error should contain expected message") + } + + if tt.expectedAllowed && tt.requestedUsername != "" && tt.currentUser.Username != tt.requestedUsername { + assert.True(t, result.RequiresUserSwitching, "Should require user switching for different user") + } + }) + } +} diff --git a/client/ssh/server/userswitching_js.go b/client/ssh/server/userswitching_js.go new file mode 100644 index 000000000..333c19259 --- /dev/null +++ b/client/ssh/server/userswitching_js.go @@ -0,0 +1,8 @@ +//go:build js + +package server + +// enableUserSwitching is not supported on JS/WASM +func enableUserSwitching() error { + return errNotSupported +} diff --git a/client/ssh/server/userswitching_unix.go b/client/ssh/server/userswitching_unix.go new file mode 100644 index 000000000..220e2240f --- /dev/null +++ b/client/ssh/server/userswitching_unix.go @@ -0,0 +1,258 @@ +//go:build unix + +package server + +import ( + "errors" + "fmt" + "net" + "net/netip" + "os" + "os/exec" + "os/user" + "regexp" + "runtime" + "strconv" + + "github.com/gliderlabs/ssh" + log "github.com/sirupsen/logrus" +) + +// POSIX portable filename character set regex: [a-zA-Z0-9._-] +// First character cannot be hyphen (POSIX requirement) +var posixUsernameRegex = regexp.MustCompile(`^[a-zA-Z0-9._][a-zA-Z0-9._-]*$`) + +// validateUsername validates that a username conforms to POSIX standards with security considerations +func validateUsername(username string) error { + if username == "" { + return errors.New("username cannot be empty") + } + + // POSIX allows up to 256 characters, but practical limit is 32 for compatibility + if len(username) > 32 { + return errors.New("username too long (max 32 characters)") + } + + if !posixUsernameRegex.MatchString(username) { + return errors.New("username contains invalid characters (must match POSIX portable filename character set)") + } + + if username == "." || username == ".." { + return fmt.Errorf("username cannot be '.' or '..'") + } + + // Warn if username is fully numeric (can cause issues with UID/username ambiguity) + if isFullyNumeric(username) { + log.Warnf("fully numeric username '%s' may cause issues with some commands", username) + } + + return nil +} + +// isFullyNumeric checks if username contains only digits +func isFullyNumeric(username string) bool { + for _, char := range username { + if char < '0' || char > '9' { + return false + } + } + return true +} + +// createPtyLoginCommand creates a Pty command using login for privileged processes +func (s *Server) createPtyLoginCommand(localUser *user.User, ptyReq ssh.Pty, session ssh.Session) (*exec.Cmd, error) { + loginPath, args, err := s.getLoginCmd(localUser.Username, session.RemoteAddr()) + if err != nil { + return nil, fmt.Errorf("get login command: %w", err) + } + + execCmd := exec.CommandContext(session.Context(), loginPath, args...) + execCmd.Dir = localUser.HomeDir + execCmd.Env = s.preparePtyEnv(localUser, ptyReq, session) + + return execCmd, nil +} + +// getLoginCmd returns the login command and args for privileged Pty user switching +func (s *Server) getLoginCmd(username string, remoteAddr net.Addr) (string, []string, error) { + loginPath, err := exec.LookPath("login") + if err != nil { + return "", nil, fmt.Errorf("login command not available: %w", err) + } + + addrPort, err := netip.ParseAddrPort(remoteAddr.String()) + if err != nil { + return "", nil, fmt.Errorf("parse remote address: %w", err) + } + + switch runtime.GOOS { + case "linux": + p, a := s.getLinuxLoginCmd(loginPath, username, addrPort.Addr().String()) + return p, a, nil + case "darwin", "freebsd", "openbsd", "netbsd", "dragonfly": + return loginPath, []string{"-fp", "-h", addrPort.Addr().String(), username}, nil + default: + return "", nil, fmt.Errorf("unsupported Unix platform for login command: %s", runtime.GOOS) + } +} + +// getLinuxLoginCmd returns the login command for Linux systems. +// Handles differences between util-linux and shadow-utils login implementations. +func (s *Server) getLinuxLoginCmd(loginPath, username, remoteIP string) (string, []string) { + // Special handling for Arch Linux without /etc/pam.d/remote + var loginArgs []string + if s.fileExists("/etc/arch-release") && !s.fileExists("/etc/pam.d/remote") { + loginArgs = []string{"-f", username, "-p"} + } else { + loginArgs = []string{"-f", username, "-h", remoteIP, "-p"} + } + + // util-linux login requires setsid -c to create a new session and set the + // controlling terminal. Without this, vhangup() kills the parent process. + // See https://bugs.debian.org/1078023 for details. + // TODO: handle this via the executor using syscall.Setsid() + TIOCSCTTY + syscall.Exec() + // to avoid external setsid dependency. + if !s.loginIsUtilLinux { + return loginPath, loginArgs + } + + setsidPath, err := exec.LookPath("setsid") + if err != nil { + log.Warnf("setsid not available but util-linux login detected, login may fail: %v", err) + return loginPath, loginArgs + } + + args := append([]string{"-w", "-c", loginPath}, loginArgs...) + return setsidPath, args +} + +// fileExists checks if a file exists +func (s *Server) fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// parseUserCredentials extracts numeric UID, GID, and supplementary groups +func (s *Server) parseUserCredentials(localUser *user.User) (uint32, uint32, []uint32, error) { + uid64, err := strconv.ParseUint(localUser.Uid, 10, 32) + if err != nil { + return 0, 0, nil, fmt.Errorf("invalid UID %s: %w", localUser.Uid, err) + } + uid := uint32(uid64) + + gid64, err := strconv.ParseUint(localUser.Gid, 10, 32) + if err != nil { + return 0, 0, nil, fmt.Errorf("invalid GID %s: %w", localUser.Gid, err) + } + gid := uint32(gid64) + + groups, err := s.getSupplementaryGroups(localUser) + if err != nil || len(groups) == 0 { + if err != nil { + log.Warnf("failed to get supplementary groups for user %s: %v", localUser.Username, err) + } + groups = []uint32{gid} + } + + return uid, gid, groups, nil +} + +// getSupplementaryGroups retrieves supplementary group IDs for a user. +// Uses id/getent fallback for NSS users in CGO_ENABLED=0 builds. +func (s *Server) getSupplementaryGroups(u *user.User) ([]uint32, error) { + groupIDStrings, err := groupIdsWithFallback(u) + if err != nil { + return nil, fmt.Errorf("get group IDs for user %s: %w", u.Username, err) + } + + groups := make([]uint32, len(groupIDStrings)) + for i, gidStr := range groupIDStrings { + gid64, err := strconv.ParseUint(gidStr, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid group ID %s for user %s: %w", gidStr, u.Username, err) + } + groups[i] = uint32(gid64) + } + + return groups, nil +} + +// createExecutorCommand creates a command that spawns netbird ssh exec for privilege dropping. +// Returns the command and a cleanup function (no-op on Unix). +func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, localUser *user.User, hasPty bool) (*exec.Cmd, func(), error) { + logger.Debugf("creating executor command for user %s (Pty: %v)", localUser.Username, hasPty) + + if err := validateUsername(localUser.Username); err != nil { + return nil, nil, fmt.Errorf("invalid username %q: %w", localUser.Username, err) + } + + uid, gid, groups, err := s.parseUserCredentials(localUser) + if err != nil { + return nil, nil, fmt.Errorf("parse user credentials: %w", err) + } + privilegeDropper := NewPrivilegeDropper(WithLogger(logger)) + config := ExecutorConfig{ + UID: uid, + GID: gid, + Groups: groups, + WorkingDir: localUser.HomeDir, + Shell: getUserShell(localUser.Uid), + Command: session.RawCommand(), + PTY: hasPty, + } + + cmd, err := privilegeDropper.CreateExecutorCommand(session.Context(), config) + return cmd, func() {}, err +} + +// enableUserSwitching is a no-op on Unix systems +func enableUserSwitching() error { + return nil +} + +// createPtyCommand creates the exec.Cmd for Pty execution respecting privilege check results +func (s *Server) createPtyCommand(privilegeResult PrivilegeCheckResult, ptyReq ssh.Pty, session ssh.Session) (*exec.Cmd, error) { + localUser := privilegeResult.User + if localUser == nil { + return nil, errors.New("no user in privilege result") + } + + if privilegeResult.UsedFallback { + return s.createDirectPtyCommand(session, localUser, ptyReq), nil + } + + return s.createPtyLoginCommand(localUser, ptyReq, session) +} + +// createDirectPtyCommand creates a direct Pty command without privilege dropping +func (s *Server) createDirectPtyCommand(session ssh.Session, localUser *user.User, ptyReq ssh.Pty) *exec.Cmd { + log.Debugf("creating direct Pty command for user %s (no user switching needed)", localUser.Username) + + shell := getUserShell(localUser.Uid) + args := s.getShellCommandArgs(shell, session.RawCommand()) + + cmd := s.createShellCommand(session.Context(), shell, args) + cmd.Dir = localUser.HomeDir + cmd.Env = s.preparePtyEnv(localUser, ptyReq, session) + + return cmd +} + +// preparePtyEnv prepares environment variables for Pty execution +func (s *Server) preparePtyEnv(localUser *user.User, ptyReq ssh.Pty, session ssh.Session) []string { + termType := ptyReq.Term + if termType == "" { + termType = "xterm-256color" + } + + env := prepareUserEnv(localUser, getUserShell(localUser.Uid)) + env = append(env, prepareSSHEnv(session)...) + env = append(env, fmt.Sprintf("TERM=%s", termType)) + + for _, v := range session.Environ() { + if acceptEnv(v) { + env = append(env, v) + } + } + return env +} diff --git a/client/ssh/server/userswitching_windows.go b/client/ssh/server/userswitching_windows.go new file mode 100644 index 000000000..260e1301e --- /dev/null +++ b/client/ssh/server/userswitching_windows.go @@ -0,0 +1,273 @@ +//go:build windows + +package server + +import ( + "errors" + "fmt" + "os/exec" + "os/user" + "strings" + "unsafe" + + "github.com/gliderlabs/ssh" + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +// validateUsername validates Windows usernames according to SAM Account Name rules +func validateUsername(username string) error { + if username == "" { + return fmt.Errorf("username cannot be empty") + } + + usernameToValidate := extractUsernameFromDomain(username) + + if err := validateUsernameLength(usernameToValidate); err != nil { + return err + } + + if err := validateUsernameCharacters(usernameToValidate); err != nil { + return err + } + + if err := validateUsernameFormat(usernameToValidate); err != nil { + return err + } + + return nil +} + +// extractUsernameFromDomain extracts the username part from domain\username or username@domain format +func extractUsernameFromDomain(username string) string { + if idx := strings.LastIndex(username, `\`); idx != -1 { + return username[idx+1:] + } + if idx := strings.Index(username, "@"); idx != -1 { + return username[:idx] + } + return username +} + +// validateUsernameLength checks if username length is within Windows limits +func validateUsernameLength(username string) error { + if len(username) > 20 { + return fmt.Errorf("username too long (max 20 characters for Windows)") + } + return nil +} + +// validateUsernameCharacters checks for invalid characters in Windows usernames +func validateUsernameCharacters(username string) error { + invalidChars := []rune{'"', '/', '[', ']', ':', ';', '|', '=', ',', '+', '*', '?', '<', '>', ' ', '`', '&', '\n'} + for _, char := range username { + for _, invalid := range invalidChars { + if char == invalid { + return fmt.Errorf("username contains invalid characters") + } + } + if char < 32 || char == 127 { + return fmt.Errorf("username contains control characters") + } + } + return nil +} + +// validateUsernameFormat checks for invalid username formats and patterns +func validateUsernameFormat(username string) error { + if username == "." || username == ".." { + return fmt.Errorf("username cannot be '.' or '..'") + } + + if strings.HasSuffix(username, ".") { + return fmt.Errorf("username cannot end with a period") + } + + return nil +} + +// createExecutorCommand creates a command using Windows executor for privilege dropping. +// Returns the command and a cleanup function that must be called after starting the process. +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) + if err := validateUsername(username); err != nil { + return nil, nil, fmt.Errorf("invalid username %q: %w", username, err) + } + + return s.createUserSwitchCommand(logger, session, localUser) +} + +// 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) + + shell := getUserShell(localUser.Uid) + + rawCmd := session.RawCommand() + var command string + if rawCmd != "" { + command = rawCmd + } + + config := WindowsExecutorConfig{ + Username: username, + Domain: domain, + WorkingDir: localUser.HomeDir, + Shell: shell, + Command: command, + } + + dropper := NewPrivilegeDropper(WithLogger(logger)) + cmd, token, err := dropper.CreateWindowsExecutorCommand(session.Context(), config) + if err != nil { + return nil, nil, err + } + + cleanup := func() { + if token != 0 { + if err := windows.CloseHandle(windows.Handle(token)); err != nil { + logger.Debugf("close primary token: %v", err) + } + } + } + + return cmd, cleanup, nil +} + +// parseUsername extracts username and domain from a Windows username +func (s *Server) parseUsername(fullUsername string) (username, domain string) { + // Handle DOMAIN\username format + if idx := strings.LastIndex(fullUsername, `\`); idx != -1 { + domain = fullUsername[:idx] + username = fullUsername[idx+1:] + return username, domain + } + + // Handle username@domain format + if username, domain, ok := strings.Cut(fullUsername, "@"); ok { + return username, domain + } + + // Local user (no domain) + return fullUsername, "." +} + +// hasPrivilege checks if the current process has a specific privilege +func hasPrivilege(token windows.Handle, privilegeName string) (bool, error) { + var luid windows.LUID + if err := windows.LookupPrivilegeValue(nil, windows.StringToUTF16Ptr(privilegeName), &luid); err != nil { + return false, fmt.Errorf("lookup privilege value: %w", err) + } + + var returnLength uint32 + err := windows.GetTokenInformation( + windows.Token(token), + windows.TokenPrivileges, + nil, // null buffer to get size + 0, + &returnLength, + ) + + if err != nil && !errors.Is(err, windows.ERROR_INSUFFICIENT_BUFFER) { + return false, fmt.Errorf("get token information size: %w", err) + } + + buffer := make([]byte, returnLength) + err = windows.GetTokenInformation( + windows.Token(token), + windows.TokenPrivileges, + &buffer[0], + returnLength, + &returnLength, + ) + if err != nil { + return false, fmt.Errorf("get token information: %w", err) + } + + privileges := (*windows.Tokenprivileges)(unsafe.Pointer(&buffer[0])) + + // Check if the privilege is present and enabled + for i := uint32(0); i < privileges.PrivilegeCount; i++ { + privilege := (*windows.LUIDAndAttributes)(unsafe.Pointer( + uintptr(unsafe.Pointer(&privileges.Privileges[0])) + + uintptr(i)*unsafe.Sizeof(windows.LUIDAndAttributes{}), + )) + if privilege.Luid == luid { + return (privilege.Attributes & windows.SE_PRIVILEGE_ENABLED) != 0, nil + } + } + + return false, nil +} + +// enablePrivilege enables a specific privilege for the current process token +// This is required because privileges like SeAssignPrimaryTokenPrivilege are present +// but disabled by default, even for the SYSTEM account +func enablePrivilege(token windows.Handle, privilegeName string) error { + var luid windows.LUID + if err := windows.LookupPrivilegeValue(nil, windows.StringToUTF16Ptr(privilegeName), &luid); err != nil { + return fmt.Errorf("lookup privilege value for %s: %w", privilegeName, err) + } + + privileges := windows.Tokenprivileges{ + PrivilegeCount: 1, + Privileges: [1]windows.LUIDAndAttributes{ + { + Luid: luid, + Attributes: windows.SE_PRIVILEGE_ENABLED, + }, + }, + } + + err := windows.AdjustTokenPrivileges( + windows.Token(token), + false, + &privileges, + 0, + nil, + nil, + ) + if err != nil { + return fmt.Errorf("adjust token privileges for %s: %w", privilegeName, err) + } + + hasPriv, err := hasPrivilege(token, privilegeName) + if err != nil { + return fmt.Errorf("verify privilege %s after enabling: %w", privilegeName, err) + } + if !hasPriv { + return fmt.Errorf("privilege %s could not be enabled (may not be granted to account)", privilegeName) + } + + log.Debugf("Successfully enabled privilege %s for current process", privilegeName) + return nil +} + +// enableUserSwitching enables required privileges for Windows user switching +func enableUserSwitching() error { + process := windows.CurrentProcess() + + var token windows.Token + err := windows.OpenProcessToken( + process, + windows.TOKEN_ADJUST_PRIVILEGES|windows.TOKEN_QUERY, + &token, + ) + if err != nil { + return fmt.Errorf("open process token: %w", err) + } + defer func() { + if err := windows.CloseHandle(windows.Handle(token)); err != nil { + log.Debugf("Failed to close process token: %v", err) + } + }() + + if err := enablePrivilege(windows.Handle(token), "SeAssignPrimaryTokenPrivilege"); err != nil { + return fmt.Errorf("enable SeAssignPrimaryTokenPrivilege: %w", err) + } + log.Infof("Windows user switching privileges enabled successfully") + return nil +} diff --git a/client/ssh/server/winpty/conpty.go b/client/ssh/server/winpty/conpty.go new file mode 100644 index 000000000..c08ccfd05 --- /dev/null +++ b/client/ssh/server/winpty/conpty.go @@ -0,0 +1,487 @@ +//go:build windows + +package winpty + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "sync" + "syscall" + "unsafe" + + "github.com/gliderlabs/ssh" + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +var ( + ErrEmptyEnvironment = errors.New("empty environment") +) + +const ( + extendedStartupInfoPresent = 0x00080000 + createUnicodeEnvironment = 0x00000400 + procThreadAttributePseudoConsole = 0x00020016 + + PowerShellCommandFlag = "-Command" + + errCloseInputRead = "close input read handle: %v" + errCloseConPtyCleanup = "close ConPty handle during cleanup" +) + +// PtyConfig holds configuration for Pty execution. +type PtyConfig struct { + Shell string + Command string + Width int + Height int + WorkingDir string +} + +// UserConfig holds user execution configuration. +type UserConfig struct { + Token windows.Handle + Environment []string +} + +var ( + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + procClosePseudoConsole = kernel32.NewProc("ClosePseudoConsole") + procInitializeProcThreadAttributeList = kernel32.NewProc("InitializeProcThreadAttributeList") + procUpdateProcThreadAttribute = kernel32.NewProc("UpdateProcThreadAttribute") + procDeleteProcThreadAttributeList = kernel32.NewProc("DeleteProcThreadAttributeList") +) + +// ExecutePtyWithUserToken executes a command with ConPty using user token. +func ExecutePtyWithUserToken(session ssh.Session, ptyConfig PtyConfig, userConfig UserConfig) error { + args := buildShellArgs(ptyConfig.Shell, ptyConfig.Command) + commandLine := buildCommandLine(args) + + config := ExecutionConfig{ + Pty: ptyConfig, + User: userConfig, + Session: session, + Context: session.Context(), + } + + return executeConPtyWithConfig(commandLine, config) +} + +// ExecutionConfig holds all execution configuration. +type ExecutionConfig struct { + Pty PtyConfig + User UserConfig + Session ssh.Session + Context context.Context +} + +// executeConPtyWithConfig creates ConPty and executes process with configuration. +func executeConPtyWithConfig(commandLine string, config ExecutionConfig) error { + ctx := config.Context + session := config.Session + width := config.Pty.Width + height := config.Pty.Height + userToken := config.User.Token + userEnv := config.User.Environment + workingDir := config.Pty.WorkingDir + + inputRead, inputWrite, outputRead, outputWrite, err := createConPtyPipes() + if err != nil { + return fmt.Errorf("create ConPty pipes: %w", err) + } + + hPty, err := createConPty(width, height, inputRead, outputWrite) + if err != nil { + return fmt.Errorf("create ConPty: %w", err) + } + + primaryToken, err := duplicateToPrimaryToken(userToken) + if err != nil { + if closeErr, _, _ := procClosePseudoConsole.Call(uintptr(hPty)); closeErr == 0 { + log.Debugf(errCloseConPtyCleanup) + } + closeHandles(inputRead, inputWrite, outputRead, outputWrite) + return fmt.Errorf("duplicate to primary token: %w", err) + } + defer func() { + if err := windows.CloseHandle(primaryToken); err != nil { + log.Debugf("close primary token: %v", err) + } + }() + + siEx, err := setupConPtyStartupInfo(hPty) + if err != nil { + if closeErr, _, _ := procClosePseudoConsole.Call(uintptr(hPty)); closeErr == 0 { + log.Debugf(errCloseConPtyCleanup) + } + closeHandles(inputRead, inputWrite, outputRead, outputWrite) + return fmt.Errorf("setup startup info: %w", err) + } + defer func() { + _, _, _ = procDeleteProcThreadAttributeList.Call(uintptr(unsafe.Pointer(siEx.ProcThreadAttributeList))) + }() + + pi, err := createConPtyProcess(commandLine, primaryToken, userEnv, workingDir, siEx) + if err != nil { + if closeErr, _, _ := procClosePseudoConsole.Call(uintptr(hPty)); closeErr == 0 { + log.Debugf(errCloseConPtyCleanup) + } + closeHandles(inputRead, inputWrite, outputRead, outputWrite) + return fmt.Errorf("create process as user with ConPty: %w", err) + } + defer closeProcessInfo(pi) + + if err := windows.CloseHandle(inputRead); err != nil { + log.Debugf(errCloseInputRead, err) + } + if err := windows.CloseHandle(outputWrite); err != nil { + log.Debugf("close output write handle: %v", err) + } + + return bridgeConPtyIO(ctx, hPty, inputWrite, outputRead, session, session, session, pi.Process) +} + +// createConPtyPipes creates input/output pipes for ConPty. +func createConPtyPipes() (inputRead, inputWrite, outputRead, outputWrite windows.Handle, err error) { + if err := windows.CreatePipe(&inputRead, &inputWrite, nil, 0); err != nil { + return 0, 0, 0, 0, fmt.Errorf("create input pipe: %w", err) + } + + if err := windows.CreatePipe(&outputRead, &outputWrite, nil, 0); err != nil { + if closeErr := windows.CloseHandle(inputRead); closeErr != nil { + log.Debugf(errCloseInputRead, closeErr) + } + if closeErr := windows.CloseHandle(inputWrite); closeErr != nil { + log.Debugf("close input write handle: %v", closeErr) + } + return 0, 0, 0, 0, fmt.Errorf("create output pipe: %w", err) + } + + return inputRead, inputWrite, outputRead, outputWrite, nil +} + +// createConPty creates a Windows ConPty with the specified size and pipe handles. +func createConPty(width, height int, inputRead, outputWrite windows.Handle) (windows.Handle, error) { + size := windows.Coord{X: int16(width), Y: int16(height)} + + var hPty windows.Handle + if err := windows.CreatePseudoConsole(size, inputRead, outputWrite, 0, &hPty); err != nil { + return 0, fmt.Errorf("CreatePseudoConsole: %w", err) + } + + return hPty, nil +} + +// setupConPtyStartupInfo prepares the STARTUPINFOEX with ConPty attributes. +func setupConPtyStartupInfo(hPty windows.Handle) (*windows.StartupInfoEx, error) { + var siEx windows.StartupInfoEx + siEx.StartupInfo.Cb = uint32(unsafe.Sizeof(siEx)) + + var attrListSize uintptr + ret, _, _ := procInitializeProcThreadAttributeList.Call(0, 1, 0, uintptr(unsafe.Pointer(&attrListSize))) + if ret == 0 && attrListSize == 0 { + return nil, fmt.Errorf("get attribute list size") + } + + attrListBytes := make([]byte, attrListSize) + siEx.ProcThreadAttributeList = (*windows.ProcThreadAttributeList)(unsafe.Pointer(&attrListBytes[0])) + + ret, _, err := procInitializeProcThreadAttributeList.Call( + uintptr(unsafe.Pointer(siEx.ProcThreadAttributeList)), + 1, + 0, + uintptr(unsafe.Pointer(&attrListSize)), + ) + if ret == 0 { + return nil, fmt.Errorf("initialize attribute list: %w", err) + } + + ret, _, err = procUpdateProcThreadAttribute.Call( + uintptr(unsafe.Pointer(siEx.ProcThreadAttributeList)), + 0, + procThreadAttributePseudoConsole, + uintptr(hPty), + unsafe.Sizeof(hPty), + 0, + 0, + ) + if ret == 0 { + return nil, fmt.Errorf("update thread attribute: %w", err) + } + + return &siEx, nil +} + +// createConPtyProcess creates the actual process with ConPty. +func createConPtyProcess(commandLine string, userToken windows.Handle, userEnv []string, workingDir string, siEx *windows.StartupInfoEx) (*windows.ProcessInformation, error) { + var pi windows.ProcessInformation + creationFlags := uint32(extendedStartupInfoPresent | createUnicodeEnvironment) + + commandLinePtr, err := windows.UTF16PtrFromString(commandLine) + if err != nil { + return nil, fmt.Errorf("convert command line to UTF16: %w", err) + } + + envPtr, err := convertEnvironmentToUTF16(userEnv) + if err != nil { + return nil, err + } + + var workingDirPtr *uint16 + if workingDir != "" { + workingDirPtr, err = windows.UTF16PtrFromString(workingDir) + if err != nil { + return nil, fmt.Errorf("convert working directory to UTF16: %w", err) + } + } + + siEx.StartupInfo.Flags |= windows.STARTF_USESTDHANDLES + siEx.StartupInfo.StdInput = windows.Handle(0) + siEx.StartupInfo.StdOutput = windows.Handle(0) + siEx.StartupInfo.StdErr = siEx.StartupInfo.StdOutput + + if userToken != windows.InvalidHandle { + err = windows.CreateProcessAsUser( + windows.Token(userToken), + nil, + commandLinePtr, + nil, + nil, + true, + creationFlags, + envPtr, + workingDirPtr, + &siEx.StartupInfo, + &pi, + ) + } else { + err = windows.CreateProcess( + nil, + commandLinePtr, + nil, + nil, + true, + creationFlags, + envPtr, + workingDirPtr, + &siEx.StartupInfo, + &pi, + ) + } + + if err != nil { + return nil, fmt.Errorf("create process: %w", err) + } + + return &pi, nil +} + +// convertEnvironmentToUTF16 converts environment variables to Windows UTF16 format. +func convertEnvironmentToUTF16(userEnv []string) (*uint16, error) { + if len(userEnv) == 0 { + // Return nil pointer for empty environment - Windows API will inherit parent environment + return nil, nil //nolint:nilnil // Intentional nil,nil for empty environment + } + + var envUTF16 []uint16 + for _, envVar := range userEnv { + if envVar != "" { + utf16Str, err := windows.UTF16FromString(envVar) + if err != nil { + log.Debugf("skipping invalid environment variable: %s (error: %v)", envVar, err) + continue + } + envUTF16 = append(envUTF16, utf16Str[:len(utf16Str)-1]...) + envUTF16 = append(envUTF16, 0) + } + } + envUTF16 = append(envUTF16, 0) + + if len(envUTF16) > 0 { + return &envUTF16[0], nil + } + // Return nil pointer when no valid environment variables found + return nil, nil //nolint:nilnil // Intentional nil,nil for empty environment +} + +// duplicateToPrimaryToken converts an impersonation token to a primary token. +func duplicateToPrimaryToken(token windows.Handle) (windows.Handle, error) { + var primaryToken windows.Handle + if err := windows.DuplicateTokenEx( + windows.Token(token), + windows.TOKEN_ALL_ACCESS, + nil, + windows.SecurityImpersonation, + windows.TokenPrimary, + (*windows.Token)(&primaryToken), + ); err != nil { + return 0, fmt.Errorf("duplicate token: %w", err) + } + return primaryToken, nil +} + +// SessionExiter provides the Exit method for reporting process exit status. +type SessionExiter interface { + Exit(code int) error +} + +// bridgeConPtyIO handles I/O bridging between ConPty and readers/writers. +func bridgeConPtyIO(ctx context.Context, hPty, inputWrite, outputRead windows.Handle, reader io.ReadCloser, writer io.Writer, session SessionExiter, process windows.Handle) error { + if err := ctx.Err(); err != nil { + return err + } + + var wg sync.WaitGroup + startIOBridging(ctx, &wg, inputWrite, outputRead, reader, writer) + + processErr := waitForProcess(ctx, process) + if processErr != nil { + return processErr + } + + var exitCode uint32 + if err := windows.GetExitCodeProcess(process, &exitCode); err != nil { + log.Debugf("get exit code: %v", err) + } else { + if err := session.Exit(int(exitCode)); err != nil { + log.Debugf("report exit code: %v", err) + } + } + + // Clean up in the original order after process completes + if err := reader.Close(); err != nil { + log.Debugf("close reader: %v", err) + } + + ret, _, err := procClosePseudoConsole.Call(uintptr(hPty)) + if ret == 0 { + log.Debugf("close ConPty handle: %v", err) + } + + wg.Wait() + + if err := windows.CloseHandle(outputRead); err != nil { + log.Debugf("close output read handle: %v", err) + } + + return nil +} + +// startIOBridging starts the I/O bridging goroutines. +func startIOBridging(ctx context.Context, wg *sync.WaitGroup, inputWrite, outputRead windows.Handle, reader io.ReadCloser, writer io.Writer) { + wg.Add(2) + + // Input: reader (SSH session) -> inputWrite (ConPty) + go func() { + defer wg.Done() + defer func() { + if err := windows.CloseHandle(inputWrite); err != nil { + log.Debugf("close input write handle in goroutine: %v", err) + } + }() + + if _, err := io.Copy(&windowsHandleWriter{handle: inputWrite}, reader); err != nil { + log.Debugf("input copy ended with error: %v", err) + } + }() + + // Output: outputRead (ConPty) -> writer (SSH session) + go func() { + defer wg.Done() + if _, err := io.Copy(writer, &windowsHandleReader{handle: outputRead}); err != nil { + log.Debugf("output copy ended with error: %v", err) + } + }() +} + +// waitForProcess waits for process completion with context cancellation. +func waitForProcess(ctx context.Context, process windows.Handle) error { + if _, err := windows.WaitForSingleObject(process, windows.INFINITE); err != nil { + return fmt.Errorf("wait for process %d: %w", process, err) + } + return nil +} + +// buildShellArgs builds shell arguments for ConPty execution. +func buildShellArgs(shell, command string) []string { + if command != "" { + return []string{shell, PowerShellCommandFlag, command} + } + return []string{shell} +} + +// buildCommandLine builds a Windows command line from arguments using proper escaping. +func buildCommandLine(args []string) string { + if len(args) == 0 { + return "" + } + + var result strings.Builder + for i, arg := range args { + if i > 0 { + result.WriteString(" ") + } + result.WriteString(syscall.EscapeArg(arg)) + } + return result.String() +} + +// closeHandles closes multiple Windows handles. +func closeHandles(handles ...windows.Handle) { + for _, handle := range handles { + if handle != windows.InvalidHandle { + if err := windows.CloseHandle(handle); err != nil { + log.Debugf("close handle: %v", err) + } + } + } +} + +// closeProcessInfo closes process and thread handles. +func closeProcessInfo(pi *windows.ProcessInformation) { + if pi != nil { + if err := windows.CloseHandle(pi.Process); err != nil { + log.Debugf("close process handle: %v", err) + } + if err := windows.CloseHandle(pi.Thread); err != nil { + log.Debugf("close thread handle: %v", err) + } + } +} + +// windowsHandleReader wraps a Windows handle for reading. +type windowsHandleReader struct { + handle windows.Handle +} + +func (r *windowsHandleReader) Read(p []byte) (n int, err error) { + var bytesRead uint32 + if err := windows.ReadFile(r.handle, p, &bytesRead, nil); err != nil { + return 0, err + } + return int(bytesRead), nil +} + +func (r *windowsHandleReader) Close() error { + return windows.CloseHandle(r.handle) +} + +// windowsHandleWriter wraps a Windows handle for writing. +type windowsHandleWriter struct { + handle windows.Handle +} + +func (w *windowsHandleWriter) Write(p []byte) (n int, err error) { + var bytesWritten uint32 + if err := windows.WriteFile(w.handle, p, &bytesWritten, nil); err != nil { + return 0, err + } + return int(bytesWritten), nil +} + +func (w *windowsHandleWriter) Close() error { + return windows.CloseHandle(w.handle) +} diff --git a/client/ssh/server/winpty/conpty_test.go b/client/ssh/server/winpty/conpty_test.go new file mode 100644 index 000000000..4f04e1fad --- /dev/null +++ b/client/ssh/server/winpty/conpty_test.go @@ -0,0 +1,290 @@ +//go:build windows + +package winpty + +import ( + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +func TestBuildShellArgs(t *testing.T) { + tests := []struct { + name string + shell string + command string + expected []string + }{ + { + name: "Shell with command", + shell: "powershell.exe", + command: "Get-Process", + expected: []string{"powershell.exe", "-Command", "Get-Process"}, + }, + { + name: "CMD with command", + shell: "cmd.exe", + command: "dir", + expected: []string{"cmd.exe", "-Command", "dir"}, + }, + { + name: "Shell interactive", + shell: "powershell.exe", + command: "", + expected: []string{"powershell.exe"}, + }, + { + name: "CMD interactive", + shell: "cmd.exe", + command: "", + expected: []string{"cmd.exe"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildShellArgs(tt.shell, tt.command) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestBuildCommandLine(t *testing.T) { + tests := []struct { + name string + args []string + expected string + }{ + { + name: "Simple args", + args: []string{"cmd.exe", "/c", "echo"}, + expected: "cmd.exe /c echo", + }, + { + name: "Args with spaces", + args: []string{"Program Files\\app.exe", "arg with spaces"}, + expected: `"Program Files\app.exe" "arg with spaces"`, + }, + { + name: "Args with quotes", + args: []string{"cmd.exe", "/c", `echo "hello world"`}, + expected: `cmd.exe /c "echo \"hello world\""`, + }, + { + name: "PowerShell calling PowerShell", + args: []string{"powershell.exe", "-Command", `powershell.exe -Command "Get-Process | Where-Object {$_.Name -eq 'notepad'}"`}, + expected: `powershell.exe -Command "powershell.exe -Command \"Get-Process | Where-Object {$_.Name -eq 'notepad'}\""`, + }, + { + name: "Complex nested quotes", + args: []string{"cmd.exe", "/c", `echo "He said \"Hello\" to me"`}, + expected: `cmd.exe /c "echo \"He said \\\"Hello\\\" to me\""`, + }, + { + name: "Path with spaces and args", + args: []string{`C:\Program Files\MyApp\app.exe`, "--config", `C:\My Config\settings.json`}, + expected: `"C:\Program Files\MyApp\app.exe" --config "C:\My Config\settings.json"`, + }, + { + name: "Empty argument", + args: []string{"cmd.exe", "/c", "echo", ""}, + expected: `cmd.exe /c echo ""`, + }, + { + name: "Argument with backslashes", + args: []string{"robocopy", `C:\Source\`, `C:\Dest\`, "/E"}, + expected: `robocopy C:\Source\ C:\Dest\ /E`, + }, + { + name: "Empty args", + args: []string{}, + expected: "", + }, + { + name: "Single arg with space", + args: []string{"path with spaces"}, + expected: `"path with spaces"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildCommandLine(tt.args) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestCreateConPtyPipes(t *testing.T) { + inputRead, inputWrite, outputRead, outputWrite, err := createConPtyPipes() + require.NoError(t, err, "Should create ConPty pipes successfully") + + // Verify all handles are valid + assert.NotEqual(t, windows.InvalidHandle, inputRead, "Input read handle should be valid") + assert.NotEqual(t, windows.InvalidHandle, inputWrite, "Input write handle should be valid") + assert.NotEqual(t, windows.InvalidHandle, outputRead, "Output read handle should be valid") + assert.NotEqual(t, windows.InvalidHandle, outputWrite, "Output write handle should be valid") + + // Clean up handles + closeHandles(inputRead, inputWrite, outputRead, outputWrite) +} + +func TestCreateConPty(t *testing.T) { + inputRead, inputWrite, outputRead, outputWrite, err := createConPtyPipes() + require.NoError(t, err, "Should create ConPty pipes successfully") + defer closeHandles(inputRead, inputWrite, outputRead, outputWrite) + + hPty, err := createConPty(80, 24, inputRead, outputWrite) + require.NoError(t, err, "Should create ConPty successfully") + assert.NotEqual(t, windows.InvalidHandle, hPty, "ConPty handle should be valid") + + // Clean up ConPty + ret, _, _ := procClosePseudoConsole.Call(uintptr(hPty)) + assert.NotEqual(t, uintptr(0), ret, "Should close ConPty successfully") +} + +func TestConvertEnvironmentToUTF16(t *testing.T) { + tests := []struct { + name string + userEnv []string + hasError bool + }{ + { + name: "Valid environment variables", + userEnv: []string{"PATH=C:\\Windows", "USER=testuser", "HOME=C:\\Users\\testuser"}, + hasError: false, + }, + { + name: "Empty environment", + userEnv: []string{}, + hasError: false, + }, + { + name: "Environment with empty strings", + userEnv: []string{"PATH=C:\\Windows", "", "USER=testuser"}, + hasError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := convertEnvironmentToUTF16(tt.userEnv) + if tt.hasError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + if len(tt.userEnv) == 0 { + assert.Nil(t, result, "Empty environment should return nil") + } else { + assert.NotNil(t, result, "Non-empty environment should return valid pointer") + } + } + }) + } +} + +func TestDuplicateToPrimaryToken(t *testing.T) { + if testing.Short() { + t.Skip("Skipping token tests in short mode") + } + + // Get current process token for testing + var token windows.Token + err := windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_ALL_ACCESS, &token) + require.NoError(t, err, "Should open current process token") + defer func() { + if err := windows.CloseHandle(windows.Handle(token)); err != nil { + t.Logf("Failed to close token: %v", err) + } + }() + + primaryToken, err := duplicateToPrimaryToken(windows.Handle(token)) + require.NoError(t, err, "Should duplicate token to primary") + assert.NotEqual(t, windows.InvalidHandle, primaryToken, "Primary token should be valid") + + // Clean up + err = windows.CloseHandle(primaryToken) + assert.NoError(t, err, "Should close primary token") +} + +func TestWindowsHandleReader(t *testing.T) { + // Create a pipe for testing + var readHandle, writeHandle windows.Handle + err := windows.CreatePipe(&readHandle, &writeHandle, nil, 0) + require.NoError(t, err, "Should create pipe for testing") + defer closeHandles(readHandle, writeHandle) + + // Write test data + testData := []byte("Hello, Windows Handle Reader!") + var bytesWritten uint32 + err = windows.WriteFile(writeHandle, testData, &bytesWritten, nil) + require.NoError(t, err, "Should write test data") + require.Equal(t, uint32(len(testData)), bytesWritten, "Should write all test data") + + // Close write handle to signal EOF + if err := windows.CloseHandle(writeHandle); err != nil { + t.Fatalf("Should close write handle: %v", err) + } + writeHandle = windows.InvalidHandle + + // Test reading + reader := &windowsHandleReader{handle: readHandle} + buffer := make([]byte, len(testData)) + n, err := reader.Read(buffer) + require.NoError(t, err, "Should read from handle") + assert.Equal(t, len(testData), n, "Should read expected number of bytes") + assert.Equal(t, testData, buffer, "Should read expected data") +} + +func TestWindowsHandleWriter(t *testing.T) { + // Create a pipe for testing + var readHandle, writeHandle windows.Handle + err := windows.CreatePipe(&readHandle, &writeHandle, nil, 0) + require.NoError(t, err, "Should create pipe for testing") + defer closeHandles(readHandle, writeHandle) + + // Test writing + testData := []byte("Hello, Windows Handle Writer!") + writer := &windowsHandleWriter{handle: writeHandle} + n, err := writer.Write(testData) + require.NoError(t, err, "Should write to handle") + assert.Equal(t, len(testData), n, "Should write expected number of bytes") + + // Close write handle + if err := windows.CloseHandle(writeHandle); err != nil { + t.Fatalf("Should close write handle: %v", err) + } + + // Verify data was written by reading it back + buffer := make([]byte, len(testData)) + var bytesRead uint32 + err = windows.ReadFile(readHandle, buffer, &bytesRead, nil) + require.NoError(t, err, "Should read back written data") + assert.Equal(t, uint32(len(testData)), bytesRead, "Should read back expected number of bytes") + assert.Equal(t, testData, buffer, "Should read back expected data") +} + +// BenchmarkConPtyCreation benchmarks ConPty creation performance +func BenchmarkConPtyCreation(b *testing.B) { + for i := 0; i < b.N; i++ { + inputRead, inputWrite, outputRead, outputWrite, err := createConPtyPipes() + if err != nil { + b.Fatal(err) + } + + hPty, err := createConPty(80, 24, inputRead, outputWrite) + if err != nil { + closeHandles(inputRead, inputWrite, outputRead, outputWrite) + b.Fatal(err) + } + + // Clean up + if ret, _, err := procClosePseudoConsole.Call(uintptr(hPty)); ret == 0 { + log.Debugf("ClosePseudoConsole failed: %v", err) + } + closeHandles(inputRead, inputWrite, outputRead, outputWrite) + } +} diff --git a/client/ssh/server_mock.go b/client/ssh/server_mock.go deleted file mode 100644 index 76f43fd4e..000000000 --- a/client/ssh/server_mock.go +++ /dev/null @@ -1,46 +0,0 @@ -//go:build !js - -package ssh - -import "context" - -// MockServer mocks ssh.Server -type MockServer struct { - Ctx context.Context - StopFunc func() error - StartFunc func() error - AddAuthorizedKeyFunc func(peer, newKey string) error - RemoveAuthorizedKeyFunc func(peer string) -} - -// RemoveAuthorizedKey removes SSH key of a given peer from the authorized keys -func (srv *MockServer) RemoveAuthorizedKey(peer string) { - if srv.RemoveAuthorizedKeyFunc == nil { - return - } - srv.RemoveAuthorizedKeyFunc(peer) -} - -// AddAuthorizedKey add a given peer key to server authorized keys -func (srv *MockServer) AddAuthorizedKey(peer, newKey string) error { - if srv.AddAuthorizedKeyFunc == nil { - return nil - } - return srv.AddAuthorizedKeyFunc(peer, newKey) -} - -// Stop stops SSH server. -func (srv *MockServer) Stop() error { - if srv.StopFunc == nil { - return nil - } - return srv.StopFunc() -} - -// Start starts SSH server. Blocking -func (srv *MockServer) Start() error { - if srv.StartFunc == nil { - return nil - } - return srv.StartFunc() -} diff --git a/client/ssh/server_test.go b/client/ssh/server_test.go deleted file mode 100644 index 1f310c2bb..000000000 --- a/client/ssh/server_test.go +++ /dev/null @@ -1,123 +0,0 @@ -//go:build !js - -package ssh - -import ( - "fmt" - "github.com/stretchr/testify/assert" - "golang.org/x/crypto/ssh" - "strings" - "testing" -) - -func TestServer_AddAuthorizedKey(t *testing.T) { - key, err := GeneratePrivateKey(ED25519) - if err != nil { - t.Fatal(err) - } - server, err := newDefaultServer(key, "localhost:") - if err != nil { - t.Fatal(err) - } - - // add multiple keys - keys := map[string][]byte{} - for i := 0; i < 10; i++ { - peer := fmt.Sprintf("%s-%d", "remotePeer", i) - remotePrivKey, err := GeneratePrivateKey(ED25519) - if err != nil { - t.Fatal(err) - } - remotePubKey, err := GeneratePublicKey(remotePrivKey) - if err != nil { - t.Fatal(err) - } - - err = server.AddAuthorizedKey(peer, string(remotePubKey)) - if err != nil { - t.Error(err) - } - keys[peer] = remotePubKey - } - - // make sure that all keys have been added - for peer, remotePubKey := range keys { - k, ok := server.authorizedKeys[peer] - assert.True(t, ok, "expecting remotePeer key to be found in authorizedKeys") - - assert.Equal(t, string(remotePubKey), strings.TrimSpace(string(ssh.MarshalAuthorizedKey(k)))) - } - -} - -func TestServer_RemoveAuthorizedKey(t *testing.T) { - key, err := GeneratePrivateKey(ED25519) - if err != nil { - t.Fatal(err) - } - server, err := newDefaultServer(key, "localhost:") - if err != nil { - t.Fatal(err) - } - - remotePrivKey, err := GeneratePrivateKey(ED25519) - if err != nil { - t.Fatal(err) - } - remotePubKey, err := GeneratePublicKey(remotePrivKey) - if err != nil { - t.Fatal(err) - } - - err = server.AddAuthorizedKey("remotePeer", string(remotePubKey)) - if err != nil { - t.Error(err) - } - - server.RemoveAuthorizedKey("remotePeer") - - _, ok := server.authorizedKeys["remotePeer"] - assert.False(t, ok, "expecting remotePeer's SSH key to be removed") -} - -func TestServer_PubKeyHandler(t *testing.T) { - key, err := GeneratePrivateKey(ED25519) - if err != nil { - t.Fatal(err) - } - server, err := newDefaultServer(key, "localhost:") - if err != nil { - t.Fatal(err) - } - - var keys []ssh.PublicKey - for i := 0; i < 10; i++ { - peer := fmt.Sprintf("%s-%d", "remotePeer", i) - remotePrivKey, err := GeneratePrivateKey(ED25519) - if err != nil { - t.Fatal(err) - } - remotePubKey, err := GeneratePublicKey(remotePrivKey) - if err != nil { - t.Fatal(err) - } - - remoteParsedPubKey, _, _, _, err := ssh.ParseAuthorizedKey(remotePubKey) - if err != nil { - t.Fatal(err) - } - - err = server.AddAuthorizedKey(peer, string(remotePubKey)) - if err != nil { - t.Error(err) - } - keys = append(keys, remoteParsedPubKey) - } - - for _, key := range keys { - accepted := server.publicKeyHandler(nil, key) - - assert.Truef(t, accepted, "expecting SSH connection to be accepted for a given SSH key %s", string(ssh.MarshalAuthorizedKey(key))) - } - -} diff --git a/client/ssh/util.go b/client/ssh/ssh.go similarity index 86% rename from client/ssh/util.go rename to client/ssh/ssh.go index a54a609bc..c0024c599 100644 --- a/client/ssh/util.go +++ b/client/ssh/ssh.go @@ -32,9 +32,8 @@ const RSA KeyType = "rsa" // RSAKeySize is a size of newly generated RSA key const RSAKeySize = 2048 -// GeneratePrivateKey creates RSA Private Key of specified byte size +// GeneratePrivateKey creates a private key of the specified type. func GeneratePrivateKey(keyType KeyType) ([]byte, error) { - var key crypto.Signer var err error switch keyType { @@ -59,7 +58,7 @@ func GeneratePrivateKey(keyType KeyType) ([]byte, error) { return pemBytes, nil } -// GeneratePublicKey returns the public part of the private key +// GeneratePublicKey returns the public part of the private key. func GeneratePublicKey(key []byte) ([]byte, error) { signer, err := gossh.ParsePrivateKey(key) if err != nil { @@ -70,20 +69,17 @@ func GeneratePublicKey(key []byte) ([]byte, error) { return []byte(strKey), nil } -// EncodePrivateKeyToPEM encodes Private Key from RSA to PEM format +// EncodePrivateKeyToPEM encodes a private key to PEM format. func EncodePrivateKeyToPEM(privateKey crypto.Signer) ([]byte, error) { mk, err := x509.MarshalPKCS8PrivateKey(privateKey) if err != nil { return nil, err } - // pem.Block privBlock := pem.Block{ Type: "PRIVATE KEY", Bytes: mk, } - - // Private key in PEM format privatePEM := pem.EncodeToMemory(&privBlock) return privatePEM, nil } diff --git a/client/ssh/testutil/user_helpers.go b/client/ssh/testutil/user_helpers.go new file mode 100644 index 000000000..8960d8dd0 --- /dev/null +++ b/client/ssh/testutil/user_helpers.go @@ -0,0 +1,173 @@ +package testutil + +import ( + "fmt" + "log" + "os" + "os/exec" + "os/user" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +var testCreatedUsers = make(map[string]bool) +var testUsersToCleanup []string + +// GetTestUsername returns an appropriate username for testing +func GetTestUsername(t *testing.T) string { + if runtime.GOOS == "windows" { + currentUser, err := user.Current() + require.NoError(t, err, "Should be able to get current user") + + if IsSystemAccount(currentUser.Username) { + if IsCI() { + if testUser := GetOrCreateTestUser(t); testUser != "" { + return testUser + } + } else { + if _, err := user.Lookup("Administrator"); err == nil { + return "Administrator" + } + if testUser := GetOrCreateTestUser(t); testUser != "" { + return testUser + } + } + } + return currentUser.Username + } + + currentUser, err := user.Current() + require.NoError(t, err, "Should be able to get current user") + return currentUser.Username +} + +// IsCI checks if we're running in a CI environment +func IsCI() bool { + if os.Getenv("GITHUB_ACTIONS") == "true" || os.Getenv("CI") == "true" { + return true + } + + hostname, err := os.Hostname() + if err == nil && strings.HasPrefix(hostname, "runner") { + return true + } + + return false +} + +// IsSystemAccount checks if the user is a system account that can't authenticate +func IsSystemAccount(username string) bool { + systemAccounts := []string{ + "system", + "NT AUTHORITY\\SYSTEM", + "NT AUTHORITY\\LOCAL SERVICE", + "NT AUTHORITY\\NETWORK SERVICE", + } + + for _, sysAccount := range systemAccounts { + if strings.EqualFold(username, sysAccount) { + return true + } + } + + return strings.HasSuffix(username, "$") +} + +// RegisterTestUserCleanup registers a test user for cleanup +func RegisterTestUserCleanup(username string) { + if !testCreatedUsers[username] { + testCreatedUsers[username] = true + testUsersToCleanup = append(testUsersToCleanup, username) + } +} + +// CleanupTestUsers removes all created test users +func CleanupTestUsers() { + for _, username := range testUsersToCleanup { + RemoveWindowsTestUser(username) + } + testUsersToCleanup = nil + testCreatedUsers = make(map[string]bool) +} + +// GetOrCreateTestUser creates a test user on Windows if needed +func GetOrCreateTestUser(t *testing.T) string { + testUsername := "netbird-test-user" + + if _, err := user.Lookup(testUsername); err == nil { + return testUsername + } + + if CreateWindowsTestUser(t, testUsername) { + RegisterTestUserCleanup(testUsername) + return testUsername + } + + return "" +} + +// RemoveWindowsTestUser removes a local user on Windows using PowerShell +func RemoveWindowsTestUser(username string) { + if runtime.GOOS != "windows" { + return + } + + psCmd := fmt.Sprintf(` + try { + Remove-LocalUser -Name "%s" -ErrorAction Stop + Write-Output "User removed successfully" + } catch { + if ($_.Exception.Message -like "*cannot be found*") { + Write-Output "User not found (already removed)" + } else { + Write-Error $_.Exception.Message + } + } + `, username) + + cmd := exec.Command("powershell", "-Command", psCmd) + output, err := cmd.CombinedOutput() + + if err != nil { + log.Printf("Failed to remove test user %s: %v, output: %s", username, err, string(output)) + } else { + log.Printf("Test user %s cleanup result: %s", username, string(output)) + } +} + +// CreateWindowsTestUser creates a local user on Windows using PowerShell +func CreateWindowsTestUser(t *testing.T, username string) bool { + if runtime.GOOS != "windows" { + return false + } + + psCmd := fmt.Sprintf(` + try { + $password = ConvertTo-SecureString "TestPassword123!" -AsPlainText -Force + New-LocalUser -Name "%s" -Password $password -Description "NetBird test user" -UserMayNotChangePassword -PasswordNeverExpires + Add-LocalGroupMember -Group "Users" -Member "%s" + Write-Output "User created successfully" + } catch { + if ($_.Exception.Message -like "*already exists*") { + Write-Output "User already exists" + } else { + Write-Error $_.Exception.Message + exit 1 + } + } + `, username, username) + + cmd := exec.Command("powershell", "-Command", psCmd) + output, err := cmd.CombinedOutput() + + if err != nil { + t.Logf("Failed to create test user: %v, output: %s", err, string(output)) + return false + } + + t.Logf("Test user creation result: %s", string(output)) + return true +} diff --git a/client/ssh/testutil/user_helpers_test.go b/client/ssh/testutil/user_helpers_test.go new file mode 100644 index 000000000..db2f5f06d --- /dev/null +++ b/client/ssh/testutil/user_helpers_test.go @@ -0,0 +1,115 @@ +package testutil + +import ( + "os/user" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestUserCurrentBehavior validates user.Current() behavior on Windows. +// When running as SYSTEM on a domain-joined machine, user.Current() returns: +// - Username: Computer account name (e.g., "DOMAIN\MACHINE$") +// - SID: SYSTEM SID (S-1-5-18) +func TestUserCurrentBehavior(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-specific test") + } + + currentUser, err := user.Current() + require.NoError(t, err, "Should be able to get current user") + + t.Logf("Current user - Username: %s, SID: %s", currentUser.Username, currentUser.Uid) + + // When running as SYSTEM, validate expected behavior + if currentUser.Uid == "S-1-5-18" { + t.Run("SYSTEM_account_behavior", func(t *testing.T) { + // SID must be S-1-5-18 for SYSTEM + require.Equal(t, "S-1-5-18", currentUser.Uid, + "SYSTEM account must have SID S-1-5-18") + + // Username can be either "NT AUTHORITY\SYSTEM" (standalone) + // or "DOMAIN\MACHINE$" (domain-joined) + username := currentUser.Username + isNTAuthority := strings.Contains(strings.ToUpper(username), "NT AUTHORITY") + isComputerAccount := strings.HasSuffix(username, "$") + + assert.True(t, isNTAuthority || isComputerAccount, + "Username should be either 'NT AUTHORITY\\SYSTEM' or computer account (ending with $), got: %s", + username) + + if isComputerAccount { + t.Logf("SYSTEM as computer account: %s", username) + } else if isNTAuthority { + t.Logf("SYSTEM as NT AUTHORITY\\SYSTEM") + } + }) + } + + // Validate that IsSystemAccount correctly identifies system accounts + t.Run("IsSystemAccount_validation", func(t *testing.T) { + // Test with current user if it's a system account + if currentUser.Uid == "S-1-5-18" || // SYSTEM + currentUser.Uid == "S-1-5-19" || // LOCAL SERVICE + currentUser.Uid == "S-1-5-20" { // NETWORK SERVICE + + result := IsSystemAccount(currentUser.Username) + assert.True(t, result, + "IsSystemAccount should recognize system account: %s (SID: %s)", + currentUser.Username, currentUser.Uid) + } + + // Test explicit cases + testCases := []struct { + username string + expected bool + reason string + }{ + {"NT AUTHORITY\\SYSTEM", true, "NT AUTHORITY\\SYSTEM"}, + {"system", true, "system"}, + {"SYSTEM", true, "SYSTEM (case insensitive)"}, + {"NT AUTHORITY\\LOCAL SERVICE", true, "LOCAL SERVICE"}, + {"NT AUTHORITY\\NETWORK SERVICE", true, "NETWORK SERVICE"}, + {"DOMAIN\\MACHINE$", true, "computer account (ends with $)"}, + {"WORKGROUP\\WIN2K19-C2$", true, "computer account (ends with $)"}, + {"Administrator", false, "Administrator is not a system account"}, + {"alice", false, "regular user"}, + {"DOMAIN\\alice", false, "domain user"}, + } + + for _, tc := range testCases { + t.Run(tc.username, func(t *testing.T) { + result := IsSystemAccount(tc.username) + assert.Equal(t, tc.expected, result, + "IsSystemAccount(%q) should be %v because: %s", + tc.username, tc.expected, tc.reason) + }) + } + }) +} + +// TestComputerAccountDetection validates computer account detection. +func TestComputerAccountDetection(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-specific test") + } + + computerAccounts := []string{ + "MACHINE$", + "WIN2K19-C2$", + "DOMAIN\\MACHINE$", + "WORKGROUP\\SERVER$", + "server.domain.com$", + } + + for _, account := range computerAccounts { + t.Run(account, func(t *testing.T) { + result := IsSystemAccount(account) + assert.True(t, result, + "Computer account %q should be recognized as system account", account) + }) + } +} diff --git a/client/ssh/window_freebsd.go b/client/ssh/window_freebsd.go deleted file mode 100644 index ef4848341..000000000 --- a/client/ssh/window_freebsd.go +++ /dev/null @@ -1,10 +0,0 @@ -//go:build freebsd - -package ssh - -import ( - "os" -) - -func setWinSize(file *os.File, width, height int) { -} diff --git a/client/ssh/window_unix.go b/client/ssh/window_unix.go deleted file mode 100644 index 2891eb70e..000000000 --- a/client/ssh/window_unix.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build linux || darwin - -package ssh - -import ( - "os" - "syscall" - "unsafe" -) - -func setWinSize(file *os.File, width, height int) { - syscall.Syscall(syscall.SYS_IOCTL, file.Fd(), uintptr(syscall.TIOCSWINSZ), //nolint - uintptr(unsafe.Pointer(&struct{ h, w, x, y uint16 }{uint16(height), uint16(width), 0, 0}))) -} diff --git a/client/ssh/window_windows.go b/client/ssh/window_windows.go deleted file mode 100644 index 5abd41f27..000000000 --- a/client/ssh/window_windows.go +++ /dev/null @@ -1,9 +0,0 @@ -package ssh - -import ( - "os" -) - -func setWinSize(file *os.File, width, height int) { - -} diff --git a/client/status/status.go b/client/status/status.go index 8a0b7bae0..e8276d0fa 100644 --- a/client/status/status.go +++ b/client/status/status.go @@ -11,8 +11,12 @@ import ( "strings" "time" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" "gopkg.in/yaml.v3" + "golang.org/x/exp/maps" + "github.com/netbirdio/netbird/client/anonymize" "github.com/netbirdio/netbird/client/internal/peer" probeRelay "github.com/netbirdio/netbird/client/internal/relay" @@ -21,9 +25,46 @@ import ( "github.com/netbirdio/netbird/version" ) +// DaemonStatus represents the current state of the NetBird daemon. +// These values mirror internal.StatusType but are defined here to avoid an import cycle. +type DaemonStatus string + +const ( + DaemonStatusIdle DaemonStatus = "Idle" + DaemonStatusConnecting DaemonStatus = "Connecting" + DaemonStatusConnected DaemonStatus = "Connected" + DaemonStatusNeedsLogin DaemonStatus = "NeedsLogin" + DaemonStatusLoginFailed DaemonStatus = "LoginFailed" + DaemonStatusSessionExpired DaemonStatus = "SessionExpired" +) + +// ParseDaemonStatus converts a raw status string to DaemonStatus. +// Unrecognized values are preserved as-is to remain visible during version skew. +func ParseDaemonStatus(s string) DaemonStatus { + return DaemonStatus(s) +} + +// ConvertOptions holds parameters for ConvertToStatusOutputOverview. +type ConvertOptions struct { + Anonymize bool + DaemonVersion string + DaemonStatus DaemonStatus + StatusFilter string + PrefixNamesFilter []string + PrefixNamesFilterMap map[string]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"` @@ -61,6 +102,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 { @@ -81,16 +123,32 @@ type NsServerGroupStateOutput struct { Error string `json:"error" yaml:"error"` } +type SSHSessionOutput struct { + Username string `json:"username" yaml:"username"` + RemoteAddress string `json:"remoteAddress" yaml:"remoteAddress"` + Command string `json:"command" yaml:"command"` + JWTUsername string `json:"jwtUsername,omitempty" yaml:"jwtUsername,omitempty"` + PortForwards []string `json:"portForwards,omitempty" yaml:"portForwards,omitempty"` +} + +type SSHServerStateOutput struct { + Enabled bool `json:"enabled" yaml:"enabled"` + Sessions []SSHSessionOutput `json:"sessions" yaml:"sessions"` +} + type OutputOverview struct { Peers PeersStateOutput `json:"peers" yaml:"peers"` CliVersion string `json:"cliVersion" yaml:"cliVersion"` DaemonVersion string `json:"daemonVersion" yaml:"daemonVersion"` + DaemonStatus DaemonStatus `json:"daemonStatus" yaml:"daemonStatus"` ManagementState ManagementStateOutput `json:"management" yaml:"management"` 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"` @@ -100,11 +158,16 @@ type OutputOverview struct { Events []SystemEventOutput `json:"events" yaml:"events"` 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"` } -func ConvertToStatusOutputOverview(resp *proto.StatusResponse, anon bool, statusFilter string, prefixNamesFilter []string, prefixNamesFilterMap map[string]struct{}, ipsFilter map[string]struct{}, connectionTypeFilter string, profName string) OutputOverview { - pbFullStatus := resp.GetFullStatus() - +// ConvertToStatusOutputOverview converts protobuf status to the output overview. +func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertOptions) OutputOverview { managementState := pbFullStatus.GetManagementState() managementOverview := ManagementStateOutput{ URL: managementState.GetURL(), @@ -120,18 +183,22 @@ func ConvertToStatusOutputOverview(resp *proto.StatusResponse, anon bool, status } relayOverview := mapRelays(pbFullStatus.GetRelays()) - peersOverview := mapPeers(resp.GetFullStatus().GetPeers(), statusFilter, prefixNamesFilter, prefixNamesFilterMap, ipsFilter, connectionTypeFilter) + sshServerOverview := mapSSHServer(pbFullStatus.GetSshServerState()) + peersOverview := mapPeers(pbFullStatus.GetPeers(), opts.StatusFilter, opts.PrefixNamesFilter, opts.PrefixNamesFilterMap, opts.IPsFilter, opts.ConnectionTypeFilter) overview := OutputOverview{ Peers: peersOverview, CliVersion: version.NetbirdVersion(), - DaemonVersion: resp.GetDaemonVersion(), + DaemonVersion: opts.DaemonVersion, + DaemonStatus: opts.DaemonStatus, ManagementState: managementOverview, 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(), @@ -140,10 +207,15 @@ func ConvertToStatusOutputOverview(resp *proto.StatusResponse, anon bool, status NSServerGroups: mapNSGroups(pbFullStatus.GetDnsServers()), Events: mapEvents(pbFullStatus.GetEvents()), LazyConnectionEnabled: pbFullStatus.GetLazyConnectionEnabled(), - ProfileName: profName, + ProfileName: opts.ProfileName, + SSHServerState: sshServerOverview, + } + if !opts.SessionExpiresAt.IsZero() { + t := opts.SessionExpiresAt + overview.SessionExpiresAt = &t } - if anon { + if opts.Anonymize { anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses()) anonymizeOverview(anonymizer, &overview) } @@ -161,7 +233,8 @@ func mapRelays(relays []*proto.RelayState) RelayStateOutput { RelayStateOutputDetail{ URI: relay.URI, Available: available, - Error: relay.GetError(), + Error: relayErrorString(relay.GetError()), + Transport: relay.GetTransport(), }, ) @@ -177,6 +250,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 { @@ -190,6 +269,31 @@ func mapNSGroups(servers []*proto.NSGroupState) []NsServerGroupStateOutput { return mappedNSGroups } +func mapSSHServer(sshServerState *proto.SSHServerState) SSHServerStateOutput { + if sshServerState == nil { + return SSHServerStateOutput{ + Enabled: false, + Sessions: []SSHSessionOutput{}, + } + } + + sessions := make([]SSHSessionOutput, 0, len(sshServerState.GetSessions())) + for _, session := range sshServerState.GetSessions() { + sessions = append(sessions, SSHSessionOutput{ + Username: session.GetUsername(), + RemoteAddress: session.GetRemoteAddress(), + Command: session.GetCommand(), + JWTUsername: session.GetJwtUsername(), + PortForwards: session.GetPortForwards(), + }) + } + + return SSHServerStateOutput{ + Enabled: sshServerState.GetEnabled(), + Sessions: sessions, + } +} + func mapPeers( peers []*proto.PeerState, statusFilter string, @@ -239,6 +343,7 @@ func mapPeers( timeLocal := pbPeerState.GetConnStatusUpdate().AsTime().Local() peerState := PeerStateDetailOutput{ IP: pbPeerState.GetIP(), + IPv6: pbPeerState.GetIpv6(), PubKey: pbPeerState.GetPubKey(), Status: pbPeerState.GetConnStatus(), LastStatusUpdate: timeLocal, @@ -284,61 +389,69 @@ func sortPeersByIP(peersStateDetail []PeerStateDetailOutput) { } } -func ParseToJSON(overview OutputOverview) (string, error) { - jsonBytes, err := json.Marshal(overview) +// JSON returns the status overview as a JSON string. +func (o *OutputOverview) JSON() (string, error) { + jsonBytes, err := json.Marshal(o) if err != nil { return "", fmt.Errorf("json marshal failed") } return string(jsonBytes), err } -func ParseToYAML(overview OutputOverview) (string, error) { - yamlBytes, err := yaml.Marshal(overview) +// YAML returns the status overview as a YAML string. +func (o *OutputOverview) YAML() (string, error) { + yamlBytes, err := yaml.Marshal(o) if err != nil { return "", fmt.Errorf("yaml marshal failed") } return string(yamlBytes), nil } -func ParseGeneralSummary(overview OutputOverview, showURL bool, showRelays bool, showNameServers bool) string { +// GeneralSummary returns a general summary of the status overview. +func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameServers bool, showSSHSessions bool) string { var managementConnString string - if overview.ManagementState.Connected { + if o.ManagementState.Connected { managementConnString = "Connected" if showURL { - managementConnString = fmt.Sprintf("%s to %s", managementConnString, overview.ManagementState.URL) + managementConnString = fmt.Sprintf("%s to %s", managementConnString, o.ManagementState.URL) } } else { managementConnString = "Disconnected" - if overview.ManagementState.Error != "" { - managementConnString = fmt.Sprintf("%s, reason: %s", managementConnString, overview.ManagementState.Error) + if o.ManagementState.Error != "" { + managementConnString = fmt.Sprintf("%s, reason: %s", managementConnString, o.ManagementState.Error) } } var signalConnString string - if overview.SignalState.Connected { + if o.SignalState.Connected { signalConnString = "Connected" if showURL { - signalConnString = fmt.Sprintf("%s to %s", signalConnString, overview.SignalState.URL) + signalConnString = fmt.Sprintf("%s to %s", signalConnString, o.SignalState.URL) } } else { signalConnString = "Disconnected" - if overview.SignalState.Error != "" { - signalConnString = fmt.Sprintf("%s, reason: %s", signalConnString, overview.SignalState.Error) + if o.SignalState.Error != "" { + signalConnString = fmt.Sprintf("%s, reason: %s", signalConnString, o.SignalState.Error) } } interfaceTypeString := "Userspace" - interfaceIP := overview.IP - if overview.KernelInterface { + interfaceIP := o.IP + if o.KernelInterface { interfaceTypeString = "Kernel" - } else if overview.IP == "" { + } else if o.IP == "" { interfaceTypeString = "N/A" interfaceIP = "N/A" } + ipv6Line := "" + if o.IPv6 != "" { + ipv6Line = fmt.Sprintf("NetBird IPv6: %s\n", o.IPv6) + } + var relaysString string if showRelays { - for _, relay := range overview.Relays.Details { + for _, relay := range o.Relays.Details { available := "Available" reason := "" @@ -349,23 +462,25 @@ func ParseGeneralSummary(overview OutputOverview, showURL bool, showRelays bool, 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) } } else { - relaysString = fmt.Sprintf("%d/%d Available", overview.Relays.Available, overview.Relays.Total) + relaysString = fmt.Sprintf("%d/%d Available", o.Relays.Available, o.Relays.Total) } networks := "-" - if len(overview.Networks) > 0 { - sort.Strings(overview.Networks) - networks = strings.Join(overview.Networks, ", ") + if len(o.Networks) > 0 { + sort.Strings(o.Networks) + networks = strings.Join(o.Networks, ", ") } var dnsServersString string if showNameServers { - for _, nsServerGroup := range overview.NSServerGroups { + for _, nsServerGroup := range o.NSServerGroups { enabled := "Available" if !nsServerGroup.Enabled { enabled = "Unavailable" @@ -389,23 +504,75 @@ func ParseGeneralSummary(overview OutputOverview, showURL bool, showRelays bool, ) } } else { - dnsServersString = fmt.Sprintf("%d/%d Available", countEnabled(overview.NSServerGroups), len(overview.NSServerGroups)) + dnsServersString = fmt.Sprintf("%d/%d Available", countEnabled(o.NSServerGroups), len(o.NSServerGroups)) } rosenpassEnabledStatus := "false" - if overview.RosenpassEnabled { + if o.RosenpassEnabled { rosenpassEnabledStatus = "true" - if overview.RosenpassPermissive { + if o.RosenpassPermissive { rosenpassEnabledStatus = "true (permissive)" //nolint:gosec } } lazyConnectionEnabledStatus := "false" - if overview.LazyConnectionEnabled { + if o.LazyConnectionEnabled { lazyConnectionEnabledStatus = "true" } - peersCountString := fmt.Sprintf("%d/%d Connected", overview.Peers.Connected, overview.Peers.Total) + sshServerStatus := "Disabled" + if o.SSHServerState.Enabled { + sessionCount := len(o.SSHServerState.Sessions) + if sessionCount > 0 { + sessionWord := "session" + if sessionCount > 1 { + sessionWord = "sessions" + } + sshServerStatus = fmt.Sprintf("Enabled (%d active %s)", sessionCount, sessionWord) + } else { + sshServerStatus = "Enabled" + } + + if showSSHSessions && sessionCount > 0 { + for _, session := range o.SSHServerState.Sessions { + var sessionDisplay string + if session.JWTUsername != "" { + sessionDisplay = fmt.Sprintf("[%s@%s -> %s] %s", + session.JWTUsername, + session.RemoteAddress, + session.Username, + session.Command, + ) + } else { + sessionDisplay = fmt.Sprintf("[%s@%s] %s", + session.Username, + session.RemoteAddress, + session.Command, + ) + } + sshServerStatus += "\n " + sessionDisplay + for _, pf := range session.PortForwards { + sshServerStatus += "\n " + pf + } + } + } + } + + 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) + } goos := runtime.GOOS goarch := runtime.GOARCH @@ -414,6 +581,21 @@ func ParseGeneralSummary(overview OutputOverview, showURL bool, showRelays bool, 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"+ @@ -425,36 +607,45 @@ func ParseGeneralSummary(overview OutputOverview, showURL bool, showRelays bool, "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"+ - "Forwarding rules: %d\n"+ + "%s"+ + "%s"+ "Peers count: %s\n", fmt.Sprintf("%s/%s%s", goos, goarch, goarm), - overview.DaemonVersion, - version.NetbirdVersion(), - overview.ProfileName, + daemonVersion, + cliVersion, + o.ProfileName, managementConnString, signalConnString, relaysString, dnsServersString, - domain.Domain(overview.FQDN).SafeString(), + domain.Domain(o.FQDN).SafeString(), interfaceIP, + ipv6Line, interfaceTypeString, + wgPortString, rosenpassEnabledStatus, lazyConnectionEnabledStatus, + sshServerStatus, networks, - overview.NumberOfForwardingRules, + forwardingRulesString, + sessionExpiryString, peersCountString, ) return summary } -func ParseToFullDetailSummary(overview OutputOverview) string { - parsedPeersString := parsePeers(overview.Peers, overview.RosenpassEnabled, overview.RosenpassPermissive) - parsedEventsString := parseEvents(overview.Events) - summary := ParseGeneralSummary(overview, true, true, true) +// FullDetailSummary returns a full detailed summary with peer details and events. +func (o *OutputOverview) FullDetailSummary() string { + parsedPeersString := parsePeers(o.Peers, o.RosenpassEnabled, o.RosenpassPermissive) + parsedEventsString := parseEvents(o.Events) + summary := o.GeneralSummary(true, true, true, true) return fmt.Sprintf( "Peers detail:"+ @@ -468,6 +659,98 @@ func ParseToFullDetailSummary(overview OutputOverview) string { ) } +func ToProtoFullStatus(fullStatus peer.FullStatus) *proto.FullStatus { + pbFullStatus := proto.FullStatus{ + ManagementState: &proto.ManagementState{}, + SignalState: &proto.SignalState{}, + LocalPeerState: &proto.LocalPeerState{}, + Peers: []*proto.PeerState{}, + } + + pbFullStatus.ManagementState.URL = fullStatus.ManagementState.URL + pbFullStatus.ManagementState.Connected = fullStatus.ManagementState.Connected + if err := fullStatus.ManagementState.Error; err != nil { + pbFullStatus.ManagementState.Error = err.Error() + } + + pbFullStatus.SignalState.URL = fullStatus.SignalState.URL + pbFullStatus.SignalState.Connected = fullStatus.SignalState.Connected + if err := fullStatus.SignalState.Error; err != nil { + pbFullStatus.SignalState.Error = err.Error() + } + + 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 + pbFullStatus.LocalPeerState.RosenpassPermissive = fullStatus.RosenpassState.Permissive + pbFullStatus.LocalPeerState.RosenpassEnabled = fullStatus.RosenpassState.Enabled + pbFullStatus.LocalPeerState.Networks = maps.Keys(fullStatus.LocalPeerState.Routes) + pbFullStatus.NumberOfForwardingRules = int32(fullStatus.NumOfForwardingRules) + pbFullStatus.LazyConnectionEnabled = fullStatus.LazyConnectionEnabled + + 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), + Relayed: peerState.Relayed, + LocalIceCandidateType: peerState.LocalIceCandidateType, + RemoteIceCandidateType: peerState.RemoteIceCandidateType, + LocalIceCandidateEndpoint: peerState.LocalIceCandidateEndpoint, + RemoteIceCandidateEndpoint: peerState.RemoteIceCandidateEndpoint, + RelayAddress: peerState.RelayServerAddress, + Fqdn: peerState.FQDN, + LastWireguardHandshake: timestamppb.New(peerState.LastWireguardHandshake), + BytesRx: peerState.BytesRx, + BytesTx: peerState.BytesTx, + RosenpassEnabled: peerState.RosenpassEnabled, + Networks: maps.Keys(peerState.GetRoutes()), + Latency: durationpb.New(peerState.Latency), + SshHostKey: peerState.SSHHostKey, + } + pbFullStatus.Peers = append(pbFullStatus.Peers, pbPeerState) + } + + for _, relayState := range fullStatus.Relays { + pbRelayState := &proto.RelayState{ + URI: relayState.URI, + Available: relayState.Err == nil, + } + if err := relayState.Err; err != nil { + pbRelayState.Error = err.Error() + } + pbFullStatus.Relays = append(pbFullStatus.Relays, pbRelayState) + } + + for _, dnsState := range fullStatus.NSGroupStates { + var err string + if dnsState.Error != nil { + err = dnsState.Error.Error() + } + + var servers []string + for _, server := range dnsState.Servers { + servers = append(servers, server.String()) + } + + pbDnsState := &proto.NSGroupState{ + Servers: servers, + Domains: dnsState.Domains, + Enabled: dnsState.Enabled, + Error: err, + } + pbFullStatus.DnsServers = append(pbFullStatus.DnsServers, pbDnsState) + } + + pbFullStatus.Events = fullStatus.Events + + return &pbFullStatus +} + func parsePeers(peers PeersStateOutput, rosenpassEnabled, rosenpassPermissive bool) string { var ( peersString = "" @@ -518,9 +801,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"+ @@ -536,6 +825,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, @@ -572,6 +862,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 } @@ -690,6 +983,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 { @@ -714,6 +1008,7 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) { overview.SignalState.Error = a.AnonymizeString(overview.SignalState.Error) 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) @@ -746,4 +1041,67 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) { event.Metadata[k] = a.AnonymizeString(v) } } + + for i, session := range overview.SSHServerState.Sessions { + if host, port, err := net.SplitHostPort(session.RemoteAddress); err == nil { + overview.SSHServerState.Sessions[i].RemoteAddress = fmt.Sprintf("%s:%s", a.AnonymizeIPString(host), port) + } else { + overview.SSHServerState.Sessions[i].RemoteAddress = a.AnonymizeIPString(session.RemoteAddress) + } + 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 660efd9ef..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", @@ -176,6 +180,7 @@ var overview = OutputOverview{ Events: []SystemEventOutput{}, CliVersion: version.NetbirdVersion(), DaemonVersion: "0.14.1", + DaemonStatus: DaemonStatusConnected, ManagementState: ManagementStateOutput{ URL: "my-awesome-management.com:443", Connected: true, @@ -203,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{ { @@ -231,10 +238,17 @@ var overview = OutputOverview{ Networks: []string{ "10.10.0.0/24", }, + SSHServerState: SSHServerStateOutput{ + Enabled: false, + Sessions: []SSHSessionOutput{}, + }, } func TestConversionFromFullStatusToOutputOverview(t *testing.T) { - convertedResult := ConvertToStatusOutputOverview(resp, false, "", nil, nil, nil, "", "") + convertedResult := ConvertToStatusOutputOverview(resp.GetFullStatus(), ConvertOptions{ + DaemonVersion: resp.GetDaemonVersion(), + DaemonStatus: ParseDaemonStatus(resp.GetStatus()), + }) assert.Equal(t, overview, convertedResult) } @@ -264,7 +278,7 @@ func TestSortingOfPeers(t *testing.T) { } func TestParsingToJSON(t *testing.T) { - jsonString, _ := ParseToJSON(overview) + jsonString, _ := overview.JSON() //@formatter:off expectedJSONString := ` @@ -276,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", @@ -325,6 +340,7 @@ func TestParsingToJSON(t *testing.T) { }, "cliVersion": "development", "daemonVersion": "0.14.1", + "daemonStatus": "Connected", "management": { "url": "my-awesome-management.com:443", "connected": true, @@ -352,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, @@ -385,7 +403,11 @@ func TestParsingToJSON(t *testing.T) { ], "events": [], "lazyConnectionEnabled": false, - "profileName":"" + "profileName":"", + "sshServer":{ + "enabled":false, + "sessions":[] + } }` // @formatter:on @@ -396,7 +418,7 @@ func TestParsingToJSON(t *testing.T) { } func TestParsingToYAML(t *testing.T) { - yaml, _ := ParseToYAML(overview) + yaml, _ := overview.YAML() expectedYAML := `peers: @@ -405,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 @@ -444,6 +467,7 @@ func TestParsingToYAML(t *testing.T) { networks: [] cliVersion: development daemonVersion: 0.14.1 +daemonStatus: Connected management: url: my-awesome-management.com:443 connected: true @@ -463,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 @@ -488,6 +514,9 @@ dnsServers: events: [] lazyConnectionEnabled: false profileName: "" +sshServer: + enabled: false + sessions: [] ` assert.Equal(t, expectedYAML, yaml) @@ -500,12 +529,13 @@ func TestParsingToDetail(t *testing.T) { lastConnectionUpdate2 := timeAgo(overview.Peers.Details[1].LastStatusUpdate) lastHandshake2 := timeAgo(overview.Peers.Details[1].LastWireguardHandshake) - detail := ParseToFullDetailSummary(overview) + detail := overview.FullDetailSummary() expectedDetail := fmt.Sprintf( `Peers detail: peer-1.awesome-domain.com: NetBird IP: 192.168.178.101 + NetBird IPv6: fd00::1 Public key: Pubkey1 Status: Connected -- detail -- @@ -551,19 +581,21 @@ 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 -Forwarding rules: 0 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) } func TestParsingToShortVersion(t *testing.T) { - shortVersion := ParseGeneralSummary(overview, false, false, false) + shortVersion := overview.GeneralSummary(false, false, false, false) expectedString := fmt.Sprintf("OS: %s/%s", runtime.GOOS, runtime.GOARCH) + ` Daemon version: 0.14.1 @@ -575,11 +607,13 @@ 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 Networks: 10.10.0.0/24 -Forwarding rules: 0 Peers count: 2/2 Connected ` @@ -613,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 a180be4c0..daeabca13 100644 --- a/client/system/info.go +++ b/client/system/info.go @@ -2,9 +2,11 @@ package system import ( "context" - "net" + "errors" "net/netip" + "slices" "strings" + "time" log "github.com/sirupsen/logrus" "google.golang.org/grpc/metadata" @@ -70,15 +72,24 @@ type Info struct { DisableFirewall bool BlockLANAccess bool BlockInbound bool + DisableIPv6 bool - LazyConnectionEnabled 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, ) { i.RosenpassEnabled = rosenpassEnabled i.RosenpassPermissive = rosenpassPermissive @@ -92,8 +103,42 @@ 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 + } + if enableSSHSFTP != nil { + i.EnableSSHSFTP = *enableSSHSFTP + } + if enableSSHLocalPortForwarding != nil { + i.EnableSSHLocalPortForwarding = *enableSSHLocalPortForwarding + } + if enableSSHRemotePortForwarding != nil { + i.EnableSSHRemotePortForwarding = *enableSSHRemotePortForwarding + } + if disableSSHAuth != nil { + i.DisableSSHAuth = *disableSSHAuth + } +} + +// 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 @@ -121,65 +166,17 @@ func extractDeviceName(ctx context.Context, defaultName string) string { return v } -func networkAddresses() ([]NetworkAddress, error) { - interfaces, err := net.Interfaces() - if err != nil { - return nil, err - } - - var netAddresses []NetworkAddress - for _, iface := range interfaces { - if iface.HardwareAddr.String() == "" { - continue - } - addrs, err := iface.Addrs() - if err != nil { - continue - } - - for _, address := range addrs { - ipNet, ok := address.(*net.IPNet) - if !ok { - continue - } - - if ipNet.IP.IsLoopback() { - continue - } - - netAddr := NetworkAddress{ - NetIP: netip.MustParsePrefix(ipNet.String()), - Mac: iface.HardwareAddr.String(), - } - - if isDuplicated(netAddresses, netAddr) { - continue - } - - netAddresses = append(netAddresses, netAddr) - } - } - return netAddresses, nil -} - -func isDuplicated(addresses []NetworkAddress, addr NetworkAddress) bool { - for _, duplicated := range addresses { - if duplicated.NetIP == addr.NetIP { - return true - } - } - return false -} - // 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 } @@ -187,7 +184,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 78895bfa8..3c71573bb 100644 --- a/client/system/info_android.go +++ b/client/system/info_android.go @@ -1,6 +1,3 @@ -//go:build android -// +build android - package system import ( @@ -53,7 +50,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 caa344737..e7bf367f6 100644 --- a/client/system/info_darwin.go +++ b/client/system/info_darwin.go @@ -1,5 +1,4 @@ //go:build !ios -// +build !ios package system @@ -33,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_freebsd.go b/client/system/info_freebsd.go index 8e1353151..755172842 100644 --- a/client/system/info_freebsd.go +++ b/client/system/info_freebsd.go @@ -43,18 +43,24 @@ func GetInfo(ctx context.Context) *Info { systemHostname, _ := os.Hostname() + addrs, err := networkAddresses() + if err != nil { + log.Warnf("failed to discover network addresses: %s", err) + } + return &Info{ - GoOS: runtime.GOOS, - Kernel: osInfo[0], - Platform: runtime.GOARCH, - OS: osName, - OSVersion: osVersion, - Hostname: extractDeviceName(ctx, systemHostname), - CPUs: runtime.NumCPU(), - NetbirdVersion: version.NetbirdVersion(), - UIVersion: extractUserAgent(ctx), - KernelVersion: osInfo[1], - Environment: env, + GoOS: runtime.GOOS, + Kernel: osInfo[0], + Platform: runtime.GOARCH, + OS: osName, + OSVersion: osVersion, + Hostname: extractDeviceName(ctx, systemHostname), + CPUs: runtime.NumCPU(), + NetbirdVersion: version.NetbirdVersion(), + UIVersion: extractUserAgent(ctx), + KernelVersion: osInfo[1], + NetworkAddresses: addrs, + Environment: env, } } diff --git a/client/system/info_ios.go b/client/system/info_ios.go index 705c37920..1b0c084b3 100644 --- a/client/system/info_ios.go +++ b/client/system/info_ios.go @@ -1,16 +1,17 @@ -//go:build ios -// +build ios - package system import ( "context" + "net" + "net/netip" "runtime" + log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/version" ) -// UpdateStaticInfoAsync is a no-op on Android as there is no static info to update +// UpdateStaticInfoAsync is a no-op on iOS as there is no static info to update func UpdateStaticInfoAsync() { // do nothing } @@ -18,11 +19,24 @@ func UpdateStaticInfoAsync() { // GetInfo retrieves and parses the system information func GetInfo(ctx context.Context) *Info { - // Convert fixed-size byte arrays to Go strings sysName := extractOsName(ctx, "sysName") swVersion := extractOsVersion(ctx, "swVersion") - gio := &Info{Kernel: sysName, OSVersion: swVersion, Platform: "unknown", OS: sysName, GoOS: runtime.GOOS, CPUs: runtime.NumCPU(), KernelVersion: swVersion} + addrs, err := networkAddresses() + if err != nil { + log.Warnf("failed to discover network addresses: %s", err) + } + + gio := &Info{ + Kernel: sysName, + OSVersion: swVersion, + Platform: "unknown", + OS: sysName, + GoOS: runtime.GOOS, + CPUs: runtime.NumCPU(), + KernelVersion: swVersion, + NetworkAddresses: addrs, + } gio.Hostname = extractDeviceName(ctx, "hostname") gio.NetbirdVersion = version.NetbirdVersion() gio.UIVersion = extractUserAgent(ctx) @@ -30,8 +44,68 @@ func GetInfo(ctx context.Context) *Info { return gio } +// networkAddresses returns the list of network addresses on iOS. +// On iOS, hardware (MAC) addresses are not available due to Apple's privacy +// restrictions (iOS returns a fixed 02:00:00:00:00:00 placeholder), so we +// leave Mac empty to match Android's behavior. We also skip the HardwareAddr +// check that other platforms use and filter out link-local addresses as they +// are not useful for posture checks. +func networkAddresses() ([]NetworkAddress, error) { + interfaces, err := net.Interfaces() + if err != nil { + return nil, err + } + + var netAddresses []NetworkAddress + for _, iface := range interfaces { + if iface.Flags&net.FlagUp == 0 { + continue + } + addrs, err := iface.Addrs() + if err != nil { + continue + } + + for _, address := range addrs { + netAddr, ok := toNetworkAddress(address) + if !ok { + continue + } + if isDuplicated(netAddresses, netAddr) { + continue + } + netAddresses = append(netAddresses, netAddr) + } + } + return netAddresses, nil +} + +func toNetworkAddress(address net.Addr) (NetworkAddress, bool) { + ipNet, ok := address.(*net.IPNet) + if !ok { + return NetworkAddress{}, false + } + if ipNet.IP.IsLoopback() || ipNet.IP.IsLinkLocalUnicast() || ipNet.IP.IsMulticast() { + return NetworkAddress{}, false + } + prefix, err := netip.ParsePrefix(ipNet.String()) + if err != nil { + return NetworkAddress{}, false + } + return NetworkAddress{NetIP: prefix, Mac: ""}, true +} + +func isDuplicated(addresses []NetworkAddress, addr NetworkAddress) bool { + for _, duplicated := range addresses { + if duplicated.NetIP == addr.NetIP { + return true + } + } + return false +} + // 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..f32532881 100644 --- a/client/system/info_js.go +++ b/client/system/info_js.go @@ -103,7 +103,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_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_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 new file mode 100644 index 000000000..44260a938 --- /dev/null +++ b/client/system/network_addr.go @@ -0,0 +1,68 @@ +//go:build !ios + +package system + +import ( + "net" + "net/netip" +) + +func networkAddresses() ([]NetworkAddress, error) { + interfaces, err := net.Interfaces() + if err != nil { + return nil, err + } + + var netAddresses []NetworkAddress + for _, iface := range interfaces { + if iface.Flags&net.FlagUp == 0 { + continue + } + if iface.HardwareAddr.String() == "" { + continue + } + addrs, err := iface.Addrs() + if err != nil { + continue + } + + mac := iface.HardwareAddr.String() + for _, address := range addrs { + netAddr, ok := toNetworkAddress(address, mac) + if !ok { + continue + } + if isDuplicated(netAddresses, netAddr) { + continue + } + netAddresses = append(netAddresses, netAddr) + } + } + return netAddresses, nil +} + +func toNetworkAddress(address net.Addr, mac string) (NetworkAddress, bool) { + ipNet, ok := address.(*net.IPNet) + if !ok { + return NetworkAddress{}, false + } + // 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()) + if err != nil { + return NetworkAddress{}, false + } + return NetworkAddress{NetIP: prefix, Mac: mac}, 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..a5f9c4279 --- /dev/null +++ b/client/system/network_addr_test.go @@ -0,0 +1,45 @@ +//go:build !ios + +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..9d0a6b935 100644 --- a/client/system/process_test.go +++ b/client/system/process_test.go @@ -1,15 +1,16 @@ 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 +30,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..d1945894d --- /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.25-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-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/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..28efe7cfd --- /dev/null +++ b/client/ui/authsession/service.go @@ -0,0 +1,116 @@ +//go:build !android && !ios && !freebsd && !js + +package authsession + +import ( + "context" + "time" + + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "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 + } + + req := &proto.RequestExtendAuthSessionRequest{} + if p.Hint != "" { + h := p.Hint + req.Hint = &h + } + + 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..162922579 --- /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.LoadPolicy()) + + 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..a487b8db0 --- /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.25-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..58fb64f76 --- /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: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/netbird.desktop b/client/ui/build/linux/netbird.desktop similarity index 54% rename from client/ui/build/netbird.desktop rename to client/ui/build/linux/netbird.desktop index b3a1b92dc..a81f3698a 100644 --- a/client/ui/build/netbird.desktop +++ b/client/ui/build/linux/netbird.desktop @@ -1,8 +1,9 @@ [Desktop Entry] Name=Netbird -Exec=/usr/bin/netbird-ui +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..a05daef62 --- /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 + - libwebkitgtk-6.0-4 + - xdg-utils + +# Distribution-specific overrides for different package formats +overrides: + # RPM packages for Fedora / RHEL / AlmaLinux / Rocky Linux + rpm: + depends: + - gtk4 + - webkitgtk6.0 + - 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/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 e580be56d..000000000 --- a/client/ui/client_ui.go +++ /dev/null @@ -1,1498 +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/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 = "**********" -) - -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, - }) - - // 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 { - 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.Warnf("another process is running with pid %d, exiting", pid) - return - } - - client.setDefaultFonts() - systray.Run(client.onTrayReady, client.onTrayExit) -} - -type cliFlags struct { - daemonAddr string - showSettings bool - showNetworks bool - showProfiles bool - showDebug bool - showLoginURL bool - errorMsg string - saveLogsInFile bool -} - -// 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.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.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) { - settingsChangeChan := make(chan fyne.Settings) - a.Settings().AddChangeListener(settingsChangeChan) - for range settingsChangeChan { - 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 - - 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 - - // 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 - - connected bool - update *version.Update - daemonVersion string - updateIndicationLock sync.Mutex - isUpdateIconActive bool - showNetworks bool - wNetworks fyne.Window - wProfiles fyne.Window - - eventManager *event.Manager - - exitNodeMu sync.Mutex - mExitNodeItems []menuHandler - exitNodeStates []exitNodeState - mExitNodeDeselectAll *systray.MenuItem - logFile string - wLoginURL fyne.Window - - 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 -} - -// 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, - update: version.NewUpdate("nb/client-ui"), - } - - 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() - } - - 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.wSettings.SetContent(s.getSettingsForm()) - s.wSettings.Resize(fyne.NewSize(600, 500)) - s.wSettings.SetFixedSize(true) - - s.getSrvConfig() - s.wSettings.Show() -} - -// getSettingsForm to embed it into settings window. -func (s *serviceClient) getSettingsForm() *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: "Quantum-Resistance", Widget: s.sRosenpassPermissive}, - {Text: "Interface Name", Widget: s.iInterfaceName}, - {Text: "Interface Port", Widget: s.iInterfacePort}, - {Text: "MTU", Widget: s.iMTU}, - {Text: "Management URL", Widget: s.iMngURL}, - {Text: "Pre-shared Key", Widget: s.iPreSharedKey}, - {Text: "Log File", Widget: s.iLogFile}, - {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}, - }, - SubmitText: "Save", - OnSubmit: func() { - // 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 s.iPreSharedKey.Text != "" && s.iPreSharedKey.Text != censoredPreSharedKey { - // validate preSharedKey if it added - if _, err := wgtypes.ParseKey(s.iPreSharedKey.Text); err != nil { - dialog.ShowError(fmt.Errorf("Invalid Pre-shared Key Value"), s.wSettings) - return - } - } - - port, err := strconv.ParseInt(s.iInterfacePort.Text, 10, 64) - if err != nil { - dialog.ShowError(errors.New("Invalid interface port"), s.wSettings) - return - } - - var mtu int64 - mtuText := strings.TrimSpace(s.iMTU.Text) - if mtuText != "" { - var err error - mtu, err = strconv.ParseInt(mtuText, 10, 64) - if err != nil { - dialog.ShowError(errors.New("Invalid MTU value"), s.wSettings) - return - } - if mtu < iface.MinMTU || mtu > iface.MaxMTU { - dialog.ShowError(fmt.Errorf("MTU must be between %d and %d bytes", iface.MinMTU, iface.MaxMTU), s.wSettings) - return - } - } - - iMngURL := strings.TrimSpace(s.iMngURL.Text) - - defer s.wSettings.Close() - - // Check if any settings have changed - if 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.managementURL = iMngURL - s.preSharedKey = s.iPreSharedKey.Text - s.mtu = uint16(mtu) - - currUser, err := user.Current() - if err != nil { - log.Errorf("get current user: %v", err) - return - } - - var req proto.SetConfigRequest - req.ProfileName = activeProf.Name - req.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 - - if s.iPreSharedKey.Text != censoredPreSharedKey { - req.OptionalPreSharedKey = &s.iPreSharedKey.Text - } - - conn, err := s.getSrvClient(failFastTimeout) - if err != nil { - log.Errorf("get client: %v", err) - dialog.ShowError(fmt.Errorf("Failed to connect to the service: %v", err), s.wSettings) - return - } - _, err = conn.SetConfig(s.ctx, &req) - if err != nil { - log.Errorf("set config: %v", err) - dialog.ShowError(fmt.Errorf("Failed to set configuration: %v", err), s.wSettings) - return - } - - go func() { - status, err := conn.Status(s.ctx, &proto.StatusRequest{}) - if err != nil { - log.Errorf("get service status: %v", err) - dialog.ShowError(fmt.Errorf("Failed to get service status: %v", err), s.wSettings) - 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) - dialog.ShowError(fmt.Errorf("Failed to reconnect: %v", err), s.wSettings) - return - } - } - }() - } - }, - OnCancel: func() { - s.wSettings.Close() - }, - } -} - -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 := conn.Up(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 = 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() - s.mNetworks.Enable() - go s.updateExitNodes() - systrayIconState = true - case status.Status == string(internal.StatusConnecting): - s.setConnectingStatus() - case status.Status != string(internal.StatusConnected) && s.mUp.Disabled(): - s.setDisconnectedStatus() - systrayIconState = false - } - - // the updater struct notify by the upgrades available only, but if meanwhile the daemon has successfully - // updated must reset the mUpdate visibility state - if s.daemonVersion != status.DaemonVersion { - s.mUpdate.Hide() - s.daemonVersion = status.DaemonVersion - - s.isUpdateIconActive = s.update.SetDaemonVersion(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() - 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", settingsMenuDescr) - 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", exitNodeMenuDescr) - 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() - - s.update.SetOnUpdateListener(s.onUpdateAvailable) - go func() { - s.getSrvConfig() - time.Sleep(100 * time.Millisecond) // To prevent race condition caused by systray not being fully initialized and ignoring setIcon - for { - err := s.updateStatus() - if err != nil { - log.Errorf("error while updating status: %v", err) - } - - // Check features periodically to handle daemon restarts - s.checkAndUpdateFeatures() - - 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() - } - }) - - go s.eventManager.Start(s.ctx) - go s.eventHandler.listen(s.ctx) -} - -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) { - 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 -} - -// 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() - s.mSettings.SetTooltip(settingsMenuDescr) - } 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 - } - - // Update settings menu based on current features - if features != nil && features.DisableUpdateSettings { - s.setSettingsEnabled(false) - } else { - s.setSettingsEnabled(true) - } - - // Update profile menu based on current features - if s.mProfile != nil { - if features != nil && features.DisableProfiles { - s.mProfile.setEnabled(false) - } else { - s.mProfile.setEnabled(true) - } - } -} - -// 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 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 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 - - return &config -} - -func (s *serviceClient) onUpdateAvailable() { - s.updateIndicationLock.Lock() - defer s.updateIndicationLock.Unlock() - - s.mUpdate.Show() - s.isUpdateIconActive = true - - if s.connected { - systray.SetTemplateIcon(iconUpdateConnectedMacOS, s.icUpdateConnected) - } else { - systray.SetTemplateIcon(iconUpdateDisconnectedMacOS, s.icUpdateDisconnected) - } -} - -// 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 332282c17..000000000 --- a/client/ui/const.go +++ /dev/null @@ -1,18 +0,0 @@ -package main - -const ( - settingsMenuDescr = "Settings of the application" - profilesMenuDescr = "Manage your profiles" - 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" - exitNodeMenuDescr = "Select exit node for routing traffic" - 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 bf9839dda..000000000 --- a/client/ui/debug.go +++ /dev/null @@ -1,736 +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/internal/profilemanager" - "github.com/netbirdio/netbird/client/proto" - nbstatus "github.com/netbirdio/netbird/client/status" - uptypes "github.com/netbirdio/netbird/upload-server/types" -) - -// Initial state for the debug collection -type debugInitialState struct { - wasDown 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 - } - - statusOutput, err := s.collectDebugData(conn, initialState, params, progressUI) - if err != nil { - handleError(progressUI, err.Error()) - return - } - - if err := s.createDebugBundleFromCollection(conn, params, statusOutput, progressUI); err != nil { - handleError(progressUI, err.Error()) - return - } - - s.restoreServiceState(conn, initialState) - - 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, -) error { - if state.wasDown { - if _, err := conn.Up(s.ctx, &proto.UpRequest{}); err != nil { - return fmt.Errorf("bring service up: %v", err) - } - 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 { - return fmt.Errorf("set log level to TRACE: %v", err) - } - log.Info("Log level set to TRACE for debug") - } - - if _, err := conn.Down(s.ctx, &proto.DownRequest{}); err != nil { - return fmt.Errorf("bring service down: %v", err) - } - time.Sleep(time.Second) - - if enablePersistence { - if _, err := conn.SetSyncResponsePersistence(s.ctx, &proto.SetSyncResponsePersistenceRequest{ - Enabled: true, - }); err != nil { - return fmt.Errorf("enable sync response persistence: %v", err) - } - log.Info("Sync response persistence enabled for debug") - } - - if _, err := conn.Up(s.ctx, &proto.UpRequest{}); err != nil { - return fmt.Errorf("bring service back up: %v", err) - } - time.Sleep(time.Second * 3) - - return nil -} - -func (s *serviceClient) collectDebugData( - conn proto.DaemonServiceClient, - state *debugInitialState, - params *debugCollectionParams, - progress *progressUI, -) (string, error) { - ctx, cancel := context.WithTimeout(s.ctx, params.duration) - defer cancel() - var wg sync.WaitGroup - startProgressTracker(ctx, &wg, params.duration, progress) - - if err := s.configureServiceForDebug(conn, state, params.enablePersistence); err != nil { - return "", err - } - - pm := profilemanager.NewProfileManager() - var profName string - if activeProf, err := pm.GetActiveProfile(); err == nil { - profName = activeProf.Name - } - - postUpStatus, err := conn.Status(s.ctx, &proto.StatusRequest{GetFullPeerStatus: true}) - if err != nil { - log.Warnf("Failed to get post-up status: %v", err) - } - - var postUpStatusOutput string - if postUpStatus != nil { - overview := nbstatus.ConvertToStatusOutputOverview(postUpStatus, params.anonymize, "", nil, nil, nil, "", profName) - postUpStatusOutput = nbstatus.ParseToFullDetailSummary(overview) - } - headerPostUp := fmt.Sprintf("----- NetBird post-up - Timestamp: %s", time.Now().Format(time.RFC3339)) - statusOutput := fmt.Sprintf("%s\n%s", headerPostUp, postUpStatusOutput) - - wg.Wait() - progress.progressBar.Hide() - progress.statusLabel.SetText("Collecting debug data...") - - preDownStatus, err := conn.Status(s.ctx, &proto.StatusRequest{GetFullPeerStatus: true}) - if err != nil { - log.Warnf("Failed to get pre-down status: %v", err) - } - - var preDownStatusOutput string - if preDownStatus != nil { - overview := nbstatus.ConvertToStatusOutputOverview(preDownStatus, params.anonymize, "", nil, nil, nil, "", profName) - preDownStatusOutput = nbstatus.ParseToFullDetailSummary(overview) - } - headerPreDown := fmt.Sprintf("----- NetBird pre-down - Timestamp: %s - Duration: %s", - time.Now().Format(time.RFC3339), params.duration) - statusOutput = fmt.Sprintf("%s\n%s\n%s", statusOutput, headerPreDown, preDownStatusOutput) - - return statusOutput, nil -} - -// Create the debug bundle with collected data -func (s *serviceClient) createDebugBundleFromCollection( - conn proto.DaemonServiceClient, - params *debugCollectionParams, - statusOutput string, - progress *progressUI, -) error { - progress.statusLabel.SetText("Creating debug bundle with collected logs...") - - request := &proto.DebugBundleRequest{ - Anonymize: params.anonymize, - Status: statusOutput, - 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(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.wasDown { - if _, err := conn.Down(s.ctx, &proto.DownRequest{}); err != nil { - log.Errorf("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.Errorf("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(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) - } - - pm := profilemanager.NewProfileManager() - var profName string - if activeProf, err := pm.GetActiveProfile(); err == nil { - profName = activeProf.Name - } - - statusResp, err := conn.Status(s.ctx, &proto.StatusRequest{GetFullPeerStatus: true}) - if err != nil { - log.Warnf("failed to get status for debug bundle: %v", err) - } - - var statusOutput string - if statusResp != nil { - overview := nbstatus.ConvertToStatusOutputOverview(statusResp, anonymize, "", nil, nil, nil, "", profName) - statusOutput = nbstatus.ParseToFullDetailSummary(overview) - } - - request := &proto.DebugBundleRequest{ - Anonymize: anonymize, - Status: statusOutput, - 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(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() { - w.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 4d949416d..000000000 --- a/client/ui/event/event.go +++ /dev/null @@ -1,176 +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() - - // critical events are always shown - if !enabled && event.Severity != proto.SystemEvent_CRITICAL { - return - } - - if event.UserMessage != "" { - 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 e0b619411..000000000 --- a/client/ui/event_handler.go +++ /dev/null @@ -1,287 +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() - } - } -} - -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() - - 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", "true") - }() -} - -func (h *eventHandler) handleCreateDebugBundleClick() { - h.client.mCreateDebugBundle.Disable() - go func() { - defer h.client.mCreateDebugBundle.Enable() - h.runSelfCommand(h.client.ctx, "debug", "true") - }() -} - -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() { - if err := openURL(version.DownloadUrl()); err != nil { - log.Errorf("failed to open download URL: %v", err) - } -} - -func (h *eventHandler) handleNetworksClick() { - h.client.mNetworks.Disable() - go func() { - defer h.client.mNetworks.Enable() - h.runSelfCommand(h.client.ctx, "networks", "true") - }() -} - -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, arg string) { - proc, err := os.Executable() - if err != nil { - log.Errorf("error getting executable path: %v", err) - return - } - - cmd := exec.CommandContext(ctx, proc, - fmt.Sprintf("--%s=%s", command, arg), - fmt.Sprintf("--daemon-addr=%s", h.client.addr), - ) - - 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 --%s=%s --daemon-addr=%s", proc, command, arg, h.client.addr) - - if err := cmd.Run(); err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - log.Printf("command '%s %s' failed with exit code %d", command, arg, exitErr.ExitCode()) - } - return - } - - log.Printf("command '%s %s' completed successfully", command, arg) -} - -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 93b23a21b..000000000 --- a/client/ui/font_windows.go +++ /dev/null @@ -1,91 +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", - "ja-JP": "Yugothm.ttc", - "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..3131b36cd --- /dev/null +++ b/client/ui/frontend/package.json @@ -0,0 +1,69 @@ +{ + "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" + }, + "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..1b2a87da4 --- /dev/null +++ b/client/ui/frontend/src/components/CopyToClipboard.tsx @@ -0,0 +1,126 @@ +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; + variant?: CopyToClipboardVariant; + "aria-label"?: string; + tabIndex?: number; + onKeyDown?: (e: KeyboardEvent) => void; +}; + +export const CopyToClipboard = ({ + children, + message, + size = 10, + iconAlignment = "right", + className, + iconClassName, + alwaysShowIcon = 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/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..a0a131fbf --- /dev/null +++ b/client/ui/frontend/src/contexts/DebugBundleContext.tsx @@ -0,0 +1,314 @@ +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; + anonymize: boolean; + systemInfo: boolean; +}; + +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.anonymize, + 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 [anonymize, setAnonymize] = useState(false); + 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 : "", + anonymize, + 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 { + anonymize, + setAnonymize, + 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..37627bc76 --- /dev/null +++ b/client/ui/frontend/src/contexts/SettingsContext.tsx @@ -0,0 +1,267 @@ +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 { 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 }; + +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; + 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); + + useEffect(() => { + loadedRef.current = loaded; + }, [loaded]); + + 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; + if (saveTimer.current) 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 { + await SettingsSvc.SetConfig({ + ...next, + ...preSharedKeyWrite, + profileName, + username, + }); + } catch (e) { + await errorDialog({ + Title: i18next.t("settings.error.saveTitle"), + Message: errorMessage(e), + }); + } + }, + [username], + ); + + 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], + ); + + 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, saveNow }; +}; + +export const SettingsProvider = ({ children }: { children: ReactNode }) => { + const { config, guiVersion, setField, saveField, saveFields, saveNow } = useSettingsState(); + + const value = useMemo( + () => (config ? { config, guiVersion, setField, saveField, saveFields, saveNow } : null), + [config, guiVersion, setField, saveField, saveFields, 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/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/layouts/AppLayout.tsx b/client/ui/frontend/src/layouts/AppLayout.tsx new file mode 100644 index 000000000..1588d9d08 --- /dev/null +++ b/client/ui/frontend/src/layouts/AppLayout.tsx @@ -0,0 +1,27 @@ +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"; + +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..fca03fc87 --- /dev/null +++ b/client/ui/frontend/src/lib/connection.ts @@ -0,0 +1,128 @@ +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 }, + 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: "" }, + { 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..34a90dace --- /dev/null +++ b/client/ui/frontend/src/lib/errors.ts @@ -0,0 +1,60 @@ +import { WindowManager } from "@bindings/services"; + +type ClassifiedError = { short: string; long: 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 } 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 : ""; + return short || long ? { short, long } : null; +}; + +export const formatErrorMessage = (e: unknown): string => { + const envelope = toWailsEnvelope(e); + + // Prefer the structured { short, long } the daemon classifier produced. + const classified = toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope); + 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 message = envelope?.message; + if (typeof message === "string" && message) return message; + if (e instanceof Error) return e.message; + return String(e); +}; + +export type ErrorDialogOptions = { + Title: string; + Message: string; +}; + +export function errorDialog(options: ErrorDialogOptions): Promise { + return WindowManager.OpenError(options.Title, options.Message); +} 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..4fb5e2586 --- /dev/null +++ b/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx @@ -0,0 +1,96 @@ +import { type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Browser } from "@wailsio/runtime"; +import { DownloadIcon, NotepadText } from "lucide-react"; +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"); + }); +} + +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..f7929fad2 --- /dev/null +++ b/client/ui/frontend/src/modules/error/ErrorDialog.tsx @@ -0,0 +1,64 @@ +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 { 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; + +export default function ErrorDialog() { + const { t } = useTranslation(); + const contentRef = useAutoSizeWindow(WINDOW_WIDTH); + const [params] = useSearchParams(); + + const title = params.get("title") || t("window.title.error"); + const message = params.get("message") || ""; + + 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 && ( + + {message} + + )} +
                                                + + + + +
                                                + ); +} 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..10e71babb --- /dev/null +++ b/client/ui/frontend/src/modules/session/SessionExpirationDialog.tsx @@ -0,0 +1,205 @@ +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 } 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; + +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 [remaining, setRemaining] = useState(initialSeconds); + const [busy, setBusy] = useState(false); + const busyRef = useRef(busy); + busyRef.current = busy; + 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); + }, [initialSeconds]); + + useEffect(() => { + const id = globalThis.setInterval(() => { + setRemaining((s) => (s <= 1 ? 0 : s - 1)); + }, 1000); + return () => globalThis.clearInterval(id); + }, [initialSeconds]); + + // Don't auto-close while busy (aborts our WaitExtend) or expired (hides the state). + useEffect(() => { + const off = Events.On("netbird:status", (ev: { data: { status?: string } }) => { + if (busyRef.current || expiredRef.current) return; + if (ev?.data?.status === "Connected") { + WindowManager.CloseSessionExpiration().catch(console.error); + } + }); + 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 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..05d40e15c --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx @@ -0,0 +1,113 @@ +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"; + +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 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) && ( + + )} + + + {!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..f18fd9493 --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsSSH.tsx @@ -0,0 +1,119 @@ +import { useTranslation } from "react-i18next"; +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 { useSettings } from "@/contexts/SettingsContext.tsx"; +import { type ChangeEvent, useEffect, useId, useState } from "react"; + +export function SettingsSSH() { + const { t } = useTranslation(); + const { config, setField } = useSettings(); + const isSSHServerEnabled = config.serverSshAllowed; + 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 ( + <> + + setField("serverSshAllowed", v)} + label={t("settings.ssh.server.label")} + helpText={t("settings.ssh.server.help")} + /> + + + + setField("enableSshRoot", v)} + label={t("settings.ssh.root.label")} + helpText={t("settings.ssh.root.help")} + /> + 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")} + /> + + + + setField("disableSshAuth", !v)} + label={t("settings.ssh.jwt.label")} + helpText={t("settings.ssh.jwt.help")} + /> +
                                                +
                                                + + {t("settings.ssh.jwtTtl.help")} +
                                                +
                                                + +
                                                +
                                                +
                                                + + ); +} 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..991937719 --- /dev/null +++ b/client/ui/frontend/src/modules/settings/SettingsTroubleshooting.tsx @@ -0,0 +1,337 @@ +import { useId, type ReactNode } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { CircleCheckBig, FolderOpen, 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 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 { formatRemaining } from "@/lib/formatters"; +import type { 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 { + anonymize, + setAnonymize, + 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.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..c8e3aed76 --- /dev/null +++ b/client/ui/grpc.go @@ -0,0 +1,67 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "fmt" + "runtime" + "strings" + "sync" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/backoff" + "google.golang.org/grpc/credentials/insecure" + + "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 + } + + cc, err := grpc.NewClient( + strings.TrimPrefix(c.addr, "tcp://"), + grpc.WithTransportCredentials(insecure.NewCredentials()), + 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, + }, + }), + ) + 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, TCP loopback on Windows. +func DaemonAddr() string { + if runtime.GOOS == "windows" { + return "tcp://127.0.0.1:41731" + } + 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..88cbb8b11 --- /dev/null +++ b/client/ui/i18n/TRANSLATING.md @@ -0,0 +1,133 @@ +# Translating the NetBird UI + +A short brief for translating the desktop UI — for any translator, human or AI agent (*"you"* = whoever's translating). + +**Drive an agent with:** *"Read `i18n/TRANSLATING.md` and translate the UI to Russian"* — or *"…and review the existing German translation."* + +> 💡 **The one habit that matters most:** read each key's `description` before translating it. Labels are terse and ambiguous on their own; the `description` tells you what the string is, where it shows up, what to keep verbatim, and what it actually means. + +--- + +## 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 | + +--- + +## The files + +``` +i18n/locales/_index.json shipped-language list +i18n/locales/en/common.json source of truth — message + description +i18n/locales//common.json a target — message only +``` + +Chrome-extension JSON, each key → `{ "message", "description" }`. You translate the **`message`**. + +| ✅ Do | ❌ Don't | +|---|---| +| Keep **every key** from `en`, in the same order | Translate, rename, reorder, drop, or add keys (they're identifiers; the set grows over time) | +| Put **only `message`** in target bundles | Copy `description` into a target bundle | +| Give every key a non-empty `message` | Leave keys missing or empty | +| Save valid UTF-8 JSON, no BOM | Add trailing commas or break the JSON | + +--- + +## 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 description 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 bundles:** 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. + +--- + +## 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 bundle 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. + +--- + +## Procedure + +**New language** — read `en/common.json` *with* descriptions → settle your Tier C terms → write `i18n/locales//common.json` (same keys and order as `en`, `message` only, placeholders & brands preserved) → add a row to `_index.json` (`{"code","displayName"` = native name`,"englishName"}`) → run the QA list. Use the locale-code style the existing entries use (e.g. `fr`, `pt`, `zh-CN`). + +**Review (de / hu / …)** — 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`. Fix in place, then report what you changed (especially term standardizations) so a native speaker can sanity-check. + +--- + +## QA before you finish + +- [ ] Valid JSON · **every `en` key** present, same order · **no `description`** fields +- [ ] Every `{placeholder}`, `\n`, and intentional space preserved · `...` / `… Failed` / `{name}` quotes kept +- [ ] Tier A/B left intact · Tier C applied consistently (and matching the existing bundle for your language) +- [ ] Buttons & tray short · locale punctuation and capitalization applied +- [ ] New language added to `_index.json` +- [ ] **Tested in the running app** ↓ + +--- + +## Test it in the app + +A bundle 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/locales/_index.json b/client/ui/i18n/locales/_index.json new file mode 100644 index 000000000..419358d36 --- /dev/null +++ b/client/ui/i18n/locales/_index.json @@ -0,0 +1,14 @@ +{ + "languages": [ + {"code": "en", "displayName": "English (US)", "englishName": "English (US)"}, + {"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..5e91e8d88 --- /dev/null +++ b/client/ui/i18n/locales/de/common.json @@ -0,0 +1,1334 @@ +{ + "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" + }, + "settings.nav.label": { + "message": "Einstellungsbereiche" + }, + "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.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.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": "Versteckt öffentliche IP-Adressen und nicht-NetBird-Domains in Logs." + }, + "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." + } +} diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json new file mode 100644 index 000000000..24bbc67ce --- /dev/null +++ b/client/ui/i18n/locales/en/common.json @@ -0,0 +1,1778 @@ +{ + "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.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": "Toggle label: anonymize sensitive information in the bundle." + }, + "settings.troubleshooting.anonymize.help": { + "message": "Hides public IP addresses and non-NetBird domains from logs.", + "description": "Helper text for anonymizing logs (hides public IPs and non-NetBird domains)." + }, + "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." + } +} diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json new file mode 100644 index 000000000..c036e4f75 --- /dev/null +++ b/client/ui/i18n/locales/es/common.json @@ -0,0 +1,1334 @@ +{ + "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" + }, + "settings.nav.label": { + "message": "Secciones de configuración" + }, + "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.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.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 las direcciones IP públicas y los dominios ajenos a NetBird de los registros." + }, + "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ó." + } +} diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json new file mode 100644 index 000000000..c6b91fb25 --- /dev/null +++ b/client/ui/i18n/locales/fr/common.json @@ -0,0 +1,1334 @@ +{ + "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" + }, + "settings.nav.label": { + "message": "Sections des paramètres" + }, + "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.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.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 publiques et les domaines non-NetBird dans les journaux." + }, + "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é." + } +} diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json new file mode 100644 index 000000000..dd5a1af6c --- /dev/null +++ b/client/ui/i18n/locales/hu/common.json @@ -0,0 +1,1334 @@ +{ + "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" + }, + "settings.nav.label": { + "message": "Beállítások szakaszai" + }, + "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.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.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 a nyilvános IP-címeket és a nem-NetBird tartományokat a naplókban." + }, + "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." + } +} diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json new file mode 100644 index 000000000..7a2eb610c --- /dev/null +++ b/client/ui/i18n/locales/it/common.json @@ -0,0 +1,1334 @@ +{ + "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" + }, + "settings.nav.label": { + "message": "Sezioni delle impostazioni" + }, + "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.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.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 gli indirizzi IP pubblici e i domini non NetBird dai log." + }, + "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." + } +} diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json new file mode 100644 index 000000000..326c825bf --- /dev/null +++ b/client/ui/i18n/locales/ja/common.json @@ -0,0 +1,1331 @@ +{ + "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.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 アドレスと NetBird 以外のドメインを隠します。" + }, + "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 サービスを更新してください。" + }, + "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": "操作に失敗しました。" + } +} diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json new file mode 100644 index 000000000..37b02d5a8 --- /dev/null +++ b/client/ui/i18n/locales/pt/common.json @@ -0,0 +1,1334 @@ +{ + "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" + }, + "settings.nav.label": { + "message": "Seções das configurações" + }, + "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.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.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 públicos e domínios que não são do NetBird nos logs." + }, + "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." + } +} diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json new file mode 100644 index 000000000..b9ae59df2 --- /dev/null +++ b/client/ui/i18n/locales/ru/common.json @@ -0,0 +1,1334 @@ +{ + "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": "Переключить все видимые ресурсы" + }, + "settings.nav.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.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.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-адреса и сторонние (не относящиеся к NetBird) домены в журналах." + }, + "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": "Не удалось выполнить операцию." + } +} 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..2141a770d --- /dev/null +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -0,0 +1,1334 @@ +{ + "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": "切换所有可见资源" + }, + "settings.nav.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.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.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 地址和非 NetBird 域名。" + }, + "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": "操作失败。" + } +} diff --git a/client/ui/icons.go b/client/ui/icons.go index e88fb9378..5ab19ca05 100644 --- a/client/ui/icons.go +++ b/client/ui/icons.go @@ -1,13 +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 +// 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 @@ -18,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 @@ -41,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 2107d3852..000000000 --- a/client/ui/icons_windows.go +++ /dev/null @@ -1,41 +0,0 @@ -package main - -import ( - _ "embed" -) - -//go:embed assets/netbird.ico -var iconAbout []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..4889bad79 --- /dev/null +++ b/client/ui/main.go @@ -0,0 +1,390 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "embed" + "flag" + "io/fs" + "log" + "runtime" + "strings" + + "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/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 *notifications.NotificationService + 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() { + 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() + } + }) + + settings := services.NewSettings(conn) + 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 := notifications.New() + 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. + 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, + }) + + window := newMainWindow(app, prefStore) + + // Settings is created eagerly (hidden) so the first gear click paints + // instantly and React keeps per-tab state across reopens. The other + // auxiliary windows stay lazy + destroy-on-close so Wails's macOS + // dock-reopen handler can't resurrect them. + windowManager := services.NewWindowManager(app, window, bundle, prefStore, iconWindow) + // 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, window, TrayServices{ + Connection: connection, + Settings: settings, + Profiles: profiles, + Networks: networks, + DaemonFeed: daemonFeed, + Notifier: notifier, + Update: update, + ProfileSwitcher: profileSwitcher, + WindowManager: windowManager, + Session: authSession, + Localizer: localizer, + }) + 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 *notifications.NotificationService) { + 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", + }, + 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)) +} + +// newMainWindow creates the hidden main window, sized to the user's last view +// mode, and installs the hide-on-close and macOS dock-reopen hooks. +func newMainWindow(app *application.App, prefStore *preferences.Store) *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: "/", + DisableResize: true, + MinimiseButtonState: application.ButtonHidden, + MaximiseButtonState: application.ButtonHidden, + Mac: services.AppleMacOSAppearanceOptions(), + Windows: services.MicrosoftWindowsAppearanceOptions(), + Linux: application.LinuxWindow{ + Icon: iconWindow, + }, + }) + + // Hide instead of quit on close; "really quit" is reached via tray -> Quit. + window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { + e.Cancel() + window.Hide() + }) + + // On macOS, Wails' default applicationShouldHandleReopen handler Show()s + // every hidden window on dock-icon click, resurrecting hide-on-close + // surfaces like Settings. Cancel it in a hook (hooks run before listeners) + // and show only the main window. No-op elsewhere — the event never fires. + if runtime.GOOS == "darwin" { + app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(e *application.ApplicationEvent) { + e.Cancel() + if e.Context().HasVisibleWindows() { + return + } + window.Show() + window.Focus() + }) + } + + return window +} 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/network.go b/client/ui/network.go deleted file mode 100644 index fb73efd7b..000000000 --- a/client/ui/network.go +++ /dev/null @@ -1,649 +0,0 @@ -//go:build !(linux && 386) - -package main - -import ( - "context" - "fmt" - "runtime" - "slices" - "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 - -type exitNodeState struct { - id string - selected bool -} - -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) -} - -func (s *serviceClient) updateExitNodes() { - conn, err := s.getSrvClient(defaultFailTimeout) - if err != nil { - log.Errorf("get client: %v", err) - return - } - - exitNodes, err := s.getExitNodes(conn) - if err != nil { - log.Errorf("get exit nodes: %v", err) - return - } - - s.exitNodeMu.Lock() - defer s.exitNodeMu.Unlock() - - s.recreateExitNodeMenu(exitNodes) - - if len(s.mExitNodeItems) > 0 { - s.mExitNode.Enable() - } else { - s.mExitNode.Disable() - } -} - -func (s *serviceClient) recreateExitNodeMenu(exitNodes []*proto.Network) { - var exitNodeIDs []exitNodeState - for _, node := range exitNodes { - exitNodeIDs = append(exitNodeIDs, exitNodeState{ - id: node.ID, - selected: node.Selected, - }) - } - - sort.Slice(exitNodeIDs, func(i, j int) bool { - return exitNodeIDs[i].id < exitNodeIDs[j].id - }) - if slices.Equal(s.exitNodeStates, exitNodeIDs) { - log.Debug("Exit node menu already up to date") - return - } - - for _, node := range s.mExitNodeItems { - node.cancel() - node.Hide() - node.Remove() - } - s.mExitNodeItems = 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", exitNodeMenuDescr) - } - - 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) - } - - s.exitNodeStates = exitNodeIDs - - if showDeselectAll { - s.mExitNode.AddSeparator() - deselectAllItem := s.mExitNode.AddSubMenuItem("Deselect All", "Deselect All") - s.mExitNodeDeselectAll = deselectAllItem - go func() { - for { - _, ok := <-deselectAllItem.ClickedCh - if !ok { - // channel closed: exit the goroutine - 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/preferences/store.go b/client/ui/preferences/store.go new file mode 100644 index 000000000..df6fbbb16 --- /dev/null +++ b/client/ui/preferences/store.go @@ -0,0 +1,309 @@ +//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"` +} + +// 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 +} + +// 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) { + 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..6384fddb8 --- /dev/null +++ b/client/ui/preferences/store_test.go @@ -0,0 +1,265 @@ +//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_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/process/process.go b/client/ui/process/process.go deleted file mode 100644 index d0ef54896..000000000 --- a/client/ui/process/process.go +++ /dev/null @@ -1,37 +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 - } - - if strings.Contains(strings.ToLower(runningProcessPath), 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/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..fae7ddd23 --- /dev/null +++ b/client/ui/services/connection.go @@ -0,0 +1,251 @@ +//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"` +} + +// WaitSSOParams are the inputs to waitSSOLogin. +type WaitSSOParams struct { + UserCode string `json:"userCode"` + Hostname string `json:"hostname"` +} + +// 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 + 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() + 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, + IsUnixDesktopClient: runtime.GOOS == "linux", + } + if profileName != "" { + req.ProfileName = ptrStr(profileName) + } + if username != "" { + req.Username = ptrStr(username) + } + if p.PreSharedKey != "" { + req.OptionalPreSharedKey = ptrStr(p.PreSharedKey) + } + if p.Hint != "" { + req.Hint = ptrStr(p.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(), + }, 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) + } + + // The daemon runs as root and can't reach the user-owned per-profile state + // file holding the account email (see Profiles.List), so clear the stale + // email here; the next SSO login recreates it. + if p.ProfileName != "" { + if err := profilemanager.NewProfileManager().RemoveProfileState(p.ProfileName); err != nil { + // Non-fatal: the logout itself succeeded. + log.Warnf("failed to remove profile state for %s: %v", p.ProfileName, 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") + 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..760fd5b86 --- /dev/null +++ b/client/ui/services/cursor_linux.go @@ -0,0 +1,53 @@ +//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 + } + 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..034086747 --- /dev/null +++ b/client/ui/services/debug.go @@ -0,0 +1,136 @@ +//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"` + 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, + 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..f1679e764 --- /dev/null +++ b/client/ui/services/errors.go @@ -0,0 +1,133 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "encoding/json" + "strings" + + gcodes "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/client/ui/preferences" +) + +// 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"` +} + +// 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() + } + 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/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/preferences.go b/client/ui/services/preferences.go new file mode 100644 index 000000000..dae086de8 --- /dev/null +++ b/client/ui/services/preferences.go @@ -0,0 +1,36 @@ +//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) +} diff --git a/client/ui/services/profile.go b/client/ui/services/profile.go new file mode 100644 index 000000000..09468c9df --- /dev/null +++ b/client/ui/services/profile.go @@ -0,0 +1,179 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "os/user" + + "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 + } + _, err = cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{ + ProfileName: p.ProfileName, + Username: p.Username, + }) + return err +} + +// 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..3b6f6f81b --- /dev/null +++ b/client/ui/services/settings.go @@ -0,0 +1,257 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "fmt" + "reflect" + + "github.com/netbirdio/netbird/client/proto" +) + +type MDMFields 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"` + DisableAdvancedView bool `json:"disableAdvancedView"` +} + +type Features struct { + DisableProfiles bool `json:"disableProfiles"` + DisableNetworks bool `json:"disableNetworks"` + DisableUpdateSettings bool `json:"disableUpdateSettings"` +} + +type Restrictions struct { + MDM MDMFields `json:"mdm"` + Features Features `json:"features"` +} + +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 +} + +func NewSettings(conn DaemonConn) *Settings { + return &Settings{conn: conn} +} + +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) error { + cli, err := s.conn.Client() + if err != nil { + return 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, + } + _, err = cli.SetConfig(ctx, req) + return err +} + +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.GetDisableAdvancedView() + return r, nil +} + +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 t.Field(i).Name == "DisableAdvancedView" { + 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/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..753177d45 --- /dev/null +++ b/client/ui/services/update.go @@ -0,0 +1,73 @@ +//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" +) + +// 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() +} + +// 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..1185ec729 --- /dev/null +++ b/client/ui/services/windowmanager.go @@ -0,0 +1,615 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "net/url" + "strconv" + "sync" + "time" + + "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" + +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), + } +} + +// WindowManager owns the auxiliary windows (main is created in main.go). Settings is created +// eagerly and hidden on close to keep React state; the rest are created on open, destroyed on +// close, so the macOS dock-reopen handler finds no hidden window to resurrect. +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 + // 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 +} + +// NewWindowManager wires the manager to the main app; translator/prefs may be nil (tests). The +// Settings window is created here (hidden) so the first OpenSettings is instant. +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} + // 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() + } + }() + } + s.settings = 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(linuxIcon), + }) + // Hide (not destroy) on close to keep React state; reset to General for a flash-free reopen. + s.settings.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { + e.Cancel() + s.app.Event.Emit(EventSettingsOpen, "general") + s.settings.Hide() + }) + return s +} + +// 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" + } + s.app.Event.Emit(EventSettingsOpen, target) + s.settings.Show() + s.settings.Focus() + // Re-center (minimal-WM only; see centerWhenReady). + s.centerWhenReady(s.settings) +} + +// 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. Singleton, destroyed on close. +func (s *WindowManager) OpenSessionExpiration(seconds int) { + s.mu.Lock() + defer s.mu.Unlock() + startURL := "/#/dialog/session-expiration?seconds=" + strconv.Itoa(seconds) + 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 are pre-localised and ride in the +// start URL. A second error replaces the open one via SetURL. Singleton, destroyed on close. +func (s *WindowManager) OpenError(title, message string) { + s.mu.Lock() + defer s.mu.Unlock() + startURL := errorDialogURL(title, message) + 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() { + if s.mainWindow == nil { + return + } + s.mainWindow.Show() + s.mainWindow.Focus() + // Re-center (minimal-WM only; see centerWhenReady). + s.centerWhenReady(s.mainWindow) +} + +// 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 as escaped query params. +func errorDialogURL(title, message string) string { + q := url.Values{} + if title != "" { + q.Set("title", title) + } + if message != "" { + q.Set("message", message) + } + 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/signal_unix.go b/client/ui/signal_unix.go new file mode 100644 index 000000000..876c5dd6e --- /dev/null +++ b/client/ui/signal_unix.go @@ -0,0 +1,31 @@ +//go:build !windows && !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + log "github.com/sirupsen/logrus" +) + +// 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 <-sigCh: + log.Debug("SIGUSR1 received, showing window") + tray.ShowWindow() + } + } + }() +} diff --git a/client/ui/signal_windows.go b/client/ui/signal_windows.go new file mode 100644 index 000000000..86caa6d0c --- /dev/null +++ b/client/ui/signal_windows.go @@ -0,0 +1,78 @@ +//go:build windows + +package main + +import ( + "context" + "errors" + "time" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +const ( + quickActionsTriggerEventName = `Global\NetBirdQuickActionsTriggerEvent` + waitTimeout = 5 * time.Second + desiredAccesses = windows.SYNCHRONIZE | windows.EVENT_MODIFY_STATE + + // WAIT_TIMEOUT return code; not exposed by golang.org/x/sys/windows. + waitTimeoutCode uint32 = 0x00000102 +) + +// 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 + } + + handle, err := windows.CreateEvent(nil, 1, 0, namePtr) + if err != nil { + 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 handle == windows.InvalidHandle { + log.Errorf("invalid handle for trigger event %q", quickActionsTriggerEventName) + return + } + + go waitForTrigger(ctx, handle, tray) +} + +func waitForTrigger(ctx context.Context, handle windows.Handle, tray *Tray) { + defer func() { + 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 + } + 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() + } + } +} diff --git a/client/ui/tray.go b/client/ui/tray.go new file mode 100644 index 000000000..63b6a46ec --- /dev/null +++ b/client/ui/tray.go @@ -0,0 +1,549 @@ +//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/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" + urlGitHubReleases = "https://github.com/netbirdio/netbird/releases/latest" + 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 *notifications.NotificationService + 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 +} + +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, window, 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 + } + if t.window == nil { + 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 + } + t.window.Show() + t.window.Focus() +} + +// 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() { + t.profileMu.Lock() + if t.switchCancel != nil { + t.switchCancel() + t.switchCancel = nil + } + t.profileMu.Unlock() + t.svc.DaemonFeed.CancelProfileSwitch() + + 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 hidden main webview is alive and subscribed, 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..890f4b49d --- /dev/null +++ b/client/ui/tray_click_linux.go @@ -0,0 +1,32 @@ +//go:build linux && !(linux && 386) + +package main + +// bindTrayClick wires the tray icon's left-click handler on Linux. +// +// Both Linux click paths converge on Wails' linuxSystemTray.Activate, which +// fires the registered clickHandler: +// - Real SNI hosts (KDE Plasma, Waybar, GNOME Shell + AppIndicator) invoke +// org.kde.StatusNotifierItem.Activate over D-Bus on left-click. +// - The in-process StatusNotifierWatcher + XEmbed host used on minimal WMs +// (Fluxbox, i3, dwm, OpenBox) maps a Button1 press to that same Activate +// call itself (xembed_host_linux.go), so it routes through the same hook. +// +// Registering OnClick here therefore covers both paths with one handler — no +// changes to the watcher or XEmbed C code are needed. Left-click now opens the +// main window; right-click still opens the menu via Wails' default +// SecondaryActivate→OpenMenu handler (and the XEmbed GTK popup on minimal WMs). +// +// 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..12da68a5c --- /dev/null +++ b/client/ui/tray_events.go @@ -0,0 +1,137 @@ +//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" { + t.openSessionExpiration() + 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..6c60e3d4b --- /dev/null +++ b/client/ui/tray_notify.go @@ -0,0 +1,58 @@ +//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) { + 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 *notifications.NotificationService, 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..885fdb348 --- /dev/null +++ b/client/ui/tray_session.go @@ -0,0 +1,322 @@ +//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 frontend's /login route drives renewal. +func (t *Tray) handleSessionExpired() { + t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired) + if t.window != nil { + t.window.SetURL("/#/login") + t.window.Show() + t.window.Focus() + } +} + +// 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. +// Idempotent on the WindowManager side. +func (t *Tray) openSessionExpiration() { + if t.svc.WindowManager == nil { + return + } + t.svc.WindowManager.OpenSessionExpiration(finalWarningCountdownSeconds) +} + +// 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 { + if t.window != nil { + t.window.SetURL("/#/login") + t.window.Show() + t.window.Focus() + } + return + } + if t.svc.WindowManager == nil { + return + } + t.svc.WindowManager.OpenSessionExpiration(seconds) +} 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..2d79cff05 --- /dev/null +++ b/client/ui/tray_update.go @@ -0,0 +1,197 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "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" +) + +// trayUpdater owns the tray UI that reacts to auto-update. Composed inside Tray. +type trayUpdater struct { + app *application.App + window *application.WebviewWindow + update *services.Update + notifier *notifications.NotificationService + 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, window *application.WebviewWindow, update *services.Update, notifier *notifications.NotificationService, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater { + u := &trayUpdater{ + app: app, + window: window, + 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 GitHub releases page 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(urlGitHubReleases) + 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.window == nil { + return + } + url := "/#/update" + if version != "" { + url += "?version=" + version + } + u.window.SetURL(url) + u.window.Show() + u.window.Focus() +} 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..96fbb9637 --- /dev/null +++ b/client/ui/uilogpath.go @@ -0,0 +1,37 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "os" + "path/filepath" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/ui/guilog" +) + +// uiLogFileName must stay in sync with the daemon's "gui-client*.log.*" glob +// for rotated siblings (addUILog in client/internal/debug). +const uiLogFileName = "gui-client.log" + +// uiLogPath returns the GUI log path with native separators, since the daemon +// opens it directly for debug-bundle collection. +func uiLogPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "netbird", uiLogFileName), 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/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_linux.go b/client/ui/xembed_host_linux.go new file mode 100644 index 000000000..ba7a5d07c --- /dev/null +++ b/client/ui/xembed_host_linux.go @@ -0,0 +1,443 @@ +//go:build linux && !(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..07ec74bb2 --- /dev/null +++ b/client/ui/xembed_tray_linux.c @@ -0,0 +1,708 @@ +#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 d542e2739..4683f4033 100644 --- a/client/wasm/cmd/main.go +++ b/client/wasm/cmd/main.go @@ -5,22 +5,37 @@ package main import ( "context" "fmt" + "net" + "strconv" + "sync" "syscall/js" "time" log "github.com/sirupsen/logrus" + "google.golang.org/protobuf/encoding/protojson" 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" ) const ( - clientStartTimeout = 30 * time.Second - clientStopTimeout = 10 * time.Second - defaultLogLevel = "warn" + clientStartTimeout = 30 * time.Second + clientStopTimeout = 10 * time.Second + pingTimeout = 10 * time.Second + defaultLogLevel = "warn" + defaultSSHDetectionTimeout = 20 * time.Second + dialWebSocketTimeout = 30 * time.Second + + icmpEchoRequest = 8 + icmpCodeEcho = 0 + pingBufferSize = 1500 ) func main() { @@ -72,6 +87,10 @@ func parseClientOptions(jsOptions js.Value) (netbird.Options, error) { options.DeviceName = deviceName.String() } + if disableIPv6 := jsOptions.Get("disableIPv6"); !disableIPv6.IsNull() && !disableIPv6.IsUndefined() { + options.DisableIPv6 = disableIPv6.Bool() + } + return options, nil } @@ -111,42 +130,217 @@ func createStopMethod(client *netbird.Client) js.Func { }) } +// validateSSHArgs validates SSH connection arguments +func validateSSHArgs(args []js.Value) (host string, port int, username string, err js.Value) { + if len(args) < 2 { + return "", 0, "", js.ValueOf("error: requires host and port") + } + + if args[0].Type() != js.TypeString { + return "", 0, "", js.ValueOf("host parameter must be a string") + } + if args[1].Type() != js.TypeNumber { + return "", 0, "", js.ValueOf("port parameter must be a number") + } + + host = args[0].String() + port = args[1].Int() + username = "root" + + if len(args) > 2 { + if args[2].Type() == js.TypeString && args[2].String() != "" { + username = args[2].String() + } else if args[2].Type() != js.TypeString { + return "", 0, "", js.ValueOf("username parameter must be a string") + } + } + + return host, port, username, js.Undefined() +} + // createSSHMethod creates the SSH connection method func createSSHMethod(client *netbird.Client) js.Func { return js.FuncOf(func(this js.Value, args []js.Value) any { - if len(args) < 2 { - return js.ValueOf("error: requires host and port") + host, port, username, validationErr := validateSSHArgs(args) + if !validationErr.IsUndefined() { + if validationErr.Type() == js.TypeString && validationErr.String() == "error: requires host and port" { + return validationErr + } + return createPromise(func(resolve, reject js.Value) { + reject.Invoke(validationErr) + }) } - host := args[0].String() - port := args[1].Int() - username := "root" - if len(args) > 2 && args[2].String() != "" { - username = args[2].String() - } + jwtToken, ipVersion := parseSSHOptions(args) return createPromise(func(resolve, reject js.Value) { - sshClient := ssh.NewClient(client) - - if err := sshClient.Connect(host, port, username); 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 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, network, hostname) + if err != nil { + js.Global().Get("console").Call("log", fmt.Sprintf("Ping to %s failed: %v", hostname, err)) + return + } + defer func() { + if err := conn.Close(); err != nil { + log.Debugf("failed to close ping connection: %v", err) + } + }() + + icmpData := make([]byte, 8) + icmpData[0] = icmpEchoRequest + icmpData[1] = icmpCodeEcho + + if _, err := conn.Write(icmpData); err != nil { + js.Global().Get("console").Call("log", fmt.Sprintf("Ping to %s write failed: %v", hostname, err)) + return + } + + buf := make([]byte, pingBufferSize) + if _, err := conn.Read(buf); err != nil { + js.Global().Get("console").Call("log", fmt.Sprintf("Ping to %s read failed: %v", hostname, err)) + return + } + + latency := time.Since(start) + 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, ipVersion int) { + ctx, cancel := context.WithTimeout(context.Background(), pingTimeout) + defer cancel() + + network := ipVersionNetwork("tcp", ipVersion) + + address := net.JoinHostPort(hostname, fmt.Sprintf("%d", port)) + start := time.Now() + 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) + } + + 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 +func createPingMethod(client *netbird.Client) js.Func { + return js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) < 1 { + return js.ValueOf("error: hostname required") + } + + if args[0].Type() != js.TypeString { + return createPromise(func(resolve, reject js.Value) { + reject.Invoke(js.ValueOf("hostname parameter must be a string")) + }) + } + + 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, ipVersion) + resolve.Invoke(js.Undefined()) + }) + }) +} + +// createPingTCPMethod creates the pingtcp method +func createPingTCPMethod(client *netbird.Client) js.Func { + return js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) < 2 { + return js.ValueOf("error: hostname and port required") + } + + if args[0].Type() != js.TypeString { + return createPromise(func(resolve, reject js.Value) { + reject.Invoke(js.ValueOf("hostname parameter must be a string")) + }) + } + + if args[1].Type() != js.TypeNumber { + return createPromise(func(resolve, reject js.Value) { + reject.Invoke(js.ValueOf("port parameter must be a number")) + }) + } + + 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, ipVersion) + resolve.Invoke(js.Undefined()) + }) + }) +} + // createProxyRequestMethod creates the proxyRequest method func createProxyRequestMethod(client *netbird.Client) js.Func { return js.FuncOf(func(this js.Value, args []js.Value) any { @@ -155,6 +349,11 @@ func createProxyRequestMethod(client *netbird.Client) js.Func { } request := args[0] + if request.Type() != js.TypeObject { + return createPromise(func(resolve, reject js.Value) { + reject.Invoke(js.ValueOf("request parameter must be an object")) + }) + } return createPromise(func(resolve, reject js.Value) { response, err := http.ProxyRequest(client, request) @@ -174,11 +373,255 @@ func createRDPProxyMethod(client *netbird.Client) js.Func { return js.ValueOf("error: hostname and port required") } + if args[0].Type() != js.TypeString { + return createPromise(func(resolve, reject js.Value) { + reject.Invoke(js.ValueOf("hostname parameter must be a string")) + }) + } + if args[1].Type() != js.TypeString { + return createPromise(func(resolve, reject js.Value) { + reject.Invoke(js.ValueOf("port parameter must be a string")) + }) + } + proxy := rdp.NewRDCleanPathProxy(client) return proxy.CreateProxy(args[0].String(), args[1].String()) }) } +// getStatusOverview is a helper to get the status overview +func getStatusOverview(client *netbird.Client) (nbstatus.OutputOverview, error) { + fullStatus, err := client.Status() + if err != nil { + return nbstatus.OutputOverview{}, err + } + + pbFullStatus := fullStatus.ToProto() + + return nbstatus.ConvertToStatusOutputOverview(pbFullStatus, nbstatus.ConvertOptions{}), nil +} + +// createStatusMethod creates the status method that returns JSON +func createStatusMethod(client *netbird.Client) js.Func { + return js.FuncOf(func(_ js.Value, args []js.Value) any { + return createPromise(func(resolve, reject js.Value) { + overview, err := getStatusOverview(client) + if err != nil { + reject.Invoke(js.ValueOf(err.Error())) + return + } + + jsonStr, err := overview.JSON() + if err != nil { + reject.Invoke(js.ValueOf(err.Error())) + return + } + jsonObj := js.Global().Get("JSON").Call("parse", jsonStr) + resolve.Invoke(jsonObj) + }) + }) +} + +// createStatusSummaryMethod creates the statusSummary method +func createStatusSummaryMethod(client *netbird.Client) js.Func { + return js.FuncOf(func(_ js.Value, args []js.Value) any { + return createPromise(func(resolve, reject js.Value) { + overview, err := getStatusOverview(client) + if err != nil { + reject.Invoke(js.ValueOf(err.Error())) + return + } + + summary := overview.GeneralSummary(false, false, false, false) + js.Global().Get("console").Call("log", summary) + resolve.Invoke(js.Undefined()) + }) + }) +} + +// createStatusDetailMethod creates the statusDetail method +func createStatusDetailMethod(client *netbird.Client) js.Func { + return js.FuncOf(func(_ js.Value, args []js.Value) any { + return createPromise(func(resolve, reject js.Value) { + overview, err := getStatusOverview(client) + if err != nil { + reject.Invoke(js.ValueOf(err.Error())) + return + } + + detail := overview.FullDetailSummary() + js.Global().Get("console").Call("log", detail) + resolve.Invoke(js.Undefined()) + }) + }) +} + +// createGetSyncResponseMethod creates the getSyncResponse method that returns the latest sync response as JSON +func createGetSyncResponseMethod(client *netbird.Client) js.Func { + return js.FuncOf(func(_ js.Value, args []js.Value) any { + return createPromise(func(resolve, reject js.Value) { + syncResp, err := client.GetLatestSyncResponse() + if err != nil { + reject.Invoke(js.ValueOf(err.Error())) + return + } + + options := protojson.MarshalOptions{ + EmitUnpopulated: true, + UseProtoNames: true, + AllowPartial: true, + } + jsonBytes, err := options.Marshal(syncResp) + if err != nil { + reject.Invoke(js.ValueOf(fmt.Sprintf("marshal sync response: %v", err))) + return + } + + jsonObj := js.Global().Get("JSON").Call("parse", string(jsonBytes)) + resolve.Invoke(jsonObj) + }) + }) +} + +// createSetLogLevelMethod creates the setLogLevel method to dynamically change logging level +func createSetLogLevelMethod(client *netbird.Client) js.Func { + return js.FuncOf(func(_ js.Value, args []js.Value) any { + if len(args) < 1 { + return js.ValueOf("error: log level required") + } + + if args[0].Type() != js.TypeString { + return createPromise(func(resolve, reject js.Value) { + reject.Invoke(js.ValueOf("log level parameter must be a string")) + }) + } + + logLevel := args[0].String() + return createPromise(func(resolve, reject js.Value) { + if err := client.SetLogLevel(logLevel); err != nil { + reject.Invoke(js.ValueOf(fmt.Sprintf("set log level: %v", err))) + return + } + log.Infof("Log level set to: %s", logLevel) + resolve.Invoke(js.ValueOf(true)) + }) + }) +} + +// 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 { @@ -191,22 +634,137 @@ func createPromise(handler func(resolve, reject js.Value)) js.Value { })) } +// createDetectSSHServerMethod creates the SSH server detection method +func createDetectSSHServerMethod(client *netbird.Client) js.Func { + return js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) < 2 { + return js.ValueOf("error: requires host and port") + } + + host := args[0].String() + port := args[1].Int() + + timeoutMs := int(defaultSSHDetectionTimeout.Milliseconds()) + if len(args) >= 3 && !args[2].IsNull() && !args[2].IsUndefined() { + timeoutMs = args[2].Int() + if timeoutMs <= 0 { + return js.ValueOf("error: timeout must be positive") + } + } + + return createPromise(func(resolve, reject js.Value) { + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutMs)*time.Millisecond) + defer cancel() + + serverType, err := sshdetection.DetectSSHServerType(ctx, client, host, port) + if err != nil { + reject.Invoke(err.Error()) + return + } + + resolve.Invoke(js.ValueOf(serverType.RequiresJWT())) + }) + }) +} + // createClientObject wraps the NetBird client in a JavaScript object func createClientObject(client *netbird.Client) js.Value { obj := make(map[string]interface{}) obj["start"] = createStartMethod(client) obj["stop"] = createStopMethod(client) + obj["ping"] = createPingMethod(client) + obj["pingtcp"] = createPingTCPMethod(client) + obj["detectSSHServerType"] = createDetectSSHServerMethod(client) 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(this js.Value, args []js.Value) any { - return js.Global().Get("Promise").New(js.FuncOf(func(this js.Value, promiseArgs []js.Value) any { +func netBirdClientConstructor(_ js.Value, args []js.Value) any { + return js.Global().Get("Promise").New(js.FuncOf(func(_ js.Value, promiseArgs []js.Value) any { resolve := promiseArgs[0] reject := promiseArgs[1] 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..53266eb0e 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,6 +69,13 @@ type proxyConnection struct { rdpConn net.Conn tlsConn *tls.Conn wsHandlers js.Value + // 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 } @@ -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 ca35525eb..9cfe65266 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" @@ -13,6 +14,7 @@ import ( "golang.org/x/crypto/ssh" netbird "github.com/netbirdio/netbird/client/embed" + nbssh "github.com/netbirdio/netbird/client/ssh" ) const ( @@ -44,42 +46,36 @@ func NewClient(nbClient *netbird.Client) *Client { } } -// Connect establishes an SSH connection through NetBird network -func (c *Client) Connect(host string, port int, username 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) - var authMethods []ssh.AuthMethod - - nbConfig, err := c.nbClient.GetConfig() + authMethods, err := c.getAuthMethods(jwtToken) if err != nil { - return fmt.Errorf("get NetBird config: %w", err) + return err } - if nbConfig.SSHKey == "" { - return fmt.Errorf("no NetBird SSH key available - key should be generated during client initialization") - } - - signer, err := parseSSHPrivateKey([]byte(nbConfig.SSHKey)) - if err != nil { - return fmt.Errorf("parse NetBird SSH private key: %w", err) - } - - pubKey := signer.PublicKey() - logrus.Infof("SSH: Using NetBird key authentication with public key type: %s", pubKey.Type()) - - authMethods = append(authMethods, ssh.PublicKeys(signer)) config := &ssh.ClientConfig{ User: username, Auth: authMethods, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), + HostKeyCallback: nbssh.CreateHostKeyCallback(c.nbClient), 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) } @@ -96,6 +92,33 @@ func (c *Client) Connect(host string, port int, username string) error { return nil } +// getAuthMethods returns SSH authentication methods, preferring JWT if available +func (c *Client) getAuthMethods(jwtToken string) ([]ssh.AuthMethod, error) { + if jwtToken != "" { + logrus.Debugf("SSH: Using JWT password authentication") + return []ssh.AuthMethod{ssh.Password(jwtToken)}, nil + } + + logrus.Debugf("SSH: No JWT token, using public key authentication") + + nbConfig, err := c.nbClient.GetConfig() + if err != nil { + return nil, fmt.Errorf("get NetBird config: %w", err) + } + + if nbConfig.SSHKey == "" { + return nil, fmt.Errorf("no NetBird SSH key available") + } + + signer, err := ssh.ParsePrivateKey([]byte(nbConfig.SSHKey)) + if err != nil { + return nil, fmt.Errorf("parse NetBird SSH private key: %w", err) + } + + logrus.Debugf("SSH: Added public key auth") + return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil +} + // StartSession starts an SSH session with PTY func (c *Client) StartSession(cols, rows int) error { if c.sshClient == 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/ssh/key.go b/client/wasm/internal/ssh/key.go deleted file mode 100644 index 4868ba30a..000000000 --- a/client/wasm/internal/ssh/key.go +++ /dev/null @@ -1,50 +0,0 @@ -//go:build js - -package ssh - -import ( - "crypto/x509" - "encoding/pem" - "fmt" - "strings" - - "github.com/sirupsen/logrus" - "golang.org/x/crypto/ssh" -) - -// parseSSHPrivateKey parses a private key in either SSH or PKCS8 format -func parseSSHPrivateKey(keyPEM []byte) (ssh.Signer, error) { - keyStr := string(keyPEM) - if !strings.Contains(keyStr, "-----BEGIN") { - keyPEM = []byte("-----BEGIN PRIVATE KEY-----\n" + keyStr + "\n-----END PRIVATE KEY-----") - } - - signer, err := ssh.ParsePrivateKey(keyPEM) - if err == nil { - return signer, nil - } - logrus.Debugf("SSH: Failed to parse as SSH format: %v", err) - - block, _ := pem.Decode(keyPEM) - if block == nil { - keyPreview := string(keyPEM) - if len(keyPreview) > 100 { - keyPreview = keyPreview[:100] - } - return nil, fmt.Errorf("decode PEM block from key: %s", keyPreview) - } - - key, err := x509.ParsePKCS8PrivateKey(block.Bytes) - if err != nil { - logrus.Debugf("SSH: Failed to parse as PKCS8: %v", err) - if rsaKey, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { - return ssh.NewSignerFromKey(rsaKey) - } - if ecKey, err := x509.ParseECPrivateKey(block.Bytes); err == nil { - return ssh.NewSignerFromKey(ecKey) - } - return nil, fmt.Errorf("parse private key: %w", err) - } - - return ssh.NewSignerFromKey(key) -} 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 new file mode 100644 index 000000000..ac88b8509 --- /dev/null +++ b/combined/Dockerfile @@ -0,0 +1,6 @@ +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"] +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/netbird-server /go/bin/netbird-server diff --git a/combined/Dockerfile.multistage b/combined/Dockerfile.multistage new file mode 100644 index 000000000..79746819d --- /dev/null +++ b/combined/Dockerfile.multistage @@ -0,0 +1,29 @@ +FROM golang:1.25-bookworm AS builder +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y gcc libc6-dev git && rm -rf /var/lib/apt/lists/* + +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download + +COPY . . + +# 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') \ + -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ) \ + -X main.builtBy=docker" \ + -o netbird-server ./combined + +FROM ubuntu:24.04 +RUN apt update && apt install -y ca-certificates && rm -fr /var/cache/apt +ENTRYPOINT [ "/go/bin/netbird-server" ] +CMD ["--config", "/etc/netbird/config.yaml"] +COPY --from=builder /app/netbird-server /go/bin/netbird-server diff --git a/combined/LICENSE b/combined/LICENSE new file mode 100644 index 000000000..be3f7b28e --- /dev/null +++ b/combined/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/combined/cmd/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 new file mode 100644 index 000000000..7f30cd8a8 --- /dev/null +++ b/combined/cmd/config.go @@ -0,0 +1,798 @@ +package cmd + +import ( + "context" + "fmt" + "net" + "net/netip" + "os" + filePath "path/filepath" + "strings" + "time" + + log "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" + + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/util" + "github.com/netbirdio/netbird/util/crypt" + + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" +) + +// CombinedConfig is the root configuration for the combined server. +// The combined server is primarily a Management server with optional embedded +// Signal, Relay, and STUN services. +// +// Architecture: +// - Management: Always runs locally (this IS the management server) +// - Signal: Runs locally by default; disabled if server.signalUri is set +// - Relay: Runs locally by default; disabled if server.relays is set +// - STUN: Runs locally on port 3478 by default; disabled if server.stuns is set +// +// All user-facing settings are under "server". The relay/signal/management +// fields are internal and populated automatically from server settings. +type CombinedConfig struct { + Server ServerConfig `yaml:"server"` + + // Internal configs - populated from Server settings, not user-configurable + Relay RelayConfig `yaml:"-"` + Signal SignalConfig `yaml:"-"` + Management ManagementConfig `yaml:"-"` +} + +// ServerConfig contains server-wide settings +// In simplified mode, this contains all configuration +type ServerConfig struct { + ListenAddress string `yaml:"listenAddress"` + MetricsPort int `yaml:"metricsPort"` + HealthcheckAddress string `yaml:"healthcheckAddress"` + LogLevel string `yaml:"logLevel"` + LogFile string `yaml:"logFile"` + TLS TLSConfig `yaml:"tls"` + + // Simplified config fields (used when relay/signal/management sections are omitted) + ExposedAddress string `yaml:"exposedAddress"` // Public address with protocol (e.g., "https://example.com:443") + StunPorts []int `yaml:"stunPorts"` // STUN ports (empty to disable local STUN) + AuthSecret string `yaml:"authSecret"` // Shared secret for relay authentication + DataDir string `yaml:"dataDir"` // Data directory for all services + + // External service overrides (simplified mode) + // When these are set, the corresponding local service is NOT started + // and these values are used for client configuration instead + Stuns []HostConfig `yaml:"stuns"` // External STUN servers (disables local STUN) + Relays RelaysConfig `yaml:"relays"` // External relay servers (disables local relay) + SignalURI string `yaml:"signalUri"` // External signal server (disables local signal) + + // Management settings (simplified mode) + DisableAnonymousMetrics bool `yaml:"disableAnonymousMetrics"` + DisableGeoliteUpdate bool `yaml:"disableGeoliteUpdate"` + Auth AuthConfig `yaml:"auth"` + Store StoreConfig `yaml:"store"` + ActivityStore StoreConfig `yaml:"activityStore"` + AuthStore StoreConfig `yaml:"authStore"` + ReverseProxy ReverseProxyConfig `yaml:"reverseProxy"` + + SupportedSyncMessageVersions *int `yaml:"supportedSyncMessageVersions,omitempty"` + PerAccountSupportedSyncMessageVersions map[string]int `yaml:"perAccountSupportedSyncMessageVersions,omitempty"` +} + +// TLSConfig contains TLS/HTTPS settings +type TLSConfig struct { + CertFile string `yaml:"certFile"` + KeyFile string `yaml:"keyFile"` + LetsEncrypt LetsEncryptConfig `yaml:"letsencrypt"` +} + +// LetsEncryptConfig contains Let's Encrypt settings +type LetsEncryptConfig struct { + Enabled bool `yaml:"enabled"` + DataDir string `yaml:"dataDir"` + Domains []string `yaml:"domains"` + Email string `yaml:"email"` + AWSRoute53 bool `yaml:"awsRoute53"` +} + +// RelayConfig contains relay service settings +type RelayConfig struct { + Enabled bool `yaml:"enabled"` + ExposedAddress string `yaml:"exposedAddress"` + AuthSecret string `yaml:"authSecret"` + LogLevel string `yaml:"logLevel"` + Stun StunConfig `yaml:"stun"` +} + +// StunConfig contains embedded STUN service settings +type StunConfig struct { + Enabled bool `yaml:"enabled"` + Ports []int `yaml:"ports"` + LogLevel string `yaml:"logLevel"` +} + +// SignalConfig contains signal service settings +type SignalConfig struct { + Enabled bool `yaml:"enabled"` + LogLevel string `yaml:"logLevel"` +} + +// ManagementConfig contains management service settings +type ManagementConfig struct { + Enabled bool `yaml:"enabled"` + LogLevel string `yaml:"logLevel"` + DataDir string `yaml:"dataDir"` + DnsDomain string `yaml:"dnsDomain"` + DisableAnonymousMetrics bool `yaml:"disableAnonymousMetrics"` + DisableGeoliteUpdate bool `yaml:"disableGeoliteUpdate"` + DisableDefaultPolicy bool `yaml:"disableDefaultPolicy"` + Auth AuthConfig `yaml:"auth"` + Stuns []HostConfig `yaml:"stuns"` + Relays RelaysConfig `yaml:"relays"` + SignalURI string `yaml:"signalUri"` + Store StoreConfig `yaml:"store"` + ReverseProxy ReverseProxyConfig `yaml:"reverseProxy"` +} + +// AuthConfig contains authentication/identity provider settings +type AuthConfig struct { + Issuer string `yaml:"issuer"` + LocalAuthDisabled bool `yaml:"localAuthDisabled"` + SignKeyRefreshEnabled bool `yaml:"signKeyRefreshEnabled"` + 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 +type AuthStorageConfig struct { + Type string `yaml:"type"` + File string `yaml:"file"` +} + +// AuthOwnerConfig contains initial admin user settings +type AuthOwnerConfig struct { + Email string `yaml:"email"` + Password string `yaml:"password"` +} + +// HostConfig represents a STUN/TURN/Signal host +type HostConfig struct { + URI string `yaml:"uri"` + Proto string `yaml:"proto,omitempty"` // udp, dtls, tcp, http, https - defaults based on URI scheme + Username string `yaml:"username,omitempty"` + Password string `yaml:"password,omitempty"` +} + +// RelaysConfig contains external relay server settings for clients +type RelaysConfig struct { + Addresses []string `yaml:"addresses"` + CredentialsTTL string `yaml:"credentialsTTL"` + Secret string `yaml:"secret"` +} + +// StoreConfig contains database settings +type StoreConfig struct { + Engine string `yaml:"engine"` + EncryptionKey string `yaml:"encryptionKey"` + DSN string `yaml:"dsn"` // Connection string for postgres or mysql engines + File string `yaml:"file"` // SQLite database file path (optional, defaults to dataDir) +} + +// ReverseProxyConfig contains reverse proxy settings +type ReverseProxyConfig struct { + TrustedHTTPProxies []string `yaml:"trustedHTTPProxies"` + TrustedHTTPProxiesCount uint `yaml:"trustedHTTPProxiesCount"` + TrustedPeers []string `yaml:"trustedPeers"` + AccessLogRetentionDays int `yaml:"accessLogRetentionDays"` + AccessLogCleanupIntervalHours int `yaml:"accessLogCleanupIntervalHours"` +} + +// DefaultConfig returns a CombinedConfig with default values +func DefaultConfig() *CombinedConfig { + return &CombinedConfig{ + Server: ServerConfig{ + ListenAddress: ":443", + MetricsPort: 9090, + HealthcheckAddress: ":9000", + LogLevel: "info", + LogFile: "console", + StunPorts: []int{3478}, + DataDir: "/var/lib/netbird/", + Auth: AuthConfig{ + Storage: AuthStorageConfig{ + Type: "sqlite3", + }, + }, + Store: StoreConfig{ + Engine: "sqlite", + }, + }, + Relay: RelayConfig{ + // LogLevel inherited from Server.LogLevel via ApplySimplifiedDefaults + Stun: StunConfig{ + Enabled: false, + Ports: []int{3478}, + // LogLevel inherited from Server.LogLevel via ApplySimplifiedDefaults + }, + }, + Signal: SignalConfig{ + // LogLevel inherited from Server.LogLevel via ApplySimplifiedDefaults + }, + Management: ManagementConfig{ + DataDir: "/var/lib/netbird/", + Auth: AuthConfig{ + Storage: AuthStorageConfig{ + Type: "sqlite3", + }, + }, + Relays: RelaysConfig{ + CredentialsTTL: "12h", + }, + Store: StoreConfig{ + Engine: "sqlite", + }, + }, + } +} + +// hasRequiredSettings returns true if the configuration has the required server settings +func (c *CombinedConfig) hasRequiredSettings() bool { + return c.Server.ExposedAddress != "" +} + +// parseExposedAddress extracts protocol, host, and host:port from the exposed address +// Input format: "https://example.com:443" or "http://example.com:8080" or "example.com:443" +// Returns: protocol ("https" or "http"), hostname only, and host:port +func parseExposedAddress(exposedAddress string) (protocol, hostname, hostPort string) { + // Default to https if no protocol specified + protocol = "https" + hostPort = exposedAddress + + // Check for protocol prefix + if strings.HasPrefix(exposedAddress, "https://") { + protocol = "https" + hostPort = strings.TrimPrefix(exposedAddress, "https://") + } else if strings.HasPrefix(exposedAddress, "http://") { + protocol = "http" + hostPort = strings.TrimPrefix(exposedAddress, "http://") + } + + // Extract hostname (without port) + hostname = hostPort + if host, _, err := net.SplitHostPort(hostPort); err == nil { + hostname = host + } + + return protocol, hostname, hostPort +} + +// ApplySimplifiedDefaults populates internal relay/signal/management configs from server settings. +// Management is always enabled. Signal, Relay, and STUN are enabled unless external +// overrides are configured (server.signalUri, server.relays, server.stuns). +func (c *CombinedConfig) ApplySimplifiedDefaults() { + if !c.hasRequiredSettings() { + return + } + + // Parse exposed address to extract protocol and hostname + exposedProto, exposedHost, exposedHostPort := parseExposedAddress(c.Server.ExposedAddress) + + // Check for external service overrides + hasExternalRelay := len(c.Server.Relays.Addresses) > 0 + hasExternalSignal := c.Server.SignalURI != "" + hasExternalStuns := len(c.Server.Stuns) > 0 + + // Default stunPorts to [3478] if not specified and no external STUN + if len(c.Server.StunPorts) == 0 && !hasExternalStuns { + c.Server.StunPorts = []int{3478} + } + + c.applyRelayDefaults(exposedProto, exposedHostPort, hasExternalRelay, hasExternalStuns) + c.applySignalDefaults(hasExternalSignal) + c.applyManagementDefaults(exposedHost) + + // Auto-configure client settings (stuns, relays, signalUri) + c.autoConfigureClientSettings(exposedProto, exposedHost, exposedHostPort, hasExternalStuns, hasExternalRelay, hasExternalSignal) +} + +// 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 { + return + } + + c.Relay.Enabled = true + relayProto := "rel" + if exposedProto == "https" { + relayProto = "rels" + } + c.Relay.ExposedAddress = fmt.Sprintf("%s://%s", relayProto, exposedHostPort) + c.Relay.AuthSecret = c.Server.AuthSecret + if c.Relay.LogLevel == "" { + c.Relay.LogLevel = c.Server.LogLevel + } + + // Enable local STUN only if no external STUN servers and stunPorts are configured + if !hasExternalStuns && len(c.Server.StunPorts) > 0 { + c.Relay.Stun.Enabled = true + c.Relay.Stun.Ports = c.Server.StunPorts + if c.Relay.Stun.LogLevel == "" { + c.Relay.Stun.LogLevel = c.Server.LogLevel + } + } +} + +// applySignalDefaults configures the signal service if no external signal is configured. +func (c *CombinedConfig) applySignalDefaults(hasExternalSignal bool) { + if hasExternalSignal { + return + } + + c.Signal.Enabled = true + if c.Signal.LogLevel == "" { + c.Signal.LogLevel = c.Server.LogLevel + } +} + +// applyManagementDefaults configures the management service (always enabled). +func (c *CombinedConfig) applyManagementDefaults(exposedHost string) { + c.Management.Enabled = true + if c.Management.LogLevel == "" { + c.Management.LogLevel = c.Server.LogLevel + } + if c.Management.DataDir == "" || c.Management.DataDir == "/var/lib/netbird/" { + c.Management.DataDir = c.Server.DataDir + } + c.Management.DnsDomain = exposedHost + c.Management.DisableAnonymousMetrics = c.Server.DisableAnonymousMetrics + c.Management.DisableGeoliteUpdate = c.Server.DisableGeoliteUpdate + // Copy auth config from server if management auth issuer is not set + if c.Management.Auth.Issuer == "" && c.Server.Auth.Issuer != "" { + c.Management.Auth = c.Server.Auth + } + + // Copy store config from server if not set + if c.Management.Store.Engine == "" || c.Management.Store.Engine == "sqlite" { + if c.Server.Store.Engine != "" { + c.Management.Store = c.Server.Store + } + } + + // Copy reverse proxy config from server + if len(c.Server.ReverseProxy.TrustedHTTPProxies) > 0 || c.Server.ReverseProxy.TrustedHTTPProxiesCount > 0 || len(c.Server.ReverseProxy.TrustedPeers) > 0 { + c.Management.ReverseProxy = c.Server.ReverseProxy + } +} + +// autoConfigureClientSettings sets up STUN/relay/signal URIs for clients +// External overrides from server config take precedence over auto-generated values +func (c *CombinedConfig) autoConfigureClientSettings(exposedProto, exposedHost, exposedHostPort string, hasExternalStuns, hasExternalRelay, hasExternalSignal bool) { + // Determine relay protocol from exposed protocol + relayProto := "rel" + if exposedProto == "https" { + relayProto = "rels" + } + + // Configure STUN servers for clients + if hasExternalStuns { + // Use external STUN servers from server config + c.Management.Stuns = c.Server.Stuns + } else if len(c.Server.StunPorts) > 0 && len(c.Management.Stuns) == 0 { + // Auto-configure local STUN servers for all ports + for _, port := range c.Server.StunPorts { + c.Management.Stuns = append(c.Management.Stuns, HostConfig{ + URI: "stun:" + net.JoinHostPort(strings.Trim(exposedHost, "[]"), fmt.Sprintf("%d", port)), + }) + } + } + + // Configure relay for clients + if hasExternalRelay { + // Use external relay config from server + c.Management.Relays = c.Server.Relays + } else if len(c.Management.Relays.Addresses) == 0 { + // Auto-configure local relay + c.Management.Relays.Addresses = []string{ + fmt.Sprintf("%s://%s", relayProto, exposedHostPort), + } + } + if c.Management.Relays.Secret == "" { + c.Management.Relays.Secret = c.Server.AuthSecret + } + if c.Management.Relays.CredentialsTTL == "" { + c.Management.Relays.CredentialsTTL = "12h" + } + + // Configure signal for clients + if hasExternalSignal { + // Use external signal URI from server config + c.Management.SignalURI = c.Server.SignalURI + } else if c.Management.SignalURI == "" { + // Auto-configure local signal + c.Management.SignalURI = fmt.Sprintf("%s://%s", exposedProto, exposedHostPort) + } +} + +// LoadConfig loads configuration from a YAML file +func LoadConfig(configPath string) (*CombinedConfig, error) { + cfg := DefaultConfig() + + if configPath == "" { + return cfg, nil + } + + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + + // Populate internal configs from server settings + cfg.ApplySimplifiedDefaults() + + return cfg, nil +} + +// Validate validates the configuration +func (c *CombinedConfig) Validate() error { + if c.Server.ExposedAddress == "" { + return fmt.Errorf("server.exposedAddress is required") + } + if c.Server.DataDir == "" { + return fmt.Errorf("server.dataDir is required") + } + + // Validate STUN ports + seen := make(map[int]bool) + for _, port := range c.Server.StunPorts { + if port <= 0 || port > 65535 { + return fmt.Errorf("invalid server.stunPorts value %d: must be between 1 and 65535", port) + } + if seen[port] { + return fmt.Errorf("duplicate STUN port %d in server.stunPorts", port) + } + seen[port] = true + } + + // authSecret is required only if running local relay (no external relay configured) + hasExternalRelay := len(c.Server.Relays.Addresses) > 0 + if !hasExternalRelay && c.Server.AuthSecret == "" { + return fmt.Errorf("server.authSecret is required when running local relay") + } + + return nil +} + +// HasTLSCert returns true if TLS certificate files are configured +func (c *CombinedConfig) HasTLSCert() bool { + return c.Server.TLS.CertFile != "" && c.Server.TLS.KeyFile != "" +} + +// HasLetsEncrypt returns true if Let's Encrypt is configured +func (c *CombinedConfig) HasLetsEncrypt() bool { + return c.Server.TLS.LetsEncrypt.Enabled && + c.Server.TLS.LetsEncrypt.DataDir != "" && + len(c.Server.TLS.LetsEncrypt.Domains) > 0 +} + +// parseExplicitProtocol parses an explicit protocol string to nbconfig.Protocol +func parseExplicitProtocol(proto string) (nbconfig.Protocol, bool) { + switch strings.ToLower(proto) { + case "udp": + return nbconfig.UDP, true + case "dtls": + return nbconfig.DTLS, true + case "tcp": + return nbconfig.TCP, true + case "http": + return nbconfig.HTTP, true + case "https": + return nbconfig.HTTPS, true + default: + return "", false + } +} + +// parseStunProtocol determines protocol for STUN/TURN servers. +// stun: → UDP, stuns: → DTLS, turn: → UDP, turns: → DTLS +// Explicit proto overrides URI scheme. Defaults to UDP. +func parseStunProtocol(uri, proto string) nbconfig.Protocol { + if proto != "" { + if p, ok := parseExplicitProtocol(proto); ok { + return p + } + } + + uri = strings.ToLower(uri) + switch { + case strings.HasPrefix(uri, "stuns:"): + return nbconfig.DTLS + case strings.HasPrefix(uri, "turns:"): + return nbconfig.DTLS + default: + // stun:, turn:, or no scheme - default to UDP + return nbconfig.UDP + } +} + +// parseSignalProtocol determines protocol for Signal servers. +// https:// → HTTPS, http:// → HTTP. Defaults to HTTPS. +func parseSignalProtocol(uri string) nbconfig.Protocol { + uri = strings.ToLower(uri) + switch { + case strings.HasPrefix(uri, "http://"): + return nbconfig.HTTP + default: + // https:// or no scheme - default to HTTPS + return nbconfig.HTTPS + } +} + +// stripSignalProtocol removes the protocol prefix from a signal URI. +// Returns just the host:port (e.g., "selfhosted2.demo.netbird.io:443"). +func stripSignalProtocol(uri string) string { + uri = strings.TrimPrefix(uri, "https://") + uri = strings.TrimPrefix(uri, "http://") + return uri +} + +func buildRelayConfig(relays RelaysConfig) (*nbconfig.Relay, error) { + var ttl time.Duration + if relays.CredentialsTTL != "" { + var err error + ttl, err = time.ParseDuration(relays.CredentialsTTL) + if err != nil { + return nil, fmt.Errorf("invalid relay credentials TTL %q: %w", relays.CredentialsTTL, err) + } + } + return &nbconfig.Relay{ + Addresses: relays.Addresses, + CredentialsTTL: util.Duration{Duration: ttl}, + Secret: relays.Secret, + }, nil +} + +// buildEmbeddedIdPConfig builds the embedded IdP configuration. +// authStore overrides auth.storage when set. +func (c *CombinedConfig) buildEmbeddedIdPConfig(mgmt ManagementConfig) (*idp.EmbeddedIdPConfig, error) { + authStorageType := mgmt.Auth.Storage.Type + authStorageDSN := c.Server.AuthStore.DSN + if c.Server.AuthStore.Engine != "" { + authStorageType = c.Server.AuthStore.Engine + } + if authStorageType == "" { + authStorageType = "sqlite3" + } + authStorageFile := "" + if authStorageType == "postgres" { + if authStorageDSN == "" { + return nil, fmt.Errorf("authStore.dsn is required when authStore.engine is postgres") + } + } else { + authStorageFile = 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) + } + } + } + + cfg := &idp.EmbeddedIdPConfig{ + 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{ + File: authStorageFile, + DSN: authStorageDSN, + }, + }, + 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 != "" { + cfg.Owner = &idp.OwnerConfig{ + Email: mgmt.Auth.Owner.Email, + Hash: mgmt.Auth.Owner.Password, + } + } + + return cfg, nil +} + +// ToManagementConfig converts CombinedConfig to management server config +func (c *CombinedConfig) ToManagementConfig() (*nbconfig.Config, error) { + mgmt := c.Management + + // Build STUN hosts + var stuns []*nbconfig.Host + for _, s := range mgmt.Stuns { + stuns = append(stuns, &nbconfig.Host{ + URI: s.URI, + Proto: parseStunProtocol(s.URI, s.Proto), + Username: s.Username, + Password: s.Password, + }) + } + + // Build relay config + var relayConfig *nbconfig.Relay + if len(mgmt.Relays.Addresses) > 0 || mgmt.Relays.Secret != "" { + relay, err := buildRelayConfig(mgmt.Relays) + if err != nil { + return nil, err + } + relayConfig = relay + } + + // Build signal config + var signalConfig *nbconfig.Host + if mgmt.SignalURI != "" { + signalConfig = &nbconfig.Host{ + URI: stripSignalProtocol(mgmt.SignalURI), + Proto: parseSignalProtocol(mgmt.SignalURI), + } + } + + // Build store config + storeConfig := nbconfig.StoreConfig{ + Engine: types.Engine(mgmt.Store.Engine), + } + + // Build reverse proxy config + reverseProxy := nbconfig.ReverseProxy{ + TrustedHTTPProxiesCount: mgmt.ReverseProxy.TrustedHTTPProxiesCount, + AccessLogRetentionDays: mgmt.ReverseProxy.AccessLogRetentionDays, + AccessLogCleanupIntervalHours: mgmt.ReverseProxy.AccessLogCleanupIntervalHours, + } + for _, p := range mgmt.ReverseProxy.TrustedHTTPProxies { + if prefix, err := netip.ParsePrefix(p); err == nil { + reverseProxy.TrustedHTTPProxies = append(reverseProxy.TrustedHTTPProxies, prefix) + } + } + for _, p := range mgmt.ReverseProxy.TrustedPeers { + if prefix, err := netip.ParsePrefix(p); err == nil { + reverseProxy.TrustedPeers = append(reverseProxy.TrustedPeers, prefix) + } + } + + // Build HTTP config (required, even if empty) + httpConfig := &nbconfig.HttpServerConfig{} + + // Build embedded IDP config (always enabled in combined server) + embeddedIdP, err := c.buildEmbeddedIdPConfig(mgmt) + if err != nil { + return nil, err + } + + // Set HTTP config fields for embedded IDP + httpConfig.AuthIssuer = mgmt.Auth.Issuer + httpConfig.AuthAudience = "netbird-dashboard" + httpConfig.AuthClientID = httpConfig.AuthAudience + httpConfig.CLIAuthAudience = "netbird-cli" + httpConfig.AuthUserIDClaim = "sub" + httpConfig.AuthKeysLocation = mgmt.Auth.Issuer + "/keys" + httpConfig.OIDCConfigEndpoint = mgmt.Auth.Issuer + "/.well-known/openid-configuration" + httpConfig.IdpSignKeyRefreshEnabled = mgmt.Auth.SignKeyRefreshEnabled + callbackURL := strings.TrimSuffix(httpConfig.AuthIssuer, "/oauth2") + httpConfig.AuthCallbackURL = callbackURL + types.ProxyCallbackEndpointFull + + return &nbconfig.Config{ + Stuns: stuns, + Relay: relayConfig, + Signal: signalConfig, + Datadir: mgmt.DataDir, + DataStoreEncryptionKey: mgmt.Store.EncryptionKey, + HttpConfig: httpConfig, + StoreConfig: storeConfig, + ReverseProxy: reverseProxy, + DisableDefaultPolicy: mgmt.DisableDefaultPolicy, + EmbeddedIdP: embeddedIdP, + HighestSupportedSyncMessageVersion: c.Server.SupportedSyncMessageVersions, + PerAccountHighestSupportedSyncMessageVersion: c.Server.PerAccountSupportedSyncMessageVersions, + }, nil +} + +// ApplyEmbeddedIdPConfig applies embedded IdP configuration to the management config. +// This mirrors the logic in management/cmd/management.go ApplyEmbeddedIdPConfig. +func ApplyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config, mgmtPort int, disableSingleAccMode bool) error { + if cfg.EmbeddedIdP == nil || !cfg.EmbeddedIdP.Enabled { + return nil + } + + // Embedded IdP requires single account mode + if disableSingleAccMode { + return fmt.Errorf("embedded IdP requires single account mode; multiple account mode is not supported with embedded IdP") + } + + // Set LocalAddress for embedded IdP, used for internal JWT validation + cfg.EmbeddedIdP.LocalAddress = fmt.Sprintf("localhost:%d", mgmtPort) + + // Set storage defaults based on Datadir + if cfg.EmbeddedIdP.Storage.Type == "" { + cfg.EmbeddedIdP.Storage.Type = "sqlite3" + } + if cfg.EmbeddedIdP.Storage.Config.File == "" && cfg.Datadir != "" { + cfg.EmbeddedIdP.Storage.Config.File = filePath.Join(cfg.Datadir, "idp.db") + } + + issuer := cfg.EmbeddedIdP.Issuer + + // Ensure HttpConfig exists + if cfg.HttpConfig == nil { + cfg.HttpConfig = &nbconfig.HttpServerConfig{} + } + + // Set HttpConfig values from EmbeddedIdP + cfg.HttpConfig.AuthIssuer = issuer + cfg.HttpConfig.AuthAudience = "netbird-dashboard" + cfg.HttpConfig.CLIAuthAudience = "netbird-cli" + cfg.HttpConfig.AuthUserIDClaim = "sub" + cfg.HttpConfig.AuthKeysLocation = issuer + "/keys" + cfg.HttpConfig.OIDCConfigEndpoint = issuer + "/.well-known/openid-configuration" + cfg.HttpConfig.IdpSignKeyRefreshEnabled = true + + return nil +} + +// EnsureEncryptionKey generates an encryption key if not set. +// Unlike management server, we don't write back to the config file. +func EnsureEncryptionKey(ctx context.Context, cfg *nbconfig.Config) error { + if cfg.DataStoreEncryptionKey != "" { + return nil + } + + log.WithContext(ctx).Infof("DataStoreEncryptionKey is not set, generating a new key") + key, err := crypt.GenerateKey() + if err != nil { + return fmt.Errorf("failed to generate datastore encryption key: %v", err) + } + cfg.DataStoreEncryptionKey = key + keyPreview := key[:8] + "..." + log.WithContext(ctx).Warnf("DataStoreEncryptionKey generated (%s); add it to your config file under 'server.store.encryptionKey' to persist across restarts", keyPreview) + + return nil +} + +// LogConfigInfo logs informational messages about the loaded configuration +func LogConfigInfo(cfg *nbconfig.Config) { + if cfg.EmbeddedIdP != nil && cfg.EmbeddedIdP.Enabled { + log.Infof("running with the embedded IdP: %v", cfg.EmbeddedIdP.Issuer) + } + if cfg.Relay != nil { + log.Infof("Relay addresses: %v", cfg.Relay.Addresses) + } +} diff --git a/combined/cmd/pprof.go b/combined/cmd/pprof.go new file mode 100644 index 000000000..37efd35f0 --- /dev/null +++ b/combined/cmd/pprof.go @@ -0,0 +1,33 @@ +//go:build pprof +// +build pprof + +package cmd + +import ( + "net/http" + _ "net/http/pprof" + "os" + + log "github.com/sirupsen/logrus" +) + +func init() { + addr := pprofAddr() + go pprof(addr) +} + +func pprofAddr() string { + listenAddr := os.Getenv("NB_PPROF_ADDR") + if listenAddr == "" { + return "localhost:6969" + } + + return listenAddr +} + +func pprof(listenAddr string) { + log.Infof("listening pprof on: %s\n", listenAddr) + if err := http.ListenAndServe(listenAddr, nil); err != nil { + log.Fatalf("Failed to start pprof: %v", err) + } +} diff --git a/combined/cmd/root.go b/combined/cmd/root.go new file mode 100644 index 000000000..5f2564e3a --- /dev/null +++ b/combined/cmd/root.go @@ -0,0 +1,758 @@ +package cmd + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/coder/websocket" + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "go.opentelemetry.io/otel/metric" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/encryption" + mgmtServer "github.com/netbirdio/netbird/management/internals/server" + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/netbirdio/netbird/relay/healthcheck" + relayServer "github.com/netbirdio/netbird/relay/server" + "github.com/netbirdio/netbird/relay/server/listener" + "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" + signalServer "github.com/netbirdio/netbird/signal/server" + "github.com/netbirdio/netbird/stun" + "github.com/netbirdio/netbird/util" + "github.com/netbirdio/netbird/util/wsproxy" + wsproxyserver "github.com/netbirdio/netbird/util/wsproxy/server" +) + +var ( + configPath string + config *CombinedConfig + + rootCmd = &cobra.Command{ + Use: "combined", + Short: "Combined Netbird server (Management + Signal + Relay + STUN)", + Long: `Combined Netbird server for self-hosted deployments. + +All services (Management, Signal, Relay) are multiplexed on a single port. +Optional STUN server runs on separate UDP ports. + +Configuration is loaded from a YAML file specified with --config.`, + SilenceUsage: true, + SilenceErrors: true, + RunE: execute, + } +) + +func init() { + rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to YAML configuration file (required)") + _ = rootCmd.MarkPersistentFlagRequired("config") + + rootCmd.AddCommand(newAdminCommands()) + rootCmd.AddCommand(newLegacyTokenCommand()) +} + +func RootCmd() *cobra.Command { + return rootCmd +} + +func Execute() error { + return rootCmd.Execute() +} + +func waitForExitSignal() { + osSigs := make(chan os.Signal, 1) + signal.Notify(osSigs, syscall.SIGINT, syscall.SIGTERM) + <-osSigs +} + +func execute(cmd *cobra.Command, _ []string) error { + if err := initializeConfig(); err != nil { + return err + } + + // Management is required as the base server when signal or relay are enabled + if (config.Signal.Enabled || config.Relay.Enabled) && !config.Management.Enabled { + return fmt.Errorf("management must be enabled when signal or relay are enabled (provides the base HTTP server)") + } + + servers, err := createAllServers(cmd.Context(), config) + if err != nil { + return err + } + + // Register services with management's gRPC server using AfterInit hook + setupServerHooks(servers, config) + + // Start management server (this also starts the HTTP listener) + if servers.mgmtSrv != nil { + if err := servers.mgmtSrv.Start(cmd.Context()); err != nil { + cleanupSTUNListeners(servers.stunListeners) + return fmt.Errorf("failed to start management server: %w", err) + } + } + + // Start all other servers + wg := sync.WaitGroup{} + startServers(&wg, servers.relaySrv, servers.healthcheck, servers.stunServer, servers.metricsServer) + + waitForExitSignal() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err = shutdownServers(ctx, servers.relaySrv, servers.healthcheck, servers.stunServer, servers.mgmtSrv, servers.metricsServer) + wg.Wait() + return err +} + +// initializeConfig loads and validates the configuration, then initializes logging. +func 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) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + if err := config.Validate(); err != nil { + return fmt.Errorf("invalid config: %w", err) + } + + if err := util.InitLog(config.Server.LogLevel, config.Server.LogFile); err != nil { + return fmt.Errorf("failed to initialize log: %w", err) + } + + applyServerStoreEnv(config.Server.Store) + + if err := applyActivityStoreEnv(config.Server.ActivityStore); err != nil { + return err + } + + log.Infof("Starting combined NetBird server") + logConfig(config) + logEnvVars() + return nil +} + +// serverInstances holds all server instances created during startup. +type serverInstances struct { + relaySrv *relayServer.Server + mgmtSrv mgmtServer.Server + signalSrv *signalServer.Server + healthcheck *healthcheck.Server + stunServer *stun.Server + stunListeners []*net.UDPConn + metricsServer *sharedMetrics.Metrics +} + +// createAllServers creates all server instances based on configuration. +func createAllServers(ctx context.Context, cfg *CombinedConfig) (*serverInstances, error) { + metricsServer, err := sharedMetrics.NewServer(cfg.Server.MetricsPort, "") + if err != nil { + return nil, fmt.Errorf("failed to create metrics server: %w", err) + } + servers := &serverInstances{ + metricsServer: metricsServer, + } + + _, tlsSupport, err := handleTLSConfig(cfg) + if err != nil { + return nil, fmt.Errorf("failed to setup TLS config: %w", err) + } + + if err := servers.createRelayServer(cfg, tlsSupport); err != nil { + return nil, err + } + + if err := servers.createManagementServer(ctx, cfg); err != nil { + return nil, err + } + + if err := servers.createSignalServer(ctx, cfg); err != nil { + return nil, err + } + + if err := servers.createHealthcheckServer(cfg); err != nil { + return nil, err + } + + return servers, nil +} + +func (s *serverInstances) createRelayServer(cfg *CombinedConfig, tlsSupport bool) error { + if !cfg.Relay.Enabled { + return nil + } + + var err error + s.stunListeners, err = createSTUNListeners(cfg) + if err != nil { + return err + } + + hashedSecret := sha256.Sum256([]byte(cfg.Relay.AuthSecret)) + authenticator := auth.NewTimedHMACValidator(hashedSecret[:]) + + relayCfg := relayServer.Config{ + Meter: s.metricsServer.Meter, + ExposedAddress: cfg.Relay.ExposedAddress, + AuthValidator: authenticator, + TLSSupport: tlsSupport, + } + + s.relaySrv, err = createRelayServer(relayCfg, s.stunListeners) + if err != nil { + return err + } + + log.Infof("Relay server created") + + if len(s.stunListeners) > 0 { + s.stunServer = stun.NewServer(s.stunListeners, cfg.Relay.Stun.LogLevel) + } + + return nil +} + +func (s *serverInstances) createManagementServer(ctx context.Context, cfg *CombinedConfig) error { + if !cfg.Management.Enabled { + return nil + } + + mgmtConfig, err := cfg.ToManagementConfig() + if err != nil { + return fmt.Errorf("failed to create management config: %w", err) + } + + _, portStr, portErr := net.SplitHostPort(cfg.Server.ListenAddress) + if portErr != nil { + portStr = "443" + } + mgmtPort, _ := strconv.Atoi(portStr) + + if err := ApplyEmbeddedIdPConfig(ctx, mgmtConfig, mgmtPort, false); err != nil { + cleanupSTUNListeners(s.stunListeners) + return fmt.Errorf("failed to apply embedded IdP config: %w", err) + } + + if err := EnsureEncryptionKey(ctx, mgmtConfig); err != nil { + cleanupSTUNListeners(s.stunListeners) + return fmt.Errorf("failed to ensure encryption key: %w", err) + } + + LogConfigInfo(mgmtConfig) + + s.mgmtSrv, err = createManagementServer(cfg, mgmtConfig) + if err != nil { + cleanupSTUNListeners(s.stunListeners) + return fmt.Errorf("failed to create management server: %w", err) + } + + // Inject externally-managed AppMetrics so management uses the shared metrics server + appMetrics, err := telemetry.NewAppMetricsWithMeter(ctx, s.metricsServer.Meter) + if err != nil { + cleanupSTUNListeners(s.stunListeners) + return fmt.Errorf("failed to create management app metrics: %w", err) + } + mgmtServer.Inject[telemetry.AppMetrics](s.mgmtSrv, appMetrics) + + log.Infof("Management server created") + return nil +} + +func (s *serverInstances) createSignalServer(ctx context.Context, cfg *CombinedConfig) error { + if !cfg.Signal.Enabled { + return nil + } + + var err error + s.signalSrv, err = signalServer.NewServer(ctx, s.metricsServer.Meter, "signal_") + if err != nil { + cleanupSTUNListeners(s.stunListeners) + return fmt.Errorf("failed to create signal server: %w", err) + } + + log.Infof("Signal server created") + return nil +} + +func (s *serverInstances) createHealthcheckServer(cfg *CombinedConfig) error { + hCfg := healthcheck.Config{ + ListenAddress: cfg.Server.HealthcheckAddress, + ServiceChecker: s.relaySrv, + } + + var err error + s.healthcheck, err = createHealthCheck(hCfg, s.stunListeners) + return err +} + +// setupServerHooks registers services with management's gRPC server. +func setupServerHooks(servers *serverInstances, cfg *CombinedConfig) { + if servers.mgmtSrv == nil { + return + } + + 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) + } + + 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) { + if srv != nil { + instanceURL := srv.InstanceURL() + log.Infof("Relay server instance URL: %s", instanceURL.String()) + log.Infof("Relay WebSocket multiplexed on management port (no separate relay listener)") + } + + wg.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.Go(func() { + if err := httpHealthcheck.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("failed to start healthcheck server: %v", err) + } + }) + + if stunServer != nil { + 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.Server, metricsServer *sharedMetrics.Metrics) error { + var errs error + + if err := httpHealthcheck.Shutdown(ctx); err != nil { + errs = multierror.Append(errs, fmt.Errorf("failed to close healthcheck server: %w", err)) + } + + if stunServer != nil { + if err := stunServer.Shutdown(); err != nil { + errs = multierror.Append(errs, fmt.Errorf("failed to close STUN server: %w", err)) + } + } + + if srv != nil { + if err := srv.Shutdown(ctx); err != nil { + errs = multierror.Append(errs, fmt.Errorf("failed to close relay server: %w", err)) + } + } + + if mgmtSrv != nil { + log.Infof("shutting down management and signal servers") + if err := mgmtSrv.Stop(); err != nil { + errs = multierror.Append(errs, fmt.Errorf("failed to close management server: %w", err)) + } + } + + if metricsServer != nil { + log.Infof("shutting down metrics server") + if err := metricsServer.Shutdown(ctx); err != nil { + errs = multierror.Append(errs, fmt.Errorf("failed to close metrics server: %w", err)) + } + } + + return errs +} + +func createHealthCheck(hCfg healthcheck.Config, stunListeners []*net.UDPConn) (*healthcheck.Server, error) { + httpHealthcheck, err := healthcheck.NewServer(hCfg) + if err != nil { + cleanupSTUNListeners(stunListeners) + return nil, fmt.Errorf("failed to create healthcheck server: %w", err) + } + return httpHealthcheck, nil +} + +func createRelayServer(cfg relayServer.Config, stunListeners []*net.UDPConn) (*relayServer.Server, error) { + srv, err := relayServer.NewServer(cfg) + if err != nil { + cleanupSTUNListeners(stunListeners) + return nil, fmt.Errorf("failed to create relay server: %w", err) + } + return srv, nil +} + +func cleanupSTUNListeners(stunListeners []*net.UDPConn) { + for _, l := range stunListeners { + _ = l.Close() + } +} + +func createSTUNListeners(cfg *CombinedConfig) ([]*net.UDPConn, error) { + var stunListeners []*net.UDPConn + if cfg.Relay.Stun.Enabled { + for _, port := range cfg.Relay.Stun.Ports { + listener, err := net.ListenUDP("udp", &net.UDPAddr{Port: port}) + if err != nil { + cleanupSTUNListeners(stunListeners) + return nil, fmt.Errorf("failed to create STUN listener on port %d: %w", port, err) + } + stunListeners = append(stunListeners, listener) + log.Infof("STUN server listening on UDP port %d", port) + } + } + return stunListeners, nil +} + +func handleTLSConfig(cfg *CombinedConfig) (*tls.Config, bool, error) { + tlsCfg := cfg.Server.TLS + + if tlsCfg.LetsEncrypt.AWSRoute53 { + log.Debugf("using Let's Encrypt DNS resolver with Route 53 support") + r53 := encryption.Route53TLS{ + DataDir: tlsCfg.LetsEncrypt.DataDir, + Email: tlsCfg.LetsEncrypt.Email, + Domains: tlsCfg.LetsEncrypt.Domains, + } + tc, err := r53.GetCertificate() + if err != nil { + return nil, false, err + } + return tc, true, nil + } + + if cfg.HasLetsEncrypt() { + log.Infof("setting up TLS with Let's Encrypt") + certManager, err := encryption.CreateCertManager(tlsCfg.LetsEncrypt.DataDir, tlsCfg.LetsEncrypt.Domains...) + if err != nil { + return nil, false, fmt.Errorf("failed creating LetsEncrypt cert manager: %w", err) + } + return certManager.TLSConfig(), true, nil + } + + if cfg.HasTLSCert() { + log.Debugf("using file based TLS config") + tc, err := encryption.LoadTLSConfig(tlsCfg.CertFile, tlsCfg.KeyFile) + if err != nil { + return nil, false, err + } + return tc, true, nil + } + + return nil, false, nil +} + +func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (mgmtServer.Server, error) { + mgmt := cfg.Management + + // Extract port from listen address + _, portStr, err := net.SplitHostPort(cfg.Server.ListenAddress) + if err != nil { + // If no port specified, assume default + portStr = "443" + } + mgmtPort, _ := strconv.Atoi(portStr) + + 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: "", + AutoResolveDomains: true, + MgmtPort: mgmtPort, + MgmtMetricsPort: cfg.Server.MetricsPort, + DisableMetrics: mgmt.DisableAnonymousMetrics, + DisableGeoliteUpdate: mgmt.DisableGeoliteUpdate, + // Always enable user deletion from IDP in combined server (embedded IdP is always enabled) + UserDeleteFromIDPEnabled: true, + }, + ) + + return mgmtSrv, nil +} + +// createCombinedHandler creates an HTTP handler that multiplexes Management, Signal (via wsproxy), and Relay WebSocket traffic +func createCombinedHandler(grpcServer *grpc.Server, httpHandler http.Handler, 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) + if relaySrv != nil { + relayAcceptFn = relaySrv.RelayAccept() + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + // Native gRPC traffic (HTTP/2 with gRPC content-type) + case r.ProtoMajor == 2 && (strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") || + strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc+proto")): + grpcServer.ServeHTTP(w, r) + + // WebSocket proxy for Management gRPC + case r.URL.Path == wsproxy.ProxyPath+wsproxy.ManagementComponent: + wsProxy.Handler().ServeHTTP(w, r) + + // WebSocket proxy for Signal gRPC + case r.URL.Path == wsproxy.ProxyPath+wsproxy.SignalComponent: + if cfg.Signal.Enabled { + wsProxy.Handler().ServeHTTP(w, r) + } else { + http.Error(w, "Signal service not enabled", http.StatusNotFound) + } + + // Relay WebSocket + case r.URL.Path == "/relay": + if relayAcceptFn != nil { + handleRelayWebSocket(w, r, relayAcceptFn, cfg) + } else { + http.Error(w, "Relay service not enabled", http.StatusNotFound) + } + + // 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) + } + }) +} + +// handleRelayWebSocket handles incoming WebSocket connections for the relay service +func handleRelayWebSocket(w http.ResponseWriter, r *http.Request, acceptFn func(conn listener.Conn), cfg *CombinedConfig) { + acceptOptions := &websocket.AcceptOptions{ + OriginPatterns: []string{"*"}, + } + + wsConn, err := websocket.Accept(w, r, acceptOptions) + if err != nil { + log.Errorf("failed to accept relay ws connection: %s", err) + return + } + + connRemoteAddr := r.RemoteAddr + if r.Header.Get("X-Real-Ip") != "" && r.Header.Get("X-Real-Port") != "" { + connRemoteAddr = net.JoinHostPort(r.Header.Get("X-Real-Ip"), r.Header.Get("X-Real-Port")) + } + + rAddr, err := net.ResolveTCPAddr("tcp", connRemoteAddr) + if err != nil { + _ = wsConn.Close(websocket.StatusInternalError, "internal error") + return + } + + log.Debugf("Relay WS client connected from: %s", rAddr) + + conn := ws.NewConn(wsConn, rAddr) + acceptFn(conn) +} + +// logConfig prints all configuration parameters for debugging +func logConfig(cfg *CombinedConfig) { + log.Info("=== Configuration ===") + logServerConfig(cfg) + logComponentsConfig(cfg) + logRelayConfig(cfg) + logManagementConfig(cfg) + log.Info("=== End Configuration ===") +} + +func logServerConfig(cfg *CombinedConfig) { + log.Info("--- Server ---") + log.Infof(" Listen address: %s", cfg.Server.ListenAddress) + log.Infof(" Exposed address: %s", cfg.Server.ExposedAddress) + log.Infof(" Healthcheck address: %s", cfg.Server.HealthcheckAddress) + log.Infof(" Metrics port: %d", cfg.Server.MetricsPort) + log.Infof(" Log level: %s", cfg.Server.LogLevel) + log.Infof(" Data dir: %s", cfg.Server.DataDir) + + switch { + case cfg.HasTLSCert(): + log.Infof(" TLS: cert=%s, key=%s", cfg.Server.TLS.CertFile, cfg.Server.TLS.KeyFile) + case cfg.HasLetsEncrypt(): + log.Infof(" TLS: Let's Encrypt (domains=%v)", cfg.Server.TLS.LetsEncrypt.Domains) + default: + log.Info(" TLS: disabled (using reverse proxy)") + } +} + +func logComponentsConfig(cfg *CombinedConfig) { + log.Info("--- Components ---") + log.Infof(" Management: %v (log level: %s)", cfg.Management.Enabled, cfg.Management.LogLevel) + log.Infof(" Signal: %v (log level: %s)", cfg.Signal.Enabled, cfg.Signal.LogLevel) + log.Infof(" Relay: %v (log level: %s)", cfg.Relay.Enabled, cfg.Relay.LogLevel) +} + +func logRelayConfig(cfg *CombinedConfig) { + if !cfg.Relay.Enabled { + return + } + log.Info("--- Relay ---") + log.Infof(" Exposed address: %s", cfg.Relay.ExposedAddress) + log.Infof(" Auth secret: %s...", maskSecret(cfg.Relay.AuthSecret)) + if cfg.Relay.Stun.Enabled { + log.Infof(" STUN ports: %v (log level: %s)", cfg.Relay.Stun.Ports, cfg.Relay.Stun.LogLevel) + } else { + log.Info(" STUN: disabled") + } +} + +func logManagementConfig(cfg *CombinedConfig) { + if !cfg.Management.Enabled { + return + } + log.Info("--- Management ---") + log.Infof(" Data dir: %s", cfg.Management.DataDir) + log.Infof(" DNS domain: %s", cfg.Management.DnsDomain) + log.Infof(" Store engine: %s", cfg.Management.Store.Engine) + if cfg.Server.Store.DSN != "" { + log.Infof(" Store DSN: %s", maskDSNPassword(cfg.Server.Store.DSN)) + } + + log.Info(" Auth (embedded IdP):") + log.Infof(" Issuer: %s", cfg.Management.Auth.Issuer) + log.Infof(" Dashboard redirect URIs: %v", cfg.Management.Auth.DashboardRedirectURIs) + log.Infof(" CLI redirect URIs: %v", cfg.Management.Auth.CLIRedirectURIs) + + log.Info(" Client settings:") + log.Infof(" Signal URI: %s", cfg.Management.SignalURI) + for _, s := range cfg.Management.Stuns { + log.Infof(" STUN: %s", s.URI) + } + if len(cfg.Management.Relays.Addresses) > 0 { + log.Infof(" Relay addresses: %v", cfg.Management.Relays.Addresses) + log.Infof(" Relay credentials TTL: %s", cfg.Management.Relays.CredentialsTTL) + } +} + +// logEnvVars logs all NB_ environment variables that are currently set +func logEnvVars() { + log.Info("=== Environment Variables ===") + found := false + for _, env := range os.Environ() { + if strings.HasPrefix(env, "NB_") { + key, _, _ := strings.Cut(env, "=") + value := os.Getenv(key) + keyLower := strings.ToLower(key) + if strings.Contains(keyLower, "secret") || strings.Contains(keyLower, "key") || strings.Contains(keyLower, "password") { + value = maskSecret(value) + } else if strings.Contains(keyLower, "dsn") { + value = maskDSNPassword(value) + } + log.Infof(" %s=%s", key, value) + found = true + } + } + if !found { + log.Info(" (none set)") + } + log.Info("=== End Environment Variables ===") +} + +// maskDSNPassword masks the password in a DSN string. +// Handles both key=value format ("password=secret") and URI format ("user:secret@host"). +func maskDSNPassword(dsn string) string { + // Key=value format: "host=localhost user=nb password=secret dbname=nb" + if strings.Contains(dsn, "password=") { + parts := strings.Fields(dsn) + for i, p := range parts { + if strings.HasPrefix(p, "password=") { + parts[i] = "password=****" + } + } + return strings.Join(parts, " ") + } + + // URI format: "user:password@host..." + if atIdx := strings.Index(dsn, "@"); atIdx != -1 { + prefix := dsn[:atIdx] + if colonIdx := strings.Index(prefix, ":"); colonIdx != -1 { + return prefix[:colonIdx+1] + "****" + dsn[atIdx:] + } + } + + return dsn +} + +// maskSecret returns first 4 chars of secret followed by "..." +func maskSecret(secret string) string { + if len(secret) <= 4 { + return "****" + } + return secret[:4] + "..." +} diff --git a/combined/cmd/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/config.yaml.example b/combined/config.yaml.example new file mode 100644 index 000000000..66bc71703 --- /dev/null +++ b/combined/config.yaml.example @@ -0,0 +1,136 @@ +# NetBird Combined Server Configuration +# Copy this file to config.yaml and customize for your deployment +# +# This is a Management server with optional embedded Signal, Relay, and STUN services. +# By default, all services run locally. You can use external services instead by +# setting the corresponding override fields. +# +# Architecture: +# - Management: Always runs locally (this IS the management server) +# - Signal: Local by default; set 'signalUri' to use external (disables local) +# - Relay: Local by default; set 'relays' to use external (disables local) +# - STUN: Local on port 3478 by default; set 'stuns' to use external instead + +server: + # Main HTTP/gRPC port for all services (Management, Signal, Relay) + listenAddress: ":443" + + # Public address that peers will use to connect to this server + # Used for relay connections and management DNS domain + # Format: protocol://hostname:port (e.g., https://server.mycompany.com:443) + exposedAddress: "https://server.mycompany.com:443" + + # STUN server ports (defaults to [3478] if not specified; set 'stuns' to use external) + # stunPorts: + # - 3478 + + # Metrics endpoint port + metricsPort: 9090 + + # Healthcheck endpoint address + healthcheckAddress: ":9000" + + # Logging configuration + logLevel: "info" # Default log level for all components: panic, fatal, error, warn, info, debug, trace + logFile: "console" # "console" or path to log file + + # TLS configuration (optional) + tls: + certFile: "" + keyFile: "" + letsencrypt: + enabled: false + dataDir: "" + domains: [] + email: "" + awsRoute53: false + + # Shared secret for relay authentication (required when running local relay) + authSecret: "your-secret-key-here" + + # Data directory for all services + dataDir: "/var/lib/netbird/" + + # ============================================================================ + # External Service Overrides (optional) + # Use these to point to external Signal, Relay, or STUN servers instead of + # running them locally. When set, the corresponding local service is disabled. + # ============================================================================ + + # External STUN servers - disables local STUN server + # stuns: + # - uri: "stun:stun.example.com:3478" + # - uri: "stun:stun.example.com:3479" + + # External relay servers - disables local relay server + # relays: + # addresses: + # - "rels://relay.example.com:443" + # credentialsTTL: "12h" + # secret: "relay-shared-secret" + + # External signal server - disables local signal server + # signalUri: "https://signal.example.com:443" + + # ============================================================================ + # Management Settings + # ============================================================================ + + # Metrics and updates + disableAnonymousMetrics: false + disableGeoliteUpdate: false + + # Embedded authentication/identity provider (Dex) configuration (always enabled) + auth: + # OIDC issuer URL - must be publicly accessible + issuer: "https://example.com/oauth2" + localAuthDisabled: false + signKeyRefreshEnabled: false + # 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" + - "https://app.example.com/nb-silent-auth" + # 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" + # password: "initial-password" + + # Store configuration + store: + engine: "sqlite" # sqlite, postgres, or mysql + dsn: "" # Connection string for postgres or mysql + encryptionKey: "" + # file: "" # Custom SQLite file path (optional, defaults to {dataDir}/store.db) + + # Activity events store configuration (optional, defaults to sqlite in dataDir) + # activityStore: + # engine: "sqlite" # sqlite or postgres + # dsn: "" # Connection string for postgres + # file: "" # Custom SQLite file path (optional, defaults to {dataDir}/events.db) + + # Auth (embedded IdP) store configuration (optional, defaults to sqlite3 in dataDir/idp.db) + # authStore: + # engine: "sqlite3" # sqlite3 or postgres + # dsn: "" # Connection string for postgres (e.g., "host=localhost port=5432 user=postgres password=postgres dbname=netbird_idp sslmode=disable") + # file: "" # Custom SQLite file path (optional, defaults to {dataDir}/idp.db) + + # Reverse proxy settings (optional) + # reverseProxy: + # trustedHTTPProxies: [] # CIDRs of trusted reverse proxies (e.g. ["10.0.0.0/8"]) + # trustedHTTPProxiesCount: 0 # Number of trusted proxies in front of the server (alternative to trustedHTTPProxies) + # 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. diff --git a/combined/main.go b/combined/main.go new file mode 100644 index 000000000..6740ac93e --- /dev/null +++ b/combined/main.go @@ -0,0 +1,13 @@ +package main + +import ( + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/combined/cmd" +) + +func main() { + if err := cmd.Execute(); err != nil { + log.Fatalf("failed to execute command: %v", err) + } +} diff --git a/dns/dns.go b/dns/dns.go index cf089d4ed..c43e5de00 100644 --- a/dns/dns.go +++ b/dns/dns.go @@ -45,6 +45,10 @@ type CustomZone struct { Domain string // Records custom zone records Records []SimpleRecord + // SearchDomainDisabled indicates whether to add match domains to a search domains list or not + SearchDomainDisabled bool + // NonAuthoritative marks user-created zones + NonAuthoritative bool } // SimpleRecord provides a simple DNS record specification for CNAME, A and AAAA records 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..7264f3768 --- /dev/null +++ b/docs/agent-networks/01-end-to-end-flows.md @@ -0,0 +1,217 @@ +# 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 model allowlist + 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 pricing.yaml + 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). +- 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..b64c1ba20 --- /dev/null +++ b/docs/agent-networks/modules/21-management-agentnetwork.md @@ -0,0 +1,225 @@ +# 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/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] + 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] +``` + +### 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` | `{"model_allowlist"?, "prompt_capture":{enabled,redact_pii}}` | – | + | on_response | 5 | `llm_limit_record` | `{}` (runs LAST at runtime) | – | + | on_response | 6 | `cost_meter` | `{}` | – | + | 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. + +## 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. + +### 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. +- **`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. + +## 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`. | +| `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..904de6424 --- /dev/null +++ b/docs/agent-networks/modules/31-proxy-middleware-builtin.md @@ -0,0 +1,365 @@ +# 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 catalog 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}`, token buckets | `cost.usd_total` or `cost.skipped` | 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` | 86 | Registry + `FactoryContext` (ctx, data dir, 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/*` | 181 / 84 / 439 | Token → USD via `proxy/internal/llm/pricing` | +| `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 per-1k rate via +`pricing.Loader`, emits `cost.usd_total` or a closed-set `cost.skipped` +reason (`missing_provider/model/tokens`, `unparseable_tokens`, `zero_tokens`, +`unknown_model`). Loader's hot-reload goroutine is bound to proxy-lifetime +context via `startReloader`. **Key invariant:** provider-shape switch lives +in `pricing.Table.Cost` (sibling doc) — `cost_meter` stays provider-agnostic. + +### 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` | `{model_allowlist: []string, prompt_capture: {enabled, redact_pii}}` | +| `llm_response_parser` | `{redact_pii?, capture_completion?: *bool}` | +| `cost_meter` | `{pricing_path?}` (basename inside data-dir; defaults `pricing.yaml`) | +| `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. + +## 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` shares a `pricing.Loader` via +`atomic.Pointer[Table]`; readers always see a consistent table. Every +middleware is 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` | 17 | Each skip reason, provider-shape, pricing loader integration | +| `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 loader. +- 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..0376bc988 --- /dev/null +++ b/docs/agent-networks/modules/32-proxy-llm-parsers.md @@ -0,0 +1,392 @@ +# 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 loader. + +--- + +## 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/` — embedded-default + hot-reload override table with + symlink-safe Unix loader (build-tagged stub elsewhere). +- `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` | 421 | `Loader`, `Table`, `Entry`; embedded defaults + atomic swap + mtime reload | +| `pricing/pricing_unix.go` | 69 | `O_NOFOLLOW` + fstat-from-FD + 1 MiB cap | +| `pricing/pricing_other.go` | 21 | Stub returning "not supported on this platform" | +| `pricing/pricing_test.go` | 432 | 21 tests — symlink rejection, reload race, path traversal, oversize | +| `pricing/defaults_pricing.yaml` | 85 | go:embed source of truth | +| `fixtures/*` | 21–59 | OAI chat/responses/stream + Anthro messages/stream + pricing starter | + +## 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 `defaults_pricing.yaml` block, +keyed by the **normalised** model id (region prefix + version suffix stripped by +the request parser). `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 catalog + +`Table.Cost` +([pricing.go:129–174](../../../proxy/internal/llm/pricing/pricing.go)) +is the cost formula — most security-relevant math in this module: + +| 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:172-174](../../../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. + +`Loader` +([pricing.go:212–268](../../../proxy/internal/llm/pricing/pricing.go)) +overlays an optional `pricing.yaml` from data-dir on top of the go:embed +defaults. Atomic pointer swap means readers never observe a partial update. +The mtime-poll reloader (30s default cadence) keeps the previous table on +parse failure so cost annotation never goes blank during a botched edit. + +`defaults_pricing.yaml` is the source of truth for built-in pricing. +Operator overrides only carry the entries they want to change. + +## 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` lookup** +([pricing.go:129](../../../proxy/internal/llm/pricing/pricing.go)): + +```go +func (t *Table) Cost(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (float64, bool) +``` + +Nil-safe: `t.Cost` on a nil receiver returns `(0, false)` +([pricing.go:130–132](../../../proxy/internal/llm/pricing/pricing.go)). +`ok=false` means provider or model is absent from the loaded table; the caller +emits `cost.skipped=unknown_model`. + +## Invariants + +1. **Cross-platform pricing build.** `pricing_unix.go` carries the only + functional `loadPricing` (uses `syscall.O_NOFOLLOW` and `f.Stat()` on an + open descriptor — both Unix-only). `pricing_other.go` is a build-tag + fallback that returns `"not supported on this platform"` + ([pricing_other.go:14–16](../../../proxy/internal/llm/pricing/pricing_other.go)). + The proxy is Linux-only in production today; a Windows port needs an + equivalent path-as-handle implementation. Reviewers building on Windows + should expect this surface to return an error at startup if an override + file is configured. + +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. **`defaults_pricing.yaml` is the source of truth.** Compiled into the + binary via `//go:embed` + ([pricing.go:29–30](../../../proxy/internal/llm/pricing/pricing.go)). + `DefaultTable()` parses once and panics on parse failure + ([pricing.go:42–49](../../../proxy/internal/llm/pricing/pricing.go)) + — by design: a broken embedded YAML must not ship to production. + +4. **Loader path validation.** `resolveMiddlewareDataPath` + ([pricing.go:370–394](../../../proxy/internal/llm/pricing/pricing.go)) + rejects absolute paths, traversal segments, and basenames that fail + `basenameRegex = ^[a-zA-Z0-9._-]+$`. The resolved path must remain + inside `baseDir` even after `filepath.Clean`. Tests: + `TestNewLoader_PathValidation`, `TestNewLoader_PathValidation_Extended`, + `TestNewLoader_SymlinkOutsideBaseDirRejected`, `TestNewLoader_SymlinkRejected`. + +5. **Unix loader symlink safety.** `O_NOFOLLOW` on open, `f.Stat()` on the + open descriptor (never re-stat by path), `info.Mode().IsRegular()` check, + `io.LimitReader(f, maxPricingBytes+1)` with a final size assertion + ([pricing_unix.go:25–57](../../../proxy/internal/llm/pricing/pricing_unix.go)). + A mid-read symlink swap is detected because the fstat is on the original + fd. Test: `TestNewLoader_RejectsOversizedFile_FixesM4`. + +6. **`yaml.NewDecoder(...).KnownFields(true)`** + ([pricing.go:397–398](../../../proxy/internal/llm/pricing/pricing.go)) + rejects YAML files that carry fields not in the schema. A typo in an + operator override file fails loud instead of silently zeroing rates. + +## Things to scrutinise + +**Correctness.** Verify OpenAI cached-prompt clamp at +[pricing.go:147–149](../../../proxy/internal/llm/pricing/pricing.go) +short-circuits before subtraction. `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 file 1 MiB cap is orders of magnitude larger than realistic. Confirm +new schema additions are mirrored in both `pricingFile` and `Entry`; +`KnownFields(true)` will reject silently-typo'd operator overrides +otherwise. + +**Concurrency.** `Loader.table` is `atomic.Pointer[Table]`; readers never +block or see a torn table. `Loader.Reload` is one goroutine, cancelled via +context (`TestLoader_ReloadBackgroundLoopCancellation`). `DefaultTable()` +uses `sync.Once`. Per-call `Scanner` instances mean no shared state across +concurrent response-parser calls. + +**Perf.** `Table.Cost` is two map lookups + multiplications, O(1). +`Scanner.Next` is one `ReadString('\n')` per line. Pricing reload poll 30s. + +**Observability.** Reload failures count via `metric.Int64Counter` keyed +`plugin`; warning log rate-limited at 5 min so a broken file doesn't flood. +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` | 21 | Provider-shape switch; cached-rate fallback; cached-clamp; symlink rejection (target outside basedir + symlink to file); path validation matrix; oversize rejection; reload-keeps-previous-on-parse-error; mtime change detection; goroutine cancellation | + +**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), +`pricing.yaml` (realistic-pricing starter for operator overrides). + +## Cross-references + +- Sibling: [31-proxy-middleware-builtin.md](./31-proxy-middleware-builtin.md) + — the chain that calls `llm.Parsers()`, `llm.ParserByName`, + `llm.NewScanner`, `pricing.NewLoader`. +- 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..f553473f8 --- /dev/null +++ b/docs/agent-networks/modules/33-proxy-runtime.md @@ -0,0 +1,194 @@ +# 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 (`MiddlewareDataDir`, `MiddlewareCaptureBudgetBytes`). Non-agent-network targets stay on the no-middleware fast path. + +## 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.MiddlewareDataDir` (string) — base dir for file-backed middleware config (server.go:238-241). +- `proxy.Server.MiddlewareCaptureBudgetBytes` (int64) — process-wide capture cap; defaults to 256 MiB (server.go:248-250). +- `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..b7cda3a97 --- /dev/null +++ b/docs/agent-networks/modules/50-path-routed-providers.md @@ -0,0 +1,251 @@ +# 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`** block in `defaults_pricing.yaml` + (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 override +> the affected entries in `pricing.yaml`. + +## 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 proxy's embedded +`defaults_pricing.yaml` covers **every metered first-party model** the catalog +enumerates — guarded by +`TestDefaultTable_FirstPartyModelCoverage` +([pricing/defaults_coverage_test.go](../../../proxy/internal/llm/pricing/defaults_coverage_test.go)), +which fails if a catalog model has no embedded price. Bedrock entries are keyed +by the **normalised** id the request parser emits (region prefix + version +suffix stripped). Vertex Claude carries no Bedrock-style prefix, so it prices +straight off the `anthropic` block. + +## 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 not in `defaults_pricing.yaml` +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 both the catalog comment +and `defaults_pricing.yaml`. Operators needing exact regional billing override +the relevant entries. + +## 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..fe10b5b63 --- /dev/null +++ b/docs/io.netbird.client.plist @@ -0,0 +1,136 @@ + + + + + + + + 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..8216dd55d --- /dev/null +++ b/docs/netbird-macos.mobileconfig @@ -0,0 +1,163 @@ + + + + + + + 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..e40efd80d --- /dev/null +++ b/docs/netbird-macos.sh @@ -0,0 +1,193 @@ +#!/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 is redacted in this script's log output. + +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' +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)` when the key is `preSharedKey`). +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" + if [[ "$key" == "preSharedKey" ]]; then + log_value='********** (secret)' + fi + log "set $key = $log_value" +} + +# emit_bool writes a boolean plist entry for a given key into the temporary plist file. +# 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. +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, 644) and optionally triggers the NetBird daemon to reload. +main() { + log "applying NetBird MDM policy to $PLIST_PATH" + /bin/mkdir -p "$PLIST_DIR" + start_plist + + is_set "$managementURL" && emit_string managementURL "$managementURL" + is_set "$preSharedKey" && emit_string preSharedKey "$preSharedKey" + is_set "$allowServerSSH" && emit_bool allowServerSSH "$allowServerSSH" + 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" + /bin/chmod 644 "$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..7c5090942 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..07e6c73a6 --- /dev/null +++ b/docs/netbird.adml @@ -0,0 +1,101 @@ + + + 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. + + 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..747447085 --- /dev/null +++ b/docs/netbird.admx @@ -0,0 +1,247 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + allow + disallow + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/testing-privileged.md b/docs/testing-privileged.md new file mode 100644 index 000000000..cf2f23171 --- /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.25-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/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..65c4d813f --- /dev/null +++ b/e2e/agentnetwork/chat_test.go @@ -0,0 +1,545 @@ +//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 the proxy's +// pricing table so a wrong embedded rate or a broken normalization fails the run. +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}, +} + +// 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. +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) +) +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) + + 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 (must be 0 — deliberately unpriced)", model, cost) + assert.Zerof(t, cost, "gateway-prefixed model %q must store cost 0, never a guessed rate", 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 { + if strings.Contains(model, "/") { + assert.Zerof(t, row.CostUsd, "gateway-prefixed model %q is not priced so the cost meter must skip (cost 0)", model) + return + } + 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 +// a uniquely-priced model for body-routed providers and none for the +// path-routed Vertex (whose model lives in the request path). +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) + } + req.Models = &[]api.AgentNetworkProviderModel{ + {Id: modelID, InputPer1k: 0.001, OutputPer1k: 0.002}, + } + } + 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). The first create bootstraps the + // cluster. + ids := make([]string, 0, len(matrix)) + for i, pc := range matrix { + req := providerRequest(pc) + if i == 0 { + req.BootstrapCluster = ptr(harness.AgentNetworkCluster) + } + 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/guardrail_test.go b/e2e/agentnetwork/guardrail_test.go new file mode 100644 index 000000000..6a2487a88 --- /dev/null +++ b/e2e/agentnetwork/guardrail_test.go @@ -0,0 +1,188 @@ +//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; the first bootstraps the cluster. + ids := make([]string, 0, len(providers)) + allowed := make([]string, 0, len(providers)) + for i, pc := range providers { + req := providerRequest(pc) + if i == 0 { + req.BootstrapCluster = ptr(harness.AgentNetworkCluster) + } + 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..17c5e00be --- /dev/null +++ b/e2e/agentnetwork/main_test.go @@ -0,0 +1,46 @@ +//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" +) + +// 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 + } + + return m.Run() +} diff --git a/e2e/agentnetwork/management_test.go b/e2e/agentnetwork/management_test.go new file mode 100644 index 000000000..cfd03f63c --- /dev/null +++ b/e2e/agentnetwork/management_test.go @@ -0,0 +1,221 @@ +//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 with a dummy key (these tests +// never call the upstream) and registers cleanup. +func newProvider(t *testing.T, ctx context.Context, name string) api.AgentNetworkProvider { + t.Helper() + prov, err := srv.CreateProvider(ctx, api.AgentNetworkProviderRequest{ + Name: name, + ProviderId: "openai_api", + UpstreamUrl: "https://api.openai.com", + ApiKey: ptr("sk-dummy-e2e-key"), + BootstrapCluster: ptr("eu.proxy.netbird.test"), + }) + 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 i, pc := range cases { + i, pc := i, pc + t.Run(pc.name, func(t *testing.T) { + req := providerRequest(pc) + req.Name = "lc-" + pc.name + // Bootstrap the cluster on the first create in case the matrix has + // not run (e.g. no provider keys → settings not yet bootstrapped). + if i == 0 { + req.BootstrapCluster = ptr(harness.AgentNetworkCluster) + } + + 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 cluster / +// subdomain stay immutable, then restores the original state. +func TestSettingsRoundTrip(t *testing.T) { + ctx := context.Background() + + // Settings are bootstrapped on first provider create. + newProvider(t, ctx, "Settings Bootstrap") + + before, err := srv.GetSettings(ctx) + require.NoError(t, err, "get settings") + require.NotEmpty(t, before.Cluster, "settings must carry an assigned cluster") + + flipped, err := srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ + EnableLogCollection: !before.EnableLogCollection, + EnablePromptCollection: !before.EnablePromptCollection, + RedactPii: !before.RedactPii, + }) + 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") + assert.Equal(t, before.Cluster, flipped.Cluster, "cluster must be immutable across updates") + assert.Equal(t, before.Subdomain, flipped.Subdomain, "subdomain must be immutable across updates") + + // Restore the original toggles. + _, err = srv.UpdateSettings(ctx, api.AgentNetworkSettingsRequest{ + EnableLogCollection: before.EnableLogCollection, + EnablePromptCollection: before.EnablePromptCollection, + RedactPii: before.RedactPii, + }) + 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/skiptls_test.go b/e2e/agentnetwork/skiptls_test.go new file mode 100644 index 000000000..1f57605e3 --- /dev/null +++ b/e2e/agentnetwork/skiptls_test.go @@ -0,0 +1,141 @@ +//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}, + }, + } + } + + // First create bootstraps the account cluster. + insecureReq := newReq("skip-tls", insecureModel, true) + insecureReq.BootstrapCluster = ptr(harness.AgentNetworkCluster) + 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/vllm_test.go b/e2e/agentnetwork/vllm_test.go new file mode 100644 index 000000000..cd598f1ed --- /dev/null +++ b/e2e/agentnetwork/vllm_test.go @@ -0,0 +1,172 @@ +//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), + BootstrapCluster: ptr(harness.AgentNetworkCluster), + 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..114577d60 --- /dev/null +++ b/e2e/harness/Dockerfile.client @@ -0,0 +1,24 @@ +# 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.25-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" ] +COPY 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..53aa8e342 --- /dev/null +++ b/e2e/harness/agentnetwork.go @@ -0,0 +1,141 @@ +//go:build e2e + +package harness + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "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) +} + +// 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 +} + +// 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) +} + +// GetSettings returns the account's agent-network settings row. It exists only +// after the first provider create bootstraps it. +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. +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) +} + +// 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) +} 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..f53d0ea64 --- /dev/null +++ b/e2e/harness/client.go @@ -0,0 +1,356 @@ +//go:build e2e + +package harness + +import ( + "context" + "errors" + "fmt" + "io" + "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 +} + +// 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) (*Client, error) { + root, err := repoRoot() + 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, + Networks: []string{c.network.Name}, + NetworkAliases: map[string][]string{c.network.Name: {clientAlias}}, + 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}, nil +} + +// 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 { + 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, []string{"netbird", "up"}, tcexec.Multiplexed()) + if err != nil { + return fmt.Errorf("netbird up: %w", err) + } + if code != 0 { + out, _ := io.ReadAll(reader) + return fmt.Errorf("netbird up exited %d: %s", 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 + // dnsProbeRetryWindow bounds DNS-failure retries: the synthesized zone lands a beat after management connects, so early NXDOMAIN is propagation; a zone still absent after this window is a real failure. + dnsProbeRetryWindow = 30 * time.Second + dnsProbeRetryInterval = 2 * time.Second +) + +// ResolveProxyIP GETs https:/// from the client's netns: any HTTP status proves DNS + tunnel and wakes the lazy proxy peer; only DNS failures retry, within dnsProbeRetryWindow. 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(dnsProbeRetryWindow) + 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) || exitErr.ExitCode() != curlExitCouldNotResolve { + return "", fmt.Errorf("no HTTP response from %s: %w (%s)", endpoint, err, strings.TrimSpace(stderr.String())) + } + dnsErr := fmt.Errorf("DNS resolution failed for %s: %s", endpoint, strings.TrimSpace(stderr.String())) + if time.Until(deadline) < dnsProbeRetryInterval { + return "", dnsErr + } + select { + case <-ctx.Done(): + return "", fmt.Errorf("%w (%w)", dnsErr, ctx.Err()) + case <-time.After(dnsProbeRetryInterval): + } + } +} + +// 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)) +} + +// 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) +} + +// post 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. +func (cl *Client) post(ctx context.Context, 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", "POST", url, + "-H", "Content-Type: application/json", + } + for _, h := range extraHeaders { + args = append(args, "-H", h) + } + 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..5723100ca --- /dev/null +++ b/e2e/harness/combined.go @@ -0,0 +1,266 @@ +//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 +} + +// 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) (*Combined, error) { + root, err := repoRoot() + 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, 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) + } + if err := os.MkdirAll(filepath.Join(workDir, "data"), 0o755); err != nil { + _ = net.Remove(ctx) + return nil, fmt.Errorf("create datadir: %w", err) + } + + req := testcontainers.ContainerRequest{ + Image: combinedImage, + ExposedPorts: []string{combinedHTTPPort}, + Networks: []string{net.Name}, + NetworkAliases: map[string][]string{net.Name: {combinedAlias}}, + Env: map[string]string{ + "NB_SETUP_PAT_ENABLED": "true", + // Skip the GeoLite DB download — it blocks startup and agent-network + // ingest doesn't use geolocation. + "NB_DISABLE_GEOLOCATION": "true", + }, + 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..b4bed60a2 --- /dev/null +++ b/e2e/harness/config.go @@ -0,0 +1,26 @@ +//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. +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: true + auth: + issuer: "%s" + store: + engine: "sqlite" +` 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/paths.go b/e2e/harness/paths.go new file mode 100644 index 000000000..d7df6bbfa --- /dev/null +++ b/e2e/harness/paths.go @@ -0,0 +1,29 @@ +//go:build e2e + +package harness + +import ( + "fmt" + "os" + "path/filepath" +) + +// repoRoot walks up from the working directory to the module root (the +// directory holding go.mod), so the Docker build context is correct no matter +// which package the test runs from. +func repoRoot() (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 + } +} diff --git a/e2e/harness/proxy.go b/e2e/harness/proxy.go new file mode 100644 index 000000000..8db2c140f --- /dev/null +++ b/e2e/harness/proxy.go @@ -0,0 +1,122 @@ +//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. +func StartProxy(ctx context.Context, c *Combined, proxyToken string) (*Proxy, error) { + root, err := repoRoot() + 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), + } + + 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..2f3d306cc --- /dev/null +++ b/e2e/harness/vllm.go @@ -0,0 +1,113 @@ +//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" + // 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" +) + +// vllmNginxConf emulates a vLLM OpenAI-compatible server over plain HTTP (vLLM's +// default: no TLS, port 8000). It answers /v1/models with a one-model list and +// any chat/completions path with a canned OpenAI-shaped chat completion carrying +// a non-zero usage block, so the proxy's OpenAI parser records real token +// consumption. Running actual vLLM in CI is infeasible (GPU + multi-GB model +// download), so this stands in for the wire contract the proxy depends on. +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"}]}'; + } + 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}}'; + } + } +} +` + +// 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 +} + +// 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}, + 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.ForListeningPort(vllmPort).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"}, 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/flow/client/client.go b/flow/client/client.go index 318fcfe1e..3f31c2464 100644 --- a/flow/client/client.go +++ b/flow/client/client.go @@ -13,12 +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/connectivity" "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" @@ -26,11 +23,22 @@ import ( "github.com/netbirdio/netbird/util/wsproxy" ) +var ErrClientClosed = errors.New("client is closed") + +// minHealthyDuration is the minimum time a stream must survive before a failure +// resets the backoff timer. Streams that fail faster are considered unhealthy and +// should not reset backoff, so that MaxElapsedTime can eventually stop retries. +const minHealthyDuration = 5 * time.Second + type GRPCClient struct { realClient proto.FlowServiceClient clientConn *grpc.ClientConn stream proto.FlowService_EventsClient - streamMu sync.Mutex + target string + opts []grpc.DialOption + closed bool // prevent creating conn in the middle of the Close + receiving bool // prevent concurrent Receive calls + mu sync.Mutex // protects clientConn, realClient, stream, closed, and receiving } func NewClient(addr, payload, signature string, interval time.Duration) (*GRPCClient, error) { @@ -65,7 +73,8 @@ func NewClient(addr, payload, signature string, interval time.Duration) (*GRPCCl grpc.WithDefaultServiceConfig(`{"healthCheckConfig": {"serviceName": ""}}`), ) - conn, err := grpc.NewClient(fmt.Sprintf("%s:%s", parsedURL.Hostname(), parsedURL.Port()), opts...) + target := parsedURL.Host + conn, err := grpc.NewClient(target, opts...) if err != nil { return nil, fmt.Errorf("creating new grpc client: %w", err) } @@ -73,30 +82,73 @@ func NewClient(addr, payload, signature string, interval time.Duration) (*GRPCCl return &GRPCClient{ realClient: proto.NewFlowServiceClient(conn), clientConn: conn, + target: target, + opts: opts, }, nil } func (c *GRPCClient) Close() error { - c.streamMu.Lock() - defer c.streamMu.Unlock() - + c.mu.Lock() + c.closed = true c.stream = nil - if err := c.clientConn.Close(); err != nil && !errors.Is(err, context.Canceled) { + conn := c.clientConn + c.clientConn = nil + c.mu.Unlock() + + if conn == nil { + return nil + } + + if err := conn.Close(); err != nil && !errors.Is(err, context.Canceled) { return fmt.Errorf("close client connection: %w", err) } return nil } +func (c *GRPCClient) Send(event *proto.FlowEvent) error { + c.mu.Lock() + stream := c.stream + defer c.mu.Unlock() // stream.Send() is not safe to call concurrently from multiple goroutines + + if stream == nil { + return errors.New("stream not initialized") + } + + if err := stream.Send(event); err != nil { + return fmt.Errorf("send flow event: %w", err) + } + + return nil +} + func (c *GRPCClient) Receive(ctx context.Context, interval time.Duration, msgHandler func(msg *proto.FlowEventAck) error) error { + c.mu.Lock() + if c.receiving { + c.mu.Unlock() + return errors.New("concurrent Receive calls are not supported") + } + c.receiving = true + c.mu.Unlock() + defer func() { + c.mu.Lock() + c.receiving = false + c.mu.Unlock() + }() + backOff := defaultBackoff(ctx, interval) operation := func() error { - if err := c.establishStreamAndReceive(ctx, msgHandler); err != nil { - if s, ok := status.FromError(err); ok && s.Code() == codes.Canceled { - return fmt.Errorf("receive: %w: %w", err, context.Canceled) - } + stream, err := c.establishStream(ctx) + if err != nil { + log.Errorf("failed to establish flow stream, retrying: %v", err) + return c.handleRetryableError(err, time.Time{}, backOff) + } + + streamStart := time.Now() + + if err := c.receive(stream, msgHandler); err != nil { log.Errorf("receive failed: %v", err) - return fmt.Errorf("receive: %w", err) + return c.handleRetryableError(err, streamStart, backOff) } return nil } @@ -108,37 +160,106 @@ func (c *GRPCClient) Receive(ctx context.Context, interval time.Duration, msgHan return nil } -func (c *GRPCClient) establishStreamAndReceive(ctx context.Context, msgHandler func(msg *proto.FlowEventAck) error) error { - if c.clientConn.GetState() == connectivity.Shutdown { - return errors.New("connection to flow receiver has been shut down") +// handleRetryableError resets the backoff timer if the stream was healthy long +// enough and recreates the underlying ClientConn so that gRPC's internal +// subchannel backoff does not accumulate and compete with our own retry timer. +// A zero streamStart means the stream was never established. +func (c *GRPCClient) handleRetryableError(err error, streamStart time.Time, backOff backoff.BackOff) error { + if isContextDone(err) { + return backoff.Permanent(err) } - stream, err := c.realClient.Events(ctx, grpc.WaitForReady(true)) - if err != nil { - return fmt.Errorf("create event stream: %w", err) + var permErr *backoff.PermanentError + if errors.As(err, &permErr) { + return err } - err = stream.Send(&proto.FlowEvent{IsInitiator: true}) + // Reset the backoff so the next retry starts with a short delay instead of + // continuing the already-elapsed timer. Only do this if the stream was healthy + // long enough; short-lived connect/drop cycles must not defeat MaxElapsedTime. + if !streamStart.IsZero() && time.Since(streamStart) >= minHealthyDuration { + backOff.Reset() + } + + if recreateErr := c.recreateConnection(); recreateErr != nil { + log.Errorf("recreate connection: %v", recreateErr) + return recreateErr + } + + log.Infof("connection recreated, retrying stream") + return fmt.Errorf("retrying after error: %w", err) +} + +func (c *GRPCClient) recreateConnection() error { + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return backoff.Permanent(ErrClientClosed) + } + + conn, err := grpc.NewClient(c.target, c.opts...) if err != nil { - log.Infof("failed to send initiator message to flow receiver but will attempt to continue. Error: %s", err) + c.mu.Unlock() + return fmt.Errorf("create new connection: %w", err) + } + + old := c.clientConn + c.clientConn = conn + c.realClient = proto.NewFlowServiceClient(conn) + c.stream = nil + c.mu.Unlock() + + _ = old.Close() + + return nil +} + +func (c *GRPCClient) establishStream(ctx context.Context) (proto.FlowService_EventsClient, error) { + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return nil, backoff.Permanent(ErrClientClosed) + } + cl := c.realClient + c.mu.Unlock() + + // open stream outside the lock — blocking operation + stream, err := cl.Events(ctx) + if err != nil { + return nil, fmt.Errorf("create event stream: %w", err) + } + streamReady := false + defer func() { + if !streamReady { + _ = stream.CloseSend() + } + }() + + if err = stream.Send(&proto.FlowEvent{IsInitiator: true}); err != nil { + return nil, fmt.Errorf("send initiator: %w", err) } if err = checkHeader(stream); err != nil { - return fmt.Errorf("check header: %w", err) + return nil, fmt.Errorf("check header: %w", err) } - c.streamMu.Lock() + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return nil, backoff.Permanent(ErrClientClosed) + } c.stream = stream - c.streamMu.Unlock() + c.mu.Unlock() + streamReady = true - return c.receive(stream, msgHandler) + return stream, nil } func (c *GRPCClient) receive(stream proto.FlowService_EventsClient, msgHandler func(msg *proto.FlowEventAck) error) error { for { msg, err := stream.Recv() if err != nil { - return fmt.Errorf("receive from stream: %w", err) + return err } if msg.IsInitiator { @@ -169,7 +290,7 @@ func checkHeader(stream proto.FlowService_EventsClient) error { func defaultBackoff(ctx context.Context, interval time.Duration) backoff.BackOff { return backoff.WithContext(&backoff.ExponentialBackOff{ InitialInterval: 800 * time.Millisecond, - RandomizationFactor: 1, + RandomizationFactor: 0.5, Multiplier: 1.7, MaxInterval: interval / 2, MaxElapsedTime: 3 * 30 * 24 * time.Hour, // 3 months @@ -178,18 +299,11 @@ func defaultBackoff(ctx context.Context, interval time.Duration) backoff.BackOff }, ctx) } -func (c *GRPCClient) Send(event *proto.FlowEvent) error { - c.streamMu.Lock() - stream := c.stream - c.streamMu.Unlock() - - if stream == nil { - return errors.New("stream not initialized") - } - - if err := stream.Send(event); err != nil { - return fmt.Errorf("send flow event: %w", err) - } - - return nil +// 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 { + return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) } diff --git a/flow/client/client_test.go b/flow/client/client_test.go index efe01c003..c8f5f4af4 100644 --- a/flow/client/client_test.go +++ b/flow/client/client_test.go @@ -2,8 +2,11 @@ package client_test import ( "context" + "encoding/binary" "errors" "net" + "sync" + "sync/atomic" "testing" "time" @@ -11,6 +14,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" flow "github.com/netbirdio/netbird/flow/client" "github.com/netbirdio/netbird/flow/proto" @@ -18,21 +23,89 @@ import ( type testServer struct { proto.UnimplementedFlowServiceServer - events chan *proto.FlowEvent - acks chan *proto.FlowEventAck - grpcSrv *grpc.Server - addr string + events chan *proto.FlowEvent + acks chan *proto.FlowEventAck + grpcSrv *grpc.Server + addr string + listener *connTrackListener + closeStream chan struct{} // signal server to close the stream + handlerDone chan struct{} // signaled each time Events() exits + handlerStarted chan struct{} // signaled each time Events() begins +} + +// connTrackListener wraps a net.Listener to track accepted connections +// so tests can forcefully close them to simulate PROTOCOL_ERROR/RST_STREAM. +type connTrackListener struct { + net.Listener + mu sync.Mutex + conns []net.Conn +} + +func (l *connTrackListener) Accept() (net.Conn, error) { + c, err := l.Listener.Accept() + if err != nil { + return nil, err + } + l.mu.Lock() + l.conns = append(l.conns, c) + l.mu.Unlock() + return c, nil +} + +// sendRSTStream writes a raw HTTP/2 RST_STREAM frame with PROTOCOL_ERROR +// (error code 0x1) on every tracked connection. This produces the exact error: +// +// rpc error: code = Internal desc = stream terminated by RST_STREAM with error code: PROTOCOL_ERROR +// +// HTTP/2 RST_STREAM frame format (9-byte header + 4-byte payload): +// +// Length (3 bytes): 0x000004 +// Type (1 byte): 0x03 (RST_STREAM) +// Flags (1 byte): 0x00 +// Stream ID (4 bytes): target stream (must have bit 31 clear) +// Error Code (4 bytes): 0x00000001 (PROTOCOL_ERROR) +func (l *connTrackListener) connCount() int { + l.mu.Lock() + defer l.mu.Unlock() + return len(l.conns) +} + +func (l *connTrackListener) sendRSTStream(streamID uint32) { + l.mu.Lock() + defer l.mu.Unlock() + + frame := make([]byte, 13) // 9-byte header + 4-byte payload + // Length = 4 (3 bytes, big-endian) + frame[0], frame[1], frame[2] = 0, 0, 4 + // Type = RST_STREAM (0x03) + frame[3] = 0x03 + // Flags = 0 + frame[4] = 0x00 + // Stream ID (4 bytes, big-endian, bit 31 reserved = 0) + binary.BigEndian.PutUint32(frame[5:9], streamID) + // Error Code = PROTOCOL_ERROR (0x1) + binary.BigEndian.PutUint32(frame[9:13], 0x1) + + for _, c := range l.conns { + _, _ = c.Write(frame) + } } func newTestServer(t *testing.T) *testServer { - listener, err := net.Listen("tcp", "127.0.0.1:0") + rawListener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) + listener := &connTrackListener{Listener: rawListener} + s := &testServer{ - events: make(chan *proto.FlowEvent, 100), - acks: make(chan *proto.FlowEventAck, 100), - grpcSrv: grpc.NewServer(), - addr: listener.Addr().String(), + events: make(chan *proto.FlowEvent, 100), + acks: make(chan *proto.FlowEventAck, 100), + grpcSrv: grpc.NewServer(), + addr: rawListener.Addr().String(), + listener: listener, + closeStream: make(chan struct{}, 1), + handlerDone: make(chan struct{}, 10), + handlerStarted: make(chan struct{}, 10), } proto.RegisterFlowServiceServer(s.grpcSrv, s) @@ -51,11 +124,23 @@ func newTestServer(t *testing.T) *testServer { } 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() @@ -91,6 +176,8 @@ func (s *testServer) Events(stream proto.FlowService_EventsServer) error { if err := stream.Send(ack); err != nil { return err } + case <-s.closeStream: + return status.Errorf(codes.Internal, "server closing stream") case <-ctx.Done(): return ctx.Err() } @@ -110,16 +197,13 @@ func TestReceive(t *testing.T) { assert.NoError(t, err, "failed to close flow") }) - receivedAcks := make(map[string]bool) + var ackCount atomic.Int32 receiveDone := make(chan struct{}) go func() { err := client.Receive(ctx, 1*time.Second, func(msg *proto.FlowEventAck) error { if !msg.IsInitiator && len(msg.EventId) > 0 { - id := string(msg.EventId) - receivedAcks[id] = true - - if len(receivedAcks) >= 3 { + if ackCount.Add(1) >= 3 { close(receiveDone) } } @@ -130,7 +214,11 @@ func TestReceive(t *testing.T) { } }() - time.Sleep(500 * time.Millisecond) + select { + case <-server.handlerStarted: + case <-time.After(3 * time.Second): + t.Fatal("timeout waiting for stream to be established") + } for i := 0; i < 3; i++ { eventID := uuid.New().String() @@ -153,7 +241,7 @@ func TestReceive(t *testing.T) { t.Fatal("timeout waiting for acks to be processed") } - assert.Equal(t, 3, len(receivedAcks)) + assert.Equal(t, int32(3), ackCount.Load()) } func TestReceive_ContextCancellation(t *testing.T) { @@ -254,3 +342,208 @@ func TestSend(t *testing.T) { t.Fatal("timeout waiting for ack to be received by flow") } } + +func TestNewClient_PermanentClose(t *testing.T) { + server := newTestServer(t) + + client, err := flow.NewClient("http://"+server.addr, "test-payload", "test-signature", 1*time.Second) + require.NoError(t, err) + + err = client.Close() + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + t.Cleanup(cancel) + + done := make(chan error, 1) + go func() { + done <- client.Receive(ctx, 1*time.Second, func(msg *proto.FlowEventAck) error { + return nil + }) + }() + + select { + case err := <-done: + require.ErrorIs(t, err, flow.ErrClientClosed) + case <-time.After(2 * time.Second): + t.Fatal("Receive did not return after Close — stuck in retry loop") + } +} + +func TestNewClient_CloseVerify(t *testing.T) { + server := newTestServer(t) + + client, err := flow.NewClient("http://"+server.addr, "test-payload", "test-signature", 1*time.Second) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + t.Cleanup(cancel) + + done := make(chan error, 1) + go func() { + done <- client.Receive(ctx, 1*time.Second, func(msg *proto.FlowEventAck) error { + return nil + }) + }() + + closeDone := make(chan struct{}, 1) + go func() { + _ = client.Close() + closeDone <- struct{}{} + }() + + select { + case err := <-done: + require.Error(t, err) + case <-time.After(2 * time.Second): + t.Fatal("Receive did not return after Close — stuck in retry loop") + } + + select { + case <-closeDone: + return + case <-time.After(2 * time.Second): + t.Fatal("Close did not return — blocked in retry loop") + } + +} + +func TestClose_WhileReceiving(t *testing.T) { + server := newTestServer(t) + client, err := flow.NewClient("http://"+server.addr, "test-payload", "test-signature", 1*time.Second) + require.NoError(t, err) + + ctx := context.Background() // no timeout — intentional + receiveDone := make(chan struct{}) + go func() { + _ = client.Receive(ctx, 1*time.Second, func(msg *proto.FlowEventAck) error { + return nil + }) + close(receiveDone) + }() + + // Wait for the server-side handler to confirm the stream is established. + select { + case <-server.handlerStarted: + case <-time.After(3 * time.Second): + t.Fatal("timeout waiting for stream to be established") + } + + closeDone := make(chan struct{}) + go func() { + _ = client.Close() + close(closeDone) + }() + + select { + case <-closeDone: + // Close returned — good + case <-time.After(2 * time.Second): + t.Fatal("Close blocked forever — Receive stuck in retry loop") + } + + select { + case <-receiveDone: + case <-time.After(2 * time.Second): + t.Fatal("Receive did not exit after Close") + } +} + +func TestReceive_ProtocolErrorStreamReconnect(t *testing.T) { + server := newTestServer(t) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + + client, err := flow.NewClient("http://"+server.addr, "test-payload", "test-signature", 1*time.Second) + require.NoError(t, err) + + // Cleanups run LIFO: the goroutine-drain registered here runs after Close below, + // which is when Receive has actually returned. Without this, the Receive goroutine + // can outlive the test and call t.Logf after teardown, panicking. + receiveDone := make(chan struct{}) + t.Cleanup(func() { + select { + case <-receiveDone: + case <-time.After(2 * time.Second): + t.Error("Receive goroutine did not exit after Close") + } + }) + t.Cleanup(func() { + err := client.Close() + assert.NoError(t, err, "failed to close flow") + }) + + // Track acks received before and after server-side stream close + var ackCount atomic.Int32 + receivedFirst := make(chan struct{}) + receivedAfterReconnect := make(chan struct{}) + + go func() { + defer close(receiveDone) + err := client.Receive(ctx, 1*time.Second, func(msg *proto.FlowEventAck) error { + if msg.IsInitiator || len(msg.EventId) == 0 { + return nil + } + n := ackCount.Add(1) + if n == 1 { + close(receivedFirst) + } + if n == 2 { + close(receivedAfterReconnect) + } + return nil + }) + if err != nil && !errors.Is(err, context.Canceled) { + t.Logf("receive error: %v", err) + } + }() + + // Wait for stream to be established, then send first ack + select { + case <-server.handlerStarted: + case <-time.After(3 * time.Second): + t.Fatal("timeout waiting for stream to be established") + } + server.acks <- &proto.FlowEventAck{EventId: []byte("before-close")} + + select { + case <-receivedFirst: + case <-time.After(3 * time.Second): + t.Fatal("timeout waiting for first ack") + } + + // Snapshot connection count before injecting the fault. + connsBefore := server.listener.connCount() + + // Send a raw HTTP/2 RST_STREAM frame with PROTOCOL_ERROR on the TCP connection. + // gRPC multiplexes streams on stream IDs 1, 3, 5, ... (odd, client-initiated). + // Stream ID 1 is the client's first stream (our Events bidi stream). + // This produces the exact error the client sees in production: + // "stream terminated by RST_STREAM with error code: PROTOCOL_ERROR" + server.listener.sendRSTStream(1) + + // Wait for the old Events() handler to fully exit so it can no longer + // drain s.acks and drop our injected ack on a broken stream. + select { + case <-server.handlerDone: + case <-time.After(5 * time.Second): + t.Fatal("old Events() handler did not exit after RST_STREAM") + } + + require.Eventually(t, func() bool { + return server.listener.connCount() > connsBefore + }, 5*time.Second, 50*time.Millisecond, "client did not open a new TCP connection after RST_STREAM") + + server.acks <- &proto.FlowEventAck{EventId: []byte("after-close")} + + select { + case <-receivedAfterReconnect: + // Client successfully reconnected and received ack after server-side stream close + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for ack after server-side stream close — client did not reconnect") + } + + assert.GreaterOrEqual(t, int(ackCount.Load()), 2, "should have received acks before and after stream close") + assert.GreaterOrEqual(t, server.listener.connCount(), 2, "client should have created at least 2 TCP connections (original + reconnect)") +} 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/formatter/hook/hook.go b/formatter/hook/hook.go index c0d8c4eba..69758566d 100644 --- a/formatter/hook/hook.go +++ b/formatter/hook/hook.go @@ -60,14 +60,7 @@ func (hook ContextHook) Fire(entry *logrus.Entry) error { entry.Data["context"] = source - switch source { - case HTTPSource: - addHTTPFields(entry) - case GRPCSource: - addGRPCFields(entry) - case SystemSource: - addSystemFields(entry) - } + addFields(entry) return nil } @@ -99,40 +92,19 @@ func (hook ContextHook) parseSrc(filePath string) string { return fmt.Sprintf("%s/%s", pkg, file) } -func addHTTPFields(entry *logrus.Entry) { +func addFields(entry *logrus.Entry) { if ctxReqID, ok := entry.Context.Value(context.RequestIDKey).(string); ok { entry.Data[context.RequestIDKey] = ctxReqID } 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 } -} - -func addGRPCFields(entry *logrus.Entry) { - if ctxReqID, ok := entry.Context.Value(context.RequestIDKey).(string); ok { - entry.Data[context.RequestIDKey] = ctxReqID - } - if ctxAccountID, ok := entry.Context.Value(context.AccountIDKey).(string); ok { - entry.Data[context.AccountIDKey] = ctxAccountID - } - if ctxDeviceID, ok := entry.Context.Value(context.PeerIDKey).(string); ok { - entry.Data[context.PeerIDKey] = ctxDeviceID - } -} - -func addSystemFields(entry *logrus.Entry) { - if ctxReqID, ok := entry.Context.Value(context.RequestIDKey).(string); ok { - entry.Data[context.RequestIDKey] = ctxReqID - } - if ctxInitiatorID, ok := entry.Context.Value(context.UserIDKey).(string); ok { - entry.Data[context.UserIDKey] = ctxInitiatorID - } - if ctxAccountID, ok := entry.Context.Value(context.AccountIDKey).(string); ok { - entry.Data[context.AccountIDKey] = ctxAccountID - } if ctxDeviceID, ok := entry.Context.Value(context.PeerIDKey).(string); ok { entry.Data[context.PeerIDKey] = ctxDeviceID } diff --git a/formatter/txt/formatter.go b/formatter/txt/formatter.go index 3b2a3fb4d..4f174a740 100644 --- a/formatter/txt/formatter.go +++ b/formatter/txt/formatter.go @@ -1,8 +1,6 @@ package txt import ( - "time" - "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/formatter/levels" @@ -18,7 +16,7 @@ type TextFormatter struct { func NewTextFormatter() *TextFormatter { return &TextFormatter{ levelDesc: levels.ValidLevelDesc, - timestampFormat: time.RFC3339, // or RFC3339 + timestampFormat: "2006-01-02T15:04:05.000Z07:00", } } diff --git a/formatter/txt/formatter_test.go b/formatter/txt/formatter_test.go index 590af5d50..1b20a3ebf 100644 --- a/formatter/txt/formatter_test.go +++ b/formatter/txt/formatter_test.go @@ -21,6 +21,6 @@ func TestLogTextFormat(t *testing.T) { result, _ := formatter.Format(someEntry) parsedString := string(result) - expectedString := "^2021-02-21T01:10:30Z WARN \\[(att1: 1, att2: 2|att2: 2, att1: 1)\\] some/fancy/path.go:46: Some Message\\s+$" + expectedString := "^2021-02-21T01:10:30.000Z WARN \\[(att1: 1, att2: 2|att2: 2, att1: 1)\\] some/fancy/path.go:46: Some Message\\s+$" assert.Regexp(t, expectedString, parsedString) } diff --git a/go.mod b/go.mod index 1e3177d7e..ca798decc 100644 --- a/go.mod +++ b/go.mod @@ -1,71 +1,90 @@ module github.com/netbirdio/netbird -go 1.23.0 +go 1.25.5 + +toolchain go1.25.12 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.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.3 - github.com/spf13/cobra v1.7.0 - github.com/spf13/pflag v1.0.5 - github.com/vishvananda/netlink v1.3.0 - golang.org/x/crypto v0.40.0 - golang.org/x/sys v0.34.0 - golang.zx2c4.com/wireguard v0.0.0-20230704135630-469159ecf7d1 - golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 + github.com/sirupsen/logrus v1.9.4 + 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.54.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.73.0 - google.golang.org/protobuf v1.36.8 - gopkg.in/natefinch/lumberjack.v2 v2.0.0 + google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 ) require ( - fyne.io/fyne/v2 v2.5.3 - fyne.io/systray v1.11.0 - github.com/TheJumpCloud/jcapi-go v3.0.0+incompatible - github.com/aws/aws-sdk-go-v2 v1.36.3 - github.com/aws/aws-sdk-go-v2/config v1.29.14 - github.com/aws/aws-sdk-go-v2/service/s3 v1.79.2 + github.com/DeRuina/timberjack v1.4.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/coder/websocket v1.8.13 + github.com/cilium/ebpf v0.19.0 + github.com/coder/websocket v1.8.14 github.com/coreos/go-iptables v0.7.0 - github.com/creack/pty v1.1.18 + 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 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.7.0 + github.com/fsnotify/fsnotify v1.9.0 github.com/gliderlabs/ssh v0.3.8 - github.com/godbus/dbus/v5 v5.1.0 - github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/go-jose/go-jose/v4 v4.1.4 + 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/golang/mock v1.6.0 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.6.0 + github.com/hashicorp/go-version v1.7.0 github.com/jackc/pgx/v5 v5.5.5 github.com/libdns/route53 v1.5.0 - github.com/libp2p/go-netroute v0.2.1 + github.com/libp2p/go-nat v0.2.0 + github.com/libp2p/go-netroute v0.4.0 + github.com/lrh3321/ipset-go v0.0.0-20250619021614-54a0a98ace81 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/nadoo/ipset v0.5.0 - github.com/netbirdio/management-integrations/integrations v0.0.0-20251027212525-d751b79f5d48 + github.com/moby/moby/api v1.54.1 + github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42 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 @@ -73,194 +92,250 @@ require ( github.com/pion/logging v0.2.4 github.com/pion/randutil v0.1.0 github.com/pion/stun/v2 v2.0.0 - github.com/pion/stun/v3 v3.0.0 - github.com/pion/transport/v3 v3.0.7 + github.com/pion/stun/v3 v3.1.0 + github.com/pion/transport/v3 v3.1.1 github.com/pion/turn/v3 v3.0.1 - github.com/prometheus/client_golang v1.22.0 - github.com/quic-go/quic-go v0.49.1 + github.com/pires/go-proxyproto v0.11.0 + github.com/pkg/sftp v1.13.9 + github.com/prometheus/client_golang v1.23.2 + github.com/quic-go/quic-go v0.55.0 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.10.0 - github.com/testcontainers/testcontainers-go v0.31.0 - github.com/testcontainers/testcontainers-go/modules/mysql v0.31.0 - github.com/testcontainers/testcontainers-go/modules/postgres v0.31.0 - github.com/testcontainers/testcontainers-go/modules/redis v0.31.0 + github.com/stretchr/testify v1.11.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 + github.com/testcontainers/testcontainers-go/modules/redis v0.37.0 github.com/things-go/go-socks5 v0.0.4 github.com/ti-mo/conntrack v0.5.1 github.com/ti-mo/netfilter v0.5.2 github.com/vmihailenco/msgpack/v5 v5.4.1 + github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 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.49.0 - go.opentelemetry.io/otel v1.35.0 - go.opentelemetry.io/otel/exporters/prometheus v0.48.0 - go.opentelemetry.io/otel/metric v1.35.0 - go.opentelemetry.io/otel/sdk/metric v1.35.0 - go.uber.org/mock v0.5.0 + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 + go.opentelemetry.io/otel v1.43.0 + 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.6.0 go.uber.org/zap v1.27.0 goauthentik.io/api/v3 v3.2023051.3 - golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 - golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a - golang.org/x/mod v0.26.0 - golang.org/x/net v0.42.0 - golang.org/x/oauth2 v0.30.0 - golang.org/x/sync v0.16.0 - golang.org/x/term v0.33.0 - golang.org/x/time v0.12.0 - google.golang.org/api v0.177.0 + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f + golang.org/x/mobile v0.0.0-20251113184115-a159579294ab + golang.org/x/mod v0.37.0 + golang.org/x/net v0.56.0 + golang.org/x/oauth2 v0.36.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 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-20231020174304-b8a429915ff1 + gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89 + howett.net/plist v1.0.2-0.20250314012144-ee69052608d9 ) require ( - cloud.google.com/go/auth v0.3.0 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect - cloud.google.com/go/compute/metadata v0.6.0 // indirect - dario.cat/mergo v1.0.0 // indirect - filippo.io/edwards25519 v1.1.0 // indirect - github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect - github.com/BurntSushi/toml v1.4.0 // indirect + 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.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-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.4.0 // indirect + github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/Microsoft/hcsshim v0.12.3 // indirect + github.com/adrg/xdg v0.5.3 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // 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/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 github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 // indirect github.com/aws/aws-sdk-go-v2/service/route53 v1.42.3 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect - github.com/aws/smithy-go v1.22.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 // indirect + 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/containerd v1.7.29 // 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 - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/crowdsecurity/go-cs-lib v0.0.25 // indirect + 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 v26.1.5+incompatible // indirect - github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fredbi/uri v1.1.0 // indirect - github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe // indirect - github.com/fyne-io/glfw-js v0.0.0-20241126112943-313d8a0fe1d0 // indirect - github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2 // indirect - github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6 // indirect - github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect - github.com/go-logr/logr v1.4.2 // 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-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-sql-driver/mysql v1.8.1 // indirect - github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect - github.com/go-text/render v0.2.0 // indirect - github.com/go-text/typesetting v0.2.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 + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/loads v0.22.0 // indirect + github.com/go-openapi/spec v0.21.0 // indirect + github.com/go-openapi/strfmt v0.23.0 // indirect + 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-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/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/google/btree v1.1.2 // indirect - github.com/google/pprof v0.0.0-20211214055906-6f57359322fd // indirect - github.com/google/s2a-go v0.1.7 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect - github.com/googleapis/gax-go/v2 v2.12.3 // indirect - github.com/gopherjs/gopherjs v1.17.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // 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/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/jeandeaual/go-locale v0.0.0-20240223122105-ce5225dcaa49 // indirect + github.com/jackpal/go-nat-pmp v1.0.2 // indirect + github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // 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/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/kelseyhightower/envconfig v1.4.0 // indirect - github.com/klauspost/compress v1.18.0 // indirect - github.com/klauspost/cpuid/v2 v2.2.7 // 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.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.7 // indirect - github.com/mattn/go-sqlite3 v1.14.22 // 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-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 github.com/mholt/acmez/v2 v2.0.1 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + 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/nicksnyder/go-i18n/v2 v2.4.0 // indirect - github.com/nxadm/tail v1.4.8 // indirect - github.com/onsi/ginkgo/v2 v2.9.5 // indirect + github.com/nxadm/tail v1.4.11 // indirect + github.com/oklog/ulid v1.3.1 // 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.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.7 // 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.0 // 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.1 // indirect - github.com/prometheus/common v0.62.0 // indirect - github.com/prometheus/procfs v0.15.1 // indirect - github.com/rymdport/portal v0.3.0 // indirect - github.com/shoenig/go-m1cpu v0.1.6 // indirect - github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect - github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect + github.com/pquerna/otp v1.5.0 // indirect + github.com/prometheus/client_model v0.6.2 // 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/ryanuber/go-glob v1.0.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/spf13/cast v1.10.0 // indirect github.com/stretchr/objx v0.5.2 // indirect - github.com/tklauser/go-sysconf v0.3.14 // indirect - github.com/tklauser/numcpus v0.8.0 // indirect - github.com/vishvananda/netns v0.0.4 // 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.3 // indirect - github.com/yuin/goldmark v1.7.1 // indirect + github.com/wlynxg/anet v0.0.5 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/blake3 v0.2.3 // indirect - go.opencensus.io v0.24.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 // indirect - go.opentelemetry.io/otel/sdk v1.35.0 // indirect - go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.mongodb.org/mongo-driver v1.17.9 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/image v0.18.0 // indirect - golang.org/x/text v0.27.0 // indirect - golang.org/x/tools v0.34.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.47.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // 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-20241230120307-6a676aebaaf6 +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/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 diff --git a/go.sum b/go.sum index 61ad8740e..58e30a580 100644 --- a/go.sum +++ b/go.sum @@ -1,121 +1,100 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= -cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= -cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= -cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= -cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= -cloud.google.com/go/auth v0.3.0 h1:PRyzEpGfx/Z9e8+lHsbkoUVXD0gnu4MNmm7Gp8TQNIs= -cloud.google.com/go/auth v0.3.0/go.mod h1:lBv6NKTWp8E3LPzmO1TbiiRKc4drLOfHsgmlH9ogv5w= -cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4= -cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= -cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= -cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -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.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= -dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -fyne.io/fyne/v2 v2.5.3 h1:k6LjZx6EzRZhClsuzy6vucLZBstdH2USDGHSGWq8ly8= -fyne.io/fyne/v2 v2.5.3/go.mod h1:0GOXKqyvNwk3DLmsFu9v0oYM0ZcD1ysGnlHCerKoAmo= -fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg= -fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +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= +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/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/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= -github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +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-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.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/Microsoft/hcsshim v0.12.3 h1:LS9NXqXhMoqNCplK1ApmVSfB4UnVLRDWRapB6EIlxE0= -github.com/Microsoft/hcsshim v0.12.3/go.mod h1:Iyl1WVpZzr+UkzjekHZbV8o5Z9ZkxNGx6CtY2Qg/JVQ= -github.com/TheJumpCloud/jcapi-go v3.0.0+incompatible h1:hqcTK6ZISdip65SR792lwYJTa/axESA0889D3UlZbLo= -github.com/TheJumpCloud/jcapi-go v3.0.0+incompatible/go.mod h1:6B1nuc1MUs6c62ODZDl7hVE5Pv7O2XGSkgg2olnq34I= +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= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= -github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= -github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= -github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14= -github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= -github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g= -github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM= -github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/awnumar/memcall v0.4.0 h1:B7hgZYdfH6Ot1Goaz8jGne/7i8xD4taZie/PNSFZ29g= +github.com/awnumar/memcall v0.4.0/go.mod h1:8xOx1YbfyuCg3Fy6TO8DK0kZUua3V42/goA5Ru47E8w= +github.com/awnumar/memguard v0.23.0 h1:sJ3a1/SWlcuKIQ7MV+R9p0Pvo9CWsMbGZvcZQtmc68A= +github.com/awnumar/memguard v0.23.0/go.mod h1:olVofBrsPdITtJ2HgxQKrEYEMyIBAIciVG4wNnZhW9M= +github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk= +github.com/aws/aws-sdk-go-v2 v1.38.3/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00= +github.com/aws/aws-sdk-go-v2/config v1.31.6 h1:a1t8fXY4GT4xjyJExz4knbuoxSCacB5hT/WgtfPyLjo= +github.com/aws/aws-sdk-go-v2/config v1.31.6/go.mod h1:5ByscNi7R+ztvOGzeUaIu49vkMk2soq5NaH5PYe33MQ= +github.com/aws/aws-sdk-go-v2/credentials v1.18.10 h1:xdJnXCouCx8Y0NncgoptztUocIYLKeQxrCgN6x9sdhg= +github.com/aws/aws-sdk-go-v2/credentials v1.18.10/go.mod h1:7tQk08ntj914F/5i9jC4+2HQTAuJirq7m1vZVIhEkWs= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 h1:wbjnrrMnKew78/juW7I2BtKQwa1qlf6EjQgS69uYY14= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6/go.mod h1:AtiqqNrDioJXuUgz3+3T0mBWN7Hro2n9wll2zRUc0ww= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 h1:uF68eJA6+S9iVr9WgX1NaRGyQ/6MdIyc4JNUo6TN1FA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6/go.mod h1:qlPeVZCGPiobx8wb1ft0GHT5l+dc6ldnwInDFaMvC7Y= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 h1:pa1DEC6JoI0zduhZePp3zmhWvk/xxm4NB8Hy/Tlsgos= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6/go.mod h1:gxEjPebnhWGJoaDdtDkA0JX46VRg1wcTHYe63OfX5pE= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0 h1:lguz0bmOoGzozP9XfRJR1QIayEYo+2vP/No3OfLF0pU= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 h1:R0tNFJqfjHL3900cqhXuwQ+1K4G0xc9Yf8EDbFXCKEw= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6/go.mod h1:y/7sDdu+aJvPtGXr4xYosdpq9a6T9Z0jkXfugmti0rI= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 h1:hncKj/4gR+TPauZgTAsxOxNcvBayhUlYZ6LO/BYiQ30= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6/go.mod h1:OiIh45tp6HdJDDJGnja0mw8ihQGz3VGrUflLqSL0SmM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 h1:LHS1YAIJXJ4K9zS+1d/xa9JAA9sL2QyXIQCQFQW/X08= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6/go.mod h1:c9PCiTEuh0wQID5/KqA32J+HAgZxN9tOGXKCiYJjTZI= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 h1:nEXUSAwyUfLTgnc9cxlDWy637qsq4UWwp3sNAfl0Z3Y= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6/go.mod h1:HGzIULx4Ge3Do2V0FaiYKcyKzOqwrhUZgCI77NisswQ= github.com/aws/aws-sdk-go-v2/service/route53 v1.42.3 h1:MmLCRqP4U4Cw9gJ4bNrCG0mWqEtBlmAVleyelcHARMU= github.com/aws/aws-sdk-go-v2/service/route53 v1.42.3/go.mod h1:AMPjK2YnRh0YgOID3PqhJA1BRNfXDfGOnSsKHtAe8yA= -github.com/aws/aws-sdk-go-v2/service/s3 v1.79.2 h1:tWUG+4wZqdMl/znThEk9tcCy8tTMxq8dW0JTgamohrY= -github.com/aws/aws-sdk-go-v2/service/s3 v1.79.2/go.mod h1:U5SNqwhXB3Xe6F47kXvWihPl/ilGaEDe8HD/50Z9wxc= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= -github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= -github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 h1:ETkfWcXP2KNPLecaDa++5bsQhCRa5M5sLUJa5DWYIIg= +github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3/go.mod h1:+/3ZTqoYb3Ur7DObD00tarKMLMuKg8iqz5CHEanqTnw= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 h1:8OLZnVJPvjnrxEwHFg9hVUof/P4sibH+Ea4KKuqAGSg= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.1/go.mod h1:27M3BpVi0C02UiQh1w9nsBEit6pLhlaH3NHna6WUbDE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 h1:gKWSTnqudpo8dAxqBqZnDoDWCiEh/40FziUjr/mo6uA= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2/go.mod h1:x7+rkNmRoEN1U13A6JE2fXne9EWyJy54o3n6d4mGaXQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 h1:YZPjhyaGzhDQEvsffDEcpycq49nl7fiGcfJTIo8BszI= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.2/go.mod h1:2dIN8qhQfv37BdUYGgEC8Q3tteM3zFxTI1MLO2O3J3c= +github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= +github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE= +github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc= 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/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= +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= @@ -128,267 +107,236 @@ github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+Y github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +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/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= -github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= -github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= -github.com/containerd/containerd v1.7.29 h1:90fWABQsaN9mJhGkoVnuzEY+o1XDPbg9BTC9QTAHnuE= -github.com/containerd/containerd v1.7.29/go.mod h1:azUkWcOvHrWvaiUjSQH0fjzuHIwSPg1WL5PshGP4Szs= +github.com/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-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +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.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= -github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= -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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/crowdsecurity/crowdsec v1.7.7 h1:sduZN763iXsrZodocWDrsR//7nLeffGu+RVkkIsbQkE= +github.com/crowdsecurity/crowdsec v1.7.7/go.mod h1:L1HLGPDnBYCcY+yfSFnuBbQ1G9DHEJN9c+Kevv9F+4Q= +github.com/crowdsecurity/go-cs-bouncer v0.0.21 h1:arPz0VtdVSaz+auOSfHythzkZVLyy18CzYvYab8UJDU= +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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/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= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v26.1.5+incompatible h1:NEAxTwEjxV6VbBMBoGG3zPqbiJosIApZjxlbrG9q3/g= -github.com/docker/docker v26.1.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= -github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/docker v28.0.1+incompatible h1:FCHjSRdXhNRFjlHMTv4jUNlIBbTeRjrWfeFuJp7jpo0= +github.com/docker/docker v28.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +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.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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -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/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8= -github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4= +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/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.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe h1:A/wiwvQ0CAjPkuJytaD+SsXkPU0asQ+guQEIg1BJGX4= -github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe/go.mod h1:d4clgH0/GrRwWjRzJJQXxT/h1TyuNSfF/X64zb/3Ggg= -github.com/fyne-io/glfw-js v0.0.0-20241126112943-313d8a0fe1d0 h1:/1YRWFv9bAWkoo3SuxpFfzpXH0D/bQnTjNXyF4ih7Os= -github.com/fyne-io/glfw-js v0.0.0-20241126112943-313d8a0fe1d0/go.mod h1:gsGA2dotD4v0SR6PmPCYvS9JuOeMwAtmfvDE7mbYXMY= -github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2 h1:hnLq+55b7Zh7/2IRzWCpiTcAvjv/P8ERF+N7+xXbZhk= -github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2/go.mod h1:eO7W361vmlPOrykIg+Rsh1SZ3tQBaOsfzZhsIOb/Lm0= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +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/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-gl/gl v0.0.0-20211210172815-726fda9656d6 h1:zDw5v7qm4yH7N8C8uWd+8Ii9rROdgWxQuGoJ9WDXxfk= -github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -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-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-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.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= +github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= +github.com/go-openapi/errors v0.22.2 h1:rdxhzcBUazEcGccKqbY1Y7NS8FDcMyIRr0934jrYnZg= +github.com/go-openapi/errors v0.22.2/go.mod h1:+n/5UdIqdVnLIJ6Q9Se8HNGUXYaY6CN8ImWzfi/Gzp0= +github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= +github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco= +github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs= +github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= +github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= +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-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= -github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/go-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.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= -github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +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.0 h1:fbzsgbmk04KiWtE+c3ZD4W2nmCRzBqrqQOvYlwAOdho= -github.com/go-text/typesetting v0.2.0/go.mod h1:2+owI/sxa73XA581LAzVuEBZ3WEEV2pXeDswCH/3i1I= -github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66 h1:GUrm65PQPlhFSKjLPGOZNPNxLCybjzjYBzjfoBGaDUY= -github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -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/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.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/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= +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.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= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 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 v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -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/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +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.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/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.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/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/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= 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-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -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/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= -github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= -github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/googleapis/gax-go/v2 v2.12.3 h1:5/zPPDvw8Q1SuXjrqrZslrqT7dL/uJT2CQii/cLCKqA= -github.com/googleapis/gax-go/v2 v2.12.3/go.mod h1:AKloxT6GtNbaLm8QTNSidHUVsHYcBHwWRvkNFJUQcS4= -github.com/gopacket/gopacket v1.1.1 h1:zbx9F9d6A7sWNkFKrvMBZTfGgxFoY4NgUudFVVHMfcw= -github.com/gopacket/gopacket v1.1.1/go.mod h1:HavMeONEl7W9036of9LbSWoonqhH7HA1+ZRO+rMIvFs= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gopherjs/gopherjs v0.0.0-20211219123610-ec9572f70e60/go.mod h1:cz9oNYuRUWGdHmLF2IodMLkAhcPtXeULvcBNagUrxTI= -github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= -github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= -github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/goxjs/gl v0.0.0-20210104184919-e3fafc6f8f2a/go.mod h1:dy/f2gjY09hwVfIyATps4G2ai7/hLwLkc5TrPqONuXY= +github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= +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.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= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.2-0.20240212192251-757544f21357 h1:Fkzd8ktnpOR9h47SXHe2AYPwelXLH2GjGsjlAloiWfo= 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 v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= -github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= -github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +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/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.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= -github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +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-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= +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-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +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.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= -github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= -github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= -github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +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/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/huin/goupnp v1.2.0 h1:uOKW26NG1hsSSbXIZ1IR7XP9Gjd1U8pnLaCMgntmkmY= +github.com/huin/goupnp v1.2.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -399,8 +347,23 @@ 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/jeandeaual/go-locale v0.0.0-20240223122105-ce5225dcaa49 h1:Po+wkNdMmN+Zj1tDsJQy7mJlPlwGNQd9JZoPjObagf8= -github.com/jeandeaual/go-locale v0.0.0-20240223122105-ce5225dcaa49/go.mod h1:YiutDnxPRLk5DLUFj6Rw4pRBBURZY07GFr54NdV9mQg= +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/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +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/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= @@ -409,22 +372,26 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +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/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e h1:LvL4XsI70QxOGHed6yhQtAU34Kx3Qq2wwBzGFKY8zKk= -github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +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.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= -github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +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= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -436,48 +403,63 @@ 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/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +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-20240513124658-fba389f38bae h1:dIZY4ULFcto4tAFlj1FYZl8ztUZ13bdq+PLY+NOfbyI= github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= -github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +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.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/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= github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= 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.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= -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/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= -github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= -github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= -github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= -github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +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= @@ -486,68 +468,73 @@ 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/modern-go/reflect2 v1.0.1/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/nadoo/ipset v0.5.0 h1:5GJUAuZ7ITQQQGne5J96AmFjRtI8Avlbk6CabzYWVUc= -github.com/nadoo/ipset v0.5.0/go.mod h1:rYF5DQLRGGoQ8ZSWeK+6eX5amAuPqwFkWjhQlEITGJQ= -github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= -github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= -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/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/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-20251027212525-d751b79f5d48 h1:moJbL1uuaWR35yUgHZ6suijjqqW8/qGCuPPBXu5MeWQ= -github.com/netbirdio/management-integrations/integrations v0.0.0-20251027212525-d751b79f5d48/go.mod h1:ifKa2jGPsOzZhJFo72v2AE5nMP3GYvlhoZ9JV6lHlJ8= +github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42 h1:F3zS5fT9xzD1OFLfcdAE+3FfyiwjGukF1hvj0jErgs8= +github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42/go.mod h1:n47r67ZSPgwSmT/Z1o48JjZQW9YJ6m/6Bd/uAXkL3Pg= github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9axERMVN63dqyFqnvuD+EMJHzM7mNGON8= github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ= 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-20241230120307-6a676aebaaf6 h1:X5h5QgP7uHAv78FWgHV8+WYLjHxK9v3ilkVXT1cpCrQ= -github.com/netbirdio/wireguard-go v0.0.0-20241230120307-6a676aebaaf6/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA= -github.com/nicksnyder/go-i18n/v2 v2.4.0 h1:3IcvPOAvnCKwNm0TB0dLDTuawWEj+ax/RERNC+diLMM= -github.com/nicksnyder/go-i18n/v2 v2.4.0/go.mod h1:nxYSZE9M0bf3Y70gPQjN9ha7XNHX7gMc814+6wVyEI4= +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 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= +github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= +github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= +github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/okta/okta-sdk-golang/v2 v2.18.0 h1:cfDasMb7CShbZvOrF6n+DnLevWwiHgedWMGJ8M8xKDc= github.com/okta/okta-sdk-golang/v2 v2.18.0/go.mod h1:dz30v3ctAiMb7jpsCngGfQUAEGm1/NsWT92uTbNDQIs= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 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.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= -github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/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/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= -github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= -github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0= -github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +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= -github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q= -github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8= +github.com/pion/dtls/v3 v3.0.9 h1:4AijfFRm8mAjd1gfdlB1wzJF3fjjR/VPIpJgkEtvYmM= +github.com/pion/dtls/v3 v3.0.9/go.mod h1:abApPjgadS/ra1wvUzHLc3o2HvoxppAh+NZkyApL4Os= github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= @@ -557,94 +544,81 @@ github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0= github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ= -github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= -github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= +github.com/pion/stun/v3 v3.1.0 h1:bS1jjT3tGWZ4UPmIUeyalOylamTMTFg1OvXtY/r6seM= +github.com/pion/stun/v3 v3.1.0/go.mod h1:egmx1CUcfSSGJxQCOjtVlomfPqmQ58BibPyuOWNGQEU= github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= github.com/pion/transport/v2 v2.2.4 h1:41JJK6DZQYSeVLxILA2+F4ZkKb4Xd/tFJZRFZQ9QAlo= github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0= github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= -github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= -github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= +github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= +github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= github.com/pion/turn/v3 v3.0.1 h1:wLi7BTQr6/Q20R0vt/lHbjv6y4GChFtC33nkYbasoT8= github.com/pion/turn/v3 v3.0.1/go.mod h1:MrJDKgqryDyWy1/4NT9TWfXWGMC7UHT6pJIv1+gMeNE= github.com/pion/turn/v4 v4.1.1 h1:9UnY2HB99tpDyz3cVVZguSxcqkJ1DsTSZ+8TGruh4fc= github.com/pion/turn/v4 v4.1.1/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4Jjs8= +github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4= +github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -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.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +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/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +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-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= -github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/quic-go/quic-go v0.49.1 h1:e5JXpUyF0f2uFjckQzD8jTghZrOUK1xxDqqZhlwixo0= -github.com/quic-go/quic-go v0.49.1/go.mod h1:s2wDnmCdooUQBmQfpUSTCYBl1/D4FcqbULMMkASvR6s= +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= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +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/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/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.8.0 h1:P2KMzcFwrPoSjkF1WLRPsp3UMLyql8L4v9hQpVeK5so= github.com/rs/cors v1.8.0/go.mod h1:EBwu+T5AvHOcXwvZIkQFjUN6s8Czyqw12GL/Y0tUyRM= github.com/rs/xid v1.3.0 h1:6NjYksEUlhurdVehpc7S7dk6DAmcKv8V9gG0FsVN2U4= github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +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/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/rymdport/portal v0.3.0 h1:QRHcwKwx3kY5JTQcsVhmhC3TGqGQb9LFghVNUy8AdB8= -github.com/rymdport/portal v0.3.0/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= -github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -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/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= -github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= -github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= -github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= -github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= -github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +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/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= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= 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/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= -github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= -github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= -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/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/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/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 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= @@ -652,54 +626,50 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= -github.com/testcontainers/testcontainers-go v0.31.0 h1:W0VwIhcEVhRflwL9as3dhY6jXjVCA27AkmbnZ+UTh3U= -github.com/testcontainers/testcontainers-go v0.31.0/go.mod h1:D2lAoA0zUFiSY+eAflqK5mcUx/A5hrrORaEQrd0SefI= -github.com/testcontainers/testcontainers-go/modules/mysql v0.31.0 h1:790+S8ewZYCbG+o8IiFlZ8ZZ33XbNO6zV9qhU6xhlRk= -github.com/testcontainers/testcontainers-go/modules/mysql v0.31.0/go.mod h1:REFmO+lSG9S6uSBEwIMZCxeI36uhScjTwChYADeO3JA= -github.com/testcontainers/testcontainers-go/modules/postgres v0.31.0 h1:isAwFS3KNKRbJMbWv+wolWqOFUECmjYZ+sIRZCIBc/E= -github.com/testcontainers/testcontainers-go/modules/postgres v0.31.0/go.mod h1:ZNYY8vumNCEG9YI59A9d6/YaMY49uwRhmeU563EzFGw= -github.com/testcontainers/testcontainers-go/modules/redis v0.31.0 h1:5X6GhOdLwV86zcW8sxppJAMtsDC9u+r9tb3biBc9GKs= -github.com/testcontainers/testcontainers-go/modules/redis v0.31.0/go.mod h1:dKi5xBwy1k4u8yb3saQHu7hMEJwewHXxzbcMAuLiA6o= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg= +github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM= +github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0 h1:LqUos1oR5iuuzorFnSvxsHNdYdCHB/DfI82CuT58wbI= +github.com/testcontainers/testcontainers-go/modules/mysql v0.37.0/go.mod h1:vHEEHx5Kf+uq5hveaVAMrTzPY8eeRZcKcl23MRw5Tkc= +github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 h1:hsVwFkS6s+79MbKEO+W7A1wNIw1fmkMtF4fg83m6kbc= +github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0/go.mod h1:Qj/eGbRbO/rEYdcRLmN+bEojzatP/+NS1y8ojl2PQsc= +github.com/testcontainers/testcontainers-go/modules/redis v0.37.0 h1:9HIY28I9ME/Zmb+zey1p/I1mto5+5ch0wLX+nJdOsQ4= +github.com/testcontainers/testcontainers-go/modules/redis v0.37.0/go.mod h1:Abu9g/25Qv+FkYVx3U4Voaynou1c+7D0HIhaQJXvk6E= github.com/things-go/go-socks5 v0.0.4 h1:jMQjIc+qhD4z9cITOMnBiwo9dDmpGuXmBlkRFrl/qD0= github.com/things-go/go-socks5 v0.0.4/go.mod h1:sh4K6WHrmHZpjxLTCHyYtXYH8OUuD+yZun41NomR1IQ= github.com/ti-mo/conntrack v0.5.1 h1:opEwkFICnDbQc0BUXl73PHBK0h23jEIFVjXsqvF4GY0= 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/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= -github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= -github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= -github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY= -github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE= +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.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= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= -github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQdrZk= -github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs= -github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= -github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= +github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg= -github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y= +github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg= +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.1.32/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.0/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.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U= -github.com/yuin/goldmark v1.7.1/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= @@ -710,501 +680,244 @@ github.com/zeebo/blake3 v0.2.3 h1:TFoLXsjeXqRNFxSbk35Dk4YtszE/MQQGK10BH4ptoTg= github.com/zeebo/blake3 v0.2.3/go.mod h1:mjJjZpnsyIVtVgTOSpJ9vmRE4wgDeyt2HU3qXvvKCaQ= github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= -go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= -go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= -go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= -go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 h1:Xs2Ncz0gNihqu9iosIZ5SkBbWo5T8JhhLJFMQL1qmLI= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0/go.mod h1:vy+2G/6NvVMpwGX/NyLqcC41fxepnuKHk16E6IZUcJc= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU= +go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= -go.opentelemetry.io/otel/exporters/prometheus v0.48.0 h1:sBQe3VNGUjY9IKWQC6z2lNqa5iGbDSxhs60ABwK4y0s= -go.opentelemetry.io/otel/exporters/prometheus v0.48.0/go.mod h1:DtrbMzoZWwQHyrQmCfLam5DZbnmorsGbOtTbYHycU5o= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= -go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= -go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= -go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= -go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 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.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= -go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +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.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= 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= 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-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= -golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= -golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +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/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mobile v0.0.0-20211207041440-4e6c2922fdee/go.mod h1:pe2sM7Uk+2Su1y7u/6Z8KJ24D7lepUjFZbhFOrmDfuQ= -golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a h1:sYbmY3FwUWCBTodZL1S3JUuOvaW6kM2o+clDzzDNBWg= -golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a/go.mod h1:Ede7gF0KGoHlj822RtphAHK1jLdrcuRBZg0sF1Q+SPc= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +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/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/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= -golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= -golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +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.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +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.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +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.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-20181026203630-95b1ffbd15a5/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-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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= 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.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.17.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.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= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= -golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= -golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +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.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.1-0.20180807135948-17ff2d5776d2/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= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +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.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +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= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= -golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.8-0.20211022200916-316ba0b74098/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= 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.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +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.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= 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= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= -google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= -google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= -google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= -google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= -google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8= -google.golang.org/api v0.177.0 h1:8a0p/BbPa65GlqGWtUKxot4p0TV8OGOfyTjtmkXNXmk= -google.golang.org/api v0.177.0/go.mod h1:srbhue4MLjkjbkux5p3dw/ocYOSZTaIEvf7bCOnFQDw= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.276.0 h1:nVArUtfLEihtW+b0DdcqRGK1xoEm2+ltAihyztq7MKY= +google.golang.org/api v0.276.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 h1:KAeGQVN3M9nD0/bQXnr/ClcEMJ968gUXJQ9pwfSynuQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463 h1:hE3bRWtU6uceqlh4fhrSnUyjKHMKB9KrTLLG+bc0ddM= -google.golang.org/genproto/googleapis/api v0.0.0-20250324211829-b45e905df463/go.mod h1:U90ffi8eUL9MwPcrJylN5+Mk2v3vuPDptd5yyNUiRR8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= -google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI= +google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= -gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 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.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= -gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= 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= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= @@ -1216,17 +929,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.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY= -gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= -gvisor.dev/gvisor v0.0.0-20231020174304-b8a429915ff1 h1:qDCwdCWECGnwQSQC01Dpnp09fRHxJs9PbktotUqG+hs= -gvisor.dev/gvisor v0.0.0-20231020174304-b8a429915ff1/go.mod h1:8hmigyCdYtw5xJGfQDJzSH5Ju8XEIDBnpyi8+O6GRt8= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +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 new file mode 100644 index 000000000..00b5ce745 --- /dev/null +++ b/idp/dex/config.go @@ -0,0 +1,669 @@ +package dex + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "log/slog" + "net/url" + "os" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" + "gopkg.in/yaml.v3" + + "github.com/dexidp/dex/server" + "github.com/dexidp/dex/storage" + "github.com/dexidp/dex/storage/sql" + + "github.com/netbirdio/netbird/idp/dex/web" +) + +// parseDuration parses a duration string (e.g., "6h", "24h", "168h"). +func parseDuration(s string) (time.Duration, error) { + return time.ParseDuration(s) +} + +// YAMLConfig represents the YAML configuration file format (mirrors dex's config format) +type YAMLConfig struct { + Issuer string `yaml:"issuer" json:"issuer"` + Storage Storage `yaml:"storage" json:"storage"` + Web Web `yaml:"web" json:"web"` + GRPC GRPC `yaml:"grpc" json:"grpc"` + OAuth2 OAuth2 `yaml:"oauth2" json:"oauth2"` + Expiry Expiry `yaml:"expiry" json:"expiry"` + Logger Logger `yaml:"logger" json:"logger"` + Frontend Frontend `yaml:"frontend" json:"frontend"` + + // StaticConnectors are user defined connectors specified in the config file + StaticConnectors []Connector `yaml:"connectors" json:"connectors"` + + // StaticClients cause the server to use this list of clients rather than + // querying the storage. Write operations, like creating a client, will fail. + StaticClients []storage.Client `yaml:"staticClients" json:"staticClients"` + + // If enabled, the server will maintain a list of passwords which can be used + // to identify a user. + EnablePasswordDB bool `yaml:"enablePasswordDB" json:"enablePasswordDB"` + + // 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. +type Web struct { + HTTP string `yaml:"http" json:"http"` + HTTPS string `yaml:"https" json:"https"` + AllowedOrigins []string `yaml:"allowedOrigins" json:"allowedOrigins"` + AllowedHeaders []string `yaml:"allowedHeaders" json:"allowedHeaders"` +} + +// GRPC is the config for the gRPC API. +type GRPC struct { + Addr string `yaml:"addr" json:"addr"` + TLSCert string `yaml:"tlsCert" json:"tlsCert"` + TLSKey string `yaml:"tlsKey" json:"tlsKey"` + TLSClientCA string `yaml:"tlsClientCA" json:"tlsClientCA"` +} + +// OAuth2 describes enabled OAuth2 extensions. +type OAuth2 struct { + SkipApprovalScreen bool `yaml:"skipApprovalScreen" json:"skipApprovalScreen"` + AlwaysShowLoginScreen bool `yaml:"alwaysShowLoginScreen" json:"alwaysShowLoginScreen"` + PasswordConnector string `yaml:"passwordConnector" json:"passwordConnector"` + ResponseTypes []string `yaml:"responseTypes" json:"responseTypes"` + GrantTypes []string `yaml:"grantTypes" json:"grantTypes"` +} + +// Expiry holds configuration for the validity period of components. +type Expiry struct { + SigningKeys string `yaml:"signingKeys" json:"signingKeys"` + IDTokens string `yaml:"idTokens" json:"idTokens"` + AuthRequests string `yaml:"authRequests" json:"authRequests"` + DeviceRequests string `yaml:"deviceRequests" json:"deviceRequests"` + RefreshTokens RefreshTokensExpiry `yaml:"refreshTokens" json:"refreshTokens"` +} + +// RefreshTokensExpiry holds configuration for refresh token expiry. +type RefreshTokensExpiry struct { + ReuseInterval string `yaml:"reuseInterval" json:"reuseInterval"` + ValidIfNotUsedFor string `yaml:"validIfNotUsedFor" json:"validIfNotUsedFor"` + AbsoluteLifetime string `yaml:"absoluteLifetime" json:"absoluteLifetime"` + DisableRotation bool `yaml:"disableRotation" json:"disableRotation"` +} + +// Logger holds configuration required to customize logging. +type Logger struct { + Level string `yaml:"level" json:"level"` + Format string `yaml:"format" json:"format"` +} + +// Frontend holds the server's frontend templates and assets config. +type Frontend struct { + Dir string `yaml:"dir" json:"dir"` + Theme string `yaml:"theme" json:"theme"` + Issuer string `yaml:"issuer" json:"issuer"` + LogoURL string `yaml:"logoURL" json:"logoURL"` + Extra map[string]string `yaml:"extra" json:"extra"` +} + +// Storage holds app's storage configuration. +type Storage struct { + Type string `yaml:"type" json:"type"` + Config map[string]interface{} `yaml:"config" json:"config"` +} + +type Password storage.Password + +func (p *Password) UnmarshalYAML(node *yaml.Node) error { + var data struct { + Email string `yaml:"email"` + Username string `yaml:"username"` + UserID string `yaml:"userID"` + Hash string `yaml:"hash"` + HashFromEnv string `yaml:"hashFromEnv"` + } + if err := node.Decode(&data); err != nil { + return err + } + *p = Password(storage.Password{ + Email: data.Email, + Username: data.Username, + UserID: data.UserID, + }) + if len(data.Hash) == 0 && len(data.HashFromEnv) > 0 { + data.Hash = os.Getenv(data.HashFromEnv) + } + if len(data.Hash) == 0 { + return fmt.Errorf("no password hash provided for user %s", data.Email) + } + + // If this value is a valid bcrypt, use it. + _, bcryptErr := bcrypt.Cost([]byte(data.Hash)) + if bcryptErr == nil { + p.Hash = []byte(data.Hash) + return nil + } + + // For backwards compatibility try to base64 decode this value. + hashBytes, err := base64.StdEncoding.DecodeString(data.Hash) + if err != nil { + return fmt.Errorf("malformed bcrypt hash: %v", bcryptErr) + } + if _, err := bcrypt.Cost(hashBytes); err != nil { + return fmt.Errorf("malformed bcrypt hash: %v", err) + } + p.Hash = hashBytes + return nil +} + +// Connector is a connector configuration that can unmarshal YAML dynamically. +type Connector struct { + Type string `yaml:"type" json:"type"` + Name string `yaml:"name" json:"name"` + ID string `yaml:"id" json:"id"` + Config map[string]interface{} `yaml:"config" json:"config"` +} + +// ToStorageConnector converts a Connector to storage.Connector type. +// It maps custom connector types (e.g., "zitadel", "entra") to Dex-native types +// and augments the config with OIDC defaults when needed. +func (c *Connector) ToStorageConnector() (storage.Connector, error) { + dexType, augmentedConfig := mapConnectorToDex(c.Type, c.Config) + + data, err := json.Marshal(augmentedConfig) + if err != nil { + return storage.Connector{}, fmt.Errorf("failed to marshal connector config: %v", err) + } + + return storage.Connector{ + ID: c.ID, + Type: dexType, + Name: c.Name, + Config: data, + }, nil +} + +// mapConnectorToDex maps custom connector types to Dex-native types and applies +// OIDC defaults. This ensures static connectors from config files or env vars +// 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", "adfs": + return "oidc", applyOIDCDefaults(connType, config) + default: + return connType, config + } +} + +// applyOIDCDefaults clones the config map, sets common OIDC defaults, +// and applies provider-specific overrides. +func applyOIDCDefaults(connType string, config map[string]interface{}) map[string]interface{} { + augmented := make(map[string]interface{}, len(config)+4) + for k, v := range config { + augmented[k] = v + } + setDefault(augmented, "scopes", []string{"openid", "profile", "email"}) + setDefault(augmented, "insecureEnableGroups", true) + setDefault(augmented, "insecureSkipEmailVerified", true) + + switch connType { + case "zitadel": + setDefault(augmented, "getUserInfo", true) + case "entra": + 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 +} + +// setDefault sets a key in the map only if it doesn't already exist. +func setDefault(m map[string]interface{}, key string, value interface{}) { + if _, ok := m[key]; !ok { + m[key] = value + } +} + +// StorageConfig is a configuration that can create a storage. +type StorageConfig interface { + Open(logger *slog.Logger) (storage.Storage, error) +} + +// OpenStorage opens a storage based on the config +func (s *Storage) OpenStorage(logger *slog.Logger) (storage.Storage, error) { + switch s.Type { + case "sqlite3": + file, _ := s.Config["file"].(string) + if file == "" { + return nil, fmt.Errorf("sqlite3 storage requires 'file' config") + } + return newSQLite3(file).Open(logger) + case "postgres": + dsn, _ := s.Config["dsn"].(string) + if dsn == "" { + return nil, fmt.Errorf("postgres storage requires 'dsn' config") + } + pg, err := parsePostgresDSN(dsn) + if err != nil { + return nil, fmt.Errorf("invalid postgres DSN: %w", err) + } + return pg.Open(logger) + default: + return nil, fmt.Errorf("unsupported storage type: %s", s.Type) + } +} + +// parsePostgresDSN parses a DSN into a sql.Postgres config. +// It accepts both URI format (postgres://user:pass@host:port/dbname?sslmode=disable) +// and libpq key=value format (host=localhost port=5432 dbname=mydb), including quoted values. +func parsePostgresDSN(dsn string) (*sql.Postgres, error) { + var params map[string]string + var err error + + if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") { + params, err = parsePostgresURI(dsn) + } else { + params, err = parsePostgresKeyValue(dsn) + } + if err != nil { + return nil, err + } + + host := params["host"] + if host == "" { + host = "localhost" + } + + var port uint16 = 5432 + if p, ok := params["port"]; ok && p != "" { + v, err := strconv.ParseUint(p, 10, 16) + if err != nil { + return nil, fmt.Errorf("invalid port %q: %w", p, err) + } + if v == 0 { + return nil, fmt.Errorf("invalid port %q: must be non-zero", p) + } + port = uint16(v) + } + + dbname := params["dbname"] + if dbname == "" { + return nil, fmt.Errorf("dbname is required in DSN") + } + + pg := &sql.Postgres{ + NetworkDB: sql.NetworkDB{ + Host: host, + Port: port, + Database: dbname, + User: params["user"], + Password: params["password"], + }, + } + + if sslMode := params["sslmode"]; sslMode != "" { + switch sslMode { + case "disable", "allow", "prefer", "require", "verify-ca", "verify-full": + pg.SSL.Mode = sslMode + default: + return nil, fmt.Errorf("unsupported sslmode %q: valid values are disable, allow, prefer, require, verify-ca, verify-full", sslMode) + } + } + + return pg, nil +} + +// parsePostgresURI parses a postgres:// or postgresql:// URI into parameter key-value pairs. +func parsePostgresURI(dsn string) (map[string]string, error) { + u, err := url.Parse(dsn) + if err != nil { + return nil, fmt.Errorf("invalid postgres URI: %w", err) + } + + params := make(map[string]string) + + if u.User != nil { + params["user"] = u.User.Username() + if p, ok := u.User.Password(); ok { + params["password"] = p + } + } + if u.Hostname() != "" { + params["host"] = u.Hostname() + } + if u.Port() != "" { + params["port"] = u.Port() + } + + dbname := strings.TrimPrefix(u.Path, "/") + if dbname != "" { + params["dbname"] = dbname + } + + for k, v := range u.Query() { + if len(v) > 0 { + params[k] = v[0] + } + } + + return params, nil +} + +// parsePostgresKeyValue parses a libpq key=value DSN string, handling single-quoted values +// (e.g., password='my pass' host=localhost). +func parsePostgresKeyValue(dsn string) (map[string]string, error) { + params := make(map[string]string) + s := strings.TrimSpace(dsn) + + for s != "" { + eqIdx := strings.IndexByte(s, '=') + if eqIdx < 0 { + break + } + key := strings.TrimSpace(s[:eqIdx]) + + value, rest, err := parseDSNValue(s[eqIdx+1:]) + if err != nil { + return nil, fmt.Errorf("%w for key %q", err, key) + } + + params[key] = value + s = strings.TrimSpace(rest) + } + + return params, nil +} + +// parseDSNValue parses the next value from a libpq key=value string positioned after the '='. +// It returns the parsed value and the remaining unparsed string. +func parseDSNValue(s string) (value, rest string, err error) { + if len(s) > 0 && s[0] == '\'' { + return parseQuotedDSNValue(s[1:]) + } + // Unquoted value: read until whitespace. + idx := strings.IndexAny(s, " \t\n") + if idx < 0 { + return s, "", nil + } + return s[:idx], s[idx:], nil +} + +// parseQuotedDSNValue parses a single-quoted value starting after the opening quote. +// Libpq uses ” to represent a literal single quote inside quoted values. +func parseQuotedDSNValue(s string) (value, rest string, err error) { + var buf strings.Builder + for len(s) > 0 { + if s[0] == '\'' { + if len(s) > 1 && s[1] == '\'' { + buf.WriteByte('\'') + s = s[2:] + continue + } + return buf.String(), s[1:], nil + } + buf.WriteByte(s[0]) + s = s[1:] + } + return "", "", fmt.Errorf("unterminated quoted value") +} + +// Validate validates the configuration +func (c *YAMLConfig) Validate() error { + if c.Issuer == "" { + return fmt.Errorf("no issuer specified in config file") + } + if c.Storage.Type == "" { + return fmt.Errorf("no storage type specified in config file") + } + if c.Web.HTTP == "" && c.Web.HTTPS == "" { + return fmt.Errorf("must supply a HTTP/HTTPS address to listen on") + } + 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{ + Issuer: c.Issuer, + Storage: stor, + Logger: logger, + SkipApprovalScreen: c.OAuth2.SkipApprovalScreen, + AllowedOrigins: c.Web.AllowedOrigins, + AllowedHeaders: c.Web.AllowedHeaders, + Web: server.WebConfig{ + Issuer: c.Frontend.Issuer, + LogoURL: c.Frontend.LogoURL, + Theme: c.Frontend.Theme, + 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 + if c.Frontend.Dir == "" { + cfg.Web.WebFS = web.FS() + } + + if len(c.OAuth2.ResponseTypes) > 0 { + cfg.SupportedResponseTypes = c.OAuth2.ResponseTypes + } + + 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 + } + } + if c.Expiry.AuthRequests != "" { + if d, err := parseDuration(c.Expiry.AuthRequests); err == nil { + cfg.AuthRequestsValidFor = d + } + } + if c.Expiry.DeviceRequests != "" { + if d, err := parseDuration(c.Expiry.DeviceRequests); err == nil { + cfg.DeviceRequestsValidFor = d + } + } + + return cfg +} + +// GetRefreshTokenPolicy creates a RefreshTokenPolicy from the expiry config. +// This should be called after ToServerConfig and the policy set on the config. +func (c *YAMLConfig) GetRefreshTokenPolicy(logger *slog.Logger) (*server.RefreshTokenPolicy, error) { + return server.NewRefreshTokenPolicy( + logger, + c.Expiry.RefreshTokens.DisableRotation, + c.Expiry.RefreshTokens.ValidIfNotUsedFor, + c.Expiry.RefreshTokens.AbsoluteLifetime, + c.Expiry.RefreshTokens.ReuseInterval, + ) +} + +// LoadConfig loads configuration from a YAML file +func LoadConfig(path string) (*YAMLConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var cfg YAMLConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + + if err := cfg.Validate(); err != nil { + return nil, err + } + + return &cfg, nil +} diff --git a/idp/dex/connector.go b/idp/dex/connector.go new file mode 100644 index 000000000..fb20fdcc3 --- /dev/null +++ b/idp/dex/connector.go @@ -0,0 +1,432 @@ +// Package dex provides an embedded Dex OIDC identity provider. +package dex + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/dexidp/dex/storage" +) + +// ConnectorConfig represents the configuration for an identity provider connector +type ConnectorConfig struct { + // ID is the unique identifier for the connector + ID string + // Name is a human-readable name for the connector + Name string + // Type is the connector type (oidc, google, microsoft) + Type string + // Issuer is the OIDC issuer URL (for OIDC-based connectors) + Issuer string + // ClientID is the OAuth2 client ID + ClientID string + // ClientSecret is the OAuth2 client secret + ClientSecret string + // RedirectURI is the OAuth2 redirect URI + RedirectURI string +} + +// CreateConnector creates a new connector in Dex storage. +// It maps the connector config to the appropriate Dex connector type and configuration. +func (p *Provider) CreateConnector(ctx context.Context, cfg *ConnectorConfig) (*ConnectorConfig, error) { + // Fill in the redirect URI if not provided + if cfg.RedirectURI == "" { + cfg.RedirectURI = p.GetRedirectURI() + } + + storageConn, err := p.buildStorageConnector(cfg) + if err != nil { + return nil, fmt.Errorf("failed to build connector: %w", err) + } + + if err := p.storage.CreateConnector(ctx, storageConn); err != nil { + return nil, fmt.Errorf("failed to create connector: %w", err) + } + + p.logger.Info("connector created", "id", cfg.ID, "type", cfg.Type) + return cfg, nil +} + +// GetConnector retrieves a connector by ID from Dex storage. +func (p *Provider) GetConnector(ctx context.Context, id string) (*ConnectorConfig, error) { + conn, err := p.storage.GetConnector(ctx, id) + if err != nil { + if err == storage.ErrNotFound { + return nil, err + } + return nil, fmt.Errorf("failed to get connector: %w", err) + } + + return p.parseStorageConnector(conn) +} + +// ListConnectors returns all connectors from Dex storage (excluding the local connector). +func (p *Provider) ListConnectors(ctx context.Context) ([]*ConnectorConfig, error) { + connectors, err := p.storage.ListConnectors(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list connectors: %w", err) + } + + result := make([]*ConnectorConfig, 0, len(connectors)) + for _, conn := range connectors { + // Skip the local password connector + if conn.ID == "local" && conn.Type == "local" { + continue + } + + cfg, err := p.parseStorageConnector(conn) + if err != nil { + p.logger.Warn("failed to parse connector", "id", conn.ID, "error", err) + continue + } + result = append(result, cfg) + } + + return result, nil +} + +// UpdateConnector updates an existing connector in Dex storage. +// 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) { + if cfg.Type != "" && cfg.Type != inferIdentityProviderType(old.Type, cfg.ID, nil) { + return storage.Connector{}, errors.New("connector type change not allowed") + } + + configData, err := overlayConnectorConfig(old.Config, cfg) + if err != nil { + return storage.Connector{}, fmt.Errorf("failed to overlay connector config: %w", err) + } + + 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) + } + + p.logger.Info("connector updated", "id", cfg.ID, "type", cfg.Type) + return nil +} + +// 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.Issuer != "" { + m["issuer"] = cfg.Issuer + } + if cfg.ClientID != "" { + m["clientID"] = cfg.ClientID + } + if cfg.ClientSecret != "" { + m["clientSecret"] = cfg.ClientSecret + } + if cfg.RedirectURI != "" { + m["redirectURI"] = cfg.RedirectURI + } + return encodeConnectorConfig(m) +} + +// DeleteConnector removes a connector from Dex storage. +func (p *Provider) DeleteConnector(ctx context.Context, id string) error { + // Prevent deletion of the local connector + if id == "local" { + return fmt.Errorf("cannot delete the local password connector") + } + + if err := p.storage.DeleteConnector(ctx, id); err != nil { + return fmt.Errorf("failed to delete connector: %w", err) + } + + p.logger.Info("connector deleted", "id", id) + return nil +} + +// GetRedirectURI returns the default redirect URI for connectors. +func (p *Provider) GetRedirectURI() string { + if p.config == nil { + return "" + } + issuer := strings.TrimSuffix(p.config.Issuer, "/") + if !strings.HasSuffix(issuer, "/oauth2") { + issuer += "/oauth2" + } + return issuer + "/callback" +} + +// buildStorageConnector creates a storage.Connector from ConnectorConfig. +// It handles the type-specific configuration for each connector type. +func (p *Provider) buildStorageConnector(cfg *ConnectorConfig) (storage.Connector, error) { + redirectURI := p.resolveRedirectURI(cfg.RedirectURI) + + var dexType string + var configData []byte + var err error + + switch cfg.Type { + case "oidc", "zitadel", "entra", "okta", "pocketid", "authentik", "keycloak", "adfs": + dexType = "oidc" + configData, err = buildOIDCConnectorConfig(cfg, redirectURI) + case "google": + dexType = "google" + configData, err = buildOAuth2ConnectorConfig(cfg, redirectURI) + case "microsoft": + dexType = "microsoft" + configData, err = buildOAuth2ConnectorConfig(cfg, redirectURI) + default: + return storage.Connector{}, fmt.Errorf("unsupported connector type: %s", cfg.Type) + } + if err != nil { + return storage.Connector{}, err + } + + return storage.Connector{ID: cfg.ID, Type: dexType, Name: cfg.Name, Config: configData}, nil +} + +// resolveRedirectURI returns the redirect URI, using a default if not provided +func (p *Provider) resolveRedirectURI(redirectURI string) string { + if redirectURI != "" || p.config == nil { + return redirectURI + } + issuer := strings.TrimSuffix(p.config.Issuer, "/") + if !strings.HasSuffix(issuer, "/oauth2") { + issuer += "/oauth2" + } + return issuer + "/callback" +} + +// buildOIDCConnectorConfig creates config for OIDC-based connectors +func buildOIDCConnectorConfig(cfg *ConnectorConfig, redirectURI string) ([]byte, error) { + oidcConfig := map[string]interface{}{ + "issuer": cfg.Issuer, + "clientID": cfg.ClientID, + "clientSecret": cfg.ClientSecret, + "redirectURI": redirectURI, + "scopes": []string{"openid", "profile", "email"}, + "insecureEnableGroups": true, + //some providers don't return email verified, so we need to skip it if not present (e.g., Entra, Okta, Duo) + "insecureSkipEmailVerified": true, + } + switch cfg.Type { + case "zitadel": + 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) +} + +// buildOAuth2ConnectorConfig creates config for OAuth2 connectors (google, microsoft) +func buildOAuth2ConnectorConfig(cfg *ConnectorConfig, redirectURI string) ([]byte, error) { + return encodeConnectorConfig(map[string]interface{}{ + "clientID": cfg.ClientID, + "clientSecret": cfg.ClientSecret, + "redirectURI": redirectURI, + }) +} + +// parseStorageConnector converts a storage.Connector back to ConnectorConfig. +// It infers the original identity provider type from the Dex connector type and ID. +func (p *Provider) parseStorageConnector(conn storage.Connector) (*ConnectorConfig, error) { + cfg := &ConnectorConfig{ + ID: conn.ID, + Name: conn.Name, + } + + if len(conn.Config) == 0 { + cfg.Type = conn.Type + return cfg, nil + } + + var configMap map[string]interface{} + if err := decodeConnectorConfig(conn.Config, &configMap); err != nil { + return nil, fmt.Errorf("failed to parse connector config: %w", err) + } + + // Extract common fields + if v, ok := configMap["clientID"].(string); ok { + cfg.ClientID = v + } + if v, ok := configMap["clientSecret"].(string); ok { + cfg.ClientSecret = v + } + if v, ok := configMap["redirectURI"].(string); ok { + cfg.RedirectURI = v + } + if v, ok := configMap["issuer"].(string); ok { + cfg.Issuer = v + } + + // Infer the original identity provider type from Dex connector type and ID + cfg.Type = inferIdentityProviderType(conn.Type, conn.ID, configMap) + + return cfg, nil +} + +// inferIdentityProviderType determines the original identity provider type +// based on the Dex connector type, connector ID, and configuration. +func inferIdentityProviderType(dexType, connectorID string, _ map[string]interface{}) string { + if dexType != "oidc" { + return dexType + } + return inferOIDCProviderType(connectorID) +} + +// 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", "adfs"} { + if strings.Contains(connectorIDLower, provider) { + return provider + } + } + return "oidc" +} + +// encodeConnectorConfig serializes connector config to JSON bytes. +func encodeConnectorConfig(config map[string]interface{}) ([]byte, error) { + return json.Marshal(config) +} + +// decodeConnectorConfig deserializes connector config from JSON bytes. +func decodeConnectorConfig(data []byte, v interface{}) error { + return json.Unmarshal(data, v) +} + +// ensureLocalConnector creates a local (password) connector if it doesn't exist +func ensureLocalConnector(ctx context.Context, stor storage.Storage) error { + // Check specifically for the local connector + _, err := stor.GetConnector(ctx, "local") + if err == nil { + // Local connector already exists + return nil + } + if !errors.Is(err, storage.ErrNotFound) { + return fmt.Errorf("failed to get local connector: %w", err) + } + + // Create a local connector for password authentication + localConnector := storage.Connector{ + ID: "local", + Type: "local", + Name: "Email", + } + + if err := stor.CreateConnector(ctx, localConnector); err != nil { + return fmt.Errorf("failed to create local connector: %w", err) + } + + return nil +} + +// HasNonLocalConnectors checks if there are any connectors other than the local connector. +func (p *Provider) HasNonLocalConnectors(ctx context.Context) (bool, error) { + connectors, err := p.storage.ListConnectors(ctx) + if err != nil { + return false, fmt.Errorf("failed to list connectors: %w", err) + } + + p.logger.Info("checking for non-local connectors", "total_connectors", len(connectors)) + for _, conn := range connectors { + p.logger.Info("found connector in storage", "id", conn.ID, "type", conn.Type, "name", conn.Name) + if conn.ID != "local" || conn.Type != "local" { + p.logger.Info("found non-local connector", "id", conn.ID) + return true, nil + } + } + p.logger.Info("no non-local connectors found") + return false, nil +} + +// DisableLocalAuth removes the local (password) connector. +// Returns an error if no other connectors are configured. +func (p *Provider) DisableLocalAuth(ctx context.Context) error { + hasOthers, err := p.HasNonLocalConnectors(ctx) + if err != nil { + return err + } + if !hasOthers { + return fmt.Errorf("cannot disable local authentication: no other identity providers configured") + } + + // Check if local connector exists + _, err = p.storage.GetConnector(ctx, "local") + if errors.Is(err, storage.ErrNotFound) { + // Already disabled + return nil + } + if err != nil { + return fmt.Errorf("failed to check local connector: %w", err) + } + + // Delete the local connector + if err := p.storage.DeleteConnector(ctx, "local"); err != nil { + return fmt.Errorf("failed to delete local connector: %w", err) + } + + p.logger.Info("local authentication disabled") + return nil +} + +// EnableLocalAuth creates the local (password) connector if it doesn't exist. +func (p *Provider) EnableLocalAuth(ctx context.Context) error { + return ensureLocalConnector(ctx, p.storage) +} + +// ensureStaticConnectors creates or updates static connectors in storage +func ensureStaticConnectors(ctx context.Context, stor storage.Storage, connectors []Connector) error { + for _, conn := range connectors { + storConn, err := conn.ToStorageConnector() + if err != nil { + return fmt.Errorf("failed to convert connector %s: %w", conn.ID, err) + } + _, err = stor.GetConnector(ctx, conn.ID) + if err == storage.ErrNotFound { + if err := stor.CreateConnector(ctx, storConn); err != nil { + return fmt.Errorf("failed to create connector %s: %w", conn.ID, err) + } + continue + } + if err != nil { + return fmt.Errorf("failed to get connector %s: %w", conn.ID, err) + } + if err := stor.UpdateConnector(ctx, conn.ID, func(old storage.Connector) (storage.Connector, error) { + old.Name = storConn.Name + old.Config = storConn.Config + return old, nil + }); err != nil { + return fmt.Errorf("failed to update connector %s: %w", conn.ID, err) + } + } + return nil +} 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/logrus_handler.go b/idp/dex/logrus_handler.go new file mode 100644 index 000000000..d911cb417 --- /dev/null +++ b/idp/dex/logrus_handler.go @@ -0,0 +1,113 @@ +package dex + +import ( + "context" + "log/slog" + + "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/formatter" +) + +// LogrusHandler is an slog.Handler that delegates to logrus. +// This allows Dex to use the same log format as the rest of NetBird. +type LogrusHandler struct { + logger *logrus.Logger + attrs []slog.Attr + groups []string +} + +// NewLogrusHandler creates a new slog handler that wraps logrus with NetBird's text formatter. +func NewLogrusHandler(level slog.Level) *LogrusHandler { + logger := logrus.New() + formatter.SetTextFormatter(logger) + + // Map slog level to logrus level + switch level { + case slog.LevelDebug: + logger.SetLevel(logrus.DebugLevel) + case slog.LevelInfo: + logger.SetLevel(logrus.InfoLevel) + case slog.LevelWarn: + logger.SetLevel(logrus.WarnLevel) + case slog.LevelError: + logger.SetLevel(logrus.ErrorLevel) + default: + logger.SetLevel(logrus.WarnLevel) + } + + return &LogrusHandler{logger: logger} +} + +// Enabled reports whether the handler handles records at the given level. +func (h *LogrusHandler) Enabled(_ context.Context, level slog.Level) bool { + switch level { + case slog.LevelDebug: + return h.logger.IsLevelEnabled(logrus.DebugLevel) + case slog.LevelInfo: + return h.logger.IsLevelEnabled(logrus.InfoLevel) + case slog.LevelWarn: + return h.logger.IsLevelEnabled(logrus.WarnLevel) + case slog.LevelError: + return h.logger.IsLevelEnabled(logrus.ErrorLevel) + default: + return true + } +} + +// Handle handles the Record. +func (h *LogrusHandler) Handle(_ context.Context, r slog.Record) error { + fields := make(logrus.Fields) + + // Add pre-set attributes + for _, attr := range h.attrs { + fields[attr.Key] = attr.Value.Any() + } + + // Add record attributes + r.Attrs(func(attr slog.Attr) bool { + fields[attr.Key] = attr.Value.Any() + return true + }) + + entry := h.logger.WithFields(fields) + + switch r.Level { + case slog.LevelDebug: + entry.Debug(r.Message) + case slog.LevelInfo: + entry.Info(r.Message) + case slog.LevelWarn: + entry.Warn(r.Message) + case slog.LevelError: + entry.Error(r.Message) + default: + entry.Info(r.Message) + } + + return nil +} + +// WithAttrs returns a new Handler with the given attributes added. +func (h *LogrusHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + newAttrs := make([]slog.Attr, len(h.attrs)+len(attrs)) + copy(newAttrs, h.attrs) + copy(newAttrs[len(h.attrs):], attrs) + return &LogrusHandler{ + logger: h.logger, + attrs: newAttrs, + groups: h.groups, + } +} + +// WithGroup returns a new Handler with the given group appended to the receiver's groups. +func (h *LogrusHandler) WithGroup(name string) slog.Handler { + newGroups := make([]string, len(h.groups)+1) + copy(newGroups, h.groups) + newGroups[len(h.groups)] = name + return &LogrusHandler{ + logger: h.logger, + attrs: h.attrs, + groups: newGroups, + } +} diff --git a/idp/dex/provider.go b/idp/dex/provider.go new file mode 100644 index 000000000..f40b96a58 --- /dev/null +++ b/idp/dex/provider.go @@ -0,0 +1,832 @@ +// Package dex provides an embedded Dex OIDC identity provider. +package dex + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + 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/go-jose/go-jose/v4" + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/crypto/bcrypt" + "google.golang.org/grpc" + + nbjwt "github.com/netbirdio/netbird/shared/auth/jwt" +) + +// Config matches what management/internals/server/server.go expects +type Config struct { + Issuer string + Port int + DataDir string + DevMode bool + + // GRPCAddr is the address for the gRPC API (e.g., ":5557"). Empty disables gRPC. + GRPCAddr string +} + +const LocalConnectorID = "local" + +// Provider wraps a Dex server +type Provider struct { + config *Config + yamlConfig *YAMLConfig + dexServer *server.Server + httpServer *http.Server + listener net.Listener + grpcServer *grpc.Server + grpcListener net.Listener + storage storage.Storage + logger *slog.Logger + mu sync.Mutex + running bool +} + +// NewProvider creates and initializes the Dex server +func NewProvider(ctx context.Context, config *Config) (*Provider, error) { + if config.Issuer == "" { + return nil, fmt.Errorf("issuer is required") + } + if config.Port <= 0 { + return nil, fmt.Errorf("invalid port") + } + if config.DataDir == "" { + return nil, fmt.Errorf("data directory is required") + } + + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + + // Ensure data directory exists + 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 := newSQLite3(dbPath) + stor, err := sqliteConfig.Open(logger) + if err != nil { + return nil, fmt.Errorf("failed to open storage: %w", err) + } + + // Ensure a local connector exists (for password authentication) + if err := ensureLocalConnector(ctx, stor); err != nil { + stor.Close() + return nil, fmt.Errorf("failed to ensure local connector: %w", err) + } + + // Ensure issuer ends with /oauth2 for proper path mounting + issuer := strings.TrimSuffix(config.Issuer, "/") + if !strings.HasSuffix(issuer, "/oauth2") { + issuer += "/oauth2" + } + + // Build refresh token policy (required to avoid nil pointer panics) + refreshPolicy, err := server.NewRefreshTokenPolicy(logger, false, "", "", "") + if err != nil { + stor.Close() + 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, + Storage: stor, + SkipApprovalScreen: true, + SupportedResponseTypes: []string{"code"}, + ContinueOnConnectorFailure: true, + Logger: logger, + PrometheusRegistry: prometheus.NewRegistry(), + IDTokensValidFor: 24 * time.Hour, + RefreshTokenPolicy: refreshPolicy, + Web: server.WebConfig{ + Issuer: "NetBird", + }, + Signer: localSigner, + } + + dexSrv, err := server.NewServer(ctx, dexConfig) + if err != nil { + stor.Close() + return nil, fmt.Errorf("failed to create dex server: %w", err) + } + + return &Provider{ + config: config, + dexServer: dexSrv, + storage: stor, + logger: logger, + }, nil +} + +// NewProviderFromYAML creates and initializes the Dex server from a YAMLConfig +func NewProviderFromYAML(ctx context.Context, yamlConfig *YAMLConfig) (*Provider, error) { + // Configure log level from config, default to WARN to avoid logging sensitive data (emails) + logLevel := slog.LevelWarn + if yamlConfig.Logger.Level != "" { + switch strings.ToLower(yamlConfig.Logger.Level) { + case "debug": + logLevel = slog.LevelDebug + case "info": + logLevel = slog.LevelInfo + case "warn", "warning": + logLevel = slog.LevelWarn + case "error": + logLevel = slog.LevelError + } + } + logger := slog.New(NewLogrusHandler(logLevel)) + + stor, err := yamlConfig.Storage.OpenStorage(logger) + if err != nil { + return nil, fmt.Errorf("failed to open storage: %w", err) + } + + if err := initializeStorage(ctx, stor, yamlConfig); err != nil { + stor.Close() + return nil, err + } + + dexConfig := buildDexConfig(yamlConfig, stor, logger) + dexConfig.RefreshTokenPolicy, err = yamlConfig.GetRefreshTokenPolicy(logger) + if err != nil { + stor.Close() + 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() + return nil, fmt.Errorf("failed to create dex server: %w", err) + } + + return &Provider{ + config: &Config{Issuer: yamlConfig.Issuer, GRPCAddr: yamlConfig.GRPC.Addr}, + yamlConfig: yamlConfig, + dexServer: dexSrv, + storage: stor, + logger: logger, + }, 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 { + if err := ensureLocalConnector(ctx, stor); err != nil { + return fmt.Errorf("failed to ensure local connector: %w", err) + } + } + if err := ensureStaticPasswords(ctx, stor, cfg.StaticPasswords); err != nil { + return err + } + if err := ensureStaticClients(ctx, stor, cfg.StaticClients); err != nil { + return err + } + return ensureStaticConnectors(ctx, stor, cfg.StaticConnectors) +} + +// ensureStaticPasswords creates or updates static passwords in storage +func ensureStaticPasswords(ctx context.Context, stor storage.Storage, passwords []Password) error { + for _, pw := range passwords { + existing, err := stor.GetPassword(ctx, pw.Email) + if errors.Is(err, storage.ErrNotFound) { + if err := stor.CreatePassword(ctx, storage.Password(pw)); err != nil { + return fmt.Errorf("failed to create password for %s: %w", pw.Email, err) + } + continue + } + if err != nil { + return fmt.Errorf("failed to get password for %s: %w", pw.Email, err) + } + if string(existing.Hash) != string(pw.Hash) { + if err := stor.UpdatePassword(ctx, pw.Email, func(old storage.Password) (storage.Password, error) { + old.Hash = pw.Hash + old.Username = pw.Username + return old, nil + }); err != nil { + return fmt.Errorf("failed to update password for %s: %w", pw.Email, err) + } + } + } + return nil +} + +// ensureStaticClients creates or updates static clients in storage +func ensureStaticClients(ctx context.Context, stor storage.Storage, clients []storage.Client) error { + for _, client := range clients { + _, err := stor.GetClient(ctx, client.ID) + if errors.Is(err, storage.ErrNotFound) { + if err := stor.CreateClient(ctx, client); err != nil { + return fmt.Errorf("failed to create client %s: %w", client.ID, err) + } + continue + } + if err != nil { + return fmt.Errorf("failed to get client %s: %w", client.ID, err) + } + if err := stor.UpdateClient(ctx, client.ID, func(old storage.Client) (storage.Client, error) { + 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) + } + } + return nil +} + +// buildDexConfig creates a server.Config with defaults applied +func buildDexConfig(yamlConfig *YAMLConfig, stor storage.Storage, logger *slog.Logger) server.Config { + cfg := yamlConfig.ToServerConfig(stor, logger) + cfg.PrometheusRegistry = prometheus.NewRegistry() + if cfg.IDTokensValidFor == 0 { + cfg.IDTokensValidFor = 24 * time.Hour + } + if cfg.Web.Issuer == "" { + cfg.Web.Issuer = "NetBird" + } + if len(cfg.SupportedResponseTypes) == 0 { + cfg.SupportedResponseTypes = []string{"code"} + } + cfg.ContinueOnConnectorFailure = true + return cfg +} + +// Start starts the HTTP server and optionally the gRPC API server +func (p *Provider) Start(_ context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.running { + return fmt.Errorf("already running") + } + + // Determine listen address from config + var addr string + if p.yamlConfig != nil { + addr = p.yamlConfig.Web.HTTP + if addr == "" { + addr = p.yamlConfig.Web.HTTPS + } + } else if p.config != nil && p.config.Port > 0 { + addr = fmt.Sprintf(":%d", p.config.Port) + } + if addr == "" { + return fmt.Errorf("no listen address configured") + } + + listener, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("failed to listen on %s: %w", addr, err) + } + p.listener = listener + + // Mount Dex at /oauth2/ path for reverse proxy compatibility + // Don't strip the prefix - Dex's issuer includes /oauth2 so it expects the full path + mux := http.NewServeMux() + mux.Handle("/oauth2/", p.dexServer) + + p.httpServer = &http.Server{Handler: mux} + p.running = true + + go func() { + if err := p.httpServer.Serve(listener); err != nil && err != http.ErrServerClosed { + p.logger.Error("http server error", "error", err) + } + }() + + // Start gRPC API server if configured + if p.config.GRPCAddr != "" { + if err := p.startGRPCServer(); err != nil { + // Clean up HTTP server on failure + _ = p.httpServer.Close() + _ = p.listener.Close() + return fmt.Errorf("failed to start gRPC server: %w", err) + } + } + + p.logger.Info("HTTP server started", "addr", addr) + return nil +} + +// startGRPCServer starts the gRPC API server using Dex's built-in API +func (p *Provider) startGRPCServer() error { + grpcListener, err := net.Listen("tcp", p.config.GRPCAddr) + if err != nil { + return fmt.Errorf("failed to listen on %s: %w", p.config.GRPCAddr, err) + } + p.grpcListener = grpcListener + + p.grpcServer = grpc.NewServer() + // Use Dex's built-in API server implementation + // server.NewAPI(storage, logger, version, dexServer) + dexapi.RegisterDexServer(p.grpcServer, server.NewAPI(p.storage, p.logger, "netbird-dex", p.dexServer)) + + go func() { + if err := p.grpcServer.Serve(grpcListener); err != nil { + p.logger.Error("grpc server error", "error", err) + } + }() + + p.logger.Info("gRPC API server started", "addr", p.config.GRPCAddr) + return nil +} + +// Stop gracefully shuts down +func (p *Provider) Stop(ctx context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + + if !p.running { + return nil + } + + var errs []error + + // Stop gRPC server first + if p.grpcServer != nil { + p.grpcServer.GracefulStop() + p.grpcServer = nil + } + if p.grpcListener != nil { + p.grpcListener.Close() + p.grpcListener = nil + } + + if p.httpServer != nil { + if err := p.httpServer.Shutdown(ctx); err != nil { + errs = append(errs, err) + } + } + + // Explicitly close listener as fallback (Shutdown should do this, but be safe) + if p.listener != nil { + if err := p.listener.Close(); err != nil { + // Ignore "use of closed network connection" - expected after Shutdown + if !strings.Contains(err.Error(), "use of closed") { + errs = append(errs, err) + } + } + p.listener = nil + } + + if p.storage != nil { + if err := p.storage.Close(); err != nil { + errs = append(errs, err) + } + } + + p.httpServer = nil + p.running = false + + if len(errs) > 0 { + return fmt.Errorf("shutdown errors: %v", errs) + } + return nil +} + +// EnsureDefaultClients creates dashboard and CLI OAuth clients +// Uses Dex's storage.Client directly - no custom wrappers +func (p *Provider) EnsureDefaultClients(ctx context.Context, dashboardURIs, cliURIs []string) error { + clients := []storage.Client{ + { + ID: "netbird-dashboard", + Name: "NetBird Dashboard", + RedirectURIs: dashboardURIs, + Public: true, + }, + { + ID: "netbird-cli", + Name: "NetBird CLI", + RedirectURIs: cliURIs, + Public: true, + }, + } + + for _, client := range clients { + _, err := p.storage.GetClient(ctx, client.ID) + if err == storage.ErrNotFound { + if err := p.storage.CreateClient(ctx, client); err != nil { + return fmt.Errorf("failed to create client %s: %w", client.ID, err) + } + continue + } + if err != nil { + return fmt.Errorf("failed to get client %s: %w", client.ID, err) + } + // Update if exists + if err := p.storage.UpdateClient(ctx, client.ID, func(old storage.Client) (storage.Client, error) { + old.RedirectURIs = client.RedirectURIs + return old, nil + }); err != nil { + return fmt.Errorf("failed to update client %s: %w", client.ID, err) + } + } + + p.logger.Info("default OIDC clients ensured") + return nil +} + +// Storage returns the underlying Dex storage for direct access +// Users can use storage.Client, storage.Password, storage.Connector directly +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 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. +// Returns the encoded user ID in Dex's format (base64-encoded protobuf with connector ID). +func (p *Provider) CreateUser(ctx context.Context, email, username, password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", fmt.Errorf("failed to hash password: %w", err) + } + + userID := uuid.New().String() + err = p.storage.CreatePassword(ctx, storage.Password{ + Email: email, + Username: username, + UserID: userID, + Hash: hash, + }) + if err != nil { + return "", err + } + + // 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, LocalConnectorID) + return encodedID, nil +} + +// EncodeDexUserID encodes user ID and connector ID into Dex's base64-encoded protobuf format. +// Dex uses this format for the 'sub' claim in JWT tokens. +// Format: base64(protobuf message with field 1 = user_id, field 2 = connector_id) +func EncodeDexUserID(userID, connectorID string) string { + // Manually encode protobuf: field 1 (user_id) and field 2 (connector_id) + // Wire type 2 (length-delimited) for strings + var buf []byte + + // Field 1: user_id (tag = 0x0a = field 1, wire type 2) + buf = append(buf, 0x0a) + buf = append(buf, byte(len(userID))) + buf = append(buf, []byte(userID)...) + + // Field 2: connector_id (tag = 0x12 = field 2, wire type 2) + buf = append(buf, 0x12) + buf = append(buf, byte(len(connectorID))) + buf = append(buf, []byte(connectorID)...) + + return base64.RawStdEncoding.EncodeToString(buf) +} + +// DecodeDexUserID decodes Dex's base64-encoded user ID back to the raw user ID and connector ID. +func DecodeDexUserID(encodedID string) (userID, connectorID string, err error) { + // Try RawStdEncoding first, then StdEncoding (with padding) + buf, err := base64.RawStdEncoding.DecodeString(encodedID) + if err != nil { + buf, err = base64.StdEncoding.DecodeString(encodedID) + if err != nil { + return "", "", fmt.Errorf("failed to decode base64: %w", err) + } + } + + // Parse protobuf manually + i := 0 + for i < len(buf) { + if i >= len(buf) { + break + } + tag := buf[i] + i++ + + fieldNum := tag >> 3 + wireType := tag & 0x07 + + if wireType != 2 { // We only expect length-delimited strings + return "", "", fmt.Errorf("unexpected wire type %d", wireType) + } + + if i >= len(buf) { + return "", "", fmt.Errorf("truncated message") + } + length := int(buf[i]) + i++ + + if i+length > len(buf) { + return "", "", fmt.Errorf("truncated string field") + } + value := string(buf[i : i+length]) + i += length + + switch fieldNum { + case 1: + userID = value + case 2: + connectorID = value + } + } + + 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) +} + +// GetUserByID returns a user by user ID. +// The userID can be either an encoded Dex ID (base64 protobuf) or a raw UUID. +// Note: This requires iterating through all users since dex storage doesn't index by userID. +func (p *Provider) GetUserByID(ctx context.Context, userID string) (storage.Password, error) { + // Try to decode the user ID in case it's encoded + rawUserID, _, err := DecodeDexUserID(userID) + if err != nil { + // If decoding fails, assume it's already a raw UUID + rawUserID = userID + } + + users, err := p.storage.ListPasswords(ctx) + if err != nil { + return storage.Password{}, fmt.Errorf("failed to list users: %w", err) + } + for _, user := range users { + if user.UserID == rawUserID { + return user, nil + } + } + return storage.Password{}, storage.ErrNotFound +} + +// DeleteUser removes a user by email +func (p *Provider) DeleteUser(ctx context.Context, email string) error { + return p.storage.DeletePassword(ctx, email) +} + +// ListUsers returns all users +func (p *Provider) ListUsers(ctx context.Context) ([]storage.Password, error) { + return p.storage.ListPasswords(ctx) +} + +// UpdateUserPassword updates the password for a user identified by userID. +// The userID can be either an encoded Dex ID (base64 protobuf) or a raw UUID. +// It verifies the current password before updating. +func (p *Provider) UpdateUserPassword(ctx context.Context, userID string, oldPassword, newPassword string) error { + // Get the user by ID to find their email + user, err := p.GetUserByID(ctx, userID) + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + + // Verify old password + if err := bcrypt.CompareHashAndPassword(user.Hash, []byte(oldPassword)); err != nil { + return fmt.Errorf("current password is incorrect") + } + + // Hash the new password + newHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash new password: %w", err) + } + + // Update the password in storage + err = p.storage.UpdatePassword(ctx, user.Email, func(old storage.Password) (storage.Password, error) { + old.Hash = newHash + return old, nil + }) + if err != nil { + return fmt.Errorf("failed to update password: %w", err) + } + + return nil +} + +// GetIssuer returns the OIDC issuer URL. +func (p *Provider) GetIssuer() string { + if p.config == nil { + return "" + } + issuer := strings.TrimSuffix(p.config.Issuer, "/") + if !strings.HasSuffix(issuer, "/oauth2") { + issuer += "/oauth2" + } + return issuer +} + +// GetKeysLocation returns the JWKS endpoint URL for token validation. +func (p *Provider) GetKeysLocation() string { + issuer := p.GetIssuer() + if issuer == "" { + return "" + } + return issuer + "/keys" +} + +// GetTokenEndpoint returns the OAuth2 token endpoint URL. +func (p *Provider) GetTokenEndpoint() string { + issuer := p.GetIssuer() + if issuer == "" { + return "" + } + return issuer + "/token" +} + +// GetDeviceAuthEndpoint returns the OAuth2 device authorization endpoint URL. +func (p *Provider) GetDeviceAuthEndpoint() string { + issuer := p.GetIssuer() + if issuer == "" { + return "" + } + return issuer + "/device/code" +} + +// GetAuthorizationEndpoint returns the OAuth2 authorization endpoint URL. +func (p *Provider) GetAuthorizationEndpoint() string { + issuer := p.GetIssuer() + if issuer == "" { + return "" + } + return issuer + "/auth" +} + +// GetJWKS reads signing keys directly from Dex storage and returns them as Jwks. +// This avoids HTTP round-trips when the embedded IDP is co-located with the management server. +// The key retrieval mirrors Dex's own handlePublicKeys/ValidationKeys logic: +// SigningKeyPub first, then all VerificationKeys, serialized via go-jose. +func (p *Provider) GetJWKS(ctx context.Context) (*nbjwt.Jwks, error) { + keys, err := p.storage.GetKeys(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get keys from storage: %w", err) + } + + if keys.SigningKeyPub == nil { + return nil, fmt.Errorf("no public keys found in storage") + } + + // Build the key set exactly as Dex's localSigner.ValidationKeys does: + // signing key first, then all verification (rotated) keys. + joseKeys := make([]jose.JSONWebKey, 0, len(keys.VerificationKeys)+1) + joseKeys = append(joseKeys, *keys.SigningKeyPub) + for _, vk := range keys.VerificationKeys { + if vk.PublicKey != nil { + joseKeys = append(joseKeys, *vk.PublicKey) + } + } + + // Serialize through go-jose (same as Dex's handlePublicKeys handler) + // then deserialize into our Jwks type, so the JSON field mapping is identical + // to what the /keys HTTP endpoint would return. + joseSet := jose.JSONWebKeySet{Keys: joseKeys} + data, err := json.Marshal(joseSet) + if err != nil { + return nil, fmt.Errorf("failed to marshal JWKS: %w", err) + } + + jwks := &nbjwt.Jwks{} + if err := json.Unmarshal(data, jwks); err != nil { + return nil, fmt.Errorf("failed to unmarshal JWKS: %w", err) + } + + jwks.ExpiresInTime = keys.NextRotation + + return jwks, nil +} diff --git a/idp/dex/provider_test.go b/idp/dex/provider_test.go new file mode 100644 index 000000000..5e132d544 --- /dev/null +++ b/idp/dex/provider_test.go @@ -0,0 +1,719 @@ +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() + + // Create a temporary directory for the test + tmpDir, err := os.MkdirTemp("", "dex-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create provider with minimal config + config := &Config{ + Issuer: "http://localhost:5556/dex", + Port: 5556, + DataDir: tmpDir, + } + + provider, err := NewProvider(ctx, config) + require.NoError(t, err) + defer func() { _ = provider.Stop(ctx) }() + + // Test user data + email := "test@example.com" + username := "testuser" + password := "testpassword123" + + // Create the user + encodedID, err := provider.CreateUser(ctx, email, username, password) + require.NoError(t, err) + require.NotEmpty(t, encodedID) + + t.Logf("Created user with encoded ID: %s", encodedID) + + // Verify the encoded ID can be decoded + rawUserID, connectorID, err := DecodeDexUserID(encodedID) + require.NoError(t, err) + assert.NotEmpty(t, rawUserID) + assert.Equal(t, "local", connectorID) + + t.Logf("Decoded: rawUserID=%s, connectorID=%s", rawUserID, connectorID) + + // Verify we can look up the user by encoded ID + user, err := provider.GetUserByID(ctx, encodedID) + require.NoError(t, err) + assert.Equal(t, email, user.Email) + assert.Equal(t, username, user.Username) + assert.Equal(t, rawUserID, user.UserID) + + // Verify we can also look up by raw UUID (backwards compatibility) + user2, err := provider.GetUserByID(ctx, rawUserID) + require.NoError(t, err) + assert.Equal(t, email, user2.Email) + + // Verify we can look up by email + user3, err := provider.GetUser(ctx, email) + require.NoError(t, err) + assert.Equal(t, rawUserID, user3.UserID) + + // Verify encoding produces consistent format + reEncodedID := EncodeDexUserID(rawUserID, "local") + assert.Equal(t, encodedID, reEncodedID) +} + +func TestDecodeDexUserID(t *testing.T) { + tests := []struct { + name string + encodedID string + wantUserID string + wantConnID string + wantErr bool + }{ + { + name: "valid encoded ID", + encodedID: "CiQ3YWFkOGMwNS0zMjg3LTQ3M2YtYjQyYS0zNjU1MDRiZjI1ZTcSBWxvY2Fs", + wantUserID: "7aad8c05-3287-473f-b42a-365504bf25e7", + wantConnID: "local", + wantErr: false, + }, + { + name: "invalid base64", + encodedID: "not-valid-base64!!!", + wantUserID: "", + wantConnID: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + userID, connID, err := DecodeDexUserID(tt.encodedID) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantUserID, userID) + assert.Equal(t, tt.wantConnID, connID) + }) + } +} + +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" + + encoded := EncodeDexUserID(userID, connectorID) + assert.NotEmpty(t, encoded) + + // Verify round-trip + decodedUserID, decodedConnID, err := DecodeDexUserID(encoded) + require.NoError(t, err) + assert.Equal(t, userID, decodedUserID) + assert.Equal(t, connectorID, decodedConnID) +} + +func TestEncodeDexUserID_MatchesDexFormat(t *testing.T) { + // This is an actual ID from Dex - verify our encoding matches + knownEncodedID := "CiQ3YWFkOGMwNS0zMjg3LTQ3M2YtYjQyYS0zNjU1MDRiZjI1ZTcSBWxvY2Fs" + knownUserID := "7aad8c05-3287-473f-b42a-365504bf25e7" + knownConnectorID := "local" + + // Decode the known ID + userID, connID, err := DecodeDexUserID(knownEncodedID) + require.NoError(t, err) + assert.Equal(t, knownUserID, userID) + assert.Equal(t, knownConnectorID, connID) + + // Re-encode and verify it matches + reEncoded := EncodeDexUserID(knownUserID, knownConnectorID) + 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() + + // Create temp directory + tmpDir, err := os.MkdirTemp("", "dex-create-user-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create YAML config for the test + yamlContent := ` +issuer: http://localhost:5556/dex +storage: + type: sqlite3 + config: + file: ` + filepath.Join(tmpDir, "dex.db") + ` +web: + http: 127.0.0.1:5556 +enablePasswordDB: true +` + configPath := filepath.Join(tmpDir, "config.yaml") + err = os.WriteFile(configPath, []byte(yamlContent), 0644) + require.NoError(t, err) + + // Load config and create provider + yamlConfig, err := LoadConfig(configPath) + require.NoError(t, err) + + provider, err := NewProviderFromYAML(ctx, yamlConfig) + require.NoError(t, err) + defer func() { _ = provider.Stop(ctx) }() + + // Create user + email := "newuser@example.com" + username := "newuser" + password := "securepassword123" + + encodedID, err := provider.CreateUser(ctx, email, username, password) + require.NoError(t, err) + + t.Logf("Created user: email=%s, encodedID=%s", email, encodedID) + + // Verify lookup works with encoded ID + user, err := provider.GetUserByID(ctx, encodedID) + require.NoError(t, err) + assert.Equal(t, email, user.Email) + assert.Equal(t, username, user.Username) + + // Decode and verify format + rawID, connID, err := DecodeDexUserID(encodedID) + require.NoError(t, err) + assert.Equal(t, "local", connID) + assert.Equal(t, rawID, user.UserID) + + t.Logf("User lookup successful: rawID=%s, connectorID=%s", rawID, connID) +} + +// openTestStorage creates a SQLite storage in the given directory for testing. +func openTestStorage(t *testing.T, tmpDir string) storage.Storage { + t.Helper() + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + stor, err := (&sqllib.SQLite3{File: filepath.Join(tmpDir, "dex.db")}).Open(logger) + require.NoError(t, err) + return stor +} + +func TestStaticConnectors_CreatedFromYAML(t *testing.T) { + ctx := context.Background() + + tmpDir, err := os.MkdirTemp("", "dex-static-conn-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + yamlContent := ` +issuer: http://localhost:5556/dex +storage: + type: sqlite3 + config: + file: ` + filepath.Join(tmpDir, "dex.db") + ` +web: + http: 127.0.0.1:5556 +enablePasswordDB: true +connectors: +- type: oidc + id: my-oidc + name: My OIDC Provider + config: + issuer: https://accounts.example.com + clientID: test-client-id + clientSecret: test-client-secret + redirectURI: http://localhost:5556/dex/callback +` + configPath := filepath.Join(tmpDir, "config.yaml") + err = os.WriteFile(configPath, []byte(yamlContent), 0644) + require.NoError(t, err) + + yamlConfig, err := LoadConfig(configPath) + require.NoError(t, err) + + // Open storage and run initializeStorage directly (avoids Dex server + // trying to dial the OIDC issuer) + stor := openTestStorage(t, tmpDir) + defer stor.Close() + + err = initializeStorage(ctx, stor, yamlConfig) + require.NoError(t, err) + + // Verify connector was created in storage + conn, err := stor.GetConnector(ctx, "my-oidc") + require.NoError(t, err) + assert.Equal(t, "my-oidc", conn.ID) + assert.Equal(t, "My OIDC Provider", conn.Name) + assert.Equal(t, "oidc", conn.Type) + + // Verify config fields were serialized correctly + var configMap map[string]interface{} + err = json.Unmarshal(conn.Config, &configMap) + require.NoError(t, err) + assert.Equal(t, "https://accounts.example.com", configMap["issuer"]) + assert.Equal(t, "test-client-id", configMap["clientID"]) +} + +func TestStaticConnectors_UpdatedOnRestart(t *testing.T) { + ctx := context.Background() + + tmpDir, err := os.MkdirTemp("", "dex-static-conn-update-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + dbFile := filepath.Join(tmpDir, "dex.db") + + // First: load config with initial connector + yamlContent1 := ` +issuer: http://localhost:5556/dex +storage: + type: sqlite3 + config: + file: ` + dbFile + ` +web: + http: 127.0.0.1:5556 +enablePasswordDB: true +connectors: +- type: oidc + id: my-oidc + name: Original Name + config: + issuer: https://accounts.example.com + clientID: original-client-id + clientSecret: original-secret +` + configPath := filepath.Join(tmpDir, "config.yaml") + err = os.WriteFile(configPath, []byte(yamlContent1), 0644) + require.NoError(t, err) + + yamlConfig1, err := LoadConfig(configPath) + require.NoError(t, err) + + stor := openTestStorage(t, tmpDir) + err = initializeStorage(ctx, stor, yamlConfig1) + require.NoError(t, err) + + // Verify initial state + conn, err := stor.GetConnector(ctx, "my-oidc") + require.NoError(t, err) + assert.Equal(t, "Original Name", conn.Name) + + var configMap1 map[string]interface{} + err = json.Unmarshal(conn.Config, &configMap1) + require.NoError(t, err) + assert.Equal(t, "original-client-id", configMap1["clientID"]) + + // Close storage to simulate restart + stor.Close() + + // Second: load updated config against the same DB + yamlContent2 := ` +issuer: http://localhost:5556/dex +storage: + type: sqlite3 + config: + file: ` + dbFile + ` +web: + http: 127.0.0.1:5556 +enablePasswordDB: true +connectors: +- type: oidc + id: my-oidc + name: Updated Name + config: + issuer: https://accounts.example.com + clientID: updated-client-id + clientSecret: updated-secret +` + err = os.WriteFile(configPath, []byte(yamlContent2), 0644) + require.NoError(t, err) + + yamlConfig2, err := LoadConfig(configPath) + require.NoError(t, err) + + stor2 := openTestStorage(t, tmpDir) + defer stor2.Close() + + err = initializeStorage(ctx, stor2, yamlConfig2) + require.NoError(t, err) + + // Verify connector was updated, not duplicated + allConnectors, err := stor2.ListConnectors(ctx) + require.NoError(t, err) + + nonLocalCount := 0 + for _, c := range allConnectors { + if c.ID != "local" { + nonLocalCount++ + } + } + assert.Equal(t, 1, nonLocalCount, "connector should be updated, not duplicated") + + conn2, err := stor2.GetConnector(ctx, "my-oidc") + require.NoError(t, err) + assert.Equal(t, "Updated Name", conn2.Name) + + var configMap2 map[string]interface{} + err = json.Unmarshal(conn2.Config, &configMap2) + require.NoError(t, err) + assert.Equal(t, "updated-client-id", configMap2["clientID"]) +} + +func TestStaticConnectors_MultipleConnectors(t *testing.T) { + ctx := context.Background() + + tmpDir, err := os.MkdirTemp("", "dex-static-conn-multi-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + yamlContent := ` +issuer: http://localhost:5556/dex +storage: + type: sqlite3 + config: + file: ` + filepath.Join(tmpDir, "dex.db") + ` +web: + http: 127.0.0.1:5556 +enablePasswordDB: true +connectors: +- type: oidc + id: my-oidc + name: My OIDC Provider + config: + issuer: https://accounts.example.com + clientID: oidc-client-id + clientSecret: oidc-secret +- type: google + id: my-google + name: Google Login + config: + clientID: google-client-id + clientSecret: google-secret +` + configPath := filepath.Join(tmpDir, "config.yaml") + err = os.WriteFile(configPath, []byte(yamlContent), 0644) + require.NoError(t, err) + + yamlConfig, err := LoadConfig(configPath) + require.NoError(t, err) + + stor := openTestStorage(t, tmpDir) + defer stor.Close() + + err = initializeStorage(ctx, stor, yamlConfig) + require.NoError(t, err) + + allConnectors, err := stor.ListConnectors(ctx) + require.NoError(t, err) + + // Build a map for easier assertion + connByID := make(map[string]storage.Connector) + for _, c := range allConnectors { + connByID[c.ID] = c + } + + // Verify both static connectors exist + oidcConn, ok := connByID["my-oidc"] + require.True(t, ok, "oidc connector should exist") + assert.Equal(t, "My OIDC Provider", oidcConn.Name) + assert.Equal(t, "oidc", oidcConn.Type) + + var oidcConfig map[string]interface{} + err = json.Unmarshal(oidcConn.Config, &oidcConfig) + require.NoError(t, err) + assert.Equal(t, "oidc-client-id", oidcConfig["clientID"]) + + googleConn, ok := connByID["my-google"] + require.True(t, ok, "google connector should exist") + assert.Equal(t, "Google Login", googleConn.Name) + assert.Equal(t, "google", googleConn.Type) + + var googleConfig map[string]interface{} + err = json.Unmarshal(googleConn.Config, &googleConfig) + require.NoError(t, err) + assert.Equal(t, "google-client-id", googleConfig["clientID"]) + + // Verify local connector still exists alongside them (enablePasswordDB: true) + localConn, ok := connByID["local"] + require.True(t, ok, "local connector should exist") + assert.Equal(t, "local", localConn.Type) +} + +func TestStaticConnectors_EmptyList(t *testing.T) { + ctx := context.Background() + + tmpDir, err := os.MkdirTemp("", "dex-static-conn-empty-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + yamlContent := ` +issuer: http://localhost:5556/dex +storage: + type: sqlite3 + config: + file: ` + filepath.Join(tmpDir, "dex.db") + ` +web: + http: 127.0.0.1:5556 +enablePasswordDB: true +` + configPath := filepath.Join(tmpDir, "config.yaml") + err = os.WriteFile(configPath, []byte(yamlContent), 0644) + require.NoError(t, err) + + yamlConfig, err := LoadConfig(configPath) + require.NoError(t, err) + + provider, err := NewProviderFromYAML(ctx, yamlConfig) + require.NoError(t, err) + defer func() { _ = provider.Stop(ctx) }() + + // No static connectors configured, so ListConnectors should return empty + connectors, err := provider.ListConnectors(ctx) + require.NoError(t, err) + assert.Empty(t, connectors) + + // But local connector should still exist + localConn, err := provider.Storage().GetConnector(ctx, "local") + require.NoError(t, err) + assert.Equal(t, "local", localConn.ID) +} + +func TestNewProvider_ContinueOnConnectorFailure(t *testing.T) { + ctx := context.Background() + + tmpDir, err := os.MkdirTemp("", "dex-connector-failure-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + config := &Config{ + Issuer: "http://localhost:5556/dex", + Port: 5556, + DataDir: tmpDir, + } + + provider, err := NewProvider(ctx, config) + require.NoError(t, err) + defer func() { _ = provider.Stop(ctx) }() + + // The provider should have started successfully even though + // ContinueOnConnectorFailure is an internal Dex config field. + // We verify the provider is functional by performing a basic operation. + assert.NotNil(t, provider.dexServer) + assert.NotNil(t, provider.storage) +} + +func TestBuildDexConfig_ContinueOnConnectorFailure(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "dex-build-config-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + yamlContent := ` +issuer: http://localhost:5556/dex +storage: + type: sqlite3 + config: + file: ` + filepath.Join(tmpDir, "dex.db") + ` +web: + http: 127.0.0.1:5556 +enablePasswordDB: true +` + configPath := filepath.Join(tmpDir, "config.yaml") + err = os.WriteFile(configPath, []byte(yamlContent), 0644) + require.NoError(t, err) + + yamlConfig, err := LoadConfig(configPath) + require.NoError(t, err) + + ctx := context.Background() + stor, err := yamlConfig.Storage.OpenStorage(slog.New(slog.NewTextHandler(os.Stderr, nil))) + require.NoError(t, err) + defer stor.Close() + + err = initializeStorage(ctx, stor, yamlConfig) + require.NoError(t, err) + + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + cfg := buildDexConfig(yamlConfig, stor, logger) + + assert.True(t, cfg.ContinueOnConnectorFailure, + "buildDexConfig must set ContinueOnConnectorFailure to true so management starts even if an external IdP is down") +} + +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/robots.txt b/idp/dex/web/robots.txt new file mode 100755 index 000000000..77470cb39 --- /dev/null +++ b/idp/dex/web/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / \ No newline at end of file diff --git a/idp/dex/web/static/main.css b/idp/dex/web/static/main.css new file mode 100755 index 000000000..39302c4c1 --- /dev/null +++ b/idp/dex/web/static/main.css @@ -0,0 +1 @@ +/* NetBird DEX Static CSS - main styles are inline in header.html */ \ No newline at end of file diff --git a/idp/dex/web/templates/approval.html b/idp/dex/web/templates/approval.html new file mode 100755 index 000000000..c84c3b3a0 --- /dev/null +++ b/idp/dex/web/templates/approval.html @@ -0,0 +1,26 @@ +{{ template "header.html" . }} + +
                                                +

                                                Grant Access

                                                +

                                                {{ .Client }} wants to access your account

                                                + +
                                                + + + +
                                                + +
                                                + +
                                                + + + +
                                                +
                                                + +{{ template "footer.html" . }} \ No newline at end of file diff --git a/idp/dex/web/templates/device.html b/idp/dex/web/templates/device.html new file mode 100755 index 000000000..61faa6d53 --- /dev/null +++ b/idp/dex/web/templates/device.html @@ -0,0 +1,34 @@ +{{ template "header.html" . }} + +
                                                +

                                                Device Login

                                                +

                                                Enter the code shown on your device

                                                + +
                                                + {{ if .Invalid }} +
                                                + Invalid user code. +
                                                + {{ end }} + +
                                                + + +
                                                + + +
                                                +
                                                + +{{ template "footer.html" . }} \ No newline at end of file diff --git a/idp/dex/web/templates/device_success.html b/idp/dex/web/templates/device_success.html new file mode 100755 index 000000000..af1d02031 --- /dev/null +++ b/idp/dex/web/templates/device_success.html @@ -0,0 +1,16 @@ +{{ template "header.html" . }} + +
                                                +
                                                + + + + +
                                                +

                                                Device Authorized

                                                +

                                                + Your device has been successfully authorized. You can close this window. +

                                                +
                                                + +{{ template "footer.html" . }} \ No newline at end of file diff --git a/idp/dex/web/templates/error.html b/idp/dex/web/templates/error.html new file mode 100755 index 000000000..5dc2d190f --- /dev/null +++ b/idp/dex/web/templates/error.html @@ -0,0 +1,16 @@ +{{ template "header.html" . }} + +
                                                +
                                                + + + + +
                                                +

                                                {{ .ErrType }}

                                                +
                                                + {{ .ErrMsg }} +
                                                +
                                                + +{{ template "footer.html" . }} \ No newline at end of file diff --git a/idp/dex/web/templates/footer.html b/idp/dex/web/templates/footer.html new file mode 100755 index 000000000..17c7245b6 --- /dev/null +++ b/idp/dex/web/templates/footer.html @@ -0,0 +1,3 @@ +
                                                + + \ No newline at end of file diff --git a/idp/dex/web/templates/header.html b/idp/dex/web/templates/header.html new file mode 100755 index 000000000..5759ee321 --- /dev/null +++ b/idp/dex/web/templates/header.html @@ -0,0 +1,70 @@ + + + + + + {{ issuer }} + + + + + +
                                                + \ No newline at end of file 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/login.html b/idp/dex/web/templates/login.html new file mode 100755 index 000000000..681532d86 --- /dev/null +++ b/idp/dex/web/templates/login.html @@ -0,0 +1,56 @@ +{{ template "header.html" . }} + +
                                                +

                                                Sign in

                                                +

                                                Choose your login method

                                                + + {{/* First pass: render Email/Local connectors at the top */}} + {{ range $c := .Connectors }} + {{- $nameLower := lower $c.Name -}} + {{- $idLower := lower $c.ID -}} + {{- if or (contains "email" $nameLower) (contains "email" $idLower) (contains "local" $nameLower) (contains "local" $idLower) -}} + + + Continue with {{ $c.Name }} + + {{- end -}} + {{ end }} + + {{/* Second pass: render all other connectors */}} + {{ range $c := .Connectors }} + {{- $nameLower := lower $c.Name -}} + {{- $idLower := lower $c.ID -}} + {{- if not (or (contains "email" $nameLower) (contains "email" $idLower) (contains "local" $nameLower) (contains "local" $idLower)) -}} + + {{- $iconClass := "nb-icon-default" -}} + {{- if or (contains "google" $nameLower) (contains "google" $idLower) -}} + {{- $iconClass = "nb-icon-google" -}} + {{- else if or (contains "github" $nameLower) (contains "github" $idLower) -}} + {{- $iconClass = "nb-icon-github" -}} + {{- else if or (contains "entra" $nameLower) (contains "entra" $idLower) -}} + {{- $iconClass = "nb-icon-entra" -}} + {{- else if or (contains "azure" $nameLower) (contains "azure" $idLower) -}} + {{- $iconClass = "nb-icon-azure" -}} + {{- else if or (contains "microsoft" $nameLower) (contains "microsoft" $idLower) -}} + {{- $iconClass = "nb-icon-microsoft" -}} + {{- else if or (contains "okta" $nameLower) (contains "okta" $idLower) -}} + {{- $iconClass = "nb-icon-okta" -}} + {{- else if or (contains "jumpcloud" $nameLower) (contains "jumpcloud" $idLower) -}} + {{- $iconClass = "nb-icon-jumpcloud" -}} + {{- else if or (contains "pocket" $nameLower) (contains "pocket" $idLower) -}} + {{- $iconClass = "nb-icon-pocketid" -}} + {{- else if or (contains "zitadel" $nameLower) (contains "zitadel" $idLower) -}} + {{- $iconClass = "nb-icon-zitadel" -}} + {{- else if or (contains "authentik" $nameLower) (contains "authentik" $idLower) -}} + {{- $iconClass = "nb-icon-authentik" -}} + {{- else if or (contains "keycloak" $nameLower) (contains "keycloak" $idLower) -}} + {{- $iconClass = "nb-icon-keycloak" -}} + {{- end -}} + + Continue with {{ $c.Name }} + + {{- end -}} + {{ end }} +
                                                + +{{ template "footer.html" . }} \ No newline at end of file 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/oob.html b/idp/dex/web/templates/oob.html new file mode 100755 index 000000000..b887dab61 --- /dev/null +++ b/idp/dex/web/templates/oob.html @@ -0,0 +1,19 @@ +{{ template "header.html" . }} + +
                                                +
                                                + + + + +
                                                +

                                                Login Successful

                                                +

                                                + Copy this code back to your application: +

                                                +
                                                + {{ .Code }} +
                                                +
                                                + +{{ template "footer.html" . }} \ No newline at end of file diff --git a/idp/dex/web/templates/password.html b/idp/dex/web/templates/password.html new file mode 100755 index 000000000..e1bfa7258 --- /dev/null +++ b/idp/dex/web/templates/password.html @@ -0,0 +1,60 @@ +{{ template "header.html" . }} + +
                                                +

                                                Sign in

                                                +

                                                Enter your credentials

                                                + +
                                                + {{ if .Invalid }} +
                                                + Invalid {{ .UsernamePrompt }} or password. +
                                                + {{ end }} + +
                                                + + +
                                                + +
                                                + + +
                                                + + +
                                                + + {{ if .BackLink }} + + {{ end }} +
                                                + + + +{{ template "footer.html" . }} \ No newline at end of file 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/client/ui/assets/netbird.ico b/idp/dex/web/themes/light/favicon.ico similarity index 100% rename from client/ui/assets/netbird.ico rename to idp/dex/web/themes/light/favicon.ico diff --git a/idp/dex/web/themes/light/favicon.png b/idp/dex/web/themes/light/favicon.png new file mode 100755 index 000000000..d534ca53d Binary files /dev/null and b/idp/dex/web/themes/light/favicon.png differ diff --git a/idp/dex/web/themes/light/logo.png b/idp/dex/web/themes/light/logo.png new file mode 100755 index 000000000..d534ca53d Binary files /dev/null and b/idp/dex/web/themes/light/logo.png differ diff --git a/idp/dex/web/themes/light/styles.css b/idp/dex/web/themes/light/styles.css new file mode 100755 index 000000000..3033ebd76 --- /dev/null +++ b/idp/dex/web/themes/light/styles.css @@ -0,0 +1 @@ +/* NetBird DEX Theme - styles loaded but CSS is inline in header.html */ \ No newline at end of file diff --git a/idp/dex/web/web.go b/idp/dex/web/web.go new file mode 100644 index 000000000..8cf81392a --- /dev/null +++ b/idp/dex/web/web.go @@ -0,0 +1,14 @@ +package web + +import ( + "embed" + "io/fs" +) + +//go:embed static/* templates/* themes/* robots.txt +var files embed.FS + +// FS returns the embedded web assets filesystem. +func FS() fs.FS { + return files +} diff --git a/idp/sdk/sdk.go b/idp/sdk/sdk.go new file mode 100644 index 000000000..d2189135b --- /dev/null +++ b/idp/sdk/sdk.go @@ -0,0 +1,135 @@ +// Package sdk provides an embeddable SDK for the Dex OIDC identity provider. +package sdk + +import ( + "context" + + "github.com/dexidp/dex/storage" + + "github.com/netbirdio/netbird/idp/dex" +) + +// DexIdP wraps the Dex provider with a builder pattern +type DexIdP struct { + provider *dex.Provider + config *dex.Config + yamlConfig *dex.YAMLConfig +} + +// Option configures a DexIdP instance +type Option func(*dex.Config) + +// WithIssuer sets the OIDC issuer URL +func WithIssuer(issuer string) Option { + return func(c *dex.Config) { c.Issuer = issuer } +} + +// WithPort sets the HTTP port +func WithPort(port int) Option { + return func(c *dex.Config) { c.Port = port } +} + +// WithDataDir sets the data directory for storage +func WithDataDir(dir string) Option { + return func(c *dex.Config) { c.DataDir = dir } +} + +// WithDevMode enables development mode (allows HTTP) +func WithDevMode(dev bool) Option { + return func(c *dex.Config) { c.DevMode = dev } +} + +// WithGRPCAddr sets the gRPC API address +func WithGRPCAddr(addr string) Option { + return func(c *dex.Config) { c.GRPCAddr = addr } +} + +// New creates a new DexIdP instance with the given options +func New(opts ...Option) (*DexIdP, error) { + config := &dex.Config{ + Port: 33081, + DevMode: true, + } + + for _, opt := range opts { + opt(config) + } + + return &DexIdP{config: config}, nil +} + +// NewFromConfigFile creates a new DexIdP instance from a YAML config file +func NewFromConfigFile(path string) (*DexIdP, error) { + yamlConfig, err := dex.LoadConfig(path) + if err != nil { + return nil, err + } + return &DexIdP{yamlConfig: yamlConfig}, nil +} + +// NewFromYAMLConfig creates a new DexIdP instance from a YAMLConfig +func NewFromYAMLConfig(yamlConfig *dex.YAMLConfig) (*DexIdP, error) { + return &DexIdP{yamlConfig: yamlConfig}, nil +} + +// Start initializes and starts the embedded OIDC provider +func (d *DexIdP) Start(ctx context.Context) error { + var err error + if d.yamlConfig != nil { + d.provider, err = dex.NewProviderFromYAML(ctx, d.yamlConfig) + } else { + d.provider, err = dex.NewProvider(ctx, d.config) + } + if err != nil { + return err + } + return d.provider.Start(ctx) +} + +// Stop gracefully shuts down the provider +func (d *DexIdP) Stop(ctx context.Context) error { + if d.provider != nil { + return d.provider.Stop(ctx) + } + return nil +} + +// EnsureDefaultClients creates the default NetBird OAuth clients +func (d *DexIdP) EnsureDefaultClients(ctx context.Context, dashboardURIs, cliURIs []string) error { + return d.provider.EnsureDefaultClients(ctx, dashboardURIs, cliURIs) +} + +// Storage exposes Dex storage for direct user/client/connector management +// Use storage.Client, storage.Password, storage.Connector directly +func (d *DexIdP) Storage() storage.Storage { + return d.provider.Storage() +} + +// CreateUser creates a new user with the given email, username, and password. +// Returns the encoded user ID in Dex's format. +func (d *DexIdP) CreateUser(ctx context.Context, email, username, password string) (string, error) { + return d.provider.CreateUser(ctx, email, username, password) +} + +// DeleteUser removes a user by email +func (d *DexIdP) DeleteUser(ctx context.Context, email string) error { + return d.provider.DeleteUser(ctx, email) +} + +// ListUsers returns all users +func (d *DexIdP) ListUsers(ctx context.Context) ([]storage.Password, error) { + return d.provider.ListUsers(ctx) +} + +// IssuerURL returns the OIDC issuer URL +func (d *DexIdP) IssuerURL() string { + if d.yamlConfig != nil { + return d.yamlConfig.Issuer + } + return d.config.Issuer +} + +// DiscoveryEndpoint returns the OIDC discovery endpoint URL +func (d *DexIdP) DiscoveryEndpoint() string { + return d.IssuerURL() + "/.well-known/openid-configuration" +} diff --git a/infrastructure_files/docker-compose.yml.tmpl b/infrastructure_files/docker-compose.yml.tmpl index 2bc49d3e5..1c9c63f78 100644 --- a/infrastructure_files/docker-compose.yml.tmpl +++ b/infrastructure_files/docker-compose.yml.tmpl @@ -53,7 +53,8 @@ services: command: [ "--cert-file", "$NETBIRD_MGMT_API_CERT_FILE", "--cert-key", "$NETBIRD_MGMT_API_CERT_KEY_FILE", - "--log-file", "console" + "--log-file", "console", + "--port", "80" ] # Relay diff --git a/infrastructure_files/getting-started-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh new file mode 100755 index 000000000..31f3f1c13 --- /dev/null +++ b/infrastructure_files/getting-started-enterprise.sh @@ -0,0 +1,656 @@ +#!/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" + +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 + echo "docker-compose is not installed or not in PATH. See https://docs.docker.com/engine/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 "Caddy 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_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 +} + +init_environment() { + check_openssl + DOCKER_COMPOSE_COMMAND=$(check_docker_compose) + + if [[ -f .env ]] || [[ -f docker-compose.yml ]] || [[ -f config.yaml ]] || [[ -f Caddyfile ]]; 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 Caddyfile 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_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_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 "" + 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 + render_caddyfile > Caddyfile + 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 "" + 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 caddy" +} + +# ------------------------------------------------------------------ +# Renderers +# ------------------------------------------------------------------ + +render_env() { + cat < /dev/null + then + echo "docker-compose" + return + fi + if docker compose --help &> /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/engine/install/" > /dev/stderr + exit 1 +} + +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 <> .env + echo "Username: $ZITADEL_ADMIN_USERNAME" + echo "Password: $ZITADEL_ADMIN_PASSWORD" } renderCaddyfile() { diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh new file mode 100755 index 000000000..0206c269a --- /dev/null +++ b/infrastructure_files/getting-started.sh @@ -0,0 +1,1640 @@ +#!/bin/bash + +set -e + +# NetBird Getting Started with Embedded IdP (Dex) +# This script sets up NetBird with the embedded Dex identity provider +# No separate Dex container or reverse proxy needed - IdP is built into management server + +# Sed pattern to strip base64 padding characters +SED_STRIP_PADDING='s/=//g' + +# Constants for repeated string literals +readonly MSG_STARTING_SERVICES="\nStarting NetBird services\n" +readonly MSG_DONE="\nDone!\n" +readonly MSG_NEXT_STEPS="Next steps:" +readonly MSG_SEPARATOR="==========================================" + +############################################ +# Utility Functions +############################################ + +check_docker_sock_perms() { + local sock="${DOCKER_HOST:-unix:///var/run/docker.sock}" + sock="${sock#unix://}" + + if [[ ! -S "$sock" ]]; then + return 0 + fi + + 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-compose &> /dev/null + then + echo "docker-compose" + return + fi + if docker compose --help &> /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/engine/install/" > /dev/stderr + exit 1 +} + +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 +} + +read_reverse_proxy_type() { + echo "" > /dev/stderr + echo "Which reverse proxy will you use?" > /dev/stderr + echo " [0] Traefik (recommended - automatic TLS, included in Docker Compose)" > /dev/stderr + echo " [1] Existing Traefik (labels for external Traefik instance)" > /dev/stderr + echo " [2] Nginx (generates config template)" > /dev/stderr + echo " [3] Nginx Proxy Manager (generates config + instructions)" > /dev/stderr + echo " [4] External Caddy (generates Caddyfile snippet)" > /dev/stderr + echo " [5] Other/Manual (displays setup documentation)" > /dev/stderr + echo "" > /dev/stderr + echo -n "Enter choice [0-5] (default: 0): " > /dev/stderr + read -r CHOICE < /dev/tty + + if [[ -z "$CHOICE" ]]; then + CHOICE="0" + fi + + if [[ ! "$CHOICE" =~ ^[0-5]$ ]]; then + echo "Invalid choice. Please enter a number between 0 and 5." > /dev/stderr + read_reverse_proxy_type + return + fi + + echo "$CHOICE" + return 0 +} + +read_traefik_network() { + echo "" > /dev/stderr + echo "If you have an existing Traefik instance, enter its external network name." > /dev/stderr + echo -n "External network (leave empty to create 'netbird' network): " > /dev/stderr + read -r NETWORK < /dev/tty + echo "$NETWORK" + return 0 +} + +read_traefik_entrypoint() { + echo "" > /dev/stderr + echo "Enter the name of your Traefik HTTPS entrypoint." > /dev/stderr + echo -n "HTTPS entrypoint name (default: websecure): " > /dev/stderr + read -r ENTRYPOINT < /dev/tty + if [[ -z "$ENTRYPOINT" ]]; then + ENTRYPOINT="websecure" + fi + echo "$ENTRYPOINT" + return 0 +} + +read_traefik_certresolver() { + echo "" > /dev/stderr + echo "Enter the name of your Traefik certificate resolver (for automatic TLS)." > /dev/stderr + echo "Leave empty if you handle TLS termination elsewhere or use a wildcard cert." > /dev/stderr + echo -n "Certificate resolver name (e.g., letsencrypt): " > /dev/stderr + read -r RESOLVER < /dev/tty + echo "$RESOLVER" + return 0 +} + +read_port_binding_preference() { + echo "" > /dev/stderr + echo "Should container ports be bound to localhost only (127.0.0.1)?" > /dev/stderr + echo "Choose 'yes' if your reverse proxy runs on the same host (more secure)." > /dev/stderr + echo -n "Bind to localhost only? [Y/n]: " > /dev/stderr + read -r CHOICE < /dev/tty + + if [[ "$CHOICE" =~ ^[Nn]$ ]]; then + echo "false" + else + echo "true" + fi + return 0 +} + +read_proxy_docker_network() { + local proxy_name="$1" + echo "" > /dev/stderr + echo "Is ${proxy_name} running in Docker?" > /dev/stderr + echo "If yes, enter the Docker network ${proxy_name} is on (NetBird will join it)." > /dev/stderr + echo -n "Docker network (leave empty if not in Docker): " > /dev/stderr + read -r NETWORK < /dev/tty + echo "$NETWORK" + return 0 +} + +read_enable_proxy() { + echo "" > /dev/stderr + echo "Do you want to enable the NetBird Proxy service?" > /dev/stderr + echo "The proxy allows you to selectively expose internal NetBird network resources" > /dev/stderr + echo "to the internet. You control which resources are exposed through the dashboard." > /dev/stderr + echo -n "Enable proxy? [y/N]: " > /dev/stderr + read -r CHOICE < /dev/tty + + if [[ "$CHOICE" =~ ^[Yy]$ ]]; then + echo "true" + else + echo "false" + fi + return 0 +} + +read_enable_crowdsec() { + echo "" > /dev/stderr + echo "Do you want to enable CrowdSec IP reputation blocking?" > /dev/stderr + echo "CrowdSec checks client IPs against a community threat intelligence database" > /dev/stderr + echo "and blocks known malicious sources before they reach your services." > /dev/stderr + echo "A local CrowdSec LAPI container will be added to your deployment." > /dev/stderr + echo -n "Enable CrowdSec? [y/N]: " > /dev/stderr + read -r CHOICE < /dev/tty + + if [[ "$CHOICE" =~ ^[Yy]$ ]]; then + echo "true" + else + echo "false" + fi + return 0 +} + +read_traefik_acme_email() { + echo "" > /dev/stderr + echo "Enter your email for Let's Encrypt certificate notifications." > /dev/stderr + echo -n "Email address: " > /dev/stderr + read -r EMAIL < /dev/tty + if [[ -z "$EMAIL" ]]; then + echo "Email is required for Let's Encrypt." > /dev/stderr + read_traefik_acme_email + return + fi + echo "$EMAIL" + return 0 +} + +get_bind_address() { + if [[ "$BIND_LOCALHOST_ONLY" == "true" ]]; then + echo "127.0.0.1" + else + echo "0.0.0.0" + fi + return 0 +} + +get_upstream_host() { + # Always return 127.0.0.1 for health checks and upstream targets + # Cannot use 0.0.0.0 as a connection target + echo "127.0.0.1" + return 0 +} + +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 + # Check the embedded IdP endpoint through the reverse proxy + if curl -sk -f -o /dev/null "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/oauth2/.well-known/openid-configuration" 2>/dev/null; then + break + fi + if [[ $counter -eq 60 ]]; then + echo "" + echo "Taking too long. Checking logs..." + 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 " ." + sleep 2 + counter=$((counter + 1)) + done + echo " done" + set -e + return 0 +} + +wait_management_direct() { + set +e + local upstream_host=$(get_upstream_host) + echo -n "Waiting for NetBird server to become ready" + counter=1 + while true; do + # Check the embedded IdP endpoint directly (no reverse proxy) + if curl -sk -f -o /dev/null "http://${upstream_host}:${MANAGEMENT_HOST_PORT}/oauth2/.well-known/openid-configuration" 2>/dev/null; then + break + fi + if [[ $counter -eq 60 ]]; then + echo "" + echo "Taking too long. Checking logs..." + $DOCKER_COMPOSE_COMMAND logs --tail=20 netbird-server + fi + echo -n " ." + sleep 2 + counter=$((counter + 1)) + done + echo " done" + set -e + return 0 +} + +############################################ +# Initialization and Configuration +############################################ + +initialize_default_values() { + NETBIRD_PORT=80 + NETBIRD_HTTP_PROTOCOL="http" + NETBIRD_RELAY_PROTO="rel" + 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) + NETBIRD_STUN_PORT=3478 + + # Docker images + DASHBOARD_IMAGE=${DASHBOARD_IMAGE:-"netbirdio/dashboard:latest"} + # Combined server replaces separate signal, relay, and management containers + 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="" + TRAEFIK_ENTRYPOINT="websecure" + TRAEFIK_CERTRESOLVER="" + TRAEFIK_ACME_EMAIL="" + DASHBOARD_HOST_PORT="8080" + MANAGEMENT_HOST_PORT="8081" # Combined server port (management + signal + relay) + BIND_LOCALHOST_ONLY="true" + EXTERNAL_PROXY_NETWORK="" + + # Traefik static IP within the internal bridge network + TRAEFIK_IP="172.30.0.10" + + # NetBird Proxy configuration + ENABLE_PROXY="false" + PROXY_TOKEN="" + + # CrowdSec configuration + ENABLE_CROWDSEC="false" + CROWDSEC_BOUNCER_KEY="" + return 0 +} + +configure_domain() { + 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) + BASE_DOMAIN=$NETBIRD_DOMAIN + else + NETBIRD_PORT=443 + NETBIRD_HTTP_PROTOCOL="https" + NETBIRD_RELAY_PROTO="rels" + BASE_DOMAIN=$(echo $NETBIRD_DOMAIN | sed -E 's/^[^.]+\.//') + fi + 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" + + if [[ -n "${NETBIRD_LETSENCRYPT_EMAIL}" ]]; then + TRAEFIK_ACME_EMAIL="${NETBIRD_LETSENCRYPT_EMAIL}" + else + TRAEFIK_ACME_EMAIL=$(read_traefik_acme_email) + fi + + 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() { + # 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 + + # Prompt for reverse proxy type + REVERSE_PROXY_TYPE=$(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) + if [[ "$ENABLE_PROXY" == "true" ]]; then + ENABLE_CROWDSEC=$(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) + fi + + # Handle port binding for external proxy options (2-5) + if [[ "$REVERSE_PROXY_TYPE" -ge 2 ]]; then + BIND_LOCALHOST_ONLY=$(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") ;; + *) ;; # No network prompt for other options + esac + return 0 +} + +check_existing_installation() { + if [[ -f config.yaml ]]; then + echo "Generated files already exist, if you want to reinitialize the environment, please remove them first." + echo "You can use the following commands:" + echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes" + echo " rm -f docker-compose.yml dashboard.env config.yaml proxy.env traefik-dynamic.yaml nginx-netbird.conf caddyfile-netbird.txt npm-advanced-config.txt && rm -rf crowdsec/" + echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard." + exit 1 + fi + return 0 +} + +generate_configuration_files() { + echo Rendering initial files... + + # Render docker-compose and proxy config based on selection + case "$REVERSE_PROXY_TYPE" in + 0) + render_docker_compose_traefik_builtin > docker-compose.yml + if [[ "$ENABLE_PROXY" == "true" ]]; then + # Create placeholder proxy.env so docker-compose can validate + # This will be overwritten with the actual token after netbird-server starts + echo "# Placeholder - will be updated with token after netbird-server starts" > proxy.env + echo "NB_PROXY_TOKEN=placeholder" >> proxy.env + # TCP ServersTransport for PROXY protocol v2 to the proxy backend + render_traefik_dynamic > traefik-dynamic.yaml + if [[ "$ENABLE_CROWDSEC" == "true" ]]; then + mkdir -p crowdsec + fi + fi + ;; + 1) + render_docker_compose_traefik > docker-compose.yml + ;; + 2) + render_docker_compose_exposed_ports > docker-compose.yml + render_nginx_conf > nginx-netbird.conf + ;; + 3) + render_docker_compose_exposed_ports > docker-compose.yml + render_npm_advanced_config > npm-advanced-config.txt + ;; + 4) + render_docker_compose_exposed_ports > docker-compose.yml + render_external_caddyfile > caddyfile-netbird.txt + ;; + 5) + render_docker_compose_exposed_ports > docker-compose.yml + ;; + *) + echo "Invalid reverse proxy type: $REVERSE_PROXY_TYPE" > /dev/stderr + exit 1 + ;; + esac + + # Common files for all configurations + render_dashboard_env > dashboard.env + render_combined_yaml > config.yaml + return 0 +} + +start_services_and_show_instructions() { + # For built-in Traefik, start containers immediately + # For NPM, start containers first (NPM needs services running to create proxy) + # For other external proxies, show instructions first and wait for user confirmation + if [[ "$REVERSE_PROXY_TYPE" == "0" ]]; then + # Built-in Traefik - two-phase startup if proxy is enabled + echo -e "$MSG_STARTING_SERVICES" + + if [[ "$ENABLE_PROXY" == "true" ]]; then + # Phase 1: Start core services (without proxy) + local core_services="traefik dashboard netbird-server" + if [[ "$ENABLE_CROWDSEC" == "true" ]]; then + core_services="$core_services crowdsec" + fi + echo "Starting core services..." + $DOCKER_COMPOSE_COMMAND up -d $core_services + + sleep 3 + wait_management_proxy traefik + + # Phase 2: Create proxy token and start proxy + echo "" + echo "Creating proxy access token..." + # Use docker exec with bash to run the token command directly + PROXY_TOKEN=$($DOCKER_COMPOSE_COMMAND exec -T netbird-server \ + /go/bin/netbird-server 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 + $DOCKER_COMPOSE_COMMAND logs --tail=20 netbird-server + exit 1 + fi + + echo "Proxy token created successfully." + + if [[ "$ENABLE_CROWDSEC" == "true" ]]; then + echo "Registering CrowdSec bouncer..." + local cs_retries=0 + while ! $DOCKER_COMPOSE_COMMAND exec -T crowdsec cscli lapi status >/dev/null 2>&1; do + cs_retries=$((cs_retries + 1)) + if [[ $cs_retries -ge 30 ]]; then + echo "WARNING: CrowdSec did not become ready. Skipping CrowdSec setup." > /dev/stderr + echo "You can register a bouncer manually later with:" > /dev/stderr + echo " docker exec netbird-crowdsec cscli bouncers add netbird-proxy -o raw" > /dev/stderr + ENABLE_CROWDSEC="false" + break + fi + sleep 2 + done + + if [[ "$ENABLE_CROWDSEC" == "true" ]]; then + CROWDSEC_BOUNCER_KEY=$($DOCKER_COMPOSE_COMMAND exec -T crowdsec \ + cscli bouncers add netbird-proxy -o raw 2>/dev/null) + if [[ -z "$CROWDSEC_BOUNCER_KEY" ]]; then + echo "WARNING: Failed to create CrowdSec bouncer key. Skipping CrowdSec setup." > /dev/stderr + ENABLE_CROWDSEC="false" + else + echo "CrowdSec bouncer registered." + fi + fi + fi + + render_proxy_env > proxy.env + + # Start proxy service + echo "Starting proxy service..." + $DOCKER_COMPOSE_COMMAND up -d proxy + else + # No proxy - start all services at once + $DOCKER_COMPOSE_COMMAND up -d + + sleep 3 + wait_management_proxy traefik + fi + + echo -e "$MSG_DONE" + print_post_setup_instructions + elif [[ "$REVERSE_PROXY_TYPE" == "1" ]]; then + # External Traefik - start containers, then show instructions + # Traefik discovers services via Docker labels, so containers must be running + echo -e "$MSG_STARTING_SERVICES" + $DOCKER_COMPOSE_COMMAND up -d + + sleep 3 + wait_management_proxy detect-traefik + + echo -e "$MSG_DONE" + print_post_setup_instructions + echo "" + echo "NetBird containers are running. Once Traefik is connected, access the dashboard at:" + echo " $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN" + elif [[ "$REVERSE_PROXY_TYPE" == "3" ]]; then + # NPM - start containers first, then show instructions + # NPM requires backend services to be running before creating proxy hosts + echo -e "$MSG_STARTING_SERVICES" + $DOCKER_COMPOSE_COMMAND up -d + + sleep 3 + wait_management_direct + + echo -e "$MSG_DONE" + print_post_setup_instructions + echo "" + echo "NetBird containers are running. Configure NPM as shown above, then access:" + echo " $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN" + else + # External proxies (nginx, external Caddy, other) - need manual config first + print_post_setup_instructions + + echo "" + echo -n "Press Enter when your reverse proxy is configured (or Ctrl+C to exit)... " + read -r < /dev/tty + + echo -e "$MSG_STARTING_SERVICES" + $DOCKER_COMPOSE_COMMAND up -d + + sleep 3 + wait_management_direct + + echo -e "$MSG_DONE" + echo "NetBird is now running. Access the dashboard at:" + echo " $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN" + fi + return 0 +} + +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 + + check_existing_installation + generate_configuration_files + start_services_and_show_instructions + return 0 +} + +############################################ +# Configuration File Renderers +############################################ + +render_docker_compose_traefik_builtin() { + # Generate proxy service section and Traefik dynamic config if enabled + local proxy_service="" + local proxy_volumes="" + local crowdsec_service="" + local crowdsec_volumes="" + local traefik_file_provider="" + local traefik_dynamic_volume="" + if [[ "$ENABLE_PROXY" == "true" ]]; then + traefik_file_provider=' - "--providers.file.filename=/etc/traefik/dynamic.yaml"' + traefik_dynamic_volume=" - ./traefik-dynamic.yaml:/etc/traefik/dynamic.yaml:ro" + + local proxy_depends=" + netbird-server: + condition: service_started" + if [[ "$ENABLE_CROWDSEC" == "true" ]]; then + proxy_depends=" + netbird-server: + condition: service_started + crowdsec: + condition: service_healthy" + fi + + proxy_service=" + # NetBird Proxy - exposes internal resources to the internet + proxy: + image: $NETBIRD_PROXY_IMAGE + container_name: netbird-proxy + ports: + - 51820:51820/udp + restart: unless-stopped + networks: [netbird] + depends_on:${proxy_depends} + env_file: + - ./proxy.env + volumes: + - netbird_proxy_certs:/certs + labels: + # TCP passthrough for any unmatched domain (proxy handles its own TLS) + - traefik.enable=true + - traefik.tcp.routers.proxy-passthrough.entrypoints=websecure + - traefik.tcp.routers.proxy-passthrough.rule=HostSNI(\`*\`) + - traefik.tcp.routers.proxy-passthrough.tls.passthrough=true + - traefik.tcp.routers.proxy-passthrough.service=proxy-tls + - traefik.tcp.routers.proxy-passthrough.priority=1 + - traefik.tcp.services.proxy-tls.loadbalancer.server.port=8443 + - traefik.tcp.services.proxy-tls.loadbalancer.serverstransport=pp-v2@file + logging: + driver: \"json-file\" + options: + max-size: \"500m\" + max-file: \"2\" +" + proxy_volumes=" + netbird_proxy_certs:" + + if [[ "$ENABLE_CROWDSEC" == "true" ]]; then + crowdsec_service=" + crowdsec: + image: $CROWDSEC_IMAGE + container_name: netbird-crowdsec + restart: unless-stopped + networks: [netbird] + environment: + COLLECTIONS: crowdsecurity/linux + volumes: + - ./crowdsec:/etc/crowdsec + - crowdsec_db:/var/lib/crowdsec/data + healthcheck: + test: ["CMD", "cscli", "lapi", "status"] + interval: 10s + timeout: 5s + retries: 15 + labels: + - traefik.enable=false + logging: + driver: \"json-file\" + options: + max-size: \"500m\" + max-file: \"2\" +" + crowdsec_volumes=" + crowdsec_db:" + fi + fi + + cat <" + echo " Get your enrollment key at: https://app.crowdsec.net" + echo "" + fi + fi + if [[ "${NETBIRD_AGENT_NETWORK}" == "true" ]]; then + echo "Note: The public domain is only for setting up secure connections." + echo "Your APIs and agent services remain private and are never exposed publicly." + echo "" + fi + return 0 +} + +print_traefik_instructions() { + echo "" + echo "$MSG_SEPARATOR" + echo " TRAEFIK SETUP" + echo "$MSG_SEPARATOR" + echo "" + echo "NetBird containers are configured with Traefik labels." + echo "" + echo "Configuration:" + echo " Entrypoint: $TRAEFIK_ENTRYPOINT" + if [[ -n "$TRAEFIK_CERTRESOLVER" ]]; then + echo " Certificate resolver: $TRAEFIK_CERTRESOLVER" + fi + if [[ -n "$TRAEFIK_EXTERNAL_NETWORK" ]]; then + echo " Network: $TRAEFIK_EXTERNAL_NETWORK (external)" + else + echo " Network: netbird" + fi + echo "" + echo "$MSG_NEXT_STEPS" + echo " - Ensure Traefik is running and configured" + if [[ -n "$TRAEFIK_EXTERNAL_NETWORK" ]]; then + echo " - Traefik must be on the '$TRAEFIK_EXTERNAL_NETWORK' network" + fi + echo " - Entrypoint '$TRAEFIK_ENTRYPOINT' must be defined" + if [[ -n "$TRAEFIK_CERTRESOLVER" ]]; then + echo " - Certificate resolver '$TRAEFIK_CERTRESOLVER' must be configured" + fi + echo " - Disable read timeout on the entrypoint for gRPC streams:" + echo " --entrypoints.$TRAEFIK_ENTRYPOINT.transport.respondingTimeouts.readTimeout=0" + echo " - HTTP to HTTPS redirect (recommended)" + return 0 +} + +print_nginx_instructions() { + local bind_addr=$(get_bind_address) + echo "" + echo "$MSG_SEPARATOR" + echo " NGINX SETUP" + echo "$MSG_SEPARATOR" + echo "" + echo "Generated: nginx-netbird.conf" + echo "" + echo "IMPORTANT: Nginx requires manual TLS certificate setup." + echo "You'll need to obtain SSL/TLS certificates and configure the paths in the" + echo "generated config file. The config includes examples for common certificate sources." + echo "" + if [[ -n "$EXTERNAL_PROXY_NETWORK" ]]; then + echo "NetBird containers have joined the '$EXTERNAL_PROXY_NETWORK' Docker network." + echo "The config uses container names for upstream servers." + echo "" + echo "$MSG_NEXT_STEPS" + echo " 1. Ensure your Nginx container has access to SSL certificates" + echo " (mount certificate directory as volume if needed)" + echo " 2. Edit nginx-netbird.conf and update SSL certificate paths" + echo " The config includes examples for certbot, acme.sh, and custom certs" + echo " 3. Include the config in your Nginx container's configuration" + echo " 4. Reload Nginx" + else + echo "$MSG_NEXT_STEPS" + echo " 1. Obtain SSL/TLS certificates (Let's Encrypt recommended)" + echo " 2. Edit nginx-netbird.conf and update certificate paths" + echo " 3. Install to /etc/nginx/sites-available/ (Debian) or /etc/nginx/conf.d/ (RHEL)" + echo " 4. Test and reload: nginx -t && systemctl reload nginx" + echo "" + echo "For detailed TLS setup instructions, see:" + echo "https://docs.netbird.io/selfhosted/reverse-proxy#tls-certificate-setup-for-nginx" + echo "" + echo "Container ports (bound to ${bind_addr}):" + echo " Dashboard: ${DASHBOARD_HOST_PORT}" + echo " NetBird Server: ${MANAGEMENT_HOST_PORT} (all services)" + fi + return 0 +} + +print_npm_instructions() { + local bind_addr=$(get_bind_address) + local upstream_host=$(get_upstream_host) + echo "" + echo "$MSG_SEPARATOR" + echo " NGINX PROXY MANAGER SETUP" + echo "$MSG_SEPARATOR" + echo "" + echo "Generated: npm-advanced-config.txt" + echo "" + if [[ -n "$EXTERNAL_PROXY_NETWORK" ]]; then + echo "NetBird containers have joined the '$EXTERNAL_PROXY_NETWORK' Docker network." + echo "" + echo "In NPM, create a Proxy Host:" + echo " Domain: $NETBIRD_DOMAIN" + echo " Forward Hostname: netbird-dashboard" + echo " Forward Port: 80" + echo " Block Common Exploits: enabled" + echo "" + echo " SSL tab:" + echo " - Request or select existing certificate" + echo " - Enable 'HTTP/2 Support' (REQUIRED for gRPC)" + echo "" + echo " Advanced tab:" + echo " - Paste contents of npm-advanced-config.txt" + else + echo "Container ports (bound to ${bind_addr}):" + echo " Dashboard: ${DASHBOARD_HOST_PORT}" + echo " NetBird Server: ${MANAGEMENT_HOST_PORT} (all services)" + echo "" + echo "In NPM, create a Proxy Host:" + echo " Domain: $NETBIRD_DOMAIN" + echo " Forward Hostname/IP: ${upstream_host}" + echo " Forward Port: ${DASHBOARD_HOST_PORT}" + echo " Block Common Exploits: enabled" + echo "" + echo " SSL tab:" + echo " - Request or select existing certificate" + echo " - Enable 'HTTP/2 Support' (REQUIRED for gRPC)" + echo "" + echo " Advanced tab:" + echo " - Paste contents of npm-advanced-config.txt" + fi + return 0 +} + +print_external_caddy_instructions() { + local bind_addr=$(get_bind_address) + echo "" + echo "$MSG_SEPARATOR" + echo " EXTERNAL CADDY SETUP" + echo "$MSG_SEPARATOR" + echo "" + echo "Generated: caddyfile-netbird.txt" + echo "" + if [[ -n "$EXTERNAL_PROXY_NETWORK" ]]; then + echo "NetBird containers have joined the '$EXTERNAL_PROXY_NETWORK' Docker network." + echo "The config uses container names for upstream servers." + echo "" + echo "$MSG_NEXT_STEPS" + echo " 1. Add the contents of caddyfile-netbird.txt to your Caddyfile" + echo " 2. Reload Caddy" + else + echo "$MSG_NEXT_STEPS" + echo " 1. Add the contents of caddyfile-netbird.txt to your Caddyfile" + echo " 2. Reload Caddy: caddy reload --config /path/to/Caddyfile" + echo "" + echo "Container ports (bound to ${bind_addr}):" + echo " Dashboard: ${DASHBOARD_HOST_PORT}" + echo " NetBird Server: ${MANAGEMENT_HOST_PORT} (all services)" + fi + return 0 +} + +print_manual_instructions() { + local bind_addr=$(get_bind_address) + local upstream_host=$(get_upstream_host) + echo "" + echo "$MSG_SEPARATOR" + echo " MANUAL REVERSE PROXY SETUP" + echo "$MSG_SEPARATOR" + echo "" + echo "Container ports (bound to ${bind_addr}):" + echo " Dashboard: ${DASHBOARD_HOST_PORT}" + echo " NetBird Server: ${MANAGEMENT_HOST_PORT} (all services: management, signal, relay)" + echo "" + echo "Configure your reverse proxy with these routes (all go to the same backend):" + echo "" + echo " WebSocket (relay, signal, management WS proxy):" + echo " /relay*, /ws-proxy/* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}" + echo " (HTTP with WebSocket upgrade, extended timeout)" + echo "" + echo " Native gRPC (signal + management):" + echo " /signalexchange.SignalExchange/* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}" + echo " /management.ManagementService/* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}" + echo " (gRPC/h2c - plaintext HTTP/2)" + echo "" + echo " HTTP (API + embedded IdP):" + echo " /api/*, /oauth2/* -> ${upstream_host}:${MANAGEMENT_HOST_PORT}" + echo "" + echo " Dashboard (catch-all):" + echo " /* -> ${upstream_host}:${DASHBOARD_HOST_PORT}" + echo "" + echo "IMPORTANT: gRPC routes require HTTP/2 (h2c) upstream support." + echo "WebSocket and gRPC connections need extended timeouts (recommend 1 day)." + return 0 +} + +print_post_setup_instructions() { + case "$REVERSE_PROXY_TYPE" in + 0) + print_builtin_traefik_instructions + ;; + 1) + print_traefik_instructions + ;; + 2) + print_nginx_instructions + ;; + 3) + print_npm_instructions + ;; + 4) + print_external_caddy_instructions + ;; + 5) + print_manual_instructions + ;; + *) + echo "Unknown reverse proxy type: $REVERSE_PROXY_TYPE" > /dev/stderr + ;; + esac + return 0 +} + +init_environment diff --git a/infrastructure_files/migrate-to-enterprise.sh b/infrastructure_files/migrate-to-enterprise.sh new file mode 100755 index 000000000..d4c59699b --- /dev/null +++ b/infrastructure_files/migrate-to-enterprise.sh @@ -0,0 +1,677 @@ +#!/bin/bash + +set -e +set -o pipefail + +# NetBird — community combined → Enterprise combined migration +# +# Non-destructive migration: produces docker-compose.override.yml (auto-loaded +# by docker compose) and config.yaml.enterprise alongside the operator's +# existing files. Original docker-compose.yml and config.yaml are never +# modified. +# +# Steps (all optional, asked interactively): +# 1. Image swap — replace community images with enterprise cloud images. +# 2. Postgres migration — add Postgres, migrate SQLite data via migrate-store. +# 3. Traffic flow — add NATS + flow-enricher + flow-receiver. +# +# To revert: +# docker compose down +# rm -f docker-compose.override.yml config.yaml.enterprise +# # If Postgres migration was done, also restore the SQLite backup printed +# # at the end of this script's run. +# docker compose up -d + +OVERRIDE_FILE="docker-compose.override.yml" +ENTERPRISE_CONFIG_FILE="config.yaml.enterprise" + +NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA" + +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 + echo "docker-compose is not installed or not in PATH." > /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("^netbirdio/netbird-server"))) | .[0].key // ""' "$COMPOSE_FILE" +} + +detect_dashboard_service() { + yq eval '.services | to_entries | map(select(.value.image | test("^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" +} + +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 [[ "$ENABLE_FLOW" == "yes" ]]; then + local flow_addr="${NETBIRD_DOMAIN}" + yq eval -i " + .server.trafficFlow.enabled = true | + .server.trafficFlow.address = \"$flow_addr\" | + .server.trafficFlow.interval = \"60s\" + " "$ENTERPRISE_CONFIG_FILE" + fi +} + +# --------------------------------------------------------------------------- +# Execution steps +# --------------------------------------------------------------------------- + +resolve_data_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 data_volume_actual + data_volume_actual=$(resolve_data_volume "$DATA_VOLUME") + echo "Backing up SQLite store from volume '$data_volume_actual' to $BACKUP_DIR ..." + docker run --rm \ + -v "${data_volume_actual}:/var/lib/netbird:ro" \ + -v "${BACKUP_DIR}:/backup" \ + busybox \ + sh -c 'cp -a /var/lib/netbird/. /backup/ 2>/dev/null || true' + local copied + copied=$(find "$BACKUP_DIR" -mindepth 1 | head -1) + if [[ -z "$copied" ]]; then + echo " ⚠ Backup directory is empty — the volume '$data_volume_actual' didn't contain 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" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +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* 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* 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 + + 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 "" + + 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 — optional + echo "" + MIGRATE_POSTGRES=$(read_yes_no "Step 2: Migrate storage from SQLite to Postgres? (recommended)" "n") + if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then + 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." + else + POSTGRES_PASSWORD=$(rand_password) + fi + fi + + # Step 3 — optional, only if Postgres is on (flow requires Postgres) + echo "" + if [[ "$MIGRATE_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 + fi + else + ENABLE_FLOW="no" + echo "Step 3 (traffic flow) skipped — requires Postgres." + fi +} + +apply_changes() { + 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 [[ "$MIGRATE_POSTGRES" == "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" + 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 + 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 "" + echo "Stopping existing services (volumes preserved) ..." + $DOCKER_COMPOSE_COMMAND down + + backup_sqlite + + 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 "" + echo "Migration complete." +} + +print_summary() { + echo "" + echo "──────────────────────────────────────────────────────────────────────" + echo " Summary" + echo "──────────────────────────────────────────────────────────────────────" + echo " Images: swapped to enterprise" + [[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " Storage: Postgres (data migrated from SQLite)" + [[ "$MIGRATE_POSTGRES" != "yes" ]] && echo " Storage: SQLite (unchanged)" + [[ "$ENABLE_FLOW" == "yes" ]] && echo " Traffic flow: enabled" + [[ "$ENABLE_FLOW" != "yes" ]] && echo " Traffic flow: disabled" + echo "" + echo " Generated files (next to your docker-compose.yml):" + echo " $OVERRIDE_FILE" + [[ "$MIGRATE_POSTGRES" == "yes" ]] && echo " $ENTERPRISE_CONFIG_FILE" + echo " .env (license key + secrets, mode 600)" + [[ "$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 "──────────────────────────────────────────────────────────────────────" + echo " $DOCKER_COMPOSE_COMMAND down" + if [[ "$MIGRATE_POSTGRES" == "yes" ]]; then + # Resolve project-prefixed volume names now (before override is removed). + local pg_volume data_volume_actual + pg_volume=$(resolve_data_volume "netbird_postgres") + data_volume_actual=$(resolve_data_volume "$DATA_VOLUME") + echo " # Remove the Postgres volume FIRST, before deleting the override file:" + echo " docker volume rm $pg_volume" + echo " # Restore SQLite from the backup created during this run:" + echo " docker run --rm -v ${data_volume_actual}:/var/lib/netbird -v ${BACKUP_DIR}:/backup busybox sh -c 'cp -a /backup/. /var/lib/netbird/'" + fi + echo " rm -f $OVERRIDE_FILE $ENTERPRISE_CONFIG_FILE" + echo " # Remove migrate-to-enterprise.sh additions from .env (search for the timestamp marker)" + echo " $DOCKER_COMPOSE_COMMAND up -d" + echo "──────────────────────────────────────────────────────────────────────" +} + +# --------------------------------------------------------------------------- +# Run +# --------------------------------------------------------------------------- + +init_migration +apply_changes +print_summary diff --git a/infrastructure_files/migrate.sh b/infrastructure_files/migrate.sh new file mode 100755 index 000000000..67895fab6 --- /dev/null +++ b/infrastructure_files/migrate.sh @@ -0,0 +1,1286 @@ +#!/bin/bash +# +# NetBird Migration Script: Pre-v0.65.0 → Combined Container Setup +# +# Migrates from the old 5-container deployment (dashboard, signal, relay, management, coturn) +# to the new 2-container setup (Traefik + combined netbird-server). +# +# Supported: Embedded IdP (Dex) setups with embedded Caddy or custom reverse proxy. +# Not supported: External IdP (Auth0, Keycloak, etc.) — use getting-started.sh for fresh setup. +# +# Usage: +# ./migrate.sh [--install-dir /path/to/netbird] [--non-interactive] + +set -euo pipefail + +############################################ +# Constants +############################################ + +readonly SCRIPT_VERSION="1.0.0" +readonly DASHBOARD_IMAGE="netbirdio/dashboard:latest" +readonly NETBIRD_SERVER_IMAGE="netbirdio/netbird-server:latest" +readonly SED_STRIP_PADDING='s/=//g' +readonly MSG_SEPARATOR="==========================================" +readonly PROXY_TYPE_CADDY="caddy_embedded" + +# Colors (disabled if not a terminal) +if [[ -t 1 ]]; then + readonly RED='\033[0;31m' + readonly GREEN='\033[0;32m' + readonly YELLOW='\033[1;33m' + readonly BLUE='\033[0;34m' + readonly NC='\033[0m' +else + readonly RED='' + readonly GREEN='' + readonly YELLOW='' + readonly BLUE='' + readonly NC='' +fi + +############################################ +# Global Variables (set during detection) +############################################ + +INSTALL_DIR="" +NON_INTERACTIVE=false +DOCKER_COMPOSE_CMD="" + +# Detection results +PROXY_TYPE="" # caddy_embedded | traefik | external +IDP_TYPE="" # embedded | external +MGMT_VOLUME="" # detected management volume name +DOMAIN="" +LETSENCRYPT_EMAIL="" +STORE_ENGINE="sqlite" +STORE_DSN="" +ENCRYPTION_KEY="" +RELAY_SECRET="" +SIGNKEY_REFRESH="true" +TRUSTED_PROXIES="" +TRUSTED_PROXIES_COUNT="" +TRUSTED_PEERS="" +MANAGEMENT_JSON_PATH="" +BACKUP_DIR="" + +############################################ +# Utility Functions +############################################ + +log_info() { + local msg="$1" + echo -e "${BLUE}[INFO]${NC} ${msg}" + return 0 +} + +log_warn() { + local msg="$1" + echo -e "${YELLOW}[WARN]${NC} ${msg}" + return 0 +} + +log_error() { + local msg="$1" + echo -e "${RED}[ERROR]${NC} ${msg}" >&2 + return 0 +} + +log_success() { + local msg="$1" + echo -e "${GREEN}[OK]${NC} ${msg}" + return 0 +} + +print_banner() { + echo "" + echo "$MSG_SEPARATOR" + echo " NetBird Migration Tool v${SCRIPT_VERSION}" + echo " Pre-v0.65.0 → Combined Container Setup" + echo "$MSG_SEPARATOR" + echo "" + return 0 +} + +confirm_action() { + local prompt="$1" + if [[ "$NON_INTERACTIVE" == "true" ]]; then + return 0 + fi + echo "" + echo -n "$prompt [y/N]: " + read -r response < /dev/tty + if [[ ! "$response" =~ ^[Yy]$ ]]; then + log_error "Aborted by user." + exit 1 + fi + return 0 +} + +############################################ +# Phase 0: Preflight & Detection +############################################ + +check_dependencies() { + log_info "Checking dependencies..." + + local missing=() + + if ! command -v docker &>/dev/null; then + missing+=("docker") + fi + + if command -v docker-compose &>/dev/null; then + DOCKER_COMPOSE_CMD="docker-compose" + elif docker compose --help &>/dev/null 2>&1; then + DOCKER_COMPOSE_CMD="docker compose" + else + missing+=("docker-compose") + fi + + if ! command -v jq &>/dev/null; then + missing+=("jq") + fi + + if ! command -v openssl &>/dev/null; then + missing+=("openssl") + fi + + if ! command -v curl &>/dev/null; then + missing+=("curl") + fi + + if [[ ${#missing[@]} -gt 0 ]]; then + log_error "Missing required dependencies: ${missing[*]}" + echo "Please install them and re-run the script." + exit 1 + fi + + log_success "All dependencies found (docker compose: '$DOCKER_COMPOSE_CMD')" + return 0 +} + +detect_install_dir() { + if [[ -n "$INSTALL_DIR" ]]; then + if [[ ! -d "$INSTALL_DIR" ]]; then + log_error "Specified install directory does not exist: $INSTALL_DIR" + exit 1 + fi + return 0 + fi + + log_info "Detecting installation directory..." + + local search_paths=("$PWD" "/opt/netbird" "/opt/wiretrustee") + for dir in "${search_paths[@]}"; do + if [[ -f "$dir/management.json" ]] || [[ -f "$dir/artifacts/management.json" ]]; then + INSTALL_DIR="$dir" + log_success "Found installation at: $INSTALL_DIR" + return 0 + fi + done + + if [[ "$NON_INTERACTIVE" == "true" ]]; then + log_error "Could not auto-detect installation directory. Use --install-dir to specify." + exit 1 + fi + + echo "" + echo -n "Enter the path to your NetBird installation directory: " + read -r INSTALL_DIR < /dev/tty + if [[ ! -d "$INSTALL_DIR" ]]; then + log_error "Directory does not exist: $INSTALL_DIR" + exit 1 + fi + return 0 +} + +validate_old_setup() { + log_info "Validating old setup..." + + # Find management.json — check both root and artifacts/ + if [[ -f "$INSTALL_DIR/management.json" ]]; then + MANAGEMENT_JSON_PATH="$INSTALL_DIR/management.json" + elif [[ -f "$INSTALL_DIR/artifacts/management.json" ]]; then + MANAGEMENT_JSON_PATH="$INSTALL_DIR/artifacts/management.json" + else + log_error "Cannot find management.json in $INSTALL_DIR or $INSTALL_DIR/artifacts/" + echo "This doesn't appear to be a valid NetBird installation." + exit 1 + fi + + # Check for docker-compose.yml (in root or artifacts/) + local compose_found=false + if [[ -f "$INSTALL_DIR/docker-compose.yml" ]]; then + compose_found=true + elif [[ -f "$INSTALL_DIR/artifacts/docker-compose.yml" ]]; then + compose_found=true + fi + + if [[ "$compose_found" != "true" ]]; then + log_error "Cannot find docker-compose.yml in $INSTALL_DIR or $INSTALL_DIR/artifacts/" + exit 1 + fi + + log_success "Found management.json at: $MANAGEMENT_JSON_PATH" + return 0 +} + +check_already_migrated() { + if [[ -f "$INSTALL_DIR/config.yaml" ]]; then + log_warn "config.yaml already exists in $INSTALL_DIR" + echo "It appears this installation has already been migrated." + echo "If you want to re-run the migration, remove config.yaml first." + exit 0 + fi + return 0 +} + +detect_reverse_proxy() { + log_info "Detecting reverse proxy type..." + + local compose_file="" + if [[ -f "$INSTALL_DIR/docker-compose.yml" ]]; then + compose_file="$INSTALL_DIR/docker-compose.yml" + elif [[ -f "$INSTALL_DIR/artifacts/docker-compose.yml" ]]; then + compose_file="$INSTALL_DIR/artifacts/docker-compose.yml" + fi + + # Check for Traefik service or labels + if grep -q 'traefik' "$compose_file" 2>/dev/null; then + PROXY_TYPE="traefik" + log_info "Detected: Traefik reverse proxy" + return 0 + fi + + # Check for embedded Caddy — two patterns: + # 1. Old configure.sh: dashboard container with LETSENCRYPT_DOMAIN env var + ports 80/443 + # 2. v0.62+ getting-started.sh: Caddy service in compose or standalone Caddyfile + if grep -q 'LETSENCRYPT_DOMAIN' "$compose_file" 2>/dev/null && { grep -q '443:443' "$compose_file" 2>/dev/null || grep -q '443:' "$compose_file" 2>/dev/null; }; then + PROXY_TYPE="$PROXY_TYPE_CADDY" + log_info "Detected: Embedded Caddy (dashboard container with Let's Encrypt)" + return 0 + fi + + # Check for Caddy service in docker-compose.yml (v0.62+ pattern) + if grep -qE '^\s+caddy:|^\s+image:.*caddy' "$compose_file" 2>/dev/null; then + PROXY_TYPE="$PROXY_TYPE_CADDY" + log_info "Detected: Caddy reverse proxy (in Docker Compose)" + return 0 + fi + + # Check for standalone Caddyfile in install directory (v0.62+ getting-started.sh) + if [[ -f "$INSTALL_DIR/Caddyfile" ]]; then + # Verify Caddy is referenced in docker-compose.yml or running as a container + if grep -q 'caddy' "$compose_file" 2>/dev/null || grep -q 'Caddyfile' "$compose_file" 2>/dev/null; then + PROXY_TYPE="$PROXY_TYPE_CADDY" + log_info "Detected: Caddy reverse proxy (Caddyfile + Docker Compose)" + return 0 + fi + # Caddyfile exists but not in compose — might be running on host + PROXY_TYPE="$PROXY_TYPE_CADDY" + log_info "Detected: Caddy reverse proxy (standalone Caddyfile)" + return 0 + fi + + # Check for disabled Let's Encrypt (external proxy) + if [[ -f "$INSTALL_DIR/setup.env" ]] && grep -q 'NETBIRD_DISABLE_LETSENCRYPT=true' "$INSTALL_DIR/setup.env" 2>/dev/null; then + PROXY_TYPE="external" + log_info "Detected: External reverse proxy (Let's Encrypt disabled)" + return 0 + fi + + # Default to external + PROXY_TYPE="external" + log_info "Detected: External/custom reverse proxy" + return 0 +} + +detect_idp_type() { + log_info "Detecting identity provider type..." + + # Check for embedded IdP (v0.62.0+ getting-started.sh format) + local embedded_enabled + embedded_enabled=$(jq -r '.EmbeddedIdP.Enabled // false' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "false") + if [[ "$embedded_enabled" == "true" ]]; then + IDP_TYPE="embedded" + log_success "IdP type: embedded (suitable for migration)" + return 0 + fi + + # Check IdpManagerConfig.ManagerType (old configure.sh format) + local manager_type + manager_type=$(jq -r '.IdpManagerConfig.ManagerType // ""' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "") + + if [[ -n "$manager_type" && "$manager_type" != "null" && "$manager_type" != "none" && "$manager_type" != "" ]]; then + IDP_TYPE="external" + log_error "External IdP detected: $manager_type" + echo "" + echo "This migration script only supports embedded IdP setups." + echo "External IdP providers (Auth0, Keycloak, Zitadel, etc.) require" + echo "a fresh installation using getting-started.sh." + echo "" + echo "Please refer to the NetBird documentation for upgrade instructions:" + echo " https://docs.netbird.io/selfhosted/getting-started" + exit 1 + fi + + # Check HttpConfig.AuthIssuer for well-known external providers + local auth_issuer + auth_issuer=$(jq -r '.HttpConfig.AuthIssuer // ""' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "") + + if [[ -n "$auth_issuer" && "$auth_issuer" != "null" ]]; then + for provider in "auth0.com" "accounts.google.com" "login.microsoftonline.com" "keycloak" "zitadel" "authentik"; do + if echo "$auth_issuer" | grep -qi "$provider" 2>/dev/null; then + log_error "External OIDC provider detected: $auth_issuer" + echo "" + echo "This migration script only supports embedded IdP setups." + echo "Please use getting-started.sh for a fresh installation." + exit 1 + fi + done + fi + + # No embedded IdP and no external IdP detected — assume old setup without IdP manager + IDP_TYPE="embedded" + log_success "IdP type: embedded (suitable for migration)" + return 0 +} + +detect_volumes() { + log_info "Detecting Docker volumes..." + + local volumes_list + volumes_list=$(docker volume ls --format '{{.Name}}' 2>/dev/null || echo "") + + # Check for well-known volume name patterns (exact match) + local volume_patterns=( + "wiretrustee-mgmt" + "netbird-mgmt" + ) + for pattern in "${volume_patterns[@]}"; do + if echo "$volumes_list" | grep -q "^${pattern}$"; then + MGMT_VOLUME="$pattern" + log_success "Found management volume: $MGMT_VOLUME" + return 0 + fi + done + + # Check compose-prefixed patterns (e.g., netbird_netbird-mgmt, infrastructure_files_netbird-mgmt) + local compose_prefixed + compose_prefixed=$(echo "$volumes_list" | grep -E '(netbird|wiretrustee).*mgmt' | head -n1 || echo "") + if [[ -n "$compose_prefixed" ]]; then + MGMT_VOLUME="$compose_prefixed" + log_success "Found management volume (compose-prefixed): $MGMT_VOLUME" + return 0 + fi + + # Try to extract volume name from old docker-compose.yml + local compose_file="" + if [[ -f "$INSTALL_DIR/docker-compose.yml" ]]; then + compose_file="$INSTALL_DIR/docker-compose.yml" + elif [[ -f "$INSTALL_DIR/artifacts/docker-compose.yml" ]]; then + compose_file="$INSTALL_DIR/artifacts/docker-compose.yml" + fi + if [[ -n "$compose_file" ]]; then + # Look for volume mount on /var/lib/netbird in management or netbird-server service + local vol_name + vol_name=$(grep -E '^\s+-\s+\S+:/var/lib/netbird' "$compose_file" 2>/dev/null | head -1 | sed 's/.*- //' | sed 's/:.*//' | tr -d ' ' || echo "") + if [[ -n "$vol_name" && "$vol_name" != "." && "$vol_name" != "/" ]]; then + # Check if this volume exists in Docker + local full_vol + full_vol=$(echo "$volumes_list" | grep -F "$vol_name" | head -1 || echo "") + if [[ -n "$full_vol" ]]; then + MGMT_VOLUME="$full_vol" + log_success "Found management volume (from compose): $MGMT_VOLUME" + return 0 + fi + fi + fi + + log_warn "Could not detect management volume. A new volume will be created." + MGMT_VOLUME="" + return 0 +} + +detect_domain() { + log_info "Detecting domain..." + + # Try setup.env first + if [[ -z "$DOMAIN" && -f "$INSTALL_DIR/setup.env" ]]; then + DOMAIN=$(grep '^NETBIRD_DOMAIN=' "$INSTALL_DIR/setup.env" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "") + fi + + # Try EmbeddedIdP.Issuer (v0.62.0+ getting-started.sh format) + if [[ -z "$DOMAIN" ]]; then + local issuer + issuer=$(jq -r '.EmbeddedIdP.Issuer // ""' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "") + if [[ -n "$issuer" && "$issuer" != "null" ]]; then + DOMAIN=$(echo "$issuer" | sed 's|https\?://||' | sed 's|/.*||' | sed 's|:.*||') + fi + fi + + # Try HttpConfig.AuthIssuer (old configure.sh format) + if [[ -z "$DOMAIN" ]]; then + local issuer + issuer=$(jq -r '.HttpConfig.AuthIssuer // ""' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "") + if [[ -n "$issuer" && "$issuer" != "null" ]]; then + DOMAIN=$(echo "$issuer" | sed 's|https\?://||' | sed 's|/.*||' | sed 's|:.*||') + fi + fi + + # Try dashboard.env NETBIRD_MGMT_API_ENDPOINT + if [[ -z "$DOMAIN" && -f "$INSTALL_DIR/dashboard.env" ]]; then + local endpoint + endpoint=$(grep '^NETBIRD_MGMT_API_ENDPOINT=' "$INSTALL_DIR/dashboard.env" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "") + if [[ -n "$endpoint" ]]; then + DOMAIN=$(echo "$endpoint" | sed 's|https\?://||' | sed 's|/.*||' | sed 's|:.*||') + fi + fi + + if [[ -z "$DOMAIN" ]]; then + log_error "Could not detect domain from management.json, setup.env, or dashboard.env." + exit 1 + fi + + # Detect Let's Encrypt email from setup.env or dashboard.env LETSENCRYPT_DOMAIN + if [[ -f "$INSTALL_DIR/setup.env" ]]; then + LETSENCRYPT_EMAIL=$(grep '^NETBIRD_LETSENCRYPT_EMAIL=' "$INSTALL_DIR/setup.env" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "") + fi + + log_success "Domain: $DOMAIN" + if [[ -n "$LETSENCRYPT_EMAIL" ]]; then + log_success "Let's Encrypt email: $LETSENCRYPT_EMAIL" + fi + return 0 +} + +detect_store_config() { + log_info "Detecting store configuration..." + + # Engine from management.json + local engine + engine=$(jq -r '.StoreConfig.Engine // ""' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "") + if [[ -n "$engine" && "$engine" != "null" && "$engine" != "" ]]; then + STORE_ENGINE="$engine" + fi + + # DSN from environment files + if [[ -f "$INSTALL_DIR/setup.env" ]]; then + local pg_dsn + pg_dsn=$(grep '^NETBIRD_STORE_ENGINE_POSTGRES_DSN=' "$INSTALL_DIR/setup.env" 2>/dev/null | sed 's/^NETBIRD_STORE_ENGINE_POSTGRES_DSN=//' | tr -d '"' || echo "") + if [[ -n "$pg_dsn" ]]; then + STORE_DSN="$pg_dsn" + fi + + local mysql_dsn + mysql_dsn=$(grep '^NETBIRD_STORE_ENGINE_MYSQL_DSN=' "$INSTALL_DIR/setup.env" 2>/dev/null | sed 's/^NETBIRD_STORE_ENGINE_MYSQL_DSN=//' | tr -d '"' || echo "") + if [[ -n "$mysql_dsn" ]]; then + STORE_DSN="$mysql_dsn" + fi + fi + + # Also check base.setup.env + if [[ -z "$STORE_DSN" && -f "$INSTALL_DIR/base.setup.env" ]]; then + local pg_dsn + pg_dsn=$(grep '^NETBIRD_STORE_ENGINE_POSTGRES_DSN=' "$INSTALL_DIR/base.setup.env" 2>/dev/null | sed 's/^NETBIRD_STORE_ENGINE_POSTGRES_DSN=//' | tr -d '"' || echo "") + if [[ -n "$pg_dsn" ]]; then + STORE_DSN="$pg_dsn" + fi + + local mysql_dsn + mysql_dsn=$(grep '^NETBIRD_STORE_ENGINE_MYSQL_DSN=' "$INSTALL_DIR/base.setup.env" 2>/dev/null | sed 's/^NETBIRD_STORE_ENGINE_MYSQL_DSN=//' | tr -d '"' || echo "") + if [[ -n "$mysql_dsn" ]]; then + STORE_DSN="$mysql_dsn" + fi + fi + + log_success "Store engine: $STORE_ENGINE" + if [[ -n "$STORE_DSN" ]]; then + log_success "Store DSN: [detected]" + fi + return 0 +} + +extract_config_values() { + log_info "Extracting configuration from management.json..." + + # DataStoreEncryptionKey + ENCRYPTION_KEY=$(jq -r '.DataStoreEncryptionKey // ""' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "") + if [[ -z "$ENCRYPTION_KEY" || "$ENCRYPTION_KEY" == "null" ]]; then + ENCRYPTION_KEY=$(openssl rand -base64 32) + log_warn "No encryption key found in management.json — generated a new one." + log_warn "IMPORTANT: Save this key! Without it, existing encrypted data cannot be read." + echo " Encryption key: $ENCRYPTION_KEY" + fi + + # Relay secret from management.json + RELAY_SECRET=$(jq -r '.Relay.Secret // ""' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "") + + # Fallback: relay secret from setup.env + if [[ (-z "$RELAY_SECRET" || "$RELAY_SECRET" == "null") && -f "$INSTALL_DIR/setup.env" ]]; then + RELAY_SECRET=$(grep '^NETBIRD_RELAY_AUTH_SECRET=' "$INSTALL_DIR/setup.env" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "") + fi + + # Fallback: relay secret from base.setup.env + if [[ (-z "$RELAY_SECRET" || "$RELAY_SECRET" == "null") && -f "$INSTALL_DIR/base.setup.env" ]]; then + RELAY_SECRET=$(grep '^NETBIRD_RELAY_AUTH_SECRET=' "$INSTALL_DIR/base.setup.env" 2>/dev/null | cut -d'=' -f2 | tr -d '"' | tr -d "'" || echo "") + fi + + # Generate if still empty + if [[ -z "$RELAY_SECRET" || "$RELAY_SECRET" == "null" ]]; then + RELAY_SECRET=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING") + log_warn "No relay secret found — generated a new one." + fi + + # IdpSignKeyRefreshEnabled — check both HttpConfig and EmbeddedIdP locations + local signkey_raw + signkey_raw=$(jq -r '(.HttpConfig.IdpSignKeyRefreshEnabled // .EmbeddedIdP.SignKeyRefreshEnabled) // "true"' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "true") + if [[ "$signkey_raw" == "false" ]]; then + SIGNKEY_REFRESH="false" + else + SIGNKEY_REFRESH="true" + fi + + # ReverseProxy settings (may not exist in v0.62+ getting-started.sh format) + TRUSTED_PROXIES=$(jq -c '.ReverseProxy.TrustedHTTPProxies // []' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "[]") + TRUSTED_PROXIES_COUNT=$(jq -r '.ReverseProxy.TrustedHTTPProxiesCount // 0' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "0") + TRUSTED_PEERS=$(jq -c '.ReverseProxy.TrustedPeers // []' "$MANAGEMENT_JSON_PATH" 2>/dev/null || echo "[]") + + log_success "Configuration values extracted" + return 0 +} + +print_detection_summary() { + echo "" + echo "$MSG_SEPARATOR" + echo " Migration Summary" + echo "$MSG_SEPARATOR" + echo "" + echo " Install directory: $INSTALL_DIR" + echo " Domain: $DOMAIN" + echo " Reverse proxy: $PROXY_TYPE" + echo " Store engine: $STORE_ENGINE" + if [[ -n "$STORE_DSN" ]]; then + echo " Store DSN: [configured]" + fi + if [[ -n "$MGMT_VOLUME" ]]; then + echo " Management volume: $MGMT_VOLUME" + else + echo " Management volume: [new volume will be created]" + fi + echo " Encryption key: ${ENCRYPTION_KEY:0:8}..." + echo " Relay secret: ${RELAY_SECRET:0:8}..." + echo "" + + if [[ "$PROXY_TYPE" == "$PROXY_TYPE_CADDY" ]]; then + echo " Migration mode: AUTOMATIC" + echo " A Traefik-based docker-compose.yml will be generated and services" + echo " will be stopped and restarted automatically." + else + echo " Migration mode: MANUAL" + echo " New config files will be generated. You will need to stop old" + echo " containers, replace docker-compose.yml, and restart manually." + fi + echo "" + return 0 +} + +############################################ +# Phase 1: Backup +############################################ + +create_backup() { + BACKUP_DIR="$INSTALL_DIR/backup-$(date +%Y%m%d-%H%M%S)" + log_info "Creating backup at: $BACKUP_DIR" + mkdir -p "$BACKUP_DIR" + + # Copy config files + local files_to_backup=( + "docker-compose.yml" + "management.json" + "setup.env" + "base.setup.env" + "turnserver.conf" + "dashboard.env" + ) + + for f in "${files_to_backup[@]}"; do + if [[ -f "$INSTALL_DIR/$f" ]]; then + cp "$INSTALL_DIR/$f" "$BACKUP_DIR/$f" + fi + done + + # Back up artifacts/ if it exists + if [[ -d "$INSTALL_DIR/artifacts" ]]; then + cp -r "$INSTALL_DIR/artifacts" "$BACKUP_DIR/artifacts" + fi + + # Record state + { + echo "# NetBird migration backup state" + echo "# Created: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" + echo "" + echo "## Docker volumes" + docker volume ls --format '{{.Name}}' 2>/dev/null | grep -E '(netbird|wiretrustee)' || echo "(none found)" + echo "" + echo "## Running containers" + docker ps --format '{{.Names}}\t{{.Image}}\t{{.Status}}' 2>/dev/null | grep -E '(netbird|wiretrustee|dashboard|signal|relay|management|coturn)' || echo "(none running)" + } > "$BACKUP_DIR/state.txt" + + # Generate rollback script + generate_rollback_script + + log_success "Backup created at: $BACKUP_DIR" + return 0 +} + +generate_rollback_script() { + cat > "$BACKUP_DIR/rollback.sh" <<'ROLLBACK_HEADER' +#!/bin/bash +set -euo pipefail + +# NetBird Migration Rollback Script +# Restores the pre-migration configuration and restarts old containers. + +ROLLBACK_HEADER + + cat >> "$BACKUP_DIR/rollback.sh" </dev/null; then + COMPOSE_CMD="docker-compose" +elif docker compose --help &>/dev/null 2>&1; then + COMPOSE_CMD="docker compose" +else + echo "ERROR: docker compose not found" >&2 + exit 1 +fi + +echo "Stopping current containers..." +\$COMPOSE_CMD down 2>/dev/null || true + +# Restore old config files +echo "Restoring configuration files..." +for f in docker-compose.yml management.json setup.env base.setup.env turnserver.conf dashboard.env; do + if [[ -f "\$BACKUP_DIR/\$f" ]]; then + cp "\$BACKUP_DIR/\$f" "\$INSTALL_DIR/\$f" + echo " Restored: \$f" + fi +done + +# Remove new config files +for f in config.yaml; do + if [[ -f "\$INSTALL_DIR/\$f" ]]; then + rm "\$INSTALL_DIR/\$f" + echo " Removed: \$f" + fi +done + +# Restart old containers +echo "Starting old containers..." +cd "\$INSTALL_DIR" +\$COMPOSE_CMD up -d + +echo "" +echo "Rollback complete. Old containers are running." +echo "Verify with: \$COMPOSE_CMD ps" +ROLLBACK_BODY + + chmod +x "$BACKUP_DIR/rollback.sh" + return 0 +} + +############################################ +# Phase 3: Generate New Configuration Files +############################################ + +generate_config_yaml() { + log_info "Generating config.yaml..." + + local dsn_line="" + if [[ -n "$STORE_DSN" ]]; then + dsn_line=" dsn: \"$STORE_DSN\"" + fi + + local reverse_proxy_section="" + # Only add reverseProxy if there are non-default values + local has_proxy_config=false + if [[ "$TRUSTED_PROXIES" != "[]" && -n "$TRUSTED_PROXIES" ]]; then + has_proxy_config=true + fi + if [[ "$TRUSTED_PROXIES_COUNT" != "0" && -n "$TRUSTED_PROXIES_COUNT" ]]; then + has_proxy_config=true + fi + if [[ "$TRUSTED_PEERS" != "[]" && -n "$TRUSTED_PEERS" ]]; then + # Check if it's only the default ["0.0.0.0/0"] + local default_peers='["0.0.0.0/0"]' + if [[ "$TRUSTED_PEERS" != "$default_peers" ]]; then + has_proxy_config=true + fi + fi + + if [[ "$has_proxy_config" == "true" ]]; then + reverse_proxy_section=" + reverseProxy:" + if [[ "$TRUSTED_PROXIES" != "[]" && -n "$TRUSTED_PROXIES" ]]; then + reverse_proxy_section+=" + trustedHTTPProxies:" + for proxy in $(echo "$TRUSTED_PROXIES" | jq -r '.[]' 2>/dev/null); do + reverse_proxy_section+=" + - \"$proxy\"" + done + fi + if [[ "$TRUSTED_PROXIES_COUNT" != "0" && -n "$TRUSTED_PROXIES_COUNT" ]]; then + reverse_proxy_section+=" + trustedHTTPProxiesCount: $TRUSTED_PROXIES_COUNT" + fi + if [[ "$TRUSTED_PEERS" != "[]" && -n "$TRUSTED_PEERS" ]]; then + reverse_proxy_section+=" + trustedPeers:" + for peer in $(echo "$TRUSTED_PEERS" | jq -r '.[]' 2>/dev/null); do + reverse_proxy_section+=" + - \"$peer\"" + done + fi + fi + + { + cat < "$INSTALL_DIR/config.yaml" + + log_success "Generated config.yaml" + return 0 +} + +generate_dashboard_env() { + log_info "Generating dashboard.env..." + + cat > "$INSTALL_DIR/dashboard.env" < "$INSTALL_DIR/docker-compose.yml" < "$INSTALL_DIR/docker-compose.yml" </dev/null) || true + + log_success "Old containers stopped" + return 0 +} + +start_new_services() { + log_info "Starting new containers..." + + (cd "$INSTALL_DIR" && $DOCKER_COMPOSE_CMD up -d) + + log_success "New containers started" + return 0 +} + +wait_for_health() { + log_info "Waiting for services to become healthy..." + + local max_attempts=60 + local attempt=0 + + set +e + echo -n " Checking" + while [[ $attempt -lt $max_attempts ]]; do + # Try OIDC endpoint through reverse proxy + if curl -sk -f -o /dev/null "https://${DOMAIN}/oauth2/.well-known/openid-configuration" 2>/dev/null; then + echo " done" + set -e + log_success "Services are healthy" + return 0 + fi + + # Also try health check endpoint directly + if curl -sk -f -o /dev/null "http://127.0.0.1:9000/" 2>/dev/null; then + echo " done" + set -e + log_success "Services are healthy (via healthcheck)" + return 0 + fi + + echo -n " ." + sleep 2 + attempt=$((attempt + 1)) + + if [[ $attempt -eq 30 ]]; then + echo "" + log_warn "Taking longer than expected. Checking container logs..." + (cd "$INSTALL_DIR" && $DOCKER_COMPOSE_CMD logs --tail=10 netbird-server 2>/dev/null) || true + echo -n " Still checking" + fi + done + echo "" + set -e + + log_warn "Health check timed out after $((max_attempts * 2)) seconds." + log_warn "Services may still be starting. Check with: cd $INSTALL_DIR && $DOCKER_COMPOSE_CMD logs" + return 0 +} + +############################################ +# Phase 5: Verification & Summary +############################################ + +verify_migration() { + log_info "Running verification checks..." + + local checks_passed=0 + local checks_total=3 + + # Check 1: Container health + local running + running=$(cd "$INSTALL_DIR" && $DOCKER_COMPOSE_CMD ps --format '{{.Name}}' 2>/dev/null | wc -l || echo "0") + if [[ "$running" -ge 2 ]]; then + log_success "Containers are running ($running services)" + checks_passed=$((checks_passed + 1)) + else + log_warn "Expected at least 2 running containers, found $running" + fi + + # Check 2: OIDC endpoint + local oidc_status + oidc_status=$(curl -sk -o /dev/null -w '%{http_code}' "https://${DOMAIN}/oauth2/.well-known/openid-configuration" 2>/dev/null || echo "000") + if [[ "$oidc_status" == "200" ]]; then + log_success "OIDC endpoint responding (HTTP $oidc_status)" + checks_passed=$((checks_passed + 1)) + else + log_warn "OIDC endpoint returned HTTP $oidc_status (expected 200)" + fi + + # Check 3: Management API (expect 401 = working but needs auth, not 502 = proxy error) + local api_status + api_status=$(curl -sk -o /dev/null -w '%{http_code}' "https://${DOMAIN}/api/accounts" 2>/dev/null || echo "000") + if [[ "$api_status" == "401" || "$api_status" == "200" || "$api_status" == "403" ]]; then + log_success "Management API responding (HTTP $api_status)" + checks_passed=$((checks_passed + 1)) + else + log_warn "Management API returned HTTP $api_status (expected 401/200/403)" + fi + + echo "" + echo " Verification: $checks_passed/$checks_total checks passed" + return 0 +} + +print_summary() { + echo "" + echo "$MSG_SEPARATOR" + echo " Migration Complete" + echo "$MSG_SEPARATOR" + echo "" + + if [[ "$PROXY_TYPE" == "$PROXY_TYPE_CADDY" ]]; then + echo " What was done:" + echo " - Old 5-container setup stopped" + echo " - New config.yaml generated (combined server config)" + echo " - New dashboard.env generated (embedded IdP)" + echo " - New docker-compose.yml generated (Traefik + combined server)" + echo " - New containers started" + else + echo " What was done:" + echo " - New config.yaml generated (combined server config)" + echo " - New dashboard.env generated (embedded IdP)" + echo " - New docker-compose.yml generated (exposed ports)" + echo "" + echo " What you need to do:" + echo " 1. Stop old containers:" + echo " cd $INSTALL_DIR && $DOCKER_COMPOSE_CMD down" + echo "" + echo " 2. Start new containers:" + echo " cd $INSTALL_DIR && $DOCKER_COMPOSE_CMD up -d" + echo "" + echo " 3. Update your reverse proxy to route:" + echo " - /signalexchange.SignalExchange/* -> 127.0.0.1:8081 (gRPC/h2c)" + echo " - /management.ManagementService/* -> 127.0.0.1:8081 (gRPC/h2c)" + echo " - /relay*, /ws-proxy/* -> 127.0.0.1:8081 (WebSocket)" + echo " - /api/*, /oauth2/* -> 127.0.0.1:8081 (HTTP)" + echo " - /* -> 127.0.0.1:8080 (dashboard)" + fi + + echo "" + echo " Backup location: $BACKUP_DIR" + echo " Rollback command: bash $BACKUP_DIR/rollback.sh" + echo "" + echo " IMPORTANT:" + echo " - Existing peers, routes, and policies are preserved in the database." + echo " - The embedded IdP data is preserved in the management volume." + echo " - Clients should reconnect automatically; if not: netbird down && netbird up" + echo "" + echo " Next steps:" + echo " - Access the dashboard: https://$DOMAIN" + echo " - Re-authenticate all clients: netbird down && netbird up" + echo " - Check logs: cd $INSTALL_DIR && $DOCKER_COMPOSE_CMD logs -f" + echo "" + return 0 +} + +############################################ +# Main +############################################ + +main() { + # Parse arguments + while [[ $# -gt 0 ]]; do + local arg="$1" + case "$arg" in + --install-dir) + local dir_value="$2" + INSTALL_DIR="$dir_value" + shift 2 + ;; + --non-interactive) + NON_INTERACTIVE=true + shift + ;; + --help|-h) + echo "Usage: $0 [--install-dir /path/to/netbird] [--non-interactive]" + echo "" + echo "Migrates a pre-v0.65.0 NetBird deployment to the combined container setup." + echo "" + echo "Options:" + echo " --install-dir DIR Path to existing NetBird installation" + echo " --non-interactive Skip confirmation prompts (for automation)" + echo " -h, --help Show this help message" + exit 0 + ;; + *) + log_error "Unknown option: $arg" + echo "Use --help for usage information." + exit 1 + ;; + esac + done + + print_banner + + # Phase 0: Preflight & Detection + check_dependencies + detect_install_dir + validate_old_setup + check_already_migrated + detect_reverse_proxy + detect_idp_type + detect_volumes + detect_domain + detect_store_config + extract_config_values + print_detection_summary + + confirm_action "Proceed with migration?" + + # Phase 1: Backup + create_backup + + # Phase 4: Apply migration + if [[ "$PROXY_TYPE" == "$PROXY_TYPE_CADDY" ]]; then + # Stop old containers BEFORE overwriting docker-compose.yml + stop_old_services + + # Phase 2 + 3: Generate new configuration files + generate_config_yaml + generate_dashboard_env + generate_docker_compose + + start_new_services + sleep 3 + wait_for_health + + # Phase 5: Verification + verify_migration + else + # For manual proxy setups, just generate files (don't stop/start) + generate_config_yaml + generate_dashboard_env + generate_docker_compose + fi + + print_summary + return 0 +} + +main "$@" diff --git a/infrastructure_files/observability/grafana/dashboards/management.json b/infrastructure_files/observability/grafana/dashboards/management.json index 95983603f..f116a8bde 100644 --- a/infrastructure_files/observability/grafana/dashboards/management.json +++ b/infrastructure_files/observability/grafana/dashboards/management.json @@ -302,7 +302,7 @@ "uid": "${datasource}" }, "editorMode": "code", - "expr": "rate(management_account_peer_meta_update_counter_ratio_total{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])", + "expr": "rate(management_account_peer_meta_update_counter_total{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])", "instant": false, "legendFormat": "{{cluster}}/{{environment}}/{{job}}", "range": true, @@ -410,7 +410,7 @@ }, "disableTextWrap": false, "editorMode": "code", - "expr": "histogram_quantile(0.5,sum(increase(management_account_get_peer_network_map_duration_ms_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le,cluster,environment,job))", + "expr": "histogram_quantile(0.5,sum(increase(management_account_get_peer_network_map_duration_ms_milliseconds_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le,cluster,environment,job))", "format": "heatmap", "fullMetaSearch": false, "includeNullMetadata": true, @@ -426,7 +426,7 @@ }, "disableTextWrap": false, "editorMode": "code", - "expr": "histogram_quantile(0.9,sum(increase(management_account_get_peer_network_map_duration_ms_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le,cluster,environment,job))", + "expr": "histogram_quantile(0.9,sum(increase(management_account_get_peer_network_map_duration_ms_milliseconds_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le,cluster,environment,job))", "format": "heatmap", "fullMetaSearch": false, "hide": false, @@ -443,7 +443,7 @@ }, "disableTextWrap": false, "editorMode": "code", - "expr": "histogram_quantile(0.99,sum(increase(management_account_get_peer_network_map_duration_ms_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le,cluster,environment,job))", + "expr": "histogram_quantile(0.99,sum(increase(management_account_get_peer_network_map_duration_ms_milliseconds_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le,cluster,environment,job))", "format": "heatmap", "fullMetaSearch": false, "hide": false, @@ -545,7 +545,7 @@ }, "disableTextWrap": false, "editorMode": "code", - "expr": "histogram_quantile(0.5,sum(increase(management_account_update_account_peers_duration_ms_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le,cluster,environment,job))", + "expr": "histogram_quantile(0.5,sum(increase(management_account_update_account_peers_duration_ms_milliseconds_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le,cluster,environment,job))", "format": "heatmap", "fullMetaSearch": false, "includeNullMetadata": true, @@ -561,7 +561,7 @@ }, "disableTextWrap": false, "editorMode": "code", - "expr": "histogram_quantile(0.9,sum(increase(management_account_update_account_peers_duration_ms_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le,cluster,environment,job))", + "expr": "histogram_quantile(0.9,sum(increase(management_account_update_account_peers_duration_ms_milliseconds_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le,cluster,environment,job))", "format": "heatmap", "fullMetaSearch": false, "hide": false, @@ -578,7 +578,7 @@ }, "disableTextWrap": false, "editorMode": "code", - "expr": "histogram_quantile(0.99,sum(increase(management_account_update_account_peers_duration_ms_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le,cluster,environment,job))", + "expr": "histogram_quantile(0.99,sum(increase(management_account_update_account_peers_duration_ms_milliseconds_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le,cluster,environment,job))", "format": "heatmap", "fullMetaSearch": false, "hide": false, @@ -694,7 +694,7 @@ }, "disableTextWrap": false, "editorMode": "code", - "expr": "histogram_quantile(0.5,sum(increase(management_grpc_updatechannel_queue_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le,cluster,environment,job))", + "expr": "histogram_quantile(0.5,sum(increase(management_grpc_updatechannel_queue_length_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le,cluster,environment,job))", "format": "heatmap", "fullMetaSearch": false, "includeNullMetadata": true, @@ -710,7 +710,7 @@ }, "disableTextWrap": false, "editorMode": "code", - "expr": "histogram_quantile(0.9,sum(increase(management_grpc_updatechannel_queue_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le,cluster,environment,job))", + "expr": "histogram_quantile(0.9,sum(increase(management_grpc_updatechannel_queue_length_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le,cluster,environment,job))", "format": "heatmap", "fullMetaSearch": false, "hide": false, @@ -727,7 +727,7 @@ }, "disableTextWrap": false, "editorMode": "code", - "expr": "histogram_quantile(0.99,sum(increase(management_grpc_updatechannel_queue_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le,cluster,environment,job))", + "expr": "histogram_quantile(0.99,sum(increase(management_grpc_updatechannel_queue_length_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le,cluster,environment,job))", "format": "heatmap", "fullMetaSearch": false, "hide": false, @@ -841,7 +841,7 @@ "uid": "${datasource}" }, "editorMode": "code", - "expr": "histogram_quantile(0.50, sum(rate(management_store_persistence_duration_ms_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le))", + "expr": "histogram_quantile(0.50, sum(rate(management_store_persistence_duration_ms_milliseconds_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le))", "instant": false, "legendFormat": "p50", "range": true, @@ -853,7 +853,7 @@ "uid": "${datasource}" }, "editorMode": "code", - "expr": "histogram_quantile(0.90, sum(rate(management_store_persistence_duration_ms_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le))", + "expr": "histogram_quantile(0.90, sum(rate(management_store_persistence_duration_ms_milliseconds_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le))", "hide": false, "instant": false, "legendFormat": "p90", @@ -866,7 +866,7 @@ "uid": "${datasource}" }, "editorMode": "code", - "expr": "histogram_quantile(0.99, sum(rate(management_store_persistence_duration_ms_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le))", + "expr": "histogram_quantile(0.99, sum(rate(management_store_persistence_duration_ms_milliseconds_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le))", "hide": false, "instant": false, "legendFormat": "p99", @@ -963,7 +963,7 @@ "uid": "${datasource}" }, "editorMode": "code", - "expr": "histogram_quantile(0.50, sum(rate(management_store_transaction_duration_ms_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le))", + "expr": "histogram_quantile(0.50, sum(rate(management_store_transaction_duration_ms_milliseconds_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le))", "instant": false, "legendFormat": "p50", "range": true, @@ -975,7 +975,7 @@ "uid": "${datasource}" }, "editorMode": "code", - "expr": "histogram_quantile(0.90, sum(rate(management_store_transaction_duration_ms_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le))", + "expr": "histogram_quantile(0.90, sum(rate(management_store_transaction_duration_ms_milliseconds_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le))", "hide": false, "instant": false, "legendFormat": "p90", @@ -988,7 +988,7 @@ "uid": "${datasource}" }, "editorMode": "code", - "expr": "histogram_quantile(0.99, sum(rate(management_store_transaction_duration_ms_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le))", + "expr": "histogram_quantile(0.99, sum(rate(management_store_transaction_duration_ms_milliseconds_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le))", "hide": false, "instant": false, "legendFormat": "p99", @@ -1085,7 +1085,7 @@ "uid": "${datasource}" }, "editorMode": "code", - "expr": "histogram_quantile(0.50, sum(rate(management_store_global_lock_acquisition_duration_ms_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le))", + "expr": "histogram_quantile(0.50, sum(rate(management_store_global_lock_acquisition_duration_ms_milliseconds_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le))", "instant": false, "legendFormat": "p50", "range": true, @@ -1097,7 +1097,7 @@ "uid": "${datasource}" }, "editorMode": "code", - "expr": "histogram_quantile(0.90, sum(rate(management_store_global_lock_acquisition_duration_ms_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le))", + "expr": "histogram_quantile(0.90, sum(rate(management_store_global_lock_acquisition_duration_ms_milliseconds_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le))", "hide": false, "instant": false, "legendFormat": "p90", @@ -1110,7 +1110,7 @@ "uid": "${datasource}" }, "editorMode": "code", - "expr": "histogram_quantile(0.99, sum(rate(management_store_global_lock_acquisition_duration_ms_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le))", + "expr": "histogram_quantile(0.99, sum(rate(management_store_global_lock_acquisition_duration_ms_milliseconds_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (le))", "hide": false, "instant": false, "legendFormat": "p99", @@ -1221,7 +1221,7 @@ "uid": "${datasource}" }, "editorMode": "code", - "expr": "rate(management_idp_authenticate_request_counter_ratio_total{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])", + "expr": "rate(management_idp_authenticate_request_counter_total{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])", "instant": false, "legendFormat": "{{cluster}}/{{environment}}/{{job}}", "range": true, @@ -1317,7 +1317,7 @@ "uid": "${datasource}" }, "editorMode": "code", - "expr": "rate(management_idp_get_account_counter_ratio_total{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])", + "expr": "rate(management_idp_get_account_counter_total{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])", "instant": false, "legendFormat": "{{cluster}}/{{environment}}/{{job}}", "range": true, @@ -1413,7 +1413,7 @@ "uid": "${datasource}" }, "editorMode": "code", - "expr": "rate(management_idp_update_user_meta_counter_ratio_total{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])", + "expr": "rate(management_idp_update_user_meta_counter_total{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])", "instant": false, "legendFormat": "{{cluster}}/{{environment}}/{{job}}", "range": true, @@ -1523,7 +1523,7 @@ "uid": "${datasource}" }, "editorMode": "code", - "expr": "sum(rate(management_http_request_counter_ratio_total{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\",method=~\"GET|OPTIONS\"}[$__rate_interval])) by (job,method)", + "expr": "sum(rate(management_http_request_counter_total{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\",method=~\"GET|OPTIONS\"}[$__rate_interval])) by (job,method)", "instant": false, "legendFormat": "{{method}}", "range": true, @@ -1619,7 +1619,7 @@ "uid": "${datasource}" }, "editorMode": "code", - "expr": "sum(rate(management_http_request_counter_ratio_total{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\",method=~\"POST|PUT|DELETE\"}[$__rate_interval])) by (job,method)", + "expr": "sum(rate(management_http_request_counter_total{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\",method=~\"POST|PUT|DELETE\"}[$__rate_interval])) by (job,method)", "instant": false, "legendFormat": "{{method}}", "range": true, @@ -1715,7 +1715,7 @@ "uid": "${datasource}" }, "editorMode": "code", - "expr": "histogram_quantile(0.50, sum(rate(management_http_request_duration_ms_total_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\",type=~\"read\"}[5m])) by (le))", + "expr": "histogram_quantile(0.50, sum(rate(management_http_request_duration_ms_total_milliseconds_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\",type=~\"read\"}[5m])) by (le))", "instant": false, "legendFormat": "p50", "range": true, @@ -1727,7 +1727,7 @@ "uid": "${datasource}" }, "editorMode": "code", - "expr": "histogram_quantile(0.90, sum(rate(management_http_request_duration_ms_total_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\",type=~\"read\"}[5m])) by (le))", + "expr": "histogram_quantile(0.90, sum(rate(management_http_request_duration_ms_total_milliseconds_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\",type=~\"read\"}[5m])) by (le))", "hide": false, "instant": false, "legendFormat": "p90", @@ -1740,7 +1740,7 @@ "uid": "${datasource}" }, "editorMode": "code", - "expr": "histogram_quantile(0.99, sum(rate(management_http_request_duration_ms_total_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\",type=~\"read\"}[5m])) by (le))", + "expr": "histogram_quantile(0.99, sum(rate(management_http_request_duration_ms_total_milliseconds_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\",type=~\"read\"}[5m])) by (le))", "hide": false, "instant": false, "legendFormat": "p99", @@ -1837,7 +1837,7 @@ "uid": "${datasource}" }, "editorMode": "code", - "expr": "histogram_quantile(0.50, sum(rate(management_http_request_duration_ms_total_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\",type=~\"write\"}[5m])) by (le))", + "expr": "histogram_quantile(0.50, sum(rate(management_http_request_duration_ms_total_milliseconds_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\",type=~\"write\"}[5m])) by (le))", "instant": false, "legendFormat": "p50", "range": true, @@ -1849,7 +1849,7 @@ "uid": "${datasource}" }, "editorMode": "code", - "expr": "histogram_quantile(0.90, sum(rate(management_http_request_duration_ms_total_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\",type=~\"write\"}[5m])) by (le))", + "expr": "histogram_quantile(0.90, sum(rate(management_http_request_duration_ms_total_milliseconds_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\",type=~\"write\"}[5m])) by (le))", "hide": false, "instant": false, "legendFormat": "p90", @@ -1862,7 +1862,7 @@ "uid": "${datasource}" }, "editorMode": "code", - "expr": "histogram_quantile(0.99, sum(rate(management_http_request_duration_ms_total_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\",type=~\"write\"}[5m])) by (le))", + "expr": "histogram_quantile(0.99, sum(rate(management_http_request_duration_ms_total_milliseconds_bucket{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\",type=~\"write\"}[5m])) by (le))", "hide": false, "instant": false, "legendFormat": "p99", @@ -1963,7 +1963,7 @@ "uid": "${datasource}" }, "editorMode": "code", - "expr": "sum(rate(management_http_request_counter_ratio_total{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (job,exported_endpoint,method)", + "expr": "sum(rate(management_http_request_counter_total{cluster=~\"$cluster\",environment=~\"$environment\",job=~\"$job\",host=~\"$host\"}[$__rate_interval])) by (job,exported_endpoint,method)", "hide": false, "instant": false, "legendFormat": "{{method}}-{{exported_endpoint}}", @@ -3222,7 +3222,7 @@ }, "disableTextWrap": false, "editorMode": "builder", - "expr": "sum by(le) (increase(management_grpc_updatechannel_queue_bucket{application=\"management\", environment=\"$environment\", host=~\"$host\"}[$__rate_interval]))", + "expr": "sum by(le) (increase(management_grpc_updatechannel_queue_length_bucket{application=\"management\", environment=\"$environment\", host=~\"$host\"}[$__rate_interval]))", "format": "heatmap", "fullMetaSearch": false, "includeNullMetadata": true, @@ -3323,7 +3323,7 @@ }, "disableTextWrap": false, "editorMode": "builder", - "expr": "sum by(le) (increase(management_account_update_account_peers_duration_ms_bucket{application=\"management\", environment=\"$environment\", host=~\"$host\"}[$__rate_interval]))", + "expr": "sum by(le) (increase(management_account_update_account_peers_duration_ms_milliseconds_bucket{application=\"management\", environment=\"$environment\", host=~\"$host\"}[$__rate_interval]))", "format": "heatmap", "fullMetaSearch": false, "includeNullMetadata": true, 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 new file mode 100644 index 000000000..619f84615 --- /dev/null +++ b/management/Dockerfile.multistage @@ -0,0 +1,17 @@ +FROM golang:1.25-bookworm AS builder +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y gcc libc6-dev && rm -rf /var/lib/apt/lists/* + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o netbird-mgmt ./management + +FROM ubuntu:24.04 +RUN apt update && apt install -y ca-certificates && rm -fr /var/cache/apt +ENTRYPOINT [ "/go/bin/netbird-mgmt","management"] +CMD ["--log-file", "console"] +COPY --from=builder /app/netbird-mgmt /go/bin/netbird-mgmt diff --git a/management/cmd/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 37ba0ae16..79c838ec4 100644 --- a/management/cmd/management.go +++ b/management/cmd/management.go @@ -13,24 +13,29 @@ import ( "os" "os/signal" "path" + "path/filepath" "strings" "syscall" - "github.com/miekg/dns" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/formatter/hook" "github.com/netbirdio/netbird/management/internals/server" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + nbdomain "github.com/netbirdio/netbird/shared/management/domain" + "github.com/netbirdio/netbird/shared/management/grpc" "github.com/netbirdio/netbird/util" + "github.com/netbirdio/netbird/util/crypt" ) -var newServer = func(config *nbconfig.Config, dnsDomain, mgmtSingleAccModeDomain string, mgmtPort int, mgmtMetricsPort int, disableMetrics, disableGeoliteUpdate, userDeleteFromIDPEnabled bool) server.Server { - return server.NewServer(config, dnsDomain, mgmtSingleAccModeDomain, mgmtPort, mgmtMetricsPort, disableMetrics, disableGeoliteUpdate, userDeleteFromIDPEnabled) +var newServer = func(cfg *server.Config) server.Server { + return server.NewServer(cfg) } -func SetNewServer(fn func(config *nbconfig.Config, dnsDomain, mgmtSingleAccModeDomain string, mgmtPort int, mgmtMetricsPort int, disableMetrics, disableGeoliteUpdate, userDeleteFromIDPEnabled bool) server.Server) { +func SetNewServer(fn func(*server.Config) server.Server) { newServer = fn } @@ -54,7 +59,7 @@ var ( // detect whether user specified a port userPort := cmd.Flag("port").Changed - config, err = loadMgmtConfig(ctx, nbconfig.MgmtConfigPath) + config, err = LoadMgmtConfig(ctx, nbconfig.MgmtConfigPath) if err != nil { return fmt.Errorf("failed reading provided config file: %s: %v", nbconfig.MgmtConfigPath, err) } @@ -63,7 +68,7 @@ var ( config.HttpConfig.IdpSignKeyRefreshEnabled = idpSignKeyRefreshEnabled } - tlsEnabled := false + var tlsEnabled bool if mgmtLetsencryptDomain != "" || (config.HttpConfig.CertFile != "" && config.HttpConfig.CertKey != "") { tlsEnabled = true } @@ -77,9 +82,8 @@ var ( } } - _, valid := dns.IsDomainName(dnsDomain) - if !valid || len(dnsDomain) > 192 { - return fmt.Errorf("failed parsing the provided dns-domain. Valid status: %t, Length: %d", valid, len(dnsDomain)) + if !nbdomain.IsValidDomainNoWildcard(dnsDomain) { + return fmt.Errorf("invalid dns-domain: %s", dnsDomain) } return nil @@ -108,7 +112,17 @@ var ( mgmtSingleAccModeDomain = "" } - srv := newServer(config, dnsDomain, mgmtSingleAccModeDomain, mgmtPort, mgmtMetricsPort, disableMetrics, disableGeoliteUpdate, userDeleteFromIDPEnabled) + srv := newServer(&server.Config{ + NbConfig: config, + DNSDomain: dnsDomain, + MgmtSingleAccModeDomain: mgmtSingleAccModeDomain, + MgmtPort: mgmtPort, + MgmtMetricsPort: mgmtMetricsPort, + DisableLegacyManagementPort: disableLegacyManagementPort, + DisableMetrics: disableMetrics, + DisableGeoliteUpdate: disableGeoliteUpdate, + UserDeleteFromIDPEnabled: userDeleteFromIDPEnabled, + }) go func() { if err := srv.Start(cmd.Context()); err != nil { log.Fatalf("Server error: %v", err) @@ -133,78 +147,215 @@ var ( } ) -func loadMgmtConfig(ctx context.Context, mgmtConfigPath string) (*nbconfig.Config, error) { +func LoadMgmtConfig(ctx context.Context, mgmtConfigPath string) (*nbconfig.Config, error) { loadedConfig := &nbconfig.Config{} - _, err := util.ReadJsonWithEnvSub(mgmtConfigPath, loadedConfig) + if _, err := util.ReadJsonWithEnvSub(mgmtConfigPath, loadedConfig); err != nil { + return nil, err + } + + 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) + if err != nil { + return nil, err + } + + if err := ApplyOIDCConfig(ctx, loadedConfig); err != nil { + return nil, err + } + + LogConfigInfo(loadedConfig) + + if err := EnsureEncryptionKey(ctx, mgmtConfigPath, loadedConfig); err != nil { + return nil, err + } + + return loadedConfig, nil +} + +// ApplyCommandLineOverrides applies command-line flag overrides to the config +func ApplyCommandLineOverrides(cfg *nbconfig.Config) { if mgmtLetsencryptDomain != "" { - loadedConfig.HttpConfig.LetsEncryptDomain = mgmtLetsencryptDomain + cfg.HttpConfig.LetsEncryptDomain = mgmtLetsencryptDomain } if mgmtDataDir != "" { - loadedConfig.Datadir = mgmtDataDir + cfg.Datadir = mgmtDataDir } - if certKey != "" && certFile != "" { - loadedConfig.HttpConfig.CertFile = certFile - loadedConfig.HttpConfig.CertKey = certKey + cfg.HttpConfig.CertFile = certFile + cfg.HttpConfig.CertKey = certKey + } +} + +// ApplyEmbeddedIdPConfig populates HttpConfig and EmbeddedIdP storage from config when embedded IdP is enabled. +// This allows users to only specify EmbeddedIdP config without duplicating values in HttpConfig. +func ApplyEmbeddedIdPConfig(ctx context.Context, cfg *nbconfig.Config) error { + if cfg.EmbeddedIdP == nil || !cfg.EmbeddedIdP.Enabled { + return nil } - oidcEndpoint := loadedConfig.HttpConfig.OIDCConfigEndpoint - if oidcEndpoint != "" { - // if OIDCConfigEndpoint is specified, we can load DeviceAuthEndpoint and TokenEndpoint automatically - log.WithContext(ctx).Infof("loading OIDC configuration from the provided IDP configuration endpoint %s", oidcEndpoint) - oidcConfig, err := fetchOIDCConfig(ctx, oidcEndpoint) - if err != nil { - return nil, err - } - log.WithContext(ctx).Infof("loaded OIDC configuration from the provided IDP configuration endpoint: %s", oidcEndpoint) + // apply some defaults based on the EmbeddedIdP config + if disableSingleAccMode { + // 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") + } + // Enable user deletion from IDP by default if EmbeddedIdP is enabled + userDeleteFromIDPEnabled = true - log.WithContext(ctx).Infof("overriding HttpConfig.AuthIssuer with a new value %s, previously configured value: %s", - oidcConfig.Issuer, loadedConfig.HttpConfig.AuthIssuer) - loadedConfig.HttpConfig.AuthIssuer = oidcConfig.Issuer + // Set LocalAddress for embedded IdP if enabled, used for internal JWT validation + cfg.EmbeddedIdP.LocalAddress = fmt.Sprintf("localhost:%d", mgmtPort) - log.WithContext(ctx).Infof("overriding HttpConfig.AuthKeysLocation (JWT certs) with a new value %s, previously configured value: %s", - oidcConfig.JwksURI, loadedConfig.HttpConfig.AuthKeysLocation) - loadedConfig.HttpConfig.AuthKeysLocation = oidcConfig.JwksURI - - if !(loadedConfig.DeviceAuthorizationFlow == nil || strings.ToLower(loadedConfig.DeviceAuthorizationFlow.Provider) == string(nbconfig.NONE)) { - log.WithContext(ctx).Infof("overriding DeviceAuthorizationFlow.TokenEndpoint with a new value: %s, previously configured value: %s", - oidcConfig.TokenEndpoint, loadedConfig.DeviceAuthorizationFlow.ProviderConfig.TokenEndpoint) - loadedConfig.DeviceAuthorizationFlow.ProviderConfig.TokenEndpoint = oidcConfig.TokenEndpoint - log.WithContext(ctx).Infof("overriding DeviceAuthorizationFlow.DeviceAuthEndpoint with a new value: %s, previously configured value: %s", - oidcConfig.DeviceAuthEndpoint, loadedConfig.DeviceAuthorizationFlow.ProviderConfig.DeviceAuthEndpoint) - loadedConfig.DeviceAuthorizationFlow.ProviderConfig.DeviceAuthEndpoint = oidcConfig.DeviceAuthEndpoint - - u, err := url.Parse(oidcEndpoint) - if err != nil { - return nil, err - } - log.WithContext(ctx).Infof("overriding DeviceAuthorizationFlow.ProviderConfig.Domain with a new value: %s, previously configured value: %s", - u.Host, loadedConfig.DeviceAuthorizationFlow.ProviderConfig.Domain) - loadedConfig.DeviceAuthorizationFlow.ProviderConfig.Domain = u.Host - - if loadedConfig.DeviceAuthorizationFlow.ProviderConfig.Scope == "" { - loadedConfig.DeviceAuthorizationFlow.ProviderConfig.Scope = nbconfig.DefaultDeviceAuthFlowScope - } - } - - if loadedConfig.PKCEAuthorizationFlow != nil { - log.WithContext(ctx).Infof("overriding PKCEAuthorizationFlow.TokenEndpoint with a new value: %s, previously configured value: %s", - oidcConfig.TokenEndpoint, loadedConfig.PKCEAuthorizationFlow.ProviderConfig.TokenEndpoint) - loadedConfig.PKCEAuthorizationFlow.ProviderConfig.TokenEndpoint = oidcConfig.TokenEndpoint - log.WithContext(ctx).Infof("overriding PKCEAuthorizationFlow.AuthorizationEndpoint with a new value: %s, previously configured value: %s", - oidcConfig.AuthorizationEndpoint, loadedConfig.PKCEAuthorizationFlow.ProviderConfig.AuthorizationEndpoint) - loadedConfig.PKCEAuthorizationFlow.ProviderConfig.AuthorizationEndpoint = oidcConfig.AuthorizationEndpoint - } + // Set storage defaults based on Datadir + if cfg.EmbeddedIdP.Storage.Type == "" { + cfg.EmbeddedIdP.Storage.Type = "sqlite3" + } + if cfg.EmbeddedIdP.Storage.Config.File == "" && cfg.Datadir != "" { + cfg.EmbeddedIdP.Storage.Config.File = filepath.Join(cfg.Datadir, "idp.db") } - if loadedConfig.Relay != nil { - log.Infof("Relay addresses: %v", loadedConfig.Relay.Addresses) + issuer := cfg.EmbeddedIdP.Issuer + + if cfg.HttpConfig != nil { + log.WithContext(ctx).Warnf("overriding HttpConfig with EmbeddedIdP config. " + + "HttpConfig is ignored when EmbeddedIdP is enabled. Please remove HttpConfig section from the config file") + } else { + // Ensure HttpConfig exists. We need it for backwards compatibility with the old config format. + cfg.HttpConfig = &nbconfig.HttpServerConfig{} } - return loadedConfig, err + // Set HttpConfig values from EmbeddedIdP + cfg.HttpConfig.AuthIssuer = issuer + cfg.HttpConfig.AuthAudience = "netbird-dashboard" + cfg.HttpConfig.AuthClientID = cfg.HttpConfig.AuthAudience + cfg.HttpConfig.CLIAuthAudience = "netbird-cli" + cfg.HttpConfig.AuthUserIDClaim = "sub" + cfg.HttpConfig.AuthKeysLocation = issuer + "/keys" + cfg.HttpConfig.OIDCConfigEndpoint = issuer + "/.well-known/openid-configuration" + cfg.HttpConfig.IdpSignKeyRefreshEnabled = true + callbackURL := strings.TrimSuffix(cfg.HttpConfig.AuthIssuer, "/oauth2") + cfg.HttpConfig.AuthCallbackURL = callbackURL + types.ProxyCallbackEndpointFull + + return nil +} + +// ApplyOIDCConfig fetches and applies OIDC configuration if endpoint is specified +func ApplyOIDCConfig(ctx context.Context, cfg *nbconfig.Config) error { + oidcEndpoint := cfg.HttpConfig.OIDCConfigEndpoint + if oidcEndpoint == "" { + return nil + } + + if cfg.EmbeddedIdP != nil && cfg.EmbeddedIdP.Enabled { + // skip OIDC config fetching if EmbeddedIdP is enabled as it is unnecessary given it is embedded + return nil + } + + log.WithContext(ctx).Infof("loading OIDC configuration from the provided IDP configuration endpoint %s", oidcEndpoint) + oidcConfig, err := fetchOIDCConfig(ctx, oidcEndpoint) + if err != nil { + return err + } + log.WithContext(ctx).Infof("loaded OIDC configuration from the provided IDP configuration endpoint: %s", oidcEndpoint) + + log.WithContext(ctx).Infof("overriding HttpConfig.AuthIssuer with a new value %s, previously configured value: %s", + oidcConfig.Issuer, cfg.HttpConfig.AuthIssuer) + cfg.HttpConfig.AuthIssuer = oidcConfig.Issuer + + log.WithContext(ctx).Infof("overriding HttpConfig.AuthKeysLocation (JWT certs) with a new value %s, previously configured value: %s", + oidcConfig.JwksURI, cfg.HttpConfig.AuthKeysLocation) + cfg.HttpConfig.AuthKeysLocation = oidcConfig.JwksURI + + if err := ApplyDeviceAuthFlowConfig(ctx, cfg, &oidcConfig, oidcEndpoint); err != nil { + return err + } + ApplyPKCEFlowConfig(ctx, cfg, &oidcConfig) + + return nil +} + +// ApplyDeviceAuthFlowConfig applies OIDC config to DeviceAuthorizationFlow if enabled +func ApplyDeviceAuthFlowConfig(ctx context.Context, cfg *nbconfig.Config, oidcConfig *OIDCConfigResponse, oidcEndpoint string) error { + if cfg.DeviceAuthorizationFlow == nil || strings.ToLower(cfg.DeviceAuthorizationFlow.Provider) == string(nbconfig.NONE) { + return nil + } + + log.WithContext(ctx).Infof("overriding DeviceAuthorizationFlow.TokenEndpoint with a new value: %s, previously configured value: %s", + oidcConfig.TokenEndpoint, cfg.DeviceAuthorizationFlow.ProviderConfig.TokenEndpoint) + cfg.DeviceAuthorizationFlow.ProviderConfig.TokenEndpoint = oidcConfig.TokenEndpoint + + log.WithContext(ctx).Infof("overriding DeviceAuthorizationFlow.DeviceAuthEndpoint with a new value: %s, previously configured value: %s", + oidcConfig.DeviceAuthEndpoint, cfg.DeviceAuthorizationFlow.ProviderConfig.DeviceAuthEndpoint) + cfg.DeviceAuthorizationFlow.ProviderConfig.DeviceAuthEndpoint = oidcConfig.DeviceAuthEndpoint + + u, err := url.Parse(oidcEndpoint) + if err != nil { + return err + } + log.WithContext(ctx).Infof("overriding DeviceAuthorizationFlow.ProviderConfig.Domain with a new value: %s, previously configured value: %s", + u.Host, cfg.DeviceAuthorizationFlow.ProviderConfig.Domain) + cfg.DeviceAuthorizationFlow.ProviderConfig.Domain = u.Host + + if cfg.DeviceAuthorizationFlow.ProviderConfig.Scope == "" { + cfg.DeviceAuthorizationFlow.ProviderConfig.Scope = nbconfig.DefaultDeviceAuthFlowScope + } + return nil +} + +// ApplyPKCEFlowConfig applies OIDC config to PKCEAuthorizationFlow if configured +func ApplyPKCEFlowConfig(ctx context.Context, cfg *nbconfig.Config, oidcConfig *OIDCConfigResponse) { + if cfg.PKCEAuthorizationFlow == nil { + return + } + log.WithContext(ctx).Infof("overriding PKCEAuthorizationFlow.TokenEndpoint with a new value: %s, previously configured value: %s", + oidcConfig.TokenEndpoint, cfg.PKCEAuthorizationFlow.ProviderConfig.TokenEndpoint) + cfg.PKCEAuthorizationFlow.ProviderConfig.TokenEndpoint = oidcConfig.TokenEndpoint + + log.WithContext(ctx).Infof("overriding PKCEAuthorizationFlow.AuthorizationEndpoint with a new value: %s, previously configured value: %s", + oidcConfig.AuthorizationEndpoint, cfg.PKCEAuthorizationFlow.ProviderConfig.AuthorizationEndpoint) + cfg.PKCEAuthorizationFlow.ProviderConfig.AuthorizationEndpoint = oidcConfig.AuthorizationEndpoint +} + +// LogConfigInfo logs informational messages about the loaded configuration +func LogConfigInfo(cfg *nbconfig.Config) { + if cfg.EmbeddedIdP != nil { + log.Infof("running with the embedded IdP: %v", cfg.EmbeddedIdP.Issuer) + } + if cfg.Relay != nil { + log.Infof("Relay addresses: %v", cfg.Relay.Addresses) + } +} + +// EnsureEncryptionKey generates and saves a DataStoreEncryptionKey if not set +func EnsureEncryptionKey(ctx context.Context, configPath string, cfg *nbconfig.Config) error { + if cfg.DataStoreEncryptionKey != "" { + return nil + } + + log.WithContext(ctx).Infof("DataStoreEncryptionKey is not set, generating a new key") + key, err := crypt.GenerateKey() + if err != nil { + return fmt.Errorf("failed to generate datastore encryption key: %v", err) + } + cfg.DataStoreEncryptionKey = key + + if err := util.DirectWriteJson(ctx, configPath, cfg); err != nil { + return fmt.Errorf("failed to save config with new encryption key: %v", err) + } + log.WithContext(ctx).Infof("DataStoreEncryptionKey generated and saved to config") + return nil } // OIDCConfigResponse used for parsing OIDC config response diff --git a/management/cmd/management_test.go b/management/cmd/management_test.go index 244d86254..2c3481213 100644 --- a/management/cmd/management_test.go +++ b/management/cmd/management_test.go @@ -4,6 +4,9 @@ import ( "context" "os" "testing" + + "github.com/netbirdio/netbird/shared/management/grpc" + "github.com/stretchr/testify/assert" ) const ( @@ -20,34 +23,49 @@ 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") - } + cfg, err := LoadMgmtConfig(context.Background(), tmpFile) + 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 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 b60f79c23..969dd60dd 100644 --- a/management/cmd/root.go +++ b/management/cmd/root.go @@ -16,21 +16,22 @@ const ( ) var ( - dnsDomain string - mgmtDataDir string - logLevel string - logFile string - disableMetrics bool - disableSingleAccMode bool - disableGeoliteUpdate bool - idpSignKeyRefreshEnabled bool - userDeleteFromIDPEnabled bool - mgmtPort int - mgmtMetricsPort int - mgmtLetsencryptDomain string - mgmtSingleAccModeDomain string - certFile string - certKey string + dnsDomain string + mgmtDataDir string + logLevel string + logFile string + disableMetrics bool + disableSingleAccMode bool + disableGeoliteUpdate bool + idpSignKeyRefreshEnabled bool + userDeleteFromIDPEnabled bool + mgmtPort int + mgmtMetricsPort int + disableLegacyManagementPort bool + mgmtLetsencryptDomain string + mgmtSingleAccModeDomain string + certFile string + certKey string rootCmd = &cobra.Command{ Use: "netbird-mgmt", @@ -55,6 +56,7 @@ func Execute() error { func init() { mgmtCmd.Flags().IntVar(&mgmtPort, "port", 80, "server port to listen on (defaults to 443 if TLS is enabled, 80 otherwise") + mgmtCmd.Flags().BoolVar(&disableLegacyManagementPort, "disable-legacy-port", false, "disabling the old legacy port (33073)") mgmtCmd.Flags().IntVar(&mgmtMetricsPort, "metrics-port", 9090, "metrics endpoint http port. Metrics are accessible under host:metrics-port/metrics") mgmtCmd.Flags().StringVar(&mgmtDataDir, "datadir", defaultMgmtDataDir, "server data directory location") mgmtCmd.Flags().StringVar(&nbconfig.MgmtConfigPath, "config", defaultMgmtConfig, "Netbird config file location. Config params specified via command line (e.g. datadir) have a precedence over configuration from this file") @@ -80,4 +82,9 @@ func init() { migrationCmd.AddCommand(upCmd) rootCmd.AddCommand(migrationCmd) + + ac := newAdminCommands() + ac.PersistentFlags().StringVar(&nbconfig.MgmtConfigPath, "config", defaultMgmtConfig, "Netbird config file location") + rootCmd.AddCommand(ac) + rootCmd.AddCommand(newLegacyTokenCommand()) } diff --git a/management/cmd/token/token.go b/management/cmd/token/token.go new file mode 100644 index 000000000..fb89e732c --- /dev/null +++ b/management/cmd/token/token.go @@ -0,0 +1,185 @@ +// Package tokencmd provides reusable cobra commands for managing proxy access tokens. +// Both the management and combined binaries use these commands, each providing +// their own StoreOpener to handle config loading and store initialization. +package tokencmd + +import ( + "context" + "fmt" + "io" + "strconv" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +// StoreOpener initializes a store from the command context and calls fn. +type StoreOpener func(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error + +// NewCommands creates the token command tree with the given store opener. +// Returns the parent "token" command with create, list, and revoke subcommands. +func NewCommands(opener StoreOpener) *cobra.Command { + var ( + tokenName string + tokenExpireIn string + ) + + tokenCmd := &cobra.Command{ + Use: "token", + Short: "Manage proxy access tokens", + Long: "Commands for creating, listing, and revoking proxy access tokens used by reverse proxy instances to authenticate with the management server.", + } + + createCmd := &cobra.Command{ + Use: "create", + Short: "Create a new proxy access token", + Long: "Creates a new proxy access token. The plain text token is displayed only once at creation time.", + RunE: func(cmd *cobra.Command, _ []string) error { + return opener(cmd, func(ctx context.Context, s store.Store) error { + return runCreate(ctx, s, cmd.OutOrStdout(), tokenName, tokenExpireIn) + }) + }, + } + createCmd.Flags().StringVar(&tokenName, "name", "", "Name for the token (required)") + createCmd.Flags().StringVar(&tokenExpireIn, "expires-in", "", "Token expiration duration (e.g., 365d, 24h, 30d). Empty means no expiration") + if err := createCmd.MarkFlagRequired("name"); err != nil { + panic(err) + } + + listCmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List all proxy access tokens", + Long: "Lists all proxy access tokens with their IDs, names, creation dates, expiration, and revocation status.", + RunE: func(cmd *cobra.Command, _ []string) error { + return opener(cmd, func(ctx context.Context, s store.Store) error { + return runList(ctx, s, cmd.OutOrStdout()) + }) + }, + } + + revokeCmd := &cobra.Command{ + Use: "revoke [token-id]", + Short: "Revoke a proxy access token", + Long: "Revokes a proxy access token by its ID. Revoked tokens can no longer be used for authentication.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return opener(cmd, func(ctx context.Context, s store.Store) error { + return runRevoke(ctx, s, cmd.OutOrStdout(), args[0]) + }) + }, + } + + tokenCmd.AddCommand(createCmd, listCmd, revokeCmd) + return tokenCmd +} + +func runCreate(ctx context.Context, s store.Store, w io.Writer, name string, expireIn string) error { + expiresIn, err := ParseDuration(expireIn) + if err != nil { + return fmt.Errorf("parse expiration: %w", err) + } + + generated, err := types.CreateNewProxyAccessToken(name, expiresIn, nil, "CLI") + if err != nil { + return fmt.Errorf("generate token: %w", err) + } + + if err := s.SaveProxyAccessToken(ctx, &generated.ProxyAccessToken); err != nil { + return fmt.Errorf("save token: %w", err) + } + + _, _ = fmt.Fprintln(w, "Token created successfully!") + _, _ = fmt.Fprintf(w, "Token: %s\n", generated.PlainToken) + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, "IMPORTANT: Save this token now. It will not be shown again.") + _, _ = fmt.Fprintf(w, "Token ID: %s\n", generated.ID) + return nil +} + +func runList(ctx context.Context, s store.Store, out io.Writer) error { + tokens, err := s.GetAllProxyAccessTokens(ctx, store.LockingStrengthNone) + if err != nil { + return fmt.Errorf("list tokens: %w", err) + } + + if len(tokens) == 0 { + _, _ = fmt.Fprintln(out, "No proxy access tokens found.") + return nil + } + + w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "ID\tNAME\tCREATED\tEXPIRES\tLAST USED\tREVOKED") + _, _ = fmt.Fprintln(w, "--\t----\t-------\t-------\t---------\t-------") + + for _, t := range tokens { + expires := "never" + if t.ExpiresAt != nil { + expires = t.ExpiresAt.Format("2006-01-02") + } + + lastUsed := "never" + if t.LastUsed != nil { + lastUsed = t.LastUsed.Format("2006-01-02 15:04") + } + + revoked := "no" + if t.Revoked { + revoked = "yes" + } + + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", + t.ID, + t.Name, + t.CreatedAt.Format("2006-01-02"), + expires, + lastUsed, + revoked, + ) + } + + w.Flush() + + return nil +} + +func runRevoke(ctx context.Context, s store.Store, w io.Writer, tokenID string) error { + if err := s.RevokeProxyAccessToken(ctx, tokenID); err != nil { + return fmt.Errorf("revoke token: %w", err) + } + + _, _ = fmt.Fprintf(w, "Token %s revoked successfully.\n", tokenID) + return nil +} + +// ParseDuration parses a duration string with support for days (e.g., "30d", "365d"). +// An empty string returns zero duration (no expiration). +func ParseDuration(s string) (time.Duration, error) { + if len(s) == 0 { + return 0, nil + } + + if s[len(s)-1] == 'd' { + d, err := strconv.Atoi(s[:len(s)-1]) + if err != nil { + return 0, fmt.Errorf("invalid day format: %s", s) + } + if d <= 0 { + return 0, fmt.Errorf("duration must be positive: %s", s) + } + return time.Duration(d) * 24 * time.Hour, nil + } + + d, err := time.ParseDuration(s) + if err != nil { + return 0, err + } + if d <= 0 { + return 0, fmt.Errorf("duration must be positive: %s", s) + } + return d, nil +} diff --git a/management/cmd/token/token_test.go b/management/cmd/token/token_test.go new file mode 100644 index 000000000..d554bbe45 --- /dev/null +++ b/management/cmd/token/token_test.go @@ -0,0 +1,101 @@ +package tokencmd + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseDuration(t *testing.T) { + tests := []struct { + name string + input string + expected time.Duration + wantErr bool + }{ + { + name: "empty string returns zero", + input: "", + expected: 0, + }, + { + name: "days suffix", + input: "30d", + expected: 30 * 24 * time.Hour, + }, + { + name: "one day", + input: "1d", + expected: 24 * time.Hour, + }, + { + name: "365 days", + input: "365d", + expected: 365 * 24 * time.Hour, + }, + { + name: "hours via Go duration", + input: "24h", + expected: 24 * time.Hour, + }, + { + name: "minutes via Go duration", + input: "30m", + expected: 30 * time.Minute, + }, + { + name: "complex Go duration", + input: "1h30m", + expected: 90 * time.Minute, + }, + { + name: "invalid day format", + input: "abcd", + wantErr: true, + }, + { + name: "negative days", + input: "-1d", + wantErr: true, + }, + { + name: "zero days", + input: "0d", + wantErr: true, + }, + { + name: "non-numeric days", + input: "xyzd", + wantErr: true, + }, + { + name: "negative Go duration", + input: "-24h", + wantErr: true, + }, + { + name: "zero Go duration", + input: "0s", + wantErr: true, + }, + { + name: "invalid Go duration", + input: "notaduration", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseDuration(tt.input) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/management/internals/controllers/network_map/controller/controller.go b/management/internals/controllers/network_map/controller/controller.go index ad25494c7..5785004db 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,9 +15,10 @@ 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/server/config" "github.com/netbirdio/netbird/management/internals/shared/grpc" "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" @@ -29,9 +29,11 @@ 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/proto" "github.com/netbirdio/netbird/shared/management/status" "github.com/netbirdio/netbird/util" + "github.com/netbirdio/netbird/version" ) type Controller struct { @@ -41,12 +43,14 @@ type Controller struct { accountManagerMetrics *telemetry.AccountManagerMetrics peersUpdateManager network_map.PeersUpdateManager settingsManager settings.Manager + 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 + config *config.Config requestBuffer account.RequestBuffer @@ -54,10 +58,9 @@ type Controller struct { integratedPeerValidator integrated_validator.IntegratedValidator - holder *types.Holder + serverSupportedSyncMessageVersion sharedgrpc.SyncMessageVersion - expNewNetworkMap bool - expNewNetworkMapAIDs map[string]struct{} + perAccountServerSupportedSyncMessageVersions map[string]sharedgrpc.SyncMessageVersion } type bufferUpdate struct { @@ -66,26 +69,21 @@ 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) *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) *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 - } - - ids := strings.Split(os.Getenv(network_map.EnvNewNetworkMapAccounts), ",") - expIDs := make(map[string]struct{}, len(ids)) - for _, id := range ids { - expIDs[id] = struct{}{} - } - return &Controller{ repo: newRepository(store), metrics: nMetrics, @@ -95,28 +93,63 @@ func NewController(ctx context.Context, store store.Store, metrics telemetry.App integratedPeerValidator: integratedPeerValidator, settingsManager: settingsManager, dnsDomain: dnsDomain, + config: config, - proxyController: proxyController, - - holder: types.NewHolder(), - expNewNetworkMap: newNetworkMapBuilder, - expNewNetworkMapAIDs: expIDs, + proxyController: proxyController, + EphemeralPeersManager: ephemeralPeersManager, + serverSupportedSyncMessageVersion: sharedgrpc.SyncMessageVersionFromConfig(config.HighestSupportedSyncMessageVersion), + perAccountServerSupportedSyncMessageVersions: sharedgrpc.SyncMessageVersionsFromMap(config.PerAccountHighestSupportedSyncMessageVersion), } } -func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID string) error { +func (c *Controller) OnPeerConnected(ctx context.Context, accountID string, peerID string) (chan *network_map.UpdateMessage, error) { + peer, err := c.repo.GetPeerByID(ctx, accountID, peerID) + if err != nil { + return nil, fmt.Errorf("failed to get peer %s: %v", peerID, err) + } + + c.EphemeralPeersManager.OnPeerConnected(ctx, peer) + + return c.peersUpdateManager.CreateChannel(ctx, peerID), nil +} + +func (c *Controller) OnPeerDisconnected(ctx context.Context, accountID string, peerID string) { + c.peersUpdateManager.CloseChannel(ctx, peerID) + peer, err := c.repo.GetPeerByID(ctx, accountID, peerID) + if err != nil { + log.WithContext(ctx).Errorf("failed to get peer %s: %v", peerID, err) + return + } + 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 existing InjectProxyPolicies + injectPrivateServicePolicies walks pick +// them up alongside persisted reverse-proxy services. 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...) + } + account.InjectProxyPolicies(ctx) +} + +func (c *Controller) CountStreams() int { + return c.peersUpdateManager.CountStreams() +} + +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(accountID) - } else { - account, err = c.requestBuffer.GetAccountWithBackpressure(ctx, accountID) - if err != nil { - return fmt.Errorf("failed to get account: %v", err) - } + account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID) + if err != nil { + return fmt.Errorf("failed to get account: %v", err) } globalStart := time.Now() @@ -142,15 +175,13 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin var wg sync.WaitGroup semaphore := make(chan struct{}, 10) + c.injectAllProxyPolicies(ctx, account) dnsCache := &cache.DNSConfigCache{} dnsDomain := c.GetDNSDomain(account.Settings) - customZone := account.GetPeersCustomZone(ctx, dnsDomain) + peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain) resourcePolicies := account.GetResourcePoliciesMap() routers := account.GetResourceRoutersMap() - - if c.experimentalNetworkMap(accountID) { - c.initNetworkMapBuilderIfNeeded(account, approvedPeersMap) - } + groupIDToUserIDs := account.GetActiveGroupUsers() proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMapsAll(ctx, accountID, account.Peers) if err != nil { @@ -165,12 +196,22 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin 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 account.Peers { if !c.peersUpdateManager.HasChannel(peer.ID) { log.WithContext(ctx).Tracef("peer %s doesn't have a channel, skipping network map update", peer.ID) continue } + if c.accountManagerMetrics != nil { + c.accountManagerMetrics.CountNmapTriggered(string(reason.Resource), string(reason.Operation)) + } + wg.Add(1) semaphore <- struct{}{} go func(p *nbpeer.Peer) { @@ -188,27 +229,59 @@ 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 - if c.experimentalNetworkMap(accountID) { - remotePeerNetworkMap = c.getPeerNetworkMapExp(ctx, p.AccountID, p.ID, approvedPeersMap, customZone, c.accountManagerMetrics) - } else { - remotePeerNetworkMap = account.GetPeerNetworkMap(ctx, p.ID, customZone, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics) + 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, p, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, 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, 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, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) c.metrics.CountToSyncResponseDuration(time.Since(start)) - c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{Update: update}) + c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ + Update: update, + MessageType: network_map.MessageTypeNetworkMap, + }) }(peer) } @@ -220,48 +293,200 @@ 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()) - - bufUpd, _ := c.sendAccountUpdateLocks.LoadOrStore(accountID, &bufferUpdate{}) - b := bufUpd.(*bufferUpdate) - - if !b.mu.TryLock() { - b.update.Store(true) - return nil +func (c *Controller) perAccountOrGlobalSupportedSyncMessageVersions(accountId string) sharedgrpc.SyncMessageVersion { + if perAccount, ok := c.perAccountServerSupportedSyncMessageVersions[accountId]; ok { + return perAccount } - - if b.next != nil { - b.next.Stop() - } - - go func() { - defer b.mu.Unlock() - _ = c.sendUpdateAccountPeers(ctx, accountID) - if !b.update.Load() { - return - } - b.update.Store(false) - if b.next == nil { - b.next = time.AfterFunc(time.Duration(c.updateAccountPeersBufferInterval.Load()), func() { - _ = c.sendUpdateAccountPeers(ctx, accountID) - }) - return - } - b.next.Reset(time.Duration(c.updateAccountPeersBufferInterval.Load())) - }() - - return nil + 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) error { - if err := c.RecalculateNetworkMapCache(ctx, accountID); err != nil { - return fmt.Errorf("recalculate network map cache: %v", err) +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 } - return c.sendUpdateAccountPeers(ctx, accountID) + 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, maps.Values(account.Groups), 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) + 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, p, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, 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, p, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort) + c.metrics.CountToSyncResponseDuration(time.Since(start)) + + c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{ + Update: update, + 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 { @@ -284,11 +509,13 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe return fmt.Errorf("failed to get validated peers: %v", err) } + c.injectAllProxyPolicies(ctx, account) dnsCache := &cache.DNSConfigCache{} dnsDomain := c.GetDNSDomain(account.Settings) - customZone := account.GetPeersCustomZone(ctx, dnsDomain) + peersCustomZone := account.GetPeersCustomZone(ctx, dnsDomain) resourcePolicies := account.GetResourcePoliciesMap() routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() postureChecks, err := c.getPeerPostureChecks(account, peerId) if err != nil { @@ -302,19 +529,13 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe return err } - var remotePeerNetworkMap *types.NetworkMap - - if c.experimentalNetworkMap(accountId) { - remotePeerNetworkMap = c.getPeerNetworkMapExp(ctx, peer.AccountID, peer.ID, approvedPeersMap, customZone, c.accountManagerMetrics) - } else { - remotePeerNetworkMap = account.GetPeerNetworkMap(ctx, peerId, customZone, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics) - } - - proxyNetworkMap, ok := proxyNetworkMaps[peer.ID] - if ok { - remotePeerNetworkMap.Merge(proxyNetworkMap) + accountZones, err := c.repo.GetAccountZones(ctx, account.Id) + if err != nil { + log.WithContext(ctx).Errorf("failed to get account zones: %v", err) + return err } + 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) @@ -323,15 +544,60 @@ 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, peer, nil, nil, remotePeerNetworkMap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort) - c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{Update: update}) + 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, peer, nil, nil, components, proxyNetworkMap, dnsDomain, postureChecks, 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, peer, nil, nil, nmap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort) + + c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{ + Update: update, + MessageType: network_map.MessageTypeNetworkMap, + }) 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) @@ -346,14 +612,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 } @@ -363,201 +629,221 @@ func (c *Controller) BufferUpdateAccountPeers(ctx context.Context, accountID str return nil } -func (c *Controller) DeletePeer(ctx context.Context, accountId string, peerId string) error { - network, err := c.repo.GetAccountNetwork(ctx, accountId) - if err != nil { - return err - } - - peers, err := c.repo.GetAccountPeers(ctx, accountId) - if err != nil { - return err - } - - dnsFwdPort := computeForwarderPort(peers, network_map.DnsForwarderPortMinVersion) - c.peersUpdateManager.SendUpdate(ctx, peerId, &network_map.UpdateMessage{ - Update: &proto.SyncResponse{ - RemotePeers: []*proto.RemotePeerConfig{}, - RemotePeersIsEmpty: true, - NetworkMap: &proto.NetworkMap{ - Serial: network.CurrentSerial(), - RemotePeers: []*proto.RemotePeerConfig{}, - RemotePeersIsEmpty: true, - FirewallRules: []*proto.FirewallRule{}, - FirewallRulesIsEmpty: true, - DNSConfig: &proto.DNSConfig{ - ForwarderPort: dnsFwdPort, - }, - }, - }, - }) - c.peersUpdateManager.CloseChannel(ctx, peerId) - 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, []*posture.Checks, 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 + } + return peer, &types.NetworkMapComponents{Network: network.Copy()}, nil, nil, 0, nil + } + + account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID) + if err != nil { + return nil, nil, nil, nil, 0, err + } + + c.injectAllProxyPolicies(ctx, account) + + approvedPeersMap, err := c.integratedPeerValidator.GetValidatedPeers(ctx, account.Id, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra) + if err != nil { + return nil, nil, nil, nil, 0, err + } + + postureChecks, err := c.getPeerPostureChecks(account, peer.ID) + if err != nil { + return nil, nil, nil, nil, 0, err + } + + accountZones, err := c.repo.GetAccountZones(ctx, account.Id) + if err != nil { + 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) + + 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 +} + +// 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, []*posture.Checks, int64, error) { + if isRequiresApproval { + network, err := c.repo.GetAccountNetwork(ctx, accountID) + if err != nil { + return nil, nil, 0, err } emptyMap := &types.NetworkMap{ Network: network.Copy(), } - return peer, emptyMap, nil, 0, nil + return emptyMap, nil, 0, nil } - var ( - account *types.Account - err error - ) - if c.experimentalNetworkMap(accountID) { - account = c.getAccountFromHolderOrInit(accountID) - } else { - account, err = c.requestBuffer.GetAccountWithBackpressure(ctx, accountID) - if err != nil { - return nil, nil, nil, 0, err - } + 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, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra) if err != nil { - return nil, nil, nil, 0, err + return nil, nil, 0, err } - startPosture := time.Now() - postureChecks, err := c.getPeerPostureChecks(account, peer.ID) + postureChecks, err := c.getPeerPostureChecks(account, peerID) if err != nil { - return nil, nil, nil, 0, err + return nil, nil, 0, err } - log.WithContext(ctx).Debugf("getPeerPostureChecks took %s", time.Since(startPosture)) - customZone := account.GetPeersCustomZone(ctx, c.GetDNSDomain(account.Settings)) + 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 + } - proxyNetworkMaps, err := c.proxyController.GetProxyNetworkMaps(ctx, account.Id, peer.ID, account.Peers) + 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, customZone, c.accountManagerMetrics) - } else { - networkMap = account.GetPeerNetworkMap(ctx, peer.ID, customZone, approvedPeersMap, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), c.accountManagerMetrics) - } - - 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 -} - -func (c *Controller) initNetworkMapBuilderIfNeeded(account *types.Account, validatedPeers map[string]struct{}) { - c.enrichAccountFromHolder(account) - account.InitNetworkMapBuilderIfNeeded(validatedPeers) -} - -func (c *Controller) getPeerNetworkMapExp( - ctx context.Context, - accountId string, - peerId string, - validatedPeers map[string]struct{}, - customZone nbdns.CustomZone, - metrics *telemetry.AccountManagerMetrics, -) *types.NetworkMap { - account := c.getAccountFromHolderOrInit(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{}, - } - } - return account.GetPeerNetworkMapExp(ctx, peerId, customZone, validatedPeers, metrics) -} - -func (c *Controller) onPeerAddedUpdNetworkMapCache(account *types.Account, peerId string) error { - c.enrichAccountFromHolder(account) - return account.OnPeerAddedUpdNetworkMapCache(peerId) -} - -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 - } - account.NetworkMapCache.UpdateAccountPointer(account) - c.holder.AddAccount(account) -} - -func (c *Controller) getAccountFromHolder(accountID string) *types.Account { - return c.holder.GetAccount(accountID) -} - -func (c *Controller) getAccountFromHolderOrInit(accountID string) *types.Account { - a := c.holder.GetAccount(accountID) - if a != nil { - return a - } - account, err := c.holder.LoadOrStoreFunc(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 @@ -632,7 +918,7 @@ func computeForwarderPort(peers []*nbpeer.Peer, requiredVersion string) int64 { for _, peer := range peers { // Development version is always supported - if peer.Meta.WtVersion == "development" { + if version.IsDevelopmentVersion(peer.Meta.WtVersion) { continue } peerVersion := semver.Canonical("v" + peer.Meta.WtVersion) @@ -695,39 +981,61 @@ func isPeerInPolicySourceGroups(account *types.Account, peerID string, policy *t return false, nil } -func (c *Controller) OnPeerUpdated(accountId string, peer *nbpeer.Peer) { - c.UpdatePeerInNetworkMapCache(accountId, peer) - _ = c.bufferSendUpdateAccountPeers(context.Background(), accountId) +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 + } + return c.BufferUpdateAffectedPeers(ctx, accountID, affectedPeerIDs, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationUpdate}) } -func (c *Controller) OnPeerAdded(ctx context.Context, accountID string, peerID string) error { - if c.experimentalNetworkMap(accountID) { - account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID) - if err != nil { - return err - } - - err = c.onPeerAddedUpdNetworkMapCache(account, peerID) - if err != nil { - return err - } +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 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) OnPeerDeleted(ctx context.Context, accountID string, peerID string) error { - if c.experimentalNetworkMap(accountID) { - account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID) - if err != nil { - return err - } - err = c.onPeerDeletedUpdNetworkMapCache(account, peerID) - if err != nil { - return err - } +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 } - return c.bufferSendUpdateAccountPeers(ctx, accountID) + peers, err := c.repo.GetAccountPeers(ctx, accountID) + if err != nil { + return err + } + + dnsFwdPort := computeForwarderPort(peers, network_map.DnsForwarderPortMinVersion) + for _, peerID := range peerIDs { + c.peersUpdateManager.SendUpdate(ctx, peerID, &network_map.UpdateMessage{ + Update: &proto.SyncResponse{ + RemotePeers: []*proto.RemotePeerConfig{}, + RemotePeersIsEmpty: true, + NetworkMap: &proto.NetworkMap{ + Serial: network.CurrentSerial(), + RemotePeers: []*proto.RemotePeerConfig{}, + RemotePeersIsEmpty: true, + FirewallRules: []*proto.FirewallRule{}, + FirewallRulesIsEmpty: true, + DNSConfig: &proto.DNSConfig{ + ForwarderPort: dnsFwdPort, + }, + }, + }, + MessageType: network_map.MessageTypeNetworkMap, + }) + c.peersUpdateManager.CloseChannel(ctx, peerID) + } + + 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) @@ -751,7 +1059,15 @@ func (c *Controller) GetNetworkMap(ctx context.Context, peerID string) (*types.N if err != nil { return nil, err } - customZone := account.GetPeersCustomZone(ctx, c.GetDNSDomain(account.Settings)) + + accountZones, err := c.repo.GetAccountZones(ctx, account.Id) + if err != nil { + log.WithContext(ctx).Errorf("failed to get account zones: %v", err) + return nil, 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 { @@ -759,13 +1075,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, customZone, nil) - } else { - networkMap = account.GetPeerNetworkMap(ctx, peer.ID, customZone, validatedPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil) - } + 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 { @@ -775,10 +1089,10 @@ func (c *Controller) GetNetworkMap(ctx context.Context, peerID string) (*types.N return networkMap, nil } -func (c *Controller) DisconnectPeers(ctx context.Context, peerIDs []string) { +func (c *Controller) DisconnectPeers(ctx context.Context, accountId string, peerIDs []string) { c.peersUpdateManager.CloseChannels(ctx, peerIDs) } -func (c *Controller) IsConnected(peerID string) bool { - return c.peersUpdateManager.HasChannel(peerID) +func (c *Controller) TrackEphemeralPeer(ctx context.Context, peer *nbpeer.Peer) { + c.EphemeralPeersManager.OnPeerDisconnected(ctx, peer) } diff --git a/management/internals/controllers/network_map/controller/controller_test.go b/management/internals/controllers/network_map/controller/controller_test.go index baaffe677..90e7b6e18 100644 --- a/management/internals/controllers/network_map/controller/controller_test.go +++ b/management/internals/controllers/network_map/controller/controller_test.go @@ -1,16 +1,9 @@ package controller import ( - "context" - "sync" - "sync/atomic" "testing" - "time" - - "github.com/stretchr/testify/assert" "github.com/netbirdio/netbird/management/internals/controllers/network_map" - "github.com/netbirdio/netbird/management/server/mock_server" nbpeer "github.com/netbirdio/netbird/management/server/peer" ) @@ -114,131 +107,3 @@ func TestComputeForwarderPort(t *testing.T) { t.Errorf("Expected %d for peers with unknown version, got %d", network_map.OldForwarderPort, result) } } - -func TestBufferUpdateAccountPeers(t *testing.T) { - const ( - peersCount = 1000 - updateAccountInterval = 50 * time.Millisecond - ) - - var ( - deletedPeers, updatePeersDeleted, updatePeersRuns atomic.Int32 - uapLastRun, dpLastRun atomic.Int64 - - totalNewRuns, totalOldRuns int - ) - - uap := func(ctx context.Context, accountID string) { - updatePeersDeleted.Store(deletedPeers.Load()) - updatePeersRuns.Add(1) - uapLastRun.Store(time.Now().UnixMilli()) - time.Sleep(100 * time.Millisecond) - } - - t.Run("new approach", func(t *testing.T) { - updatePeersRuns.Store(0) - updatePeersDeleted.Store(0) - deletedPeers.Store(0) - - var mustore sync.Map - bufupd := func(ctx context.Context, accountID string) { - mu, _ := mustore.LoadOrStore(accountID, &bufferUpdate{}) - b := mu.(*bufferUpdate) - - if !b.mu.TryLock() { - b.update.Store(true) - return - } - - if b.next != nil { - b.next.Stop() - } - - go func() { - defer b.mu.Unlock() - uap(ctx, accountID) - if !b.update.Load() { - return - } - b.update.Store(false) - b.next = time.AfterFunc(updateAccountInterval, func() { - uap(ctx, accountID) - }) - }() - } - dp := func(ctx context.Context, accountID, peerID, userID string) error { - deletedPeers.Add(1) - dpLastRun.Store(time.Now().UnixMilli()) - time.Sleep(10 * time.Millisecond) - bufupd(ctx, accountID) - return nil - } - - am := mock_server.MockAccountManager{ - UpdateAccountPeersFunc: uap, - BufferUpdateAccountPeersFunc: bufupd, - DeletePeerFunc: dp, - } - empty := "" - for range peersCount { - //nolint - am.DeletePeer(context.Background(), empty, empty, empty) - } - time.Sleep(100 * time.Millisecond) - - assert.Equal(t, peersCount, int(deletedPeers.Load()), "Expected all peers to be deleted") - assert.Equal(t, peersCount, int(updatePeersDeleted.Load()), "Expected all peers to be updated in the buffer") - assert.GreaterOrEqual(t, uapLastRun.Load(), dpLastRun.Load(), "Expected update account peers to run after delete peer") - - totalNewRuns = int(updatePeersRuns.Load()) - }) - - t.Run("old approach", func(t *testing.T) { - updatePeersRuns.Store(0) - updatePeersDeleted.Store(0) - deletedPeers.Store(0) - - var mustore sync.Map - bufupd := func(ctx context.Context, accountID string) { - mu, _ := mustore.LoadOrStore(accountID, &sync.Mutex{}) - b := mu.(*sync.Mutex) - - if !b.TryLock() { - return - } - - go func() { - time.Sleep(updateAccountInterval) - b.Unlock() - uap(ctx, accountID) - }() - } - dp := func(ctx context.Context, accountID, peerID, userID string) error { - deletedPeers.Add(1) - dpLastRun.Store(time.Now().UnixMilli()) - time.Sleep(10 * time.Millisecond) - bufupd(ctx, accountID) - return nil - } - - am := mock_server.MockAccountManager{ - UpdateAccountPeersFunc: uap, - BufferUpdateAccountPeersFunc: bufupd, - DeletePeerFunc: dp, - } - empty := "" - for range peersCount { - //nolint - am.DeletePeer(context.Background(), empty, empty, empty) - } - time.Sleep(100 * time.Millisecond) - - assert.Equal(t, peersCount, int(deletedPeers.Load()), "Expected all peers to be deleted") - assert.Equal(t, peersCount, int(updatePeersDeleted.Load()), "Expected all peers to be updated in the buffer") - assert.GreaterOrEqual(t, uapLastRun.Load(), dpLastRun.Load(), "Expected update account peers to run after delete peer") - - totalOldRuns = int(updatePeersRuns.Load()) - }) - assert.Less(t, totalNewRuns, totalOldRuns, "Expected new approach to run less than old approach. New runs: %d, Old runs: %d", totalNewRuns, totalOldRuns) - t.Logf("New runs: %d, Old runs: %d", totalNewRuns, totalOldRuns) -} diff --git a/management/internals/controllers/network_map/controller/repository.go b/management/internals/controllers/network_map/controller/repository.go index 44144263b..a5a97f530 100644 --- a/management/internals/controllers/network_map/controller/repository.go +++ b/management/internals/controllers/network_map/controller/repository.go @@ -3,6 +3,9 @@ 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" @@ -12,6 +15,13 @@ type Repository interface { GetAccountNetwork(ctx context.Context, accountID string) (*types.Network, error) GetAccountPeers(ctx context.Context, accountID string) ([]*peer.Peer, error) GetAccountByPeerID(ctx context.Context, peerID string) (*types.Account, error) + 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) } type repository struct { @@ -37,3 +47,19 @@ func (r *repository) GetAccountPeers(ctx context.Context, accountID string) ([]* func (r *repository) GetAccountByPeerID(ctx context.Context, peerID string) (*types.Account, error) { return r.store.GetAccountByPeerID(ctx, peerID) } + +func (r *repository) GetPeersByIDs(ctx context.Context, accountID string, peerIDs []string) (map[string]*peer.Peer, error) { + return r.store.GetPeersByIDs(ctx, store.LockingStrengthNone, accountID, peerIDs) +} + +func (r *repository) GetPeerByID(ctx context.Context, accountID string, peerID string) (*peer.Peer, error) { + 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) 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/interface.go b/management/internals/controllers/network_map/interface.go index 6f893ce79..e6e464566 100644 --- a/management/internals/controllers/network_map/interface.go +++ b/management/internals/controllers/network_map/interface.go @@ -12,28 +12,30 @@ import ( ) 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, []*posture.Checks, int64, error) + GetValidatedPeerWithComponents(ctx context.Context, isRequiresApproval bool, accountID string, p *nbpeer.Peer) (*nbpeer.Peer, *types.NetworkMapComponents, *types.NetworkMap, []*posture.Checks, int64, error) GetDNSDomain(settings *types.Settings) string StartWarmup(context.Context) GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error) + CountStreams() int - DeletePeer(ctx context.Context, accountId string, peerId 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) - OnPeerUpdated(accountId string, peer *nbpeer.Peer) - OnPeerAdded(ctx context.Context, accountID string, peerID string) error - OnPeerDeleted(ctx context.Context, accountID string, peerID string) error - DisconnectPeers(ctx context.Context, peerIDs []string) - IsConnected(peerID string) bool + TrackEphemeralPeer(ctx context.Context, peer *nbpeer.Peer) } diff --git a/management/internals/controllers/network_map/interface_mock.go b/management/internals/controllers/network_map/interface_mock.go index aaa093e47..42051f172 100644 --- a/management/internals/controllers/network_map/interface_mock.go +++ b/management/internals/controllers/network_map/interface_mock.go @@ -44,43 +44,57 @@ 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) } -// DeletePeer mocks base method. -func (m *MockController) DeletePeer(ctx context.Context, accountId, peerId string) error { +// 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, "DeletePeer", ctx, accountId, peerId) + ret := m.ctrl.Call(m, "BufferUpdateAffectedPeers", ctx, accountID, peerIDs, reason) ret0, _ := ret[0].(error) return ret0 } -// DeletePeer indicates an expected call of DeletePeer. -func (mr *MockControllerMockRecorder) DeletePeer(ctx, accountId, peerId any) *gomock.Call { +// 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, "DeletePeer", reflect.TypeOf((*MockController)(nil).DeletePeer), ctx, accountId, peerId) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BufferUpdateAffectedPeers", reflect.TypeOf((*MockController)(nil).BufferUpdateAffectedPeers), ctx, accountID, peerIDs, reason) +} + +// CountStreams mocks base method. +func (m *MockController) CountStreams() int { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CountStreams") + ret0, _ := ret[0].(int) + return ret0 +} + +// CountStreams indicates an expected call of CountStreams. +func (mr *MockControllerMockRecorder) CountStreams() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountStreams", reflect.TypeOf((*MockController)(nil).CountStreams)) } // DisconnectPeers mocks base method. -func (m *MockController) DisconnectPeers(ctx context.Context, peerIDs []string) { +func (m *MockController) DisconnectPeers(ctx context.Context, accountId string, peerIDs []string) { m.ctrl.T.Helper() - m.ctrl.Call(m, "DisconnectPeers", ctx, peerIDs) + m.ctrl.Call(m, "DisconnectPeers", ctx, accountId, peerIDs) } // DisconnectPeers indicates an expected call of DisconnectPeers. -func (mr *MockControllerMockRecorder) DisconnectPeers(ctx, peerIDs any) *gomock.Call { +func (mr *MockControllerMockRecorder) DisconnectPeers(ctx, accountId, peerIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DisconnectPeers", reflect.TypeOf((*MockController)(nil).DisconnectPeers), ctx, peerIDs) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DisconnectPeers", reflect.TypeOf((*MockController)(nil).DisconnectPeers), ctx, accountId, peerIDs) } // GetDNSDomain mocks base method. @@ -112,76 +126,109 @@ 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, []*posture.Checks, 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].([]*posture.Checks) + 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, []*posture.Checks, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetValidatedPeerWithMap", ctx, isRequiresApproval, accountID, peerID) + ret0, _ := ret[0].(*types.NetworkMap) + ret1, _ := ret[1].([]*posture.Checks) + 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) } -// IsConnected mocks base method. -func (m *MockController) IsConnected(peerID string) bool { +// OnPeerConnected mocks base method. +func (m *MockController) OnPeerConnected(ctx context.Context, accountID, peerID string) (chan *UpdateMessage, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "IsConnected", peerID) - ret0, _ := ret[0].(bool) - return ret0 + ret := m.ctrl.Call(m, "OnPeerConnected", ctx, accountID, peerID) + ret0, _ := ret[0].(chan *UpdateMessage) + ret1, _ := ret[1].(error) + return ret0, ret1 } -// IsConnected indicates an expected call of IsConnected. -func (mr *MockControllerMockRecorder) IsConnected(peerID any) *gomock.Call { +// OnPeerConnected indicates an expected call of OnPeerConnected. +func (mr *MockControllerMockRecorder) OnPeerConnected(ctx, accountID, peerID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsConnected", reflect.TypeOf((*MockController)(nil).IsConnected), peerID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeerConnected", reflect.TypeOf((*MockController)(nil).OnPeerConnected), ctx, accountID, peerID) } -// OnPeerAdded mocks base method. -func (m *MockController) OnPeerAdded(ctx context.Context, accountID, peerID string) error { +// OnPeerDisconnected mocks base method. +func (m *MockController) OnPeerDisconnected(ctx context.Context, accountID, peerID string) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "OnPeerAdded", ctx, accountID, peerID) + m.ctrl.Call(m, "OnPeerDisconnected", ctx, accountID, peerID) +} + +// OnPeerDisconnected indicates an expected call of OnPeerDisconnected. +func (mr *MockControllerMockRecorder) OnPeerDisconnected(ctx, accountID, peerID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeerDisconnected", reflect.TypeOf((*MockController)(nil).OnPeerDisconnected), ctx, accountID, peerID) +} + +// OnPeersAdded mocks base method. +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, affectedPeerIDs) ret0, _ := ret[0].(error) return ret0 } -// OnPeerAdded indicates an expected call of OnPeerAdded. -func (mr *MockControllerMockRecorder) OnPeerAdded(ctx, accountID, peerID any) *gomock.Call { +// OnPeersAdded indicates an expected call of OnPeersAdded. +func (mr *MockControllerMockRecorder) OnPeersAdded(ctx, accountID, peerIDs, affectedPeerIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeerAdded", reflect.TypeOf((*MockController)(nil).OnPeerAdded), ctx, accountID, peerID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersAdded", reflect.TypeOf((*MockController)(nil).OnPeersAdded), ctx, accountID, peerIDs, affectedPeerIDs) } -// OnPeerDeleted mocks base method. -func (m *MockController) OnPeerDeleted(ctx context.Context, accountID, peerID string) error { +// OnPeersDeleted mocks base method. +func (m *MockController) OnPeersDeleted(ctx context.Context, accountID string, peerIDs, affectedPeerIDs []string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "OnPeerDeleted", ctx, accountID, peerID) + ret := m.ctrl.Call(m, "OnPeersDeleted", ctx, accountID, peerIDs, affectedPeerIDs) ret0, _ := ret[0].(error) return ret0 } -// OnPeerDeleted indicates an expected call of OnPeerDeleted. -func (mr *MockControllerMockRecorder) OnPeerDeleted(ctx, accountID, peerID any) *gomock.Call { +// OnPeersDeleted indicates an expected call of OnPeersDeleted. +func (mr *MockControllerMockRecorder) OnPeersDeleted(ctx, accountID, peerIDs, affectedPeerIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeerDeleted", reflect.TypeOf((*MockController)(nil).OnPeerDeleted), ctx, accountID, peerID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersDeleted", reflect.TypeOf((*MockController)(nil).OnPeersDeleted), ctx, accountID, peerIDs, affectedPeerIDs) } -// OnPeerUpdated mocks base method. -func (m *MockController) OnPeerUpdated(accountId string, peer *peer.Peer) { +// OnPeersUpdated mocks base method. +func (m *MockController) OnPeersUpdated(ctx context.Context, accountId string, peerIDs, affectedPeerIDs []string) error { m.ctrl.T.Helper() - m.ctrl.Call(m, "OnPeerUpdated", accountId, peer) + ret := m.ctrl.Call(m, "OnPeersUpdated", ctx, accountId, peerIDs, affectedPeerIDs) + ret0, _ := ret[0].(error) + return ret0 } -// OnPeerUpdated indicates an expected call of OnPeerUpdated. -func (mr *MockControllerMockRecorder) OnPeerUpdated(accountId, peer any) *gomock.Call { +// OnPeersUpdated indicates an expected call of OnPeersUpdated. +func (mr *MockControllerMockRecorder) OnPeersUpdated(ctx, accountId, peerIDs, affectedPeerIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeerUpdated", reflect.TypeOf((*MockController)(nil).OnPeerUpdated), accountId, peer) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeersUpdated", reflect.TypeOf((*MockController)(nil).OnPeersUpdated), ctx, accountId, peerIDs, affectedPeerIDs) } // StartWarmup mocks base method. @@ -196,6 +243,18 @@ func (mr *MockControllerMockRecorder) StartWarmup(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartWarmup", reflect.TypeOf((*MockController)(nil).StartWarmup), arg0) } +// TrackEphemeralPeer mocks base method. +func (m *MockController) TrackEphemeralPeer(ctx context.Context, arg1 *peer.Peer) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "TrackEphemeralPeer", ctx, arg1) +} + +// TrackEphemeralPeer indicates an expected call of TrackEphemeralPeer. +func (mr *MockControllerMockRecorder) TrackEphemeralPeer(ctx, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TrackEphemeralPeer", reflect.TypeOf((*MockController)(nil).TrackEphemeralPeer), ctx, arg1) +} + // UpdateAccountPeer mocks base method. func (m *MockController) UpdateAccountPeer(ctx context.Context, accountId, peerId string) error { m.ctrl.T.Helper() @@ -211,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/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/controllers/network_map/update_channel/updatechannel_test.go b/management/internals/controllers/network_map/update_channel/updatechannel_test.go index afc1e2c32..c73baf81f 100644 --- a/management/internals/controllers/network_map/update_channel/updatechannel_test.go +++ b/management/internals/controllers/network_map/update_channel/updatechannel_test.go @@ -25,11 +25,14 @@ func TestCreateChannel(t *testing.T) { func TestSendUpdate(t *testing.T) { peer := "test-sendupdate" peersUpdater := NewPeersUpdateManager(nil) - update1 := &network_map.UpdateMessage{Update: &proto.SyncResponse{ - NetworkMap: &proto.NetworkMap{ - Serial: 0, + update1 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{ + NetworkMap: &proto.NetworkMap{ + Serial: 0, + }, }, - }} + MessageType: network_map.MessageTypeNetworkMap, + } _ = peersUpdater.CreateChannel(context.Background(), peer) if _, ok := peersUpdater.peerChannels[peer]; !ok { t.Error("Error creating the channel") @@ -45,11 +48,14 @@ func TestSendUpdate(t *testing.T) { peersUpdater.SendUpdate(context.Background(), peer, update1) } - update2 := &network_map.UpdateMessage{Update: &proto.SyncResponse{ - NetworkMap: &proto.NetworkMap{ - Serial: 10, + update2 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{ + NetworkMap: &proto.NetworkMap{ + Serial: 10, + }, }, - }} + MessageType: network_map.MessageTypeNetworkMap, + } peersUpdater.SendUpdate(context.Background(), peer, update2) timeout := time.After(5 * time.Second) diff --git a/management/internals/controllers/network_map/update_message.go b/management/internals/controllers/network_map/update_message.go index 33643bcbd..0ffddf8b2 100644 --- a/management/internals/controllers/network_map/update_message.go +++ b/management/internals/controllers/network_map/update_message.go @@ -4,6 +4,19 @@ import ( "github.com/netbirdio/netbird/shared/management/proto" ) +// MessageType indicates the type of update message for debouncing strategy +type MessageType int + +const ( + // MessageTypeNetworkMap represents network map updates (peers, routes, DNS, firewall) + // These updates can be safely debounced - only the latest state matters + MessageTypeNetworkMap MessageType = iota + // MessageTypeControlConfig represents control/config updates (tokens, peer expiration) + // These updates should not be dropped as they contain time-sensitive information + MessageTypeControlConfig +) + type UpdateMessage struct { - Update *proto.SyncResponse + Update *proto.SyncResponse + MessageType MessageType } diff --git a/management/internals/modules/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/catalog/catalog.go b/management/internals/modules/agentnetwork/catalog/catalog.go new file mode 100644 index 000000000..f82e94bf3 --- /dev/null +++ b/management/internals/modules/agentnetwork/catalog/catalog.go @@ -0,0 +1,823 @@ +// 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. +type Model struct { + ID string + Label string + InputPer1k float64 + OutputPer1k 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 + // 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 +} + +// 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", + ParserID: "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, ContextWindow: 1050000}, + {ID: "gpt-5.5-pro", Label: "GPT-5.5 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, ContextWindow: 1050000}, + {ID: "gpt-5.4", Label: "GPT-5.4", InputPer1k: 0.0025, OutputPer1k: 0.015, ContextWindow: 1050000}, + {ID: "gpt-5.4-pro", Label: "GPT-5.4 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, ContextWindow: 1050000}, + {ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini", InputPer1k: 0.00075, OutputPer1k: 0.0045, ContextWindow: 272000}, + {ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano", InputPer1k: 0.0002, OutputPer1k: 0.00125, ContextWindow: 272000}, + {ID: "gpt-5.3-codex", Label: "GPT-5.3 Codex", InputPer1k: 0.00175, OutputPer1k: 0.014, ContextWindow: 272000}, + {ID: "gpt-5.3-chat-latest", Label: "GPT-5.3 Chat", InputPer1k: 0.00175, OutputPer1k: 0.014, ContextWindow: 128000}, + {ID: "o4-mini", Label: "o4-mini", InputPer1k: 0.0011, OutputPer1k: 0.0044, ContextWindow: 200000}, + {ID: "gpt-4.1", Label: "GPT-4.1", InputPer1k: 0.002, OutputPer1k: 0.008, ContextWindow: 1047576}, + {ID: "gpt-4.1-mini", Label: "GPT-4.1 mini", InputPer1k: 0.0004, OutputPer1k: 0.0016, ContextWindow: 1047576}, + {ID: "gpt-4.1-nano", Label: "GPT-4.1 nano", InputPer1k: 0.0001, OutputPer1k: 0.0004, ContextWindow: 1047576}, + {ID: "gpt-4o", Label: "GPT-4o", InputPer1k: 0.0025, OutputPer1k: 0.010, ContextWindow: 128000}, + {ID: "gpt-4o-mini", Label: "GPT-4o mini", InputPer1k: 0.00015, OutputPer1k: 0.0006, 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", + ParserID: "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-fable-5", Label: "Claude Fable 5", InputPer1k: 0.010, OutputPer1k: 0.050, ContextWindow: 1000000}, + {ID: "claude-opus-4-8", Label: "Claude Opus 4.8", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, + {ID: "claude-opus-4-7", Label: "Claude Opus 4.7", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, + {ID: "claude-opus-4-6", Label: "Claude Opus 4.6", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, + {ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (deprecated, retires 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, ContextWindow: 200000}, + {ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000}, + {ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 200000}, + {ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5", InputPer1k: 0.001, OutputPer1k: 0.005, 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", + // 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, ContextWindow: 1050000}, + {ID: "gpt-5.4", Label: "GPT-5.4 (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.015, ContextWindow: 1050000}, + {ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini (Azure)", InputPer1k: 0.00075, OutputPer1k: 0.0045, ContextWindow: 272000}, + {ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano (Azure)", InputPer1k: 0.0002, OutputPer1k: 0.00125, ContextWindow: 272000}, + {ID: "o4-mini", Label: "o4-mini (Azure)", InputPer1k: 0.0011, OutputPer1k: 0.0044, ContextWindow: 200000}, + {ID: "gpt-4.1", Label: "GPT-4.1 (Azure)", InputPer1k: 0.002, OutputPer1k: 0.008, ContextWindow: 1047576}, + {ID: "gpt-4.1-mini", Label: "GPT-4.1 mini (Azure)", InputPer1k: 0.0004, OutputPer1k: 0.0016, ContextWindow: 1047576}, + {ID: "gpt-4o", Label: "GPT-4o (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.010, ContextWindow: 128000}, + {ID: "gpt-4o-mini", Label: "GPT-4o mini (Azure)", InputPer1k: 0.00015, OutputPer1k: 0.0006, 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", + // 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-4-8", Label: "Claude Opus 4.8 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, + {ID: "anthropic.claude-opus-4-7", Label: "Claude Opus 4.7 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, + {ID: "anthropic.claude-opus-4-6", Label: "Claude Opus 4.6 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, + {ID: "anthropic.claude-opus-4-1", Label: "Claude Opus 4.1 (Bedrock, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, ContextWindow: 200000}, + {ID: "anthropic.claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000}, + {ID: "anthropic.claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 200000}, + {ID: "anthropic.claude-haiku-4-5", Label: "Claude Haiku 4.5 (Bedrock)", InputPer1k: 0.001, OutputPer1k: 0.005, 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", + // 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-fable-5", Label: "Claude Fable 5 (Vertex)", InputPer1k: 0.010, OutputPer1k: 0.050, ContextWindow: 1000000}, + {ID: "claude-opus-4-8", Label: "Claude Opus 4.8 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, + {ID: "claude-opus-4-7", Label: "Claude Opus 4.7 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, + {ID: "claude-opus-4-6", Label: "Claude Opus 4.6 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000}, + {ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (Vertex, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, ContextWindow: 200000}, + {ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000}, + {ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 200000}, + {ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5 (Vertex)", InputPer1k: 0.001, OutputPer1k: 0.005, 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", + // 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: "", + // 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{ + {ID: "kimi-k3", Label: "Kimi K3", InputPer1k: 0.003, OutputPer1k: 0.015, 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: "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 { + models = append(models, api.AgentNetworkCatalogModel{ + Id: m.ID, + Label: m.Label, + InputPer1k: m.InputPer1k, + OutputPer1k: m.OutputPer1k, + ContextWindow: m.ContextWindow, + }) + } + 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.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/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/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..4038761c5 --- /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, + Cluster: "eu.proxy.netbird.io", + Subdomain: "violet", + 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..27ebea5dd --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/handlers_test.go @@ -0,0 +1,256 @@ +package handlers + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "runtime" + "strings" + "testing" + + "github.com/golang/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" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/management/server/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() + + manager := agentnetwork.NewManager(st, perms, nil, nil) + h := &handler{manager: manager} + + router := mux.NewRouter() + 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/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..13da137d5 --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/providers_handler.go @@ -0,0 +1,217 @@ +// 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" + "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/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/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) +} + +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 { + out = append(out, e.ToAPIResponse()) + } + util.WriteJSONObject(r.Context(), w, out) +} + +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) + + bootstrapCluster := "" + if req.BootstrapCluster != nil { + bootstrapCluster = *req.BootstrapCluster + } + + created, err := h.manager.CreateProvider(r.Context(), userAuth.UserId, provider, bootstrapCluster) + 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") + } + return nil +} 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..c65efad0f --- /dev/null +++ b/management/internals/modules/agentnetwork/handlers/settings_handler.go @@ -0,0 +1,74 @@ +package handlers + +import ( + "encoding/json" + "errors" + "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" + "github.com/netbirdio/netbird/shared/management/status" +) + +// addSettingsEndpoints registers the Agent Network settings routes. The +// settings row is bootstrapped server-side on first provider create; GET reads +// it and PUT updates the mutable collection toggles (cluster/subdomain stay +// immutable). +func (h *handler) addSettingsEndpoints(router *mux.Router) { + router.HandleFunc("/agent-network/settings", h.getSettings).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/settings", h.updateSettings).Methods("PUT", "OPTIONS") +} + +// updateSettings applies the collection toggles to the account's settings row. +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()) +} + +// getSettings returns the account's agent-network settings. The settings +// row is bootstrapped on first provider create, so freshly-onboarded +// accounts have nothing to read. Rather than 404-ing in that case (which +// the dashboard would have to special-case), return a JSON null with 200 +// so consumers can branch on the body alone. +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 { + var sErr *status.Error + if errors.As(err, &sErr) && sErr.Type() == status.NotFound { + util.WriteJSONObject(r.Context(), w, nil) + return + } + util.WriteError(r.Context(), err, w) + return + } + util.WriteJSONObject(r.Context(), w, settings.ToAPIResponse()) +} diff --git a/management/internals/modules/agentnetwork/labelgen/labelgen.go b/management/internals/modules/agentnetwork/labelgen/labelgen.go new file mode 100644 index 000000000..b45ff4ea8 --- /dev/null +++ b/management/internals/modules/agentnetwork/labelgen/labelgen.go @@ -0,0 +1,66 @@ +// Package labelgen produces DNS-safe Agent Network subdomain labels. +package labelgen + +import ( + "fmt" + "math/rand" + "sort" + "sync" +) + +// 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 deterministic word and returns. The caller +// is responsible for seeding rng (math/rand). +func PickUnique(rng *rand.Rand, taken map[string]struct{}, fallbackSuffix string) string { + pool := uniqueWords() + if len(pool) == 0 { + return fallbackSuffix + } + + for i := 0; i < pickAttempts; i++ { + w := pool[rng.Intn(len(pool))] + if _, ok := taken[w]; !ok { + return w + } + } + + for _, w := range pool { + if _, ok := taken[w]; !ok { + return w + } + } + + w := pool[rng.Intn(len(pool))] + return fmt.Sprintf("%s-%s", w, fallbackSuffix) +} 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..f03a3501d --- /dev/null +++ b/management/internals/modules/agentnetwork/labelgen/labelgen_test.go @@ -0,0 +1,101 @@ +package labelgen + +import ( + "math/rand" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPickUnique_DeterministicWithSeededRng locks the property the +// caller relies on: same seed + same taken set → same pick. Without +// that, the bootstrap flow can't reproduce a label across retries. +func TestPickUnique_DeterministicWithSeededRng(t *testing.T) { + taken := map[string]struct{}{} + + rngA := rand.New(rand.NewSource(42)) + rngB := rand.New(rand.NewSource(42)) + + a := PickUnique(rngA, taken, "abcd") + b := PickUnique(rngB, taken, "abcd") + + assert.Equal(t, a, b, "Same seed and taken set must produce identical pick") +} + +// 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{}{} + } + + rng := rand.New(rand.NewSource(7)) + got := PickUnique(rng, 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{}{} + } + + rng := rand.New(rand.NewSource(99)) + got := PickUnique(rng, 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") +} 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..d88e0d77c --- /dev/null +++ b/management/internals/modules/agentnetwork/manager.go @@ -0,0 +1,911 @@ +package agentnetwork + +import ( + "context" + "errors" + "fmt" + "math/rand" + "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/types" + "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/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/proto" + "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, bootstrapCluster string) (*types.Provider, error) + UpdateProvider(ctx context.Context, userID string, provider *types.Provider) (*types.Provider, error) + DeleteProvider(ctx context.Context, accountID, userID, providerID string) 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) + UpdateSettings(ctx context.Context, userID string, settings *types.Settings) (*types.Settings, 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) +} + +// PolicySelectionInput is the per-request selection envelope. The +// proxy populates it from CapturedData (account, user, groups) plus +// the provider llm_router resolved. +type PolicySelectionInput struct { + AccountID string + UserID string + GroupIDs []string + ProviderID 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 + + // reconcileCache holds the last set of synthesised proxy mappings + // per account so reconcile can emit precise Create/Update/Delete + // updates instead of a full re-push on every mutation. Keyed by + // accountID, then by synthesised service ID. + reconcileMu sync.Mutex + reconcileCache map[string]map[string]*proto.ProxyMapping + + // labelRngMu guards labelRng. PickUnique consumes math/rand.Source + // state; concurrent provider creates would otherwise race. + labelRngMu sync.Mutex + labelRng *rand.Rand +} + +// 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, +) Manager { + return &managerImpl{ + store: store, + accountManager: accountManager, + permissionsManager: permissionsManager, + proxyController: proxyController, + reconcileCache: make(map[string]map[string]*proto.ProxyMapping), + labelRng: rand.New(rand.NewSource(time.Now().UnixNano())), + } +} + +func (m *managerImpl) GetAllProviders(ctx context.Context, accountID, userID string) ([]*types.Provider, error) { + if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil { + return nil, err + } + return m.store.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID) +} + +func (m *managerImpl) GetProvider(ctx context.Context, accountID, userID, providerID string) (*types.Provider, error) { + if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil { + return nil, err + } + return m.store.GetAgentNetworkProviderByID(ctx, store.LockingStrengthNone, accountID, providerID) +} + +// CreateProvider persists a new provider for the account. bootstrapCluster +// is used only when the per-account agent-network Settings row hasn't +// been created yet; otherwise it is ignored (the cluster is pinned on +// Settings and every provider in the account routes through it). +func (m *managerImpl) CreateProvider(ctx context.Context, userID string, provider *types.Provider, bootstrapCluster string) (*types.Provider, error) { + if err := m.requirePermission(ctx, provider.AccountID, userID, 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") + } + + 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) + } + + if strings.TrimSpace(bootstrapCluster) != "" { + if _, err := m.bootstrapSettingsIfNeeded(ctx, provider.AccountID, bootstrapCluster); err != nil { + // The provider create has already succeeded; logging the + // bootstrap miss matches the plan's PoC behaviour. The synth + // path treats a missing settings row as a no-op, and the next + // provider create retries the bootstrap. + log.WithContext(ctx).Debugf("agent-network bootstrap settings for account %s on cluster %s: %v", provider.AccountID, bootstrapCluster, 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, 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. + if provider.APIKey == "" { + provider.APIKey = existing.APIKey + } else if strings.TrimSpace(provider.APIKey) == "" { + return nil, status.Errorf(status.InvalidArgument, "api_key must be non-blank when rotating an agent network provider") + } + // 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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 applies the mutable account-level settings — the collection +// toggles — onto the existing row. Cluster and Subdomain are immutable and are +// preserved from the persisted row regardless of the input. 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, operations.Update); err != nil { + return nil, err + } + + existing, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthUpdate, settings.AccountID) + if err != nil { + return nil, fmt.Errorf("get agent network settings: %w", err) + } + + existing.EnableLogCollection = settings.EnableLogCollection + existing.EnablePromptCollection = settings.EnablePromptCollection + existing.RedactPii = settings.RedactPii + existing.AccessLogRetentionDays = settings.AccessLogRetentionDays + existing.UpdatedAt = time.Now().UTC() + + if err := m.store.SaveAgentNetworkSettings(ctx, existing); err != nil { + return nil, fmt.Errorf("save agent network settings: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, settings.AccountID, settings.AccountID, activity.AgentNetworkSettingsUpdated, map[string]any{ + "log_collection": existing.EnableLogCollection, + "prompt_collection": existing.EnablePromptCollection, + "redact_pii": existing.RedactPii, + }) + m.reconcile(ctx, settings.AccountID) + + return existing, nil +} + +// 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. +// Returns the underlying status.NotFound when no row has been +// bootstrapped yet (i.e. the account has no providers). +func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error) { + if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil { + return nil, err + } + return m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID) +} + +// bootstrapSettingsIfNeeded creates the per-account agent-network +// settings row when missing. The cluster comes from the create-time +// hint the dashboard sends (auto-picked from the active cluster list); +// the subdomain is picked from the curated wordlist avoiding +// collisions on the same cluster. Idempotent: if a row already exists +// it is returned untouched and the hint is ignored. +func (m *managerImpl) bootstrapSettingsIfNeeded(ctx context.Context, accountID, providerCluster string) (*types.Settings, error) { + if accountID == "" { + return nil, fmt.Errorf("bootstrap settings: account id is required") + } + if strings.TrimSpace(providerCluster) == "" { + return nil, fmt.Errorf("bootstrap settings: provider cluster is required") + } + + existing, err := m.store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID) + if err == nil { + return existing, nil + } + var sErr *status.Error + if !errors.As(err, &sErr) || sErr.Type() != status.NotFound { + return nil, fmt.Errorf("get agent network settings: %w", err) + } + + siblings, err := m.store.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, providerCluster) + if err != nil { + return nil, fmt.Errorf("list agent network settings on cluster: %w", err) + } + taken := make(map[string]struct{}, len(siblings)) + for _, s := range siblings { + taken[s.Subdomain] = struct{}{} + } + + suffix := accountID + if len(suffix) > 4 { + suffix = suffix[:4] + } + + m.labelRngMu.Lock() + subdomain := labelgen.PickUnique(m.labelRng, taken, suffix) + m.labelRngMu.Unlock() + + now := time.Now().UTC() + settings := &types.Settings{ + AccountID: accountID, + Cluster: providerCluster, + Subdomain: subdomain, + // Logs on by default; usage is collected regardless. Retention bounds + // how long full log rows are kept. + EnableLogCollection: true, + AccessLogRetentionDays: types.DefaultAccessLogRetentionDays, + CreatedAt: now, + UpdatedAt: now, + } + if err := m.store.SaveAgentNetworkSettings(ctx, settings); err != nil { + return nil, fmt.Errorf("save agent network settings: %w", err) + } + return settings, nil +} + +// ListConsumption returns every consumption row recorded for the +// account, ordered window-newest-first. Backs the dashboard's basic +// counter view; permission gate is the same Read role that gates +// every other agent-network surface. +func (m *managerImpl) ListConsumption(ctx context.Context, accountID, userID string) ([]*types.Consumption, error) { + if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil { + return nil, err + } + return m.store.ListAgentNetworkConsumption(ctx, store.LockingStrengthNone, accountID) +} + +// ListAccessLogs returns a paginated, server-side-filtered page of +// agent-network access logs plus the total count matching the filter. +func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error) { + if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil { + return nil, 0, err + } + return m.store.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, accountID, filter) +} + +// ListAccessLogSessions returns a paginated, server-side-filtered page of +// agent-network access logs grouped by session, plus the total number of +// sessions matching the filter. +func (m *managerImpl) ListAccessLogSessions(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) { + if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil { + return nil, 0, err + } + return m.store.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, accountID, filter) +} + +// GetUsageOverview returns the filtered usage rows aggregated into time buckets +// at the requested granularity, oldest-first. +func (m *managerImpl) GetUsageOverview(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) { + if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil { + return nil, err + } + rows, err := m.store.GetAgentNetworkUsageRows(ctx, store.LockingStrengthNone, accountID, filter) + if err != nil { + return nil, err + } + return types.AggregateUsageByGranularity(rows, granularity), nil +} + +// StartAccessLogCleanup launches a background sweep that periodically deletes +// each account's agent-network access-log rows older than that account's +// AccessLogRetentionDays. Usage records are never swept. A non-positive +// interval defaults to 24h. +func (m *managerImpl) StartAccessLogCleanup(ctx context.Context, cleanupIntervalHours int) { + if cleanupIntervalHours <= 0 { + cleanupIntervalHours = 24 + } + interval := time.Duration(cleanupIntervalHours) * time.Hour + + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + m.cleanupAccessLogsOnce(ctx) // run once on startup + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + m.cleanupAccessLogsOnce(ctx) + } + } + }() +} + +// cleanupAccessLogsOnce sweeps every account's expired access-log rows against +// its configured retention. Best-effort: a per-account failure is logged and +// the sweep continues. +func (m *managerImpl) cleanupAccessLogsOnce(ctx context.Context) { + settings, err := m.store.GetAllAgentNetworkSettings(ctx, store.LockingStrengthNone) + if err != nil { + log.WithContext(ctx).Errorf("agent-network access-log cleanup: list settings: %v", err) + return + } + for _, s := range settings { + if s.AccessLogRetentionDays <= 0 { + continue // keep indefinitely + } + cutoff := time.Now().UTC().AddDate(0, 0, -s.AccessLogRetentionDays) + deleted, err := m.store.DeleteOldAgentNetworkAccessLogs(ctx, s.AccountID, cutoff) + if err != nil { + log.WithContext(ctx).Warnf("agent-network access-log cleanup for account %s: %v", s.AccountID, err) + continue + } + if deleted > 0 { + log.WithContext(ctx).Infof("agent-network access-log cleanup: deleted %d rows for account %s (retention %d days)", deleted, s.AccountID, s.AccessLogRetentionDays) + } + } +} + +// RecordConsumption increments the (dim, window) counter by the +// supplied deltas. The window_start is computed from time.Now under +// the supplied window_seconds so callers don't have to pre-align — +// the proxy's post-flight path simply hands us tokens + cost and +// which dimension we're booking against. +func (m *managerImpl) RecordConsumption(ctx context.Context, accountID string, kind types.ConsumptionDimension, dimID string, windowSeconds, tokensIn, tokensOut int64, costUSD float64) error { + if accountID == "" || dimID == "" || windowSeconds <= 0 { + return status.Errorf(status.InvalidArgument, "account_id, dim_id and window_seconds must be set") + } + windowStart := types.WindowStart(time.Now(), windowSeconds) + return m.store.IncrementAgentNetworkConsumption(ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD) +} + +func (m *managerImpl) requirePermission(ctx context.Context, accountID, userID string, op operations.Operation) error { + ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.AgentNetwork, op) + if err != nil { + return status.NewPermissionValidationError(err) + } + if !ok { + return status.NewPermissionDeniedError() + } + return nil +} + +type mockManager struct{} + +// NewManagerMock returns a no-op manager useful for tests. +func NewManagerMock() Manager { + return &mockManager{} +} + +func (*mockManager) GetAllProviders(_ context.Context, _, _ string) ([]*types.Provider, error) { + return []*types.Provider{}, nil +} + +func (*mockManager) GetProvider(_ context.Context, _, _, _ string) (*types.Provider, error) { + return &types.Provider{}, nil +} + +func (*mockManager) CreateProvider(_ context.Context, _ string, p *types.Provider, _ string) (*types.Provider, error) { + return p, nil +} + +func (*mockManager) UpdateProvider(_ context.Context, _ string, p *types.Provider) (*types.Provider, error) { + return p, nil +} + +func (*mockManager) DeleteProvider(_ context.Context, _, _, _ string) error { return nil } + +func (*mockManager) GetAllPolicies(_ context.Context, _, _ string) ([]*types.Policy, error) { + return []*types.Policy{}, nil +} + +func (*mockManager) GetPolicy(_ context.Context, _, _, _ string) (*types.Policy, error) { + return &types.Policy{}, nil +} + +func (*mockManager) CreatePolicy(_ context.Context, _ string, p *types.Policy) (*types.Policy, error) { + return p, nil +} + +func (*mockManager) UpdatePolicy(_ context.Context, _ string, p *types.Policy) (*types.Policy, error) { + return p, nil +} + +func (*mockManager) DeletePolicy(_ context.Context, _, _, _ string) error { return nil } + +func (*mockManager) GetAllGuardrails(_ context.Context, _, _ string) ([]*types.Guardrail, error) { + return []*types.Guardrail{}, nil +} + +func (*mockManager) GetGuardrail(_ context.Context, _, _, _ string) (*types.Guardrail, error) { + return &types.Guardrail{}, nil +} + +func (*mockManager) CreateGuardrail(_ context.Context, _ string, g *types.Guardrail) (*types.Guardrail, error) { + return g, nil +} + +func (*mockManager) UpdateGuardrail(_ context.Context, _ string, g *types.Guardrail) (*types.Guardrail, error) { + return g, nil +} + +func (*mockManager) DeleteGuardrail(_ context.Context, _, _, _ string) error { return nil } + +func (*mockManager) GetAllBudgetRules(_ context.Context, _, _ string) ([]*types.AccountBudgetRule, error) { + return []*types.AccountBudgetRule{}, nil +} + +func (*mockManager) GetBudgetRule(_ context.Context, _, _, _ string) (*types.AccountBudgetRule, error) { + return &types.AccountBudgetRule{}, nil +} + +func (*mockManager) CreateBudgetRule(_ context.Context, _ string, r *types.AccountBudgetRule) (*types.AccountBudgetRule, error) { + return r, nil +} + +func (*mockManager) UpdateBudgetRule(_ context.Context, _ string, r *types.AccountBudgetRule) (*types.AccountBudgetRule, error) { + return r, nil +} + +func (*mockManager) DeleteBudgetRule(_ context.Context, _, _, _ string) error { return nil } + +func (*mockManager) GetSettings(_ context.Context, _, _ string) (*types.Settings, error) { + return nil, status.Errorf(status.NotFound, "agent network settings not found") +} + +func (*mockManager) UpdateSettings(_ context.Context, _ string, s *types.Settings) (*types.Settings, error) { + return s, nil +} + +func (*mockManager) ListConsumption(_ context.Context, _, _ string) ([]*types.Consumption, error) { + return nil, nil +} + +func (*mockManager) ListAccessLogs(_ context.Context, _, _ string, _ types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error) { + return nil, 0, nil +} + +func (*mockManager) ListAccessLogSessions(_ context.Context, _, _ string, _ types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) { + return nil, 0, nil +} + +func (*mockManager) GetUsageOverview(_ context.Context, _, _ string, _ types.AgentNetworkAccessLogFilter, _ types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) { + return nil, nil +} + +func (*mockManager) StartAccessLogCleanup(_ context.Context, _ int) {} + +func (*mockManager) RecordConsumption(_ context.Context, _ string, _ types.ConsumptionDimension, _ string, _, _, _ int64, _ float64) error { + return nil +} + +func (*mockManager) RecordAccountBudgetUsage(_ context.Context, _, _ string, _ []string, _, _ int64, _ float64) error { + return nil +} + +func (*mockManager) RecordUsage(_ context.Context, _ RecordUsageInput) error { + return nil +} diff --git a/management/internals/modules/agentnetwork/policyselect.go b/management/internals/modules/agentnetwork/policyselect.go new file mode 100644 index 000000000..9203a1910 --- /dev/null +++ b/management/internals/modules/agentnetwork/policyselect.go @@ -0,0 +1,660 @@ +package agentnetwork + +import ( + "context" + "fmt" + "math" + "sort" + "time" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/status" +) + +// validateUsageDeltas rejects negative or non-finite usage counters before they +// reach the consumption store, so a bad delta can't decrement or poison totals. +// The store batch method enforces the same invariant; this is the manager-level +// guard so direct callers fail fast with a clear error. +func validateUsageDeltas(tokensIn, tokensOut int64, costUSD float64) error { + if tokensIn < 0 || tokensOut < 0 || costUSD < 0 || math.IsNaN(costUSD) || math.IsInf(costUSD, 0) { + return status.Errorf(status.InvalidArgument, "usage deltas must be non-negative and finite") + } + return nil +} + +// Deny codes the proxy surfaces back to the caller when every +// applicable policy is exhausted. The proxy converts these into +// upstream-shaped error responses. +const ( + //nolint:gosec // policy deny code label, not a credential + denyCodeTokenCapExceeded = "llm_policy.token_cap_exceeded" + //nolint:gosec // policy deny code label, not a credential + denyCodeBudgetCapExceeded = "llm_policy.budget_cap_exceeded" + //nolint:gosec // account deny code label, not a credential + denyCodeAccountTokenCapExceeded = "llm_account.token_cap_exceeded" + //nolint:gosec // account deny code label, not a credential + denyCodeAccountBudgetCapExceeded = "llm_account.budget_cap_exceeded" +) + +// consumptionCache holds the consumption counters prefetched for one +// policy-selection request, keyed by ConsumptionKey. A miss returns a zero +// counter — the same contract the store's single-row getter uses for absent +// rows — so the eval logic is identical whether a counter exists yet or not. +type consumptionCache map[types.ConsumptionKey]*types.Consumption + +func (c consumptionCache) get(accountID string, kind types.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time) *types.Consumption { + key := types.ConsumptionKey{Kind: kind, DimID: dimID, WindowSeconds: windowSeconds, WindowStartUTC: windowStart.UTC()} + if row, ok := c[key]; ok && row != nil { + return row + } + return &types.Consumption{ + AccountID: accountID, + DimensionKind: kind, + DimensionID: dimID, + WindowSeconds: windowSeconds, + WindowStartUTC: windowStart.UTC(), + } +} + +// addLimitKeys records the user/group consumption keys a single enabled (token +// or budget) limit window reads for the given attribution group, into a dedup +// set. attrGroup may be empty (no group dimension applies). +func addLimitKeys(set map[types.ConsumptionKey]struct{}, userID, attrGroup string, windowSeconds int64, now time.Time) { + if windowSeconds <= 0 { + return + } + ws := types.WindowStart(now, windowSeconds) + if userID != "" { + set[types.ConsumptionKey{Kind: types.DimensionUser, DimID: userID, WindowSeconds: windowSeconds, WindowStartUTC: ws}] = struct{}{} + } + if attrGroup != "" { + set[types.ConsumptionKey{Kind: types.DimensionGroup, DimID: attrGroup, WindowSeconds: windowSeconds, WindowStartUTC: ws}] = struct{}{} + } +} + +// prefetchConsumption loads, in one store round-trip, every consumption counter +// that the account-budget ceiling and the candidate policies will read while +// scoring this request. This replaces the per-cap point reads the selector +// previously issued one at a time (the N+1 on the hot path). +func (m *managerImpl) prefetchConsumption(ctx context.Context, in PolicySelectionInput, rules []*types.AccountBudgetRule, candidates []*types.Policy, now time.Time) (consumptionCache, error) { + set := make(map[types.ConsumptionKey]struct{}) + for _, p := range candidates { + attr := lowestIntersect(p.SourceGroups, in.GroupIDs) + if p.Limits.TokenLimit.Enabled { + addLimitKeys(set, in.UserID, attr, p.Limits.TokenLimit.WindowSeconds, now) + } + if p.Limits.BudgetLimit.Enabled { + addLimitKeys(set, in.UserID, attr, p.Limits.BudgetLimit.WindowSeconds, now) + } + } + for _, r := range rules { + if r == nil || !r.Enabled || !budgetRuleApplies(r, in) { + continue + } + attr := lowestIntersect(r.TargetGroups, in.GroupIDs) + if r.Limits.TokenLimit.Enabled { + addLimitKeys(set, in.UserID, attr, r.Limits.TokenLimit.WindowSeconds, now) + } + if r.Limits.BudgetLimit.Enabled { + addLimitKeys(set, in.UserID, attr, r.Limits.BudgetLimit.WindowSeconds, now) + } + } + if len(set) == 0 { + return consumptionCache{}, nil + } + keys := make([]types.ConsumptionKey, 0, len(set)) + for k := range set { + keys = append(keys, k) + } + rows, err := m.store.GetAgentNetworkConsumptionBatch(ctx, store.LockingStrengthNone, in.AccountID, keys) + if err != nil { + return nil, fmt.Errorf("batch read consumption: %w", err) + } + return consumptionCache(rows), nil +} + +// SelectPolicyForRequest picks the policy that "pays" for the +// incoming request. The chosen policy is the one with the largest +// pool that still has headroom — drain the bigger bucket first, +// fall through to the next-biggest only when the current one's +// group cap or shared per-user cap is exhausted. This matches +// operator intuition for layered tiers ("privileged group has the +// 10k budget, regular group has 1k as the safety net") and avoids +// the load-balancer flapping that fraction-based scoring produces +// once any cap has been touched. +// +// Ordering across non-exhausted candidates: +// 1. Policies with NO enabled caps (catch-all-allow) win over any +// capped policy — operators who configure unlimited access +// expect requests to attribute there until they explicitly add +// caps. +// 2. Larger group token cap wins. +// 3. Larger group budget USD cap wins. +// 4. Larger user token cap wins. +// 5. Larger user budget USD cap wins. +// 6. Older created_at wins (deterministic final tiebreak so +// multi-node selection converges). +// +// Returns Allow=true with empty SelectedPolicyID when no policy in +// the account targets the (provider, caller-groups) combination — +// llm_router is the gate that owns "no policy authorises this +// request" semantics; this function trusts that authorisation has +// already happened upstream and only does the limit-aware +// attribution. +func (m *managerImpl) SelectPolicyForRequest(ctx context.Context, in PolicySelectionInput) (*PolicySelectionResult, error) { + if in.AccountID == "" { + return nil, status.Errorf(status.InvalidArgument, "account_id is required") + } + + now := time.Now().UTC() + + rules, err := m.store.GetAccountAgentNetworkBudgetRules(ctx, store.LockingStrengthNone, in.AccountID) + if err != nil { + return nil, fmt.Errorf("list account budget rules: %w", err) + } + policies, err := m.store.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, in.AccountID) + if err != nil { + return nil, fmt.Errorf("list account policies: %w", err) + } + candidates := filterApplicablePolicies(policies, in) + + // Prefetch every consumption counter the ceiling + candidate policies will + // read, in a single store round-trip, then score against the cache. + cache, err := m.prefetchConsumption(ctx, in, rules, candidates, now) + if err != nil { + return nil, err + } + + // Account-level budget rules are an always-on ceiling, evaluated + // independently of policy selection (they bind even for catch-all-allow + // policies or requests that match no policy). All applicable rules must + // pass — this is where min-wins lives. + if deny, code, reason := checkAccountBudget(in, rules, cache, now); deny { + return &PolicySelectionResult{Allow: false, DenyCode: code, DenyReason: reason}, nil + } + + if len(candidates) == 0 { + return &PolicySelectionResult{Allow: true}, nil + } + scored, lastDenyCode, lastDenyReason := scoreCandidates(in, candidates, cache, now) + if len(scored) == 0 { + return &PolicySelectionResult{ + Allow: false, + DenyCode: lastDenyCode, + DenyReason: lastDenyReason, + }, nil + } + + sort.SliceStable(scored, func(i, j int) bool { + // Catch-all-allow (no caps configured) wins outright over + // any capped policy. + iNoCap := isUncapped(scored[i].policy) + jNoCap := isUncapped(scored[j].policy) + if iNoCap != jNoCap { + return iNoCap + } + // Bigger pool drains first. Group caps dominate (shared + // across the group) before individual caps. + if a, b := groupCapTokens(scored[i].policy), groupCapTokens(scored[j].policy); a != b { + return a > b + } + if a, b := groupCapBudgetUsd(scored[i].policy), groupCapBudgetUsd(scored[j].policy); a != b { + return a > b + } + if a, b := userCapTokens(scored[i].policy), userCapTokens(scored[j].policy); a != b { + return a > b + } + if a, b := userCapBudgetUsd(scored[i].policy), userCapBudgetUsd(scored[j].policy); a != b { + return a > b + } + return scored[i].policy.CreatedAt.Before(scored[j].policy.CreatedAt) + }) + + winner := scored[0] + return &PolicySelectionResult{ + Allow: true, + SelectedPolicyID: winner.policy.ID, + AttributionGroupID: winner.attributionGroup, + WindowSeconds: winner.windowSeconds, + }, nil +} + +// filterApplicablePolicies returns the enabled policies that target +// the requested provider and have at least one of the caller's groups +// in their source_groups. Caller's group set is matched +// case-sensitively against policy.SourceGroups. +func filterApplicablePolicies(policies []*types.Policy, in PolicySelectionInput) []*types.Policy { + if len(policies) == 0 { + return nil + } + groupSet := make(map[string]struct{}, len(in.GroupIDs)) + for _, g := range in.GroupIDs { + if g != "" { + groupSet[g] = struct{}{} + } + } + out := make([]*types.Policy, 0, len(policies)) + for _, p := range policies { + if p == nil || !p.Enabled { + continue + } + if !sliceContains(p.DestinationProviderIDs, in.ProviderID) { + continue + } + if !anyGroupMatches(p.SourceGroups, groupSet) { + continue + } + out = append(out, p) + } + return out +} + +// candidate is the per-policy intermediate the selector ranks. A +// policy that's been exhausted on any enabled cap never makes it +// into this slice; the selector's deny envelope carries the latest +// exhaustion's reason out separately. +type candidate struct { + policy *types.Policy + attributionGroup string + windowSeconds int64 +} + +// scoreCandidates evaluates every applicable policy against the +// caller's current consumption. Exhausted policies are filtered out +// of the returned slice; the most recent exhaustion's deny code + +// human reason is returned alongside so the caller can surface it +// when no candidate survives. +func scoreCandidates( + in PolicySelectionInput, + candidates []*types.Policy, + cache consumptionCache, + now time.Time, +) ([]candidate, string, string) { + out := make([]candidate, 0, len(candidates)) + var lastDenyCode, lastDenyReason string + + for _, p := range candidates { + c, exhausted, denyCode, denyReason := scoreOne(in, p, cache, now) + if exhausted { + lastDenyCode = denyCode + lastDenyReason = denyReason + continue + } + out = append(out, c) + } + return out, lastDenyCode, lastDenyReason +} + +// scoreOne checks a single policy for cap exhaustion. Returns the +// candidate envelope when the policy still has headroom on every +// enabled cap; reports exhausted=true with a deny code naming the +// offending cap kind otherwise. +func scoreOne( + in PolicySelectionInput, + p *types.Policy, + cache consumptionCache, + now time.Time, +) (candidate, bool, string, string) { + attrGroup := lowestIntersect(p.SourceGroups, in.GroupIDs) + c := candidate{ + policy: p, + attributionGroup: attrGroup, + windowSeconds: effectiveWindowSeconds(p), + } + + if p.Limits.TokenLimit.Enabled && p.Limits.TokenLimit.WindowSeconds > 0 { + if exhausted, reason := evalTokenCap(cache, in.AccountID, in.UserID, attrGroup, p.Limits.TokenLimit, now, "policy "+p.ID); exhausted { + return candidate{}, true, denyCodeTokenCapExceeded, reason + } + } + + if p.Limits.BudgetLimit.Enabled && p.Limits.BudgetLimit.WindowSeconds > 0 { + if exhausted, reason := evalBudgetCap(cache, in.AccountID, in.UserID, attrGroup, p.Limits.BudgetLimit, now, "policy "+p.ID); exhausted { + return candidate{}, true, denyCodeBudgetCapExceeded, reason + } + } + + return c, false, "", "" +} + +// evalTokenCap reports whether the token limit is already exhausted for the +// caller in its own window. attrGroup may be empty (no group dimension applies). +// label identifies the cap source ("policy " or "account rule ") for the +// deny reason. It is the shared primitive behind both policy and account-rule +// enforcement. +func evalTokenCap( + cache consumptionCache, + accountID, userID, attrGroup string, + tl types.PolicyTokenLimit, + now time.Time, + label string, +) (bool, string) { + windowStart := types.WindowStart(now, tl.WindowSeconds) + + if tl.UserCap > 0 && userID != "" { + row := cache.get(accountID, types.DimensionUser, userID, tl.WindowSeconds, windowStart) + used := row.TokensInput + row.TokensOutput + if used >= tl.UserCap { + return true, fmt.Sprintf("user token cap exhausted on %s (used %d of %d)", label, used, tl.UserCap) + } + } + + if tl.GroupCap > 0 && attrGroup != "" { + row := cache.get(accountID, types.DimensionGroup, attrGroup, tl.WindowSeconds, windowStart) + used := row.TokensInput + row.TokensOutput + if used >= tl.GroupCap { + return true, fmt.Sprintf("group token cap exhausted on %s (used %d of %d)", label, used, tl.GroupCap) + } + } + + return false, "" +} + +// evalBudgetCap is the budget (USD) counterpart of evalTokenCap. +func evalBudgetCap( + cache consumptionCache, + accountID, userID, attrGroup string, + bl types.PolicyBudgetLimit, + now time.Time, + label string, +) (bool, string) { + windowStart := types.WindowStart(now, bl.WindowSeconds) + + if bl.UserCapUsd > 0 && userID != "" { + row := cache.get(accountID, types.DimensionUser, userID, bl.WindowSeconds, windowStart) + if row.CostUSD >= bl.UserCapUsd { + return true, fmt.Sprintf("user budget cap exhausted on %s (used $%.4f of $%.4f)", label, row.CostUSD, bl.UserCapUsd) + } + } + + if bl.GroupCapUsd > 0 && attrGroup != "" { + row := cache.get(accountID, types.DimensionGroup, attrGroup, bl.WindowSeconds, windowStart) + if row.CostUSD >= bl.GroupCapUsd { + return true, fmt.Sprintf("group budget cap exhausted on %s (used $%.4f of $%.4f)", label, row.CostUSD, bl.GroupCapUsd) + } + } + + return false, "" +} + +// checkAccountBudget evaluates every applicable account-level budget rule as an +// all-must-pass ceiling. A rule applies when the caller is in its TargetUsers, +// one of its TargetGroups, or it has no targets at all (account-wide). Returns +// deny=true with an llm_account.* code on the first exhausted rule. Group caps +// attribute to the lowest intersecting group (the same model policies use), so +// multi-group behavior is unchanged. +func checkAccountBudget(in PolicySelectionInput, rules []*types.AccountBudgetRule, cache consumptionCache, now time.Time) (bool, string, string) { + for _, r := range rules { + if r == nil || !r.Enabled || !budgetRuleApplies(r, in) { + continue + } + attrGroup := lowestIntersect(r.TargetGroups, in.GroupIDs) + label := "account rule " + r.ID + + if r.Limits.TokenLimit.Enabled && r.Limits.TokenLimit.WindowSeconds > 0 { + if exhausted, reason := evalTokenCap(cache, in.AccountID, in.UserID, attrGroup, r.Limits.TokenLimit, now, label); exhausted { + return true, denyCodeAccountTokenCapExceeded, reason + } + } + + if r.Limits.BudgetLimit.Enabled && r.Limits.BudgetLimit.WindowSeconds > 0 { + if exhausted, reason := evalBudgetCap(cache, in.AccountID, in.UserID, attrGroup, r.Limits.BudgetLimit, now, label); exhausted { + return true, denyCodeAccountBudgetCapExceeded, reason + } + } + } + + return false, "", "" +} + +// budgetRuleApplies reports whether an account budget rule binds the caller: +// a direct user match, a group intersection, or an untargeted (account-wide) +// rule. +func budgetRuleApplies(r *types.AccountBudgetRule, in PolicySelectionInput) bool { + if len(r.TargetUsers) == 0 && len(r.TargetGroups) == 0 { + return true + } + if in.UserID != "" && sliceContains(r.TargetUsers, in.UserID) { + return true + } + groupSet := make(map[string]struct{}, len(in.GroupIDs)) + for _, g := range in.GroupIDs { + if g != "" { + groupSet[g] = struct{}{} + } + } + return anyGroupMatches(r.TargetGroups, groupSet) +} + +// RecordAccountBudgetUsage fans the served request's usage out to every +// applicable account budget rule's own (dimension, window) counter. The user +// dimension is always booked when a rule has a user-applicable cap; the group +// dimension books against the rule's lowest intersecting group. This runs +// alongside the policy-window record so account ceilings accumulate in their own +// windows (commonly monthly) independently of the per-policy window. +func (m *managerImpl) RecordAccountBudgetUsage(ctx context.Context, accountID, userID string, groupIDs []string, tokensIn, tokensOut int64, costUSD float64) error { + if accountID == "" { + return status.Errorf(status.InvalidArgument, "account_id is required") + } + if err := validateUsageDeltas(tokensIn, tokensOut, costUSD); err != nil { + return err + } + rules, err := m.store.GetAccountAgentNetworkBudgetRules(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return fmt.Errorf("list account budget rules: %w", err) + } + set := make(map[types.ConsumptionKey]struct{}) + addAccountBudgetKeys(set, PolicySelectionInput{AccountID: accountID, UserID: userID, GroupIDs: groupIDs}, rules, time.Now().UTC()) + if len(set) == 0 { + return nil + } + return m.store.IncrementAgentNetworkConsumptionBatch(ctx, accountID, keysSlice(set), tokensIn, tokensOut, costUSD) +} + +// RecordUsageInput carries everything RecordUsage books for one served request. +type RecordUsageInput struct { + AccountID string + UserID string + AttributionGroupID string // selected policy's attribution group (policy window) + GroupIDs []string + WindowSeconds int64 // selected policy's window; 0 means no policy cap + TokensIn int64 + TokensOut int64 + CostUSD float64 +} + +// RecordUsage books a served request's usage against every counter it touches — +// the selected policy's per-(user, group) window plus every applicable account +// budget rule's own window — deduplicated and written in a single transaction. +// Two counters that collapse to the same (dimension, window) tuple are booked +// once, so a single request can never double-count against one cap. +func (m *managerImpl) RecordUsage(ctx context.Context, in RecordUsageInput) error { + if in.AccountID == "" { + return status.Errorf(status.InvalidArgument, "account_id is required") + } + if err := validateUsageDeltas(in.TokensIn, in.TokensOut, in.CostUSD); err != nil { + return err + } + now := time.Now().UTC() + set := make(map[types.ConsumptionKey]struct{}) + + // Policy-window dimensions are booked only when a policy cap bound this + // request (window > 0). A zero window means catch-all-allow / no policy cap; + // the account fan-out below still books against the budget rules' windows. + if in.WindowSeconds > 0 { + addLimitKeys(set, in.UserID, in.AttributionGroupID, in.WindowSeconds, now) + } + + rules, err := m.store.GetAccountAgentNetworkBudgetRules(ctx, store.LockingStrengthNone, in.AccountID) + if err != nil { + return fmt.Errorf("list account budget rules: %w", err) + } + addAccountBudgetKeys(set, PolicySelectionInput{AccountID: in.AccountID, UserID: in.UserID, GroupIDs: in.GroupIDs}, rules, now) + + if len(set) == 0 { + return nil + } + return m.store.IncrementAgentNetworkConsumptionBatch(ctx, in.AccountID, keysSlice(set), in.TokensIn, in.TokensOut, in.CostUSD) +} + +// addAccountBudgetKeys adds the (dimension, window) keys a served request books +// against every applicable account budget rule into the dedup set. +func addAccountBudgetKeys(set map[types.ConsumptionKey]struct{}, in PolicySelectionInput, rules []*types.AccountBudgetRule, now time.Time) { + for _, r := range rules { + if r == nil || !r.Enabled || !budgetRuleApplies(r, in) { + continue + } + attrGroup := lowestIntersect(r.TargetGroups, in.GroupIDs) + for _, window := range ruleWindows(r) { + addLimitKeys(set, in.UserID, attrGroup, window, now) + } + } +} + +// keysSlice flattens a ConsumptionKey set into a slice. +func keysSlice(set map[types.ConsumptionKey]struct{}) []types.ConsumptionKey { + keys := make([]types.ConsumptionKey, 0, len(set)) + for k := range set { + keys = append(keys, k) + } + return keys +} + +// ruleWindows returns the distinct enabled window lengths a budget rule books +// against (token window and/or budget window, deduplicated). +func ruleWindows(r *types.AccountBudgetRule) []int64 { + var windows []int64 + if r.Limits.TokenLimit.Enabled && r.Limits.TokenLimit.WindowSeconds > 0 { + windows = append(windows, r.Limits.TokenLimit.WindowSeconds) + } + if r.Limits.BudgetLimit.Enabled && r.Limits.BudgetLimit.WindowSeconds > 0 { + bw := r.Limits.BudgetLimit.WindowSeconds + if len(windows) == 0 || windows[0] != bw { + windows = append(windows, bw) + } + } + return windows +} + +// effectiveWindowSeconds returns the window length the proxy should +// hand back to RecordLLMUsage. When both halves are enabled with +// different windows, token_limit wins (the more common config); when +// only one is enabled that one wins; when neither is enabled the +// returned value is 0 — RecordLLMUsage treats 0 as "no limit +// tracking" and skips the increment, which is the right pass-through +// for catch-all-allow policies with no caps configured. +func effectiveWindowSeconds(p *types.Policy) int64 { + if p.Limits.TokenLimit.Enabled && p.Limits.TokenLimit.WindowSeconds > 0 { + return p.Limits.TokenLimit.WindowSeconds + } + if p.Limits.BudgetLimit.Enabled && p.Limits.BudgetLimit.WindowSeconds > 0 { + return p.Limits.BudgetLimit.WindowSeconds + } + return 0 +} + +// lowestIntersect returns the lowest-by-string-sort element of +// callerGroups ∩ sourceGroups. Empty when the intersection is empty. +// Lowest is deterministic so multi-node selection converges. +func lowestIntersect(sourceGroups, callerGroups []string) string { + if len(sourceGroups) == 0 || len(callerGroups) == 0 { + return "" + } + srcSet := make(map[string]struct{}, len(sourceGroups)) + for _, g := range sourceGroups { + srcSet[g] = struct{}{} + } + var best string + for _, g := range callerGroups { + if _, ok := srcSet[g]; !ok { + continue + } + if best == "" || g < best { + best = g + } + } + return best +} + +func anyGroupMatches(sourceGroups []string, callerSet map[string]struct{}) bool { + for _, g := range sourceGroups { + if _, ok := callerSet[g]; ok { + return true + } + } + return false +} + +// isUncapped reports whether a policy has any enabled cap with a +// positive limit value. Mirrors the eval functions' guards: a policy +// with token_limit.enabled=true but every cap value at 0 still +// counts as uncapped because the eval would query nothing and bind +// nothing. +func isUncapped(p *types.Policy) bool { + tl := p.Limits.TokenLimit + if tl.Enabled && tl.WindowSeconds > 0 && (tl.GroupCap > 0 || tl.UserCap > 0) { + return false + } + bl := p.Limits.BudgetLimit + if bl.Enabled && bl.WindowSeconds > 0 && (bl.GroupCapUsd > 0 || bl.UserCapUsd > 0) { + return false + } + return true +} + +// groupCapTokens returns the policy's group-token cap when the token +// limit is enabled, zero otherwise. Drives the primary "bigger pool +// first" sort. +func groupCapTokens(p *types.Policy) int64 { + if p.Limits.TokenLimit.Enabled { + return p.Limits.TokenLimit.GroupCap + } + return 0 +} + +// groupCapBudgetUsd returns the policy's group-budget cap in USD +// when the budget limit is enabled, zero otherwise. Secondary sort +// key after token group cap so budget-only policies still order +// predictably. +func groupCapBudgetUsd(p *types.Policy) float64 { + if p.Limits.BudgetLimit.Enabled { + return p.Limits.BudgetLimit.GroupCapUsd + } + return 0 +} + +// userCapTokens returns the policy's per-user token cap when the +// token limit is enabled, zero otherwise. Tertiary sort key, used +// when group caps tie or are absent. +func userCapTokens(p *types.Policy) int64 { + if p.Limits.TokenLimit.Enabled { + return p.Limits.TokenLimit.UserCap + } + return 0 +} + +// userCapBudgetUsd returns the policy's per-user budget cap in USD +// when the budget limit is enabled, zero otherwise. Quaternary sort +// key for budget-only policies whose group caps tie or are absent. +func userCapBudgetUsd(p *types.Policy) float64 { + if p.Limits.BudgetLimit.Enabled { + return p.Limits.BudgetLimit.UserCapUsd + } + return 0 +} + +func sliceContains(haystack []string, needle string) bool { + for _, v := range haystack { + if v == needle { + return true + } + } + return false +} + +// mockManager fallback so tests that don't care about selection still +// compile. +func (*mockManager) SelectPolicyForRequest(_ context.Context, _ PolicySelectionInput) (*PolicySelectionResult, error) { + return &PolicySelectionResult{Allow: true}, nil +} diff --git a/management/internals/modules/agentnetwork/policyselect_account_realstore_test.go b/management/internals/modules/agentnetwork/policyselect_account_realstore_test.go new file mode 100644 index 000000000..c3b13a6cc --- /dev/null +++ b/management/internals/modules/agentnetwork/policyselect_account_realstore_test.go @@ -0,0 +1,181 @@ +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" +) + +// GC-2 no-mock enforcement tests for the account-budget ceiling. They drive the +// real store + real consumption accounting through SelectPolicyForRequest and +// RecordAccountBudgetUsage, asserting min-wins (account binds independently of +// policy), targeting (groups + direct users), and the record fan-out. + +func accountWideUserTokenRule(id string, userCap, window int64) *types.AccountBudgetRule { + r := types.NewAccountBudgetRule(realSelectAccount) + r.ID = id + r.Limits.TokenLimit = types.PolicyTokenLimit{Enabled: true, UserCap: userCap, WindowSeconds: window} + return r +} + +// TestSelectPolicy_RealStore_AccountCeilingBindsEvenWithUncappedPolicy proves +// min-wins: the account user ceiling denies once exhausted even though a +// catch-all-allow (uncapped) policy would otherwise pass the request. The +// account gate runs independently of and ahead of policy selection. +func TestSelectPolicy_RealStore_AccountCeilingBindsEvenWithUncappedPolicy(t *testing.T) { + mgr, s := newRealSelectorMgr(t) + ctx := context.Background() + + // An uncapped (catch-all-allow) policy: enabled token limit, zero caps. + uncapped := capPolicy("pol-open", realSelectAccount, []string{"grp-eng"}, "prov-1", 0, 86_400) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, uncapped)) + + // Account-wide user ceiling of 100 tokens in an hourly window. + require.NoError(t, s.SaveAgentNetworkBudgetRule(ctx, accountWideUserTokenRule("ainbud-1", 100, 3_600))) + + in := PolicySelectionInput{AccountID: realSelectAccount, UserID: "user-1", GroupIDs: []string{"grp-eng"}, ProviderID: "prov-1"} + + // Fresh: account ceiling has headroom, uncapped policy wins. + res, err := mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + assert.True(t, res.Allow, "fresh account ceiling must allow") + + // Drain the account user ceiling via the fan-out path. + require.NoError(t, mgr.RecordAccountBudgetUsage(ctx, realSelectAccount, "user-1", []string{"grp-eng"}, 100, 0, 0)) + + res, err = mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + assert.False(t, res.Allow, "account ceiling must deny even though the policy is uncapped (min-wins)") + assert.Equal(t, denyCodeAccountTokenCapExceeded, res.DenyCode, "deny must carry the llm_account.* code") +} + +// TestSelectPolicy_RealStore_AccountGroupCeiling proves a group-targeted rule +// binds the caller's group dimension. +func TestSelectPolicy_RealStore_AccountGroupCeiling(t *testing.T) { + mgr, s := newRealSelectorMgr(t) + ctx := context.Background() + + rule := types.NewAccountBudgetRule(realSelectAccount) + rule.ID = "ainbud-grp" + rule.TargetGroups = []string{"grp-eng"} + rule.Limits.BudgetLimit = types.PolicyBudgetLimit{Enabled: true, GroupCapUsd: 5.0, WindowSeconds: 2_592_000} + require.NoError(t, s.SaveAgentNetworkBudgetRule(ctx, rule)) + + in := PolicySelectionInput{AccountID: realSelectAccount, UserID: "user-1", GroupIDs: []string{"grp-eng"}, ProviderID: "prov-1"} + + res, err := mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + assert.True(t, res.Allow, "fresh group ceiling must allow") + + require.NoError(t, mgr.RecordAccountBudgetUsage(ctx, realSelectAccount, "user-1", []string{"grp-eng"}, 0, 0, 5.0)) + + res, err = mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + assert.False(t, res.Allow, "group budget ceiling must deny once spent") + assert.Equal(t, denyCodeAccountBudgetCapExceeded, res.DenyCode, "account budget deny code") +} + +// TestSelectPolicy_RealStore_AccountTargetUsersBindsOnlyThatUser proves a +// TargetUsers rule tightens only the named user, leaving others unbound. +func TestSelectPolicy_RealStore_AccountTargetUsersBindsOnlyThatUser(t *testing.T) { + mgr, s := newRealSelectorMgr(t) + ctx := context.Background() + + rule := types.NewAccountBudgetRule(realSelectAccount) + rule.ID = "ainbud-alice" + rule.TargetUsers = []string{"alice"} + rule.Limits.TokenLimit = types.PolicyTokenLimit{Enabled: true, UserCap: 100, WindowSeconds: 3_600} + require.NoError(t, s.SaveAgentNetworkBudgetRule(ctx, rule)) + + // Record alice's usage to the rule window. + require.NoError(t, mgr.RecordAccountBudgetUsage(ctx, realSelectAccount, "alice", nil, 100, 0, 0)) + + aliceIn := PolicySelectionInput{AccountID: realSelectAccount, UserID: "alice", ProviderID: "prov-1"} + res, err := mgr.SelectPolicyForRequest(ctx, aliceIn) + require.NoError(t, err) + assert.False(t, res.Allow, "alice is bound by the TargetUsers rule and is exhausted") + + bobIn := PolicySelectionInput{AccountID: realSelectAccount, UserID: "bob", ProviderID: "prov-1"} + res, err = mgr.SelectPolicyForRequest(ctx, bobIn) + require.NoError(t, err) + assert.True(t, res.Allow, "bob is not in TargetUsers, so the rule must not bind him") +} + +// TestSelectPolicy_RealStore_AccountRuleRecordsToOwnWindow proves the record +// fan-out books usage in the rule's own window (distinct from any policy +// window), so the account ceiling accumulates independently. +func TestSelectPolicy_RealStore_AccountRuleRecordsToOwnWindow(t *testing.T) { + mgr, s := newRealSelectorMgr(t) + ctx := context.Background() + + require.NoError(t, s.SaveAgentNetworkBudgetRule(ctx, accountWideUserTokenRule("ainbud-w", 100, 3_600))) + + require.NoError(t, mgr.RecordAccountBudgetUsage(ctx, realSelectAccount, "user-1", nil, 60, 0, 0)) + + // Same user, a policy-style daily window must NOT see the account-window + // usage — windows are independent counters. + dailyRow, err := s.GetAgentNetworkConsumption(ctx, store.LockingStrengthNone, realSelectAccount, types.DimensionUser, "user-1", 86_400, types.WindowStart(time.Now().UTC(), 86_400)) + require.NoError(t, err) + assert.Equal(t, int64(0), dailyRow.TokensInput+dailyRow.TokensOutput, "daily window must be untouched by the hourly account-rule record") + + // A second record pushes the hourly account window to its cap → deny. + require.NoError(t, mgr.RecordAccountBudgetUsage(ctx, realSelectAccount, "user-1", nil, 40, 0, 0)) + res, err := mgr.SelectPolicyForRequest(ctx, PolicySelectionInput{AccountID: realSelectAccount, UserID: "user-1", ProviderID: "prov-1"}) + require.NoError(t, err) + assert.False(t, res.Allow, "100 tokens recorded in the rule's hourly window must exhaust the 100-token ceiling") + assert.Equal(t, denyCodeAccountTokenCapExceeded, res.DenyCode, "account token deny code") +} + +// TestRecordUsage_RealStore_BooksPolicyAndAccountWindows proves the batched +// post-flight write books the selected policy's window AND every applicable +// account rule's (independent) window in a single call — the #6 batched-write +// path the proxy's RecordLLMUsage RPC now uses. +func TestRecordUsage_RealStore_BooksPolicyAndAccountWindows(t *testing.T) { + mgr, s := newRealSelectorMgr(t) + ctx := context.Background() + + // Policy: 100-token group cap on a daily window. Account rule: 100-token + // user ceiling on an hourly window — an independent counter. + policy := capPolicy("pol-1", realSelectAccount, []string{"grp-eng"}, "prov-1", 100, 86_400) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, policy)) + require.NoError(t, s.SaveAgentNetworkBudgetRule(ctx, accountWideUserTokenRule("ainbud-1", 100, 3_600))) + + in := PolicySelectionInput{AccountID: realSelectAccount, UserID: "user-1", GroupIDs: []string{"grp-eng"}, ProviderID: "prov-1"} + res, err := mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + require.True(t, res.Allow) + require.Equal(t, "pol-1", res.SelectedPolicyID) + + // One batched record books the policy window (group + user @86400) and the + // account rule window (user @3600) atomically. + require.NoError(t, mgr.RecordUsage(ctx, RecordUsageInput{ + AccountID: realSelectAccount, + UserID: "user-1", + AttributionGroupID: res.AttributionGroupID, + GroupIDs: []string{"grp-eng"}, + WindowSeconds: res.WindowSeconds, + TokensIn: 100, + })) + + // The next selection denies — the account hourly ceiling binds first. + res, err = mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + assert.False(t, res.Allow, "usage booked by RecordUsage must enforce on the next request") + + // Prove BOTH windows were booked in the one call via a direct batch read. + now := time.Now().UTC() + userKey := types.ConsumptionKey{Kind: types.DimensionUser, DimID: "user-1", WindowSeconds: 3_600, WindowStartUTC: types.WindowStart(now, 3_600)} + groupKey := types.ConsumptionKey{Kind: types.DimensionGroup, DimID: "grp-eng", WindowSeconds: 86_400, WindowStartUTC: types.WindowStart(now, 86_400)} + rows, err := s.GetAgentNetworkConsumptionBatch(ctx, store.LockingStrengthNone, realSelectAccount, []types.ConsumptionKey{userKey, groupKey}) + require.NoError(t, err) + require.Contains(t, rows, userKey, "account rule user/hourly window booked") + require.Contains(t, rows, groupKey, "policy group/daily window booked") + assert.Equal(t, int64(100), rows[userKey].TokensInput, "account hourly user counter") + assert.Equal(t, int64(100), rows[groupKey].TokensInput, "policy daily group counter") +} diff --git a/management/internals/modules/agentnetwork/policyselect_realstore_test.go b/management/internals/modules/agentnetwork/policyselect_realstore_test.go new file mode 100644 index 000000000..cc8cfb1e7 --- /dev/null +++ b/management/internals/modules/agentnetwork/policyselect_realstore_test.go @@ -0,0 +1,214 @@ +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" +) + +// This file is the no-mock regression guard for policy limit enforcement. +// policyselect_test.go pins the same behavior through a gomock store with +// explicit call-sequence expectations — brittle precisely where the upcoming +// account-budget work (GC-2) refactors the cap-eval primitive and adds an +// account-level gate. These tests drive the REAL sqlite store + REAL +// consumption accounting and assert observable behavior (allow / deny / +// selection / attribution), not which store methods get called. They must keep +// passing unchanged after GC-2 lands, which is what proves "current behavior is +// not changed." + +const realSelectAccount = "acc-realselect-1" + +// newRealSelectorMgr builds a managerImpl backed by a real sqlite test store. +func newRealSelectorMgr(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 +} + +// TestSelectPolicy_RealStore_NoApplicablePolicies pins the pass-through: +// nothing targets the (provider, groups) combination, so the selector allows +// without attribution or consumption tracking. +func TestSelectPolicy_RealStore_NoApplicablePolicies(t *testing.T) { + mgr, _ := newRealSelectorMgr(t) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: realSelectAccount, + UserID: "user-1", + GroupIDs: []string{"grp-x"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.True(t, res.Allow, "no applicable policy must pass through as allow") + assert.Empty(t, res.SelectedPolicyID, "no selection when nothing applies") +} + +// TestSelectPolicy_RealStore_AllowAndLowestGroupAttribution pins the v1 +// attribution rule (lowest intersecting group by string sort) through the +// real store, with a fresh (zero) consumption row. +func TestSelectPolicy_RealStore_AllowAndLowestGroupAttribution(t *testing.T) { + mgr, s := newRealSelectorMgr(t) + ctx := context.Background() + + p := capPolicy("pol-A", realSelectAccount, []string{"grp-zz", "grp-aa", "grp-mm"}, "prov-1", 10_000, 86_400) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p)) + + res, err := mgr.SelectPolicyForRequest(ctx, PolicySelectionInput{ + AccountID: realSelectAccount, + UserID: "user-1", + GroupIDs: []string{"grp-zz", "grp-aa", "grp-mm"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.True(t, res.Allow, "fresh state under cap must allow") + assert.Equal(t, "pol-A", res.SelectedPolicyID, "only applicable policy must be selected") + assert.Equal(t, "grp-aa", res.AttributionGroupID, "lowest-by-sort intersecting group must win") + assert.Equal(t, int64(86_400), res.WindowSeconds, "selected policy's window must be returned") +} + +// TestSelectPolicy_RealStore_LargerPoolWins_FallsThroughWhenExhausted pins the +// core selection behavior end to end. The two policies bind DISTINCT groups so +// they read separate counters — the only shape where fall-through actually +// yields headroom (policies on the same group share one counter, as +// policyselect_test.go notes). Larger pool wins fresh; after real consumption +// drains the larger group, selection falls through to the smaller; once both +// counters are exhausted the request is denied. +func TestSelectPolicy_RealStore_LargerPoolWins_FallsThroughWhenExhausted(t *testing.T) { + mgr, s := newRealSelectorMgr(t) + ctx := context.Background() + + tight := capPolicy("pol-tight", realSelectAccount, []string{"grp-tight"}, "prov-1", 100, 86_400) + tight.CreatedAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + wide := capPolicy("pol-wide", realSelectAccount, []string{"grp-wide"}, "prov-1", 10_000, 86_400) + wide.CreatedAt = time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, tight)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, wide)) + + // Caller is in both groups, so both policies apply with independent counters. + in := PolicySelectionInput{ + AccountID: realSelectAccount, + UserID: "user-1", + GroupIDs: []string{"grp-tight", "grp-wide"}, + ProviderID: "prov-1", + } + + // Fresh: larger pool wins. + res, err := mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + assert.Equal(t, "pol-wide", res.SelectedPolicyID, "larger pool drains first") + + // Drain only the wide group's counter to its cap. + require.NoError(t, mgr.RecordConsumption(ctx, realSelectAccount, types.DimensionGroup, "grp-wide", 86_400, 10_000, 0, 0)) + + // Wide exhausted, tight's separate counter is fresh → fall through to tight. + res, err = mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + assert.True(t, res.Allow, "tight pool has its own untouched counter") + assert.Equal(t, "pol-tight", res.SelectedPolicyID, "selection falls through to the smaller pool once the larger is exhausted") + + // Drain the tight group's counter too → both exhausted → deny. + require.NoError(t, mgr.RecordConsumption(ctx, realSelectAccount, types.DimensionGroup, "grp-tight", 86_400, 100, 0, 0)) + res, err = mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + assert.False(t, res.Allow, "both group counters exhausted must deny") + assert.Equal(t, denyCodeTokenCapExceeded, res.DenyCode, "deny code names the offending cap kind") +} + +// TestSelectPolicy_RealStore_BudgetCapDenies pins budget (USD) enforcement +// through the real store: once recorded cost reaches the cap, deny. +func TestSelectPolicy_RealStore_BudgetCapDenies(t *testing.T) { + mgr, s := newRealSelectorMgr(t) + ctx := context.Background() + + p := &types.Policy{ + ID: "pol-budget", + AccountID: realSelectAccount, + Enabled: true, + SourceGroups: []string{"grp-eng"}, + DestinationProviderIDs: []string{"prov-1"}, + Limits: types.PolicyLimits{ + BudgetLimit: types.PolicyBudgetLimit{ + Enabled: true, + GroupCapUsd: 5.0, + WindowSeconds: 86_400, + }, + }, + CreatedAt: time.Now().UTC(), + } + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p)) + + in := PolicySelectionInput{ + AccountID: realSelectAccount, + UserID: "user-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + } + + res, err := mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + assert.True(t, res.Allow, "fresh budget must allow") + + require.NoError(t, mgr.RecordConsumption(ctx, realSelectAccount, types.DimensionGroup, "grp-eng", 86_400, 0, 0, 5.0)) + + res, err = mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + assert.False(t, res.Allow, "cost at the cap must deny") + assert.Equal(t, denyCodeBudgetCapExceeded, res.DenyCode, "budget deny code must be surfaced") +} + +// TestSelectPolicy_RealStore_GroupCounterSharedAcrossPolicies pins that two +// policies on the same group+window read one shared consumption counter: usage +// recorded once is visible to both, so exhausting the group budget denies +// regardless of which policy would attribute. +func TestSelectPolicy_RealStore_GroupCounterSharedAcrossPolicies(t *testing.T) { + mgr, s := newRealSelectorMgr(t) + ctx := context.Background() + + a := capPolicy("pol-a", realSelectAccount, []string{"grp-eng"}, "prov-1", 1_000, 86_400) + b := capPolicy("pol-b", realSelectAccount, []string{"grp-eng"}, "prov-1", 1_000, 86_400) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, a)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, b)) + + in := PolicySelectionInput{ + AccountID: realSelectAccount, + UserID: "user-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + } + + require.NoError(t, mgr.RecordConsumption(ctx, realSelectAccount, types.DimensionGroup, "grp-eng", 86_400, 1_000, 0, 0)) + + res, err := mgr.SelectPolicyForRequest(ctx, in) + require.NoError(t, err) + assert.False(t, res.Allow, "shared group counter at cap denies both equal policies") + assert.Equal(t, denyCodeTokenCapExceeded, res.DenyCode, "token deny code on the shared counter") +} + +// TestSelectPolicy_RealStore_DisabledPolicyIgnored pins that a disabled policy +// is invisible to selection even when it otherwise matches. +func TestSelectPolicy_RealStore_DisabledPolicyIgnored(t *testing.T) { + mgr, s := newRealSelectorMgr(t) + ctx := context.Background() + + p := capPolicy("pol-disabled", realSelectAccount, []string{"grp-eng"}, "prov-1", 10_000, 86_400) + p.Enabled = false + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, p)) + + res, err := mgr.SelectPolicyForRequest(ctx, PolicySelectionInput{ + AccountID: realSelectAccount, + UserID: "user-1", + GroupIDs: []string{"grp-eng"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.True(t, res.Allow, "no enabled policy applies → pass-through allow") + assert.Empty(t, res.SelectedPolicyID, "disabled policy must not be selected") +} diff --git a/management/internals/modules/agentnetwork/policyselect_test.go b/management/internals/modules/agentnetwork/policyselect_test.go new file mode 100644 index 000000000..dd7687fe1 --- /dev/null +++ b/management/internals/modules/agentnetwork/policyselect_test.go @@ -0,0 +1,641 @@ +package agentnetwork + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/golang/mock/gomock" + "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" + nbstatus "github.com/netbirdio/netbird/shared/management/status" +) + +func newSelectorMgr(t *testing.T, ctrl *gomock.Controller) (*managerImpl, *store.MockStore) { + t.Helper() + mockStore := store.NewMockStore(ctrl) + // SelectPolicyForRequest evaluates the account-budget ceiling before policy + // selection. These policy-selection tests don't exercise account rules, so + // default to "no rules" — the no-mock policyselect_realstore_test.go covers + // the account gate's behavior end to end. + mockStore.EXPECT(). + GetAccountAgentNetworkBudgetRules(gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, nil). + AnyTimes() + return &managerImpl{store: mockStore}, mockStore +} + +type usedKey struct { + kind types.ConsumptionDimension + dimID string + window int64 +} + +// expectConsumptionBatch stubs the batched consumption read to return the +// supplied per-(kind, dim, window) counters, filling each row's window start +// from the actual request keys so it always matches what the selector computed. +// Keys absent from used resolve to zero counters. +func expectConsumptionBatch(mockStore *store.MockStore, used map[usedKey]*types.Consumption) { + mockStore.EXPECT(). + GetAgentNetworkConsumptionBatch(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ store.LockingStrength, _ string, keys []types.ConsumptionKey) (map[types.ConsumptionKey]*types.Consumption, error) { + out := make(map[types.ConsumptionKey]*types.Consumption) + for _, k := range keys { + if row, ok := used[usedKey{k.Kind, k.DimID, k.WindowSeconds}]; ok { + rc := *row + rc.WindowStartUTC = k.WindowStartUTC + out[k] = &rc + } + } + return out, nil + }). + AnyTimes() +} + +func capPolicy(id, account string, sourceGroups []string, providerID string, tokenCap int64, windowSec int64) *types.Policy { + return &types.Policy{ + ID: id, + AccountID: account, + Enabled: true, + SourceGroups: sourceGroups, + DestinationProviderIDs: []string{providerID}, + Limits: types.PolicyLimits{ + TokenLimit: types.PolicyTokenLimit{ + Enabled: true, + GroupCap: tokenCap, + WindowSeconds: windowSec, + }, + }, + CreatedAt: time.Now().UTC(), + } +} + +// TestSelectPolicy_NoApplicablePolicies covers the pass-through path: +// llm_router authorisation is upstream of selection; when the +// selector finds no policy targeting the (provider, caller-groups) +// combination, it returns Allow with no attribution and lets the +// request continue without consumption tracking. +func TestSelectPolicy_NoApplicablePolicies(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{}, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + UserID: "user-1", + GroupIDs: []string{"grp-x"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.True(t, res.Allow, "no applicable policies = pass-through allow") + assert.Empty(t, res.SelectedPolicyID, "no selection when nothing applies") +} + +// TestSelectPolicy_AllowWithLowestGroupAttribution proves the v1 +// attribution rule: when the caller's groups intersect a policy's +// source_groups in multiple positions, the selector picks the lowest +// group id by string sort so multi-node selection converges. +func TestSelectPolicy_AllowWithLowestGroupAttribution(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policy := capPolicy("pol-A", "acc-1", []string{"grp-zz", "grp-aa", "grp-mm"}, "prov-1", 10_000, 86_400) + + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{policy}, nil) + // Fresh: zero consumption across the board. + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + UserID: "user-1", + GroupIDs: []string{"grp-zz", "grp-aa", "grp-mm"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.True(t, res.Allow) + assert.Equal(t, "pol-A", res.SelectedPolicyID) + assert.Equal(t, "grp-aa", res.AttributionGroupID, + "lowest-by-sort intersection wins so multi-node selection converges") + assert.Equal(t, int64(86_400), res.WindowSeconds) +} + +// TestSelectPolicy_LargerPoolWinsAcrossUsageLevels proves the core +// selection rule: among multiple applicable policies with caps, the +// selector picks the one with the larger absolute pool — at every +// usage level, not just at fresh state. The smaller-pool policy is +// only reached when the larger one is exhausted. This is the +// "drain biggest first" semantic operators expect for layered +// tiers; a fraction-based score would flap between the two as +// soon as one is partially used. +func TestSelectPolicy_LargerPoolWinsAcrossUsageLevels(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + tight := capPolicy("pol-tight", "acc-1", []string{"grp-engineers"}, "prov-1", 100, 86_400) + tight.CreatedAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + wide := capPolicy("pol-wide", "acc-1", []string{"grp-engineers"}, "prov-1", 10_000, 86_400) + wide.CreatedAt = time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{tight, wide}, nil) + + // Both partially used. tight at 50/100 (50% used); wide at + // 50/10000 (0.5% used). Old fraction-based algo would pick wide + // here too — but for the wrong reason ("more relative slack"). + // New algo picks wide because its initial group cap is bigger + // (10000 > 100), and that decision is stable as wide drains. + expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{ + {types.DimensionGroup, "grp-engineers", 86_400}: {TokensInput: 50}, + }) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + UserID: "user-1", + GroupIDs: []string{"grp-engineers"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.Equal(t, "pol-wide", res.SelectedPolicyID, + "the policy with the bigger initial pool wins — operators expect 'drain the privileged tier first', not load-balance across tiers") +} + +// TestSelectPolicy_StaysOnLargerPoolAfterPartialDrain locks the +// stickiness contract reported by operators: with two policies +// where A has a 200-token group cap and B has 150, the very first +// request goes to A AND every subsequent request continues to land +// on A until A's group cap is exhausted — at which point B becomes +// the only candidate. A fraction-based score would flap to B as +// soon as A had any consumption (B's 1.0 fraction beats A's 0.75) +// even though A still has more absolute headroom; that produced +// confusing per-policy attribution ledger entries and stranded +// A's remaining capacity behind B's exhaustion. +func TestSelectPolicy_StaysOnLargerPoolAfterPartialDrain(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policyA := capPolicy("pol-A-200", "acc-1", []string{"grp-engineers"}, "prov-1", 200, 86_400) + policyB := capPolicy("pol-B-150", "acc-1", []string{"grp-engineers"}, "prov-1", 150, 86_400) + + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{policyA, policyB}, nil) + + // A is partially drained (50/200 used = 25% used; 75% headroom + // remaining). B is fresh (0/150). The old fraction-based score + // would pick B here (1.0 > 0.75 fraction); the new pool-size + // score sticks with A (200 > 150 absolute cap). + expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{ + {types.DimensionGroup, "grp-engineers", 86_400}: {TokensInput: 50}, + }) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + UserID: "user-1", + GroupIDs: []string{"grp-engineers"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.Equal(t, "pol-A-200", res.SelectedPolicyID, + "once attribution lands on the bigger pool it must STAY there until exhausted — operators expect 'drain A then B', not 'flip to B as soon as A is touched'") +} + +// TestSelectPolicy_FallsThroughToSmallerPoolWhenLargerExhausted +// proves the second half of the stickiness contract: once the +// larger-pool policy IS exhausted, the smaller one takes over. +// Without this we'd deny on requests the smaller policy is fully +// equipped to serve. +func TestSelectPolicy_FallsThroughToSmallerPoolWhenLargerExhausted(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policyA := capPolicy("pol-A-200", "acc-1", []string{"grp-engineers"}, "prov-1", 200, 86_400) + // B uses a different window length so it has an INDEPENDENT counter — the + // realistic shape for fall-through. On the SAME (group, window) tuple the + // counter is shared, so A's cap of 200 being reached would also exhaust B's + // 150; independent counters are what let A exhaust while B retains headroom. + policyB := capPolicy("pol-B-150", "acc-1", []string{"grp-engineers"}, "prov-1", 150, 3_600) + + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{policyA, policyB}, nil) + + expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{ + {types.DimensionGroup, "grp-engineers", 86_400}: {TokensInput: 200}, // A: 200 >= 200 → exhausted + {types.DimensionGroup, "grp-engineers", 3_600}: {TokensInput: 100}, // B: 100 < 150 → headroom + }) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + UserID: "user-1", + GroupIDs: []string{"grp-engineers"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.Equal(t, "pol-B-150", res.SelectedPolicyID, + "once the bigger pool is exhausted, the smaller one must take over — denying when capacity remains would strand B's allowance") +} + +// TestSelectPolicy_TiebreakByLargerGroupPool covers the user-reported +// bug: an admin in two groups (Users + Admins) where Users is bound +// by a smaller-group-cap policy (50 group, 100 user) and Admins is +// bound by a bigger-group-cap policy (100 group, 20 user) MUST get +// attributed to the Admins policy on the first request. +// +// Without this rule, the fresh-state fraction is 1.0 for both and +// the older policy wins by created_at. The first 24-token request +// then drains the shared user counter past Admins's tight 20-token +// user cap, locking Admins out of selection forever. The 100-token +// Admins group pool ends up stranded while requests pile onto the +// 50-token Users pool — the opposite of what the operator intended +// when they put the bigger pool on the privileged group. +func TestSelectPolicy_TiebreakByLargerGroupPool(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + // Policy A: Users group, smaller group pool, looser per-user cap. + policyA := &types.Policy{ + ID: "pol-Users", + AccountID: "acc-1", + Enabled: true, + SourceGroups: []string{"grp-Users"}, + DestinationProviderIDs: []string{"prov-1"}, + Limits: types.PolicyLimits{ + TokenLimit: types.PolicyTokenLimit{ + Enabled: true, GroupCap: 50, UserCap: 100, WindowSeconds: 86_400, + }, + }, + // Older — would win the legacy created_at tiebreak. + CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } + // Policy B: Admins group, bigger group pool, tighter per-user cap. + policyB := &types.Policy{ + ID: "pol-Admins", + AccountID: "acc-1", + Enabled: true, + SourceGroups: []string{"grp-Admins"}, + DestinationProviderIDs: []string{"prov-1"}, + Limits: types.PolicyLimits{ + TokenLimit: types.PolicyTokenLimit{ + Enabled: true, GroupCap: 100, UserCap: 20, WindowSeconds: 86_400, + }, + }, + CreatedAt: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + } + + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{policyA, policyB}, nil) + // Fresh state: every cap evaluation reads zero usage. + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + UserID: "user-1", + GroupIDs: []string{"grp-Users", "grp-Admins"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.Equal(t, "pol-Admins", res.SelectedPolicyID, + "the bigger group pool wins the fresh-state tiebreak — picking Users first would burn the shared user counter past Admins's tight user cap on the very first request and strand the bigger Admins pool") + assert.Equal(t, "grp-Admins", res.AttributionGroupID) +} + +// TestSelectPolicy_TiebreakByCreatedAt proves the deterministic +// final tiebreak: when two applicable policies have the same +// headroom fraction AND the same group cap (so the larger-pool rule +// can't differentiate either), the older policy wins so attribution +// is stable across replays. +func TestSelectPolicy_TiebreakByCreatedAt(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + older := capPolicy("pol-old", "acc-1", []string{"grp-engineers"}, "prov-1", 1_000, 86_400) + older.CreatedAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + newer := capPolicy("pol-new", "acc-1", []string{"grp-engineers"}, "prov-1", 1_000, 86_400) + newer.CreatedAt = time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{newer, older}, nil) + // Both at zero consumption → identical headroom fraction. + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-engineers"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.Equal(t, "pol-old", res.SelectedPolicyID, + "older policy wins on equal-headroom tiebreak so attribution is stable across replays") +} + +// TestSelectPolicy_DeniesWhenAllExhausted proves the deny envelope: +// when every applicable policy has at least one cap fully exhausted, +// the selector returns Allow=false with the most-recent exhaustion's +// deny code + human reason. The proxy's middleware surfaces this as +// a 403 with the canonical llm_policy.* code. +func TestSelectPolicy_DeniesWhenAllExhausted(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + a := capPolicy("pol-a", "acc-1", []string{"grp-engineers"}, "prov-1", 100, 86_400) + b := capPolicy("pol-b", "acc-1", []string{"grp-engineers"}, "prov-1", 200, 86_400) + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{a, b}, nil) + + // Shared group counter at 200: A (cap 100) and B (cap 200) both exhausted. + expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{ + {types.DimensionGroup, "grp-engineers", 86_400}: {TokensInput: 200}, + }) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-engineers"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.False(t, res.Allow, "every applicable policy exhausted = deny") + assert.Equal(t, denyCodeTokenCapExceeded, res.DenyCode) + assert.Contains(t, res.DenyReason, "token cap exhausted", + "deny reason must name the exhausted cap kind for operator debugging") +} + +// TestSelectPolicy_UncappedPolicyAlwaysWinsAgainstCapped proves the +// catch-all-allow contract: a policy with NO enabled caps wins +// against any capped policy regardless of how much headroom the +// capped one has, because operators who configure unlimited access +// expect requests to attribute there until they explicitly add caps. +func TestSelectPolicy_UncappedPolicyAlwaysWinsAgainstCapped(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + uncapped := &types.Policy{ + ID: "pol-uncapped", + AccountID: "acc-1", + Enabled: true, + SourceGroups: []string{"grp-engineers"}, + DestinationProviderIDs: []string{"prov-1"}, + // All Limits.*.Enabled = false (zero-value). + CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } + wide := capPolicy("pol-wide", "acc-1", []string{"grp-engineers"}, "prov-1", 1_000_000, 86_400) + wide.CreatedAt = time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC) // older than uncapped + + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{uncapped, wide}, nil) + // Only the wide policy reads consumption; uncapped doesn't query + // because it has no enabled caps. + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-engineers"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.Equal(t, "pol-uncapped", res.SelectedPolicyID, + "a no-caps policy must always win selection — that's how operators express 'unlimited access through this path'") + assert.Equal(t, int64(0), res.WindowSeconds, "no caps configured = WindowSeconds=0 so RecordLLMUsage skips counter writes") +} + +// TestSelectPolicy_DisabledPolicyIgnored proves disabled policies +// don't count toward selection — even when they'd otherwise be the +// best match. Operators disable a policy to take it offline; the +// selector must respect that and route through whatever's left. +func TestSelectPolicy_DisabledPolicyIgnored(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + disabled := capPolicy("pol-disabled", "acc-1", []string{"grp-engineers"}, "prov-1", 1_000_000, 86_400) + disabled.Enabled = false + enabled := capPolicy("pol-enabled", "acc-1", []string{"grp-engineers"}, "prov-1", 100, 86_400) + + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{disabled, enabled}, nil) + expectConsumptionBatch(mockStore, nil) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-engineers"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.Equal(t, "pol-enabled", res.SelectedPolicyID, + "disabled policies must be ignored at selection time") +} + +// TestSelectPolicy_StoreErrorPropagates locks the no-fail-open +// contract: a transient store error must surface to the caller, not +// be silently treated as "no policies = allow". A false allow on the +// hot path would let a request slip past every cap. +func TestSelectPolicy_StoreErrorPropagates(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return(nil, errors.New("boom")) + + _, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + }) + require.Error(t, err, "store errors must surface — never fail open on the hot path") +} + +// TestSelectPolicy_RejectsEmptyAccount is the input-validation guard: +// empty account_id is a programmer error and must surface as +// InvalidArgument, not as a silent zero-result lookup. +func TestSelectPolicy_RejectsEmptyAccount(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, _ := newSelectorMgr(t, ctrl) + + _, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{}) + require.Error(t, err) + var sErr *nbstatus.Error + require.True(t, errors.As(err, &sErr)) + assert.Equal(t, nbstatus.InvalidArgument, sErr.Type()) +} + +// TestSelectPolicy_SharesGroupCounterAcrossPolicies locks the +// counter-keying design fork: counters are keyed on (account, +// dim_kind, dim_id, window_hours, window_start) — NOT on policy_id. +// Two policies that target the same group with the SAME window length +// share one bucket: spend booked under policy A is visible to policy +// B's headroom calculation and counts toward B's cap. +// +// This is what makes "operator's per-group enforcement" sane — caps +// describe how much a GROUP can use, not how much each policy owes. +func TestSelectPolicy_SharesGroupCounterAcrossPolicies(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + // Two policies, both targeting grp-engineers + prov-1, same 24h + // window length. Different cap sizes. + policyA := capPolicy("pol-A", "acc-1", []string{"grp-engineers"}, "prov-1", 1_000, 86_400) + policyB := capPolicy("pol-B", "acc-1", []string{"grp-engineers"}, "prov-1", 5_000, 86_400) + + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{policyA, policyB}, nil) + // Both policies query the SAME consumption row — same dim_id, + // same window_hours, same window_start. The mock returns the + // same row for both calls, simulating the shared counter. + expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{ + {types.DimensionGroup, "grp-engineers", 86_400}: {TokensInput: 800}, + }) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-engineers"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + // 800 used → policy A has 200 tokens left of 1000 (20% headroom); + // policy B has 4200 left of 5000 (84% headroom). B wins. + assert.Equal(t, "pol-B", res.SelectedPolicyID, + "the SAME 800 tokens count toward both policies — counters share the (group, window) key, caps differ per policy") +} + +// TestSelectPolicy_AntiFallThroughOnLowestGroup locks the no-fall- +// through behaviour: when a caller is in multiple of a policy's +// source_groups and the lowest-by-sort group is exhausted, we DENY +// rather than fall through to a less-loaded sibling. Per-group caps +// are independent (each group has its own bucket), but attribution +// is one-shot — operators wanting fall-through must split into +// separate policies. +// +// This nails down semantics future contributors might "improve" into +// fall-through behaviour by accident. +func TestSelectPolicy_AntiFallThroughOnLowestGroup(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + // Policy targets two groups; caller is in both. + policy := capPolicy("pol-1", "acc-1", []string{"grp-aaa", "grp-bbb"}, "prov-1", 100, 86_400) + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{policy}, nil) + + // grp-aaa is the lowest by sort → attribution picks it, and the + // prefetch only collects the attribution group's key. We exhaust + // grp-aaa (100/100); grp-bbb's counter is never requested because the + // selector attributes one-shot to the lowest group, so it can't fall + // through to a less-loaded sibling. + expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{ + {types.DimensionGroup, "grp-aaa", 86_400}: {TokensInput: 100}, + }) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-aaa", "grp-bbb"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.False(t, res.Allow, + "lowest-group-by-sort attribution does NOT fall through to a less-loaded sibling — operators wanting fall-through must split into separate policies") + assert.Equal(t, denyCodeTokenCapExceeded, res.DenyCode) + assert.Contains(t, res.DenyReason, "pol-1", + "deny reason names the exhausted policy id so operators can grep it from the access log") +} + +// TestSelectPolicy_BudgetOnlyExhaustionDenies covers the symmetric +// path to TestSelectPolicy_DeniesWhenAllExhausted but for the budget +// cap: a policy with token_limit DISABLED and budget_limit at-cap +// must deny with llm_policy.budget_cap_exceeded (not the token code). +// +// Without this, the budget evaluation path in evalBudgetCap could +// silently regress and we'd still pass DeniesWhenAllExhausted (which +// only exercises tokens). +func TestSelectPolicy_BudgetOnlyExhaustionDenies(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policy := &types.Policy{ + ID: "pol-budget", + AccountID: "acc-1", + Enabled: true, + SourceGroups: []string{"grp-engineers"}, + DestinationProviderIDs: []string{"prov-1"}, + Limits: types.PolicyLimits{ + TokenLimit: types.PolicyTokenLimit{Enabled: false}, + BudgetLimit: types.PolicyBudgetLimit{ + Enabled: true, + GroupCapUsd: 10.00, + WindowSeconds: 86_400, + }, + }, + CreatedAt: time.Now().UTC(), + } + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{policy}, nil) + expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{ + {types.DimensionGroup, "grp-engineers", 86_400}: {CostUSD: 10.50}, // over the $10 cap + }) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-engineers"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.False(t, res.Allow, "budget cap exhausted must deny independently of any token cap state") + assert.Equal(t, denyCodeBudgetCapExceeded, res.DenyCode, + "deny code must be the budget code — token-only deny would silently regress the budget evaluation path") + assert.Contains(t, res.DenyReason, "budget", "deny reason names the budget cap kind for operator debugging") +} + +// TestSelectPolicy_BudgetTighterThanTokenWins is the dual-cap headroom +// fork: when both Token and Budget are enabled on the same policy, +// the SMALLER remaining ratio gates the policy. A policy with +// abundant token headroom but near-zero budget headroom must deny on +// budget, not pass on tokens. +func TestSelectPolicy_BudgetTighterThanTokenWins(t *testing.T) { + ctrl := gomock.NewController(t) + mgr, mockStore := newSelectorMgr(t, ctrl) + + policy := &types.Policy{ + ID: "pol-dual", + AccountID: "acc-1", + Enabled: true, + SourceGroups: []string{"grp-engineers"}, + DestinationProviderIDs: []string{"prov-1"}, + Limits: types.PolicyLimits{ + TokenLimit: types.PolicyTokenLimit{Enabled: true, GroupCap: 10_000_000, WindowSeconds: 86_400}, + BudgetLimit: types.PolicyBudgetLimit{Enabled: true, GroupCapUsd: 1.00, WindowSeconds: 86_400}, + }, + CreatedAt: time.Now().UTC(), + } + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(gomock.Any(), gomock.Any(), "acc-1"). + Return([]*types.Policy{policy}, nil) + // One shared counter carries both token usage (ample headroom) and cost + // (at the $1 budget cap); the tighter budget cap gates the policy. + expectConsumptionBatch(mockStore, map[usedKey]*types.Consumption{ + {types.DimensionGroup, "grp-engineers", 86_400}: {TokensInput: 100, CostUSD: 1.00}, + }) + + res, err := mgr.SelectPolicyForRequest(context.Background(), PolicySelectionInput{ + AccountID: "acc-1", + GroupIDs: []string{"grp-engineers"}, + ProviderID: "prov-1", + }) + require.NoError(t, err) + assert.False(t, res.Allow, + "the tighter of (token, budget) wins — abundant token headroom must NOT mask an exhausted budget") + assert.Equal(t, denyCodeBudgetCapExceeded, res.DenyCode) +} diff --git a/management/internals/modules/agentnetwork/reconcile.go b/management/internals/modules/agentnetwork/reconcile.go new file mode 100644 index 000000000..319553ebc --- /dev/null +++ b/management/internals/modules/agentnetwork/reconcile.go @@ -0,0 +1,131 @@ +package agentnetwork + +import ( + "context" + + log "github.com/sirupsen/logrus" + + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// reconcile recomputes the synthesised reverse-proxy services for an +// account, diffs them against the previously-synthesised set in the +// in-memory cache, and emits Create / Update / Delete proxy mappings +// to the affected clusters. Also triggers a peer-side network-map +// recompute via accountManager.UpdateAccountPeers so the +// private-service ACL injection picks up the new state immediately. +// +// Reconcile failures are logged and swallowed — the underlying CRUD +// has already completed, and the next mutation (or proxy reconnect) +// will re-converge the cluster's view. +func (m *managerImpl) reconcile(ctx context.Context, accountID string) { + if accountID == "" { + return + } + + defer func() { + if m.accountManager != nil { + m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{ + Resource: types.UpdateResourceService, + Operation: types.UpdateOperationUpdate, + }) + } + }() + + if m.proxyController == nil { + return + } + + services, err := SynthesizeServices(ctx, m.store, accountID) + if err != nil { + log.WithContext(ctx).WithError(err).Warnf("agent-network reconcile: synthesise services for account %s", accountID) + return + } + + oidcCfg := m.proxyController.GetOIDCValidationConfig() + current := make(map[string]*proto.ProxyMapping, len(services)) + for _, svc := range services { + if svc == nil || svc.ID == "" { + continue + } + current[svc.ID] = svc.ToProtoMapping(rpservice.Update, "", oidcCfg) + } + + m.reconcileMu.Lock() + previous := m.reconcileCache[accountID] + if previous == nil { + previous = make(map[string]*proto.ProxyMapping) + } + + creates, updates, deletes := diffMappings(previous, current) + if len(current) == 0 { + delete(m.reconcileCache, accountID) + } else { + m.reconcileCache[accountID] = current + } + m.reconcileMu.Unlock() + + for _, mapping := range creates { + mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, mapping, clusterFromMapping(mapping)) + } + for _, mapping := range updates { + mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, mapping, clusterFromMapping(mapping)) + } + for _, mapping := range deletes { + mapping.Type = proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, mapping, clusterFromMapping(mapping)) + } +} + +// diffMappings classifies the previous→current transition for a +// single account into Create / Update / Delete sets. +// +// Cluster moves (current.cluster != previous.cluster) are surfaced as +// a Delete on the old cluster + Create on the new — handled by +// emitting both a delete (on previous mapping) and a create (on the +// current mapping) for that service ID. +func diffMappings(previous, current map[string]*proto.ProxyMapping) (creates, updates, deletes []*proto.ProxyMapping) { + for id, cur := range current { + prev, existed := previous[id] + switch { + case !existed: + creates = append(creates, cur) + case prev.GetDomain() == "" || cur.GetAccountId() == prev.GetAccountId() && currentClusterChanged(prev, cur): + deletes = append(deletes, prev) + creates = append(creates, cur) + default: + updates = append(updates, cur) + } + } + for id, prev := range previous { + if _, stillThere := current[id]; !stillThere { + deletes = append(deletes, prev) + } + } + return creates, updates, deletes +} + +func currentClusterChanged(prev, cur *proto.ProxyMapping) bool { + return clusterFromMapping(prev) != clusterFromMapping(cur) +} + +// clusterFromMapping returns the cluster the mapping should be sent +// to. ProxyMapping doesn't carry the cluster directly, so we rely on +// the synthesised service's domain (`.`) and split on +// the first '.'. +func clusterFromMapping(m *proto.ProxyMapping) string { + if m == nil { + return "" + } + domain := m.GetDomain() + for i := 0; i < len(domain); i++ { + if domain[i] == '.' { + return domain[i+1:] + } + } + return "" +} diff --git a/management/internals/modules/agentnetwork/reconcile_test.go b/management/internals/modules/agentnetwork/reconcile_test.go new file mode 100644 index 000000000..0855f0dc1 --- /dev/null +++ b/management/internals/modules/agentnetwork/reconcile_test.go @@ -0,0 +1,232 @@ +package agentnetwork + +import ( + "context" + "testing" + + "github.com/golang/mock/gomock" + "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/proxy" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/proto" +) + +func newReconcileMgr(t *testing.T, ctrl *gomock.Controller) (*managerImpl, *store.MockStore, *proxy.MockController) { + t.Helper() + mockStore := store.NewMockStore(ctrl) + mockProxy := proxy.NewMockController(ctrl) + return &managerImpl{ + store: mockStore, + proxyController: mockProxy, + reconcileCache: make(map[string]map[string]*proto.ProxyMapping), + }, mockStore, mockProxy +} + +func newReconcileTestProvider() *types.Provider { + return &types.Provider{ + ID: "prov-1", + AccountID: "acct-1", + ProviderID: "openai_api", + Name: "OpenAI", + UpstreamURL: "https://api.openai.com", + APIKey: "sk-test-key", + Enabled: true, + SessionPrivateKey: "test-priv-key", + SessionPublicKey: "test-pub-key", + } +} + +func newReconcileTestPolicy(providerID, sourceGroupID string) *types.Policy { + return &types.Policy{ + ID: "pol-1", + AccountID: "acct-1", + Name: "engineers", + Enabled: true, + SourceGroups: []string{sourceGroupID}, + DestinationProviderIDs: []string{providerID}, + } +} + +func newReconcileTestSettings() *types.Settings { + return &types.Settings{ + AccountID: "acct-1", + Cluster: "eu.proxy.netbird.io", + Subdomain: "violet", + } +} + +func expectReconcileSynthInputs(mockStore *store.MockStore, ctx context.Context, providers []*types.Provider, policies []*types.Policy, guardrails []*types.Guardrail) { + mockStore.EXPECT(). + GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "acct-1"). + Return(newReconcileTestSettings(), nil) + mockStore.EXPECT(). + GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "acct-1"). + Return(providers, nil) + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, "acct-1"). + Return(policies, nil) + mockStore.EXPECT(). + GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, "acct-1"). + Return(guardrails, nil) +} + +func TestReconcile_FirstSynth_EmitsCreate(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mgr, mockStore, mockProxy := newReconcileMgr(t, ctrl) + provider := newReconcileTestProvider() + policy := newReconcileTestPolicy(provider.ID, "grp-eng") + + expectReconcileSynthInputs(mockStore, ctx, []*types.Provider{provider}, []*types.Policy{policy}, []*types.Guardrail{}) + mockProxy.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{}) + + var sentMappings []*proto.ProxyMapping + mockProxy.EXPECT(). + SendServiceUpdateToCluster(ctx, "acct-1", gomock.Any(), "eu.proxy.netbird.io"). + Do(func(_ context.Context, _ string, m *proto.ProxyMapping, _ string) { + sentMappings = append(sentMappings, m) + }) + + mgr.reconcile(ctx, "acct-1") + + require.Len(t, sentMappings, 1, "first synth must emit one mapping") + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, sentMappings[0].Type, "first synth is a Create") + assert.Equal(t, "agent-net-svc-acct-1", sentMappings[0].Id, "stable account-scoped virtual service id") + assert.Equal(t, "violet.eu.proxy.netbird.io", sentMappings[0].Domain, "domain comes from settings (subdomain.cluster)") + + mgr.reconcileMu.Lock() + cached := mgr.reconcileCache["acct-1"] + mgr.reconcileMu.Unlock() + require.Len(t, cached, 1, "cache must hold the synth result for next diff") +} + +func TestReconcile_NoChange_EmitsNothingExtra(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mgr, mockStore, mockProxy := newReconcileMgr(t, ctrl) + provider := newReconcileTestProvider() + policy := newReconcileTestPolicy(provider.ID, "grp-eng") + + // Two identical synth runs. + mockStore.EXPECT(). + GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "acct-1"). + Return(newReconcileTestSettings(), nil).Times(2) + mockStore.EXPECT(). + GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "acct-1"). + Return([]*types.Provider{provider}, nil).Times(2) + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, "acct-1"). + Return([]*types.Policy{policy}, nil).Times(2) + mockStore.EXPECT(). + GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, "acct-1"). + Return([]*types.Guardrail{}, nil).Times(2) + mockProxy.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{}).Times(2) + + createCalls := 0 + updateCalls := 0 + mockProxy.EXPECT(). + SendServiceUpdateToCluster(ctx, "acct-1", gomock.Any(), gomock.Any()). + Do(func(_ context.Context, _ string, m *proto.ProxyMapping, _ string) { + switch m.Type { + case proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED: + createCalls++ + case proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED: + updateCalls++ + } + }). + AnyTimes() + + mgr.reconcile(ctx, "acct-1") + mgr.reconcile(ctx, "acct-1") + + assert.Equal(t, 1, createCalls, "first reconcile creates") + assert.Equal(t, 1, updateCalls, "second reconcile re-pushes as Modified (no semantic change but mapping fields refresh)") +} + +func TestReconcile_PolicyRemoved_EmitsDelete(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mgr, mockStore, mockProxy := newReconcileMgr(t, ctrl) + provider := newReconcileTestProvider() + policy := newReconcileTestPolicy(provider.ID, "grp-eng") + + gomock.InOrder( + // First reconcile: provider + policy, synthesised. + mockStore.EXPECT().GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "acct-1").Return(newReconcileTestSettings(), nil), + mockStore.EXPECT().GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "acct-1").Return([]*types.Provider{provider}, nil), + mockStore.EXPECT().GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, "acct-1").Return([]*types.Policy{policy}, nil), + mockStore.EXPECT().GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, "acct-1").Return([]*types.Guardrail{}, nil), + // Second reconcile: policy gone, provider stays but no longer referenced. + mockStore.EXPECT().GetAgentNetworkSettings(ctx, store.LockingStrengthNone, "acct-1").Return(newReconcileTestSettings(), nil), + mockStore.EXPECT().GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, "acct-1").Return([]*types.Provider{provider}, nil), + mockStore.EXPECT().GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, "acct-1").Return([]*types.Policy{}, nil), + ) + mockProxy.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{}).AnyTimes() + + var seenTypes []proto.ProxyMappingUpdateType + mockProxy.EXPECT(). + SendServiceUpdateToCluster(ctx, "acct-1", gomock.Any(), "eu.proxy.netbird.io"). + Do(func(_ context.Context, _ string, m *proto.ProxyMapping, _ string) { + seenTypes = append(seenTypes, m.Type) + }). + AnyTimes() + + mgr.reconcile(ctx, "acct-1") + mgr.reconcile(ctx, "acct-1") + + require.Len(t, seenTypes, 2, "create then delete") + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, seenTypes[0]) + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, seenTypes[1]) + + mgr.reconcileMu.Lock() + _, present := mgr.reconcileCache["acct-1"] + mgr.reconcileMu.Unlock() + assert.False(t, present, "cache for the account must be cleared once nothing is synthesised") +} + +func TestReconcile_NilProxyController_NoOp(t *testing.T) { + ctx := context.Background() + mgr := &managerImpl{ + reconcileCache: make(map[string]map[string]*proto.ProxyMapping), + } + // Must not panic; must not query the store. + mgr.reconcile(ctx, "acct-1") +} + +func TestReconcile_EmptyAccountID_NoOp(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mgr, _, _ := newReconcileMgr(t, ctrl) + // Empty accountID short-circuits before any store call. + mgr.reconcile(ctx, "") +} + +func TestClusterFromMapping(t *testing.T) { + tests := []struct { + name string + domain string + want string + }{ + {"simple", "openai.eu.proxy.netbird.io", "eu.proxy.netbird.io"}, + {"deeply nested", "a.b.c.d", "b.c.d"}, + {"no dot", "openai", ""}, + {"empty", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := clusterFromMapping(&proto.ProxyMapping{Domain: tt.domain}) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/management/internals/modules/agentnetwork/synthesizer.go b/management/internals/modules/agentnetwork/synthesizer.go new file mode 100644 index 000000000..95fe91773 --- /dev/null +++ b/management/internals/modules/agentnetwork/synthesizer.go @@ -0,0 +1,1092 @@ +package agentnetwork + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "sort" + "strings" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/status" +) + +// apiKeyPlaceholder is the literal substituted with the provider's +// decrypted API key in catalog AuthHeaderTemplate strings. +const apiKeyPlaceholder = "${API_KEY}" //nolint:gosec // template marker, not a credential + +// gcpKeyfilePrefix marks an api_key that holds a base64-encoded GCP +// service-account JSON key ("keyfile::") rather than a static bearer +// token; the proxy mints OAuth tokens from it. Mirrors Aperture's convention. +const gcpKeyfilePrefix = "keyfile::" + +// SynthesizedServiceIDPrefix prefixes the in-memory ID of every +// reverse-proxy service synthesised from Agent Network state. One +// synthesised service exists per (account, cluster); the suffix is the +// account ID so the proxy can dedup mappings cleanly. +const SynthesizedServiceIDPrefix = "agent-net-svc-" + +// agentNetworkRequestCaptureBytes is the request-side body capture cap. +// Kept modest: oversized requests (a long conversation's context can be +// many MB) have their routing fields recovered by the proxy's tolerant +// scan rather than buffered here, so there's no need to size this to the +// largest possible request. +const agentNetworkRequestCaptureBytes = 1 << 20 + +// agentNetworkResponseCaptureBytes is the response-side body capture cap. +// Token usage lives in the trailing SSE message_delta event, so the +// captured prefix must reach the end of the stream. Unlike a request's +// unbounded context, a single response is hard-capped by the model's max +// output tokens (~128K on Opus → a few hundred KB of gzipped SSE even +// with thinking), so 8 MiB is comfortably above any real response and is +// effectively unlimited here — not a moving ceiling. The proxy clamps to +// its own MaxBodyCapBytes at apply time. +const agentNetworkResponseCaptureBytes = 8 << 20 + +// agentNetworkCaptureContentTypes is the set of content types whose +// bodies the proxy buffers for the LLM middlewares. JSON covers +// buffered request and response bodies; SSE covers streaming +// responses (the response parser sums delta tokens across chunks). +var agentNetworkCaptureContentTypes = []string{ + "application/json", + "text/event-stream", +} + +// Middleware IDs the synthesised target chain registers, mirroring the +// proxy-side built-in registry. Order matters: on_request runs in the +// order they're listed; on_response runs in reverse, so cost_meter must +// come BEFORE llm_response_parser in the slice so the parser populates +// tokens before the cost meter reads them. +const ( + middlewareIDLLMRequestParser = "llm_request_parser" + middlewareIDLLMRouter = "llm_router" + middlewareIDLLMIdentityInject = "llm_identity_inject" + middlewareIDLLMLimitCheck = "llm_limit_check" + middlewareIDLLMGuardrail = "llm_guardrail" + middlewareIDCostMeter = "cost_meter" + middlewareIDLLMResponseParser = "llm_response_parser" + middlewareIDLLMLimitRecord = "llm_limit_record" +) + +// SynthesizeServicesForCluster walks every account's agent-network +// settings row pinned to clusterAddr and synthesises the per-account +// gateway service. Used by the proxy-mapping snapshot path where the +// connecting proxy has a specific cluster address and cares about every +// account that routes through it. +// +// Returns nil (no error) when no settings row references the cluster. +// Per-account synthesis failures are skipped rather than dropping every +// account on the cluster. +func SynthesizeServicesForCluster(ctx context.Context, s store.Store, clusterAddr string) ([]*rpservice.Service, error) { + clusterAddr = strings.TrimSpace(clusterAddr) + if clusterAddr == "" { + return nil, nil + } + + settingsRows, err := s.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, clusterAddr) + if err != nil { + return nil, fmt.Errorf("list agent network settings on cluster: %w", err) + } + if len(settingsRows) == 0 { + return nil, nil + } + + var out []*rpservice.Service + for _, settings := range settingsRows { + if settings == nil { + continue + } + services, serr := SynthesizeServices(ctx, s, settings.AccountID) + if serr != nil { + continue + } + for _, svc := range services { + if svc != nil && svc.ProxyCluster == clusterAddr { + out = append(out, svc) + } + } + } + return out, nil +} + +// SynthesizeServiceForDomain resolves a single agent-network service by its +// public endpoint domain. It lists the (few) settings rows on the domain's +// cluster, matches the one whose endpoint equals the domain, and synthesises +// only that account — avoiding full per-account synthesis for every tenant on +// the cluster, which is what auth/session paths previously paid. Returns nil +// (no error) when no account owns the domain. +func SynthesizeServiceForDomain(ctx context.Context, s store.Store, domain string) (*rpservice.Service, error) { + domain = strings.TrimSpace(domain) + cluster := clusterFromDomain(domain) + if domain != "" && cluster != "" { + settingsRows, err := s.GetAgentNetworkSettingsByCluster(ctx, store.LockingStrengthNone, cluster) + if err != nil { + return nil, fmt.Errorf("list agent network settings on cluster: %w", err) + } + for _, settings := range settingsRows { + if settings == nil || settings.Endpoint() != domain { + continue + } + services, serr := SynthesizeServices(ctx, s, settings.AccountID) + if serr != nil { + return nil, serr + } + for _, svc := range services { + if svc != nil && svc.Domain == domain { + return svc, nil + } + } + break + } + } + return nil, nil //nolint:nilnil // optional lookup: no account owns the domain +} + +// clusterFromDomain returns the cluster portion of an endpoint domain (every +// label after the first). +func clusterFromDomain(domain string) string { + if i := strings.IndexByte(domain, '.'); i >= 0 { + return domain[i+1:] + } + return "" +} + +// SynthesizeServices builds the in-memory reverse-proxy service that +// fronts the account's agent-network gateway. Returns nil when the +// account has no settings row, no enabled providers, or no enabled +// policies — in any of those cases there's nothing useful to expose. +// +// One service per (account, settings.Cluster) is emitted. The router +// middleware encodes a denormalised model→provider routing table +// (auth headers + decrypted API keys baked in); the policy_check +// middleware encodes per-provider authorised group IDs derived from +// the account's enabled policies. +// +// Services are NEVER persisted — callers regenerate them on every +// network-map / proxy-mapping cycle from current state. +func SynthesizeServices(ctx context.Context, s store.Store, accountID string) ([]*rpservice.Service, error) { + settings, ok, err := loadSettings(ctx, s, accountID) + if err != nil { + return nil, err + } + if !ok || strings.TrimSpace(settings.Cluster) == "" { + return nil, nil + } + + providers, err := s.GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, fmt.Errorf("list agent network providers: %w", err) + } + enabledProviders := filterEnabledProviders(providers) + if len(enabledProviders) == 0 { + return nil, nil + } + + policies, err := s.GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, fmt.Errorf("list agent network policies: %w", err) + } + enabledPolicies := filterEnabledPolicies(policies) + if len(enabledPolicies) == 0 { + return nil, nil + } + + // Backfill any missing session keypairs before deriving a + // service-level keypair. Old rows pre-date the column; treating + // the gap as a no-op produces an immediate dial failure, so we + // fix it once here and persist for future cycles. + for _, p := range enabledProviders { + if p.SessionPrivateKey != "" && p.SessionPublicKey != "" { + continue + } + if err := backfillProviderSessionKeys(ctx, s, p); err != nil { + return nil, fmt.Errorf("backfill session keys for provider %s: %w", p.ID, err) + } + } + + guardrails, err := s.GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, fmt.Errorf("list agent network guardrails: %w", err) + } + guardrailsByID := make(map[string]*types.Guardrail, len(guardrails)) + for _, g := range guardrails { + if g != nil { + guardrailsByID[g.ID] = g + } + } + + groupIndex := indexProviderGroups(enabledPolicies) + + routerCfgJSON, err := buildRouterConfigJSON(enabledProviders, groupIndex) + if err != nil { + return nil, err + } + + identityInjectJSON, err := buildIdentityInjectConfigJSON(enabledProviders, groupIndex) + if err != nil { + return nil, err + } + + mergedGuardrails := mergeGuardrails(enabledPolicies, guardrailsByID) + applyAccountCollectionControls(&mergedGuardrails, settings) + guardrailJSON, err := marshalGuardrailConfig(mergedGuardrails) + if err != nil { + return nil, err + } + + // Use the merged decision (account settings OR policy-required redaction), + // not the raw account flag, so a policy that mandates PII redaction is + // honored by the capture parsers even when the account toggle is off. + middlewares := buildMiddlewareChain(routerCfgJSON, identityInjectJSON, guardrailJSON, mergedGuardrails.PromptCapture.RedactPii, mergedGuardrails.PromptCapture.Enabled) + + priv, pub, err := pickServiceSessionKeys(enabledProviders) + if err != nil { + return nil, err + } + + svc := buildAccountService(accountID, settings, enabledPolicies, middlewares, priv, pub) + return []*rpservice.Service{svc}, nil +} + +// loadSettings returns the account's agent-network settings row. The +// boolean reports whether a row exists; a status.NotFound surfaces as +// (nil, false, nil) so callers can treat "no settings" as "no +// synthesis" without inspecting error types themselves. +func loadSettings(ctx context.Context, s store.Store, accountID string) (*types.Settings, bool, error) { + settings, err := s.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID) + if err == nil { + return settings, true, nil + } + var sErr *status.Error + if errors.As(err, &sErr) && sErr.Type() == status.NotFound { + return nil, false, nil + } + return nil, false, fmt.Errorf("get agent network settings: %w", err) +} + +// filterEnabledProviders returns the subset of enabled providers, sorted +// by created_at ascending so the router config is deterministic and +// first-match-wins is stable across synthesis cycles. +func filterEnabledProviders(providers []*types.Provider) []*types.Provider { + out := make([]*types.Provider, 0, len(providers)) + for _, p := range providers { + if p == nil || !p.Enabled { + continue + } + out = append(out, p) + } + sort.SliceStable(out, func(i, j int) bool { + if !out[i].CreatedAt.Equal(out[j].CreatedAt) { + return out[i].CreatedAt.Before(out[j].CreatedAt) + } + return out[i].ID < out[j].ID + }) + return out +} + +// filterEnabledPolicies returns the subset of enabled policies. +func filterEnabledPolicies(policies []*types.Policy) []*types.Policy { + out := make([]*types.Policy, 0, len(policies)) + for _, p := range policies { + if p == nil || !p.Enabled { + continue + } + out = append(out, p) + } + return out +} + +// backfillProviderSessionKeys mints an ed25519 session keypair on a +// provider row that doesn't have one yet (rows created before the +// keys were persistent fields) and persists it via the store so +// subsequent cycles get stable keys. +func backfillProviderSessionKeys(ctx context.Context, s store.Store, p *types.Provider) error { + pair, err := sessionkey.GenerateKeyPair() + if err != nil { + return fmt.Errorf("generate session keys for provider %s: %w", p.ID, err) + } + p.SessionPrivateKey = pair.PrivateKey + p.SessionPublicKey = pair.PublicKey + if err := s.SaveAgentNetworkProvider(ctx, p); err != nil { + return fmt.Errorf("persist backfilled session keys for provider %s: %w", p.ID, err) + } + return nil +} + +// pickServiceSessionKeys returns the keypair the synthesised gateway +// service signs / verifies session JWTs with. The PoC reuses the first +// enabled provider's keypair so existing session cookies survive +// provider edits as long as the first-by-created_at provider stays in +// place. Returns an error when no provider has a usable keypair after +// backfill — that surfaces a misconfigured account loudly instead of +// emitting a service the proxy will reject as "invalid session public +// key size". +func pickServiceSessionKeys(providers []*types.Provider) (priv, pub string, err error) { + for _, p := range providers { + if p.SessionPrivateKey != "" && p.SessionPublicKey != "" { + return p.SessionPrivateKey, p.SessionPublicKey, nil + } + } + return "", "", fmt.Errorf("no provider with session keypair; update one provider to backfill") +} + +// routerConfig mirrors the on-wire shape llm_router accepts. Kept +// private so the synthesiser owns the contract; the proxy-side factory +// JSON-decodes the same shape. +type routerConfig struct { + Providers []routerProviderRoute `json:"providers"` +} + +type routerProviderRoute struct { + ID string `json:"id"` + Vendor string `json:"vendor,omitempty"` + Models []string `json:"models"` + UpstreamScheme string `json:"upstream_scheme"` + UpstreamHost string `json:"upstream_host"` + UpstreamPath string `json:"upstream_path,omitempty"` + AuthHeaderName string `json:"auth_header_name"` + AuthHeaderValue string `json:"auth_header_value"` + AllowedGroupIDs []string `json:"allowed_group_ids,omitempty"` + // Vertex marks a Google Vertex AI provider, whose requests carry the + // model in the URL path. The router selects it by path, bypassing the + // model/vendor table. + Vertex bool `json:"vertex,omitempty"` + // Bedrock marks an AWS Bedrock provider, whose requests carry the model in + // the URL path (/model/{id}/{action}). The router selects it by path, + // bypassing the model/vendor table; auth is a static bearer token. + Bedrock bool `json:"bedrock,omitempty"` + // GCPServiceAccountKeyB64 carries a base64-encoded GCP service-account + // JSON key (from a "keyfile::" api_key). When set, the proxy mints + // + refreshes the OAuth token at request time instead of injecting a static + // AuthHeaderValue. + GCPServiceAccountKeyB64 string `json:"gcp_sa_key_b64,omitempty"` + // SkipTLSVerify disables upstream TLS certificate verification when the + // proxy dials this provider's upstream. For self-hosted / internal gateways + // behind a private or self-signed certificate. + SkipTLSVerify bool `json:"skip_tls_verify,omitempty"` +} + +// indexProviderGroups walks the enabled policies and returns, per +// provider id, the sorted union of source group ids across every +// policy that authorises the provider. Providers with no authorising +// policy are absent from the map. The router consumes this to filter +// candidate routes by the caller's group memberships before the +// path-prefix tiebreak runs. +func indexProviderGroups(policies []*types.Policy) map[string][]string { + sets := make(map[string]map[string]struct{}) + for _, policy := range policies { + if policy == nil { + continue + } + for _, providerID := range policy.DestinationProviderIDs { + if providerID == "" { + continue + } + set, ok := sets[providerID] + if !ok { + set = make(map[string]struct{}) + sets[providerID] = set + } + for _, group := range policy.SourceGroups { + if group != "" { + set[group] = struct{}{} + } + } + } + } + out := make(map[string][]string, len(sets)) + for providerID, set := range sets { + groups := make([]string, 0, len(set)) + for g := range set { + groups = append(groups, g) + } + sort.Strings(groups) + out[providerID] = groups + } + return out +} + +// buildRouterConfigJSON denormalises the account's enabled providers +// into the router middleware's first-match-wins routing table. +// Providers are listed in created_at order so the table is +// deterministic and stable across synth cycles. +// +// AllowedGroupIDs is the union of source group ids across every enabled +// policy that authorises the provider. The router uses it as a hard +// filter — a route whose AllowedGroupIDs has no intersection with the +// caller's user groups is removed from the candidate list before the +// path-prefix tiebreak. Providers no enabled policy authorises +// (orphans) are intentionally OMITTED so the router never observes a +// route with an empty ACL. +func buildRouterConfigJSON(providers []*types.Provider, groupIndex map[string][]string) ([]byte, error) { + cfg := routerConfig{Providers: make([]routerProviderRoute, 0, len(providers))} + for _, p := range providers { + groups, hasPolicy := groupIndex[p.ID] + if !hasPolicy { + // Orphan: skip. No enabled policy authorises this + // provider, so it must not be reachable. + continue + } + scheme, host, path, err := parseUpstreamHost(p.UpstreamURL) + if err != nil { + return nil, fmt.Errorf("router config for provider %s: %w", p.ID, err) + } + headerName, headerValue, gcpSAKeyB64, err := providerAuthHeader(p) + if err != nil { + return nil, err + } + cfg.Providers = append(cfg.Providers, routerProviderRoute{ + ID: p.ID, + Vendor: providerVendor(p), + Models: providerModelIDs(p), + UpstreamScheme: scheme, + UpstreamHost: host, + UpstreamPath: path, + AuthHeaderName: headerName, + AuthHeaderValue: headerValue, + AllowedGroupIDs: groups, + Vertex: catalog.IsVertexPathStyle(p.ProviderID), + Bedrock: catalog.IsBedrockPathStyle(p.ProviderID), + GCPServiceAccountKeyB64: gcpSAKeyB64, + SkipTLSVerify: p.SkipTLSVerification, + }) + } + out, err := json.Marshal(cfg) + if err != nil { + return nil, fmt.Errorf("marshal llm_router middleware config: %w", err) + } + return out, nil +} + +// providerVendor returns the parser surface ("openai", "anthropic", …) +// the provider speaks, sourced from its catalog entry's ParserID. The +// router uses it to keep a request the parser tagged with a vendor on a +// route of the same vendor — so e.g. an Anthropic /v1/messages call is +// never sent to an OpenAI-compatible gateway that also claims the model. +// Empty when the catalog entry is unknown or declares no parser surface; +// the router then falls back to model / path routing. +func providerVendor(p *types.Provider) string { + entry, ok := catalog.Lookup(p.ProviderID) + if !ok { + return "" + } + return entry.ParserID +} + +// providerModelIDs returns the model identifiers exposed by the +// provider, deduplicated and in the operator's declared order. Empty +// slice when no models are configured — the router treats that as +// "claim every model" so gateway-style providers (LiteLLM, custom +// OpenAI-compatible endpoints) work without the operator enumerating +// the upstream's full model catalog in NetBird. +func providerModelIDs(p *types.Provider) []string { + if len(p.Models) == 0 { + return []string{} + } + seen := make(map[string]struct{}, len(p.Models)) + out := make([]string, 0, len(p.Models)) + for _, m := range p.Models { + if m.ID == "" { + continue + } + if _, dup := seen[m.ID]; dup { + continue + } + seen[m.ID] = struct{}{} + out = append(out, m.ID) + } + return out +} + +// identityInjectConfig mirrors the on-wire shape llm_identity_inject +// accepts. +type identityInjectConfig struct { + Providers []identityInjectProvider `json:"providers"` +} + +// identityInjectProvider carries one provider's injection rule. +// Identity-stamping uses one of HeaderPair / JSONMetadata (mutually +// exclusive). ExtraHeaders is independent — a list of extra +// per-provider routing/config headers (catalog-declared, value lives +// on the provider record) the middleware stamps with anti-spoof +// (Remove + Add) on every matching request. +type identityInjectProvider struct { + ProviderID string `json:"provider_id"` + HeaderPair *identityInjectHeaderPair `json:"header_pair,omitempty"` + JSONMetadata *identityInjectJSONMetadata `json:"json_metadata,omitempty"` + ExtraHeaders []identityInjectExtraHeader `json:"extra_headers,omitempty"` +} + +type identityInjectExtraHeader struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type identityInjectHeaderPair struct { + EndUserIDHeader string `json:"end_user_id_header,omitempty"` + TagsHeader string `json:"tags_header,omitempty"` + TagsInBody bool `json:"tags_in_body,omitempty"` + EndUserIDInBody bool `json:"end_user_id_in_body,omitempty"` +} + +type identityInjectJSONMetadata struct { + Header string `json:"header"` + UserKey string `json:"user_key,omitempty"` + GroupsKey string `json:"groups_key,omitempty"` + MaxValueLength int `json:"max_value_length,omitempty"` + Sanitize bool `json:"sanitize,omitempty"` +} + +// buildIdentityInjectConfigJSON walks the enabled providers and emits +// one entry per provider whose catalog entry declares an +// IdentityInjection block. The middleware no-ops for any provider not +// in this list, so the chain is safe to ship to all targets even when +// no identity-stamping provider is configured. +// +// The caller passes groupIndex so we can mirror the synthesiser's own +// "drop orphans" rule — providers no enabled policy authorises don't +// reach the router, so injecting identity for them would never fire. +// We could leave them in for symmetry, but skipping is cheaper and +// clearer. +func buildIdentityInjectConfigJSON(providers []*types.Provider, groupIndex map[string][]string) ([]byte, error) { + cfg := identityInjectConfig{Providers: make([]identityInjectProvider, 0)} + for _, p := range providers { + if _, hasPolicy := groupIndex[p.ID]; !hasPolicy { + continue + } + entry, ok := catalog.Lookup(p.ProviderID) + if !ok { + continue + } + rule, ok := buildIdentityInjectRule(p, entry) + if !ok { + continue + } + cfg.Providers = append(cfg.Providers, rule) + } + out, err := json.Marshal(cfg) + if err != nil { + return nil, fmt.Errorf("marshal llm_identity_inject middleware config: %w", err) + } + return out, nil +} + +// buildIdentityInjectRule assembles the injection rule for one provider +// from its record and catalog entry. The second return is false when the +// provider would emit nothing, so the caller can skip it entirely rather +// than carry an inert rule for it. +func buildIdentityInjectRule(p *types.Provider, entry catalog.Provider) (identityInjectProvider, bool) { + rule := identityInjectProvider{ProviderID: p.ID} + // Identity-stamping shape (one of HeaderPair / JSONMetadata). Skip the + // shape silently when the catalog entry doesn't declare one, or when the + // operator disabled metadata for this provider — extras can still apply, + // see below. MetadataDisabled suppresses only the identity dimensions + // (user + authorizing group), not the catalog's routing ExtraHeaders. + if !p.MetadataDisabled && entry.IdentityInjection != nil { + switch { + case entry.IdentityInjection.HeaderPair != nil: + rule.HeaderPair = buildIdentityHeaderPair(p, entry.IdentityInjection.HeaderPair) + case entry.IdentityInjection.JSONMetadata != nil: + rule.JSONMetadata = buildIdentityJSONMetadata(p, entry.IdentityInjection.JSONMetadata) + } + } + rule.ExtraHeaders = buildIdentityExtraHeaders(p, entry.ExtraHeaders) + if rule.HeaderPair == nil && rule.JSONMetadata == nil && len(rule.ExtraHeaders) == 0 { + return identityInjectProvider{}, false + } + return rule, true +} + +// buildIdentityHeaderPair resolves the header-pair injection shape, +// returning nil when nothing would be stamped. For Customizable shapes +// (Bifrost today) the wire header names come from the provider record +// verbatim; the catalog values are placeholder defaults shown by the +// dashboard, not authoritative. Empty operator value disables stamping +// for that dimension — applyHeaderPair already no-ops on empty header +// names. The body-inject flags stay catalog-owned because Customizable +// today only applies to gateways that read identity from headers (the +// flags would be no-ops anyway). +func buildIdentityHeaderPair(p *types.Provider, hp *catalog.HeaderPairInjection) *identityInjectHeaderPair { + userHeader := hp.EndUserIDHeader + tagsHeader := hp.TagsHeader + if hp.Customizable { + userHeader = p.IdentityHeaderUserID + tagsHeader = p.IdentityHeaderGroups + } + if userHeader == "" && tagsHeader == "" && !hp.TagsInBody && !hp.EndUserIDInBody { + return nil + } + return &identityInjectHeaderPair{ + EndUserIDHeader: userHeader, + TagsHeader: tagsHeader, + TagsInBody: hp.TagsInBody, + EndUserIDInBody: hp.EndUserIDInBody, + } +} + +// buildIdentityJSONMetadata resolves the JSON-metadata injection shape, +// returning nil when the catalog entry carries no header. Customizable +// JSONMetadata reuses the same provider-record fields HeaderPair uses — +// IdentityHeaderUserID becomes the JSON key for the user dimension, and +// IdentityHeaderGroups becomes the JSON key for groups. Empty operator +// value is honored as "skip this key"; applyJSONMetadata already drops +// keys with empty names. Header itself is catalog-owned (e.g. +// cf-aig-metadata) — operators only override the keys inside the JSON, +// not the wire header that carries it. +func buildIdentityJSONMetadata(p *types.Provider, jm *catalog.JSONMetadataInjection) *identityInjectJSONMetadata { + if jm.Header == "" { + return nil + } + userKey := jm.UserKey + groupsKey := jm.GroupsKey + if jm.Customizable { + userKey = p.IdentityHeaderUserID + groupsKey = p.IdentityHeaderGroups + } + return &identityInjectJSONMetadata{ + Header: jm.Header, + UserKey: userKey, + GroupsKey: groupsKey, + MaxValueLength: jm.MaxValueLength, + Sanitize: jm.Sanitize, + } +} + +// buildIdentityExtraHeaders collects catalog-declared static headers (e.g. +// Portkey config id), emitting only entries whose value the operator has +// filled in on the provider record; missing/empty values are no-ops. +func buildIdentityExtraHeaders(p *types.Provider, extras []catalog.ExtraHeader) []identityInjectExtraHeader { + var out []identityInjectExtraHeader + for _, h := range extras { + if h.Name == "" { + continue + } + v := strings.TrimSpace(p.ExtraValues[h.Name]) + if v == "" { + continue + } + out = append(out, identityInjectExtraHeader{Name: h.Name, Value: v}) + } + return out +} + +// buildMiddlewareChain assembles the per-target middleware chain that +// implements the Agent Network behaviour at the proxy. Slot order on +// the request leg is the slice order; on the response leg it runs in +// reverse, so cost_meter must come BEFORE llm_response_parser so the +// parser populates token counts before the cost meter reads them. +// +// Authorisation is fused into llm_router: the router carries +// AllowedGroupIDs per provider and filters candidates by the caller's +// user-groups before the path-prefix tiebreak. Per-policy +// enforcement (token / budget caps) lives in llm_limit_check, which +// runs after the router so it can read the resolved provider id; +// llm_limit_record on the response leg posts deltas back to +// management to keep the consumption counters fresh. +// +// llm_identity_inject runs immediately after the router so the +// resolved provider id is available; it stamps NetBird identity onto +// requests bound for gateways like LiteLLM that key budgets and +// attribution off request headers. CanMutate is required so its +// HeadersAdd / HeadersRemove pass the framework's mutation gate. +func buildMiddlewareChain(routerCfgJSON, identityInjectJSON, guardrailJSON []byte, redactPii, capturePromptContent bool) []rpservice.MiddlewareConfig { + // Both parsers receive an explicit capture flag derived from the account's + // enable_prompt_collection toggle; nil/unset would default to the legacy + // "always emit" behavior in the middleware, which is precisely what we + // must suppress when the operator hasn't opted in. The flag is duplicated + // across both parsers under distinct field names (capture_prompt / + // capture_completion) to keep each parser's config independently + // auditable. + requestParserCfg := buildParserConfigJSON("capture_prompt", redactPii, capturePromptContent) + responseParserCfg := buildParserConfigJSON("capture_completion", redactPii, capturePromptContent) + return []rpservice.MiddlewareConfig{ + { + ID: middlewareIDLLMRequestParser, + Enabled: true, + Slot: rpservice.MiddlewareSlotOnRequest, + ConfigJSON: requestParserCfg, + }, + { + ID: middlewareIDLLMRouter, + Enabled: true, + Slot: rpservice.MiddlewareSlotOnRequest, + ConfigJSON: routerCfgJSON, + // llm_router rewrites the request's headers (strip + // client auth + inject provider auth) and the upstream + // target via Mutations.RewriteUpstream. Both gated on + // CanMutate; without this flag the chain framework + // drops every mutation and the reverse proxy dials the + // placeholder noop.invalid host (502). + CanMutate: true, + }, + { + // llm_limit_check runs after the router so it knows the + // resolved provider id, but before identity_inject so a + // cap-deny doesn't pay the cost of stamping headers + // we'll never use. + ID: middlewareIDLLMLimitCheck, + Enabled: true, + Slot: rpservice.MiddlewareSlotOnRequest, + ConfigJSON: []byte("{}"), + }, + { + ID: middlewareIDLLMIdentityInject, + Enabled: true, + Slot: rpservice.MiddlewareSlotOnRequest, + ConfigJSON: identityInjectJSON, + // CanMutate is required so HeadersAdd / HeadersRemove + // emitted to stamp NetBird identity onto the upstream + // request actually land — without it the framework + // drops every header mutation. + CanMutate: true, + }, + { + ID: middlewareIDLLMGuardrail, + Enabled: true, + Slot: rpservice.MiddlewareSlotOnRequest, + ConfigJSON: guardrailJSON, + }, + { + // Response slot runs in reverse slice order at runtime: + // limit_record sits FIRST in the response section so it + // runs LAST, after llm_response_parser stamped tokens + // and cost_meter computed cost — both of which the + // recorder reads from the metadata bag. + ID: middlewareIDLLMLimitRecord, + Enabled: true, + Slot: rpservice.MiddlewareSlotOnResponse, + ConfigJSON: []byte("{}"), + }, + { + ID: middlewareIDCostMeter, + Enabled: true, + Slot: rpservice.MiddlewareSlotOnResponse, + ConfigJSON: []byte("{}"), + }, + { + ID: middlewareIDLLMResponseParser, + Enabled: true, + Slot: rpservice.MiddlewareSlotOnResponse, + ConfigJSON: responseParserCfg, + }, + } +} + +// guardrailConfig is the JSON shape the proxy-side llm_guardrail +// middleware expects. Mirrors the proxy registration documented in +// the management→proxy contract. +type guardrailConfig struct { + ModelAllowlist []string `json:"model_allowlist,omitempty"` + PromptCapture guardrailPromptCapture `json:"prompt_capture"` +} + +type guardrailPromptCapture struct { + Enabled bool `json:"enabled"` + RedactPii bool `json:"redact_pii"` +} + +// buildParserConfigJSON assembles the request- or response-parser config JSON. +// captureField names the parser-specific gate (capture_prompt for the request +// parser, capture_completion for the response parser); both are sourced from +// settings.EnablePromptCollection. redact_pii is only meaningful when capture +// is on (no content → nothing to redact) but we forward it verbatim so the +// proxy-side parser stays the only place that interprets the combination. +func buildParserConfigJSON(captureField string, redactPii, capture bool) []byte { + payload := map[string]any{ + captureField: capture, + } + if redactPii { + payload["redact_pii"] = true + } + out, err := json.Marshal(payload) + if err != nil { + // json.Marshal on a map[string]any of bools cannot fail; if it + // somehow does, ship the static minimal config so synth keeps + // working instead of panicking. + return []byte(`{}`) + } + return out +} + +// applyAccountCollectionControls folds the account-level collection master +// switches into the merged guardrail set. Prompt capture enablement is sourced +// SOLELY from the account toggle — the account-network setting is the master +// enable, and policies don't need to attach a capture-enabled guardrail to opt +// in. PII redaction is safe-additive: it applies when either the account or a +// policy guardrail enables it (OR). +func applyAccountCollectionControls(merged *MergedGuardrails, settings *types.Settings) { + if settings == nil { + return + } + merged.PromptCapture.Enabled = settings.EnablePromptCollection + merged.PromptCapture.RedactPii = settings.RedactPii || merged.PromptCapture.RedactPii +} + +func marshalGuardrailConfig(merged MergedGuardrails) ([]byte, error) { + cfg := guardrailConfig{ + ModelAllowlist: merged.ModelAllowlist, + PromptCapture: guardrailPromptCapture{ + Enabled: merged.PromptCapture.Enabled, + RedactPii: merged.PromptCapture.RedactPii, + }, + } + out, err := json.Marshal(cfg) + if err != nil { + return nil, fmt.Errorf("marshal guardrail middleware config: %w", err) + } + return out, nil +} + +// buildAccountService composes the per-account gateway Service. The +// target carries the noop placeholder URL — the router middleware +// rewrites every request to the matched provider's upstream before the +// proxy dials — alongside the full middleware chain and capture caps. +func buildAccountService( + accountID string, + settings *types.Settings, + enabledPolicies []*types.Policy, + middlewares []rpservice.MiddlewareConfig, + sessionPriv, sessionPub string, +) *rpservice.Service { + cluster := settings.Cluster + domain := settings.Endpoint() + serviceID := SynthesizedServiceIDPrefix + accountID + + return &rpservice.Service{ + ID: serviceID, + AccountID: accountID, + Name: "agent-network-" + accountID, + Domain: domain, + ProxyCluster: cluster, + Mode: rpservice.ModeHTTP, + Enabled: true, + Private: true, + // AccessGroups gates tunnel-peer access (ValidateTunnelPeer) to the + // synthesised agent-network endpoint. Agents reach the gateway over + // the WireGuard tunnel and are authorised by their peer→user group + // membership — the union of every enabled policy's source groups. + AccessGroups: unionSourceGroups(enabledPolicies), + PassHostHeader: false, + RewriteRedirects: false, + SessionPrivateKey: sessionPriv, + SessionPublicKey: sessionPub, + Targets: []*rpservice.Target{ + { + AccountID: accountID, + ServiceID: serviceID, + TargetType: rpservice.TargetTypeCluster, + TargetId: cluster, + Host: noopUpstreamHost, + Port: noopUpstreamPort, + Protocol: noopUpstreamScheme, + Enabled: true, + Options: rpservice.TargetOptions{ + DirectUpstream: true, + AgentNetwork: true, + DisableAccessLog: !settings.EnableLogCollection, + Middlewares: middlewares, + CaptureMaxRequestBytes: agentNetworkRequestCaptureBytes, + CaptureMaxResponseBytes: agentNetworkResponseCaptureBytes, + CaptureContentTypes: append([]string(nil), agentNetworkCaptureContentTypes...), + }, + }, + }, + } +} + +const ( + noopUpstreamScheme = "https" + noopUpstreamHost = "noop.invalid" + noopUpstreamPort = uint16(443) +) + +// providerAuthHeader builds the upstream auth header pair for a +// provider from its catalog entry. The catalog declares which header +// name and template a provider's API expects; the synthesiser +// substitutes the provider's decrypted API key into the template and +// returns the (name, value) pair the router middleware injects after +// stripping the inbound vendor auth headers. +func providerAuthHeader(p *types.Provider) (name, value, gcpSAKeyB64 string, err error) { + entry, ok := catalog.Lookup(p.ProviderID) + if !ok { + return "", "", "", fmt.Errorf("provider %s references unknown catalog id %q", p.ID, p.ProviderID) + } + if entry.AuthHeaderName == "" || entry.AuthHeaderTemplate == "" { + return "", "", "", fmt.Errorf("catalog entry %q has no auth header configured", p.ProviderID) + } + if p.APIKey == "" { + return "", "", "", fmt.Errorf("provider %s has no api key", p.ID) + } + // A "keyfile::" api_key is a GCP service-account key, not a + // static bearer. The proxy mints + refreshes a short-lived OAuth token from + // it at request time, so carry the key material on the route and emit no + // static value. + if rest, isKeyfile := strings.CutPrefix(p.APIKey, gcpKeyfilePrefix); isKeyfile { + return entry.AuthHeaderName, "", strings.TrimSpace(rest), nil + } + value = strings.ReplaceAll(entry.AuthHeaderTemplate, apiKeyPlaceholder, p.APIKey) + return entry.AuthHeaderName, value, "", nil +} + +// parseUpstreamHost splits provider.UpstreamURL into (scheme, host, path) +// where host carries an explicit ":port" suffix when the URL set one +// and path is the URL's path component normalised by stripping a +// trailing slash. The router uses path to disambiguate providers that +// claim the same model. Used by the router config so the rewrite +// carries an authority the reverse proxy can dial verbatim. +func parseUpstreamHost(raw string) (scheme, host, path string, err error) { + parsed, perr := url.Parse(strings.TrimSpace(raw)) + if perr != nil { + return "", "", "", fmt.Errorf("parse upstream_url %q: %w", raw, perr) + } + switch strings.ToLower(parsed.Scheme) { + case "http": + scheme = "http" + case "https": + scheme = "https" + default: + return "", "", "", fmt.Errorf("upstream_url scheme must be http or https, got %q", parsed.Scheme) + } + hostname := parsed.Hostname() + if hostname == "" { + return "", "", "", fmt.Errorf("upstream_url %q has no host", raw) + } + if port := parsed.Port(); port != "" { + host = hostname + ":" + port + } else { + host = hostname + } + path = strings.TrimRight(parsed.Path, "/") + return scheme, host, path, nil +} + +// unionSourceGroups deduplicates source-group IDs across the policies +// pointing at any provider, in deterministic order. +func unionSourceGroups(policies []*types.Policy) []string { + seen := make(map[string]struct{}) + for _, policy := range policies { + for _, group := range policy.SourceGroups { + if group == "" { + continue + } + seen[group] = struct{}{} + } + } + out := make([]string, 0, len(seen)) + for group := range seen { + out = append(out, group) + } + sort.Strings(out) + return out +} + +// MergedGuardrails is the JSON shape passed to the proxy via the +// guardrail middleware's config_json. Mirrors the proxy-side +// expectations and is intentionally distinct from +// types.GuardrailChecks so we can evolve either side independently. +type MergedGuardrails struct { + ModelAllowlist []string `json:"model_allowlist,omitempty"` + TokenLimits MergedTokenLimits `json:"token_limits"` + Budget MergedBudget `json:"budget"` + PromptCapture MergedPromptCapture `json:"prompt_capture"` + Retention MergedRetention `json:"retention"` +} + +type MergedTokenLimits struct { + Hourly *MergedTokenWindow `json:"hourly,omitempty"` + Daily *MergedTokenWindow `json:"daily,omitempty"` + Monthly *MergedTokenWindow `json:"monthly,omitempty"` +} + +type MergedTokenWindow struct { + MaxInputTokens int `json:"max_input_tokens,omitempty"` + MaxOutputTokens int `json:"max_output_tokens,omitempty"` +} + +type MergedBudget struct { + Hourly *MergedBudgetWindow `json:"hourly,omitempty"` + Daily *MergedBudgetWindow `json:"daily,omitempty"` + Monthly *MergedBudgetWindow `json:"monthly,omitempty"` +} + +type MergedBudgetWindow struct { + SoftCapUSD float64 `json:"soft_cap_usd,omitempty"` + HardCapUSD float64 `json:"hard_cap_usd,omitempty"` +} + +type MergedPromptCapture struct { + Enabled bool `json:"enabled"` + RedactPii bool `json:"redact_pii"` +} + +type MergedRetention struct { + Enabled bool `json:"enabled"` + Days int `json:"days"` +} + +// mergeGuardrails computes the effective guardrail spec applied at the +// proxy, given the referencing policies and the account's guardrail +// catalogue. Policy enabled-ness is the caller's responsibility — only +// enabled policies should be passed in. +// +// Merge rules: +// - Model allowlist: union of allowlists across policies that enable it. +// - Token / Budget: most-restrictive (min of non-zero caps) per window. +// - Prompt capture: enabled if any policy enables it; redact_pii sticks +// if any enabling policy turns it on. +// - Retention: enabled if any enables it; smallest non-zero days wins. +func mergeGuardrails(policies []*types.Policy, byID map[string]*types.Guardrail) MergedGuardrails { + merged := MergedGuardrails{} + allowlist := make(map[string]struct{}) + allowlistEnabled := false + + for _, policy := range policies { + for _, gID := range policy.GuardrailIDs { + g, ok := byID[gID] + if !ok || g == nil { + continue + } + mergeGuardrail(g, &merged, allowlist, &allowlistEnabled) + } + } + + if allowlistEnabled { + merged.ModelAllowlist = make([]string, 0, len(allowlist)) + for m := range allowlist { + merged.ModelAllowlist = append(merged.ModelAllowlist, m) + } + sort.Strings(merged.ModelAllowlist) + } + return merged +} + +// mergeGuardrail folds a single guardrail's enabled checks into the +// running merge: model-allowlist models join the shared set (and flip +// allowlistEnabled), and prompt-capture / redact-pii stick once any +// enabling guardrail turns them on. +// +// TokenLimits, Budget, and Retention have moved off guardrails — token +// and budget caps now live on the Policy itself (Policy.Limits) and +// retention moves to account-level Settings — so they are not merged here. +func mergeGuardrail(g *types.Guardrail, merged *MergedGuardrails, allowlist map[string]struct{}, allowlistEnabled *bool) { + if g.Checks.ModelAllowlist.Enabled { + *allowlistEnabled = true + for _, m := range g.Checks.ModelAllowlist.Models { + if m != "" { + allowlist[m] = struct{}{} + } + } + } + if g.Checks.PromptCapture.Enabled { + merged.PromptCapture.Enabled = true + if g.Checks.PromptCapture.RedactPii { + merged.PromptCapture.RedactPii = true + } + } +} diff --git a/management/internals/modules/agentnetwork/synthesizer_guardrail_realstore_test.go b/management/internals/modules/agentnetwork/synthesizer_guardrail_realstore_test.go new file mode 100644 index 000000000..8ed4910da --- /dev/null +++ b/management/internals/modules/agentnetwork/synthesizer_guardrail_realstore_test.go @@ -0,0 +1,178 @@ +package agentnetwork + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/store" +) + +// decodeServiceGuardrailConfig pulls the llm_guardrail middleware config off the +// synthesised service's single target. +func decodeServiceGuardrailConfig(t *testing.T, svc *rpservice.Service) guardrailConfig { + t.Helper() + require.NotEmpty(t, svc.Targets, "synth service must carry a target") + for _, mw := range svc.Targets[0].Options.Middlewares { + if mw.ID == middlewareIDLLMGuardrail { + var cfg guardrailConfig + require.NoError(t, json.Unmarshal(mw.ConfigJSON, &cfg), "guardrail config must decode") + return cfg + } + } + t.Fatal("llm_guardrail middleware not present on synthesised service") + return guardrailConfig{} +} + +// decodeMiddlewareRawConfig returns the raw ConfigJSON bytes for the named +// middleware on the synth service's target, or fails the test. +func decodeMiddlewareRawConfig(t *testing.T, svc *rpservice.Service, id string) []byte { + t.Helper() + require.NotEmpty(t, svc.Targets, "synth service must carry a target") + for _, mw := range svc.Targets[0].Options.Middlewares { + if mw.ID == id { + return mw.ConfigJSON + } + } + t.Fatalf("middleware %q not present on synthesised service", id) + return nil +} + +// saveGuardrailAndPolicy persists a guardrail with prompt capture + redact + a +// model allowlist, referenced by one enabled policy. Shared by the GC-3 tests. +func saveGuardrailAndPolicy(t *testing.T, ctx context.Context, s store.Store, provider *types.Provider) { + t.Helper() + guardrail := &types.Guardrail{ + ID: "ainguard-1", + AccountID: testAccountID, + Name: "strict", + Checks: types.GuardrailChecks{ + ModelAllowlist: types.GuardrailModelAllowlist{Enabled: true, Models: []string{"gpt-5.4"}}, + PromptCapture: types.GuardrailPromptCapture{Enabled: true, RedactPii: true}, + }, + } + require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, guardrail)) + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", guardrail.ID))) +} + +// TestSynthesizeServices_RealStore_PromptCaptureAccountIsSoleControl is the +// GC-3 contract: the account master switch (EnablePromptCollection) is the +// SOLE control for capture enablement. Policy-level guardrail prompt_capture is +// ignored for enablement — operators don't need to attach a capture guardrail +// to a policy just to turn capture on for the account. Off by default. +func TestSynthesizeServices_RealStore_PromptCaptureAccountIsSoleControl(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() + + // Account collection master switch OFF (default). + require.NoError(t, s.SaveAgentNetworkSettings(ctx, newSynthTestSettings())) + saveGuardrailAndPolicy(t, ctx, s, newSynthTestProvider()) + + services, err := SynthesizeServices(ctx, s, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + cfg := decodeServiceGuardrailConfig(t, services[0]) + assert.Equal(t, []string{"gpt-5.4"}, cfg.ModelAllowlist, + "model allowlist is a pure policy guardrail and must always reach the config") + assert.False(t, cfg.PromptCapture.Enabled, + "prompt capture must be off when the account toggle is off, even with a capture-enabled guardrail") +} + +// TestSynthesizeServices_RealStore_PromptCaptureFlowsWhenAccountOptsIn proves +// the account toggle is sufficient on its own — even with NO guardrail +// attached to the policy, capture fires when the account opts in. Redact is +// the OR of account + guardrail. +func TestSynthesizeServices_RealStore_PromptCaptureFlowsWhenAccountOptsIn(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.EnablePromptCollection = true + require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings)) + + // Save a provider and a policy with NO guardrails attached — proves the + // account toggle is sufficient on its own. + provider := newSynthTestProvider() + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", ""))) + + services, err := SynthesizeServices(ctx, s, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + cfg := decodeServiceGuardrailConfig(t, services[0]) + assert.True(t, cfg.PromptCapture.Enabled, + "account toggle alone must enable capture; no guardrail attachment required") +} + +// TestSynthesizeServices_RealStore_AccountRedactWithoutGuardrailRedact proves +// the redact OR-merge from the account side: account RedactPii on, guardrail +// redact off, capture on at both levels. +func TestSynthesizeServices_RealStore_AccountRedactWithoutGuardrailRedact(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.EnablePromptCollection = true + settings.RedactPii = true + require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings)) + + provider := newSynthTestProvider() + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + guardrail := &types.Guardrail{ + ID: "ainguard-noredact", + AccountID: testAccountID, + Name: "capture-only", + Checks: types.GuardrailChecks{ + PromptCapture: types.GuardrailPromptCapture{Enabled: true, RedactPii: false}, + }, + } + require.NoError(t, s.SaveAgentNetworkGuardrail(ctx, guardrail)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", guardrail.ID))) + + services, err := SynthesizeServices(ctx, s, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + cfg := decodeServiceGuardrailConfig(t, services[0]) + assert.True(t, cfg.PromptCapture.Enabled, "capture on (account + guardrail)") + assert.True(t, cfg.PromptCapture.RedactPii, "account RedactPii must apply even when the guardrail leaves it off (OR)") +} + +// TestSynthesizeServices_RealStore_NoGuardrail_CaptureOff pins the default: +// with no guardrail referenced, the synth service's guardrail config has prompt +// capture disabled and an empty allowlist. This is the "off by default" baseline +// the account switch must preserve. +func TestSynthesizeServices_RealStore_NoGuardrail_CaptureOff(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() + + 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", ""))) + + services, err := SynthesizeServices(ctx, s, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1, "exactly one synth service expected") + + cfg := decodeServiceGuardrailConfig(t, services[0]) + assert.Empty(t, cfg.ModelAllowlist, "no guardrail → no allowlist") + assert.False(t, cfg.PromptCapture.Enabled, "no guardrail → prompt capture off by default") + assert.False(t, cfg.PromptCapture.RedactPii, "no guardrail → redact off by default") +} diff --git a/management/internals/modules/agentnetwork/synthesizer_log_collection_realstore_test.go b/management/internals/modules/agentnetwork/synthesizer_log_collection_realstore_test.go new file mode 100644 index 000000000..9aa2a0abe --- /dev/null +++ b/management/internals/modules/agentnetwork/synthesizer_log_collection_realstore_test.go @@ -0,0 +1,70 @@ +package agentnetwork + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/store" +) + +// TestSynthesizeServices_RealStore_LogCollectionOff_SuppressesAccessLog drives the +// happy default: account settings ship with EnableLogCollection=false, so the +// synthesised target opts out of access-log emission (DisableAccessLog=true) and +// the proto mapping the proxy receives reflects that. +func TestSynthesizeServices_RealStore_LogCollectionOff_SuppressesAccessLog(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() + + 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", ""))) + + services, err := SynthesizeServices(ctx, s, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1, "exactly one synth service expected") + require.NotEmpty(t, services[0].Targets, "synth service must carry a target") + assert.True(t, services[0].Targets[0].Options.DisableAccessLog, + "EnableLogCollection=false (default) must produce DisableAccessLog=true on the synth target") + + mapping := services[0].ToProtoMapping(rpservice.Update, "", rpproxy.OIDCValidationConfig{}) + require.NotEmpty(t, mapping.GetPath(), "proto mapping must carry a path") + assert.True(t, mapping.GetPath()[0].GetOptions().GetDisableAccessLog(), + "proto mapping must propagate DisableAccessLog=true so the proxy suppresses access-log emission") +} + +// TestSynthesizeServices_RealStore_LogCollectionOn_PermitsAccessLog asserts the +// inverse: once the account opts in, the synth target leaves DisableAccessLog +// at its default false and the proto wire stays unset. +func TestSynthesizeServices_RealStore_LogCollectionOn_PermitsAccessLog(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)) + provider := newSynthTestProvider() + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", ""))) + + services, err := SynthesizeServices(ctx, s, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1, "exactly one synth service expected") + require.NotEmpty(t, services[0].Targets, "synth service must carry a target") + assert.False(t, services[0].Targets[0].Options.DisableAccessLog, + "EnableLogCollection=true must leave DisableAccessLog=false on the synth target") + + mapping := services[0].ToProtoMapping(rpservice.Update, "", rpproxy.OIDCValidationConfig{}) + require.NotEmpty(t, mapping.GetPath(), "proto mapping must carry a path") + assert.False(t, mapping.GetPath()[0].GetOptions().GetDisableAccessLog(), + "proto mapping must propagate DisableAccessLog=false so access-log emission stays on") +} diff --git a/management/internals/modules/agentnetwork/synthesizer_parser_redact_realstore_test.go b/management/internals/modules/agentnetwork/synthesizer_parser_redact_realstore_test.go new file mode 100644 index 000000000..5f42c3b39 --- /dev/null +++ b/management/internals/modules/agentnetwork/synthesizer_parser_redact_realstore_test.go @@ -0,0 +1,145 @@ +package agentnetwork + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/store" +) + +// parserRedactConfig mirrors the on-wire shape of the redact + capture knobs +// that both llm_request_parser and llm_response_parser unmarshal. We don't +// import the proxy-side packages from a management test (cross-module), so we +// decode the JSON directly and assert on the fields that are part of the +// synth contract. +type parserRedactConfig struct { + RedactPii bool `json:"redact_pii,omitempty"` + CapturePrompt *bool `json:"capture_prompt,omitempty"` // present only on the request parser + CaptureCompletion *bool `json:"capture_completion,omitempty"` // present only on the response parser +} + +// TestSynthesizeServices_RealStore_ParserConfigsCarryRedactPii is the +// management-side contract test for the request/response parser redaction +// wiring. When settings.RedactPii is true, the synthesised middleware chain +// MUST stamp redact_pii=true on both llm_request_parser and llm_response_parser +// configs — otherwise the parsers ship raw prompts / completions to the +// access log even though the account has opted in. This is exactly the live +// leak path that motivated the parser-side redaction in the first place. +func TestSynthesizeServices_RealStore_ParserConfigsCarryRedactPii(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.RedactPii = true + settings.EnablePromptCollection = true + require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings)) + + provider := newSynthTestProvider() + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", ""))) + + services, err := SynthesizeServices(ctx, s, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1, "exactly one synth service expected") + + for _, parserID := range []string{middlewareIDLLMRequestParser, middlewareIDLLMResponseParser} { + raw := decodeMiddlewareRawConfig(t, services[0], parserID) + var cfg parserRedactConfig + require.NoError(t, json.Unmarshal(raw, &cfg), "%s config must be valid JSON", parserID) + assert.True(t, cfg.RedactPii, "%s config must carry redact_pii=true when settings.RedactPii is on (otherwise the parser ships raw prompts/completions to the access log)", parserID) + } + // The capture flag is set explicitly to enable_prompt_collection on each + // parser. With it on here, both must allow emission. + reqCfg := decodeParserConfig(t, services[0], middlewareIDLLMRequestParser) + require.NotNil(t, reqCfg.CapturePrompt, "request parser must carry an explicit capture_prompt") + assert.True(t, *reqCfg.CapturePrompt, "capture_prompt=true when EnablePromptCollection=true") + respCfg := decodeParserConfig(t, services[0], middlewareIDLLMResponseParser) + require.NotNil(t, respCfg.CaptureCompletion, "response parser must carry an explicit capture_completion") + assert.True(t, *respCfg.CaptureCompletion, "capture_completion=true when EnablePromptCollection=true") +} + +// decodeParserConfig is a small helper around decodeMiddlewareRawConfig that +// also unmarshals into parserRedactConfig. +func decodeParserConfig(t *testing.T, svc *rpservice.Service, parserID string) parserRedactConfig { + t.Helper() + raw := decodeMiddlewareRawConfig(t, svc, parserID) + var cfg parserRedactConfig + require.NoError(t, json.Unmarshal(raw, &cfg), "%s config must be valid JSON", parserID) + return cfg +} + +// TestSynthesizeServices_RealStore_ParserConfigsSuppressCaptureWhenLogCollectionOnly +// is the contract test for the bug: enable_log_collection=true with +// enable_prompt_collection=false MUST result in capture_prompt=false on the +// request parser AND capture_completion=false on the response parser, so the +// access-log row stays metadata-only (provider, model, tokens, cost) and +// carries NO prompt input nor response output. Without this, operators who +// want billing-style logs end up with raw user prompts and model outputs in +// every access-log entry. +func TestSynthesizeServices_RealStore_ParserConfigsSuppressCaptureWhenLogCollectionOnly(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 // operator wants logs ON + settings.EnablePromptCollection = false // but NOT content capture + require.NoError(t, s.SaveAgentNetworkSettings(ctx, settings)) + + provider := newSynthTestProvider() + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + require.NoError(t, s.SaveAgentNetworkPolicy(ctx, newSynthTestPolicy(provider.ID, "grp-eng", ""))) + + services, err := SynthesizeServices(ctx, s, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + reqCfg := decodeParserConfig(t, services[0], middlewareIDLLMRequestParser) + require.NotNil(t, reqCfg.CapturePrompt, "request parser must carry an explicit capture_prompt gate") + assert.False(t, *reqCfg.CapturePrompt, "capture_prompt MUST be false when EnablePromptCollection is off — otherwise llm.request_prompt_raw leaks user input into the access log") + + respCfg := decodeParserConfig(t, services[0], middlewareIDLLMResponseParser) + require.NotNil(t, respCfg.CaptureCompletion, "response parser must carry an explicit capture_completion gate") + assert.False(t, *respCfg.CaptureCompletion, "capture_completion MUST be false when EnablePromptCollection is off — otherwise llm.response_completion leaks model output into the access log") +} + +// TestSynthesizeServices_RealStore_ParserConfigsOmitRedactPiiWhenOff proves +// the inverse: with the account toggle off, the parser configs stay clean (no +// redact_pii field, which the parsers treat as zero / no redaction). This is +// the operator-opt-out path — the access log keeps raw prompts/completions +// for debugging until the operator opts in. +func TestSynthesizeServices_RealStore_ParserConfigsOmitRedactPiiWhenOff(t *testing.T) { + ctx := context.Background() + s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + defer cleanup() + + // Default settings: RedactPii = false. + 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", ""))) + + services, err := SynthesizeServices(ctx, s, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + for _, parserID := range []string{middlewareIDLLMRequestParser, middlewareIDLLMResponseParser} { + raw := decodeMiddlewareRawConfig(t, services[0], parserID) + // Inspect the decoded JSON directly: a struct decode would also pass + // if redact_pii were present-but-false. The contract is that the key + // is omitted entirely while the account toggle is off. + var rawCfg map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &rawCfg), "%s config must be valid JSON", parserID) + assert.NotContains(t, rawCfg, "redact_pii", + "%s config must omit redact_pii entirely while the account toggle is off", parserID) + } +} diff --git a/management/internals/modules/agentnetwork/synthesizer_realstore_test.go b/management/internals/modules/agentnetwork/synthesizer_realstore_test.go new file mode 100644 index 000000000..1e07c0e81 --- /dev/null +++ b/management/internals/modules/agentnetwork/synthesizer_realstore_test.go @@ -0,0 +1,174 @@ +package agentnetwork + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/account" + "github.com/netbirdio/netbird/management/server/store" + nbtypes "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// decodeServiceRouterConfig finds the llm_router middleware on the synthesised +// service's single target and decodes its config — the model→provider routing +// table the proxy authorises against. +func decodeServiceRouterConfig(t *testing.T, svc *rpservice.Service) routerConfig { + t.Helper() + require.NotEmpty(t, svc.Targets, "synth service must carry a target") + for _, mw := range svc.Targets[0].Options.Middlewares { + if mw.ID == middlewareIDLLMRouter { + var cfg routerConfig + require.NoError(t, json.Unmarshal(mw.ConfigJSON, &cfg), "router config must decode") + return cfg + } + } + t.Fatal("llm_router middleware not present on synthesised service") + return routerConfig{} +} + +// decodeMappingRouterConfig is the proto-wire equivalent: it pulls the +// llm_router config off the ProxyMapping the proxy actually receives. +func decodeMappingRouterConfig(t *testing.T, m *proto.ProxyMapping) routerConfig { + t.Helper() + require.NotEmpty(t, m.GetPath(), "mapping must carry a path") + for _, mw := range m.GetPath()[0].GetOptions().GetMiddlewares() { + if mw.GetId() == middlewareIDLLMRouter { + var cfg routerConfig + require.NoError(t, json.Unmarshal(mw.GetConfigJson(), &cfg), "wire router config must decode") + return cfg + } + } + t.Fatal("llm_router middleware not present on proxy mapping") + return routerConfig{} +} + +// TestSynthesizeServices_RealStore_SurvivesStatusToggle drives synthesis through +// a REAL sqlite store (Save → gorm/JSON serialize → reload → decrypt) instead of +// a MockStore, so it exercises the field round-trip that a provider/policy edit +// actually hits. Mock-based tests can't catch a field that dies in persistence; +// this one can. It then performs the exact operation that reproduced the live +// 403 — disable then re-enable the provider — and asserts the re-enabled state +// is fully routable again. +func TestSynthesizeServices_RealStore_SurvivesStatusToggle(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() + + 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", ""))) + + assertRoutable := func(t *testing.T, stage string) { + services, err := SynthesizeServices(ctx, s, testAccountID) + require.NoError(t, err, stage) + require.Len(t, services, 1, "%s: exactly one synth service expected", stage) + svc := services[0] + + assert.True(t, svc.Private, "%s: synth service must be Private after store round-trip", stage) + assert.Equal(t, []string{"grp-eng"}, svc.AccessGroups, "%s: AccessGroups must survive the round-trip", stage) + + m := svc.ToProtoMapping(rpservice.Update, "", rpproxy.OIDCValidationConfig{}) + assert.True(t, m.GetPrivate(), "%s: proto mapping Private must be true (proxy gates tunnel-peer auth on it)", stage) + + cfg := decodeServiceRouterConfig(t, svc) + require.Len(t, cfg.Providers, 1, "%s: the enabled+linked provider must appear in the router config", stage) + assert.Equal(t, []string{"gpt-5.4"}, cfg.Providers[0].Models, "%s: provider models must reach the route", stage) + assert.Equal(t, []string{"grp-eng"}, cfg.Providers[0].AllowedGroupIDs, "%s: policy source groups must reach the route", stage) + } + + assertRoutable(t, "initial") + + provider.Enabled = false + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + disabled, err := SynthesizeServices(ctx, s, testAccountID) + require.NoError(t, err, "synthesis must not error with a disabled provider") + for _, svc := range disabled { + assert.Empty(t, decodeServiceRouterConfig(t, svc).Providers, + "a disabled provider must not appear in the router config (otherwise it would route while off)") + } + + provider.Enabled = true + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + assertRoutable(t, "after disable->enable") +} + +// captureController is a proxy.Controller that records the mappings reconcile +// pushes, so the test can inspect the exact wire payload — Private flag and +// router config included. +type captureController struct { + rpproxy.Controller + pushed []*proto.ProxyMapping +} + +func (c *captureController) GetOIDCValidationConfig() rpproxy.OIDCValidationConfig { + return rpproxy.OIDCValidationConfig{} +} + +func (c *captureController) SendServiceUpdateToCluster(_ context.Context, _ string, update *proto.ProxyMapping, _ string) { + c.pushed = append(c.pushed, update) +} + +// noopAccountManager satisfies the reconcile path's accountManager dependency. +type noopAccountManager struct { + account.Manager +} + +func (noopAccountManager) UpdateAccountPeers(context.Context, string, nbtypes.UpdateReason) {} + +// TestReconcile_RealStore_PushesPrivateAfterStatusToggle reproduces the live +// path end-to-end below the gRPC boundary: a real store + the real +// managerImpl.reconcile + a capturing proxy controller. It runs the operation +// that broke in production — provider disable then re-enable — and asserts the +// mapping reconcile pushes to the cluster after re-enable is Private=true and +// carries the routable provider. If reconcile ever pushes private=false (the +// symptom that left UserGroups empty → no_authorised_provider), this fails. +func TestReconcile_RealStore_PushesPrivateAfterStatusToggle(t *testing.T) { + ctx := context.Background() + s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + defer cleanup() + + 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", ""))) + + ctrl := &captureController{} + m := &managerImpl{ + store: s, + accountManager: noopAccountManager{}, + proxyController: ctrl, + reconcileCache: make(map[string]map[string]*proto.ProxyMapping), + } + + m.reconcile(ctx, testAccountID) // initial, provider enabled + + provider.Enabled = false + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + m.reconcile(ctx, testAccountID) // disabled + + provider.Enabled = true + require.NoError(t, s.SaveAgentNetworkProvider(ctx, provider)) + m.reconcile(ctx, testAccountID) // re-enabled — the reproduction step + + require.NotEmpty(t, ctrl.pushed, "reconcile must push at least one mapping") + last := ctrl.pushed[len(ctrl.pushed)-1] + + assert.Equal(t, newSynthTestSettings().Endpoint(), last.GetDomain(), "synth domain on the wire") + assert.True(t, last.GetPrivate(), + "reconcile-pushed mapping after re-enable MUST be Private=true; a false here is the exact bug — the proxy skips ValidateTunnelPeer, UserGroups stays empty, and llm_router denies no_authorised_provider") + + cfg := decodeMappingRouterConfig(t, last) + require.Len(t, cfg.Providers, 1, "re-enabled provider must be back in the pushed router config") + assert.Equal(t, []string{"gpt-5.4"}, cfg.Providers[0].Models, "model must be routable again after re-enable") + assert.Equal(t, []string{"grp-eng"}, cfg.Providers[0].AllowedGroupIDs, "authorised groups must be present after re-enable") +} diff --git a/management/internals/modules/agentnetwork/synthesizer_test.go b/management/internals/modules/agentnetwork/synthesizer_test.go new file mode 100644 index 000000000..206d1d12a --- /dev/null +++ b/management/internals/modules/agentnetwork/synthesizer_test.go @@ -0,0 +1,1221 @@ +package agentnetwork + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/status" +) + +const ( + testAccountID = "acct-1" + testCluster = "eu.proxy.netbird.io" + testSubdomain = "violet" + testEndpoint = "violet.eu.proxy.netbird.io" +) + +func newSynthTestSettings() *types.Settings { + return &types.Settings{ + AccountID: testAccountID, + Cluster: testCluster, + Subdomain: testSubdomain, + } +} + +func newSynthTestProvider() *types.Provider { + return &types.Provider{ + ID: "prov-1", + AccountID: testAccountID, + ProviderID: "openai_api", + Name: "OpenAI", + UpstreamURL: "https://api.openai.com", + APIKey: "sk-test-key", + Enabled: true, + Models: []types.ProviderModel{{ID: "gpt-5.4", InputPer1k: 0.0025, OutputPer1k: 0.015}}, + SessionPrivateKey: "test-priv-key", + SessionPublicKey: "test-pub-key", + CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } +} + +func newSynthTestPolicy(providerID, sourceGroupID, guardrailID string) *types.Policy { + policy := &types.Policy{ + ID: "pol-1", + AccountID: testAccountID, + Name: "engineers", + Enabled: true, + SourceGroups: []string{sourceGroupID}, + DestinationProviderIDs: []string{providerID}, + } + if guardrailID != "" { + policy.GuardrailIDs = []string{guardrailID} + } + return policy +} + +// expectSynthBaseInputs wires the four reads the new synthesiser issues +// in the happy path: settings, providers, policies, guardrails. +func expectSynthBaseInputs(mockStore *store.MockStore, ctx context.Context, settings *types.Settings, providers []*types.Provider, policies []*types.Policy, guardrails []*types.Guardrail) { + if settings == nil { + mockStore.EXPECT(). + GetAgentNetworkSettings(ctx, store.LockingStrengthNone, testAccountID). + Return(nil, status.Errorf(status.NotFound, "agent network settings not found")) + return + } + mockStore.EXPECT(). + GetAgentNetworkSettings(ctx, store.LockingStrengthNone, testAccountID). + Return(settings, nil) + mockStore.EXPECT(). + GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, testAccountID). + Return(providers, nil) + if hasEnabled(providers) { + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, testAccountID). + Return(policies, nil) + if hasEnabledPolicy(policies) { + mockStore.EXPECT(). + GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, testAccountID). + Return(guardrails, nil) + } + } +} + +func hasEnabled(providers []*types.Provider) bool { + for _, p := range providers { + if p != nil && p.Enabled { + return true + } + } + return false +} + +func hasEnabledPolicy(policies []*types.Policy) bool { + for _, p := range policies { + if p != nil && p.Enabled { + return true + } + } + return false +} + +func TestSynthesizeServices_HappyPath(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + openai := newSynthTestProvider() + anthropic := &types.Provider{ + ID: "prov-2", + AccountID: testAccountID, + ProviderID: "anthropic_api", + Name: "Anthropic", + UpstreamURL: "https://api.anthropic.com", + APIKey: "sk-ant-secret", + Enabled: true, + Models: []types.ProviderModel{{ID: "claude-opus-4-7"}}, + SessionPrivateKey: "ant-priv", + SessionPublicKey: "ant-pub", + CreatedAt: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + } + + policyEng := newSynthTestPolicy(openai.ID, "grp-eng", "") + policyEng.ID = "pol-eng" + policyOps := newSynthTestPolicy(anthropic.ID, "grp-ops", "") + policyOps.ID = "pol-ops" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{openai, anthropic}, + []*types.Policy{policyEng, policyOps}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err, "synthesis must succeed") + require.Len(t, services, 1, "exactly one service per account") + + svc := services[0] + assert.Equal(t, "agent-net-svc-acct-1", svc.ID, "service id is account-scoped") + assert.Equal(t, testAccountID, svc.AccountID, "service inherits account ID") + assert.Equal(t, testEndpoint, svc.Domain, "domain is settings.Endpoint() (subdomain.cluster)") + assert.Equal(t, testCluster, svc.ProxyCluster, "proxy cluster comes from settings") + assert.Equal(t, rpservice.ModeHTTP, svc.Mode, "synthesised services are HTTP mode") + assert.True(t, svc.Private, "synthesised services are always private") + assert.True(t, svc.Enabled, "synthesised services are enabled when emitted") + assert.Equal(t, []string{"grp-eng", "grp-ops"}, svc.AccessGroups, + "access groups union both policies' source groups (tunnel-peer auth)") + + require.Len(t, svc.Targets, 1, "single cluster target") + target := svc.Targets[0] + assert.Equal(t, rpservice.TargetTypeCluster, target.TargetType, "target type is cluster") + assert.Equal(t, testCluster, target.TargetId, "target id is the cluster address") + assert.Equal(t, "noop.invalid", target.Host, "host is the placeholder; router rewrites at request time") + assert.Equal(t, uint16(443), target.Port, "placeholder port") + assert.Equal(t, "https", target.Protocol, "placeholder scheme") + assert.True(t, target.Options.DirectUpstream, "synth targets imply direct upstream") + assert.True(t, target.Options.AgentNetwork, "synth targets must be flagged as agent_network") + + mws := target.Options.Middlewares + require.Len(t, mws, 8, "eight middlewares: request_parser, router, limit_check, identity_inject, guardrail, limit_record, cost_meter, response_parser") + assert.Equal(t, middlewareIDLLMRequestParser, mws[0].ID, "first middleware is the request parser") + assert.Equal(t, rpservice.MiddlewareSlotOnRequest, mws[0].Slot, "request parser runs on_request") + // Request parser carries the capture_prompt gate sourced from + // settings.EnablePromptCollection. The synth-test settings default + // EnablePromptCollection=false, so capture is off and the access-log row + // will not carry prompt content. + assert.JSONEq(t, `{"capture_prompt":false}`, string(mws[0].ConfigJSON), "request parser config must carry capture_prompt from synth") + + assert.Equal(t, middlewareIDLLMRouter, mws[1].ID, "second middleware is the router") + assert.Equal(t, rpservice.MiddlewareSlotOnRequest, mws[1].Slot, "router runs on_request") + assert.True(t, mws[1].CanMutate, "router must carry CanMutate=true; without it the framework drops the auth-header strip/inject AND the upstream rewrite, leaving the proxy to dial the placeholder noop.invalid") + require.NotEmpty(t, mws[1].ConfigJSON, "router config JSON must be populated") + + var routerCfg routerConfig + require.NoError(t, json.Unmarshal(mws[1].ConfigJSON, &routerCfg), "router config must unmarshal") + require.Len(t, routerCfg.Providers, 2, "both providers must reach the router") + assert.Equal(t, openai.ID, routerCfg.Providers[0].ID, "openai is first by created_at") + assert.Equal(t, "Bearer sk-test-key", routerCfg.Providers[0].AuthHeaderValue, "openai auth header value substitutes the API key") + assert.Equal(t, "Authorization", routerCfg.Providers[0].AuthHeaderName, "openai uses Authorization header") + assert.Equal(t, "https", routerCfg.Providers[0].UpstreamScheme, "openai scheme") + assert.Equal(t, "api.openai.com", routerCfg.Providers[0].UpstreamHost, "openai host") + assert.Equal(t, []string{"grp-eng"}, routerCfg.Providers[0].AllowedGroupIDs, "openai inherits policyEng's source groups") + assert.Equal(t, []string{"gpt-5.4"}, routerCfg.Providers[0].Models, + "the provider's configured model IDs must reach the router route — otherwise the model never matches and llm_router denies model_not_routable") + assert.Equal(t, anthropic.ID, routerCfg.Providers[1].ID, "anthropic follows openai by created_at") + assert.Equal(t, "sk-ant-secret", routerCfg.Providers[1].AuthHeaderValue, "anthropic value is the raw API key") + assert.Equal(t, "x-api-key", routerCfg.Providers[1].AuthHeaderName, "anthropic uses x-api-key header") + assert.Equal(t, []string{"grp-ops"}, routerCfg.Providers[1].AllowedGroupIDs, "anthropic inherits policyOps' source groups") + assert.Equal(t, []string{"claude-opus-4-7"}, routerCfg.Providers[1].Models, "anthropic's configured model ID must reach its route") + + assert.Equal(t, middlewareIDLLMLimitCheck, mws[2].ID, + "limit_check sits between router and identity_inject so deny paths skip header-stamp work") + assert.Equal(t, rpservice.MiddlewareSlotOnRequest, mws[2].Slot, "limit_check runs on_request") + + assert.Equal(t, middlewareIDLLMIdentityInject, mws[3].ID, "fourth middleware is identity inject") + assert.Equal(t, rpservice.MiddlewareSlotOnRequest, mws[3].Slot, "identity inject runs on_request") + assert.True(t, mws[3].CanMutate, "identity inject must carry CanMutate=true so its HeadersAdd / HeadersRemove pass the framework's mutation gate") + require.NotEmpty(t, mws[3].ConfigJSON, "identity inject config JSON must be populated even when no provider needs injection") + + assert.Equal(t, middlewareIDLLMGuardrail, mws[4].ID, "fifth middleware is the guardrail") + assert.Equal(t, rpservice.MiddlewareSlotOnRequest, mws[4].Slot, "guardrail runs on_request") + require.NotEmpty(t, mws[4].ConfigJSON, "guardrail config JSON must be populated") + + assert.Equal(t, middlewareIDLLMLimitRecord, mws[5].ID, + "limit_record sits FIRST in the response section so it RUNS LAST at runtime — needs cost_meter + response_parser to have stamped tokens / cost first") + assert.Equal(t, rpservice.MiddlewareSlotOnResponse, mws[5].Slot, "limit_record runs on_response") + + assert.Equal(t, middlewareIDCostMeter, mws[6].ID, "seventh middleware is the cost meter") + assert.Equal(t, rpservice.MiddlewareSlotOnResponse, mws[6].Slot, "cost meter runs on_response") + assert.Equal(t, []byte("{}"), mws[6].ConfigJSON, "cost meter carries an explicit empty config") + + assert.Equal(t, middlewareIDLLMResponseParser, mws[7].ID, "eighth middleware is the response parser") + assert.Equal(t, rpservice.MiddlewareSlotOnResponse, mws[7].Slot, "response parser runs on_response") +} + +func TestSynthesizeServices_NoSettings_ReturnsNil(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + expectSynthBaseInputs(mockStore, ctx, nil, nil, nil, nil) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + assert.Empty(t, services, "missing settings row must yield no synth") +} + +func TestSynthesizeServices_NoProviders_ReturnsNil(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), []*types.Provider{}, nil, nil) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + assert.Empty(t, services, "settings present but no providers must yield no synth") +} + +func TestSynthesizeServices_DisabledProvider_NoService(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + provider := newSynthTestProvider() + provider.Enabled = false + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{provider}, nil, nil) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + assert.Empty(t, services, "disabled provider must not synthesise a service") +} + +func TestSynthesizeServices_DisabledPolicy_NoService(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + provider := newSynthTestProvider() + policy := newSynthTestPolicy(provider.ID, "grp-eng", "") + policy.Enabled = false + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{provider}, []*types.Policy{policy}, nil) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + assert.Empty(t, services, "disabled policy must not trigger synthesis") +} + +func TestSynthesizeServices_RouterConfigOrdering(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + first := newSynthTestProvider() + first.ID = "prov-first" + first.CreatedAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + second := newSynthTestProvider() + second.ID = "prov-second" + second.ProviderID = "anthropic_api" + second.UpstreamURL = "https://api.anthropic.com" + second.APIKey = "sk-ant" + second.CreatedAt = time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + + third := newSynthTestProvider() + third.ID = "prov-third" + third.ProviderID = "mistral_api" + third.UpstreamURL = "https://api.mistral.ai" + third.APIKey = "sk-mistral" + third.CreatedAt = time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(first.ID, "grp-eng", "") + policy.DestinationProviderIDs = []string{first.ID, second.ID, third.ID} + + // Pass providers in shuffled order to confirm the synth sorts them. + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{second, first, third}, + []*types.Policy{policy}, []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var routerCfg routerConfig + for _, m := range mws { + if m.ID == middlewareIDLLMRouter { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &routerCfg)) + break + } + } + require.Len(t, routerCfg.Providers, 3, "all three providers must be in the router config") + assert.Equal(t, first.ID, routerCfg.Providers[0].ID, "providers ordered by created_at; first is earliest") + assert.Equal(t, third.ID, routerCfg.Providers[1].ID, "second is mid") + assert.Equal(t, second.ID, routerCfg.Providers[2].ID, "third is latest") +} + +func TestSynthesizeServices_PolicyCheckConfig_UnionsSourceGroups(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + provider := newSynthTestProvider() + + policyA := newSynthTestPolicy(provider.ID, "grp-eng", "") + policyA.ID = "pol-a" + policyA.SourceGroups = []string{"grp-eng", "grp-shared"} + policyB := newSynthTestPolicy(provider.ID, "grp-ops", "") + policyB.ID = "pol-b" + policyB.SourceGroups = []string{"grp-ops", "grp-shared"} + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{provider}, + []*types.Policy{policyA, policyB}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var routerCfg routerConfig + for _, m := range mws { + if m.ID == middlewareIDLLMRouter { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &routerCfg)) + break + } + } + require.Len(t, routerCfg.Providers, 1, "single provider authorised by both policies") + assert.Equal(t, provider.ID, routerCfg.Providers[0].ID) + assert.Equal(t, []string{"grp-eng", "grp-ops", "grp-shared"}, routerCfg.Providers[0].AllowedGroupIDs, + "source groups must be unioned and sorted; the duplicate grp-shared collapses") +} + +func TestSynthesizeServices_OrphanProvider_HasEmptyAllowedGroups(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + authorised := newSynthTestProvider() + authorised.ID = "prov-authed" + + orphan := newSynthTestProvider() + orphan.ID = "prov-orphan" + orphan.ProviderID = "anthropic_api" + orphan.UpstreamURL = "https://api.anthropic.com" + orphan.APIKey = "sk-ant" + orphan.CreatedAt = time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) + + // Policy authorises the first provider only. + policy := newSynthTestPolicy(authorised.ID, "grp-eng", "") + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{authorised, orphan}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var routerCfg routerConfig + for _, m := range mws { + if m.ID == middlewareIDLLMRouter { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &routerCfg)) + break + } + } + + // Orphan providers are dropped from the router config entirely. + // The router treats an empty AllowedGroupIDs as a catch-all (right + // default for non-agent-network targets, wrong default here), so + // we don't ship them at all. Peers attempting to call models only + // the orphan claims see model_not_routable; peers calling models + // shared with the authorised provider get routed there. + require.Len(t, routerCfg.Providers, 1, "only the authorised provider reaches the router") + assert.Equal(t, authorised.ID, routerCfg.Providers[0].ID, + "authorised provider must be in router config") + assert.Equal(t, []string{"grp-eng"}, routerCfg.Providers[0].AllowedGroupIDs, + "authorised provider inherits the policy's source groups") +} + +// TestSynthesizeServices_IdentityInject_LiteLLM pins that a LiteLLM +// provider lands in the identity-inject middleware's config with the +// catalog-defined LiteLLM headers, while a non-LiteLLM provider does +// not. Together they prove the middleware is a no-op for accounts that +// don't use LiteLLM and stamps identity for those that do. +func TestSynthesizeServices_IdentityInject_LiteLLM(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + openai := newSynthTestProvider() + openai.ID = "prov-openai" + + litellm := newSynthTestProvider() + litellm.ID = "prov-litellm" + litellm.ProviderID = "litellm_proxy" + litellm.UpstreamURL = "https://litellm.acme.example.com" + litellm.APIKey = "sk-llm-master" + litellm.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policyOpenAI := newSynthTestPolicy(openai.ID, "grp-eng", "") + policyOpenAI.ID = "pol-openai" + policyLiteLLM := newSynthTestPolicy(litellm.ID, "grp-eng", "") + policyLiteLLM.ID = "pol-litellm" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{openai, litellm}, + []*types.Policy{policyOpenAI, policyLiteLLM}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var injectCfg identityInjectConfig + for _, m := range mws { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + require.Len(t, injectCfg.Providers, 1, + "only providers whose catalog entry declares IdentityInjection should appear in the inject config") + entry := injectCfg.Providers[0] + assert.Equal(t, litellm.ID, entry.ProviderID, + "the LiteLLM provider must be the one identity-stamped, not the OpenAI direct provider") + require.NotNil(t, entry.HeaderPair, "LiteLLM uses the HeaderPair shape") + assert.Nil(t, entry.JSONMetadata, "shapes are mutually exclusive — JSONMetadata must be nil for HeaderPair providers") + assert.Equal(t, "x-litellm-end-user-id", entry.HeaderPair.EndUserIDHeader, + "end-user-id header must come from the catalog entry's IdentityInjection block") + assert.Equal(t, "x-litellm-tags", entry.HeaderPair.TagsHeader) +} + +// TestSynthesizeServices_IdentityInject_Bifrost_OperatorOverrides +// covers the customizable HeaderPair contract. The Bifrost catalog +// entry sets HeaderPair.Customizable=true with x-bf-dim-* defaults +// (placeholders surfaced by the dashboard, NOT authoritative at +// synth time). The wire header names that actually land on the +// inject middleware config come from the provider record's +// IdentityHeaderUserID / IdentityHeaderGroups fields verbatim. This +// lets operators pick between Bifrost's two attribution paths +// (always-on x-bf-lh-* logs metadata vs. label-declared x-bf-dim-* +// telemetry) per provider record without code changes. +// +// Three sub-cases under one fixture: full override, partial +// override (user kept, groups disabled), and ParserID empty so the +// proxy falls back to URL sniffing. +func TestSynthesizeServices_IdentityInject_Bifrost_OperatorOverrides(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + bifrost := newSynthTestProvider() + bifrost.ID = "prov-bifrost" + bifrost.ProviderID = "bifrost" + bifrost.UpstreamURL = "https://bifrost.acme.example.com/openai/v1" + bifrost.APIKey = "sk-bf-key" + bifrost.IdentityHeaderUserID = "x-bf-lh-netbird_user_id" + bifrost.IdentityHeaderGroups = "x-bf-lh-netbird_groups" + bifrost.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(bifrost.ID, "grp-eng", "") + policy.ID = "pol-bifrost" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{bifrost}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var injectCfg identityInjectConfig + for _, m := range mws { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + require.Len(t, injectCfg.Providers, 1, + "single bifrost catalog entry → one inject config target — operator's URL path picks the parser, not the catalog id") + + entry := injectCfg.Providers[0] + assert.Equal(t, bifrost.ID, entry.ProviderID) + require.NotNil(t, entry.HeaderPair, "Bifrost uses HeaderPair shape") + assert.Equal(t, "x-bf-lh-netbird_user_id", entry.HeaderPair.EndUserIDHeader, + "operator-set IdentityHeaderUserID overrides the catalog's x-bf-dim- placeholder — proves the Customizable flag actually swaps the source of truth") + assert.Equal(t, "x-bf-lh-netbird_groups", entry.HeaderPair.TagsHeader, + "operator-set IdentityHeaderGroups overrides the catalog's x-bf-dim- placeholder") + assert.False(t, entry.HeaderPair.TagsInBody, + "body-inject flags stay catalog-owned — Bifrost reads identity from headers, body inject would be a no-op") + assert.False(t, entry.HeaderPair.EndUserIDInBody) +} + +// TestSynthesizeServices_IdentityInject_Bifrost_PartialDisable proves +// that clearing one of the IdentityHeader* fields disables stamping +// for THAT dimension only, leaving the other dimension active. +// Critical because the customizable contract says "empty = disabled +// for that dimension"; if the synth path silently fell back to the +// catalog default for an empty operator value, operators couldn't +// turn off groups while keeping user id (or vice versa). +func TestSynthesizeServices_IdentityInject_Bifrost_PartialDisable(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + bifrost := newSynthTestProvider() + bifrost.ID = "prov-bifrost" + bifrost.ProviderID = "bifrost" + bifrost.UpstreamURL = "https://bifrost.acme.example.com/openai/v1" + bifrost.APIKey = "sk-bf-key" + bifrost.IdentityHeaderUserID = "x-bf-lh-netbird_user_id" + bifrost.IdentityHeaderGroups = "" // operator explicitly disabled groups + bifrost.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(bifrost.ID, "grp-eng", "") + policy.ID = "pol-bifrost" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{bifrost}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var injectCfg identityInjectConfig + for _, m := range mws { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + require.Len(t, injectCfg.Providers, 1) + entry := injectCfg.Providers[0] + require.NotNil(t, entry.HeaderPair, "user-id header is still set so the rule fires") + assert.Equal(t, "x-bf-lh-netbird_user_id", entry.HeaderPair.EndUserIDHeader) + assert.Empty(t, entry.HeaderPair.TagsHeader, + "groups header must be empty — operator cleared it; the inject middleware no-ops on empty header names so groups are NOT stamped") +} + +// TestSynthesizeServices_IdentityInject_Cloudflare_OperatorOverrides +// covers the JSONMetadata customizable contract: Cloudflare's +// catalog entry sets JSONMetadata.Customizable=true with +// netbird_user_id / netbird_groups defaults that the dashboard +// surfaces as placeholders. The actual JSON keys that land inside +// the cf-aig-metadata header come from the provider record's +// IdentityHeaderUserID / IdentityHeaderGroups fields. Reuses the +// same fields HeaderPair customizable does — the dimensions +// (user identity, groups) match; only the wire encoding (JSON key +// vs HTTP header name) differs. +func TestSynthesizeServices_IdentityInject_Cloudflare_OperatorOverrides(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + cf := newSynthTestProvider() + cf.ID = "prov-cf" + cf.ProviderID = "cloudflare_ai_gateway" + cf.UpstreamURL = "https://gateway.ai.cloudflare.com/v1/acct-xyz/my-gateway/openai" + cf.APIKey = "cf-aig-token" + cf.IdentityHeaderUserID = "team_member" + cf.IdentityHeaderGroups = "team_groups" + cf.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(cf.ID, "grp-eng", "") + policy.ID = "pol-cf" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{cf}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var injectCfg identityInjectConfig + for _, m := range mws { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + require.Len(t, injectCfg.Providers, 1) + entry := injectCfg.Providers[0] + require.NotNil(t, entry.JSONMetadata, "Cloudflare uses JSONMetadata shape — single header carrying a JSON object") + assert.Nil(t, entry.HeaderPair, "shapes are mutually exclusive") + assert.Equal(t, "cf-aig-metadata", entry.JSONMetadata.Header, + "the wire header is catalog-owned (cf-aig-metadata) — operator can rename the JSON keys but not the header itself") + assert.Equal(t, "team_member", entry.JSONMetadata.UserKey, + "operator-set IdentityHeaderUserID overrides the catalog's netbird_user_id default — proves the JSONMetadata Customizable flag swaps the source of truth like HeaderPair already does") + assert.Equal(t, "team_groups", entry.JSONMetadata.GroupsKey, + "operator-set IdentityHeaderGroups overrides the catalog's netbird_groups default") +} + +// TestSynthesizeServices_IdentityInject_Portkey_NotCustomizable +// is the JSONMetadata negative case: Portkey's catalog entry leaves +// Customizable=false because Portkey's analytics dashboard reserves +// "_user" and "groups" as fixed JSON keys. An operator-set +// IdentityHeader* on a Portkey provider record must NOT override +// those keys, or Portkey's per-user filters silently break. +func TestSynthesizeServices_IdentityInject_Portkey_NotCustomizable(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + portkey := newSynthTestProvider() + portkey.ID = "prov-portkey" + portkey.ProviderID = "portkey" + portkey.UpstreamURL = "https://api.portkey.ai/v1" + portkey.APIKey = "portkey-account-key" + // Operator set these — but portkey's catalog entry has + // JSONMetadata.Customizable=false, so synth must IGNORE them + // and stick with the catalog's _user / groups defaults. + portkey.IdentityHeaderUserID = "should-be-ignored" + portkey.IdentityHeaderGroups = "should-be-ignored-too" + portkey.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(portkey.ID, "grp-eng", "") + policy.ID = "pol-portkey" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{portkey}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var injectCfg identityInjectConfig + for _, m := range mws { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + require.Len(t, injectCfg.Providers, 1) + entry := injectCfg.Providers[0] + require.NotNil(t, entry.JSONMetadata) + assert.Equal(t, "_user", entry.JSONMetadata.UserKey, + "Portkey's reserved JSON key must hold — Customizable=false on the catalog blocks the operator's override fields") + assert.Equal(t, "groups", entry.JSONMetadata.GroupsKey, + "same fixed-schema guarantee for the groups dimension") +} + +// TestSynthesizeServices_IdentityInject_Bedrock pins Bedrock's cost-allocation +// metadata: a JSONMetadata shape emitting X-Amzn-Bedrock-Request-Metadata with +// the reserved user/group keys, sanitized to Bedrock's accepted charset. +func TestSynthesizeServices_IdentityInject_Bedrock(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + br := newSynthTestProvider() + br.ID = "prov-bedrock" + br.ProviderID = "bedrock_api" + br.UpstreamURL = "https://bedrock-runtime.us-east-1.amazonaws.com" + br.APIKey = "bedrock-bearer" + br.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(br.ID, "grp-eng", "") + policy.ID = "pol-bedrock" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{br}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + var injectCfg identityInjectConfig + for _, m := range services[0].Targets[0].Options.Middlewares { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + require.Len(t, injectCfg.Providers, 1) + entry := injectCfg.Providers[0] + require.NotNil(t, entry.JSONMetadata, "Bedrock uses the JSONMetadata shape for cost-allocation metadata") + assert.Nil(t, entry.HeaderPair, "shapes are mutually exclusive") + assert.Equal(t, "X-Amzn-Bedrock-Request-Metadata", entry.JSONMetadata.Header, + "the caller identity lands in Bedrock's cost-allocation metadata header") + assert.Equal(t, "user", entry.JSONMetadata.UserKey) + assert.Equal(t, "group", entry.JSONMetadata.GroupsKey) + assert.True(t, entry.JSONMetadata.Sanitize, + "Bedrock restricts the metadata value charset, so values must be sanitized") +} + +// TestSynthesizeServices_MetadataDisabled_SuppressesInjection verifies the +// per-provider opt-out: a provider with MetadataDisabled set emits no +// identity-inject entry (Bedrock has no catalog ExtraHeaders, so the whole +// entry is dropped). +func TestSynthesizeServices_MetadataDisabled_SuppressesInjection(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + br := newSynthTestProvider() + br.ID = "prov-bedrock" + br.ProviderID = "bedrock_api" + br.UpstreamURL = "https://bedrock-runtime.us-east-1.amazonaws.com" + br.APIKey = "bedrock-bearer" + br.MetadataDisabled = true + br.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(br.ID, "grp-eng", "") + policy.ID = "pol-bedrock" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{br}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + var injectCfg identityInjectConfig + for _, m := range services[0].Targets[0].Options.Middlewares { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + assert.Empty(t, injectCfg.Providers, + "metadata_disabled must drop the provider's identity-inject entry") +} + +// TestSynthesizeServices_IdentityInject_Vercel pins Vercel AI +// Gateway's wiring: HeaderPair shape with fixed wire names dictated +// by Vercel's Custom Reporting API (ai-reporting-user / +// ai-reporting-tags). Customizable=false on the catalog entry, so +// the synth path takes the catalog values verbatim and ignores any +// IdentityHeader* fields the operator might have set. Renaming +// these headers would just silently disable attribution — Vercel's +// reporting endpoint only matches the canonical names — so the +// fixed contract is the right semantic. +func TestSynthesizeServices_IdentityInject_Vercel(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + vercel := newSynthTestProvider() + vercel.ID = "prov-vercel" + vercel.ProviderID = "vercel_ai_gateway" + vercel.UpstreamURL = "https://ai-gateway.vercel.sh/v1" + vercel.APIKey = "vrc-team-key" + // Operator set these — they MUST be ignored because Vercel's + // catalog entry is non-customizable. Renaming the headers on + // the wire would defeat Vercel's reporting endpoint. + vercel.IdentityHeaderUserID = "should-be-ignored" + vercel.IdentityHeaderGroups = "should-be-ignored-too" + vercel.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(vercel.ID, "grp-eng", "") + policy.ID = "pol-vercel" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{vercel}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var injectCfg identityInjectConfig + for _, m := range mws { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + require.Len(t, injectCfg.Providers, 1) + entry := injectCfg.Providers[0] + require.NotNil(t, entry.HeaderPair, "Vercel uses HeaderPair shape — separate ai-reporting-user / ai-reporting-tags headers, not a JSON blob") + assert.Nil(t, entry.JSONMetadata, "shapes are mutually exclusive") + assert.Equal(t, "ai-reporting-user", entry.HeaderPair.EndUserIDHeader, + "end-user-id header must be Vercel's canonical ai-reporting-user — renaming would silently disable attribution at Vercel's Custom Reporting endpoint") + assert.Equal(t, "ai-reporting-tags", entry.HeaderPair.TagsHeader, + "tags header must be Vercel's canonical ai-reporting-tags for the same reason") + assert.False(t, entry.HeaderPair.TagsInBody, + "Vercel reads from headers — body inject would be a LiteLLM-specific belt-and-suspenders unneeded here") + assert.False(t, entry.HeaderPair.EndUserIDInBody) +} + +// TestSynthesizeServices_IdentityInject_OpenRouter pins OpenRouter's +// wiring: HeaderPair shape with body-only injection. OpenRouter's +// per-user attribution is the OpenAI-standard `user` body field — +// there's no header path and no groups dimension at all. The catalog +// entry sets EndUserIDInBody=true with empty header names; the inject +// middleware writes user identity into the request body but stamps +// nothing on the header surface. Customizable=false so any operator +// IdentityHeader* fields are ignored. +// +// Also asserts the static ExtraHeaders surface: operators provide +// their app URL and display name on the provider record (HTTP-Referer +// and X-OpenRouter-Title), and these land on every upstream request +// so OpenRouter's app rankings / analytics attribute correctly. +func TestSynthesizeServices_IdentityInject_OpenRouter(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + openrouter := newSynthTestProvider() + openrouter.ID = "prov-openrouter" + openrouter.ProviderID = "openrouter" + openrouter.UpstreamURL = "https://openrouter.ai/api/v1" + openrouter.APIKey = "sk-or-v1-acme" + // These would only apply if the catalog entry was Customizable; + // it isn't, so they must be IGNORED. + openrouter.IdentityHeaderUserID = "should-be-ignored" + openrouter.IdentityHeaderGroups = "should-be-ignored-too" + openrouter.ExtraValues = map[string]string{ + "HTTP-Referer": "https://acme.example/agents", + "X-OpenRouter-Title": "Acme Agents", + } + openrouter.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(openrouter.ID, "grp-eng", "") + policy.ID = "pol-openrouter" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{openrouter}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var injectCfg identityInjectConfig + for _, m := range mws { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + require.Len(t, injectCfg.Providers, 1) + entry := injectCfg.Providers[0] + require.NotNil(t, entry.HeaderPair, "OpenRouter uses HeaderPair shape — body-inject is the only branch active") + assert.Empty(t, entry.HeaderPair.EndUserIDHeader, + "OpenRouter does not document a header path for per-user identity; the inject must NOT stamp a header here. Customizable=false means operator IdentityHeader* fields are ignored.") + assert.Empty(t, entry.HeaderPair.TagsHeader, + "OpenRouter has no per-request groups / tags dimension — the tags header MUST stay empty") + assert.True(t, entry.HeaderPair.EndUserIDInBody, + "OpenRouter's only per-user attribution path is the OpenAI-standard `user` body field — body inject is the load-bearing piece for this provider") + assert.False(t, entry.HeaderPair.TagsInBody, + "no tags dimension at all → no tags-in-body either") + + // ExtraHeaders carry the operator-typed app URL + display name to + // OpenRouter's app rankings. The synth must echo BOTH static + // header values with the operator's typed strings. + require.Len(t, entry.ExtraHeaders, 2, + "both ExtraHeaders the catalog declares should land on the inject config when the operator filled in values") + byName := map[string]string{} + for _, h := range entry.ExtraHeaders { + byName[h.Name] = h.Value + } + assert.Equal(t, "https://acme.example/agents", byName["HTTP-Referer"], + "HTTP-Referer is OpenRouter's primary app identifier — must round-trip the operator-typed URL verbatim") + assert.Equal(t, "Acme Agents", byName["X-OpenRouter-Title"], + "X-OpenRouter-Title surfaces as the app's display name in OpenRouter's rankings — must round-trip operator's chosen string") +} + +// TestSynthesizeServices_IdentityInject_NonCustomizable_UsesCatalog +// is the LiteLLM-style negative case: when the catalog entry does +// NOT flag HeaderPair as Customizable, the catalog defaults are +// authoritative and any IdentityHeader* values on the provider +// record are ignored. Without this guard, an operator who set those +// fields on a non-Bifrost provider could accidentally break the +// gateway's wire protocol (LiteLLM only honours x-litellm-end-user- +// id; renaming it would silently drop spend tracking). +func TestSynthesizeServices_IdentityInject_NonCustomizable_UsesCatalog(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + litellm := newSynthTestProvider() + litellm.ID = "prov-litellm" + litellm.ProviderID = "litellm_proxy" + litellm.UpstreamURL = "https://litellm.acme.example.com" + litellm.APIKey = "sk-llm-master" + // Operator set these — but litellm_proxy's catalog entry has + // HeaderPair.Customizable=false, so the synth path must IGNORE + // these and fall back to the catalog defaults. + litellm.IdentityHeaderUserID = "x-bf-lh-should-be-ignored" + litellm.IdentityHeaderGroups = "x-bf-lh-should-be-ignored-too" + litellm.CreatedAt = time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC) + + policy := newSynthTestPolicy(litellm.ID, "grp-eng", "") + policy.ID = "pol-litellm" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{litellm}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var injectCfg identityInjectConfig + for _, m := range mws { + if m.ID == middlewareIDLLMIdentityInject { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &injectCfg)) + break + } + } + require.Len(t, injectCfg.Providers, 1) + entry := injectCfg.Providers[0] + require.NotNil(t, entry.HeaderPair) + assert.Equal(t, "x-litellm-end-user-id", entry.HeaderPair.EndUserIDHeader, + "Customizable=false on the catalog entry must hold — operator IdentityHeader* fields cannot rename a fixed wire protocol's headers") + assert.Equal(t, "x-litellm-tags", entry.HeaderPair.TagsHeader, + "Customizable=false on the catalog entry must hold for tags too") +} + +func TestSynthesizeServices_GuardrailMerge_AllowlistUnion_LimitsRestrictive(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + provider := newSynthTestProvider() + + guardrailA := &types.Guardrail{ + ID: "g-a", + AccountID: testAccountID, + Checks: types.GuardrailChecks{ + ModelAllowlist: types.GuardrailModelAllowlist{Enabled: true, Models: []string{"gpt-5.4-mini"}}, + }, + } + guardrailB := &types.Guardrail{ + ID: "g-b", + AccountID: testAccountID, + Checks: types.GuardrailChecks{ + ModelAllowlist: types.GuardrailModelAllowlist{Enabled: true, Models: []string{"gpt-5.4-pro"}}, + }, + } + + policyA := newSynthTestPolicy(provider.ID, "grp-a", guardrailA.ID) + policyA.ID = "pol-a" + policyB := newSynthTestPolicy(provider.ID, "grp-b", guardrailB.ID) + policyB.ID = "pol-b" + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{provider}, + []*types.Policy{policyA, policyB}, + []*types.Guardrail{guardrailA, guardrailB}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var guardrailJSON []byte + for _, m := range mws { + if m.ID == middlewareIDLLMGuardrail { + guardrailJSON = m.ConfigJSON + break + } + } + require.NotEmpty(t, guardrailJSON, "guardrail middleware config JSON must be present") + + var cfg guardrailConfig + require.NoError(t, json.Unmarshal(guardrailJSON, &cfg), "guardrail config must unmarshal cleanly") + assert.ElementsMatch(t, []string{"gpt-5.4-mini", "gpt-5.4-pro"}, cfg.ModelAllowlist, + "model allowlist union must keep both models") +} + +func TestSynthesizeServices_BackfillsMissingSessionKeys(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + provider := newSynthTestProvider() + provider.SessionPrivateKey = "" + provider.SessionPublicKey = "" + policy := newSynthTestPolicy(provider.ID, "grp-eng", "") + + mockStore.EXPECT(). + GetAgentNetworkSettings(ctx, store.LockingStrengthNone, testAccountID). + Return(newSynthTestSettings(), nil) + mockStore.EXPECT(). + GetAccountAgentNetworkProviders(ctx, store.LockingStrengthNone, testAccountID). + Return([]*types.Provider{provider}, nil) + mockStore.EXPECT(). + GetAccountAgentNetworkPolicies(ctx, store.LockingStrengthNone, testAccountID). + Return([]*types.Policy{policy}, nil) + // Backfill must persist the new keys before synthesising. + mockStore.EXPECT(). + SaveAgentNetworkProvider(ctx, gomock.Any()). + DoAndReturn(func(_ context.Context, p *types.Provider) error { + require.NotEmpty(t, p.SessionPrivateKey, "backfill must populate private key") + require.NotEmpty(t, p.SessionPublicKey, "backfill must populate public key") + return nil + }) + mockStore.EXPECT(). + GetAccountAgentNetworkGuardrails(ctx, store.LockingStrengthNone, testAccountID). + Return([]*types.Guardrail{}, nil) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1, "synthesis must complete after backfill") + assert.NotEmpty(t, services[0].SessionPrivateKey, "synthesised service inherits the freshly-minted private key") + assert.NotEmpty(t, services[0].SessionPublicKey, "synthesised service inherits the freshly-minted public key") +} + +func TestSynthesizeServices_HTTPUpstream_KeepsExplicitPort(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + provider := newSynthTestProvider() + provider.UpstreamURL = "http://internal-llm.lan:8080" + policy := newSynthTestPolicy(provider.ID, "grp-eng", "") + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{provider}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var routerCfg routerConfig + for _, m := range mws { + if m.ID == middlewareIDLLMRouter { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &routerCfg)) + break + } + } + require.Len(t, routerCfg.Providers, 1) + assert.Equal(t, "http", routerCfg.Providers[0].UpstreamScheme, "scheme follows the upstream URL") + assert.Equal(t, "internal-llm.lan:8080", routerCfg.Providers[0].UpstreamHost, + "explicit port travels with host so the router rewrite carries an authority the proxy can dial") +} + +func TestSynthesizeServices_UpstreamURLPath_FlowsToRouter(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + // Provider configured with a path-prefixed upstream — common for + // OpenAI-compatible endpoints behind corporate gateways. The path + // is the router's disambiguator when two providers claim the same + // model, so it must round-trip through buildRouterConfigJSON with + // the trailing slash trimmed. + provider := newSynthTestProvider() + provider.UpstreamURL = "https://corp.example.com/openai/" + policy := newSynthTestPolicy(provider.ID, "grp-eng", "") + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{provider}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var routerCfg routerConfig + for _, m := range mws { + if m.ID == middlewareIDLLMRouter { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &routerCfg)) + break + } + } + require.Len(t, routerCfg.Providers, 1) + assert.Equal(t, "corp.example.com", routerCfg.Providers[0].UpstreamHost, "host should drop the path") + assert.Equal(t, "/openai", routerCfg.Providers[0].UpstreamPath, + "upstream path must be carried so the router can disambiguate same-model providers; trailing slash trimmed for stable string-prefix matching") +} + +func TestSynthesizeServices_SkipTLSVerification_FlowsToRouter(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + // A provider fronting a self-hosted / internal gateway opts into skipping + // upstream TLS verification; the synthesiser must carry it into the router + // route so the proxy dials that upstream insecurely. + provider := newSynthTestProvider() + provider.SkipTLSVerification = true + policy := newSynthTestPolicy(provider.ID, "grp-eng", "") + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{provider}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + mws := services[0].Targets[0].Options.Middlewares + var routerCfg routerConfig + for _, m := range mws { + if m.ID == middlewareIDLLMRouter { + require.NoError(t, json.Unmarshal(m.ConfigJSON, &routerCfg)) + break + } + } + require.Len(t, routerCfg.Providers, 1) + assert.True(t, routerCfg.Providers[0].SkipTLSVerify, + "provider skip_tls_verification must flow into the router route") +} + +func TestSynthesizeServices_UnknownProviderID_FailsClosed(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + provider := newSynthTestProvider() + provider.ProviderID = "nonexistent_provider" + policy := newSynthTestPolicy(provider.ID, "grp-eng", "") + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{provider}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + _, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.Error(t, err, "synthesis must fail when the catalog can't resolve the provider id") + assert.Contains(t, err.Error(), "unknown catalog id", "error must surface the misconfiguration") +} + +func TestSynthesizeServices_EmptyAPIKey_FailsClosed(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + provider := newSynthTestProvider() + provider.APIKey = "" + policy := newSynthTestPolicy(provider.ID, "grp-eng", "") + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{provider}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + _, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.Error(t, err, "synthesis must refuse a provider with no api key") + assert.Contains(t, err.Error(), "no api key", "error must surface the missing credential") +} diff --git a/management/internals/modules/agentnetwork/types/accesslog.go b/management/internals/modules/agentnetwork/types/accesslog.go new file mode 100644 index 000000000..cde7be7de --- /dev/null +++ b/management/internals/modules/agentnetwork/types/accesslog.go @@ -0,0 +1,358 @@ +package types + +import ( + "time" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// AgentNetworkAccessLog is the dedicated, flattened agent-network access-log +// row. Unlike the shared reverse-proxy AccessLogEntry (which kept LLM data in +// an opaque metadata JSON blob), the LLM dimensions live in first-class, +// indexed columns so the access-log surface can filter server-side by +// user / group / provider / model / decision. +type AgentNetworkAccessLog struct { + // The composite index idx_anal_acct_session_ts backs the session-grouped + // listing (GROUP BY session_id ORDER BY MAX(timestamp) within an account); + // the single-column indexes still back the flat filters/sorts. + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index;index:idx_anal_acct_session_ts,priority:1"` + ServiceID string `gorm:"index"` + Timestamp time.Time `gorm:"index;index:idx_anal_acct_session_ts,priority:3"` + UserID string `gorm:"index"` + SourceIP string + Method string + Host string + Path string `gorm:"type:text"` + Duration time.Duration + StatusCode int `gorm:"index"` + AuthMethod string + BytesUpload int64 + BytesDownload int64 + + // Flattened LLM dimensions (queryable). Sourced from proxy metadata keys. + Provider string `gorm:"index"` // vendor, e.g. "openai" (llm.provider) + Model string `gorm:"index"` // llm.model + SessionID string `gorm:"index;index:idx_anal_acct_session_ts,priority:2"` // llm.session_id — groups a conversation / coding session + ResolvedProviderID string `gorm:"index"` // llm.resolved_provider_id + SelectedPolicyID string `gorm:"index"` // llm.selected_policy_id + Decision string `gorm:"index"` // llm_policy.decision (allow/deny) + DenyReason string // llm_policy.reason (raw code, mapped in the UI) + InputTokens int64 + OutputTokens int64 + TotalTokens int64 + // Prompt-cache buckets: read + write token counts. + CachedInputTokens int64 + CacheCreationTokens int64 + // Per-bucket cost breakdown — one column per token bucket the provider + // bills separately. These four are the only cost state stored: the total + // and the cache portion are derived on read (TotalCostUSD / CacheCostUSD) + // rather than stored alongside, so a stored aggregate can never drift out + // of step with the components it summarises. + // + // default:0 matters on upgrade: these columns are ALTER TABLE ADD COLUMN + // on an existing table, and without it every historical row holds NULL — + // which a raw SUM()/scan into float64 can't read. The default backfills + // them as 0, so pre-upgrade rows report an unknown split, not an error. + InputCostUSD float64 `gorm:"not null;default:0"` + CachedInputCostUSD float64 `gorm:"not null;default:0"` + CacheCreationCostUSD float64 `gorm:"not null;default:0"` + OutputCostUSD float64 `gorm:"not null;default:0"` + Stream bool + + // Prompt capture. Only populated when prompt collection is enabled + // (account master switch AND policy guardrail). Heavy free text. + RequestPrompt string `gorm:"type:text"` + ResponseCompletion string `gorm:"type:text"` + + CreatedAt time.Time + + // GroupIDs is the authorising group ids for this entry, hydrated from the + // group child table on read. Not a column. + GroupIDs []string `gorm:"-"` +} + +// TableName keeps agent-network access logs in their own table, separate from +// the reverse-proxy AccessLogEntry table. +func (AgentNetworkAccessLog) TableName() string { return "agent_network_access_log" } + +// CostUSDSQLExpr is the SQL sum of the per-bucket cost columns — the total cost +// of a row. Used wherever a query has to sort or aggregate on total cost now +// that no cost_usd column is stored. Plain arithmetic over NOT NULL columns, so +// it stays portable across SQLite and Postgres. +const CostUSDSQLExpr = "(input_cost_usd + cached_input_cost_usd + cache_creation_cost_usd + output_cost_usd)" + +// TotalCostUSD is the request's total cost: the sum of the four per-bucket +// costs. Derived rather than stored so it cannot disagree with the breakdown. +func (a *AgentNetworkAccessLog) TotalCostUSD() float64 { + return a.InputCostUSD + a.CachedInputCostUSD + a.CacheCreationCostUSD + a.OutputCostUSD +} + +// CacheCostUSD is the portion of the total billed for prompt-cache buckets: +// cache reads plus cache writes. +func (a *AgentNetworkAccessLog) CacheCostUSD() float64 { + return a.CachedInputCostUSD + a.CacheCreationCostUSD +} + +// ToAPIResponse renders the flattened entry as the API representation. +func (a *AgentNetworkAccessLog) ToAPIResponse() api.AgentNetworkAccessLog { + out := api.AgentNetworkAccessLog{ + Id: a.ID, + ServiceId: a.ServiceID, + Timestamp: a.Timestamp, + StatusCode: a.StatusCode, + DurationMs: int(a.Duration.Milliseconds()), + InputTokens: a.InputTokens, + OutputTokens: a.OutputTokens, + TotalTokens: a.TotalTokens, + CachedInputTokens: a.CachedInputTokens, + CacheCreationTokens: a.CacheCreationTokens, + InputCostUsd: a.InputCostUSD, + CachedInputCostUsd: a.CachedInputCostUSD, + CacheCreationCostUsd: a.CacheCreationCostUSD, + OutputCostUsd: a.OutputCostUSD, + CostUsd: a.TotalCostUSD(), + CacheCostUsd: a.CacheCostUSD(), + Stream: &a.Stream, + } + + out.UserId = strPtr(a.UserID) + out.SourceIp = strPtr(a.SourceIP) + out.Method = strPtr(a.Method) + out.Host = strPtr(a.Host) + out.Path = strPtr(a.Path) + out.Provider = strPtr(a.Provider) + out.Model = strPtr(a.Model) + out.SessionId = strPtr(a.SessionID) + out.ResolvedProviderId = strPtr(a.ResolvedProviderID) + out.SelectedPolicyId = strPtr(a.SelectedPolicyID) + out.Decision = strPtr(a.Decision) + out.DenyReason = strPtr(a.DenyReason) + out.RequestPrompt = strPtr(a.RequestPrompt) + out.ResponseCompletion = strPtr(a.ResponseCompletion) + + if len(a.GroupIDs) > 0 { + groups := a.GroupIDs + out.GroupIds = &groups + } + return out +} + +// strPtr returns a pointer to s, or nil when s is empty — so empty optional +// fields are omitted from the JSON rather than serialised as "". +func strPtr(s string) *string { + if s == "" { + return nil + } + return &s +} + +// AgentNetworkAccessLogSession is a session-grouped view of access-log entries: +// all requests sharing a session id (or, for a request the client sent no +// session id for, that single request keyed by its own row id) folded into one +// summary plus its ordered entries. Assembled in Go from a page of entries — it +// is not a stored table. +type AgentNetworkAccessLogSession struct { + SessionID string // empty for a session-less (singleton) request + UserID string + GroupIDs []string // union of the entries' authorising groups + StartedAt time.Time + EndedAt time.Time + RequestCount int + InputTokens int64 + OutputTokens int64 + TotalTokens int64 + CachedInputTokens int64 + CacheCreationTokens int64 + InputCostUSD float64 + CachedInputCostUSD float64 + CacheCreationCostUSD float64 + OutputCostUSD float64 + Providers []string // distinct vendors seen in the session + Models []string // distinct models seen in the session + Decision string // "deny" if any entry was denied, else "allow" + Entries []*AgentNetworkAccessLog +} + +// TotalCostUSD is the session's total cost: the sum of the four per-bucket +// costs accumulated across its entries. +func (sess *AgentNetworkAccessLogSession) TotalCostUSD() float64 { + return sess.InputCostUSD + sess.CachedInputCostUSD + sess.CacheCreationCostUSD + sess.OutputCostUSD +} + +// CacheCostUSD is the session's prompt-cache spend: cache reads plus writes. +func (sess *AgentNetworkAccessLogSession) CacheCostUSD() float64 { + return sess.CachedInputCostUSD + sess.CacheCreationCostUSD +} + +// sessionKey is the grouping key for an entry: its session id, or — when the +// client sent none — its own row id, so session-less requests each form their +// own singleton group. Must match the SQL group key +// COALESCE(NULLIF(session_id, ”), id). +func sessionKey(e *AgentNetworkAccessLog) string { + if e.SessionID != "" { + return e.SessionID + } + return e.ID +} + +// FoldAccessLogSessions folds a page of entries into per-session summaries, +// preserving the order of orderedKeys (the already-sorted, already-paginated +// session keys from the store). Entries are expected pre-sorted by timestamp +// within each key. Aggregation (sums, distinct providers/models, deny rollup) +// happens here in Go rather than in SQL so the query stays engine-portable. +func FoldAccessLogSessions(orderedKeys []string, entries []*AgentNetworkAccessLog) []*AgentNetworkAccessLogSession { + byKey := make(map[string]*AgentNetworkAccessLogSession, len(orderedKeys)) + order := make([]*AgentNetworkAccessLogSession, 0, len(orderedKeys)) + for _, k := range orderedKeys { + if _, ok := byKey[k]; ok { + continue + } + sess := &AgentNetworkAccessLogSession{Decision: "allow"} + byKey[k] = sess + order = append(order, sess) + } + + seenBy := make(map[string]*sessionSeen, len(orderedKeys)) + + for _, e := range entries { + k := sessionKey(e) + sess, ok := byKey[k] + if !ok { + continue // entry outside the paged set; defensive + } + sk := seenBy[k] + if sk == nil { + sk = newSessionSeen() + seenBy[k] = sk + sess.SessionID = e.SessionID + sess.UserID = e.UserID + sess.StartedAt = e.Timestamp + sess.EndedAt = e.Timestamp + } + sess.foldEntry(sk, e) + } + + out := make([]*AgentNetworkAccessLogSession, 0, len(order)) + for _, sess := range order { + if sess.RequestCount > 0 { + out = append(out, sess) + } + } + return out +} + +// sessionSeen tracks the distinct provider / model / group values already +// recorded for a session so foldEntry can dedupe as it accumulates. +type sessionSeen struct{ providers, models, groups map[string]struct{} } + +func newSessionSeen() *sessionSeen { + return &sessionSeen{ + providers: map[string]struct{}{}, + models: map[string]struct{}{}, + groups: map[string]struct{}{}, + } +} + +// foldEntry accumulates a single entry into the session summary: sums, time +// bounds, first-seen user, deny rollup, distinct provider / model / group +// lists, and the entry itself. +func (sess *AgentNetworkAccessLogSession) foldEntry(sk *sessionSeen, e *AgentNetworkAccessLog) { + sess.RequestCount++ + sess.InputTokens += e.InputTokens + sess.OutputTokens += e.OutputTokens + sess.TotalTokens += e.TotalTokens + sess.CachedInputTokens += e.CachedInputTokens + sess.CacheCreationTokens += e.CacheCreationTokens + sess.InputCostUSD += e.InputCostUSD + sess.CachedInputCostUSD += e.CachedInputCostUSD + sess.CacheCreationCostUSD += e.CacheCreationCostUSD + sess.OutputCostUSD += e.OutputCostUSD + if e.Timestamp.Before(sess.StartedAt) { + sess.StartedAt = e.Timestamp + } + if e.Timestamp.After(sess.EndedAt) { + sess.EndedAt = e.Timestamp + } + if sess.UserID == "" { + sess.UserID = e.UserID + } + if e.Decision == "deny" { + sess.Decision = "deny" + } + sess.Providers = appendDistinct(sk.providers, sess.Providers, e.Provider) + sess.Models = appendDistinct(sk.models, sess.Models, e.Model) + for _, g := range e.GroupIDs { + sess.GroupIDs = appendDistinct(sk.groups, sess.GroupIDs, g) + } + sess.Entries = append(sess.Entries, e) +} + +// appendDistinct appends v to list when v is non-empty and not already recorded +// in seen, returning the possibly-extended list. +func appendDistinct(seen map[string]struct{}, list []string, v string) []string { + if v == "" { + return list + } + if _, dup := seen[v]; dup { + return list + } + seen[v] = struct{}{} + return append(list, v) +} + +// ToAPIResponse renders the session summary (and its entries) as the API +// representation. +func (sess *AgentNetworkAccessLogSession) ToAPIResponse() api.AgentNetworkAccessLogSession { + entries := make([]api.AgentNetworkAccessLog, 0, len(sess.Entries)) + for _, e := range sess.Entries { + entries = append(entries, e.ToAPIResponse()) + } + + out := api.AgentNetworkAccessLogSession{ + StartedAt: sess.StartedAt, + EndedAt: sess.EndedAt, + RequestCount: sess.RequestCount, + InputTokens: sess.InputTokens, + OutputTokens: sess.OutputTokens, + TotalTokens: sess.TotalTokens, + CachedInputTokens: sess.CachedInputTokens, + CacheCreationTokens: sess.CacheCreationTokens, + InputCostUsd: sess.InputCostUSD, + CachedInputCostUsd: sess.CachedInputCostUSD, + CacheCreationCostUsd: sess.CacheCreationCostUSD, + OutputCostUsd: sess.OutputCostUSD, + CostUsd: sess.TotalCostUSD(), + CacheCostUsd: sess.CacheCostUSD(), + Decision: sess.Decision, + Entries: entries, + } + out.SessionId = strPtr(sess.SessionID) + out.UserId = strPtr(sess.UserID) + if len(sess.Providers) > 0 { + providers := sess.Providers + out.Providers = &providers + } + if len(sess.Models) > 0 { + models := sess.Models + out.Models = &models + } + if len(sess.GroupIDs) > 0 { + groups := sess.GroupIDs + out.GroupIds = &groups + } + return out +} + +// AgentNetworkAccessLogGroup is the normalised many-to-many row linking a log +// entry to one authorising group, so the access-log endpoint can filter by +// group with a simple `group_id IN (...)` join instead of substring-matching a +// CSV column. +type AgentNetworkAccessLogGroup struct { + LogID string `gorm:"primaryKey"` + GroupID string `gorm:"primaryKey;index"` + AccountID string `gorm:"index"` +} + +// TableName names the access-log group child table. +func (AgentNetworkAccessLogGroup) TableName() string { return "agent_network_access_log_group" } diff --git a/management/internals/modules/agentnetwork/types/accesslogfilter.go b/management/internals/modules/agentnetwork/types/accesslogfilter.go new file mode 100644 index 000000000..d35516ffa --- /dev/null +++ b/management/internals/modules/agentnetwork/types/accesslogfilter.go @@ -0,0 +1,249 @@ +package types + +import ( + "math" + "net/http" + "strconv" + "strings" + "time" + + "github.com/netbirdio/netbird/shared/management/status" +) + +const ( + // AccessLogDefaultPageSize is the default number of records per page. + AccessLogDefaultPageSize = 50 + // AccessLogMaxPageSize is the maximum number of records allowed per page. + AccessLogMaxPageSize = 100 + + accessLogDefaultSortBy = "timestamp" + accessLogDefaultSortOrder = "desc" + + // usageOverviewDefaultLookback bounds an unbounded usage-overview query so + // it never aggregates an account's entire history into memory. + usageOverviewDefaultLookback = 90 * 24 * time.Hour + // usageOverviewMaxRange caps how far back an explicit range may reach. + usageOverviewMaxRange = 366 * 24 * time.Hour +) + +// ApplyUsageOverviewBounds bounds a missing or over-wide date range so the +// in-memory usage aggregation can't load an account's full usage history. An +// absent range defaults to the last usageOverviewDefaultLookback; a range wider +// than usageOverviewMaxRange is clamped from the (possibly defaulted) end. +func (f *AgentNetworkAccessLogFilter) ApplyUsageOverviewBounds(now time.Time) { + end := now + if f.EndDate != nil { + end = *f.EndDate + } + f.EndDate = &end + if f.StartDate == nil { + start := end.Add(-usageOverviewDefaultLookback) + f.StartDate = &start + return + } + if end.Sub(*f.StartDate) > usageOverviewMaxRange { + start := end.Add(-usageOverviewMaxRange) + f.StartDate = &start + } +} + +// accessLogSortFields maps the API sort_by values to their database columns. +var accessLogSortFields = map[string]string{ + "timestamp": "timestamp", + "model": "model", + "provider": "provider", + "status_code": "status_code", + "duration": "duration", + "cost_usd": CostUSDSQLExpr, + "total_tokens": "total_tokens", + "user_id": "user_id", + "decision": "decision", +} + +// sessionSortExprs maps the API sort_by values to the aggregate expression a +// session-grouped query sorts on. A session has no single row, so per-row +// columns become aggregates: "timestamp" (the default) is the session's last +// activity, "started_at" its first. Every expression is a plain SQL aggregate +// over the GROUP BY, so the ordering stays portable across SQLite and Postgres. +// Keys absent here (e.g. "model", "provider") fall back to the default — the +// grouped UI only offers the session-level sorts below. +var sessionSortExprs = map[string]string{ //nolint:gosec // G101 false positive: "total_tokens" sort key, not a credential + "timestamp": "MAX(timestamp)", + "started_at": "MIN(timestamp)", + "cost_usd": "SUM" + CostUSDSQLExpr, + "total_tokens": "SUM(total_tokens)", + "duration": "SUM(duration)", + "request_count": "COUNT(*)", + "status_code": "MAX(status_code)", + "user_id": "MIN(user_id)", + "decision": "MAX(decision)", // "deny" > "allow": DESC surfaces denied sessions first +} + +// AgentNetworkAccessLogFilter holds pagination, filtering and sorting +// parameters for the agent-network access-log listing. Group / provider / +// model are multi-valued (the UI uses multi-select; an entry matches when it +// matches any selected value). +type AgentNetworkAccessLogFilter struct { + Page int + PageSize int + + SortBy string + SortOrder string + + Search *string // log id, host, path, model, user email/name + UserID *string // exact user id (the dashboard sends the picked user's id) + SessionID *string // exact session id — groups one conversation / coding session + GroupIDs []string // authorising group ids (match any) + ProviderIDs []string // resolved provider ids (match any) + Models []string // models (match any) + Decision *string // policy decision (allow/deny) + PathPrefix *string // request path prefix (path LIKE 'prefix%') + StartDate *time.Time // timestamp >= start_date + EndDate *time.Time // timestamp <= end_date +} + +// ParseFromRequest fills the filter from the request query parameters. It +// returns a validation error when a supplied start_date / end_date is present +// but not valid RFC3339: silently dropping a malformed date would broaden the +// query (and, for the usage overview, fall back to the default window). +func (f *AgentNetworkAccessLogFilter) ParseFromRequest(r *http.Request) error { + q := r.URL.Query() + + f.Page = parseAccessLogPositiveInt(q.Get("page"), 1) + f.PageSize = min(parseAccessLogPositiveInt(q.Get("page_size"), AccessLogDefaultPageSize), AccessLogMaxPageSize) + + f.SortBy = parseAccessLogSortField(q.Get("sort_by")) + f.SortOrder = parseAccessLogSortOrder(q.Get("sort_order")) + + f.Search = parseAccessLogOptionalString(q.Get("search")) + f.UserID = parseAccessLogOptionalString(q.Get("user_id")) + f.SessionID = parseAccessLogOptionalString(q.Get("session_id")) + f.Decision = parseAccessLogOptionalString(q.Get("decision")) + f.PathPrefix = parseAccessLogOptionalString(q.Get("path")) + // Multi-value filters accept either repeated params (?group_id=a&group_id=b) + // or a single comma-separated value (?group_id=a,b) so both the OpenAPI + // array form and the dashboard's single-value query builder work. + f.GroupIDs = splitMultiValue(q["group_id"]) + f.ProviderIDs = splitMultiValue(q["provider_id"]) + f.Models = splitMultiValue(q["model"]) + + var err error + if f.StartDate, err = parseAccessLogOptionalRFC3339(q.Get("start_date")); err != nil { + return status.Errorf(status.InvalidArgument, "invalid start_date: %v", err) + } + if f.EndDate, err = parseAccessLogOptionalRFC3339(q.Get("end_date")); err != nil { + return status.Errorf(status.InvalidArgument, "invalid end_date: %v", err) + } + return nil +} + +// GetSortColumn returns the database column for the active sort field. +func (f *AgentNetworkAccessLogFilter) GetSortColumn() string { + if col, ok := accessLogSortFields[f.SortBy]; ok { + return col + } + return accessLogSortFields[accessLogDefaultSortBy] +} + +// GetSessionSortExpr returns the aggregate ORDER BY expression for the active +// sort field when listing session-grouped logs. Unknown / non-session sort +// fields fall back to the default (last activity). +func (f *AgentNetworkAccessLogFilter) GetSessionSortExpr() string { + if expr, ok := sessionSortExprs[f.SortBy]; ok { + return expr + } + return sessionSortExprs[accessLogDefaultSortBy] +} + +// GetSortOrder returns the normalised sort order ("ASC"/"DESC"). +func (f *AgentNetworkAccessLogFilter) GetSortOrder() string { + if strings.EqualFold(f.SortOrder, "asc") { + return "ASC" + } + return "DESC" +} + +// GetLimit returns the page size, defaulting/clamping when unset. +func (f *AgentNetworkAccessLogFilter) GetLimit() int { + if f.PageSize <= 0 { + return AccessLogDefaultPageSize + } + return min(f.PageSize, AccessLogMaxPageSize) +} + +// GetOffset returns the zero-based row offset for the active page. Page is +// user-controlled, so the multiplication is guarded against int overflow. +func (f *AgentNetworkAccessLogFilter) GetOffset() int { + limit := f.GetLimit() + if f.Page <= 1 || limit <= 0 { + return 0 + } + if f.Page-1 > math.MaxInt/limit { + return math.MaxInt - (math.MaxInt % limit) + } + return (f.Page - 1) * limit +} + +func parseAccessLogPositiveInt(s string, def int) int { + if v, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && v > 0 { + return v + } + return def +} + +func parseAccessLogSortField(s string) string { + if _, ok := accessLogSortFields[s]; ok { + return s + } + // Session-grouped listings sort on aggregates (e.g. request_count, + // started_at) that aren't flat-row columns; accept those too. The flat + // listing maps any unknown field back to the default, so this stays safe + // for the non-grouped endpoint. + if _, ok := sessionSortExprs[s]; ok { + return s + } + return accessLogDefaultSortBy +} + +func parseAccessLogSortOrder(s string) string { + if strings.EqualFold(s, "asc") { + return "asc" + } + return accessLogDefaultSortOrder +} + +func parseAccessLogOptionalString(s string) *string { + if s = strings.TrimSpace(s); s != "" { + return &s + } + return nil +} + +func parseAccessLogOptionalRFC3339(s string) (*time.Time, error) { + if s = strings.TrimSpace(s); s == "" { + return nil, nil //nolint:nilnil // not provided: no value and no error + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return nil, err + } + return &t, nil +} + +// splitMultiValue flattens repeated query params and comma-separated values +// into a single trimmed, blank-free list. Returns nil when nothing remains so +// callers can skip the filter entirely. +func splitMultiValue(values []string) []string { + out := make([]string, 0, len(values)) + for _, raw := range values { + for _, v := range strings.Split(raw, ",") { + if v = strings.TrimSpace(v); v != "" { + out = append(out, v) + } + } + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/management/internals/modules/agentnetwork/types/budgetrule.go b/management/internals/modules/agentnetwork/types/budgetrule.go new file mode 100644 index 000000000..6f02a5b92 --- /dev/null +++ b/management/internals/modules/agentnetwork/types/budgetrule.go @@ -0,0 +1,106 @@ +package types + +import ( + "time" + + "github.com/rs/xid" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// AccountBudgetRule is an account-level, limit-only rule bound to groups +// and/or users. It mirrors the policy budget experience without any routing: +// it carries the same cap shape as a policy (PolicyLimits) but never selects a +// provider. Rules apply across policies as an always-on ceiling — every +// applicable rule binds (min-wins), so a rule can only tighten a caller's +// effective limit, never loosen it. +// +// TargetGroups matches when it intersects the caller's groups; TargetUsers +// binds a specific user directly. Empty TargetGroups and TargetUsers means the +// rule applies to every caller (the account-wide default). +type AccountBudgetRule struct { + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index"` + Name string + Enabled bool + TargetGroups []string `gorm:"serializer:json;column:target_groups"` + TargetUsers []string `gorm:"serializer:json;column:target_users"` + Limits PolicyLimits `gorm:"serializer:json;column:limits"` + + CreatedAt time.Time + UpdatedAt time.Time +} + +// TableName puts budget rules in their own table. +func (AccountBudgetRule) TableName() string { return "agent_network_budget_rules" } + +// NewAccountBudgetRule returns a new rule with a freshly minted ID. +func NewAccountBudgetRule(accountID string) *AccountBudgetRule { + now := time.Now().UTC() + return &AccountBudgetRule{ + ID: "ainbud_" + xid.New().String(), + AccountID: accountID, + Enabled: true, + CreatedAt: now, + UpdatedAt: now, + } +} + +// Copy returns a deep copy of the rule, including its target slices. +func (r *AccountBudgetRule) Copy() *AccountBudgetRule { + c := *r + c.TargetGroups = append([]string(nil), r.TargetGroups...) + c.TargetUsers = append([]string(nil), r.TargetUsers...) + return &c +} + +// EventMeta renders the rule for the activity log. +func (r *AccountBudgetRule) EventMeta() map[string]any { + return map[string]any{ + "name": r.Name, + "enabled": r.Enabled, + } +} + +// FromAPIRequest applies the request payload onto the receiver. +func (r *AccountBudgetRule) FromAPIRequest(req *api.AgentNetworkBudgetRuleRequest) { + r.Name = req.Name + if req.Enabled != nil { + r.Enabled = *req.Enabled + } + if req.TargetGroups != nil { + r.TargetGroups = append([]string(nil), (*req.TargetGroups)...) + } else { + r.TargetGroups = []string{} + } + if req.TargetUsers != nil { + r.TargetUsers = append([]string(nil), (*req.TargetUsers)...) + } else { + r.TargetUsers = []string{} + } + r.Limits = limitsFromAPI(req.Limits) +} + +// ToAPIResponse renders the rule as the API representation. +func (r *AccountBudgetRule) ToAPIResponse() *api.AgentNetworkBudgetRule { + groups := r.TargetGroups + if groups == nil { + groups = []string{} + } + users := r.TargetUsers + if users == nil { + users = []string{} + } + created := r.CreatedAt + updated := r.UpdatedAt + return &api.AgentNetworkBudgetRule{ + Id: r.ID, + Name: r.Name, + Enabled: r.Enabled, + TargetGroups: groups, + TargetUsers: users, + Limits: limitsToAPI(r.Limits), + CreatedAt: &created, + UpdatedAt: &updated, + } +} diff --git a/management/internals/modules/agentnetwork/types/consumption.go b/management/internals/modules/agentnetwork/types/consumption.go new file mode 100644 index 000000000..5295b5570 --- /dev/null +++ b/management/internals/modules/agentnetwork/types/consumption.go @@ -0,0 +1,69 @@ +package types + +import "time" + +// ConsumptionDimension classifies which kind of identity a consumption +// row counts against. The proxy-side enforcement layer ticks one row +// per dimension per request — typically one user row plus one group +// row. +type ConsumptionDimension string + +const ( + // DimensionUser counts tokens / spend for a single end user. The + // dim_id column carries the netbird user id (or peer.ID when the + // caller is a tunnel-peer principal). + DimensionUser ConsumptionDimension = "user" + // DimensionGroup counts tokens / spend for a single source group + // across every member of that group. The dim_id column carries + // the netbird group id. + DimensionGroup ConsumptionDimension = "group" +) + +// Consumption is a per-dimension token + USD counter for a fixed +// aligned window. The (account, dim_kind, dim_id, window_seconds, +// window_start) tuple is the primary key; rows are rolled forward by +// the proxy's post-flight RecordLLMUsage path on every request. +// +// The same dim_id (e.g. a group id) gets one row per distinct +// window_seconds length in scope across the account's policies, +// because two policies with different window lengths read independent +// counters even though they share the dimension. Two policies with +// identical window_seconds on the same dimension share one counter +// (correct: their caps are checked against the same shared bucket). +type Consumption struct { + AccountID string `gorm:"primaryKey;type:varchar(255)"` + DimensionKind ConsumptionDimension `gorm:"primaryKey;type:varchar(16);column:dim_kind"` + DimensionID string `gorm:"primaryKey;type:varchar(255);column:dim_id"` + WindowSeconds int64 `gorm:"primaryKey;column:window_seconds"` + WindowStartUTC time.Time `gorm:"primaryKey;column:window_start_utc"` + TokensInput int64 `gorm:"column:tokens_input"` + TokensOutput int64 `gorm:"column:tokens_output"` + CostUSD float64 `gorm:"column:cost_usd"` + UpdatedAt time.Time +} + +// TableName forces a stable name independent of GORM's pluraliser. +func (Consumption) TableName() string { return "agent_network_consumption" } + +// ConsumptionKey identifies a single consumption counter within an account: +// the (dim_kind, dim_id, window_seconds, window_start) part of the row's +// primary key. Used to batch-read and batch-increment many counters for one +// request in a single store round-trip / transaction. +type ConsumptionKey struct { + Kind ConsumptionDimension + DimID string + WindowSeconds int64 + WindowStartUTC time.Time +} + +// WindowStart returns the aligned UTC start of the window of length +// windowSeconds that contains t. Aligned to the unix epoch so the +// same bucket boundary is computed deterministically across processes. +func WindowStart(t time.Time, windowSeconds int64) time.Time { + if windowSeconds <= 0 { + return t.UTC() + } + step := windowSeconds * int64(time.Second) + bucketed := t.UTC().UnixNano() / step * step + return time.Unix(0, bucketed).UTC() +} diff --git a/management/internals/modules/agentnetwork/types/consumption_test.go b/management/internals/modules/agentnetwork/types/consumption_test.go new file mode 100644 index 000000000..596cc158c --- /dev/null +++ b/management/internals/modules/agentnetwork/types/consumption_test.go @@ -0,0 +1,141 @@ +package types + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// TestWindowStart_AlignedToUnixEpoch is the multi-node-convergence +// guarantee: any two proxies computing WindowStart(now, s) for the +// same s must land on the same boundary. The implementation aligns +// to the unix epoch (UTC) rather than local time, calendar weeks, or +// process start time — none of which are shared across nodes. +// +// Table covers the load-bearing window lengths (5m, 1h, 24h, 30d) +// plus a few odd values that still need to align cleanly. +func TestWindowStart_AlignedToUnixEpoch(t *testing.T) { + cases := []struct { + name string + instant time.Time + windowSeconds int64 + want time.Time + }{ + { + name: "5m window — drops seconds inside the bucket", + instant: time.Date(2026, 5, 6, 13, 47, 23, 0, time.UTC), + windowSeconds: 300, + want: time.Date(2026, 5, 6, 13, 45, 0, 0, time.UTC), + }, + { + name: "1h window — drops minutes / seconds, keeps the hour", + instant: time.Date(2026, 5, 6, 13, 47, 23, 0, time.UTC), + windowSeconds: 3600, + want: time.Date(2026, 5, 6, 13, 0, 0, 0, time.UTC), + }, + { + name: "24h window aligns to UTC midnight", + instant: time.Date(2026, 5, 6, 13, 47, 23, 0, time.UTC), + windowSeconds: 86_400, + want: time.Date(2026, 5, 6, 0, 0, 0, 0, time.UTC), + }, + { + name: "30d (2_592_000s) window aligns to the 30d epoch grid, not month boundaries", + instant: time.Date(2026, 5, 6, 0, 0, 0, 0, time.UTC), + windowSeconds: 2_592_000, + // 2026-05-06 UTC = 1778025600s; 1778025600 / 2592000 = 685 + // 685 * 2592000 = 1775520000s = 2026-04-07 00:00:00 UTC + want: time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC), + }, + { + name: "non-UTC input still anchors on UTC epoch boundaries", + instant: time.Date(2026, 5, 6, 13, 47, 23, 0, time.FixedZone("CEST", 2*3600)), + windowSeconds: 86_400, + // 2026-05-06 13:47:23 CEST = 11:47:23 UTC → bucket 2026-05-06 00:00:00 UTC + want: time.Date(2026, 5, 6, 0, 0, 0, 0, time.UTC), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := WindowStart(tc.instant, tc.windowSeconds) + assert.True(t, got.Equal(tc.want), + "WindowStart(%v, %ds) = %v, want %v", tc.instant, tc.windowSeconds, got, tc.want) + }) + } +} + +// TestWindowStart_WithinWindowConverges proves the determinism +// contract: any two timestamps inside the same window land on the +// exact same boundary. Two proxy nodes serving requests 7s apart +// must agree on which counter row to upsert. +func TestWindowStart_WithinWindowConverges(t *testing.T) { + t1 := time.Date(2026, 5, 6, 14, 0, 0, 0, time.UTC) + t2 := t1.Add(7 * time.Second) + t3 := t1.Add(59*time.Minute + 59*time.Second) + + a := WindowStart(t1, 3600) + b := WindowStart(t2, 3600) + c := WindowStart(t3, 3600) + + assert.True(t, a.Equal(b), "two timestamps 7s apart in the same 1h window must align to the same boundary") + assert.True(t, a.Equal(c), "the very last second of a 1h window still lands on the SAME bucket as the first second") +} + +// TestWindowStart_AcrossWindowsDiverges is the symmetric guarantee: +// two timestamps separated by a window's worth of time MUST land on +// different boundaries. Without this, a 24h window's "rollover" +// would never reset the counter. +func TestWindowStart_AcrossWindowsDiverges(t *testing.T) { + t1 := time.Date(2026, 5, 6, 23, 59, 59, 0, time.UTC) + t2 := t1.Add(2 * time.Second) // 2026-05-07 00:00:01 + + a := WindowStart(t1, 86_400) + b := WindowStart(t2, 86_400) + assert.False(t, a.Equal(b), + "timestamps straddling a 24h-window boundary must land on different buckets — otherwise daily caps never reset") +} + +// TestWindowStart_DifferentWindowsHaveDifferentBuckets locks the +// design fork "two policies with different window_seconds on the same +// group produce independent counters". A 24h boundary at noon is NOT +// the same as the 30d boundary that contains it. +func TestWindowStart_DifferentWindowsHaveDifferentBuckets(t *testing.T) { + now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC) + short := WindowStart(now, 86_400) + long := WindowStart(now, 2_592_000) + assert.False(t, short.Equal(long), + "the 24h bucket and 30d bucket containing the same instant must differ — independent counters require independent keys") +} + +// TestWindowStart_SubMinuteAndMinuteAlignment locks sub-hour windows. +// A 5-minute window must align to multiples of 300s from the unix +// epoch — minute marks 0/5/10/.../55 within an hour, deterministic +// across nodes regardless of clock drift. +func TestWindowStart_SubMinuteAndMinuteAlignment(t *testing.T) { + t1 := time.Date(2026, 5, 6, 14, 12, 30, 0, time.UTC) + t2 := time.Date(2026, 5, 6, 14, 14, 59, 0, time.UTC) + t3 := time.Date(2026, 5, 6, 14, 15, 0, 0, time.UTC) + + a := WindowStart(t1, 300) + b := WindowStart(t2, 300) + c := WindowStart(t3, 300) + + assert.True(t, a.Equal(b), + "14:12:30 and 14:14:59 fall in the same 5m bucket starting at 14:10:00") + assert.True(t, a.Equal(time.Date(2026, 5, 6, 14, 10, 0, 0, time.UTC)), + "5m bucket containing 14:12 starts at 14:10 — aligned to multiples of 300s from unix epoch") + assert.False(t, a.Equal(c), + "14:15:00 is the start of the next 5m bucket — must not fold into the previous one") +} + +// TestWindowStart_ZeroWindowReturnsInputUTC covers the defensive +// path: caller hands a zero / negative window (shouldn't happen, but +// might mid-refactor). The function returns the input as UTC rather +// than dividing by zero. +func TestWindowStart_ZeroWindowReturnsInputUTC(t *testing.T) { + now := time.Date(2026, 5, 6, 12, 30, 45, 0, time.FixedZone("CEST", 2*3600)) + got := WindowStart(now, 0) + assert.True(t, got.Equal(now.UTC()), "zero window must not panic — return input as UTC") +} diff --git a/management/internals/modules/agentnetwork/types/cost_test.go b/management/internals/modules/agentnetwork/types/cost_test.go new file mode 100644 index 000000000..9194ef4a3 --- /dev/null +++ b/management/internals/modules/agentnetwork/types/cost_test.go @@ -0,0 +1,124 @@ +package types + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// costRow builds an access-log entry carrying only a cost breakdown — the rest +// of the row is irrelevant to the summation identities under test. +func costRow(id, session string, ts time.Time, in, cachedIn, cacheCreate, out float64) *AgentNetworkAccessLog { + return &AgentNetworkAccessLog{ + ID: id, + SessionID: session, + Timestamp: ts, + InputCostUSD: in, + CachedInputCostUSD: cachedIn, + CacheCreationCostUSD: cacheCreate, + OutputCostUSD: out, + } +} + +// TestAPIResponse_CostComponentsSumToAggregates is the contract a client adding +// up an API response depends on: within a single rendered object, the four +// per-bucket fields sum to cost_usd, and the two cache fields sum to +// cache_cost_usd. Uses rates that are not exactly representable in binary +// floating point, so the identity is checked against real arithmetic rather +// than round numbers. +func TestAPIResponse_CostComponentsSumToAggregates(t *testing.T) { + row := costRow("r1", "s1", time.Now(), 0.000768, 0.0002304, 0.00192, 0.003) + + api := row.ToAPIResponse() + assert.InDelta(t, api.InputCostUsd+api.CachedInputCostUsd+api.CacheCreationCostUsd+api.OutputCostUsd, + api.CostUsd, 1e-12, "rendered buckets must sum to the rendered cost_usd") + assert.InDelta(t, api.CachedInputCostUsd+api.CacheCreationCostUsd, api.CacheCostUsd, 1e-12, + "rendered cache buckets must sum to the rendered cache_cost_usd") + assert.InDelta(t, 0.0059184, api.CostUsd, 1e-12, "total is the exact sum, not a separately rounded figure") + assert.InDelta(t, 0.0021504, api.CacheCostUsd, 1e-12, "cache cost is the exact sum of the two cache buckets") +} + +// TestSessionSummary_SumsMatchSummedEntries proves a session summary equals the +// sum of the entries it renders: a client that adds up the entries itself must +// land on the same number the summary reports, per bucket and in total. +func TestSessionSummary_SumsMatchSummedEntries(t *testing.T) { + base := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC) + entries := []*AgentNetworkAccessLog{ + costRow("r1", "s1", base, 0.000768, 0.0002304, 0.00192, 0.003), + costRow("r2", "s1", base.Add(time.Minute), 0.000625, 0.0009375, 0, 0.005), + costRow("r3", "s1", base.Add(2*time.Minute), 0.0000016, 0, 0, 0.0000032), + } + + sessions := FoldAccessLogSessions([]string{"s1"}, entries) + require.Len(t, sessions, 1) + sess := sessions[0].ToAPIResponse() + + var wantInput, wantCachedInput, wantCacheCreation, wantOutput float64 + for _, e := range entries { + wantInput += e.InputCostUSD + wantCachedInput += e.CachedInputCostUSD + wantCacheCreation += e.CacheCreationCostUSD + wantOutput += e.OutputCostUSD + } + + assert.InDelta(t, wantInput, sess.InputCostUsd, 1e-12, "session input cost is the sum of its entries") + assert.InDelta(t, wantCachedInput, sess.CachedInputCostUsd, 1e-12, "session cache-read cost is the sum of its entries") + assert.InDelta(t, wantCacheCreation, sess.CacheCreationCostUsd, 1e-12, "session cache-write cost is the sum of its entries") + assert.InDelta(t, wantOutput, sess.OutputCostUsd, 1e-12, "session output cost is the sum of its entries") + assert.InDelta(t, wantInput+wantCachedInput+wantCacheCreation+wantOutput, sess.CostUsd, 1e-12, + "session total equals the summed entry buckets") + + // Summing the rendered entries must give the same answer as reading the + // summary — the property a UI relies on when it totals a table itself. + var fromEntries float64 + for _, e := range sess.Entries { + fromEntries += e.CostUsd + } + assert.InDelta(t, sess.CostUsd, fromEntries, 1e-12, "summary total must match the summed rendered entries") + + // The sub-microdollar row must still contribute; it would vanish under + // 6-decimal quantisation. + assert.Greater(t, sess.InputCostUsd, 0.001393, "small-cost rows must not be quantised away") +} + +// TestUsageBuckets_SumsMatchSummedRows proves the same identity one level up: +// a usage bucket equals the sum of the ledger rows folded into it, and the +// buckets together equal the whole range. +func TestUsageBuckets_SumsMatchSummedRows(t *testing.T) { + day1 := time.Date(2026, 5, 5, 9, 0, 0, 0, time.UTC) + day2 := time.Date(2026, 5, 6, 9, 0, 0, 0, time.UTC) + rows := []*AgentNetworkUsage{ + {ID: "u1", Timestamp: day1, InputCostUSD: 0.000768, CachedInputCostUSD: 0.0002304, CacheCreationCostUSD: 0.00192, OutputCostUSD: 0.003}, + {ID: "u2", Timestamp: day1.Add(time.Hour), InputCostUSD: 0.000625, CachedInputCostUSD: 0.0009375, OutputCostUSD: 0.005}, + {ID: "u3", Timestamp: day2, InputCostUSD: 0.0000016, OutputCostUSD: 0.0000032}, + } + + buckets := AggregateUsageByGranularity(rows, UsageGranularityDay) + require.Len(t, buckets, 2, "two distinct days expected") + + var total, cache float64 + for _, b := range buckets { + api := b.ToAPIResponse() + assert.InDelta(t, api.InputCostUsd+api.CachedInputCostUsd+api.CacheCreationCostUsd+api.OutputCostUsd, + api.CostUsd, 1e-12, "each bucket's components must sum to its cost_usd") + total += api.CostUsd + cache += api.CacheCostUsd + } + + var wantTotal, wantCache float64 + for _, r := range rows { + wantTotal += r.TotalCostUSD() + wantCache += r.CacheCostUSD() + } + assert.InDelta(t, wantTotal, total, 1e-12, "buckets must sum to the total across all ledger rows") + assert.InDelta(t, wantCache, cache, 1e-12, "buckets must sum to the cache spend across all ledger rows") + + // A month bucket over the same rows must total identically — regrouping + // changes the partition, never the sum. + monthly := AggregateUsageByGranularity(rows, UsageGranularityMonth) + require.Len(t, monthly, 1) + assert.InDelta(t, wantTotal, monthly[0].ToAPIResponse().CostUsd, 1e-12, + "re-bucketing at a different granularity must preserve the total") +} diff --git a/management/internals/modules/agentnetwork/types/guardrail.go b/management/internals/modules/agentnetwork/types/guardrail.go new file mode 100644 index 000000000..12edd815c --- /dev/null +++ b/management/internals/modules/agentnetwork/types/guardrail.go @@ -0,0 +1,120 @@ +package types + +import ( + "time" + + "github.com/rs/xid" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// GuardrailChecks is the configurable parameter set persisted with each +// guardrail. Stored as a JSON blob to keep the table flat. +type GuardrailChecks struct { + ModelAllowlist GuardrailModelAllowlist `json:"model_allowlist"` + PromptCapture GuardrailPromptCapture `json:"prompt_capture"` +} + +type GuardrailModelAllowlist struct { + Enabled bool `json:"enabled"` + Models []string `json:"models"` +} + +type GuardrailPromptCapture struct { + Enabled bool `json:"enabled"` + RedactPii bool `json:"redact_pii"` +} + +// Guardrail is an Agent Network reusable guardrail set persisted per account. +type Guardrail struct { + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index"` + Name string + Description string + Checks GuardrailChecks `gorm:"serializer:json"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// TableName uses an explicit name so guardrail rows live in their own +// table. +func (Guardrail) TableName() string { return "agent_network_guardrails" } + +// NewGuardrail returns a new Guardrail with a freshly minted ID. +func NewGuardrail(accountID string) *Guardrail { + now := time.Now().UTC() + return &Guardrail{ + ID: "ainguard_" + xid.New().String(), + AccountID: accountID, + Checks: GuardrailChecks{ModelAllowlist: GuardrailModelAllowlist{Models: []string{}}}, + CreatedAt: now, + UpdatedAt: now, + } +} + +// FromAPIRequest applies the request payload onto the receiver. +func (g *Guardrail) FromAPIRequest(req *api.AgentNetworkGuardrailRequest) { + g.Name = req.Name + if req.Description != nil { + g.Description = *req.Description + } + g.Checks = checksFromAPI(req.Checks) +} + +// ToAPIResponse renders the guardrail as the API representation. +func (g *Guardrail) ToAPIResponse() *api.AgentNetworkGuardrail { + created := g.CreatedAt + updated := g.UpdatedAt + return &api.AgentNetworkGuardrail{ + Id: g.ID, + Name: g.Name, + Description: g.Description, + Checks: checksToAPI(g.Checks), + CreatedAt: &created, + UpdatedAt: &updated, + } +} + +// Copy returns a deep copy of the guardrail. +func (g *Guardrail) Copy() *Guardrail { + clone := *g + if g.Checks.ModelAllowlist.Models != nil { + clone.Checks.ModelAllowlist.Models = append([]string(nil), g.Checks.ModelAllowlist.Models...) + } + return &clone +} + +// EventMeta is the audit-log payload for activity events. +func (g *Guardrail) EventMeta() map[string]any { + return map[string]any{"name": g.Name} +} + +func checksFromAPI(c api.AgentNetworkGuardrailChecks) GuardrailChecks { + models := append([]string(nil), c.ModelAllowlist.Models...) + if models == nil { + models = []string{} + } + return GuardrailChecks{ + ModelAllowlist: GuardrailModelAllowlist{ + Enabled: c.ModelAllowlist.Enabled, + Models: models, + }, + PromptCapture: GuardrailPromptCapture{ + Enabled: c.PromptCapture.Enabled, + RedactPii: c.PromptCapture.RedactPii, + }, + } +} + +func checksToAPI(c GuardrailChecks) api.AgentNetworkGuardrailChecks { + models := c.ModelAllowlist.Models + if models == nil { + models = []string{} + } + out := api.AgentNetworkGuardrailChecks{} + out.ModelAllowlist.Enabled = c.ModelAllowlist.Enabled + out.ModelAllowlist.Models = models + out.PromptCapture.Enabled = c.PromptCapture.Enabled + out.PromptCapture.RedactPii = c.PromptCapture.RedactPii + return out +} diff --git a/management/internals/modules/agentnetwork/types/policy.go b/management/internals/modules/agentnetwork/types/policy.go new file mode 100644 index 000000000..709b8149d --- /dev/null +++ b/management/internals/modules/agentnetwork/types/policy.go @@ -0,0 +1,192 @@ +package types + +import ( + "time" + + "github.com/rs/xid" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// Policy is an Agent Network policy persisted per account. A policy +// authorises members of SourceGroups to reach the listed +// DestinationProviderIDs under the attached GuardrailIDs and Limits. +// +// Token and budget limits live on the Policy itself (Limits field); +// guardrails carry only model allowlist and prompt capture. +type Policy struct { + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index"` + Name string + Description string + Enabled bool + SourceGroups []string `gorm:"serializer:json;column:source_groups"` + DestinationProviderIDs []string `gorm:"serializer:json;column:destination_provider_ids"` + GuardrailIDs []string `gorm:"serializer:json;column:guardrail_ids"` + Limits PolicyLimits `gorm:"serializer:json;column:limits"` + + CreatedAt time.Time + UpdatedAt time.Time +} + +// PolicyLimits aggregates the token and budget caps attached directly +// to a policy. Both halves are always present; their Enabled flags +// control whether the proxy enforces them. +type PolicyLimits struct { + TokenLimit PolicyTokenLimit `json:"token_limit"` + BudgetLimit PolicyBudgetLimit `json:"budget_limit"` +} + +// PolicyTokenLimit is a token-count cap evaluated over an aligned +// window of WindowSeconds seconds. GroupCap is applied to each +// source group independently — every group in the policy's +// SourceGroups gets its own bucket of GroupCap tokens. UserCap +// applies independently to each individual user. A zero cap means +// uncapped. WindowSeconds must be at least 60 (one minute) when the +// limit is enabled. +type PolicyTokenLimit struct { + Enabled bool `json:"enabled"` + GroupCap int64 `json:"group_cap"` + UserCap int64 `json:"user_cap"` + WindowSeconds int64 `json:"window_seconds"` +} + +// PolicyBudgetLimit is a USD spend cap evaluated over an aligned +// window of WindowSeconds seconds. GroupCapUsd is applied to each +// source group independently — every group in the policy's +// SourceGroups gets its own bucket of GroupCapUsd USD. UserCapUsd +// applies independently to each individual user. A zero cap means +// uncapped. WindowSeconds must be at least 60 (one minute) when the +// limit is enabled. +type PolicyBudgetLimit struct { + Enabled bool `json:"enabled"` + GroupCapUsd float64 `json:"group_cap_usd"` + UserCapUsd float64 `json:"user_cap_usd"` + WindowSeconds int64 `json:"window_seconds"` +} + +// TableName forces a unique GORM table to avoid collision with the access +// control Policy type, which also resolves to "policies" by default. +func (Policy) TableName() string { return "agent_network_policies" } + +// NewPolicy returns a new Policy with a freshly minted ID. +func NewPolicy(accountID string) *Policy { + now := time.Now().UTC() + return &Policy{ + ID: "ainpol_" + xid.New().String(), + AccountID: accountID, + Enabled: true, + CreatedAt: now, + UpdatedAt: now, + } +} + +// FromAPIRequest applies the request payload onto the receiver. +func (p *Policy) FromAPIRequest(req *api.AgentNetworkPolicyRequest) { + p.Name = req.Name + if req.Description != nil { + p.Description = *req.Description + } + if req.Enabled != nil { + p.Enabled = *req.Enabled + } + p.SourceGroups = append([]string(nil), req.SourceGroups...) + p.DestinationProviderIDs = append([]string(nil), req.DestinationProviderIds...) + if req.GuardrailIds != nil { + p.GuardrailIDs = append([]string(nil), (*req.GuardrailIds)...) + } else { + p.GuardrailIDs = []string{} + } + if req.Limits != nil { + p.Limits = limitsFromAPI(*req.Limits) + } else { + p.Limits = PolicyLimits{} + } +} + +// ToAPIResponse renders the policy as the API representation. +func (p *Policy) ToAPIResponse() *api.AgentNetworkPolicy { + src := p.SourceGroups + if src == nil { + src = []string{} + } + dst := p.DestinationProviderIDs + if dst == nil { + dst = []string{} + } + guardrails := p.GuardrailIDs + if guardrails == nil { + guardrails = []string{} + } + created := p.CreatedAt + updated := p.UpdatedAt + return &api.AgentNetworkPolicy{ + Id: p.ID, + Name: p.Name, + Description: p.Description, + Enabled: p.Enabled, + SourceGroups: src, + DestinationProviderIds: dst, + GuardrailIds: guardrails, + Limits: limitsToAPI(p.Limits), + CreatedAt: &created, + UpdatedAt: &updated, + } +} + +// Copy returns a deep copy of the policy. +func (p *Policy) Copy() *Policy { + clone := *p + if p.SourceGroups != nil { + clone.SourceGroups = append([]string(nil), p.SourceGroups...) + } + if p.DestinationProviderIDs != nil { + clone.DestinationProviderIDs = append([]string(nil), p.DestinationProviderIDs...) + } + if p.GuardrailIDs != nil { + clone.GuardrailIDs = append([]string(nil), p.GuardrailIDs...) + } + return &clone +} + +// EventMeta is the audit-log payload for activity events. +func (p *Policy) EventMeta() map[string]any { + return map[string]any{ + "name": p.Name, + "enabled": p.Enabled, + } +} + +func limitsFromAPI(in api.AgentNetworkPolicyLimits) PolicyLimits { + return PolicyLimits{ + TokenLimit: PolicyTokenLimit{ + Enabled: in.TokenLimit.Enabled, + GroupCap: in.TokenLimit.GroupCap, + UserCap: in.TokenLimit.UserCap, + WindowSeconds: in.TokenLimit.WindowSeconds, + }, + BudgetLimit: PolicyBudgetLimit{ + Enabled: in.BudgetLimit.Enabled, + GroupCapUsd: in.BudgetLimit.GroupCapUsd, + UserCapUsd: in.BudgetLimit.UserCapUsd, + WindowSeconds: in.BudgetLimit.WindowSeconds, + }, + } +} + +func limitsToAPI(in PolicyLimits) api.AgentNetworkPolicyLimits { + return api.AgentNetworkPolicyLimits{ + TokenLimit: api.AgentNetworkPolicyTokenLimit{ + Enabled: in.TokenLimit.Enabled, + GroupCap: in.TokenLimit.GroupCap, + UserCap: in.TokenLimit.UserCap, + WindowSeconds: in.TokenLimit.WindowSeconds, + }, + BudgetLimit: api.AgentNetworkPolicyBudgetLimit{ + Enabled: in.BudgetLimit.Enabled, + GroupCapUsd: in.BudgetLimit.GroupCapUsd, + UserCapUsd: in.BudgetLimit.UserCapUsd, + WindowSeconds: in.BudgetLimit.WindowSeconds, + }, + } +} diff --git a/management/internals/modules/agentnetwork/types/provider.go b/management/internals/modules/agentnetwork/types/provider.go new file mode 100644 index 000000000..b3287168e --- /dev/null +++ b/management/internals/modules/agentnetwork/types/provider.go @@ -0,0 +1,271 @@ +package types + +import ( + "fmt" + "strings" + "time" + + "github.com/rs/xid" + + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/util/crypt" +) + +// ProviderModel is one row in the provider's models list. The operator +// pins the per-1k input/output price for cost tracking; ID is the +// model identifier the upstream provider expects on the wire. +type ProviderModel struct { + ID string `json:"id"` + InputPer1k float64 `json:"input_per_1k"` + OutputPer1k float64 `json:"output_per_1k"` +} + +// Provider is an Agent Network AI provider record persisted per account. +// The proxy cluster fronting the account lives on the per-account +// agent-network Settings row, not on the Provider — every provider in +// an account routes through the same cluster. +type Provider struct { + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index"` + ProviderID string `gorm:"index:idx_agent_network_provider"` + Name string + // UpstreamURL is the full upstream URL (e.g. https://api.openai.com) + // the operator selected. + UpstreamURL string `gorm:"column:upstream_url"` + APIKey string `gorm:"column:api_key"` + // ExtraValues holds operator-typed values for catalog-declared + // ExtraHeaders (see catalog.Provider.ExtraHeaders). Keyed by + // header name (e.g. "x-portkey-config"); a non-empty value is + // stamped on every upstream request to this provider via the + // proxy's identity-inject middleware (anti-spoof Remove + Add). + // Empty / missing keys = no header stamped. Stored as a JSON + // blob so the schema doesn't grow per-catalog-entry. + ExtraValues map[string]string `gorm:"serializer:json;column:extra_values"` + // Models is the operator's curated list of models exposed by this + // provider together with their per-1k input/output prices (USD). + // Empty means all catalog models are allowed at catalog prices. + Models []ProviderModel `gorm:"serializer:json"` + Enabled bool + // SkipTLSVerification disables upstream TLS certificate verification for + // this provider's URL. For self-hosted / internal gateways fronted by a + // private or self-signed certificate. The synthesiser propagates it into + // the router route so the proxy dials that provider's upstream insecurely. + SkipTLSVerification bool `gorm:"column:skip_tls_verification"` + // MetadataDisabled suppresses identity metadata injection for this provider. + // Metadata (the caller's user + authorizing group) is injected by default; + // when true the synthesiser omits the provider's identity-inject shape, so no + // user/group headers (e.g. Bedrock's X-Amzn-Bedrock-Request-Metadata) are + // stamped. Catalog ExtraHeaders (routing config) are unaffected. + MetadataDisabled bool `gorm:"column:metadata_disabled"` + // SessionPrivateKey + SessionPublicKey are the ed25519 keypair the + // synthesised reverse-proxy service uses to sign / verify session + // JWTs after a successful OIDC handshake. Generated once on + // provider create and never rotated by the manager so existing + // session cookies survive provider edits. SessionPrivateKey is + // encrypted at rest via EncryptSensitiveData / + // DecryptSensitiveData; SessionPublicKey is plain. + SessionPrivateKey string `gorm:"column:session_private_key"` + SessionPublicKey string `gorm:"column:session_public_key"` + // IdentityHeaderUserID + IdentityHeaderGroups are the operator- + // chosen wire header names for HeaderPair-style identity + // injection on catalog entries that flag the shape as + // Customizable (e.g. Bifrost, where the operator picks between + // the always-on x-bf-lh- log-metadata family and the + // label-declared x-bf-dim- telemetry family). Empty value + // disables stamping for that dimension; the inject middleware + // already no-ops on empty header names. Catalog entries with + // Customizable=false ignore these fields and use the static + // header names defined in their HeaderPairInjection block. + IdentityHeaderUserID string `gorm:"column:identity_header_user_id"` + IdentityHeaderGroups string `gorm:"column:identity_header_groups"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// TableName uses an explicit name so the Agent Network provider rows live +// in their own table, separate from any future "providers"-named entity. +func (Provider) TableName() string { return "agent_network_providers" } + +// NewProvider returns a new Provider with a freshly minted ID. +func NewProvider(accountID string) *Provider { + now := time.Now().UTC() + return &Provider{ + ID: xid.New().String(), + AccountID: accountID, + CreatedAt: now, + UpdatedAt: now, + } +} + +// FromAPIRequest applies the request payload onto the receiver. The api_key +// is only overwritten when the caller provided one — empty/nil leaves the +// existing key intact, so updates can omit it. +func (p *Provider) FromAPIRequest(req *api.AgentNetworkProviderRequest) { + p.ProviderID = req.ProviderId + p.Name = req.Name + p.UpstreamURL = req.UpstreamUrl + if req.ApiKey != nil && strings.TrimSpace(*req.ApiKey) != "" { + p.APIKey = *req.ApiKey + } + if req.ExtraValues != nil { + // Replace the whole map (rather than merge) so unsetting a + // value on the dashboard actually clears it. Empty strings + // are dropped so we don't waste a row on no-op values. + next := make(map[string]string, len(*req.ExtraValues)) + for k, v := range *req.ExtraValues { + v = strings.TrimSpace(v) + if v != "" { + next[k] = v + } + } + if len(next) == 0 { + p.ExtraValues = nil + } else { + p.ExtraValues = next + } + } + p.Models = p.Models[:0] + if req.Models != nil { + for _, m := range *req.Models { + p.Models = append(p.Models, ProviderModel{ + ID: m.Id, + InputPer1k: m.InputPer1k, + OutputPer1k: m.OutputPer1k, + }) + } + } + if p.Models == nil { + p.Models = []ProviderModel{} + } + if req.Enabled != nil { + p.Enabled = *req.Enabled + } + if req.SkipTlsVerification != nil { + p.SkipTLSVerification = *req.SkipTlsVerification + } + if req.MetadataDisabled != nil { + p.MetadataDisabled = *req.MetadataDisabled + } + // Identity-header overrides for catalogs flagged Customizable. + // nil pointer = "field omitted on the wire" → leave the stored + // value untouched (per the openapi description). Empty string is + // an explicit clear that disables stamping for this dimension. + if req.IdentityHeaderUserId != nil { + p.IdentityHeaderUserID = strings.TrimSpace(*req.IdentityHeaderUserId) + } + if req.IdentityHeaderGroups != nil { + p.IdentityHeaderGroups = strings.TrimSpace(*req.IdentityHeaderGroups) + } +} + +// ToAPIResponse renders the provider as the API representation. The API +// key is intentionally never surfaced. +func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider { + models := make([]api.AgentNetworkProviderModel, 0, len(p.Models)) + for _, m := range p.Models { + models = append(models, api.AgentNetworkProviderModel{ + Id: m.ID, + InputPer1k: m.InputPer1k, + OutputPer1k: m.OutputPer1k, + }) + } + created := p.CreatedAt + updated := p.UpdatedAt + resp := &api.AgentNetworkProvider{ + Id: p.ID, + ProviderId: p.ProviderID, + Name: p.Name, + UpstreamUrl: p.UpstreamURL, + Models: models, + Enabled: p.Enabled, + SkipTlsVerification: p.SkipTLSVerification, + MetadataDisabled: p.MetadataDisabled, + CreatedAt: &created, + UpdatedAt: &updated, + } + if len(p.ExtraValues) > 0 { + out := make(map[string]string, len(p.ExtraValues)) + for k, v := range p.ExtraValues { + out[k] = v + } + resp.ExtraValues = &out + } + if p.IdentityHeaderUserID != "" { + v := p.IdentityHeaderUserID + resp.IdentityHeaderUserId = &v + } + if p.IdentityHeaderGroups != "" { + v := p.IdentityHeaderGroups + resp.IdentityHeaderGroups = &v + } + return resp +} + +// Copy returns a deep copy of the provider. +func (p *Provider) Copy() *Provider { + clone := *p + if p.Models != nil { + clone.Models = append([]ProviderModel(nil), p.Models...) + } + if p.ExtraValues != nil { + clone.ExtraValues = make(map[string]string, len(p.ExtraValues)) + for k, v := range p.ExtraValues { + clone.ExtraValues[k] = v + } + } + return &clone +} + +// EventMeta is the audit-log payload for activity events. +func (p *Provider) EventMeta() map[string]any { + return map[string]any{ + "name": p.Name, + "provider_id": p.ProviderID, + } +} + +// EncryptSensitiveData encrypts the upstream API key and the session +// signing key in place. +func (p *Provider) EncryptSensitiveData(enc *crypt.FieldEncrypt) error { + if enc == nil { + return nil + } + if p.APIKey != "" { + encrypted, err := enc.Encrypt(p.APIKey) + if err != nil { + return fmt.Errorf("encrypt agent network provider api key: %w", err) + } + p.APIKey = encrypted + } + if p.SessionPrivateKey != "" { + encrypted, err := enc.Encrypt(p.SessionPrivateKey) + if err != nil { + return fmt.Errorf("encrypt agent network provider session key: %w", err) + } + p.SessionPrivateKey = encrypted + } + return nil +} + +// DecryptSensitiveData decrypts the upstream API key and the session +// signing key in place. +func (p *Provider) DecryptSensitiveData(enc *crypt.FieldEncrypt) error { + if enc == nil { + return nil + } + if p.APIKey != "" { + decrypted, err := enc.Decrypt(p.APIKey) + if err != nil { + return fmt.Errorf("decrypt agent network provider api key: %w", err) + } + p.APIKey = decrypted + } + if p.SessionPrivateKey != "" { + decrypted, err := enc.Decrypt(p.SessionPrivateKey) + if err != nil { + return fmt.Errorf("decrypt agent network provider session key: %w", err) + } + p.SessionPrivateKey = decrypted + } + return nil +} diff --git a/management/internals/modules/agentnetwork/types/provider_test.go b/management/internals/modules/agentnetwork/types/provider_test.go new file mode 100644 index 000000000..f9756bb8b --- /dev/null +++ b/management/internals/modules/agentnetwork/types/provider_test.go @@ -0,0 +1,79 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// TestProvider_SkipTLSVerification_RoundTrip covers the request→provider→ +// response mapping of skip_tls_verification, including the update semantics +// (nil pointer preserves, explicit false clears). +func TestProvider_SkipTLSVerification_RoundTrip(t *testing.T) { + enable := true + disable := false + + base := func() *api.AgentNetworkProviderRequest { + return &api.AgentNetworkProviderRequest{ + ProviderId: "openai_api", + Name: "internal", + UpstreamUrl: "https://gw.internal", + } + } + + p := NewProvider("acc-1") + + req := base() + req.SkipTlsVerification = &enable + p.FromAPIRequest(req) + assert.True(t, p.SkipTLSVerification, "create with skip_tls_verification=true must set the field") + assert.True(t, p.ToAPIResponse().SkipTlsVerification, "response must surface skip_tls_verification") + + // Omitting the field on update leaves the stored value untouched. + p.FromAPIRequest(base()) + assert.True(t, p.SkipTLSVerification, "omitting skip_tls_verification on update must preserve it") + + // Explicit false clears it. + req = base() + req.SkipTlsVerification = &disable + p.FromAPIRequest(req) + assert.False(t, p.SkipTLSVerification, "explicit false must clear skip_tls_verification") + assert.False(t, p.ToAPIResponse().SkipTlsVerification, "response must reflect the cleared value") +} + +// TestProvider_MetadataDisabled_RoundTrip covers the request→provider→response +// mapping of metadata_disabled, with the same update semantics: nil preserves, +// explicit false clears. +func TestProvider_MetadataDisabled_RoundTrip(t *testing.T) { + enable := true + disable := false + + base := func() *api.AgentNetworkProviderRequest { + return &api.AgentNetworkProviderRequest{ + ProviderId: "bedrock_api", + Name: "bedrock", + UpstreamUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + } + } + + p := NewProvider("acc-1") + + req := base() + req.MetadataDisabled = &enable + p.FromAPIRequest(req) + assert.True(t, p.MetadataDisabled, "create with metadata_disabled=true must set the field") + assert.True(t, p.ToAPIResponse().MetadataDisabled, "response must surface metadata_disabled") + + // Omitting the field on update leaves the stored value untouched. + p.FromAPIRequest(base()) + assert.True(t, p.MetadataDisabled, "omitting metadata_disabled on update must preserve it") + + // Explicit false clears it (re-enables metadata). + req = base() + req.MetadataDisabled = &disable + p.FromAPIRequest(req) + assert.False(t, p.MetadataDisabled, "explicit false must clear metadata_disabled") + assert.False(t, p.ToAPIResponse().MetadataDisabled, "response must reflect the cleared value") +} diff --git a/management/internals/modules/agentnetwork/types/settings.go b/management/internals/modules/agentnetwork/types/settings.go new file mode 100644 index 000000000..d61d9deff --- /dev/null +++ b/management/internals/modules/agentnetwork/types/settings.go @@ -0,0 +1,78 @@ +package types + +import ( + "time" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// DefaultAccessLogRetentionDays is the retention applied to new accounts' +// agent-network access logs. Usage records are not subject to this — they are +// the long-term aggregate and are retained independently. +const DefaultAccessLogRetentionDays = 30 + +// Settings is the per-account agent-network configuration row. One +// row per account. Cluster + Subdomain are immutable once written and +// produce the public endpoint agents call (`.`). +type Settings struct { + AccountID string `gorm:"primaryKey"` + Cluster string + Subdomain string `gorm:"index:idx_agent_network_settings_cluster_subdomain"` + + // Account-level collection controls sourced by the synthesizer. + // EnableLogCollection gates the per-request access-log trail and defaults + // ON for new accounts. EnablePromptCollection is the master gate for + // request/response prompt capture (AND-gated with the policy-level + // guardrail). RedactPii enables PII redaction on captured prompts; + // effective redaction is account OR policy. + EnableLogCollection bool + EnablePromptCollection bool + RedactPii bool + + // AccessLogRetentionDays bounds how long full access-log rows are kept; a + // periodic sweep deletes older rows. <= 0 means keep indefinitely. Usage + // records are unaffected. + AccessLogRetentionDays int + + CreatedAt time.Time + UpdatedAt time.Time +} + +// TableName puts the rows in their own table to keep the agent-network +// schema cohesive. +func (Settings) TableName() string { return "agent_network_settings" } + +// Endpoint returns the bare hostname agents reach this account at: +// `.`. +func (s *Settings) Endpoint() string { + return s.Subdomain + "." + s.Cluster +} + +// ToAPIResponse renders the settings as the API representation. +func (s *Settings) ToAPIResponse() *api.AgentNetworkSettings { + created := s.CreatedAt + updated := s.UpdatedAt + retention := s.AccessLogRetentionDays + return &api.AgentNetworkSettings{ + Cluster: s.Cluster, + Subdomain: s.Subdomain, + Endpoint: s.Endpoint(), + EnableLogCollection: s.EnableLogCollection, + EnablePromptCollection: s.EnablePromptCollection, + RedactPii: s.RedactPii, + AccessLogRetentionDays: &retention, + CreatedAt: &created, + UpdatedAt: &updated, + } +} + +// FromAPIRequest applies the mutable settings fields from the request. Cluster +// and Subdomain are immutable and intentionally not touched here. +func (s *Settings) FromAPIRequest(req *api.AgentNetworkSettingsRequest) { + s.EnableLogCollection = req.EnableLogCollection + s.EnablePromptCollection = req.EnablePromptCollection + s.RedactPii = req.RedactPii + if req.AccessLogRetentionDays != nil { + s.AccessLogRetentionDays = *req.AccessLogRetentionDays + } +} diff --git a/management/internals/modules/agentnetwork/types/usage.go b/management/internals/modules/agentnetwork/types/usage.go new file mode 100644 index 000000000..658fa8e59 --- /dev/null +++ b/management/internals/modules/agentnetwork/types/usage.go @@ -0,0 +1,69 @@ +package types + +import ( + "time" +) + +// AgentNetworkUsage is the stripped, always-collected per-request usage record +// powering the Usage overview. Unlike AgentNetworkAccessLog it carries no +// request detail (host/path/source IP/prompt) — only the dimensions needed to +// aggregate and filter spend by user / group / provider / model over time. +// +// It is written unconditionally on every served agent-network request, +// independent of the account's EnableLogCollection toggle: when log collection +// is off the proxy ships a stripped, usage-only entry and management still +// records the usage row (but skips the full AgentNetworkAccessLog row). +type AgentNetworkUsage struct { + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index"` + Timestamp time.Time `gorm:"index"` + UserID string `gorm:"index"` + ResolvedProviderID string `gorm:"index"` + Provider string // vendor, e.g. "openai" + Model string `gorm:"index"` + SessionID string `gorm:"index"` // llm.session_id — groups a conversation / coding session + InputTokens int64 + OutputTokens int64 + TotalTokens int64 + // Prompt-cache buckets: read + write token counts. + CachedInputTokens int64 + CacheCreationTokens int64 + // Per-bucket cost breakdown, mirroring AgentNetworkAccessLog — the only + // cost state stored; total and cache portion are derived on read. Kept on + // the usage ledger too so spend can be attributed per bucket even for + // accounts with log collection turned off. See AgentNetworkAccessLog for + // why the columns carry a zero default. + InputCostUSD float64 `gorm:"not null;default:0"` + CachedInputCostUSD float64 `gorm:"not null;default:0"` + CacheCreationCostUSD float64 `gorm:"not null;default:0"` + OutputCostUSD float64 `gorm:"not null;default:0"` + CreatedAt time.Time +} + +// TableName keeps usage records in their own stripped table. Named +// distinctly (…_request_usage) to avoid colliding with any pre-existing +// agent_network_usage table in a shared database. +func (AgentNetworkUsage) TableName() string { return "agent_network_request_usage" } + +// TotalCostUSD is the request's total cost: the sum of the four per-bucket +// costs. Derived rather than stored so it cannot disagree with the breakdown. +func (u *AgentNetworkUsage) TotalCostUSD() float64 { + return u.InputCostUSD + u.CachedInputCostUSD + u.CacheCreationCostUSD + u.OutputCostUSD +} + +// CacheCostUSD is the portion of the total billed for prompt-cache buckets. +func (u *AgentNetworkUsage) CacheCostUSD() float64 { + return u.CachedInputCostUSD + u.CacheCreationCostUSD +} + +// AgentNetworkUsageGroup is the normalised many-to-many row linking a usage +// record to one authorising group, mirroring AgentNetworkAccessLogGroup so the +// usage overview can filter by group with a `group_id IN (...)` join. +type AgentNetworkUsageGroup struct { + UsageID string `gorm:"primaryKey"` + GroupID string `gorm:"primaryKey;index"` + AccountID string `gorm:"index"` +} + +// TableName names the usage group child table. +func (AgentNetworkUsageGroup) TableName() string { return "agent_network_request_usage_group" } diff --git a/management/internals/modules/agentnetwork/types/usageoverview.go b/management/internals/modules/agentnetwork/types/usageoverview.go new file mode 100644 index 000000000..81e02c6b8 --- /dev/null +++ b/management/internals/modules/agentnetwork/types/usageoverview.go @@ -0,0 +1,125 @@ +package types + +import ( + "sort" + "time" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// UsageGranularity is the time-bucket width for the usage overview. New values +// can be added here and handled in bucketStart without touching the store. +type UsageGranularity string + +const ( + UsageGranularityDay UsageGranularity = "day" + UsageGranularityWeek UsageGranularity = "week" + UsageGranularityMonth UsageGranularity = "month" +) + +// ParseUsageGranularity maps the API query value to a granularity, defaulting +// to day for empty/unknown input. +func ParseUsageGranularity(s string) UsageGranularity { + switch UsageGranularity(s) { + case UsageGranularityWeek: + return UsageGranularityWeek + case UsageGranularityMonth: + return UsageGranularityMonth + default: + return UsageGranularityDay + } +} + +// AgentNetworkUsageBucket is one aggregated usage time bucket. PeriodStart is +// the UTC start of the bucket as YYYY-MM-DD. +type AgentNetworkUsageBucket struct { + PeriodStart string + InputTokens int64 + OutputTokens int64 + TotalTokens int64 + CachedInputTokens int64 + CacheCreationTokens int64 + InputCostUSD float64 + CachedInputCostUSD float64 + CacheCreationCostUSD float64 + OutputCostUSD float64 +} + +// TotalCostUSD is the bucket's total spend: the sum of the four per-bucket +// costs. Derived rather than accumulated separately so it cannot disagree with +// the components. +func (b *AgentNetworkUsageBucket) TotalCostUSD() float64 { + return b.InputCostUSD + b.CachedInputCostUSD + b.CacheCreationCostUSD + b.OutputCostUSD +} + +// CacheCostUSD is the bucket's prompt-cache spend: cache reads plus writes. +func (b *AgentNetworkUsageBucket) CacheCostUSD() float64 { + return b.CachedInputCostUSD + b.CacheCreationCostUSD +} + +// ToAPIResponse renders the bucket as the API representation. +func (b *AgentNetworkUsageBucket) ToAPIResponse() api.AgentNetworkUsageBucket { + return api.AgentNetworkUsageBucket{ + PeriodStart: b.PeriodStart, + InputTokens: b.InputTokens, + OutputTokens: b.OutputTokens, + TotalTokens: b.TotalTokens, + CachedInputTokens: b.CachedInputTokens, + CacheCreationTokens: b.CacheCreationTokens, + InputCostUsd: b.InputCostUSD, + CachedInputCostUsd: b.CachedInputCostUSD, + CacheCreationCostUsd: b.CacheCreationCostUSD, + OutputCostUsd: b.OutputCostUSD, + CostUsd: b.TotalCostUSD(), + CacheCostUsd: b.CacheCostUSD(), + } +} + +// bucketStart truncates t (in UTC) to the start of its bucket for the given +// granularity. Week buckets start on Monday (ISO week). +func bucketStart(t time.Time, g UsageGranularity) time.Time { + t = t.UTC() + switch g { + case UsageGranularityWeek: + // Monday-start week. time.Weekday: Sunday=0..Saturday=6. + offset := (int(t.Weekday()) + 6) % 7 + day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) + return day.AddDate(0, 0, -offset) + case UsageGranularityMonth: + return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC) + default: // day + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) + } +} + +// AggregateUsageByGranularity buckets the usage rows by the requested +// granularity and returns the buckets ordered oldest-first. Aggregation is done +// in Go (rather than per-engine SQL date_trunc) so granularities stay portable +// across SQLite/Postgres/MySQL and easy to extend. +func AggregateUsageByGranularity(rows []*AgentNetworkUsage, g UsageGranularity) []*AgentNetworkUsageBucket { + byPeriod := make(map[string]*AgentNetworkUsageBucket) + for _, r := range rows { + key := bucketStart(r.Timestamp, g).Format("2006-01-02") + b := byPeriod[key] + if b == nil { + b = &AgentNetworkUsageBucket{PeriodStart: key} + byPeriod[key] = b + } + b.InputTokens += r.InputTokens + b.OutputTokens += r.OutputTokens + b.TotalTokens += r.TotalTokens + b.CachedInputTokens += r.CachedInputTokens + b.CacheCreationTokens += r.CacheCreationTokens + b.InputCostUSD += r.InputCostUSD + b.CachedInputCostUSD += r.CachedInputCostUSD + b.CacheCreationCostUSD += r.CacheCreationCostUSD + b.OutputCostUSD += r.OutputCostUSD + } + + out := make([]*AgentNetworkUsageBucket, 0, len(byPeriod)) + for _, b := range byPeriod { + out = append(out, b) + } + sort.Slice(out, func(i, j int) bool { return out[i].PeriodStart < out[j].PeriodStart }) + return out +} diff --git a/management/internals/modules/agentnetwork/wire_shape_test.go b/management/internals/modules/agentnetwork/wire_shape_test.go new file mode 100644 index 000000000..b574ab3e1 --- /dev/null +++ b/management/internals/modules/agentnetwork/wire_shape_test.go @@ -0,0 +1,109 @@ +package agentnetwork + +import ( + "context" + "encoding/json" + "testing" + + "github.com/golang/mock/gomock" + "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/proxy" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// TestSynthesizedService_WireShape locks down the proto shape that +// flows from the synthesizer through ToProtoMapping to the proxy. +// Drift between this test and what the proxy expects manifests as +// "service not matching" — the proxy receives a mapping but can't +// register an SNI/HTTP route from it. +func TestSynthesizedService_WireShape(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := store.NewMockStore(ctrl) + + provider := newSynthTestProvider() + policy := newSynthTestPolicy(provider.ID, "grp-eng", "") + + expectSynthBaseInputs(mockStore, ctx, newSynthTestSettings(), + []*types.Provider{provider}, + []*types.Policy{policy}, + []*types.Guardrail{}) + + services, err := SynthesizeServices(ctx, mockStore, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1) + + svc := services[0] + mapping := svc.ToProtoMapping(rpservice.Create, "test-token", proxy.OIDCValidationConfig{}) + + // Identifiers — account-scoped service ID, settings-derived domain. + assert.Equal(t, "agent-net-svc-acct-1", mapping.GetId(), "stable account-scoped virtual service ID") + assert.Equal(t, testAccountID, mapping.GetAccountId(), "account id round-trips") + assert.Equal(t, testEndpoint, mapping.GetDomain(), "domain matches settings.Endpoint() output") + + // Mode + listen port — addMapping at proxy/server.go switches on Mode. + assert.Equal(t, "http", mapping.GetMode(), "synthesised services are HTTP mode") + assert.Equal(t, int32(0), mapping.GetListenPort(), "no custom listen port for HTTP services") + + // Auth token + private/tunnel shape: agent-network endpoints authenticate + // inbound agents via ValidateTunnelPeer against AccessGroups, not OIDC. + assert.Equal(t, "test-token", mapping.GetAuthToken(), "auth token round-trips for proxy CreateProxyPeer") + assert.True(t, mapping.GetPrivate(), "synthesised services are private (tunnel-peer auth via AccessGroups)") + require.NotNil(t, mapping.GetAuth(), "auth payload carries the session key") + assert.False(t, mapping.GetAuth().GetOidc(), "OIDC is off for tunnel-auth agent-network services") + + // Path mappings — proxy/server.go::setupHTTPMapping early-returns when + // len(mapping.GetPath()) == 0, so this is a critical assertion. + require.Len(t, mapping.GetPath(), 1, "exactly one path mapping for the cluster target") + pm := mapping.GetPath()[0] + assert.Equal(t, "/", pm.GetPath(), "default path is '/'") + assert.Equal(t, "https://noop.invalid/", pm.GetTarget(), + "target URL is the placeholder; the router middleware rewrites it per request") + require.NotNil(t, pm.GetOptions(), "target options must be populated so direct_upstream + middleware chain reach the proxy") + assert.True(t, pm.GetOptions().GetDirectUpstream(), "synth targets imply direct_upstream so the proxy dials via the host stack") + assert.True(t, pm.GetOptions().GetAgentNetwork(), "agent_network flag must travel on the wire so the proxy can tag access logs") + + mws := pm.GetOptions().GetMiddlewares() + require.Len(t, mws, 8, "eight middlewares reach the proxy: request_parser, router, limit_check, identity_inject, guardrail, limit_record, cost_meter, response_parser") + + assert.Equal(t, middlewareIDLLMRequestParser, mws[0].GetId(), "first middleware id") + assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, mws[0].GetSlot(), "request parser slot") + + assert.Equal(t, middlewareIDLLMRouter, mws[1].GetId(), "second middleware id") + assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, mws[1].GetSlot(), "router slot") + require.NotEmpty(t, mws[1].GetConfigJson(), "router config must travel on the wire") + var routerCfg routerConfig + require.NoError(t, json.Unmarshal(mws[1].GetConfigJson(), &routerCfg), "router config decodes") + require.Len(t, routerCfg.Providers, 1, "the only enabled provider reaches the router") + assert.Equal(t, provider.ID, routerCfg.Providers[0].ID, "router provider id matches synth provider") + assert.Equal(t, "Bearer sk-test-key", routerCfg.Providers[0].AuthHeaderValue, + "openai catalog template substitutes the API key on the wire") + + assert.Equal(t, middlewareIDLLMLimitCheck, mws[2].GetId(), + "limit_check runs after the router so the resolved provider id is available, before identity_inject so a deny doesn't pay the header-stamp cost") + assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, mws[2].GetSlot()) + + assert.Equal(t, middlewareIDLLMIdentityInject, mws[3].GetId(), "fourth middleware id") + assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, mws[3].GetSlot(), "identity inject slot") + require.NotEmpty(t, mws[3].GetConfigJson(), "identity inject config JSON must travel on the wire") + + assert.Equal(t, middlewareIDLLMGuardrail, mws[4].GetId(), "fifth middleware id") + assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, mws[4].GetSlot(), "guardrail slot") + require.NotEmpty(t, mws[4].GetConfigJson(), "guardrail middleware config JSON must travel on the wire") + + assert.Equal(t, middlewareIDLLMLimitRecord, mws[5].GetId(), + "limit_record sits FIRST in the response section so it RUNS LAST at runtime — slot order on the response leg is reverse-of-slice") + assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE, mws[5].GetSlot()) + + assert.Equal(t, middlewareIDCostMeter, mws[6].GetId(), "seventh middleware id") + assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE, mws[6].GetSlot(), "cost meter slot") + + assert.Equal(t, middlewareIDLLMResponseParser, mws[7].GetId(), "eighth middleware id") + assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE, mws[7].GetSlot(), "response parser slot") +} diff --git a/management/server/peers/ephemeral/interface.go b/management/internals/modules/peers/ephemeral/interface.go similarity index 83% rename from management/server/peers/ephemeral/interface.go rename to management/internals/modules/peers/ephemeral/interface.go index a1605b3b9..8fe25435c 100644 --- a/management/server/peers/ephemeral/interface.go +++ b/management/internals/modules/peers/ephemeral/interface.go @@ -2,10 +2,15 @@ package ephemeral import ( "context" + "time" nbpeer "github.com/netbirdio/netbird/management/server/peer" ) +const ( + EphemeralLifeTime = 10 * time.Minute +) + type Manager interface { LoadInitialPeers(ctx context.Context) Stop() diff --git a/management/server/peers/ephemeral/manager/ephemeral.go b/management/internals/modules/peers/ephemeral/manager/ephemeral.go similarity index 66% rename from management/server/peers/ephemeral/manager/ephemeral.go rename to management/internals/modules/peers/ephemeral/manager/ephemeral.go index 062ba69d2..7867d6579 100644 --- a/management/server/peers/ephemeral/manager/ephemeral.go +++ b/management/internals/modules/peers/ephemeral/manager/ephemeral.go @@ -7,14 +7,16 @@ import ( log "github.com/sirupsen/logrus" - nbAccount "github.com/netbirdio/netbird/management/server/account" + "github.com/netbirdio/netbird/management/internals/modules/peers" + "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral" "github.com/netbirdio/netbird/management/server/activity" nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/netbirdio/netbird/management/server/store" ) const ( - ephemeralLifeTime = 10 * time.Minute // cleanupWindow is the time window to wait after nearest peer deadline to start the cleanup procedure. cleanupWindow = 1 * time.Minute ) @@ -33,11 +35,11 @@ type ephemeralPeer struct { // todo: consider to remove peer from ephemeral list when the peer has been deleted via API. If we do not do it // in worst case we will get invalid error message in this manager. -// EphemeralManager keep a list of ephemeral peers. After ephemeralLifeTime inactivity the peer will be deleted +// EphemeralManager keep a list of ephemeral peers. After EphemeralLifeTime inactivity the peer will be deleted // automatically. Inactivity means the peer disconnected from the Management server. type EphemeralManager struct { - store store.Store - accountManager nbAccount.Manager + store store.Store + peersManager peers.Manager headPeer *ephemeralPeer tailPeer *ephemeralPeer @@ -46,19 +48,33 @@ type EphemeralManager struct { lifeTime time.Duration cleanupWindow time.Duration + + // metrics is nil-safe; methods on telemetry.EphemeralPeersMetrics + // no-op when the receiver is nil so deployments without an app + // metrics provider work unchanged. + metrics *telemetry.EphemeralPeersMetrics } // NewEphemeralManager instantiate new EphemeralManager -func NewEphemeralManager(store store.Store, accountManager nbAccount.Manager) *EphemeralManager { +func NewEphemeralManager(store store.Store, peersManager peers.Manager) *EphemeralManager { return &EphemeralManager{ - store: store, - accountManager: accountManager, + store: store, + peersManager: peersManager, - lifeTime: ephemeralLifeTime, + lifeTime: ephemeral.EphemeralLifeTime, cleanupWindow: cleanupWindow, } } +// SetMetrics attaches a metrics collector. Safe to call once before +// LoadInitialPeers; later attachment is fine but earlier loads won't be +// reflected in the gauge. Pass nil to detach. +func (e *EphemeralManager) SetMetrics(m *telemetry.EphemeralPeersMetrics) { + e.peersLock.Lock() + e.metrics = m + e.peersLock.Unlock() +} + // LoadInitialPeers load from the database the ephemeral type of peers and schedule a cleanup procedure to the head // of the linked list (to the most deprecated peer). At the end of cleanup it schedules the next cleanup to the new // head. @@ -96,7 +112,9 @@ func (e *EphemeralManager) OnPeerConnected(ctx context.Context, peer *nbpeer.Pee e.peersLock.Lock() defer e.peersLock.Unlock() - e.removePeer(peer.ID) + if e.removePeer(peer.ID) { + e.metrics.DecPending(1) + } // stop the unnecessary timer if e.headPeer == nil && e.timer != nil { @@ -106,7 +124,7 @@ func (e *EphemeralManager) OnPeerConnected(ctx context.Context, peer *nbpeer.Pee } // OnPeerDisconnected add the peer to the linked list of ephemeral peers. Because of the peer -// is inactive it will be deleted after the ephemeralLifeTime period. +// is inactive it will be deleted after the EphemeralLifeTime period. func (e *EphemeralManager) OnPeerDisconnected(ctx context.Context, peer *nbpeer.Peer) { if !peer.Ephemeral { return @@ -122,6 +140,7 @@ func (e *EphemeralManager) OnPeerDisconnected(ctx context.Context, peer *nbpeer. } e.addPeer(peer.AccountID, peer.ID, e.newDeadLine()) + e.metrics.IncPending() if e.timer == nil { delay := e.headPeer.deadline.Sub(timeNow()) + e.cleanupWindow if delay < 0 { @@ -144,6 +163,7 @@ func (e *EphemeralManager) loadEphemeralPeers(ctx context.Context) { for _, p := range peers { e.addPeer(p.AccountID, p.ID, t) } + e.metrics.AddPending(int64(len(peers))) log.WithContext(ctx).Debugf("loaded ephemeral peer(s): %d", len(peers)) } @@ -180,19 +200,29 @@ func (e *EphemeralManager) cleanup(ctx context.Context) { e.peersLock.Unlock() - bufferAccountCall := make(map[string]struct{}) - - for id, p := range deletePeers { - log.WithContext(ctx).Debugf("delete ephemeral peer: %s", id) - err := e.accountManager.DeletePeer(ctx, p.accountID, id, activity.SystemInitiator) - if err != nil { - log.WithContext(ctx).Errorf("failed to delete ephemeral peer: %s", err) - } else { - bufferAccountCall[p.accountID] = struct{}{} - } + // Drop the gauge by the number of entries we just took off the list, + // regardless of whether the subsequent DeletePeers call succeeds. The + // list invariant is what the gauge tracks; failed delete batches are + // counted separately via CountCleanupError so we can still see them. + if len(deletePeers) > 0 { + e.metrics.CountCleanupRun() + e.metrics.DecPending(int64(len(deletePeers))) } - for accountID := range bufferAccountCall { - e.accountManager.BufferUpdateAccountPeers(ctx, accountID) + + peerIDsPerAccount := make(map[string][]string) + for id, p := range deletePeers { + peerIDsPerAccount[p.accountID] = append(peerIDsPerAccount[p.accountID], id) + } + + for accountID, peerIDs := range peerIDsPerAccount { + log.WithContext(ctx).Debugf("cleanup: deleting %d ephemeral peers for account %s: %s", len(peerIDs), accountID, peerIDs) + err := e.peersManager.DeletePeers(ctx, accountID, peerIDs, activity.SystemInitiator, true) + if err != nil { + log.WithContext(ctx).Errorf("failed to delete ephemeral peers: %s", err) + e.metrics.CountCleanupError() + continue + } + e.metrics.CountPeersCleaned(int64(len(peerIDs))) } } @@ -212,9 +242,12 @@ func (e *EphemeralManager) addPeer(accountID string, peerID string, deadline tim e.tailPeer = ep } -func (e *EphemeralManager) removePeer(id string) { +// removePeer drops the entry from the linked list. Returns true if a +// matching entry was found and removed so callers can keep the pending +// metric gauge in sync. +func (e *EphemeralManager) removePeer(id string) bool { if e.headPeer == nil { - return + return false } if e.headPeer.id == id { @@ -222,7 +255,7 @@ func (e *EphemeralManager) removePeer(id string) { if e.tailPeer.id == id { e.tailPeer = nil } - return + return true } for p := e.headPeer; p.next != nil; p = p.next { @@ -232,9 +265,10 @@ func (e *EphemeralManager) removePeer(id string) { e.tailPeer = p } p.next = p.next.next - return + return true } } + return false } func (e *EphemeralManager) isPeerOnList(id string) bool { diff --git a/management/server/peers/ephemeral/manager/ephemeral_test.go b/management/internals/modules/peers/ephemeral/manager/ephemeral_test.go similarity index 68% rename from management/server/peers/ephemeral/manager/ephemeral_test.go rename to management/internals/modules/peers/ephemeral/manager/ephemeral_test.go index fc7525c29..314e84501 100644 --- a/management/server/peers/ephemeral/manager/ephemeral_test.go +++ b/management/internals/modules/peers/ephemeral/manager/ephemeral_test.go @@ -7,10 +7,13 @@ import ( "testing" "time" + "github.com/golang/mock/gomock" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/modules/peers" + "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral" nbAccount "github.com/netbirdio/netbird/management/server/account" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/store" @@ -59,7 +62,7 @@ func (a *MockAccountManager) GetDeletePeerCalls() int { return a.deletePeerCalls } -func (a *MockAccountManager) BufferUpdateAccountPeers(ctx context.Context, accountID string) { +func (a *MockAccountManager) BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) { a.mu.Lock() defer a.mu.Unlock() if a.bufferUpdateCalls == nil { @@ -91,17 +94,27 @@ func TestNewManager(t *testing.T) { } store := &MockStore{} - am := MockAccountManager{ - store: store, - } + ctrl := gomock.NewController(t) + peersManager := peers.NewMockManager(ctrl) numberOfPeers := 5 numberOfEphemeralPeers := 3 seedPeers(store, numberOfPeers, numberOfEphemeralPeers) - mgr := NewEphemeralManager(store, &am) + // Expect DeletePeers to be called for ephemeral peers + peersManager.EXPECT(). + DeletePeers(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), true). + DoAndReturn(func(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error { + for _, peerID := range peerIDs { + delete(store.account.Peers, peerID) + } + return nil + }). + AnyTimes() + + mgr := NewEphemeralManager(store, peersManager) mgr.loadEphemeralPeers(context.Background()) - startTime = startTime.Add(ephemeralLifeTime + 1) + startTime = startTime.Add(ephemeral.EphemeralLifeTime + 1) mgr.cleanup(context.Background()) if len(store.account.Peers) != numberOfPeers { @@ -119,19 +132,29 @@ func TestNewManagerPeerConnected(t *testing.T) { } store := &MockStore{} - am := MockAccountManager{ - store: store, - } + ctrl := gomock.NewController(t) + peersManager := peers.NewMockManager(ctrl) numberOfPeers := 5 numberOfEphemeralPeers := 3 seedPeers(store, numberOfPeers, numberOfEphemeralPeers) - mgr := NewEphemeralManager(store, &am) + // Expect DeletePeers to be called for ephemeral peers (except the connected one) + peersManager.EXPECT(). + DeletePeers(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), true). + DoAndReturn(func(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error { + for _, peerID := range peerIDs { + delete(store.account.Peers, peerID) + } + return nil + }). + AnyTimes() + + mgr := NewEphemeralManager(store, peersManager) mgr.loadEphemeralPeers(context.Background()) mgr.OnPeerConnected(context.Background(), store.account.Peers["ephemeral_peer_0"]) - startTime = startTime.Add(ephemeralLifeTime + 1) + startTime = startTime.Add(ephemeral.EphemeralLifeTime + 1) mgr.cleanup(context.Background()) expected := numberOfPeers + 1 @@ -150,15 +173,25 @@ func TestNewManagerPeerDisconnected(t *testing.T) { } store := &MockStore{} - am := MockAccountManager{ - store: store, - } + ctrl := gomock.NewController(t) + peersManager := peers.NewMockManager(ctrl) numberOfPeers := 5 numberOfEphemeralPeers := 3 seedPeers(store, numberOfPeers, numberOfEphemeralPeers) - mgr := NewEphemeralManager(store, &am) + // Expect DeletePeers to be called for the one disconnected peer + peersManager.EXPECT(). + DeletePeers(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), true). + DoAndReturn(func(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error { + for _, peerID := range peerIDs { + delete(store.account.Peers, peerID) + } + return nil + }). + AnyTimes() + + mgr := NewEphemeralManager(store, peersManager) mgr.loadEphemeralPeers(context.Background()) for _, v := range store.account.Peers { mgr.OnPeerConnected(context.Background(), v) @@ -166,7 +199,7 @@ func TestNewManagerPeerDisconnected(t *testing.T) { } mgr.OnPeerDisconnected(context.Background(), store.account.Peers["ephemeral_peer_0"]) - startTime = startTime.Add(ephemeralLifeTime + 1) + startTime = startTime.Add(ephemeral.EphemeralLifeTime + 1) mgr.cleanup(context.Background()) expected := numberOfPeers + numberOfEphemeralPeers - 1 @@ -181,25 +214,63 @@ func TestCleanupSchedulingBehaviorIsBatched(t *testing.T) { testLifeTime = 1 * time.Second testCleanupWindow = 100 * time.Millisecond ) + + t.Cleanup(func() { + timeNow = time.Now + }) + startTime := time.Now() + timeNow = func() time.Time { + return startTime + } + mockStore := &MockStore{} + account := newAccountWithId(context.Background(), "account", "", "", false) + mockStore.account = account + + wg := &sync.WaitGroup{} + wg.Add(ephemeralPeers) mockAM := &MockAccountManager{ store: mockStore, + wg: wg, } - mockAM.wg = &sync.WaitGroup{} - mockAM.wg.Add(ephemeralPeers) - mgr := NewEphemeralManager(mockStore, mockAM) + + ctrl := gomock.NewController(t) + peersManager := peers.NewMockManager(ctrl) + + // Set up expectation that DeletePeers will be called once with all peer IDs + peersManager.EXPECT(). + DeletePeers(gomock.Any(), account.Id, gomock.Any(), gomock.Any(), true). + DoAndReturn(func(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error { + // Simulate the actual deletion behavior + for _, peerID := range peerIDs { + err := mockAM.DeletePeer(ctx, accountID, peerID, userID) + if err != nil { + return err + } + } + mockAM.BufferUpdateAccountPeers(ctx, accountID, types.UpdateReason{}) + return nil + }). + Times(1) + + mgr := NewEphemeralManager(mockStore, peersManager) mgr.lifeTime = testLifeTime mgr.cleanupWindow = testCleanupWindow - account := newAccountWithId(context.Background(), "account", "", "", false) - mockStore.account = account + // Add peers and disconnect them at slightly different times (within cleanup window) for i := range ephemeralPeers { p := &nbpeer.Peer{ID: fmt.Sprintf("peer-%d", i), AccountID: account.Id, Ephemeral: true} mockStore.account.Peers[p.ID] = p - time.Sleep(testCleanupWindow / ephemeralPeers) mgr.OnPeerDisconnected(context.Background(), p) + startTime = startTime.Add(testCleanupWindow / (ephemeralPeers * 2)) } - mockAM.wg.Wait() + + // Advance time past the lifetime to trigger cleanup + startTime = startTime.Add(testLifeTime + testCleanupWindow) + + // Wait for all deletions to complete + wg.Wait() + assert.Len(t, mockStore.account.Peers, 0, "all ephemeral peers should be cleaned up after the lifetime") assert.Equal(t, 1, mockAM.GetBufferUpdateCalls(account.Id), "buffer update should be called once") assert.Equal(t, ephemeralPeers, mockAM.GetDeletePeerCalls(), "should have deleted all peers") @@ -238,7 +309,7 @@ func newAccountWithId(ctx context.Context, accountID, userID, domain string, dis setupKeys := map[string]*types.SetupKey{} nameServersGroups := make(map[string]*nbdns.NameServerGroup) - owner := types.NewOwnerUser(userID) + owner := types.NewOwnerUser(userID, "", "") owner.AccountID = accountID users[userID] = owner diff --git a/management/internals/modules/peers/manager.go b/management/internals/modules/peers/manager.go new file mode 100644 index 000000000..6f292f6ed --- /dev/null +++ b/management/internals/modules/peers/manager.go @@ -0,0 +1,253 @@ +package peers + +//go:generate go run github.com/golang/mock/mockgen -package peers -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod + +import ( + "context" + "fmt" + "net" + "time" + + "github.com/rs/xid" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map" + "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral" + "github.com/netbirdio/netbird/management/server/account" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" + "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +type Manager interface { + GetPeer(ctx context.Context, accountID, userID, peerID string) (*peer.Peer, error) + GetPeerAccountID(ctx context.Context, peerID string) (string, error) + GetAllPeers(ctx context.Context, accountID, userID string) ([]*peer.Peer, error) + GetPeersByGroupIDs(ctx context.Context, accountID string, groupsIDs []string) ([]*peer.Peer, error) + DeletePeers(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error + SetNetworkMapController(networkMapController network_map.Controller) + SetIntegratedPeerValidator(integratedPeerValidator integrated_validator.IntegratedValidator) + SetAccountManager(accountManager account.Manager) + GetPeerID(ctx context.Context, peerKey string) (string, error) + CreateProxyPeer(ctx context.Context, accountID string, peerKey string, cluster string) error + // GetPeerByTunnelIP looks up a peer in accountID by its WireGuard tunnel IP. + // Returns nil with an error when no match exists. No permission check; + // callers (the proxy's ValidateTunnelPeer RPC) are trusted server components. + GetPeerByTunnelIP(ctx context.Context, accountID string, ip net.IP) (*peer.Peer, error) + // GetPeerWithGroups returns the peer and the list of *types.Group it belongs + // to. Used by the proxy's auth path to authorise a request by the calling + // peer's group memberships. + GetPeerWithGroups(ctx context.Context, accountID, peerID string) (*peer.Peer, []*types.Group, error) +} + +type managerImpl struct { + store store.Store + permissionsManager permissions.Manager + integratedPeerValidator integrated_validator.IntegratedValidator + accountManager account.Manager + + networkMapController network_map.Controller +} + +func NewManager(store store.Store, permissionsManager permissions.Manager) Manager { + return &managerImpl{ + store: store, + permissionsManager: permissionsManager, + } +} + +func (m *managerImpl) SetNetworkMapController(networkMapController network_map.Controller) { + m.networkMapController = networkMapController +} + +func (m *managerImpl) SetIntegratedPeerValidator(integratedPeerValidator integrated_validator.IntegratedValidator) { + m.integratedPeerValidator = integratedPeerValidator +} + +func (m *managerImpl) SetAccountManager(accountManager account.Manager) { + m.accountManager = accountManager +} + +func (m *managerImpl) GetPeer(ctx context.Context, accountID, userID, peerID string) (*peer.Peer, error) { + allowed, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read) + if err != nil { + return nil, fmt.Errorf("failed to validate user permissions: %w", err) + } + + if !allowed { + return nil, status.NewPermissionDeniedError() + } + + return m.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) +} + +func (m *managerImpl) GetAllPeers(ctx context.Context, accountID, userID string) ([]*peer.Peer, error) { + allowed, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read) + if err != nil { + return nil, fmt.Errorf("failed to validate user permissions: %w", err) + } + + if !allowed { + return m.store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID) + } + + return m.store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") +} + +func (m *managerImpl) GetPeerAccountID(ctx context.Context, peerID string) (string, error) { + return m.store.GetAccountIDByPeerID(ctx, store.LockingStrengthNone, peerID) +} + +func (m *managerImpl) GetPeersByGroupIDs(ctx context.Context, accountID string, groupsIDs []string) ([]*peer.Peer, error) { + return m.store.GetPeersByGroupIDs(ctx, accountID, groupsIDs) +} + +// GetPeerByTunnelIP delegates to the store's indexed lookup. +func (m *managerImpl) GetPeerByTunnelIP(ctx context.Context, accountID string, ip net.IP) (*peer.Peer, error) { + return m.store.GetPeerByIP(ctx, store.LockingStrengthNone, accountID, ip) +} + +// GetPeerWithGroups returns the peer plus its group memberships. Any store +// error returns (nil, nil, err) so callers never receive a valid peer +// alongside a non-nil error. +func (m *managerImpl) GetPeerWithGroups(ctx context.Context, accountID, peerID string) (*peer.Peer, []*types.Group, error) { + p, err := m.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) + if err != nil { + return nil, nil, err + } + groups, err := m.store.GetPeerGroups(ctx, store.LockingStrengthNone, accountID, peerID) + if err != nil { + return nil, nil, err + } + return p, groups, nil +} + +func (m *managerImpl) DeletePeers(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error { + settings, err := m.store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return err + } + dnsDomain := m.networkMapController.GetDNSDomain(settings) + + for _, peerID := range peerIDs { + var eventsToStore []func() + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + peer, err := transaction.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) + if err != nil { + if e, ok := status.FromError(err); ok && e.Type() == status.NotFound { + log.WithContext(ctx).Tracef("DeletePeers: peer %s not found, skipping", peerID) + return nil + } + return err + } + + if checkConnected && (peer.Status.Connected || peer.Status.LastSeen.After(time.Now().Add(-(ephemeral.EphemeralLifeTime - 10*time.Second)))) { + log.WithContext(ctx).Tracef("DeletePeers: peer %s skipped (connected=%t, lastSeen=%s, threshold=%s, ephemeral=%t)", + peerID, peer.Status.Connected, + peer.Status.LastSeen.Format(time.RFC3339), + time.Now().Add(-(ephemeral.EphemeralLifeTime - 10*time.Second)).Format(time.RFC3339), + peer.Ephemeral) + return nil + } + + if err := transaction.RemovePeerFromAllGroups(ctx, peerID); err != nil { + return fmt.Errorf("failed to remove peer %s from groups", peerID) + } + + peerPolicyRules, err := transaction.GetPolicyRulesByResourceID(ctx, store.LockingStrengthNone, accountID, peerID) + if err != nil { + return err + } + for _, rule := range peerPolicyRules { + policy, err := transaction.GetPolicyByID(ctx, store.LockingStrengthNone, accountID, rule.PolicyID) + if err != nil { + return err + } + + err = transaction.DeletePolicy(ctx, accountID, rule.PolicyID) + if err != nil { + return err + } + + eventsToStore = append(eventsToStore, func() { + m.accountManager.StoreEvent(ctx, userID, peer.ID, accountID, activity.PolicyRemoved, policy.EventMeta()) + }) + } + + if err = transaction.DeletePeer(ctx, accountID, peerID); err != nil { + return err + } + + log.WithContext(ctx).Debugf("DeletePeers: deleted peer %s", peerID) + + if !(peer.ProxyMeta.Embedded || peer.Meta.KernelVersion == "wasm") { + eventsToStore = append(eventsToStore, func() { + m.accountManager.StoreEvent(ctx, userID, peer.ID, accountID, activity.PeerRemovedByUser, peer.EventMeta(dnsDomain)) + }) + } + + return nil + }) + if err != nil { + log.WithContext(ctx).Errorf("DeletePeers: failed to delete peer %s: %v", peerID, err) + continue + } + + if m.integratedPeerValidator != nil { + if err = m.integratedPeerValidator.PeerDeleted(ctx, accountID, peerID, settings.Extra); err != nil { + log.WithContext(ctx).Errorf("failed to delete peer %s from integrated validator: %v", peerID, err) + } + } + + for _, event := range eventsToStore { + event() + } + } + + m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourcePeer, Operation: types.UpdateOperationDelete}) + + return nil +} + +func (m *managerImpl) GetPeerID(ctx context.Context, peerKey string) (string, error) { + return m.store.GetPeerIDByKey(ctx, store.LockingStrengthNone, peerKey) +} + +func (m *managerImpl) CreateProxyPeer(ctx context.Context, accountID string, peerKey string, cluster string) error { + existingPeerID, err := m.store.GetPeerIDByKey(ctx, store.LockingStrengthNone, peerKey) + if err == nil && existingPeerID != "" { + // Same pubkey already registered — idempotent. + return nil + } + + name := fmt.Sprintf("proxy-%s", xid.New().String()) + newPeer := &peer.Peer{ + Ephemeral: true, + ProxyMeta: peer.ProxyMeta{ + Cluster: cluster, + Embedded: true, + }, + Name: name, + Key: peerKey, + LoginExpirationEnabled: false, + InactivityExpirationEnabled: false, + Meta: peer.PeerSystemMeta{ + Hostname: name, + GoOS: "proxy", + OS: "proxy", + }, + } + + _, _, _, _, err = m.accountManager.AddPeer(ctx, accountID, "", "", newPeer, true) + if err != nil { + return fmt.Errorf("failed to create proxy peer: %w", err) + } + + return nil +} diff --git a/management/internals/modules/peers/manager_mock.go b/management/internals/modules/peers/manager_mock.go new file mode 100644 index 000000000..3836ac909 --- /dev/null +++ b/management/internals/modules/peers/manager_mock.go @@ -0,0 +1,211 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./manager.go + +// Package peers is a generated GoMock package. +package peers + +import ( + context "context" + net "net" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + network_map "github.com/netbirdio/netbird/management/internals/controllers/network_map" + account "github.com/netbirdio/netbird/management/server/account" + integrated_validator "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" + peer "github.com/netbirdio/netbird/management/server/peer" + types "github.com/netbirdio/netbird/management/server/types" +) + +// MockManager is a mock of Manager interface. +type MockManager struct { + ctrl *gomock.Controller + recorder *MockManagerMockRecorder +} + +// MockManagerMockRecorder is the mock recorder for MockManager. +type MockManagerMockRecorder struct { + mock *MockManager +} + +// NewMockManager creates a new mock instance. +func NewMockManager(ctrl *gomock.Controller) *MockManager { + mock := &MockManager{ctrl: ctrl} + mock.recorder = &MockManagerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockManager) EXPECT() *MockManagerMockRecorder { + return m.recorder +} + +// CreateProxyPeer mocks base method. +func (m *MockManager) CreateProxyPeer(ctx context.Context, accountID, peerKey, cluster string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateProxyPeer", ctx, accountID, peerKey, cluster) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateProxyPeer indicates an expected call of CreateProxyPeer. +func (mr *MockManagerMockRecorder) CreateProxyPeer(ctx, accountID, peerKey, cluster interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateProxyPeer", reflect.TypeOf((*MockManager)(nil).CreateProxyPeer), ctx, accountID, peerKey, cluster) +} + +// DeletePeers mocks base method. +func (m *MockManager) DeletePeers(ctx context.Context, accountID string, peerIDs []string, userID string, checkConnected bool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeletePeers", ctx, accountID, peerIDs, userID, checkConnected) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeletePeers indicates an expected call of DeletePeers. +func (mr *MockManagerMockRecorder) DeletePeers(ctx, accountID, peerIDs, userID, checkConnected interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePeers", reflect.TypeOf((*MockManager)(nil).DeletePeers), ctx, accountID, peerIDs, userID, checkConnected) +} + +// GetAllPeers mocks base method. +func (m *MockManager) GetAllPeers(ctx context.Context, accountID, userID string) ([]*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllPeers", ctx, accountID, userID) + ret0, _ := ret[0].([]*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllPeers indicates an expected call of GetAllPeers. +func (mr *MockManagerMockRecorder) GetAllPeers(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllPeers", reflect.TypeOf((*MockManager)(nil).GetAllPeers), ctx, accountID, userID) +} + +// GetPeer mocks base method. +func (m *MockManager) GetPeer(ctx context.Context, accountID, userID, peerID string) (*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeer", ctx, accountID, userID, peerID) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeer indicates an expected call of GetPeer. +func (mr *MockManagerMockRecorder) GetPeer(ctx, accountID, userID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeer", reflect.TypeOf((*MockManager)(nil).GetPeer), ctx, accountID, userID, peerID) +} + +// GetPeerAccountID mocks base method. +func (m *MockManager) GetPeerAccountID(ctx context.Context, peerID string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerAccountID", ctx, peerID) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerAccountID indicates an expected call of GetPeerAccountID. +func (mr *MockManagerMockRecorder) GetPeerAccountID(ctx, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerAccountID", reflect.TypeOf((*MockManager)(nil).GetPeerAccountID), ctx, peerID) +} + +// GetPeerByTunnelIP mocks base method. +func (m *MockManager) GetPeerByTunnelIP(ctx context.Context, accountID string, ip net.IP) (*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerByTunnelIP", ctx, accountID, ip) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerByTunnelIP indicates an expected call of GetPeerByTunnelIP. +func (mr *MockManagerMockRecorder) GetPeerByTunnelIP(ctx, accountID, ip interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerByTunnelIP", reflect.TypeOf((*MockManager)(nil).GetPeerByTunnelIP), ctx, accountID, ip) +} + +// GetPeerID mocks base method. +func (m *MockManager) GetPeerID(ctx context.Context, peerKey string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerID", ctx, peerKey) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerID indicates an expected call of GetPeerID. +func (mr *MockManagerMockRecorder) GetPeerID(ctx, peerKey interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerID", reflect.TypeOf((*MockManager)(nil).GetPeerID), ctx, peerKey) +} + +// GetPeerWithGroups mocks base method. +func (m *MockManager) GetPeerWithGroups(ctx context.Context, accountID, peerID string) (*peer.Peer, []*types.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerWithGroups", ctx, accountID, peerID) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].([]*types.Group) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetPeerWithGroups indicates an expected call of GetPeerWithGroups. +func (mr *MockManagerMockRecorder) GetPeerWithGroups(ctx, accountID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerWithGroups", reflect.TypeOf((*MockManager)(nil).GetPeerWithGroups), ctx, accountID, peerID) +} + +// GetPeersByGroupIDs mocks base method. +func (m *MockManager) GetPeersByGroupIDs(ctx context.Context, accountID string, groupsIDs []string) ([]*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeersByGroupIDs", ctx, accountID, groupsIDs) + ret0, _ := ret[0].([]*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeersByGroupIDs indicates an expected call of GetPeersByGroupIDs. +func (mr *MockManagerMockRecorder) GetPeersByGroupIDs(ctx, accountID, groupsIDs interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersByGroupIDs", reflect.TypeOf((*MockManager)(nil).GetPeersByGroupIDs), ctx, accountID, groupsIDs) +} + +// SetAccountManager mocks base method. +func (m *MockManager) SetAccountManager(accountManager account.Manager) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetAccountManager", accountManager) +} + +// SetAccountManager indicates an expected call of SetAccountManager. +func (mr *MockManagerMockRecorder) SetAccountManager(accountManager interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetAccountManager", reflect.TypeOf((*MockManager)(nil).SetAccountManager), accountManager) +} + +// SetIntegratedPeerValidator mocks base method. +func (m *MockManager) SetIntegratedPeerValidator(integratedPeerValidator integrated_validator.IntegratedValidator) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetIntegratedPeerValidator", integratedPeerValidator) +} + +// SetIntegratedPeerValidator indicates an expected call of SetIntegratedPeerValidator. +func (mr *MockManagerMockRecorder) SetIntegratedPeerValidator(integratedPeerValidator interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetIntegratedPeerValidator", reflect.TypeOf((*MockManager)(nil).SetIntegratedPeerValidator), integratedPeerValidator) +} + +// SetNetworkMapController mocks base method. +func (m *MockManager) SetNetworkMapController(networkMapController network_map.Controller) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetNetworkMapController", networkMapController) +} + +// SetNetworkMapController indicates an expected call of SetNetworkMapController. +func (mr *MockManagerMockRecorder) SetNetworkMapController(networkMapController interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetNetworkMapController", reflect.TypeOf((*MockManager)(nil).SetNetworkMapController), networkMapController) +} diff --git a/management/internals/modules/reverseproxy/accesslogs/accesslogentry.go b/management/internals/modules/reverseproxy/accesslogs/accesslogentry.go new file mode 100644 index 000000000..6705eab1a --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/accesslogentry.go @@ -0,0 +1,154 @@ +package accesslogs + +import ( + "maps" + "net" + "net/netip" + "time" + + "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// AccessLogProtocol identifies the transport protocol of an access log entry. +type AccessLogProtocol string + +const ( + AccessLogProtocolHTTP AccessLogProtocol = "http" + AccessLogProtocolTCP AccessLogProtocol = "tcp" + AccessLogProtocolUDP AccessLogProtocol = "udp" +) + +type AccessLogEntry struct { + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index"` + ServiceID string `gorm:"index"` + Timestamp time.Time `gorm:"index"` + GeoLocation peer.Location `gorm:"embedded;embeddedPrefix:location_"` + SubdivisionCode string + Method string `gorm:"index"` + Host string `gorm:"index"` + Path string `gorm:"index"` + Duration time.Duration `gorm:"index"` + StatusCode int `gorm:"index"` + Reason string + UserId string `gorm:"index"` + AuthMethodUsed string `gorm:"index"` + BytesUpload int64 `gorm:"index"` + BytesDownload int64 `gorm:"index"` + Protocol AccessLogProtocol `gorm:"index"` + Metadata map[string]string `gorm:"serializer:json"` + // AgentNetwork marks the entry as emitted by a synthesised agent-network + // service. Sourced from proto.AccessLog.AgentNetwork the proxy stamps + // before shipping. Indexed so the agent-network log surface filters cheaply. + AgentNetwork bool `gorm:"index"` +} + +// FromProto creates an AccessLogEntry from a proto.AccessLog +func (a *AccessLogEntry) FromProto(serviceLog *proto.AccessLog) { + a.ID = serviceLog.GetLogId() + a.ServiceID = serviceLog.GetServiceId() + a.Timestamp = serviceLog.GetTimestamp().AsTime() + a.Method = serviceLog.GetMethod() + a.Host = serviceLog.GetHost() + a.Path = serviceLog.GetPath() + a.Duration = time.Duration(serviceLog.GetDurationMs()) * time.Millisecond + a.StatusCode = int(serviceLog.GetResponseCode()) + a.UserId = serviceLog.GetUserId() + a.AuthMethodUsed = serviceLog.GetAuthMechanism() + a.AccountID = serviceLog.GetAccountId() + a.BytesUpload = serviceLog.GetBytesUpload() + a.BytesDownload = serviceLog.GetBytesDownload() + a.Protocol = AccessLogProtocol(serviceLog.GetProtocol()) + a.Metadata = maps.Clone(serviceLog.GetMetadata()) + a.AgentNetwork = serviceLog.GetAgentNetwork() + + if sourceIP := serviceLog.GetSourceIp(); sourceIP != "" { + if addr, err := netip.ParseAddr(sourceIP); err == nil { + addr = addr.Unmap() + a.GeoLocation.ConnectionIP = net.IP(addr.AsSlice()) + } + } + + // Only set reason for HTTP entries. L4 entries have no auth or status code. + if a.Protocol == "" || a.Protocol == AccessLogProtocolHTTP { + if !serviceLog.GetAuthSuccess() { + a.Reason = "Authentication failed" + } else if serviceLog.GetResponseCode() >= 400 { + a.Reason = "Request failed" + } + } +} + +// ToAPIResponse converts an AccessLogEntry to the API ProxyAccessLog type +func (a *AccessLogEntry) ToAPIResponse() *api.ProxyAccessLog { + var sourceIP *string + if a.GeoLocation.ConnectionIP != nil { + ip := a.GeoLocation.ConnectionIP.String() + sourceIP = &ip + } + + var reason *string + if a.Reason != "" { + reason = &a.Reason + } + + var userID *string + if a.UserId != "" { + userID = &a.UserId + } + + var authMethod *string + if a.AuthMethodUsed != "" { + authMethod = &a.AuthMethodUsed + } + + var countryCode *string + if a.GeoLocation.CountryCode != "" { + countryCode = &a.GeoLocation.CountryCode + } + + var cityName *string + if a.GeoLocation.CityName != "" { + cityName = &a.GeoLocation.CityName + } + + var subdivisionCode *string + if a.SubdivisionCode != "" { + subdivisionCode = &a.SubdivisionCode + } + + var protocol *string + if a.Protocol != "" { + p := string(a.Protocol) + protocol = &p + } + + var metadata *map[string]string + if len(a.Metadata) > 0 { + metadata = &a.Metadata + } + + return &api.ProxyAccessLog{ + Id: a.ID, + ServiceId: a.ServiceID, + Timestamp: a.Timestamp, + Method: a.Method, + Host: a.Host, + Path: a.Path, + DurationMs: int(a.Duration.Milliseconds()), + StatusCode: a.StatusCode, + SourceIp: sourceIP, + Reason: reason, + UserId: userID, + AuthMethodUsed: authMethod, + CountryCode: countryCode, + CityName: cityName, + SubdivisionCode: subdivisionCode, + BytesUpload: a.BytesUpload, + BytesDownload: a.BytesDownload, + Protocol: protocol, + Metadata: metadata, + } +} diff --git a/management/internals/modules/reverseproxy/accesslogs/filter.go b/management/internals/modules/reverseproxy/accesslogs/filter.go new file mode 100644 index 000000000..a1fa28312 --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/filter.go @@ -0,0 +1,178 @@ +package accesslogs + +import ( + "net/http" + "strconv" + "strings" + "time" +) + +const ( + // DefaultPageSize is the default number of records per page + DefaultPageSize = 50 + // MaxPageSize is the maximum number of records allowed per page + MaxPageSize = 100 + + // Default sorting + DefaultSortBy = "timestamp" + DefaultSortOrder = "desc" +) + +// Valid sortable fields mapped to their database column names or expressions +// For multi-column sorts, columns are separated by comma (e.g., "host, path") +var validSortFields = map[string]string{ + "timestamp": "timestamp", + "url": "host, path", // Sort by host first, then path + "host": "host", + "path": "path", + "method": "method", + "status_code": "status_code", + "duration": "duration", + "source_ip": "location_connection_ip", + "user_id": "user_id", + "auth_method": "auth_method_used", + "reason": "reason", +} + +// AccessLogFilter holds pagination, filtering, and sorting parameters for access logs +type AccessLogFilter struct { + // Page is the current page number (1-indexed) + Page int + // PageSize is the number of records per page + PageSize int + + // Sorting parameters + SortBy string // Field to sort by: timestamp, url, host, path, method, status_code, duration, source_ip, user_id, auth_method, reason + SortOrder string // Sort order: asc or desc (default: desc) + + // Filtering parameters + Search *string // General search across log ID, host, path, source IP, and user fields + SourceIP *string // Filter by source IP address + Host *string // Filter by host header + Path *string // Filter by request path (supports LIKE pattern) + UserID *string // Filter by authenticated user ID + UserEmail *string // Filter by user email (requires user lookup) + UserName *string // Filter by user name (requires user lookup) + Method *string // Filter by HTTP method + Status *string // Filter by status: "success" (2xx/3xx) or "failed" (1xx/4xx/5xx) + StatusCode *int // Filter by HTTP status code + StartDate *time.Time // Filter by timestamp >= start_date + EndDate *time.Time // Filter by timestamp <= end_date +} + +// ParseFromRequest parses pagination, sorting, and filter parameters from HTTP request query parameters +func (f *AccessLogFilter) ParseFromRequest(r *http.Request) { + queryParams := r.URL.Query() + + f.Page = parsePositiveInt(queryParams.Get("page"), 1) + f.PageSize = min(parsePositiveInt(queryParams.Get("page_size"), DefaultPageSize), MaxPageSize) + + f.SortBy = parseSortField(queryParams.Get("sort_by")) + f.SortOrder = parseSortOrder(queryParams.Get("sort_order")) + + f.Search = parseOptionalString(queryParams.Get("search")) + f.SourceIP = parseOptionalString(queryParams.Get("source_ip")) + f.Host = parseOptionalString(queryParams.Get("host")) + f.Path = parseOptionalString(queryParams.Get("path")) + f.UserID = parseOptionalString(queryParams.Get("user_id")) + f.UserEmail = parseOptionalString(queryParams.Get("user_email")) + f.UserName = parseOptionalString(queryParams.Get("user_name")) + f.Method = parseOptionalString(queryParams.Get("method")) + f.Status = parseOptionalString(queryParams.Get("status")) + f.StatusCode = parseOptionalInt(queryParams.Get("status_code")) + f.StartDate = parseOptionalRFC3339(queryParams.Get("start_date")) + f.EndDate = parseOptionalRFC3339(queryParams.Get("end_date")) +} + +// parsePositiveInt parses a positive integer from a string, returning defaultValue if invalid +func parsePositiveInt(s string, defaultValue int) int { + if s == "" { + return defaultValue + } + if val, err := strconv.Atoi(s); err == nil && val > 0 { + return val + } + return defaultValue +} + +// parseOptionalString returns a pointer to the string if non-empty, otherwise nil +func parseOptionalString(s string) *string { + if s == "" { + return nil + } + return &s +} + +// parseOptionalInt parses an optional positive integer from a string +func parseOptionalInt(s string) *int { + if s == "" { + return nil + } + if val, err := strconv.Atoi(s); err == nil && val > 0 { + v := val + return &v + } + return nil +} + +// parseOptionalRFC3339 parses an optional RFC3339 timestamp from a string +func parseOptionalRFC3339(s string) *time.Time { + if s == "" { + return nil + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return &t + } + return nil +} + +// GetOffset calculates the database offset for pagination +func (f *AccessLogFilter) GetOffset() int { + return (f.Page - 1) * f.PageSize +} + +// GetLimit returns the page size for database queries +func (f *AccessLogFilter) GetLimit() int { + return f.PageSize +} + +// GetSortColumn returns the validated database column name for sorting +func (f *AccessLogFilter) GetSortColumn() string { + if column, ok := validSortFields[f.SortBy]; ok { + return column + } + return validSortFields[DefaultSortBy] +} + +// GetSortOrder returns the validated sort order (ASC or DESC) +func (f *AccessLogFilter) GetSortOrder() string { + if f.SortOrder == "asc" || f.SortOrder == "desc" { + return f.SortOrder + } + return DefaultSortOrder +} + +// parseSortField validates and returns the sort field, defaulting if invalid +func parseSortField(s string) string { + if s == "" { + return DefaultSortBy + } + // Check if the field is valid + if _, ok := validSortFields[s]; ok { + return s + } + return DefaultSortBy +} + +// parseSortOrder validates and returns the sort order, defaulting if invalid +func parseSortOrder(s string) string { + if s == "" { + return DefaultSortOrder + } + // Normalize to lowercase + s = strings.ToLower(s) + if s == "asc" || s == "desc" { + return s + } + return DefaultSortOrder +} diff --git a/management/internals/modules/reverseproxy/accesslogs/filter_test.go b/management/internals/modules/reverseproxy/accesslogs/filter_test.go new file mode 100644 index 000000000..ea1fce54b --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/filter_test.go @@ -0,0 +1,570 @@ +package accesslogs + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAccessLogFilter_ParseFromRequest(t *testing.T) { + tests := []struct { + name string + queryParams map[string]string + expectedPage int + expectedPageSize int + }{ + { + name: "default values when no params provided", + queryParams: map[string]string{}, + expectedPage: 1, + expectedPageSize: DefaultPageSize, + }, + { + name: "valid page and page_size", + queryParams: map[string]string{ + "page": "2", + "page_size": "25", + }, + expectedPage: 2, + expectedPageSize: 25, + }, + { + name: "page_size exceeds max, should cap at MaxPageSize", + queryParams: map[string]string{ + "page": "1", + "page_size": "200", + }, + expectedPage: 1, + expectedPageSize: MaxPageSize, + }, + { + name: "invalid page number, should use default", + queryParams: map[string]string{ + "page": "invalid", + "page_size": "10", + }, + expectedPage: 1, + expectedPageSize: 10, + }, + { + name: "invalid page_size, should use default", + queryParams: map[string]string{ + "page": "2", + "page_size": "invalid", + }, + expectedPage: 2, + expectedPageSize: DefaultPageSize, + }, + { + name: "zero page number, should use default", + queryParams: map[string]string{ + "page": "0", + "page_size": "10", + }, + expectedPage: 1, + expectedPageSize: 10, + }, + { + name: "negative page number, should use default", + queryParams: map[string]string{ + "page": "-1", + "page_size": "10", + }, + expectedPage: 1, + expectedPageSize: 10, + }, + { + name: "zero page_size, should use default", + queryParams: map[string]string{ + "page": "1", + "page_size": "0", + }, + expectedPage: 1, + expectedPageSize: DefaultPageSize, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + q := req.URL.Query() + for key, value := range tt.queryParams { + q.Set(key, value) + } + req.URL.RawQuery = q.Encode() + + filter := &AccessLogFilter{} + filter.ParseFromRequest(req) + + assert.Equal(t, tt.expectedPage, filter.Page, "Page mismatch") + assert.Equal(t, tt.expectedPageSize, filter.PageSize, "PageSize mismatch") + }) + } +} + +func TestAccessLogFilter_GetOffset(t *testing.T) { + tests := []struct { + name string + page int + pageSize int + expectedOffset int + }{ + { + name: "first page", + page: 1, + pageSize: 50, + expectedOffset: 0, + }, + { + name: "second page", + page: 2, + pageSize: 50, + expectedOffset: 50, + }, + { + name: "third page with page size 25", + page: 3, + pageSize: 25, + expectedOffset: 50, + }, + { + name: "page 10 with page size 10", + page: 10, + pageSize: 10, + expectedOffset: 90, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter := &AccessLogFilter{ + Page: tt.page, + PageSize: tt.pageSize, + } + + offset := filter.GetOffset() + assert.Equal(t, tt.expectedOffset, offset) + }) + } +} + +func TestAccessLogFilter_GetLimit(t *testing.T) { + filter := &AccessLogFilter{ + Page: 2, + PageSize: 25, + } + + limit := filter.GetLimit() + assert.Equal(t, 25, limit, "GetLimit should return PageSize") +} + +func TestAccessLogFilter_ParseFromRequest_FilterParams(t *testing.T) { + startDate := "2024-01-15T10:30:00Z" + endDate := "2024-01-16T15:45:00Z" + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + q := req.URL.Query() + q.Set("search", "test query") + q.Set("source_ip", "192.168.1.1") + q.Set("host", "example.com") + q.Set("path", "/api/users") + q.Set("user_id", "user123") + q.Set("user_email", "user@example.com") + q.Set("user_name", "John Doe") + q.Set("method", "GET") + q.Set("status", "success") + q.Set("status_code", "200") + q.Set("start_date", startDate) + q.Set("end_date", endDate) + req.URL.RawQuery = q.Encode() + + filter := &AccessLogFilter{} + filter.ParseFromRequest(req) + + require.NotNil(t, filter.Search) + assert.Equal(t, "test query", *filter.Search) + + require.NotNil(t, filter.SourceIP) + assert.Equal(t, "192.168.1.1", *filter.SourceIP) + + require.NotNil(t, filter.Host) + assert.Equal(t, "example.com", *filter.Host) + + require.NotNil(t, filter.Path) + assert.Equal(t, "/api/users", *filter.Path) + + require.NotNil(t, filter.UserID) + assert.Equal(t, "user123", *filter.UserID) + + require.NotNil(t, filter.UserEmail) + assert.Equal(t, "user@example.com", *filter.UserEmail) + + require.NotNil(t, filter.UserName) + assert.Equal(t, "John Doe", *filter.UserName) + + require.NotNil(t, filter.Method) + assert.Equal(t, "GET", *filter.Method) + + require.NotNil(t, filter.Status) + assert.Equal(t, "success", *filter.Status) + + require.NotNil(t, filter.StatusCode) + assert.Equal(t, 200, *filter.StatusCode) + + require.NotNil(t, filter.StartDate) + expectedStart, _ := time.Parse(time.RFC3339, startDate) + assert.Equal(t, expectedStart, *filter.StartDate) + + require.NotNil(t, filter.EndDate) + expectedEnd, _ := time.Parse(time.RFC3339, endDate) + assert.Equal(t, expectedEnd, *filter.EndDate) +} + +func TestAccessLogFilter_ParseFromRequest_EmptyFilters(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + + filter := &AccessLogFilter{} + filter.ParseFromRequest(req) + + assert.Nil(t, filter.Search) + assert.Nil(t, filter.SourceIP) + assert.Nil(t, filter.Host) + assert.Nil(t, filter.Path) + assert.Nil(t, filter.UserID) + assert.Nil(t, filter.UserEmail) + assert.Nil(t, filter.UserName) + assert.Nil(t, filter.Method) + assert.Nil(t, filter.Status) + assert.Nil(t, filter.StatusCode) + assert.Nil(t, filter.StartDate) + assert.Nil(t, filter.EndDate) +} + +func TestAccessLogFilter_ParseFromRequest_InvalidFilters(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + q := req.URL.Query() + q.Set("status_code", "invalid") + q.Set("start_date", "not-a-date") + q.Set("end_date", "2024-99-99") + req.URL.RawQuery = q.Encode() + + filter := &AccessLogFilter{} + filter.ParseFromRequest(req) + + assert.Nil(t, filter.StatusCode, "invalid status_code should be nil") + assert.Nil(t, filter.StartDate, "invalid start_date should be nil") + assert.Nil(t, filter.EndDate, "invalid end_date should be nil") +} + +func TestParsePositiveInt(t *testing.T) { + tests := []struct { + name string + input string + defaultValue int + expected int + }{ + {"empty string", "", 10, 10}, + {"valid positive int", "25", 10, 25}, + {"zero", "0", 10, 10}, + {"negative", "-5", 10, 10}, + {"invalid string", "abc", 10, 10}, + {"float", "3.14", 10, 10}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parsePositiveInt(tt.input, tt.defaultValue) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseOptionalString(t *testing.T) { + tests := []struct { + name string + input string + expected *string + }{ + {"empty string", "", nil}, + {"valid string", "hello", strPtr("hello")}, + {"whitespace", " ", strPtr(" ")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseOptionalString(tt.input) + if tt.expected == nil { + assert.Nil(t, result) + } else { + require.NotNil(t, result) + assert.Equal(t, *tt.expected, *result) + } + }) + } +} + +func TestParseOptionalInt(t *testing.T) { + tests := []struct { + name string + input string + expected *int + }{ + {"empty string", "", nil}, + {"valid positive int", "42", intPtr(42)}, + {"zero", "0", nil}, + {"negative", "-10", nil}, + {"invalid string", "abc", nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseOptionalInt(tt.input) + if tt.expected == nil { + assert.Nil(t, result) + } else { + require.NotNil(t, result) + assert.Equal(t, *tt.expected, *result) + } + }) + } +} + +func TestParseOptionalRFC3339(t *testing.T) { + validDate := "2024-01-15T10:30:00Z" + expectedTime, _ := time.Parse(time.RFC3339, validDate) + + tests := []struct { + name string + input string + expected *time.Time + }{ + {"empty string", "", nil}, + {"valid RFC3339", validDate, &expectedTime}, + {"invalid format", "2024-01-15", nil}, + {"invalid date", "not-a-date", nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseOptionalRFC3339(tt.input) + if tt.expected == nil { + assert.Nil(t, result) + } else { + require.NotNil(t, result) + assert.Equal(t, *tt.expected, *result) + } + }) + } +} + +func TestAccessLogFilter_SortingDefaults(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + + filter := &AccessLogFilter{} + filter.ParseFromRequest(req) + + assert.Equal(t, DefaultSortBy, filter.SortBy, "SortBy should default to timestamp") + assert.Equal(t, DefaultSortOrder, filter.SortOrder, "SortOrder should default to desc") + assert.Equal(t, "timestamp", filter.GetSortColumn(), "GetSortColumn should return timestamp") + assert.Equal(t, "desc", filter.GetSortOrder(), "GetSortOrder should return desc") +} + +func TestAccessLogFilter_ValidSortFields(t *testing.T) { + tests := []struct { + name string + sortBy string + expectedColumn string + expectedSortByVal string + }{ + {"timestamp", "timestamp", "timestamp", "timestamp"}, + {"url", "url", "host, path", "url"}, + {"host", "host", "host", "host"}, + {"path", "path", "path", "path"}, + {"method", "method", "method", "method"}, + {"status_code", "status_code", "status_code", "status_code"}, + {"duration", "duration", "duration", "duration"}, + {"source_ip", "source_ip", "location_connection_ip", "source_ip"}, + {"user_id", "user_id", "user_id", "user_id"}, + {"auth_method", "auth_method", "auth_method_used", "auth_method"}, + {"reason", "reason", "reason", "reason"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test?sort_by="+tt.sortBy, nil) + + filter := &AccessLogFilter{} + filter.ParseFromRequest(req) + + assert.Equal(t, tt.expectedSortByVal, filter.SortBy, "SortBy mismatch") + assert.Equal(t, tt.expectedColumn, filter.GetSortColumn(), "GetSortColumn mismatch") + }) + } +} + +func TestAccessLogFilter_InvalidSortField(t *testing.T) { + tests := []struct { + name string + sortBy string + expected string + }{ + {"invalid field", "invalid_field", DefaultSortBy}, + {"empty field", "", DefaultSortBy}, + {"malicious input", "timestamp--DROP", DefaultSortBy}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + q := req.URL.Query() + q.Set("sort_by", tt.sortBy) + req.URL.RawQuery = q.Encode() + + filter := &AccessLogFilter{} + filter.ParseFromRequest(req) + + assert.Equal(t, tt.expected, filter.SortBy, "Invalid sort field should default to timestamp") + assert.Equal(t, validSortFields[DefaultSortBy], filter.GetSortColumn()) + }) + } +} + +func TestAccessLogFilter_SortOrder(t *testing.T) { + tests := []struct { + name string + sortOrder string + expected string + }{ + {"ascending", "asc", "asc"}, + {"descending", "desc", "desc"}, + {"uppercase ASC", "ASC", "asc"}, + {"uppercase DESC", "DESC", "desc"}, + {"mixed case Asc", "Asc", "asc"}, + {"invalid order", "invalid", DefaultSortOrder}, + {"empty order", "", DefaultSortOrder}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test?sort_order="+tt.sortOrder, nil) + + filter := &AccessLogFilter{} + filter.ParseFromRequest(req) + + assert.Equal(t, tt.expected, filter.GetSortOrder(), "GetSortOrder mismatch") + }) + } +} + +func TestAccessLogFilter_CompleteSortingScenarios(t *testing.T) { + tests := []struct { + name string + sortBy string + sortOrder string + expectedColumn string + expectedOrder string + }{ + { + name: "sort by host ascending", + sortBy: "host", + sortOrder: "asc", + expectedColumn: "host", + expectedOrder: "asc", + }, + { + name: "sort by duration descending", + sortBy: "duration", + sortOrder: "desc", + expectedColumn: "duration", + expectedOrder: "desc", + }, + { + name: "sort by status_code ascending", + sortBy: "status_code", + sortOrder: "asc", + expectedColumn: "status_code", + expectedOrder: "asc", + }, + { + name: "invalid sort with valid order", + sortBy: "invalid", + sortOrder: "asc", + expectedColumn: "timestamp", + expectedOrder: "asc", + }, + { + name: "valid sort with invalid order", + sortBy: "method", + sortOrder: "invalid", + expectedColumn: "method", + expectedOrder: DefaultSortOrder, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test?sort_by="+tt.sortBy+"&sort_order="+tt.sortOrder, nil) + + filter := &AccessLogFilter{} + filter.ParseFromRequest(req) + + assert.Equal(t, tt.expectedColumn, filter.GetSortColumn()) + assert.Equal(t, tt.expectedOrder, filter.GetSortOrder()) + }) + } +} + +func TestParseSortField(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"valid field", "host", "host"}, + {"empty string", "", DefaultSortBy}, + {"invalid field", "invalid", DefaultSortBy}, + {"malicious input", "timestamp--DROP", DefaultSortBy}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseSortField(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseSortOrder(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"asc lowercase", "asc", "asc"}, + {"desc lowercase", "desc", "desc"}, + {"ASC uppercase", "ASC", "asc"}, + {"DESC uppercase", "DESC", "desc"}, + {"invalid", "invalid", DefaultSortOrder}, + {"empty", "", DefaultSortOrder}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseSortOrder(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +// Helper functions for creating pointers +func strPtr(s string) *string { + return &s +} + +func intPtr(i int) *int { + return &i +} diff --git a/management/internals/modules/reverseproxy/accesslogs/interface.go b/management/internals/modules/reverseproxy/accesslogs/interface.go new file mode 100644 index 000000000..04f096bf1 --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/interface.go @@ -0,0 +1,13 @@ +package accesslogs + +import ( + "context" +) + +type Manager interface { + SaveAccessLog(ctx context.Context, proxyLog *AccessLogEntry) error + GetAllAccessLogs(ctx context.Context, accountID, userID string, filter *AccessLogFilter) ([]*AccessLogEntry, int64, error) + CleanupOldAccessLogs(ctx context.Context, retentionDays int) (int64, error) + StartPeriodicCleanup(ctx context.Context, retentionDays, cleanupIntervalHours int) + StopPeriodicCleanup() +} diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/api.go b/management/internals/modules/reverseproxy/accesslogs/manager/api.go new file mode 100644 index 000000000..1e1414ca5 --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/manager/api.go @@ -0,0 +1,64 @@ +package manager + +import ( + "net/http" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" +) + +type handler struct { + manager accesslogs.Manager +} + +func RegisterEndpoints(router *mux.Router, manager accesslogs.Manager) { + h := &handler{ + manager: manager, + } + + router.HandleFunc("/events/proxy", h.getAccessLogs).Methods("GET", "OPTIONS") +} + +func (h *handler) getAccessLogs(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var filter accesslogs.AccessLogFilter + filter.ParseFromRequest(r) + + logs, totalCount, err := h.manager.GetAllAccessLogs(r.Context(), userAuth.AccountId, userAuth.UserId, &filter) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + apiLogs := make([]api.ProxyAccessLog, 0, len(logs)) + for _, log := range logs { + apiLogs = append(apiLogs, *log.ToAPIResponse()) + } + + response := &api.ProxyAccessLogsResponse{ + Data: apiLogs, + Page: filter.Page, + PageSize: filter.PageSize, + TotalRecords: int(totalCount), + TotalPages: getTotalPageCount(int(totalCount), filter.PageSize), + } + + util.WriteJSONObject(r.Context(), w, response) +} + +// getTotalPageCount calculates the total number of pages +func getTotalPageCount(totalCount, pageSize int) int { + if pageSize <= 0 { + return 0 + } + return (totalCount + pageSize - 1) / pageSize +} diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/manager.go b/management/internals/modules/reverseproxy/accesslogs/manager/manager.go new file mode 100644 index 000000000..4b24adaa1 --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/manager/manager.go @@ -0,0 +1,198 @@ +package manager + +import ( + "context" + "strings" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/server/geolocation" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/status" +) + +type managerImpl struct { + store store.Store + permissionsManager permissions.Manager + geo geolocation.Geolocation + cleanupCancel context.CancelFunc +} + +func NewManager(store store.Store, permissionsManager permissions.Manager, geo geolocation.Geolocation) accesslogs.Manager { + return &managerImpl{ + store: store, + permissionsManager: permissionsManager, + geo: geo, + } +} + +// SaveAccessLog saves an access log entry to the database after enriching it. +// Agent-network entries are flattened into their own dedicated table (queryable +// LLM columns + group child rows) instead of the shared reverse-proxy table. +func (m *managerImpl) SaveAccessLog(ctx context.Context, logEntry *accesslogs.AccessLogEntry) error { + if logEntry.AgentNetwork { + return agentnetwork.IngestAccessLog(ctx, m.store, logEntry) + } + + if m.geo != nil && logEntry.GeoLocation.ConnectionIP != nil { + location, err := m.geo.Lookup(logEntry.GeoLocation.ConnectionIP) + if err != nil { + log.WithContext(ctx).Warnf("failed to get location for access log source IP [%s]: %v", logEntry.GeoLocation.ConnectionIP.String(), err) + } else { + logEntry.GeoLocation.CountryCode = location.Country.ISOCode + logEntry.GeoLocation.CityName = location.City.Names.En + logEntry.GeoLocation.GeoNameID = location.City.GeonameID + if len(location.Subdivisions) > 0 { + logEntry.SubdivisionCode = location.Subdivisions[0].ISOCode + } + } + } + + if err := m.store.CreateAccessLog(ctx, logEntry); err != nil { + log.WithContext(ctx).WithFields(log.Fields{ + "service_id": logEntry.ServiceID, + "method": logEntry.Method, + "host": logEntry.Host, + "path": logEntry.Path, + "status": logEntry.StatusCode, + }).Errorf("failed to save access log: %v", err) + return err + } + + return nil +} + +// GetAllAccessLogs retrieves access logs for an account with pagination and filtering +func (m *managerImpl) GetAllAccessLogs(ctx context.Context, accountID, userID string, filter *accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read) + if err != nil { + return nil, 0, status.NewPermissionValidationError(err) + } + if !ok { + return nil, 0, status.NewPermissionDeniedError() + } + + if err := m.resolveUserFilters(ctx, accountID, filter); err != nil { + log.WithContext(ctx).Warnf("failed to resolve user filters: %v", err) + } + + logs, totalCount, err := m.store.GetAccountAccessLogs(ctx, store.LockingStrengthNone, accountID, *filter) + if err != nil { + return nil, 0, err + } + + return logs, totalCount, nil +} + +// CleanupOldAccessLogs deletes access logs older than the specified retention period +func (m *managerImpl) CleanupOldAccessLogs(ctx context.Context, retentionDays int) (int64, error) { + if retentionDays <= 0 { + log.WithContext(ctx).Debug("access log cleanup skipped: retention days is 0 or negative") + return 0, nil + } + + cutoffTime := time.Now().AddDate(0, 0, -retentionDays) + deletedCount, err := m.store.DeleteOldAccessLogs(ctx, cutoffTime) + if err != nil { + log.WithContext(ctx).Errorf("failed to cleanup old access logs: %v", err) + return 0, err + } + + if deletedCount > 0 { + log.WithContext(ctx).Infof("cleaned up %d access logs older than %d days", deletedCount, retentionDays) + } + + return deletedCount, nil +} + +// StartPeriodicCleanup starts a background goroutine that periodically cleans up old access logs +func (m *managerImpl) StartPeriodicCleanup(ctx context.Context, retentionDays, cleanupIntervalHours int) { + if retentionDays < 0 { + log.WithContext(ctx).Debug("periodic access log cleanup disabled: retention days is negative") + return + } + + if retentionDays == 0 { + retentionDays = 7 + log.WithContext(ctx).Debugf("no retention days specified for access log cleanup, defaulting to %d days", retentionDays) + } else { + log.WithContext(ctx).Debugf("access log retention period set to %d days", retentionDays) + } + + if cleanupIntervalHours <= 0 { + cleanupIntervalHours = 24 + log.WithContext(ctx).Debugf("no cleanup interval specified for access log cleanup, defaulting to %d hours", cleanupIntervalHours) + } else { + log.WithContext(ctx).Debugf("access log cleanup interval set to %d hours", cleanupIntervalHours) + } + + cleanupCtx, cancel := context.WithCancel(ctx) + m.cleanupCancel = cancel + + cleanupInterval := time.Duration(cleanupIntervalHours) * time.Hour + ticker := time.NewTicker(cleanupInterval) + + go func() { + defer ticker.Stop() + + // Run cleanup immediately on startup + log.WithContext(cleanupCtx).Infof("starting access log cleanup routine (retention: %d days, interval: %d hours)", retentionDays, cleanupIntervalHours) + if _, err := m.CleanupOldAccessLogs(cleanupCtx, retentionDays); err != nil { + log.WithContext(cleanupCtx).Errorf("initial access log cleanup failed: %v", err) + } + + for { + select { + case <-cleanupCtx.Done(): + log.WithContext(cleanupCtx).Info("stopping access log cleanup routine") + return + case <-ticker.C: + if _, err := m.CleanupOldAccessLogs(cleanupCtx, retentionDays); err != nil { + log.WithContext(cleanupCtx).Errorf("periodic access log cleanup failed: %v", err) + } + } + } + }() +} + +// StopPeriodicCleanup stops the periodic cleanup routine +func (m *managerImpl) StopPeriodicCleanup() { + if m.cleanupCancel != nil { + m.cleanupCancel() + } +} + +// resolveUserFilters converts user email/name filters to user ID filter +func (m *managerImpl) resolveUserFilters(ctx context.Context, accountID string, filter *accesslogs.AccessLogFilter) error { + if filter.UserEmail == nil && filter.UserName == nil { + return nil + } + + users, err := m.store.GetAccountUsers(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return err + } + + var matchingUserIDs []string + for _, user := range users { + if filter.UserEmail != nil && strings.Contains(strings.ToLower(user.Email), strings.ToLower(*filter.UserEmail)) { + matchingUserIDs = append(matchingUserIDs, user.Id) + continue + } + if filter.UserName != nil && strings.Contains(strings.ToLower(user.Name), strings.ToLower(*filter.UserName)) { + matchingUserIDs = append(matchingUserIDs, user.Id) + } + } + + if len(matchingUserIDs) > 0 { + filter.UserID = &matchingUserIDs[0] + } + + return nil +} diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/manager_test.go b/management/internals/modules/reverseproxy/accesslogs/manager/manager_test.go new file mode 100644 index 000000000..11bf60829 --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/manager/manager_test.go @@ -0,0 +1,281 @@ +package manager + +import ( + "context" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/store" +) + +func TestCleanupOldAccessLogs(t *testing.T) { + tests := []struct { + name string + retentionDays int + setupMock func(*store.MockStore) + expectedCount int64 + expectedError bool + }{ + { + name: "cleanup logs older than retention period", + retentionDays: 30, + setupMock: func(mockStore *store.MockStore) { + mockStore.EXPECT(). + DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, olderThan time.Time) (int64, error) { + expectedCutoff := time.Now().AddDate(0, 0, -30) + timeDiff := olderThan.Sub(expectedCutoff) + if timeDiff.Abs() > time.Second { + t.Errorf("cutoff time not as expected: got %v, want ~%v", olderThan, expectedCutoff) + } + return 5, nil + }) + }, + expectedCount: 5, + expectedError: false, + }, + { + name: "no logs to cleanup", + retentionDays: 30, + setupMock: func(mockStore *store.MockStore) { + mockStore.EXPECT(). + DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + Return(int64(0), nil) + }, + expectedCount: 0, + expectedError: false, + }, + { + name: "zero retention days skips cleanup", + retentionDays: 0, + setupMock: func(mockStore *store.MockStore) { + // No expectations - DeleteOldAccessLogs should not be called + }, + expectedCount: 0, + expectedError: false, + }, + { + name: "negative retention days skips cleanup", + retentionDays: -10, + setupMock: func(mockStore *store.MockStore) { + // No expectations - DeleteOldAccessLogs should not be called + }, + expectedCount: 0, + expectedError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + tt.setupMock(mockStore) + + manager := &managerImpl{ + store: mockStore, + } + + ctx := context.Background() + deletedCount, err := manager.CleanupOldAccessLogs(ctx, tt.retentionDays) + + if tt.expectedError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.expectedCount, deletedCount, "unexpected number of deleted logs") + }) + } +} + +func TestCleanupWithExactBoundary(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + + mockStore.EXPECT(). + DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, olderThan time.Time) (int64, error) { + expectedCutoff := time.Now().AddDate(0, 0, -30) + timeDiff := olderThan.Sub(expectedCutoff) + assert.Less(t, timeDiff.Abs(), time.Second, "cutoff time should be close to expected value") + return 1, nil + }) + + manager := &managerImpl{ + store: mockStore, + } + + ctx := context.Background() + deletedCount, err := manager.CleanupOldAccessLogs(ctx, 30) + + require.NoError(t, err) + assert.Equal(t, int64(1), deletedCount) +} + +func TestStartPeriodicCleanup(t *testing.T) { + t.Run("periodic cleanup disabled with negative retention", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + // No expectations - cleanup should not run + + manager := &managerImpl{ + store: mockStore, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + manager.StartPeriodicCleanup(ctx, -1, 1) + + time.Sleep(100 * time.Millisecond) + + // If DeleteOldAccessLogs was called, the test will fail due to unexpected call + }) + + t.Run("periodic cleanup runs immediately on start", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + + mockStore.EXPECT(). + DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + Return(int64(2), nil). + Times(1) + + manager := &managerImpl{ + store: mockStore, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + manager.StartPeriodicCleanup(ctx, 30, 24) + + time.Sleep(200 * time.Millisecond) + + // Expectations verified by gomock on defer ctrl.Finish() + }) + + t.Run("periodic cleanup stops on context cancel", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + + mockStore.EXPECT(). + DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + Return(int64(1), nil). + Times(1) + + manager := &managerImpl{ + store: mockStore, + } + + ctx, cancel := context.WithCancel(context.Background()) + + manager.StartPeriodicCleanup(ctx, 30, 24) + + time.Sleep(100 * time.Millisecond) + + cancel() + + time.Sleep(200 * time.Millisecond) + + }) + + t.Run("cleanup interval defaults to 24 hours when invalid", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + + mockStore.EXPECT(). + DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + Return(int64(0), nil). + Times(1) + + manager := &managerImpl{ + store: mockStore, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + manager.StartPeriodicCleanup(ctx, 30, 0) + + time.Sleep(100 * time.Millisecond) + + manager.StopPeriodicCleanup() + }) + + t.Run("cleanup interval uses configured hours", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + + mockStore.EXPECT(). + DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + Return(int64(3), nil). + Times(1) + + manager := &managerImpl{ + store: mockStore, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + manager.StartPeriodicCleanup(ctx, 30, 12) + + time.Sleep(100 * time.Millisecond) + + manager.StopPeriodicCleanup() + }) +} + +func TestStopPeriodicCleanup(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + + mockStore.EXPECT(). + DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + Return(int64(1), nil). + Times(1) + + manager := &managerImpl{ + store: mockStore, + } + + ctx := context.Background() + + manager.StartPeriodicCleanup(ctx, 30, 24) + + time.Sleep(100 * time.Millisecond) + + manager.StopPeriodicCleanup() + + time.Sleep(200 * time.Millisecond) + + // Expectations verified by gomock - would fail if more than 1 call happened +} + +func TestStopPeriodicCleanup_NotStarted(t *testing.T) { + manager := &managerImpl{} + + // Should not panic if cleanup was never started + manager.StopPeriodicCleanup() +} diff --git a/management/internals/modules/reverseproxy/domain/domain.go b/management/internals/modules/reverseproxy/domain/domain.go new file mode 100644 index 000000000..08d7ad19b --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/domain.go @@ -0,0 +1,42 @@ +package domain + +type Type string + +const ( + TypeFree Type = "free" + TypeCustom Type = "custom" +) + +type Domain struct { + ID string `gorm:"unique;primaryKey;autoIncrement"` + Domain string `gorm:"unique"` // Domain records must be unique, this avoids domain reuse across accounts. + AccountID string `gorm:"index"` + TargetCluster string // The proxy cluster this domain should be validated against + Type Type `gorm:"-"` + Validated bool + // SupportsCustomPorts is populated at query time for free domains from the + // proxy cluster capabilities. Not persisted. + SupportsCustomPorts *bool `gorm:"-"` + // RequireSubdomain is populated at query time. When true, the domain + // cannot be used bare and a subdomain label must be prepended. Not persisted. + RequireSubdomain *bool `gorm:"-"` + // SupportsCrowdSec is populated at query time from proxy cluster capabilities. + // Not persisted. + SupportsCrowdSec *bool `gorm:"-"` + // SupportsPrivate is populated at query time from proxy cluster capabilities. Not persisted. + SupportsPrivate *bool `gorm:"-"` +} + +// EventMeta returns activity event metadata for a domain +func (d *Domain) EventMeta() map[string]any { + return map[string]any{ + "domain": d.Domain, + "target_cluster": d.TargetCluster, + "validated": d.Validated, + } +} + +func (d *Domain) Copy() *Domain { + dCopy := *d + return &dCopy +} diff --git a/management/internals/modules/reverseproxy/domain/interface.go b/management/internals/modules/reverseproxy/domain/interface.go new file mode 100644 index 000000000..a4bba5841 --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/interface.go @@ -0,0 +1,13 @@ +package domain + +import ( + "context" +) + +type Manager interface { + GetDomains(ctx context.Context, accountID, userID string) ([]*Domain, error) + CreateDomain(ctx context.Context, accountID, userID, domainName, targetCluster string) (*Domain, error) + DeleteDomain(ctx context.Context, accountID, userID, domainID string) error + ValidateDomain(ctx context.Context, accountID, userID, domainID string) + GetClusterDomains() []string +} diff --git a/management/internals/modules/reverseproxy/domain/manager/api.go b/management/internals/modules/reverseproxy/domain/manager/api.go new file mode 100644 index 000000000..f01329010 --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/manager/api.go @@ -0,0 +1,140 @@ +package manager + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/shared/management/status" +) + +type handler struct { + manager Manager +} + +func RegisterEndpoints(router *mux.Router, manager Manager) { + h := &handler{ + manager: manager, + } + + router.HandleFunc("/domains", h.getAllDomains).Methods("GET", "OPTIONS") + router.HandleFunc("/domains", h.createCustomDomain).Methods("POST", "OPTIONS") + router.HandleFunc("/domains/{domainId}", h.deleteCustomDomain).Methods("DELETE", "OPTIONS") + router.HandleFunc("/domains/{domainId}/validate", h.triggerCustomDomainValidation).Methods("GET", "OPTIONS") +} + +func domainTypeToApi(t domain.Type) api.ReverseProxyDomainType { + switch t { + case domain.TypeCustom: + return api.ReverseProxyDomainTypeCustom + case domain.TypeFree: + return api.ReverseProxyDomainTypeFree + } + // By default return as a "free" domain as that is more restrictive. + // TODO: is this correct? + return api.ReverseProxyDomainTypeFree +} + +func domainToApi(d *domain.Domain) api.ReverseProxyDomain { + resp := api.ReverseProxyDomain{ + Domain: d.Domain, + Id: d.ID, + Type: domainTypeToApi(d.Type), + Validated: d.Validated, + SupportsCustomPorts: d.SupportsCustomPorts, + RequireSubdomain: d.RequireSubdomain, + SupportsCrowdsec: d.SupportsCrowdSec, + SupportsPrivate: d.SupportsPrivate, + } + if d.TargetCluster != "" { + resp.TargetCluster = &d.TargetCluster + } + return resp +} + +func (h *handler) getAllDomains(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + domains, err := h.manager.GetDomains(r.Context(), userAuth.AccountId, userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + ret := make([]api.ReverseProxyDomain, 0) + for _, d := range domains { + ret = append(ret, domainToApi(d)) + } + + util.WriteJSONObject(r.Context(), w, ret) +} + +func (h *handler) createCustomDomain(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var req api.PostApiReverseProxiesDomainsJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + domain, err := h.manager.CreateDomain(r.Context(), userAuth.AccountId, userAuth.UserId, req.Domain, req.TargetCluster) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, domainToApi(domain)) +} + +func (h *handler) deleteCustomDomain(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + domainID := mux.Vars(r)["domainId"] + if domainID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "domain ID is required"), w) + return + } + + if err := h.manager.DeleteDomain(r.Context(), userAuth.AccountId, userAuth.UserId, domainID); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *handler) triggerCustomDomainValidation(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + domainID := mux.Vars(r)["domainId"] + if domainID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "domain ID is required"), w) + return + } + + go h.manager.ValidateDomain(r.Context(), userAuth.AccountId, userAuth.UserId, domainID) + + w.WriteHeader(http.StatusAccepted) +} diff --git a/management/internals/modules/reverseproxy/domain/manager/domain_test.go b/management/internals/modules/reverseproxy/domain/manager/domain_test.go new file mode 100644 index 000000000..523920a99 --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/manager/domain_test.go @@ -0,0 +1,172 @@ +package manager + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" +) + +func TestExtractClusterFromFreeDomain(t *testing.T) { + clusters := []string{"eu1.proxy.netbird.io", "us1.proxy.netbird.io"} + + tests := []struct { + name string + domain string + wantOK bool + wantVal string + }{ + { + name: "subdomain of cluster matches", + domain: "myapp.eu1.proxy.netbird.io", + wantOK: true, + wantVal: "eu1.proxy.netbird.io", + }, + { + name: "deep subdomain of cluster matches", + domain: "foo.bar.eu1.proxy.netbird.io", + wantOK: true, + wantVal: "eu1.proxy.netbird.io", + }, + { + name: "bare cluster domain matches", + domain: "eu1.proxy.netbird.io", + wantOK: true, + wantVal: "eu1.proxy.netbird.io", + }, + { + name: "unrelated domain does not match", + domain: "example.com", + wantOK: false, + }, + { + name: "partial suffix does not match", + domain: "fakeu1.proxy.netbird.io", + wantOK: false, + }, + { + name: "second cluster matches", + domain: "app.us1.proxy.netbird.io", + wantOK: true, + wantVal: "us1.proxy.netbird.io", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cluster, ok := ExtractClusterFromFreeDomain(tc.domain, clusters) + assert.Equal(t, tc.wantOK, ok) + if ok { + assert.Equal(t, tc.wantVal, cluster) + } + }) + } +} + +func TestExtractClusterFromCustomDomains(t *testing.T) { + customDomains := []*domain.Domain{ + {Domain: "example.com", TargetCluster: "eu1.proxy.netbird.io"}, + {Domain: "proxy.corp.io", TargetCluster: "us1.proxy.netbird.io"}, + } + + tests := []struct { + name string + domain string + wantOK bool + wantVal string + }{ + { + name: "subdomain of custom domain matches", + domain: "app.example.com", + wantOK: true, + wantVal: "eu1.proxy.netbird.io", + }, + { + name: "bare custom domain matches", + domain: "example.com", + wantOK: true, + wantVal: "eu1.proxy.netbird.io", + }, + { + name: "deep subdomain of custom domain matches", + domain: "a.b.example.com", + wantOK: true, + wantVal: "eu1.proxy.netbird.io", + }, + { + name: "subdomain of multi-level custom domain matches", + domain: "app.proxy.corp.io", + wantOK: true, + wantVal: "us1.proxy.netbird.io", + }, + { + name: "bare multi-level custom domain matches", + domain: "proxy.corp.io", + wantOK: true, + wantVal: "us1.proxy.netbird.io", + }, + { + name: "unrelated domain does not match", + domain: "other.com", + wantOK: false, + }, + { + name: "partial suffix does not match custom domain", + domain: "fakeexample.com", + wantOK: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cluster, ok := extractClusterFromCustomDomains(tc.domain, customDomains) + assert.Equal(t, tc.wantOK, ok) + if ok { + assert.Equal(t, tc.wantVal, cluster) + } + }) + } +} + +func TestExtractClusterFromCustomDomains_OverlappingDomains(t *testing.T) { + customDomains := []*domain.Domain{ + {Domain: "example.com", TargetCluster: "cluster-generic"}, + {Domain: "app.example.com", TargetCluster: "cluster-app"}, + } + + tests := []struct { + name string + domain string + wantVal string + }{ + { + name: "exact match on more specific domain", + domain: "app.example.com", + wantVal: "cluster-app", + }, + { + name: "subdomain of more specific domain", + domain: "api.app.example.com", + wantVal: "cluster-app", + }, + { + name: "subdomain of generic domain", + domain: "other.example.com", + wantVal: "cluster-generic", + }, + { + name: "bare generic domain", + domain: "example.com", + wantVal: "cluster-generic", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cluster, ok := extractClusterFromCustomDomains(tc.domain, customDomains) + assert.True(t, ok) + assert.Equal(t, tc.wantVal, cluster) + }) + } +} diff --git a/management/internals/modules/reverseproxy/domain/manager/manager.go b/management/internals/modules/reverseproxy/domain/manager/manager.go new file mode 100644 index 000000000..3c0f0d73b --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/manager/manager.go @@ -0,0 +1,358 @@ +package manager + +import ( + "context" + "fmt" + "net" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + "github.com/netbirdio/netbird/management/server/account" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +type store interface { + GetAccount(ctx context.Context, accountID string) (*types.Account, error) + + GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error) + ListFreeDomains(ctx context.Context, accountID string) ([]string, error) + ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) + CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error) + UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error) + DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error +} + +type proxyManager interface { + GetActiveClusterAddresses(ctx context.Context) ([]string, error) + GetActiveClusterAddressesForAccount(ctx context.Context, accountID string) ([]string, error) + ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool + ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool + ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool + ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool +} + +type Manager struct { + store store + validator domain.Validator + proxyManager proxyManager + permissionsManager permissions.Manager + accountManager account.Manager +} + +func NewManager(store store, proxyMgr proxyManager, permissionsManager permissions.Manager, accountManager account.Manager) Manager { + return Manager{ + store: store, + proxyManager: proxyMgr, + validator: domain.Validator{Resolver: net.DefaultResolver}, + permissionsManager: permissionsManager, + accountManager: accountManager, + } +} + +func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*domain.Domain, error) { + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + domains, err := m.store.ListCustomDomains(ctx, accountID) + if err != nil { + return nil, fmt.Errorf("list custom domains: %w", err) + } + + var ret []*domain.Domain + + // Add connected proxy clusters as free domains. + // For BYOP accounts, only their own cluster is returned; otherwise shared clusters. + allowList, err := m.getClusterAllowList(ctx, accountID) + if err != nil { + log.WithContext(ctx).Errorf("failed to get active proxy cluster addresses: %v", err) + return nil, err + } + log.WithContext(ctx).WithFields(log.Fields{ + "accountID": accountID, + "proxyAllowList": allowList, + }).Debug("getting domains with proxy allow list") + + for _, cluster := range allowList { + d := &domain.Domain{ + Domain: cluster, + AccountID: accountID, + Type: domain.TypeFree, + Validated: true, + } + d.SupportsCustomPorts = m.proxyManager.ClusterSupportsCustomPorts(ctx, cluster) + d.RequireSubdomain = m.proxyManager.ClusterRequireSubdomain(ctx, cluster) + d.SupportsCrowdSec = m.proxyManager.ClusterSupportsCrowdSec(ctx, cluster) + d.SupportsPrivate = m.proxyManager.ClusterSupportsPrivate(ctx, cluster) + ret = append(ret, d) + } + + // Add custom domains. + for _, d := range domains { + cd := &domain.Domain{ + ID: d.ID, + Domain: d.Domain, + AccountID: accountID, + TargetCluster: d.TargetCluster, + Type: domain.TypeCustom, + Validated: d.Validated, + } + if d.TargetCluster != "" { + cd.SupportsCustomPorts = m.proxyManager.ClusterSupportsCustomPorts(ctx, d.TargetCluster) + cd.SupportsCrowdSec = m.proxyManager.ClusterSupportsCrowdSec(ctx, d.TargetCluster) + cd.SupportsPrivate = m.proxyManager.ClusterSupportsPrivate(ctx, d.TargetCluster) + } + // Custom domains never require a subdomain by default since + // the account owns them and should be able to use the bare domain. + ret = append(ret, cd) + } + + return ret, nil +} + +func (m Manager) CreateDomain(ctx context.Context, accountID, userID, domainName, targetCluster string) (*domain.Domain, error) { + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + // Verify the target cluster is in the available clusters for this account + allowList, err := m.getClusterAllowList(ctx, accountID) + if err != nil { + return nil, fmt.Errorf("failed to get active proxy cluster addresses: %w", err) + } + clusterValid := false + for _, cluster := range allowList { + if cluster == targetCluster { + clusterValid = true + break + } + } + if !clusterValid { + return nil, fmt.Errorf("target cluster %s is not available", targetCluster) + } + + // Attempt an initial validation against the specified cluster only + var validated bool + if m.validator.IsValid(ctx, domainName, []string{targetCluster}) { + validated = true + } + + d, err := m.store.CreateCustomDomain(ctx, accountID, domainName, targetCluster, validated) + if err != nil { + return d, fmt.Errorf("create domain in store: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, d.ID, accountID, activity.DomainAdded, d.EventMeta()) + + return d, nil +} + +func (m Manager) DeleteDomain(ctx context.Context, accountID, userID, domainID string) error { + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete) + if err != nil { + return status.NewPermissionValidationError(err) + } + if !ok { + return status.NewPermissionDeniedError() + } + + d, err := m.store.GetCustomDomain(ctx, accountID, domainID) + if err != nil { + return fmt.Errorf("get domain from store: %w", err) + } + + if err := m.store.DeleteCustomDomain(ctx, accountID, domainID); err != nil { + // TODO: check for "no records" type error. Because that is a success condition. + return fmt.Errorf("delete domain from store: %w", err) + } + + m.accountManager.StoreEvent(ctx, userID, domainID, accountID, activity.DomainDeleted, d.EventMeta()) + + return nil +} + +func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID string) { + ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create) + if err != nil { + log.WithFields(log.Fields{ + "accountID": accountID, + "domainID": domainID, + }).WithError(err).Error("validate domain") + return + } + if !ok { + log.WithFields(log.Fields{ + "accountID": accountID, + "domainID": domainID, + }).WithError(err).Error("validate domain") + } + + log.WithFields(log.Fields{ + "accountID": accountID, + "domainID": domainID, + }).Info("starting domain validation") + + d, err := m.store.GetCustomDomain(context.Background(), accountID, domainID) + if err != nil { + log.WithFields(log.Fields{ + "accountID": accountID, + "domainID": domainID, + }).WithError(err).Error("get custom domain from store") + return + } + + // Validate only against the domain's target cluster + targetCluster := d.TargetCluster + if targetCluster == "" { + log.WithFields(log.Fields{ + "accountID": accountID, + "domainID": domainID, + "domain": d.Domain, + }).Warn("domain has no target cluster set, skipping validation") + return + } + + log.WithFields(log.Fields{ + "accountID": accountID, + "domainID": domainID, + "domain": d.Domain, + "targetCluster": targetCluster, + }).Info("validating domain against target cluster") + + if m.validator.IsValid(context.Background(), d.Domain, []string{targetCluster}) { + log.WithFields(log.Fields{ + "accountID": accountID, + "domainID": domainID, + "domain": d.Domain, + }).Info("domain validated successfully") + d.Validated = true + if _, err := m.store.UpdateCustomDomain(context.Background(), accountID, d); err != nil { + log.WithFields(log.Fields{ + "accountID": accountID, + "domainID": domainID, + "domain": d.Domain, + }).WithError(err).Error("update custom domain in store") + return + } + + m.accountManager.StoreEvent(context.Background(), userID, domainID, accountID, activity.DomainValidated, d.EventMeta()) + } else { + log.WithFields(log.Fields{ + "accountID": accountID, + "domainID": domainID, + "domain": d.Domain, + "targetCluster": targetCluster, + }).Warn("domain validation failed - CNAME does not match target cluster") + } +} + +// GetClusterDomains returns a list of proxy cluster domains. +func (m Manager) GetClusterDomains() []string { + if m.proxyManager == nil { + return nil + } + addresses, err := m.proxyManager.GetActiveClusterAddresses(context.Background()) + if err != nil { + return nil + } + return addresses +} + +// DeriveClusterFromDomain determines the proxy cluster for a given domain. +// For free domains (those ending with a known cluster suffix), the cluster is extracted from the domain. +// For custom domains, the cluster is determined by checking the registered custom domain's target cluster. +func (m Manager) DeriveClusterFromDomain(ctx context.Context, accountID, domain string) (string, error) { + allowList, err := m.getClusterAllowList(ctx, accountID) + if err != nil { + return "", fmt.Errorf("failed to get active proxy cluster addresses: %w", err) + } + if len(allowList) == 0 { + return "", fmt.Errorf("no proxy clusters available") + } + + if cluster, ok := ExtractClusterFromFreeDomain(domain, allowList); ok { + return cluster, nil + } + + customDomains, err := m.store.ListCustomDomains(ctx, accountID) + if err != nil { + return "", fmt.Errorf("list custom domains: %w", err) + } + + targetCluster, valid := extractClusterFromCustomDomains(domain, customDomains) + if valid { + return targetCluster, nil + } + + return "", fmt.Errorf("domain %s does not match any available proxy cluster", domain) +} + +func (m Manager) getClusterAllowList(ctx context.Context, accountID string) ([]string, error) { + byopAddresses, err := m.proxyManager.GetActiveClusterAddressesForAccount(ctx, accountID) + if err != nil { + return nil, fmt.Errorf("get BYOP cluster addresses: %w", err) + } + publicAddresses, err := m.proxyManager.GetActiveClusterAddresses(ctx) + if err != nil { + return nil, fmt.Errorf("get public cluster addresses: %w", err) + } + seen := make(map[string]struct{}, len(byopAddresses)+len(publicAddresses)) + merged := make([]string, 0, len(byopAddresses)+len(publicAddresses)) + for _, addr := range byopAddresses { + if _, ok := seen[addr]; ok { + continue + } + seen[addr] = struct{}{} + merged = append(merged, addr) + } + for _, addr := range publicAddresses { + if _, ok := seen[addr]; ok { + continue + } + seen[addr] = struct{}{} + merged = append(merged, addr) + } + return merged, nil +} + +func extractClusterFromCustomDomains(serviceDomain string, customDomains []*domain.Domain) (string, bool) { + bestCluster := "" + bestLen := -1 + for _, cd := range customDomains { + if serviceDomain != cd.Domain && !strings.HasSuffix(serviceDomain, "."+cd.Domain) { + continue + } + if l := len(cd.Domain); l > bestLen { + bestLen = l + bestCluster = cd.TargetCluster + } + } + return bestCluster, bestLen >= 0 +} + +// ExtractClusterFromFreeDomain extracts the cluster address from a free domain. +// Free domains have the format: .. (e.g., myapp.abc123.eu.proxy.netbird.io) +// It matches the domain suffix against available clusters and returns the matching cluster. +func ExtractClusterFromFreeDomain(domain string, availableClusters []string) (string, bool) { + for _, cluster := range availableClusters { + if domain == cluster || strings.HasSuffix(domain, "."+cluster) { + return cluster, true + } + } + return "", false +} diff --git a/management/internals/modules/reverseproxy/domain/manager/manager_test.go b/management/internals/modules/reverseproxy/domain/manager/manager_test.go new file mode 100644 index 000000000..53a8dedae --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/manager/manager_test.go @@ -0,0 +1,157 @@ +package manager + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockProxyManager struct { + getActiveClusterAddressesFunc func(ctx context.Context) ([]string, error) + getActiveClusterAddressesForAccountFunc func(ctx context.Context, accountID string) ([]string, error) +} + +func (m *mockProxyManager) GetActiveClusterAddresses(ctx context.Context) ([]string, error) { + if m.getActiveClusterAddressesFunc != nil { + return m.getActiveClusterAddressesFunc(ctx) + } + return nil, nil +} + +func (m *mockProxyManager) GetActiveClusterAddressesForAccount(ctx context.Context, accountID string) ([]string, error) { + if m.getActiveClusterAddressesForAccountFunc != nil { + return m.getActiveClusterAddressesForAccountFunc(ctx, accountID) + } + return nil, nil +} + +func (m *mockProxyManager) ClusterSupportsCustomPorts(_ context.Context, _ string) *bool { + return nil +} + +func (m *mockProxyManager) ClusterRequireSubdomain(_ context.Context, _ string) *bool { + return nil +} + +func (m *mockProxyManager) ClusterSupportsCrowdSec(_ context.Context, _ string) *bool { + return nil +} + +func (m *mockProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) *bool { + return nil +} + +func TestGetClusterAllowList_BYOPMergedWithPublic(t *testing.T) { + pm := &mockProxyManager{ + getActiveClusterAddressesForAccountFunc: func(_ context.Context, accID string) ([]string, error) { + assert.Equal(t, "acc-123", accID) + return []string{"byop.example.com"}, nil + }, + getActiveClusterAddressesFunc: func(_ context.Context) ([]string, error) { + return []string{"eu.proxy.netbird.io"}, nil + }, + } + + mgr := Manager{proxyManager: pm} + result, err := mgr.getClusterAllowList(context.Background(), "acc-123") + require.NoError(t, err) + assert.Equal(t, []string{"byop.example.com", "eu.proxy.netbird.io"}, result) +} + +func TestGetClusterAllowList_DeduplicatesBYOPAndPublic(t *testing.T) { + pm := &mockProxyManager{ + getActiveClusterAddressesForAccountFunc: func(_ context.Context, _ string) ([]string, error) { + return []string{"shared.example.com", "byop.example.com"}, nil + }, + getActiveClusterAddressesFunc: func(_ context.Context) ([]string, error) { + return []string{"shared.example.com", "eu.proxy.netbird.io"}, nil + }, + } + + mgr := Manager{proxyManager: pm} + result, err := mgr.getClusterAllowList(context.Background(), "acc-123") + require.NoError(t, err) + assert.Equal(t, []string{"shared.example.com", "byop.example.com", "eu.proxy.netbird.io"}, result) +} + +func TestGetClusterAllowList_NoBYOP_FallbackToShared(t *testing.T) { + pm := &mockProxyManager{ + getActiveClusterAddressesForAccountFunc: func(_ context.Context, _ string) ([]string, error) { + return nil, nil + }, + getActiveClusterAddressesFunc: func(_ context.Context) ([]string, error) { + return []string{"eu.proxy.netbird.io", "us.proxy.netbird.io"}, nil + }, + } + + mgr := Manager{proxyManager: pm} + result, err := mgr.getClusterAllowList(context.Background(), "acc-123") + require.NoError(t, err) + assert.Equal(t, []string{"eu.proxy.netbird.io", "us.proxy.netbird.io"}, result) +} + +func TestGetClusterAllowList_BYOPError_ReturnsError(t *testing.T) { + pm := &mockProxyManager{ + getActiveClusterAddressesForAccountFunc: func(_ context.Context, _ string) ([]string, error) { + return nil, errors.New("db error") + }, + } + + mgr := Manager{proxyManager: pm} + result, err := mgr.getClusterAllowList(context.Background(), "acc-123") + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "BYOP cluster addresses") +} + +func TestGetClusterAllowList_PublicError_ReturnsError(t *testing.T) { + pm := &mockProxyManager{ + getActiveClusterAddressesForAccountFunc: func(_ context.Context, _ string) ([]string, error) { + return []string{"byop.example.com"}, nil + }, + getActiveClusterAddressesFunc: func(_ context.Context) ([]string, error) { + return nil, errors.New("db error") + }, + } + + mgr := Manager{proxyManager: pm} + result, err := mgr.getClusterAllowList(context.Background(), "acc-123") + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "public cluster addresses") +} + +func TestGetClusterAllowList_BYOPEmptySlice_FallbackToShared(t *testing.T) { + pm := &mockProxyManager{ + getActiveClusterAddressesForAccountFunc: func(_ context.Context, _ string) ([]string, error) { + return []string{}, nil + }, + getActiveClusterAddressesFunc: func(_ context.Context) ([]string, error) { + return []string{"eu.proxy.netbird.io"}, nil + }, + } + + mgr := Manager{proxyManager: pm} + result, err := mgr.getClusterAllowList(context.Background(), "acc-123") + require.NoError(t, err) + assert.Equal(t, []string{"eu.proxy.netbird.io"}, result) +} + +func TestGetClusterAllowList_PublicEmpty_BYOPOnly(t *testing.T) { + pm := &mockProxyManager{ + getActiveClusterAddressesForAccountFunc: func(_ context.Context, _ string) ([]string, error) { + return []string{"byop.example.com"}, nil + }, + getActiveClusterAddressesFunc: func(_ context.Context) ([]string, error) { + return nil, nil + }, + } + + mgr := Manager{proxyManager: pm} + result, err := mgr.getClusterAllowList(context.Background(), "acc-123") + require.NoError(t, err) + assert.Equal(t, []string{"byop.example.com"}, result) +} diff --git a/management/internals/modules/reverseproxy/domain/validator.go b/management/internals/modules/reverseproxy/domain/validator.go new file mode 100644 index 000000000..9c23c1192 --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/validator.go @@ -0,0 +1,88 @@ +package domain + +import ( + "context" + "net" + "strings" + + log "github.com/sirupsen/logrus" +) + +type resolver interface { + LookupCNAME(context.Context, string) (string, error) +} + +type Validator struct { + Resolver resolver +} + +// NewValidator initializes a validator with a specific DNS Resolver. +// If a Validator is used without specifying a Resolver, then it will +// use the net.DefaultResolver. +func NewValidator(resolver resolver) *Validator { + return &Validator{ + Resolver: resolver, + } +} + +// IsValid looks up the CNAME record for the passed domain with a prefix +// and compares it against the acceptable domains. +// If the returned CNAME matches any accepted domain, it will return true, +// otherwise, including in the event of a DNS error, it will return false. +// The comparison is very simple, so wildcards will not match if included +// in the acceptable domain list. +func (v *Validator) IsValid(ctx context.Context, domain string, accept []string) bool { + _, valid := v.ValidateWithCluster(ctx, domain, accept) + return valid +} + +// ValidateWithCluster validates a custom domain and returns the matched cluster address. +// Returns the cluster address and true if valid, or empty string and false if invalid. +func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, accept []string) (string, bool) { + if v.Resolver == nil { + v.Resolver = net.DefaultResolver + } + + lookupDomain := "validation." + domain + log.WithFields(log.Fields{ + "domain": domain, + "lookupDomain": lookupDomain, + "acceptList": accept, + }).Debug("looking up CNAME for domain validation") + + cname, err := v.Resolver.LookupCNAME(ctx, lookupDomain) + if err != nil { + log.WithFields(log.Fields{ + "domain": domain, + "lookupDomain": lookupDomain, + }).WithError(err).Warn("CNAME lookup failed for domain validation") + return "", false + } + + nakedCNAME := strings.TrimSuffix(cname, ".") + log.WithFields(log.Fields{ + "domain": domain, + "cname": cname, + "nakedCNAME": nakedCNAME, + "acceptList": accept, + }).Debug("CNAME lookup result for domain validation") + + for _, acceptDomain := range accept { + normalizedAccept := strings.TrimSuffix(acceptDomain, ".") + if nakedCNAME == normalizedAccept { + log.WithFields(log.Fields{ + "domain": domain, + "cname": nakedCNAME, + "cluster": acceptDomain, + }).Info("domain CNAME matched cluster") + return acceptDomain, true + } + } + + log.WithFields(log.Fields{ + "domain": domain, + "cname": nakedCNAME, + "acceptList": accept, + }).Warn("domain CNAME does not match any accepted cluster") + return "", false +} diff --git a/management/internals/modules/reverseproxy/domain/validator_test.go b/management/internals/modules/reverseproxy/domain/validator_test.go new file mode 100644 index 000000000..1f9583728 --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/validator_test.go @@ -0,0 +1,56 @@ +package domain_test + +import ( + "context" + "testing" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" +) + +type resolver struct { + CNAME string +} + +func (r resolver) LookupCNAME(_ context.Context, _ string) (string, error) { + return r.CNAME, nil +} + +func TestIsValid(t *testing.T) { + tests := map[string]struct { + resolver interface { + LookupCNAME(context.Context, string) (string, error) + } + domain string + accept []string + expect bool + }{ + "match": { + resolver: resolver{"bar.example.com."}, // Including trailing "." in response. + domain: "foo.example.com", + accept: []string{"bar.example.com"}, + expect: true, + }, + "no match": { + resolver: resolver{"invalid"}, + domain: "foo.example.com", + accept: []string{"bar.example.com"}, + expect: false, + }, + "accept trailing dot": { + resolver: resolver{"bar.example.com."}, + domain: "foo.example.com", + accept: []string{"bar.example.com."}, // Including trailing "." in accept. + expect: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + validator := domain.NewValidator(test.resolver) + actual := validator.IsValid(t.Context(), test.domain, test.accept) + if test.expect != actual { + t.Errorf("Incorrect return value:\nexpect: %v\nactual: %v", test.expect, actual) + } + }) + } +} diff --git a/management/internals/modules/reverseproxy/proxy/manager.go b/management/internals/modules/reverseproxy/proxy/manager.go new file mode 100644 index 000000000..22f1007ec --- /dev/null +++ b/management/internals/modules/reverseproxy/proxy/manager.go @@ -0,0 +1,45 @@ +package proxy + +//go:generate go run github.com/golang/mock/mockgen -package proxy -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod + +import ( + "context" + "time" + + "github.com/netbirdio/netbird/shared/management/proto" +) + +// Manager defines the interface for proxy operations +type Manager interface { + Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress string, accountID *string, capabilities *Capabilities) (*Proxy, error) + Disconnect(ctx context.Context, proxyID, sessionID string) error + Heartbeat(ctx context.Context, p *Proxy) error + GetActiveClusterAddresses(ctx context.Context) ([]string, error) + GetActiveClusterAddressesForAccount(ctx context.Context, accountID string) ([]string, error) + ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool + ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool + ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool + ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool + CleanupStale(ctx context.Context, inactivityDuration time.Duration) error + GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error) + CountAccountProxies(ctx context.Context, accountID string) (int64, error) + IsClusterAddressAvailable(ctx context.Context, clusterAddress, accountID string) (bool, error) + DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error +} + +// OIDCValidationConfig contains the OIDC configuration needed for token validation. +type OIDCValidationConfig struct { + Issuer string + Audiences []string + KeysLocation string + MaxTokenAgeSeconds int64 +} + +// Controller is responsible for managing proxy clusters and routing service updates. +type Controller interface { + SendServiceUpdateToCluster(ctx context.Context, accountID string, update *proto.ProxyMapping, clusterAddr string) + GetOIDCValidationConfig() OIDCValidationConfig + RegisterProxyToCluster(ctx context.Context, clusterAddr, proxyID string) error + UnregisterProxyFromCluster(ctx context.Context, clusterAddr, proxyID string) error + GetProxiesForCluster(clusterAddr string) []string +} diff --git a/management/internals/modules/reverseproxy/proxy/manager/controller.go b/management/internals/modules/reverseproxy/proxy/manager/controller.go new file mode 100644 index 000000000..e5b3e9886 --- /dev/null +++ b/management/internals/modules/reverseproxy/proxy/manager/controller.go @@ -0,0 +1,88 @@ +package manager + +import ( + "context" + "sync" + + log "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel/metric" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// GRPCController is a concrete implementation that manages proxy clusters and sends updates directly via gRPC. +type GRPCController struct { + proxyGRPCServer *nbgrpc.ProxyServiceServer + // Map of cluster address -> set of proxy IDs + clusterProxies sync.Map + metrics *metrics +} + +// NewGRPCController creates a new GRPCController. +func NewGRPCController(proxyGRPCServer *nbgrpc.ProxyServiceServer, meter metric.Meter) (*GRPCController, error) { + m, err := newMetrics(meter) + if err != nil { + return nil, err + } + + return &GRPCController{ + proxyGRPCServer: proxyGRPCServer, + metrics: m, + }, nil +} + +// SendServiceUpdateToCluster sends a service update to a specific proxy cluster. +func (c *GRPCController) SendServiceUpdateToCluster(ctx context.Context, accountID string, update *proto.ProxyMapping, clusterAddr string) { + c.proxyGRPCServer.SendServiceUpdateToCluster(ctx, update, clusterAddr) + c.metrics.IncrementServiceUpdateSendCount(clusterAddr) +} + +// GetOIDCValidationConfig returns the OIDC validation configuration from the gRPC server. +func (c *GRPCController) GetOIDCValidationConfig() proxy.OIDCValidationConfig { + return c.proxyGRPCServer.GetOIDCValidationConfig() +} + +// RegisterProxyToCluster registers a proxy to a specific cluster for routing. +func (c *GRPCController) RegisterProxyToCluster(ctx context.Context, clusterAddr, proxyID string) error { + if clusterAddr == "" { + return nil + } + proxySet, _ := c.clusterProxies.LoadOrStore(clusterAddr, &sync.Map{}) + proxySet.(*sync.Map).Store(proxyID, struct{}{}) + log.WithContext(ctx).Debugf("Registered proxy %s to cluster %s", proxyID, clusterAddr) + + c.metrics.IncrementProxyConnectionCount(clusterAddr) + + return nil +} + +// UnregisterProxyFromCluster removes a proxy from a cluster. +func (c *GRPCController) UnregisterProxyFromCluster(ctx context.Context, clusterAddr, proxyID string) error { + if clusterAddr == "" { + return nil + } + if proxySet, ok := c.clusterProxies.Load(clusterAddr); ok { + proxySet.(*sync.Map).Delete(proxyID) + log.WithContext(ctx).Debugf("Unregistered proxy %s from cluster %s", proxyID, clusterAddr) + + c.metrics.DecrementProxyConnectionCount(clusterAddr) + } + return nil +} + +// GetProxiesForCluster returns all proxy IDs registered for a specific cluster. +func (c *GRPCController) GetProxiesForCluster(clusterAddr string) []string { + proxySet, ok := c.clusterProxies.Load(clusterAddr) + if !ok { + return nil + } + + var proxies []string + proxySet.(*sync.Map).Range(func(key, _ interface{}) bool { + proxies = append(proxies, key.(string)) + return true + }) + return proxies +} diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager.go b/management/internals/modules/reverseproxy/proxy/manager/manager.go new file mode 100644 index 000000000..943766004 --- /dev/null +++ b/management/internals/modules/reverseproxy/proxy/manager/manager.go @@ -0,0 +1,186 @@ +package manager + +import ( + "context" + "time" + + log "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel/metric" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" +) + +// store defines the interface for proxy persistence operations +type store interface { + SaveProxy(ctx context.Context, p *proxy.Proxy) error + DisconnectProxy(ctx context.Context, proxyID, sessionID string) error + UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) error + GetActiveProxyClusterAddresses(ctx context.Context) ([]string, error) + GetActiveProxyClusterAddressesForAccount(ctx context.Context, accountID string) ([]string, error) + GetProxyClusters(ctx context.Context, accountID string) ([]proxy.Cluster, error) + GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool + GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool + GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool + GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool + CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error + GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error) + CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error) + IsClusterAddressConflicting(ctx context.Context, clusterAddress, accountID string) (bool, error) + DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error +} + +// Manager handles all proxy operations +type Manager struct { + store store + metrics *metrics +} + +// NewManager creates a new proxy Manager +func NewManager(store store, meter metric.Meter) (*Manager, error) { + m, err := newMetrics(meter) + if err != nil { + return nil, err + } + + return &Manager{ + store: store, + metrics: m, + }, nil +} + +// Connect registers a new proxy connection in the database. +// capabilities may be nil for old proxies that do not report them. +func (m *Manager) Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress string, accountID *string, capabilities *proxy.Capabilities) (*proxy.Proxy, error) { + now := time.Now() + var caps proxy.Capabilities + if capabilities != nil { + caps = *capabilities + } + p := &proxy.Proxy{ + ID: proxyID, + SessionID: sessionID, + ClusterAddress: clusterAddress, + IPAddress: ipAddress, + AccountID: accountID, + LastSeen: now, + ConnectedAt: &now, + Status: proxy.StatusConnected, + Capabilities: caps, + } + + if err := m.store.SaveProxy(ctx, p); err != nil { + log.WithContext(ctx).Errorf("failed to register proxy %s: %v", proxyID, err) + return nil, err + } + + log.WithContext(ctx).WithFields(log.Fields{ + "proxyID": proxyID, + "sessionID": sessionID, + "clusterAddress": clusterAddress, + "ipAddress": ipAddress, + }).Info("proxy connected") + + return p, nil +} + +// Disconnect marks a proxy as disconnected in the database. +func (m *Manager) Disconnect(ctx context.Context, proxyID, sessionID string) error { + if err := m.store.DisconnectProxy(ctx, proxyID, sessionID); err != nil { + log.WithContext(ctx).Errorf("failed to disconnect proxy %s session %s: %v", proxyID, sessionID, err) + return err + } + + log.WithContext(ctx).WithFields(log.Fields{ + "proxyID": proxyID, + "sessionID": sessionID, + }).Info("proxy disconnected") + + return nil +} + +// Heartbeat updates the proxy's last seen timestamp. +func (m *Manager) Heartbeat(ctx context.Context, p *proxy.Proxy) error { + if err := m.store.UpdateProxyHeartbeat(ctx, p); err != nil { + log.WithContext(ctx).Debugf("failed to update proxy %s heartbeat: %v", p.ID, err) + return err + } + + log.WithContext(ctx).Tracef("updated heartbeat for proxy %s session %s", p.ID, p.SessionID) + m.metrics.IncrementProxyHeartbeatCount() + return nil +} + +// GetActiveClusterAddresses returns all unique cluster addresses for active proxies +func (m *Manager) GetActiveClusterAddresses(ctx context.Context) ([]string, error) { + addresses, err := m.store.GetActiveProxyClusterAddresses(ctx) + if err != nil { + log.WithContext(ctx).Errorf("failed to get active proxy cluster addresses: %v", err) + return nil, err + } + return addresses, nil +} + +// ClusterSupportsCustomPorts returns whether any active proxy in the cluster +// supports custom ports. Returns nil when no proxy has reported capabilities. +func (m Manager) ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool { + return m.store.GetClusterSupportsCustomPorts(ctx, clusterAddr) +} + +// ClusterRequireSubdomain returns whether any active proxy in the cluster +// requires a subdomain. Returns nil when no proxy has reported capabilities. +func (m Manager) ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool { + return m.store.GetClusterRequireSubdomain(ctx, clusterAddr) +} + +// ClusterSupportsCrowdSec returns whether all active proxies in the cluster +// have CrowdSec configured (unanimous). Returns nil when no proxy has reported capabilities. +func (m Manager) ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool { + return m.store.GetClusterSupportsCrowdSec(ctx, clusterAddr) +} + +// ClusterSupportsPrivate reports whether any active proxy claims the private capability (nil = unreported). +func (m Manager) ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool { + return m.store.GetClusterSupportsPrivate(ctx, clusterAddr) +} + +// CleanupStale removes proxies that haven't sent heartbeat in the specified duration +func (m *Manager) CleanupStale(ctx context.Context, inactivityDuration time.Duration) error { + if err := m.store.CleanupStaleProxies(ctx, inactivityDuration); err != nil { + log.WithContext(ctx).Errorf("failed to cleanup stale proxies: %v", err) + return err + } + return nil +} + +func (m *Manager) GetActiveClusterAddressesForAccount(ctx context.Context, accountID string) ([]string, error) { + addresses, err := m.store.GetActiveProxyClusterAddressesForAccount(ctx, accountID) + if err != nil { + log.WithContext(ctx).Errorf("failed to get active proxy cluster addresses for account %s: %v", accountID, err) + return nil, err + } + return addresses, nil +} + +func (m *Manager) GetAccountProxy(ctx context.Context, accountID string) (*proxy.Proxy, error) { + return m.store.GetProxyByAccountID(ctx, accountID) +} + +func (m *Manager) CountAccountProxies(ctx context.Context, accountID string) (int64, error) { + return m.store.CountProxiesByAccountID(ctx, accountID) +} + +func (m *Manager) IsClusterAddressAvailable(ctx context.Context, clusterAddress, accountID string) (bool, error) { + conflicting, err := m.store.IsClusterAddressConflicting(ctx, clusterAddress, accountID) + if err != nil { + return false, err + } + return !conflicting, nil +} + +func (m *Manager) DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error { + if err := m.store.DeleteAccountCluster(ctx, clusterAddress, accountID); err != nil { + log.WithContext(ctx).Errorf("failed to delete cluster %s for account %s: %v", clusterAddress, accountID, err) + return err + } + return nil +} diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager_test.go b/management/internals/modules/reverseproxy/proxy/manager/manager_test.go new file mode 100644 index 000000000..5c44470a3 --- /dev/null +++ b/management/internals/modules/reverseproxy/proxy/manager/manager_test.go @@ -0,0 +1,340 @@ +package manager + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/metric/noop" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" +) + +type mockStore struct { + saveProxyFunc func(ctx context.Context, p *proxy.Proxy) error + disconnectProxyFunc func(ctx context.Context, proxyID, sessionID string) error + updateProxyHeartbeatFunc func(ctx context.Context, p *proxy.Proxy) error + getActiveProxyClusterAddressesFunc func(ctx context.Context) ([]string, error) + getActiveProxyClusterAddressesForAccFunc func(ctx context.Context, accountID string) ([]string, error) + cleanupStaleProxiesFunc func(ctx context.Context, d time.Duration) error + getProxyByAccountIDFunc func(ctx context.Context, accountID string) (*proxy.Proxy, error) + countProxiesByAccountIDFunc func(ctx context.Context, accountID string) (int64, error) + isClusterAddressConflictingFunc func(ctx context.Context, clusterAddress, accountID string) (bool, error) + deleteAccountClusterFunc func(ctx context.Context, clusterAddress, accountID string) error +} + +func (m *mockStore) SaveProxy(ctx context.Context, p *proxy.Proxy) error { + if m.saveProxyFunc != nil { + return m.saveProxyFunc(ctx, p) + } + return nil +} +func (m *mockStore) DisconnectProxy(ctx context.Context, proxyID, sessionID string) error { + if m.disconnectProxyFunc != nil { + return m.disconnectProxyFunc(ctx, proxyID, sessionID) + } + return nil +} +func (m *mockStore) UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) error { + if m.updateProxyHeartbeatFunc != nil { + return m.updateProxyHeartbeatFunc(ctx, p) + } + return nil +} +func (m *mockStore) GetActiveProxyClusterAddresses(ctx context.Context) ([]string, error) { + if m.getActiveProxyClusterAddressesFunc != nil { + return m.getActiveProxyClusterAddressesFunc(ctx) + } + return nil, nil +} +func (m *mockStore) GetActiveProxyClusterAddressesForAccount(ctx context.Context, accountID string) ([]string, error) { + if m.getActiveProxyClusterAddressesForAccFunc != nil { + return m.getActiveProxyClusterAddressesForAccFunc(ctx, accountID) + } + return nil, nil +} +func (m *mockStore) GetProxyClusters(_ context.Context, _ string) ([]proxy.Cluster, error) { + return nil, nil +} +func (m *mockStore) CleanupStaleProxies(ctx context.Context, d time.Duration) error { + if m.cleanupStaleProxiesFunc != nil { + return m.cleanupStaleProxiesFunc(ctx, d) + } + return nil +} +func (m *mockStore) GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error) { + if m.getProxyByAccountIDFunc != nil { + return m.getProxyByAccountIDFunc(ctx, accountID) + } + return nil, fmt.Errorf("proxy not found for account %s", accountID) +} +func (m *mockStore) CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error) { + if m.countProxiesByAccountIDFunc != nil { + return m.countProxiesByAccountIDFunc(ctx, accountID) + } + return 0, nil +} +func (m *mockStore) IsClusterAddressConflicting(ctx context.Context, clusterAddress, accountID string) (bool, error) { + if m.isClusterAddressConflictingFunc != nil { + return m.isClusterAddressConflictingFunc(ctx, clusterAddress, accountID) + } + return false, nil +} +func (m *mockStore) DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error { + if m.deleteAccountClusterFunc != nil { + return m.deleteAccountClusterFunc(ctx, clusterAddress, accountID) + } + return nil +} +func (m *mockStore) GetClusterSupportsCustomPorts(_ context.Context, _ string) *bool { + return nil +} +func (m *mockStore) GetClusterRequireSubdomain(_ context.Context, _ string) *bool { + return nil +} +func (m *mockStore) GetClusterSupportsCrowdSec(_ context.Context, _ string) *bool { + return nil +} +func (m *mockStore) GetClusterSupportsPrivate(_ context.Context, _ string) *bool { + return nil +} + +func newTestManager(s store) *Manager { + meter := noop.NewMeterProvider().Meter("test") + m, err := NewManager(s, meter) + if err != nil { + panic(err) + } + return m +} + +func TestConnect_WithAccountID(t *testing.T) { + accountID := "acc-123" + + var savedProxy *proxy.Proxy + s := &mockStore{ + saveProxyFunc: func(_ context.Context, p *proxy.Proxy) error { + savedProxy = p + return nil + }, + } + + mgr := newTestManager(s) + _, err := mgr.Connect(context.Background(), "proxy-1", "session-1", "cluster.example.com", "10.0.0.1", &accountID, nil) + require.NoError(t, err) + + require.NotNil(t, savedProxy) + assert.Equal(t, "proxy-1", savedProxy.ID) + assert.Equal(t, "session-1", savedProxy.SessionID) + assert.Equal(t, "cluster.example.com", savedProxy.ClusterAddress) + assert.Equal(t, "10.0.0.1", savedProxy.IPAddress) + assert.Equal(t, &accountID, savedProxy.AccountID) + assert.Equal(t, proxy.StatusConnected, savedProxy.Status) + assert.NotNil(t, savedProxy.ConnectedAt) +} + +func TestConnect_WithoutAccountID(t *testing.T) { + var savedProxy *proxy.Proxy + s := &mockStore{ + saveProxyFunc: func(_ context.Context, p *proxy.Proxy) error { + savedProxy = p + return nil + }, + } + + mgr := newTestManager(s) + _, err := mgr.Connect(context.Background(), "proxy-1", "session-1", "eu.proxy.netbird.io", "10.0.0.1", nil, nil) + require.NoError(t, err) + + require.NotNil(t, savedProxy) + assert.Nil(t, savedProxy.AccountID) + assert.Equal(t, proxy.StatusConnected, savedProxy.Status) +} + +func TestConnect_StoreError(t *testing.T) { + s := &mockStore{ + saveProxyFunc: func(_ context.Context, _ *proxy.Proxy) error { + return errors.New("db error") + }, + } + + mgr := newTestManager(s) + _, err := mgr.Connect(context.Background(), "proxy-1", "session-1", "cluster.example.com", "10.0.0.1", nil, nil) + assert.Error(t, err) +} + +func TestIsClusterAddressAvailable(t *testing.T) { + tests := []struct { + name string + conflicting bool + storeErr error + wantResult bool + wantErr bool + }{ + { + name: "available - no conflict", + conflicting: false, + wantResult: true, + }, + { + name: "not available - conflict exists", + conflicting: true, + wantResult: false, + }, + { + name: "store error", + storeErr: errors.New("db error"), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &mockStore{ + isClusterAddressConflictingFunc: func(_ context.Context, _, _ string) (bool, error) { + return tt.conflicting, tt.storeErr + }, + } + + mgr := newTestManager(s) + result, err := mgr.IsClusterAddressAvailable(context.Background(), "cluster.example.com", "acc-123") + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantResult, result) + }) + } +} + +func TestCountAccountProxies(t *testing.T) { + tests := []struct { + name string + count int64 + storeErr error + wantCount int64 + wantErr bool + }{ + { + name: "no proxies", + count: 0, + wantCount: 0, + }, + { + name: "one proxy", + count: 1, + wantCount: 1, + }, + { + name: "store error", + storeErr: errors.New("db error"), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &mockStore{ + countProxiesByAccountIDFunc: func(_ context.Context, _ string) (int64, error) { + return tt.count, tt.storeErr + }, + } + + mgr := newTestManager(s) + count, err := mgr.CountAccountProxies(context.Background(), "acc-123") + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantCount, count) + }) + } +} + +func TestGetAccountProxy(t *testing.T) { + accountID := "acc-123" + + t.Run("found", func(t *testing.T) { + expected := &proxy.Proxy{ + ID: "proxy-1", + ClusterAddress: "byop.example.com", + AccountID: &accountID, + Status: proxy.StatusConnected, + } + s := &mockStore{ + getProxyByAccountIDFunc: func(_ context.Context, accID string) (*proxy.Proxy, error) { + assert.Equal(t, accountID, accID) + return expected, nil + }, + } + + mgr := newTestManager(s) + p, err := mgr.GetAccountProxy(context.Background(), accountID) + require.NoError(t, err) + assert.Equal(t, expected, p) + }) + + t.Run("not found", func(t *testing.T) { + s := &mockStore{ + getProxyByAccountIDFunc: func(_ context.Context, _ string) (*proxy.Proxy, error) { + return nil, errors.New("not found") + }, + } + + mgr := newTestManager(s) + _, err := mgr.GetAccountProxy(context.Background(), accountID) + assert.Error(t, err) + }) +} + +func TestDeleteAccountCluster(t *testing.T) { + t.Run("success", func(t *testing.T) { + var deletedCluster, deletedAccount string + s := &mockStore{ + deleteAccountClusterFunc: func(_ context.Context, clusterAddress, accountID string) error { + deletedCluster = clusterAddress + deletedAccount = accountID + return nil + }, + } + + mgr := newTestManager(s) + err := mgr.DeleteAccountCluster(context.Background(), "cluster.example.com", "acc-123") + require.NoError(t, err) + assert.Equal(t, "cluster.example.com", deletedCluster) + assert.Equal(t, "acc-123", deletedAccount) + }) + + t.Run("store error", func(t *testing.T) { + s := &mockStore{ + deleteAccountClusterFunc: func(_ context.Context, _, _ string) error { + return errors.New("db error") + }, + } + + mgr := newTestManager(s) + err := mgr.DeleteAccountCluster(context.Background(), "cluster.example.com", "acc-123") + assert.Error(t, err) + }) +} + +func TestGetActiveClusterAddressesForAccount(t *testing.T) { + expected := []string{"byop.example.com"} + s := &mockStore{ + getActiveProxyClusterAddressesForAccFunc: func(_ context.Context, accID string) ([]string, error) { + assert.Equal(t, "acc-123", accID) + return expected, nil + }, + } + + mgr := newTestManager(s) + result, err := mgr.GetActiveClusterAddressesForAccount(context.Background(), "acc-123") + require.NoError(t, err) + assert.Equal(t, expected, result) +} diff --git a/management/internals/modules/reverseproxy/proxy/manager/metrics.go b/management/internals/modules/reverseproxy/proxy/manager/metrics.go new file mode 100644 index 000000000..2b402cead --- /dev/null +++ b/management/internals/modules/reverseproxy/proxy/manager/metrics.go @@ -0,0 +1,74 @@ +package manager + +import ( + "context" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +type metrics struct { + proxyConnectionCount metric.Int64UpDownCounter + serviceUpdateSendCount metric.Int64Counter + proxyHeartbeatCount metric.Int64Counter +} + +func newMetrics(meter metric.Meter) (*metrics, error) { + proxyConnectionCount, err := meter.Int64UpDownCounter( + "management_proxy_connection_count", + metric.WithDescription("Number of active proxy connections"), + metric.WithUnit("{connection}"), + ) + if err != nil { + return nil, err + } + + serviceUpdateSendCount, err := meter.Int64Counter( + "management_proxy_service_update_send_count", + metric.WithDescription("Total number of service updates sent to proxies"), + metric.WithUnit("{update}"), + ) + if err != nil { + return nil, err + } + + proxyHeartbeatCount, err := meter.Int64Counter( + "management_proxy_heartbeat_count", + metric.WithDescription("Total number of proxy heartbeats received"), + metric.WithUnit("{heartbeat}"), + ) + if err != nil { + return nil, err + } + + return &metrics{ + proxyConnectionCount: proxyConnectionCount, + serviceUpdateSendCount: serviceUpdateSendCount, + proxyHeartbeatCount: proxyHeartbeatCount, + }, nil +} + +func (m *metrics) IncrementProxyConnectionCount(clusterAddr string) { + m.proxyConnectionCount.Add(context.Background(), 1, + metric.WithAttributes( + attribute.String("cluster", clusterAddr), + )) +} + +func (m *metrics) DecrementProxyConnectionCount(clusterAddr string) { + m.proxyConnectionCount.Add(context.Background(), -1, + metric.WithAttributes( + attribute.String("cluster", clusterAddr), + )) +} + +func (m *metrics) IncrementServiceUpdateSendCount(clusterAddr string) { + m.serviceUpdateSendCount.Add(context.Background(), 1, + metric.WithAttributes( + attribute.String("cluster", clusterAddr), + )) +} + +func (m *metrics) IncrementProxyHeartbeatCount() { + m.proxyHeartbeatCount.Add(context.Background(), 1) +} diff --git a/management/internals/modules/reverseproxy/proxy/manager_mock.go b/management/internals/modules/reverseproxy/proxy/manager_mock.go new file mode 100644 index 000000000..d2be46c9f --- /dev/null +++ b/management/internals/modules/reverseproxy/proxy/manager_mock.go @@ -0,0 +1,328 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./manager.go + +// Package proxy is a generated GoMock package. +package proxy + +import ( + context "context" + reflect "reflect" + time "time" + + gomock "github.com/golang/mock/gomock" + proto "github.com/netbirdio/netbird/shared/management/proto" +) + +// MockManager is a mock of Manager interface. +type MockManager struct { + ctrl *gomock.Controller + recorder *MockManagerMockRecorder +} + +// MockManagerMockRecorder is the mock recorder for MockManager. +type MockManagerMockRecorder struct { + mock *MockManager +} + +// NewMockManager creates a new mock instance. +func NewMockManager(ctrl *gomock.Controller) *MockManager { + mock := &MockManager{ctrl: ctrl} + mock.recorder = &MockManagerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockManager) EXPECT() *MockManagerMockRecorder { + return m.recorder +} + +// CleanupStale mocks base method. +func (m *MockManager) CleanupStale(ctx context.Context, inactivityDuration time.Duration) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CleanupStale", ctx, inactivityDuration) + ret0, _ := ret[0].(error) + return ret0 +} + +// CleanupStale indicates an expected call of CleanupStale. +func (mr *MockManagerMockRecorder) CleanupStale(ctx, inactivityDuration interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanupStale", reflect.TypeOf((*MockManager)(nil).CleanupStale), ctx, inactivityDuration) +} + +// ClusterSupportsCustomPorts mocks base method. +func (m *MockManager) ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClusterSupportsCustomPorts", ctx, clusterAddr) + ret0, _ := ret[0].(*bool) + return ret0 +} + +// ClusterSupportsCustomPorts indicates an expected call of ClusterSupportsCustomPorts. +func (mr *MockManagerMockRecorder) ClusterSupportsCustomPorts(ctx, clusterAddr interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCustomPorts", reflect.TypeOf((*MockManager)(nil).ClusterSupportsCustomPorts), ctx, clusterAddr) +} + +// ClusterRequireSubdomain mocks base method. +func (m *MockManager) ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClusterRequireSubdomain", ctx, clusterAddr) + ret0, _ := ret[0].(*bool) + return ret0 +} + +// ClusterRequireSubdomain indicates an expected call of ClusterRequireSubdomain. +func (mr *MockManagerMockRecorder) ClusterRequireSubdomain(ctx, clusterAddr interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterRequireSubdomain", reflect.TypeOf((*MockManager)(nil).ClusterRequireSubdomain), ctx, clusterAddr) +} + +// ClusterSupportsCrowdSec mocks base method. +func (m *MockManager) ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClusterSupportsCrowdSec", ctx, clusterAddr) + ret0, _ := ret[0].(*bool) + return ret0 +} + +// ClusterSupportsCrowdSec indicates an expected call of ClusterSupportsCrowdSec. +func (mr *MockManagerMockRecorder) ClusterSupportsCrowdSec(ctx, clusterAddr interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCrowdSec", reflect.TypeOf((*MockManager)(nil).ClusterSupportsCrowdSec), ctx, clusterAddr) +} + +// ClusterSupportsPrivate mocks base method. +func (m *MockManager) ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClusterSupportsPrivate", ctx, clusterAddr) + ret0, _ := ret[0].(*bool) + return ret0 +} + +// ClusterSupportsPrivate indicates an expected call of ClusterSupportsPrivate. +func (mr *MockManagerMockRecorder) ClusterSupportsPrivate(ctx, clusterAddr interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsPrivate", reflect.TypeOf((*MockManager)(nil).ClusterSupportsPrivate), ctx, clusterAddr) +} + +// Connect mocks base method. +func (m *MockManager) Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress string, accountID *string, capabilities *Capabilities) (*Proxy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Connect", ctx, proxyID, sessionID, clusterAddress, ipAddress, accountID, capabilities) + ret0, _ := ret[0].(*Proxy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Connect indicates an expected call of Connect. +func (mr *MockManagerMockRecorder) Connect(ctx, proxyID, sessionID, clusterAddress, ipAddress, accountID, capabilities interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Connect", reflect.TypeOf((*MockManager)(nil).Connect), ctx, proxyID, sessionID, clusterAddress, ipAddress, accountID, capabilities) +} + +// Disconnect mocks base method. +func (m *MockManager) Disconnect(ctx context.Context, proxyID, sessionID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Disconnect", ctx, proxyID, sessionID) + ret0, _ := ret[0].(error) + return ret0 +} + +// Disconnect indicates an expected call of Disconnect. +func (mr *MockManagerMockRecorder) Disconnect(ctx, proxyID, sessionID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Disconnect", reflect.TypeOf((*MockManager)(nil).Disconnect), ctx, proxyID, sessionID) +} + +// GetActiveClusterAddresses mocks base method. +func (m *MockManager) GetActiveClusterAddresses(ctx context.Context) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetActiveClusterAddresses", ctx) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetActiveClusterAddresses indicates an expected call of GetActiveClusterAddresses. +func (mr *MockManagerMockRecorder) GetActiveClusterAddresses(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveClusterAddresses", reflect.TypeOf((*MockManager)(nil).GetActiveClusterAddresses), ctx) +} + +func (m *MockManager) GetActiveClusterAddressesForAccount(ctx context.Context, accountID string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetActiveClusterAddressesForAccount", ctx, accountID) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +func (mr *MockManagerMockRecorder) GetActiveClusterAddressesForAccount(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveClusterAddressesForAccount", reflect.TypeOf((*MockManager)(nil).GetActiveClusterAddressesForAccount), ctx, accountID) +} + +// Heartbeat mocks base method. +func (m *MockManager) Heartbeat(ctx context.Context, p *Proxy) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Heartbeat", ctx, p) + ret0, _ := ret[0].(error) + return ret0 +} + +// Heartbeat indicates an expected call of Heartbeat. +func (mr *MockManagerMockRecorder) Heartbeat(ctx, p interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Heartbeat", reflect.TypeOf((*MockManager)(nil).Heartbeat), ctx, p) +} + +// GetAccountProxy mocks base method. +func (m *MockManager) GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountProxy", ctx, accountID) + ret0, _ := ret[0].(*Proxy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountProxy indicates an expected call of GetAccountProxy. +func (mr *MockManagerMockRecorder) GetAccountProxy(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountProxy", reflect.TypeOf((*MockManager)(nil).GetAccountProxy), ctx, accountID) +} + +// CountAccountProxies mocks base method. +func (m *MockManager) CountAccountProxies(ctx context.Context, accountID string) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CountAccountProxies", ctx, accountID) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CountAccountProxies indicates an expected call of CountAccountProxies. +func (mr *MockManagerMockRecorder) CountAccountProxies(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountAccountProxies", reflect.TypeOf((*MockManager)(nil).CountAccountProxies), ctx, accountID) +} + +// IsClusterAddressAvailable mocks base method. +func (m *MockManager) IsClusterAddressAvailable(ctx context.Context, clusterAddress, accountID string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsClusterAddressAvailable", ctx, clusterAddress, accountID) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IsClusterAddressAvailable indicates an expected call of IsClusterAddressAvailable. +func (mr *MockManagerMockRecorder) IsClusterAddressAvailable(ctx, clusterAddress, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsClusterAddressAvailable", reflect.TypeOf((*MockManager)(nil).IsClusterAddressAvailable), ctx, clusterAddress, accountID) +} + +// DeleteAccountCluster mocks base method. +func (m *MockManager) DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAccountCluster", ctx, clusterAddress, accountID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAccountCluster indicates an expected call of DeleteAccountCluster. +func (mr *MockManagerMockRecorder) DeleteAccountCluster(ctx, clusterAddress, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccountCluster", reflect.TypeOf((*MockManager)(nil).DeleteAccountCluster), ctx, clusterAddress, accountID) +} + +// MockController is a mock of Controller interface. +type MockController struct { + ctrl *gomock.Controller + recorder *MockControllerMockRecorder +} + +// MockControllerMockRecorder is the mock recorder for MockController. +type MockControllerMockRecorder struct { + mock *MockController +} + +// NewMockController creates a new mock instance. +func NewMockController(ctrl *gomock.Controller) *MockController { + mock := &MockController{ctrl: ctrl} + mock.recorder = &MockControllerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockController) EXPECT() *MockControllerMockRecorder { + return m.recorder +} + +// GetOIDCValidationConfig mocks base method. +func (m *MockController) GetOIDCValidationConfig() OIDCValidationConfig { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOIDCValidationConfig") + ret0, _ := ret[0].(OIDCValidationConfig) + return ret0 +} + +// GetOIDCValidationConfig indicates an expected call of GetOIDCValidationConfig. +func (mr *MockControllerMockRecorder) GetOIDCValidationConfig() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOIDCValidationConfig", reflect.TypeOf((*MockController)(nil).GetOIDCValidationConfig)) +} + +// GetProxiesForCluster mocks base method. +func (m *MockController) GetProxiesForCluster(clusterAddr string) []string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetProxiesForCluster", clusterAddr) + ret0, _ := ret[0].([]string) + return ret0 +} + +// GetProxiesForCluster indicates an expected call of GetProxiesForCluster. +func (mr *MockControllerMockRecorder) GetProxiesForCluster(clusterAddr interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProxiesForCluster", reflect.TypeOf((*MockController)(nil).GetProxiesForCluster), clusterAddr) +} + +// RegisterProxyToCluster mocks base method. +func (m *MockController) RegisterProxyToCluster(ctx context.Context, clusterAddr, proxyID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RegisterProxyToCluster", ctx, clusterAddr, proxyID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RegisterProxyToCluster indicates an expected call of RegisterProxyToCluster. +func (mr *MockControllerMockRecorder) RegisterProxyToCluster(ctx, clusterAddr, proxyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterProxyToCluster", reflect.TypeOf((*MockController)(nil).RegisterProxyToCluster), ctx, clusterAddr, proxyID) +} + +// SendServiceUpdateToCluster mocks base method. +func (m *MockController) SendServiceUpdateToCluster(ctx context.Context, accountID string, update *proto.ProxyMapping, clusterAddr string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SendServiceUpdateToCluster", ctx, accountID, update, clusterAddr) +} + +// SendServiceUpdateToCluster indicates an expected call of SendServiceUpdateToCluster. +func (mr *MockControllerMockRecorder) SendServiceUpdateToCluster(ctx, accountID, update, clusterAddr interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendServiceUpdateToCluster", reflect.TypeOf((*MockController)(nil).SendServiceUpdateToCluster), ctx, accountID, update, clusterAddr) +} + +// UnregisterProxyFromCluster mocks base method. +func (m *MockController) UnregisterProxyFromCluster(ctx context.Context, clusterAddr, proxyID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UnregisterProxyFromCluster", ctx, clusterAddr, proxyID) + ret0, _ := ret[0].(error) + return ret0 +} + +// UnregisterProxyFromCluster indicates an expected call of UnregisterProxyFromCluster. +func (mr *MockControllerMockRecorder) UnregisterProxyFromCluster(ctx, clusterAddr, proxyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnregisterProxyFromCluster", reflect.TypeOf((*MockController)(nil).UnregisterProxyFromCluster), ctx, clusterAddr, proxyID) +} diff --git a/management/internals/modules/reverseproxy/proxy/proxy.go b/management/internals/modules/reverseproxy/proxy/proxy.go new file mode 100644 index 000000000..4404b0d24 --- /dev/null +++ b/management/internals/modules/reverseproxy/proxy/proxy.go @@ -0,0 +1,78 @@ +package proxy + +import ( + "time" +) + +const ( + StatusConnected = "connected" + StatusDisconnected = "disconnected" +) + +// Capabilities describes what a proxy can handle, as reported via gRPC. +// Nil fields mean the proxy never reported this capability. +type Capabilities struct { + // SupportsCustomPorts indicates whether this proxy can bind arbitrary + // ports for TCP/UDP services. TLS uses SNI routing and is not gated. + SupportsCustomPorts *bool + // RequireSubdomain indicates whether a subdomain label is required in + // front of the cluster domain. + RequireSubdomain *bool + // SupportsCrowdsec indicates whether this proxy has CrowdSec configured. + SupportsCrowdsec *bool + // Private indicates whether this proxy supports inbound access via Wireguard + // tunnel and netbird-only authentication policies + Private *bool +} + +// Proxy represents a reverse proxy instance +type Proxy struct { + ID string `gorm:"primaryKey;type:varchar(255)"` + SessionID string `gorm:"type:varchar(36)"` + ClusterAddress string `gorm:"type:varchar(255);not null;index:idx_proxy_cluster_status"` + IPAddress string `gorm:"type:varchar(45)"` + AccountID *string `gorm:"type:varchar(255);index:idx_proxy_account_id"` + LastSeen time.Time `gorm:"not null;index:idx_proxy_last_seen"` + ConnectedAt *time.Time + DisconnectedAt *time.Time + Status string `gorm:"type:varchar(20);not null;index:idx_proxy_cluster_status"` + Capabilities Capabilities `gorm:"embedded"` + CreatedAt time.Time + UpdatedAt time.Time +} + +func (Proxy) TableName() string { + return "proxies" +} + +// ClusterType is the source of a proxy cluster. +type ClusterType string + +const ( + // ClusterTypeAccount is a cluster operated by the account itself (BYOP) — + // at least one proxy row in the cluster carries a non-NULL account_id. + ClusterTypeAccount ClusterType = "account" + // ClusterTypeShared is a cluster operated by NetBird and shared across + // accounts — all proxy rows in the cluster have account_id IS NULL. + ClusterTypeShared ClusterType = "shared" +) + +// Cluster represents a group of proxy nodes serving the same address. +// +// Online and ConnectedProxies derive from the same 2-min active window +// the rest of the module uses, but Cluster rows are not gated on it — +// the cluster listing surfaces offline clusters too so operators can +// see and clean them up. The 1-hour heartbeat reaper still bounds the +// table eventually. +type Cluster struct { + ID string + Address string + Type ClusterType + Online bool + ConnectedProxies int + // *bool: nil = no proxy reported the capability; the dashboard renders that as unknown. + SupportsCustomPorts *bool + RequireSubdomain *bool + SupportsCrowdSec *bool + Private *bool +} diff --git a/management/internals/modules/reverseproxy/proxytoken/handler.go b/management/internals/modules/reverseproxy/proxytoken/handler.go new file mode 100644 index 000000000..ed098a6dd --- /dev/null +++ b/management/internals/modules/reverseproxy/proxytoken/handler.go @@ -0,0 +1,195 @@ +package proxytoken + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/gorilla/mux" + + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/shared/management/status" +) + +type handler struct { + store store.Store + permissionsManager permissions.Manager +} + +func RegisterEndpoints(s store.Store, permissionsManager permissions.Manager, router *mux.Router) { + h := &handler{store: s, permissionsManager: permissionsManager} + router.HandleFunc("/reverse-proxies/proxy-tokens", h.listTokens).Methods("GET", "OPTIONS") + router.HandleFunc("/reverse-proxies/proxy-tokens", h.createToken).Methods("POST", "OPTIONS") + router.HandleFunc("/reverse-proxies/proxy-tokens/{tokenId}", h.revokeToken).Methods("DELETE", "OPTIONS") +} + +func (h *handler) createToken(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + ok, ctx, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Services, operations.Create) + if err != nil { + util.WriteErrorResponse("failed to validate permissions", http.StatusInternalServerError, w) + return + } + if !ok { + util.WriteErrorResponse("permission denied", http.StatusForbidden, w) + return + } + + var req api.ProxyTokenRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + if req.Name == "" || len(req.Name) > 255 { + util.WriteErrorResponse("name is required and must be at most 255 characters", http.StatusBadRequest, w) + return + } + + var expiresIn time.Duration + if req.ExpiresIn != nil { + if *req.ExpiresIn < 0 { + util.WriteErrorResponse("expires_in must be non-negative", http.StatusBadRequest, w) + return + } + if *req.ExpiresIn > 0 { + expiresIn = time.Duration(*req.ExpiresIn) * time.Second + } + } + + accountID := userAuth.AccountId + generated, err := types.CreateNewProxyAccessToken(req.Name, expiresIn, &accountID, userAuth.UserId) + if err != nil { + util.WriteErrorResponse("failed to generate token", http.StatusInternalServerError, w) + return + } + + if err := h.store.SaveProxyAccessToken(ctx, &generated.ProxyAccessToken); err != nil { + util.WriteErrorResponse("failed to save token", http.StatusInternalServerError, w) + return + } + + resp := toProxyTokenCreatedResponse(generated) + util.WriteJSONObject(ctx, w, resp) +} + +func (h *handler) listTokens(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + ok, ctx, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Services, operations.Read) + if err != nil { + util.WriteErrorResponse("failed to validate permissions", http.StatusInternalServerError, w) + return + } + if !ok { + util.WriteErrorResponse("permission denied", http.StatusForbidden, w) + return + } + + tokens, err := h.store.GetProxyAccessTokensByAccountID(ctx, store.LockingStrengthNone, userAuth.AccountId) + if err != nil { + util.WriteErrorResponse("failed to list tokens", http.StatusInternalServerError, w) + return + } + + resp := make([]api.ProxyToken, 0, len(tokens)) + for _, token := range tokens { + resp = append(resp, toProxyTokenResponse(token)) + } + + util.WriteJSONObject(ctx, w, resp) +} + +func (h *handler) revokeToken(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + ok, ctx, err := h.permissionsManager.ValidateUserPermissions(r.Context(), userAuth.AccountId, userAuth.UserId, modules.Services, operations.Delete) + if err != nil { + util.WriteErrorResponse("failed to validate permissions", http.StatusInternalServerError, w) + return + } + if !ok { + util.WriteErrorResponse("permission denied", http.StatusForbidden, w) + return + } + + tokenID := mux.Vars(r)["tokenId"] + if tokenID == "" { + util.WriteErrorResponse("token ID is required", http.StatusBadRequest, w) + return + } + + token, err := h.store.GetProxyAccessTokenByID(ctx, store.LockingStrengthNone, tokenID) + if err != nil { + if s, ok := status.FromError(err); ok && s.ErrorType == status.NotFound { + util.WriteErrorResponse("token not found", http.StatusNotFound, w) + } else { + util.WriteErrorResponse("failed to retrieve token", http.StatusInternalServerError, w) + } + return + } + + if token.AccountID == nil || *token.AccountID != userAuth.AccountId { + util.WriteErrorResponse("token not found", http.StatusNotFound, w) + return + } + + if err := h.store.RevokeProxyAccessToken(ctx, tokenID); err != nil { + util.WriteErrorResponse("failed to revoke token", http.StatusInternalServerError, w) + return + } + + util.WriteJSONObject(ctx, w, util.EmptyObject{}) +} + +func toProxyTokenResponse(token *types.ProxyAccessToken) api.ProxyToken { + resp := api.ProxyToken{ + Id: token.ID, + Name: token.Name, + Revoked: token.Revoked, + } + if !token.CreatedAt.IsZero() { + resp.CreatedAt = token.CreatedAt + } + if token.ExpiresAt != nil { + resp.ExpiresAt = token.ExpiresAt + } + if token.LastUsed != nil { + resp.LastUsed = token.LastUsed + } + return resp +} + +func toProxyTokenCreatedResponse(generated *types.ProxyAccessTokenGenerated) api.ProxyTokenCreated { + base := toProxyTokenResponse(&generated.ProxyAccessToken) + plainToken := string(generated.PlainToken) + return api.ProxyTokenCreated{ + Id: base.Id, + Name: base.Name, + CreatedAt: base.CreatedAt, + ExpiresAt: base.ExpiresAt, + LastUsed: base.LastUsed, + Revoked: base.Revoked, + PlainToken: plainToken, + } +} diff --git a/management/internals/modules/reverseproxy/proxytoken/handler_test.go b/management/internals/modules/reverseproxy/proxytoken/handler_test.go new file mode 100644 index 000000000..a5b5713c6 --- /dev/null +++ b/management/internals/modules/reverseproxy/proxytoken/handler_test.go @@ -0,0 +1,275 @@ +package proxytoken + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +func authContext(accountID, userID string) context.Context { + return nbcontext.SetUserAuthInContext(context.Background(), auth.UserAuth{ + AccountId: accountID, + UserId: userID, + }) +} + +func TestCreateToken_AccountScoped(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + accountID := "acc-123" + var savedToken *types.ProxyAccessToken + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT().SaveProxyAccessToken(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, token *types.ProxyAccessToken) error { + savedToken = token + return nil + }, + ) + + permsMgr := permissions.NewMockManager(ctrl) + permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Create).Return(true, context.Background(), nil) + + h := &handler{ + store: mockStore, + permissionsManager: permsMgr, + } + + body := `{"name": "my-token"}` + req := httptest.NewRequest("POST", "/reverse-proxies/proxy-tokens", bytes.NewBufferString(body)) + req = req.WithContext(authContext(accountID, "user-1")) + w := httptest.NewRecorder() + + h.createToken(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + var resp api.ProxyTokenCreated + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + + assert.NotEmpty(t, resp.PlainToken) + assert.Equal(t, "my-token", resp.Name) + assert.False(t, resp.Revoked) + + require.NotNil(t, savedToken) + require.NotNil(t, savedToken.AccountID) + assert.Equal(t, accountID, *savedToken.AccountID) + assert.Equal(t, "user-1", savedToken.CreatedBy) +} + +func TestCreateToken_WithExpiration(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + var savedToken *types.ProxyAccessToken + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT().SaveProxyAccessToken(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, token *types.ProxyAccessToken) error { + savedToken = token + return nil + }, + ) + + permsMgr := permissions.NewMockManager(ctrl) + permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Create).Return(true, context.Background(), nil) + + h := &handler{ + store: mockStore, + permissionsManager: permsMgr, + } + + body := `{"name": "expiring-token", "expires_in": 3600}` + req := httptest.NewRequest("POST", "/reverse-proxies/proxy-tokens", bytes.NewBufferString(body)) + req = req.WithContext(authContext("acc-123", "user-1")) + w := httptest.NewRecorder() + + h.createToken(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + require.NotNil(t, savedToken) + require.NotNil(t, savedToken.ExpiresAt) + assert.True(t, savedToken.ExpiresAt.After(time.Now())) +} + +func TestCreateToken_EmptyName(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + permsMgr := permissions.NewMockManager(ctrl) + permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Create).Return(true, context.Background(), nil) + + h := &handler{ + permissionsManager: permsMgr, + } + + body := `{"name": ""}` + req := httptest.NewRequest("POST", "/reverse-proxies/proxy-tokens", bytes.NewBufferString(body)) + req = req.WithContext(authContext("acc-123", "user-1")) + w := httptest.NewRecorder() + + h.createToken(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestCreateToken_PermissionDenied(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + permsMgr := permissions.NewMockManager(ctrl) + permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Create).Return(false, context.Background(), nil) + + h := &handler{ + permissionsManager: permsMgr, + } + + body := `{"name": "test"}` + req := httptest.NewRequest("POST", "/reverse-proxies/proxy-tokens", bytes.NewBufferString(body)) + req = req.WithContext(authContext("acc-123", "user-1")) + w := httptest.NewRecorder() + + h.createToken(w, req) + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestListTokens(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + accountID := "acc-123" + now := time.Now() + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT().GetProxyAccessTokensByAccountID(gomock.Any(), store.LockingStrengthNone, accountID).Return([]*types.ProxyAccessToken{ + {ID: "tok-1", Name: "token-1", AccountID: &accountID, CreatedAt: now, Revoked: false}, + {ID: "tok-2", Name: "token-2", AccountID: &accountID, CreatedAt: now, Revoked: true}, + }, nil) + + permsMgr := permissions.NewMockManager(ctrl) + permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Read).Return(true, context.Background(), nil) + + h := &handler{ + store: mockStore, + permissionsManager: permsMgr, + } + + req := httptest.NewRequest("GET", "/reverse-proxies/proxy-tokens", nil) + req = req.WithContext(authContext(accountID, "user-1")) + w := httptest.NewRecorder() + + h.listTokens(w, req) + assert.Equal(t, http.StatusOK, w.Code) + + var resp []api.ProxyToken + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + require.Len(t, resp, 2) + assert.Equal(t, "tok-1", resp[0].Id) + assert.False(t, resp[0].Revoked) + assert.Equal(t, "tok-2", resp[1].Id) + assert.True(t, resp[1].Revoked) +} + +func TestRevokeToken_Success(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + accountID := "acc-123" + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT().GetProxyAccessTokenByID(gomock.Any(), store.LockingStrengthNone, "tok-1").Return(&types.ProxyAccessToken{ + ID: "tok-1", + Name: "test-token", + AccountID: &accountID, + }, nil) + mockStore.EXPECT().RevokeProxyAccessToken(gomock.Any(), "tok-1").Return(nil) + + permsMgr := permissions.NewMockManager(ctrl) + permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil) + + h := &handler{ + store: mockStore, + permissionsManager: permsMgr, + } + + req := httptest.NewRequest("DELETE", "/reverse-proxies/proxy-tokens/tok-1", nil) + req = req.WithContext(authContext(accountID, "user-1")) + req = mux.SetURLVars(req, map[string]string{"tokenId": "tok-1"}) + w := httptest.NewRecorder() + + h.revokeToken(w, req) + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestRevokeToken_WrongAccount(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + otherAccount := "acc-other" + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT().GetProxyAccessTokenByID(gomock.Any(), store.LockingStrengthNone, "tok-1").Return(&types.ProxyAccessToken{ + ID: "tok-1", + AccountID: &otherAccount, + }, nil) + + permsMgr := permissions.NewMockManager(ctrl) + permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil) + + h := &handler{ + store: mockStore, + permissionsManager: permsMgr, + } + + req := httptest.NewRequest("DELETE", "/reverse-proxies/proxy-tokens/tok-1", nil) + req = req.WithContext(authContext("acc-123", "user-1")) + req = mux.SetURLVars(req, map[string]string{"tokenId": "tok-1"}) + w := httptest.NewRecorder() + + h.revokeToken(w, req) + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestRevokeToken_ManagementWideToken(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT().GetProxyAccessTokenByID(gomock.Any(), store.LockingStrengthNone, "tok-1").Return(&types.ProxyAccessToken{ + ID: "tok-1", + AccountID: nil, + }, nil) + + permsMgr := permissions.NewMockManager(ctrl) + permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil) + + h := &handler{ + store: mockStore, + permissionsManager: permsMgr, + } + + req := httptest.NewRequest("DELETE", "/reverse-proxies/proxy-tokens/tok-1", nil) + req = req.WithContext(authContext("acc-123", "user-1")) + req = mux.SetURLVars(req, map[string]string{"tokenId": "tok-1"}) + w := httptest.NewRecorder() + + h.revokeToken(w, req) + assert.Equal(t, http.StatusNotFound, w.Code) +} diff --git a/management/internals/modules/reverseproxy/service/interface.go b/management/internals/modules/reverseproxy/service/interface.go new file mode 100644 index 000000000..dddf6ae8a --- /dev/null +++ b/management/internals/modules/reverseproxy/service/interface.go @@ -0,0 +1,33 @@ +package service + +//go:generate go run github.com/golang/mock/mockgen -package service -destination=interface_mock.go -source=./interface.go -build_flags=-mod=mod + +import ( + "context" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" +) + +type Manager interface { + GetClusters(ctx context.Context, accountID, userID string) ([]proxy.Cluster, error) + DeleteAccountCluster(ctx context.Context, accountID, userID, clusterAddress string) error + GetAllServices(ctx context.Context, accountID, userID string) ([]*Service, error) + GetService(ctx context.Context, accountID, userID, serviceID string) (*Service, error) + CreateService(ctx context.Context, accountID, userID string, service *Service) (*Service, error) + UpdateService(ctx context.Context, accountID, userID string, service *Service) (*Service, error) + DeleteService(ctx context.Context, accountID, userID, serviceID string) error + DeleteAllServices(ctx context.Context, accountID, userID string) error + SetCertificateIssuedAt(ctx context.Context, accountID, serviceID string) error + SetStatus(ctx context.Context, accountID, serviceID string, status Status) error + ReloadAllServicesForAccount(ctx context.Context, accountID string) error + ReloadService(ctx context.Context, accountID, serviceID string) error + GetGlobalServices(ctx context.Context) ([]*Service, error) + GetServiceByID(ctx context.Context, accountID, serviceID string) (*Service, error) + GetAccountServices(ctx context.Context, accountID string) ([]*Service, error) + GetServiceIDByTargetID(ctx context.Context, accountID string, resourceID string) (string, error) + CreateServiceFromPeer(ctx context.Context, accountID, peerID string, req *ExposeServiceRequest) (*ExposeServiceResponse, error) + RenewServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error + StopServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error + StartExposeReaper(ctx context.Context) + GetServiceByDomain(ctx context.Context, domain string) (*Service, error) +} diff --git a/management/internals/modules/reverseproxy/service/interface_mock.go b/management/internals/modules/reverseproxy/service/interface_mock.go new file mode 100644 index 000000000..24963fe30 --- /dev/null +++ b/management/internals/modules/reverseproxy/service/interface_mock.go @@ -0,0 +1,339 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./interface.go + +// Package service is a generated GoMock package. +package service + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + proxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" +) + +// MockManager is a mock of Manager interface. +type MockManager struct { + ctrl *gomock.Controller + recorder *MockManagerMockRecorder +} + +// MockManagerMockRecorder is the mock recorder for MockManager. +type MockManagerMockRecorder struct { + mock *MockManager +} + +// NewMockManager creates a new mock instance. +func NewMockManager(ctrl *gomock.Controller) *MockManager { + mock := &MockManager{ctrl: ctrl} + mock.recorder = &MockManagerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockManager) EXPECT() *MockManagerMockRecorder { + return m.recorder +} + +// CreateService mocks base method. +func (m *MockManager) CreateService(ctx context.Context, accountID, userID string, service *Service) (*Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateService", ctx, accountID, userID, service) + ret0, _ := ret[0].(*Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateService indicates an expected call of CreateService. +func (mr *MockManagerMockRecorder) CreateService(ctx, accountID, userID, service interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateService", reflect.TypeOf((*MockManager)(nil).CreateService), ctx, accountID, userID, service) +} + +// CreateServiceFromPeer mocks base method. +func (m *MockManager) CreateServiceFromPeer(ctx context.Context, accountID, peerID string, req *ExposeServiceRequest) (*ExposeServiceResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateServiceFromPeer", ctx, accountID, peerID, req) + ret0, _ := ret[0].(*ExposeServiceResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateServiceFromPeer indicates an expected call of CreateServiceFromPeer. +func (mr *MockManagerMockRecorder) CreateServiceFromPeer(ctx, accountID, peerID, req interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateServiceFromPeer", reflect.TypeOf((*MockManager)(nil).CreateServiceFromPeer), ctx, accountID, peerID, req) +} + +// DeleteAccountCluster mocks base method. +func (m *MockManager) DeleteAccountCluster(ctx context.Context, accountID, userID, clusterAddress string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAccountCluster", ctx, accountID, userID, clusterAddress) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAccountCluster indicates an expected call of DeleteAccountCluster. +func (mr *MockManagerMockRecorder) DeleteAccountCluster(ctx, accountID, userID, clusterAddress interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccountCluster", reflect.TypeOf((*MockManager)(nil).DeleteAccountCluster), ctx, accountID, userID, clusterAddress) +} + +// DeleteAllServices mocks base method. +func (m *MockManager) DeleteAllServices(ctx context.Context, accountID, userID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAllServices", ctx, accountID, userID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAllServices indicates an expected call of DeleteAllServices. +func (mr *MockManagerMockRecorder) DeleteAllServices(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAllServices", reflect.TypeOf((*MockManager)(nil).DeleteAllServices), ctx, accountID, userID) +} + +// DeleteService mocks base method. +func (m *MockManager) DeleteService(ctx context.Context, accountID, userID, serviceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteService", ctx, accountID, userID, serviceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteService indicates an expected call of DeleteService. +func (mr *MockManagerMockRecorder) DeleteService(ctx, accountID, userID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteService", reflect.TypeOf((*MockManager)(nil).DeleteService), ctx, accountID, userID, serviceID) +} + +// GetAccountServices mocks base method. +func (m *MockManager) GetAccountServices(ctx context.Context, accountID string) ([]*Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountServices", ctx, accountID) + ret0, _ := ret[0].([]*Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountServices indicates an expected call of GetAccountServices. +func (mr *MockManagerMockRecorder) GetAccountServices(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountServices", reflect.TypeOf((*MockManager)(nil).GetAccountServices), ctx, accountID) +} + +// GetAllServices mocks base method. +func (m *MockManager) GetAllServices(ctx context.Context, accountID, userID string) ([]*Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllServices", ctx, accountID, userID) + ret0, _ := ret[0].([]*Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllServices indicates an expected call of GetAllServices. +func (mr *MockManagerMockRecorder) GetAllServices(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllServices", reflect.TypeOf((*MockManager)(nil).GetAllServices), ctx, accountID, userID) +} + +// GetClusters mocks base method. +func (m *MockManager) GetClusters(ctx context.Context, accountID, userID string) ([]proxy.Cluster, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetClusters", ctx, accountID, userID) + ret0, _ := ret[0].([]proxy.Cluster) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetClusters indicates an expected call of GetClusters. +func (mr *MockManagerMockRecorder) GetClusters(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusters", reflect.TypeOf((*MockManager)(nil).GetClusters), ctx, accountID, userID) +} + +// GetGlobalServices mocks base method. +func (m *MockManager) GetGlobalServices(ctx context.Context) ([]*Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGlobalServices", ctx) + ret0, _ := ret[0].([]*Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGlobalServices indicates an expected call of GetGlobalServices. +func (mr *MockManagerMockRecorder) GetGlobalServices(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGlobalServices", reflect.TypeOf((*MockManager)(nil).GetGlobalServices), ctx) +} + +// GetService mocks base method. +func (m *MockManager) GetService(ctx context.Context, accountID, userID, serviceID string) (*Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetService", ctx, accountID, userID, serviceID) + ret0, _ := ret[0].(*Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetService indicates an expected call of GetService. +func (mr *MockManagerMockRecorder) GetService(ctx, accountID, userID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetService", reflect.TypeOf((*MockManager)(nil).GetService), ctx, accountID, userID, serviceID) +} + +// GetServiceByDomain mocks base method. +func (m *MockManager) GetServiceByDomain(ctx context.Context, domain string) (*Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetServiceByDomain", ctx, domain) + ret0, _ := ret[0].(*Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServiceByDomain indicates an expected call of GetServiceByDomain. +func (mr *MockManagerMockRecorder) GetServiceByDomain(ctx, domain interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceByDomain", reflect.TypeOf((*MockManager)(nil).GetServiceByDomain), ctx, domain) +} + +// GetServiceByID mocks base method. +func (m *MockManager) GetServiceByID(ctx context.Context, accountID, serviceID string) (*Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetServiceByID", ctx, accountID, serviceID) + ret0, _ := ret[0].(*Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServiceByID indicates an expected call of GetServiceByID. +func (mr *MockManagerMockRecorder) GetServiceByID(ctx, accountID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceByID", reflect.TypeOf((*MockManager)(nil).GetServiceByID), ctx, accountID, serviceID) +} + +// GetServiceIDByTargetID mocks base method. +func (m *MockManager) GetServiceIDByTargetID(ctx context.Context, accountID, resourceID string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetServiceIDByTargetID", ctx, accountID, resourceID) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServiceIDByTargetID indicates an expected call of GetServiceIDByTargetID. +func (mr *MockManagerMockRecorder) GetServiceIDByTargetID(ctx, accountID, resourceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceIDByTargetID", reflect.TypeOf((*MockManager)(nil).GetServiceIDByTargetID), ctx, accountID, resourceID) +} + +// ReloadAllServicesForAccount mocks base method. +func (m *MockManager) ReloadAllServicesForAccount(ctx context.Context, accountID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReloadAllServicesForAccount", ctx, accountID) + ret0, _ := ret[0].(error) + return ret0 +} + +// ReloadAllServicesForAccount indicates an expected call of ReloadAllServicesForAccount. +func (mr *MockManagerMockRecorder) ReloadAllServicesForAccount(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReloadAllServicesForAccount", reflect.TypeOf((*MockManager)(nil).ReloadAllServicesForAccount), ctx, accountID) +} + +// ReloadService mocks base method. +func (m *MockManager) ReloadService(ctx context.Context, accountID, serviceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReloadService", ctx, accountID, serviceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// ReloadService indicates an expected call of ReloadService. +func (mr *MockManagerMockRecorder) ReloadService(ctx, accountID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReloadService", reflect.TypeOf((*MockManager)(nil).ReloadService), ctx, accountID, serviceID) +} + +// RenewServiceFromPeer mocks base method. +func (m *MockManager) RenewServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RenewServiceFromPeer", ctx, accountID, peerID, serviceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RenewServiceFromPeer indicates an expected call of RenewServiceFromPeer. +func (mr *MockManagerMockRecorder) RenewServiceFromPeer(ctx, accountID, peerID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenewServiceFromPeer", reflect.TypeOf((*MockManager)(nil).RenewServiceFromPeer), ctx, accountID, peerID, serviceID) +} + +// SetCertificateIssuedAt mocks base method. +func (m *MockManager) SetCertificateIssuedAt(ctx context.Context, accountID, serviceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetCertificateIssuedAt", ctx, accountID, serviceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetCertificateIssuedAt indicates an expected call of SetCertificateIssuedAt. +func (mr *MockManagerMockRecorder) SetCertificateIssuedAt(ctx, accountID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetCertificateIssuedAt", reflect.TypeOf((*MockManager)(nil).SetCertificateIssuedAt), ctx, accountID, serviceID) +} + +// SetStatus mocks base method. +func (m *MockManager) SetStatus(ctx context.Context, accountID, serviceID string, status Status) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetStatus", ctx, accountID, serviceID, status) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetStatus indicates an expected call of SetStatus. +func (mr *MockManagerMockRecorder) SetStatus(ctx, accountID, serviceID, status interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetStatus", reflect.TypeOf((*MockManager)(nil).SetStatus), ctx, accountID, serviceID, status) +} + +// StartExposeReaper mocks base method. +func (m *MockManager) StartExposeReaper(ctx context.Context) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "StartExposeReaper", ctx) +} + +// StartExposeReaper indicates an expected call of StartExposeReaper. +func (mr *MockManagerMockRecorder) StartExposeReaper(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartExposeReaper", reflect.TypeOf((*MockManager)(nil).StartExposeReaper), ctx) +} + +// StopServiceFromPeer mocks base method. +func (m *MockManager) StopServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StopServiceFromPeer", ctx, accountID, peerID, serviceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// StopServiceFromPeer indicates an expected call of StopServiceFromPeer. +func (mr *MockManagerMockRecorder) StopServiceFromPeer(ctx, accountID, peerID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StopServiceFromPeer", reflect.TypeOf((*MockManager)(nil).StopServiceFromPeer), ctx, accountID, peerID, serviceID) +} + +// UpdateService mocks base method. +func (m *MockManager) UpdateService(ctx context.Context, accountID, userID string, service *Service) (*Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateService", ctx, accountID, userID, service) + ret0, _ := ret[0].(*Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateService indicates an expected call of UpdateService. +func (mr *MockManagerMockRecorder) UpdateService(ctx, accountID, userID, service interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateService", reflect.TypeOf((*MockManager)(nil).UpdateService), ctx, accountID, userID, service) +} diff --git a/management/internals/modules/reverseproxy/service/manager/api.go b/management/internals/modules/reverseproxy/service/manager/api.go new file mode 100644 index 000000000..7298b4261 --- /dev/null +++ b/management/internals/modules/reverseproxy/service/manager/api.go @@ -0,0 +1,233 @@ +package manager + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager" + domainmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/shared/management/status" +) + +type handler struct { + manager rpservice.Manager + permissionsManager permissions.Manager +} + +// RegisterEndpoints registers all service HTTP endpoints. +func RegisterEndpoints(manager rpservice.Manager, domainManager domainmanager.Manager, accessLogsManager accesslogs.Manager, permissionsManager permissions.Manager, router *mux.Router) { + h := &handler{ + manager: manager, + permissionsManager: permissionsManager, + } + + domainRouter := router.PathPrefix("/reverse-proxies").Subrouter() + domainmanager.RegisterEndpoints(domainRouter, domainManager) + + accesslogsmanager.RegisterEndpoints(router, accessLogsManager) + + router.HandleFunc("/reverse-proxies/clusters", h.getClusters).Methods("GET", "OPTIONS") + router.HandleFunc("/reverse-proxies/clusters/{clusterAddress}", h.deleteCluster).Methods("DELETE", "OPTIONS") + router.HandleFunc("/reverse-proxies/services", h.getAllServices).Methods("GET", "OPTIONS") + router.HandleFunc("/reverse-proxies/services", h.createService).Methods("POST", "OPTIONS") + router.HandleFunc("/reverse-proxies/services/{serviceId}", h.getService).Methods("GET", "OPTIONS") + router.HandleFunc("/reverse-proxies/services/{serviceId}", h.updateService).Methods("PUT", "OPTIONS") + router.HandleFunc("/reverse-proxies/services/{serviceId}", h.deleteService).Methods("DELETE", "OPTIONS") +} + +func (h *handler) getAllServices(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + allServices, err := h.manager.GetAllServices(r.Context(), userAuth.AccountId, userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + apiServices := make([]*api.Service, 0, len(allServices)) + for _, service := range allServices { + apiServices = append(apiServices, service.ToAPIResponse()) + } + + util.WriteJSONObject(r.Context(), w, apiServices) +} + +func (h *handler) createService(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var req api.ServiceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + service := new(rpservice.Service) + if err = service.FromAPIRequest(&req, userAuth.AccountId); err != nil { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "%s", err.Error()), w) + return + } + + if err = service.Validate(); err != nil { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "%s", err.Error()), w) + return + } + + createdService, err := h.manager.CreateService(r.Context(), userAuth.AccountId, userAuth.UserId, service) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, createdService.ToAPIResponse()) +} + +func (h *handler) getService(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + serviceID := mux.Vars(r)["serviceId"] + if serviceID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "service ID is required"), w) + return + } + + service, err := h.manager.GetService(r.Context(), userAuth.AccountId, userAuth.UserId, serviceID) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, service.ToAPIResponse()) +} + +func (h *handler) updateService(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + serviceID := mux.Vars(r)["serviceId"] + if serviceID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "service ID is required"), w) + return + } + + var req api.ServiceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + service := new(rpservice.Service) + service.ID = serviceID + if err = service.FromAPIRequest(&req, userAuth.AccountId); err != nil { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "%s", err.Error()), w) + return + } + + if err = service.Validate(); err != nil { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "%s", err.Error()), w) + return + } + + updatedService, err := h.manager.UpdateService(r.Context(), userAuth.AccountId, userAuth.UserId, service) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, updatedService.ToAPIResponse()) +} + +func (h *handler) deleteService(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + serviceID := mux.Vars(r)["serviceId"] + if serviceID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "service ID is required"), w) + return + } + + if err := h.manager.DeleteService(r.Context(), userAuth.AccountId, userAuth.UserId, serviceID); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} + +func (h *handler) getClusters(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + clusters, err := h.manager.GetClusters(r.Context(), userAuth.AccountId, userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + apiClusters := make([]api.ProxyCluster, 0, len(clusters)) + for _, c := range clusters { + apiClusters = append(apiClusters, api.ProxyCluster{ + Id: c.ID, + Address: c.Address, + Type: api.ProxyClusterType(c.Type), + Online: c.Online, + ConnectedProxies: c.ConnectedProxies, + SupportsCustomPorts: c.SupportsCustomPorts, + RequireSubdomain: c.RequireSubdomain, + SupportsCrowdsec: c.SupportsCrowdSec, + Private: c.Private, + }) + } + + util.WriteJSONObject(r.Context(), w, apiClusters) +} + +func (h *handler) deleteCluster(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + clusterAddress := mux.Vars(r)["clusterAddress"] + if clusterAddress == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "cluster address is required"), w) + return + } + + if err := h.manager.DeleteAccountCluster(r.Context(), userAuth.AccountId, userAuth.UserId, clusterAddress); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} diff --git a/management/internals/modules/reverseproxy/service/manager/expose_tracker.go b/management/internals/modules/reverseproxy/service/manager/expose_tracker.go new file mode 100644 index 000000000..911add3bb --- /dev/null +++ b/management/internals/modules/reverseproxy/service/manager/expose_tracker.go @@ -0,0 +1,65 @@ +package manager + +import ( + "context" + "math/rand/v2" + "time" + + "github.com/netbirdio/netbird/shared/management/status" + log "github.com/sirupsen/logrus" +) + +const ( + exposeTTL = 90 * time.Second + exposeReapInterval = 30 * time.Second + maxExposesPerPeer = 10 + exposeReapBatch = 100 +) + +type exposeReaper struct { + manager *Manager +} + +// StartExposeReaper starts a background goroutine that reaps expired ephemeral services from the DB. +func (r *exposeReaper) StartExposeReaper(ctx context.Context) { + go func() { + // start with a random delay + rn := rand.IntN(10) + time.Sleep(time.Duration(rn) * time.Second) + + ticker := time.NewTicker(exposeReapInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + r.reapExpiredExposes(ctx) + } + } + }() +} + +func (r *exposeReaper) reapExpiredExposes(ctx context.Context) { + expired, err := r.manager.store.GetExpiredEphemeralServices(ctx, exposeTTL, exposeReapBatch) + if err != nil { + log.Errorf("failed to get expired ephemeral services: %v", err) + return + } + + for _, svc := range expired { + log.Infof("reaping expired expose session for peer %s, domain %s", svc.SourcePeer, svc.Domain) + + err := r.manager.deleteExpiredPeerService(ctx, svc.AccountID, svc.SourcePeer, svc.ID) + if err == nil { + continue + } + + if s, ok := status.FromError(err); ok && s.ErrorType == status.NotFound { + log.Debugf("service %s was already deleted by another instance", svc.Domain) + } else { + log.Errorf("failed to delete expired peer-exposed service for domain %s: %v", svc.Domain, err) + } + } +} diff --git a/management/internals/modules/reverseproxy/service/manager/expose_tracker_test.go b/management/internals/modules/reverseproxy/service/manager/expose_tracker_test.go new file mode 100644 index 000000000..6ff8343b9 --- /dev/null +++ b/management/internals/modules/reverseproxy/service/manager/expose_tracker_test.go @@ -0,0 +1,221 @@ +package manager + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/store" +) + +func TestReapExpiredExposes(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + ctx := context.Background() + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "http", + }) + require.NoError(t, err) + + // Manually expire the service by backdating meta_last_renewed_at + expireEphemeralService(t, testStore, testAccountID, resp.Domain) + + // Create a non-expired service + resp2, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8081, + Mode: "http", + }) + require.NoError(t, err) + + mgr.exposeReaper.reapExpiredExposes(ctx) + + // Expired service should be deleted + _, err = testStore.GetServiceByDomain(ctx, resp.Domain) + require.Error(t, err, "expired service should be deleted") + + // Non-expired service should remain + _, err = testStore.GetServiceByDomain(ctx, resp2.Domain) + require.NoError(t, err, "active service should remain") +} + +func TestReapAlreadyDeletedService(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + ctx := context.Background() + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "http", + }) + require.NoError(t, err) + + expireEphemeralService(t, testStore, testAccountID, resp.Domain) + + // Delete the service before reaping + svcID := resolveServiceIDByDomain(t, testStore, resp.Domain) + err = mgr.StopServiceFromPeer(ctx, testAccountID, testPeerID, svcID) + require.NoError(t, err) + + // Reaping should handle the already-deleted service gracefully + mgr.exposeReaper.reapExpiredExposes(ctx) +} + +func TestConcurrentReapAndRenew(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + ctx := context.Background() + + for i := range 5 { + _, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: uint16(8080 + i), + Mode: "http", + }) + require.NoError(t, err) + } + + // Expire all services + services, err := testStore.GetAccountServices(ctx, store.LockingStrengthNone, testAccountID) + require.NoError(t, err) + for _, svc := range services { + if svc.Source == rpservice.SourceEphemeral { + expireEphemeralService(t, testStore, testAccountID, svc.Domain) + } + } + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + mgr.exposeReaper.reapExpiredExposes(ctx) + }() + go func() { + defer wg.Done() + _, _ = mgr.store.CountEphemeralServicesByPeer(ctx, store.LockingStrengthNone, testAccountID, testPeerID) + }() + wg.Wait() + + count, err := mgr.store.CountEphemeralServicesByPeer(ctx, store.LockingStrengthNone, testAccountID, testPeerID) + require.NoError(t, err) + assert.Equal(t, int64(0), count, "all expired services should be reaped") +} + +func TestRenewEphemeralService(t *testing.T) { + mgr, _ := setupIntegrationTest(t) + ctx := context.Background() + + t.Run("renew succeeds for active service", func(t *testing.T) { + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8082, + Mode: "http", + }) + require.NoError(t, err) + + svc, lookupErr := mgr.store.GetServiceByDomain(ctx, resp.Domain) + require.NoError(t, lookupErr) + err = mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, svc.ID) + require.NoError(t, err) + }) + + t.Run("renew fails for nonexistent domain", func(t *testing.T) { + err := mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, "nonexistent-service-id") + require.Error(t, err) + assert.Contains(t, err.Error(), "no active expose session") + }) +} + +func TestCountAndExistsEphemeralServices(t *testing.T) { + mgr, _ := setupIntegrationTest(t) + ctx := context.Background() + + count, err := mgr.store.CountEphemeralServicesByPeer(ctx, store.LockingStrengthNone, testAccountID, testPeerID) + require.NoError(t, err) + assert.Equal(t, int64(0), count) + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8083, + Mode: "http", + }) + require.NoError(t, err) + + count, err = mgr.store.CountEphemeralServicesByPeer(ctx, store.LockingStrengthNone, testAccountID, testPeerID) + require.NoError(t, err) + assert.Equal(t, int64(1), count) + + exists, err := mgr.store.EphemeralServiceExists(ctx, store.LockingStrengthNone, testAccountID, testPeerID, resp.Domain) + require.NoError(t, err) + assert.True(t, exists, "service should exist") + + exists, err = mgr.store.EphemeralServiceExists(ctx, store.LockingStrengthNone, testAccountID, testPeerID, "no-such.domain") + require.NoError(t, err) + assert.False(t, exists, "non-existent service should not exist") +} + +func TestMaxExposesPerPeerEnforced(t *testing.T) { + mgr, _ := setupIntegrationTest(t) + ctx := context.Background() + + for i := range maxExposesPerPeer { + _, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: uint16(8090 + i), + Mode: "http", + }) + require.NoError(t, err, "expose %d should succeed", i) + } + + _, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 9999, + Mode: "http", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "maximum number of active expose sessions") +} + +func TestReapSkipsRenewedService(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + ctx := context.Background() + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8086, + Mode: "http", + }) + require.NoError(t, err) + + // Expire the service + expireEphemeralService(t, testStore, testAccountID, resp.Domain) + + // Renew it before the reaper runs + svc, err := testStore.GetServiceByDomain(ctx, resp.Domain) + require.NoError(t, err) + err = mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, svc.ID) + require.NoError(t, err) + + // Reaper should skip it because the re-check sees a fresh timestamp + mgr.exposeReaper.reapExpiredExposes(ctx) + + _, err = testStore.GetServiceByDomain(ctx, resp.Domain) + require.NoError(t, err, "renewed service should survive reaping") +} + +// resolveServiceIDByDomain looks up a service ID by domain in tests. +func resolveServiceIDByDomain(t *testing.T, s store.Store, domain string) string { + t.Helper() + svc, err := s.GetServiceByDomain(context.Background(), domain) + require.NoError(t, err) + return svc.ID +} + +// expireEphemeralService backdates meta_last_renewed_at to force expiration. +func expireEphemeralService(t *testing.T, s store.Store, accountID, domain string) { + t.Helper() + svc, err := s.GetServiceByDomain(context.Background(), domain) + require.NoError(t, err) + + expired := time.Now().Add(-2 * exposeTTL) + svc.Meta.LastRenewedAt = &expired + err = s.UpdateService(context.Background(), svc) + require.NoError(t, err) +} diff --git a/management/internals/modules/reverseproxy/service/manager/l4_port_test.go b/management/internals/modules/reverseproxy/service/manager/l4_port_test.go new file mode 100644 index 000000000..c218291ef --- /dev/null +++ b/management/internals/modules/reverseproxy/service/manager/l4_port_test.go @@ -0,0 +1,777 @@ +package manager + +import ( + "context" + "net/netip" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/mock_server" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +const testCluster = "test-cluster" + +func boolPtr(v bool) *bool { return &v } + +// setupL4Test creates a manager with a mock proxy controller for L4 port tests. +func setupL4Test(t *testing.T, customPortsSupported *bool) (*Manager, store.Store, *proxy.MockController) { + t.Helper() + + ctrl := gomock.NewController(t) + + ctx := context.Background() + testStore, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanup) + + err = testStore.SaveAccount(ctx, &types.Account{ + Id: testAccountID, + CreatedBy: testUserID, + Settings: &types.Settings{ + PeerExposeEnabled: true, + PeerExposeGroups: []string{testGroupID}, + }, + Users: map[string]*types.User{ + testUserID: { + Id: testUserID, + AccountID: testAccountID, + Role: types.UserRoleAdmin, + }, + }, + Peers: map[string]*nbpeer.Peer{ + testPeerID: { + ID: testPeerID, + AccountID: testAccountID, + Key: "test-key", + DNSLabel: "test-peer", + Name: "test-peer", + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, + Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, + }, + }, + Groups: map[string]*types.Group{ + testGroupID: { + ID: testGroupID, + AccountID: testAccountID, + Name: "Expose Group", + }, + }, + }) + require.NoError(t, err) + + err = testStore.AddPeerToGroup(ctx, testAccountID, testPeerID, testGroupID) + require.NoError(t, err) + + mockCtrl := proxy.NewMockController(ctrl) + mockCtrl.EXPECT().SendServiceUpdateToCluster(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + mockCtrl.EXPECT().GetOIDCValidationConfig().Return(proxy.OIDCValidationConfig{}).AnyTimes() + + mockCaps := proxy.NewMockManager(ctrl) + mockCaps.EXPECT().ClusterSupportsCustomPorts(gomock.Any(), testCluster).Return(customPortsSupported).AnyTimes() + mockCaps.EXPECT().ClusterRequireSubdomain(gomock.Any(), testCluster).Return((*bool)(nil)).AnyTimes() + mockCaps.EXPECT().ClusterSupportsCrowdSec(gomock.Any(), testCluster).Return((*bool)(nil)).AnyTimes() + + accountMgr := &mock_server.MockAccountManager{ + StoreEventFunc: func(_ context.Context, _, _, _ string, _ activity.ActivityDescriber, _ map[string]any) {}, + UpdateAccountPeersFunc: func(_ context.Context, _ string, _ types.UpdateReason) {}, + GetGroupByNameFunc: func(ctx context.Context, groupName, accountID, userID string) (*types.Group, error) { + return testStore.GetGroupByName(ctx, store.LockingStrengthNone, accountID, groupName) + }, + } + + mgr := &Manager{ + store: testStore, + accountManager: accountMgr, + permissionsManager: permissions.NewManager(testStore), + proxyController: mockCtrl, + capabilities: mockCaps, + clusterDeriver: &testClusterDeriver{domains: []string{"test.netbird.io"}}, + } + mgr.exposeReaper = &exposeReaper{manager: mgr} + + return mgr, testStore, mockCtrl +} + +// seedService creates a service directly in the store for test setup. +func seedService(t *testing.T, s store.Store, name, protocol, domain, cluster string, port uint16) *rpservice.Service { + t.Helper() + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: name, + Mode: protocol, + Domain: domain, + ProxyCluster: cluster, + ListenPort: port, + Enabled: true, + Source: "permanent", + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: protocol, Port: 8080, Enabled: true}, + }, + } + svc.InitNewRecord() + err := s.CreateService(context.Background(), svc) + require.NoError(t, err) + return svc +} + +func TestPortConflict_TCPSamePortCluster(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + seedService(t, testStore, "existing-tcp", "tcp", testCluster, testCluster, 5432) + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "conflicting-tcp", + Mode: "tcp", + Domain: "conflicting-tcp." + testCluster, + ProxyCluster: testCluster, + ListenPort: 5432, + Enabled: true, + Source: "permanent", + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewService(ctx, testAccountID, svc) + require.Error(t, err, "TCP+TCP on same port/cluster should be rejected") + assert.Contains(t, err.Error(), "already in use") +} + +func TestPortConflict_UDPSamePortCluster(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + seedService(t, testStore, "existing-udp", "udp", testCluster, testCluster, 5432) + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "conflicting-udp", + Mode: "udp", + Domain: "conflicting-udp." + testCluster, + ProxyCluster: testCluster, + ListenPort: 5432, + Enabled: true, + Source: "permanent", + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "udp", Port: 9090, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewService(ctx, testAccountID, svc) + require.Error(t, err, "UDP+UDP on same port/cluster should be rejected") + assert.Contains(t, err.Error(), "already in use") +} + +func TestPortConflict_TLSSamePortDifferentDomain(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + seedService(t, testStore, "existing-tls", "tls", "app1.example.com", testCluster, 443) + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "new-tls", + Mode: "tls", + Domain: "app2.example.com", + ProxyCluster: testCluster, + ListenPort: 443, + Enabled: true, + Source: "permanent", + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8443, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewService(ctx, testAccountID, svc) + assert.NoError(t, err, "TLS+TLS on same port with different domains should be allowed (SNI routing)") +} + +func TestPortConflict_TLSSamePortSameDomain(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + seedService(t, testStore, "existing-tls", "tls", "app.example.com", testCluster, 443) + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "duplicate-tls", + Mode: "tls", + Domain: "app.example.com", + ProxyCluster: testCluster, + ListenPort: 443, + Enabled: true, + Source: "permanent", + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8443, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewService(ctx, testAccountID, svc) + require.Error(t, err, "TLS+TLS on same domain should be rejected") + assert.Contains(t, err.Error(), "domain already taken") +} + +func TestPortConflict_TLSAndTCPSamePort(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + seedService(t, testStore, "existing-tls", "tls", "app.example.com", testCluster, 443) + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "new-tcp", + Mode: "tcp", + Domain: testCluster, + ProxyCluster: testCluster, + ListenPort: 443, + Enabled: true, + Source: "permanent", + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8080, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewService(ctx, testAccountID, svc) + assert.NoError(t, err, "TLS+TCP on same port should be allowed (multiplexed)") +} + +func TestAutoAssign_TCPNoListenPort(t *testing.T) { + mgr, _, _ := setupL4Test(t, boolPtr(false)) + ctx := context.Background() + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "auto-tcp", + Mode: "tcp", + Domain: testCluster, + ProxyCluster: testCluster, + ListenPort: 0, + Enabled: true, + Source: "permanent", + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8080, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewService(ctx, testAccountID, svc) + require.NoError(t, err) + assert.True(t, svc.ListenPort >= autoAssignPortMin && svc.ListenPort <= autoAssignPortMax, + "auto-assigned port %d should be in range [%d, %d]", svc.ListenPort, autoAssignPortMin, autoAssignPortMax) + assert.True(t, svc.PortAutoAssigned, "PortAutoAssigned should be set") +} + +func TestAutoAssign_TCPCustomPortRejectedWhenNotSupported(t *testing.T) { + mgr, _, _ := setupL4Test(t, boolPtr(false)) + ctx := context.Background() + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "custom-tcp", + Mode: "tcp", + Domain: testCluster, + ProxyCluster: testCluster, + ListenPort: 5555, + Enabled: true, + Source: "permanent", + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8080, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewService(ctx, testAccountID, svc) + require.Error(t, err, "TCP with custom port should be rejected when cluster doesn't support it") + assert.Contains(t, err.Error(), "custom ports") +} + +func TestAutoAssign_TLSCustomPortAlwaysAllowed(t *testing.T) { + mgr, _, _ := setupL4Test(t, boolPtr(false)) + ctx := context.Background() + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "custom-tls", + Mode: "tls", + Domain: "app.example.com", + ProxyCluster: testCluster, + ListenPort: 9999, + Enabled: true, + Source: "permanent", + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8443, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewService(ctx, testAccountID, svc) + assert.NoError(t, err, "TLS with custom port should always be allowed regardless of cluster capability") + assert.Equal(t, uint16(9999), svc.ListenPort, "TLS listen port should not be overridden") + assert.False(t, svc.PortAutoAssigned, "PortAutoAssigned should not be set for TLS") +} + +func TestAutoAssign_EphemeralOverridesPortWhenNotSupported(t *testing.T) { + mgr, _, _ := setupL4Test(t, boolPtr(false)) + ctx := context.Background() + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "ephemeral-tcp", + Mode: "tcp", + Domain: testCluster, + ProxyCluster: testCluster, + ListenPort: 5555, + Enabled: true, + Source: "ephemeral", + SourcePeer: testPeerID, + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8080, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewEphemeralService(ctx, testAccountID, testPeerID, svc) + require.NoError(t, err) + assert.NotEqual(t, uint16(5555), svc.ListenPort, "requested port should be overridden") + assert.True(t, svc.ListenPort >= autoAssignPortMin && svc.ListenPort <= autoAssignPortMax, + "auto-assigned port %d should be in range", svc.ListenPort) + assert.True(t, svc.PortAutoAssigned) +} + +func TestAutoAssign_EphemeralTLSKeepsCustomPort(t *testing.T) { + mgr, _, _ := setupL4Test(t, boolPtr(false)) + ctx := context.Background() + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "ephemeral-tls", + Mode: "tls", + Domain: "app.example.com", + ProxyCluster: testCluster, + ListenPort: 9999, + Enabled: true, + Source: "ephemeral", + SourcePeer: testPeerID, + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8443, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewEphemeralService(ctx, testAccountID, testPeerID, svc) + require.NoError(t, err) + assert.Equal(t, uint16(9999), svc.ListenPort, "TLS listen port should not be overridden") + assert.False(t, svc.PortAutoAssigned) +} + +func TestAutoAssign_AvoidsExistingPorts(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + existingPort := uint16(20000) + seedService(t, testStore, "existing", "tcp", testCluster, testCluster, existingPort) + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "auto-tcp", + Mode: "tcp", + Domain: "auto-tcp." + testCluster, + ProxyCluster: testCluster, + ListenPort: 0, + Enabled: true, + Source: "permanent", + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8080, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewService(ctx, testAccountID, svc) + require.NoError(t, err) + assert.NotEqual(t, existingPort, svc.ListenPort, "auto-assigned port should not collide with existing") + assert.True(t, svc.PortAutoAssigned) +} + +func TestAutoAssign_TCPCustomPortAllowedWhenSupported(t *testing.T) { + mgr, _, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + svc := &rpservice.Service{ + AccountID: testAccountID, + Name: "custom-tcp", + Mode: "tcp", + Domain: testCluster, + ProxyCluster: testCluster, + ListenPort: 5555, + Enabled: true, + Source: "permanent", + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8080, Enabled: true}, + }, + } + svc.InitNewRecord() + + err := mgr.persistNewService(ctx, testAccountID, svc) + require.NoError(t, err) + assert.Equal(t, uint16(5555), svc.ListenPort, "custom port should be preserved when supported") + assert.False(t, svc.PortAutoAssigned) +} + +func TestUpdate_PreservesExistingListenPort(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + existing := seedService(t, testStore, "tcp-svc", "tcp", testCluster, testCluster, 12345) + + updated := &rpservice.Service{ + ID: existing.ID, + AccountID: testAccountID, + Name: "tcp-svc-renamed", + Mode: "tcp", + Domain: testCluster, + ProxyCluster: testCluster, + ListenPort: 0, + Enabled: true, + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true}, + }, + } + + _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated) + require.NoError(t, err) + assert.Equal(t, uint16(12345), updated.ListenPort, "existing listen port should be preserved when update sends 0") +} + +func TestUpdate_AllowsPortChange(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + existing := seedService(t, testStore, "tcp-svc", "tcp", testCluster, testCluster, 12345) + + updated := &rpservice.Service{ + ID: existing.ID, + AccountID: testAccountID, + Name: "tcp-svc", + Mode: "tcp", + Domain: testCluster, + ProxyCluster: testCluster, + ListenPort: 54321, + Enabled: true, + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true}, + }, + } + + _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated) + require.NoError(t, err) + assert.Equal(t, uint16(54321), updated.ListenPort, "explicit port change should be applied") +} + +func TestUpdate_PreservesPortWhenCustomPortsNotSupported(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(false)) + ctx := context.Background() + + existing := seedService(t, testStore, "tcp-svc", "tcp", testCluster, testCluster, 12345) + + updated := &rpservice.Service{ + ID: existing.ID, + AccountID: testAccountID, + Name: "tcp-svc-renamed", + Mode: "tcp", + Domain: testCluster, + ProxyCluster: testCluster, + ListenPort: 0, + Enabled: true, + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true}, + }, + } + + _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated) + require.NoError(t, err, "update must not be rejected by the custom-port capability check") + assert.Equal(t, uint16(12345), updated.ListenPort, "existing listen port should be preserved on unsupported cluster") +} + +func TestUpdate_PreservesPortWhenCustomPortsUnknown(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, nil) + ctx := context.Background() + + existing := seedService(t, testStore, "tcp-svc", "tcp", testCluster, testCluster, 12345) + + updated := &rpservice.Service{ + ID: existing.ID, + AccountID: testAccountID, + Name: "tcp-svc-renamed", + Mode: "tcp", + Domain: testCluster, + ProxyCluster: testCluster, + ListenPort: 0, + Enabled: true, + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true}, + }, + } + + _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated) + require.NoError(t, err, "update must not be rejected when cluster capability is unknown") + assert.Equal(t, uint16(12345), updated.ListenPort, "existing listen port should be preserved when capability is unknown") +} + +func TestUpdate_RejectsPortChangeWhenCustomPortsNotSupported(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(false)) + ctx := context.Background() + + existing := seedService(t, testStore, "tcp-svc", "tcp", testCluster, testCluster, 12345) + + updated := &rpservice.Service{ + ID: existing.ID, + AccountID: testAccountID, + Name: "tcp-svc", + Mode: "tcp", + Domain: testCluster, + ProxyCluster: testCluster, + ListenPort: 54321, + Enabled: true, + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true}, + }, + } + + _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated) + require.Error(t, err, "explicit port change on update must be rejected on unsupported clusters") + assert.Contains(t, err.Error(), "custom ports not supported on target cluster") +} + +func TestUpdate_TLSPortChangeAllowedWhenNotSupported(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(false)) + ctx := context.Background() + + existing := seedService(t, testStore, "tls-svc", "tls", "app.example.com", testCluster, 443) + + updated := &rpservice.Service{ + ID: existing.ID, + AccountID: testAccountID, + Name: "tls-svc", + Mode: "tls", + Domain: "app.example.com", + ProxyCluster: testCluster, + ListenPort: 9999, + Enabled: true, + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8443, Enabled: true}, + }, + } + + _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated) + require.NoError(t, err, "TLS port change uses SNI routing and is exempt from the custom-port check") + assert.Equal(t, uint16(9999), updated.ListenPort, "TLS port change should be applied") +} + +func TestValidateL4PortDiffOnClusterDiff(t *testing.T) { + tests := []struct { + name string + mode string + customPorts *bool + newPort uint16 + oldPort uint16 + wantErr bool + }{ + {"tcp port change unsupported", "tcp", boolPtr(false), 54321, 12345, true}, + {"tcp port change unknown capability", "tcp", nil, 54321, 12345, true}, + {"udp port change unsupported", "udp", boolPtr(false), 54321, 12345, true}, + {"tcp first port assignment unsupported", "tcp", boolPtr(false), 54321, 0, true}, + {"tcp port change supported", "tcp", boolPtr(true), 54321, 12345, false}, + {"tcp port unchanged unsupported", "tcp", boolPtr(false), 12345, 12345, false}, + {"tcp zero port unsupported", "tcp", boolPtr(false), 0, 12345, false}, + {"tls port change unsupported", "tls", boolPtr(false), 9999, 443, false}, + {"http mode ignored", "http", boolPtr(false), 54321, 12345, false}, + {"empty mode ignored", "", boolPtr(false), 54321, 12345, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + newSvc := &rpservice.Service{Mode: tc.mode, ListenPort: tc.newPort, ProxyCluster: testCluster} + oldSvc := &rpservice.Service{Mode: tc.mode, ListenPort: tc.oldPort, ProxyCluster: testCluster} + + err := validateL4PortDiffOnClusterDiff(tc.customPorts, newSvc, oldSvc) + if tc.wantErr { + assert.Error(t, err, "port diff should be rejected for %s", tc.name) + } else { + assert.NoError(t, err, "port diff should be allowed for %s", tc.name) + } + }) + } +} + +func TestUpdate_PortConflictRejected(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + seedService(t, testStore, "tcp-a", "tcp", "tcp-a."+testCluster, testCluster, 5432) + svcB := seedService(t, testStore, "tcp-b", "tcp", "tcp-b."+testCluster, testCluster, 6543) + + updated := &rpservice.Service{ + ID: svcB.ID, + AccountID: testAccountID, + Name: "tcp-b", + Mode: "tcp", + Domain: "tcp-b." + testCluster, + ProxyCluster: testCluster, + ListenPort: 5432, + Enabled: true, + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true}, + }, + } + + _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated) + require.Error(t, err, "updating to a port held by another service should be rejected") + assert.Contains(t, err.Error(), "already in use") +} + +func TestUpdate_AutoAssignsWhenNoPort(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(false)) + ctx := context.Background() + + existing := seedService(t, testStore, "tcp-svc", "tcp", testCluster, testCluster, 0) + + updated := &rpservice.Service{ + ID: existing.ID, + AccountID: testAccountID, + Name: "tcp-svc", + Mode: "tcp", + Domain: testCluster, + ProxyCluster: testCluster, + ListenPort: 0, + Enabled: true, + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true}, + }, + } + + _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated) + require.NoError(t, err) + assert.True(t, updated.ListenPort >= autoAssignPortMin && updated.ListenPort <= autoAssignPortMax, + "auto-assigned port %d should be in range [%d, %d]", updated.ListenPort, autoAssignPortMin, autoAssignPortMax) + assert.True(t, updated.PortAutoAssigned, "PortAutoAssigned should be set when update triggers auto-assignment") +} + +func TestCreateServiceFromPeer_TCP(t *testing.T) { + mgr, _, _ := setupL4Test(t, boolPtr(false)) + ctx := context.Background() + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 5432, + Mode: "tcp", + }) + require.NoError(t, err) + + assert.NotEmpty(t, resp.ServiceName) + assert.Contains(t, resp.Domain, ".test.netbird.io", "TCP uses unique subdomain") + assert.True(t, resp.PortAutoAssigned, "port should be auto-assigned when cluster doesn't support custom ports") + assert.Contains(t, resp.ServiceURL, "tcp://") +} + +func TestCreateServiceFromPeer_TCP_CustomPort(t *testing.T) { + mgr, _, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 5432, + Mode: "tcp", + ListenPort: 15432, + }) + require.NoError(t, err) + + assert.False(t, resp.PortAutoAssigned) + assert.Contains(t, resp.ServiceURL, ":15432") +} + +func TestCreateServiceFromPeer_TCP_DefaultListenPort(t *testing.T) { + mgr, _, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 5432, + Mode: "tcp", + }) + require.NoError(t, err) + + // When no explicit listen port, defaults to target port + assert.Contains(t, resp.ServiceURL, ":5432") + assert.False(t, resp.PortAutoAssigned) +} + +func TestCreateServiceFromPeer_TLS(t *testing.T) { + mgr, _, _ := setupL4Test(t, boolPtr(false)) + ctx := context.Background() + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 443, + Mode: "tls", + }) + require.NoError(t, err) + + assert.Contains(t, resp.Domain, ".test.netbird.io", "TLS uses subdomain") + assert.Contains(t, resp.ServiceURL, "tls://") + assert.Contains(t, resp.ServiceURL, ":443") + // TLS always keeps its port (not port-based protocol for auto-assign) + assert.False(t, resp.PortAutoAssigned) +} + +func TestCreateServiceFromPeer_TCP_StopAndRenew(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "tcp", + }) + require.NoError(t, err) + + svcID := resolveServiceIDByDomain(t, testStore, resp.Domain) + + err = mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, svcID) + require.NoError(t, err) + + err = mgr.StopServiceFromPeer(ctx, testAccountID, testPeerID, svcID) + require.NoError(t, err) + + // Renew after stop should fail + err = mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, svcID) + require.Error(t, err) +} + +func TestCreateServiceFromPeer_L4_RejectsAuth(t *testing.T) { + mgr, _, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + _, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "tcp", + Pin: "123456", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "authentication is not supported") +} diff --git a/management/internals/modules/reverseproxy/service/manager/manager.go b/management/internals/modules/reverseproxy/service/manager/manager.go new file mode 100644 index 000000000..365fbab40 --- /dev/null +++ b/management/internals/modules/reverseproxy/service/manager/manager.go @@ -0,0 +1,1372 @@ +package manager + +import ( + "context" + "fmt" + "math/rand/v2" + "net" + "net/http" + "os" + "slices" + "strconv" + "time" + + log "github.com/sirupsen/logrus" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" + + resourcetypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" + "github.com/netbirdio/netbird/management/server/account" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +const ( + defaultAutoAssignPortMin uint16 = 10000 + defaultAutoAssignPortMax uint16 = 49151 + + // EnvAutoAssignPortMin overrides the lower bound for auto-assigned L4 listen ports. + EnvAutoAssignPortMin = "NB_PROXY_PORT_MIN" + // EnvAutoAssignPortMax overrides the upper bound for auto-assigned L4 listen ports. + EnvAutoAssignPortMax = "NB_PROXY_PORT_MAX" +) + +var ( + autoAssignPortMin = defaultAutoAssignPortMin + autoAssignPortMax = defaultAutoAssignPortMax +) + +func init() { + autoAssignPortMin = portFromEnv(EnvAutoAssignPortMin, defaultAutoAssignPortMin) + autoAssignPortMax = portFromEnv(EnvAutoAssignPortMax, defaultAutoAssignPortMax) + if autoAssignPortMin > autoAssignPortMax { + log.Warnf("port range invalid: %s (%d) > %s (%d), using defaults", + EnvAutoAssignPortMin, autoAssignPortMin, EnvAutoAssignPortMax, autoAssignPortMax) + autoAssignPortMin = defaultAutoAssignPortMin + autoAssignPortMax = defaultAutoAssignPortMax + } +} + +func portFromEnv(key string, fallback uint16) uint16 { + val := os.Getenv(key) + if val == "" { + return fallback + } + n, err := strconv.ParseUint(val, 10, 16) + if err != nil { + log.Warnf("invalid %s value %q, using default %d: %v", key, val, fallback, err) + return fallback + } + return uint16(n) +} + +const unknownHostPlaceholder = "unknown" + +// ClusterDeriver derives the proxy cluster from a domain. +type ClusterDeriver interface { + DeriveClusterFromDomain(ctx context.Context, accountID, domain string) (string, error) + GetClusterDomains() []string +} + +// CapabilityProvider queries proxy cluster capabilities from the database. +type CapabilityProvider interface { + ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool + ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool + ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool + ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool +} + +type Manager struct { + store store.Store + accountManager account.Manager + permissionsManager permissions.Manager + proxyController proxy.Controller + capabilities CapabilityProvider + clusterDeriver ClusterDeriver + exposeReaper *exposeReaper +} + +// NewManager creates a new service manager. +func NewManager(store store.Store, accountManager account.Manager, permissionsManager permissions.Manager, proxyController proxy.Controller, capabilities CapabilityProvider, clusterDeriver ClusterDeriver) *Manager { + mgr := &Manager{ + store: store, + accountManager: accountManager, + permissionsManager: permissionsManager, + proxyController: proxyController, + capabilities: capabilities, + clusterDeriver: clusterDeriver, + } + mgr.exposeReaper = &exposeReaper{manager: mgr} + return mgr +} + +// StartExposeReaper starts the background goroutine that reaps expired ephemeral services. +func (m *Manager) StartExposeReaper(ctx context.Context) { + m.exposeReaper.StartExposeReaper(ctx) +} + +// GetClusters returns every proxy cluster visible to the account +// (shared + its own BYOP), regardless of whether any proxy in the +// cluster is currently heartbeating. Each cluster is enriched with the +// capability flags reported by its active proxies so the dashboard can +// render feature support without a second round-trip. +func (m *Manager) GetClusters(ctx context.Context, accountID, userID string) ([]proxy.Cluster, error) { + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + clusters, err := m.store.GetProxyClusters(ctx, accountID) + if err != nil { + return nil, err + } + + for i := range clusters { + clusters[i].SupportsCustomPorts = m.capabilities.ClusterSupportsCustomPorts(ctx, clusters[i].Address) + clusters[i].RequireSubdomain = m.capabilities.ClusterRequireSubdomain(ctx, clusters[i].Address) + clusters[i].SupportsCrowdSec = m.capabilities.ClusterSupportsCrowdSec(ctx, clusters[i].Address) + clusters[i].Private = m.capabilities.ClusterSupportsPrivate(ctx, clusters[i].Address) + } + + return clusters, nil +} + +// DeleteAccountCluster removes all proxy registrations for the given cluster address +// owned by the account. +func (m *Manager) DeleteAccountCluster(ctx context.Context, accountID, userID, clusterAddress string) error { + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete) + if err != nil { + return status.NewPermissionValidationError(err) + } + if !ok { + return status.NewPermissionDeniedError() + } + + return m.store.DeleteAccountCluster(ctx, clusterAddress, accountID) +} + +func (m *Manager) GetAllServices(ctx context.Context, accountID, userID string) ([]*service.Service, error) { + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + services, err := m.store.GetAccountServices(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, fmt.Errorf("failed to get services: %w", err) + } + + for _, service := range services { + err = m.replaceHostByLookup(ctx, accountID, service) + if err != nil { + return nil, fmt.Errorf("failed to replace host by lookup for service %s: %w", service.ID, err) + } + } + + return services, nil +} + +func (m *Manager) replaceHostByLookup(ctx context.Context, accountID string, s *service.Service) error { + for _, target := range s.Targets { + switch target.TargetType { + case service.TargetTypePeer: + peer, err := m.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, target.TargetId) + if err != nil { + log.WithContext(ctx).Warnf("failed to get peer by id %s for service %s: %v", target.TargetId, s.ID, err) + target.Host = unknownHostPlaceholder + continue + } + target.Host = peer.IP.String() + case service.TargetTypeHost: + resource, err := m.store.GetNetworkResourceByID(ctx, store.LockingStrengthNone, accountID, target.TargetId) + if err != nil { + log.WithContext(ctx).Warnf("failed to get resource by id %s for service %s: %v", target.TargetId, s.ID, err) + target.Host = unknownHostPlaceholder + continue + } + target.Host = resource.Prefix.Addr().String() + case service.TargetTypeDomain: + resource, err := m.store.GetNetworkResourceByID(ctx, store.LockingStrengthNone, accountID, target.TargetId) + if err != nil { + log.WithContext(ctx).Warnf("failed to get resource by id %s for service %s: %v", target.TargetId, s.ID, err) + target.Host = unknownHostPlaceholder + continue + } + target.Host = resource.Domain + case service.TargetTypeSubnet: + // For subnets we do not do any lookups on the resource + case service.TargetTypeCluster: + // Cluster targets carry the upstream address on target_id; the + // proxy resolves the destination at request time. + default: + return fmt.Errorf("unknown target type: %s", target.TargetType) + } + } + + return nil +} + +func (m *Manager) GetService(ctx context.Context, accountID, userID, serviceID string) (*service.Service, error) { + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + service, err := m.store.GetServiceByID(ctx, store.LockingStrengthNone, accountID, serviceID) + if err != nil { + return nil, fmt.Errorf("failed to get service: %w", err) + } + + err = m.replaceHostByLookup(ctx, accountID, service) + if err != nil { + return nil, fmt.Errorf("failed to replace host by lookup for service %s: %w", service.ID, err) + } + return service, nil +} + +func (m *Manager) CreateService(ctx context.Context, accountID, userID string, s *service.Service) (*service.Service, error) { + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + if err := m.initializeServiceForCreate(ctx, accountID, s); err != nil { + return nil, err + } + + if err := m.persistNewService(ctx, accountID, s); err != nil { + return nil, err + } + + m.accountManager.StoreEvent(ctx, userID, s.ID, accountID, activity.ServiceCreated, s.EventMeta()) + + err = m.replaceHostByLookup(ctx, accountID, s) + if err != nil { + return nil, fmt.Errorf("failed to replace host by lookup for service %s: %w", s.ID, err) + } + + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Create, "", m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster) + + m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationCreate}) + + return s, nil +} + +func (m *Manager) initializeServiceForCreate(ctx context.Context, accountID string, service *service.Service) error { + if m.clusterDeriver != nil { + proxyCluster, err := m.clusterDeriver.DeriveClusterFromDomain(ctx, accountID, service.Domain) + if err != nil { + log.WithError(err).Warnf("could not derive cluster from domain %s, updates will broadcast to all proxy servers", service.Domain) + return status.Errorf(status.PreconditionFailed, "could not derive cluster from domain %s: %v", service.Domain, err) + } + service.ProxyCluster = proxyCluster + + if err := m.validateSubdomainRequirement(ctx, service.Domain, proxyCluster); err != nil { + return err + } + } + + service.AccountID = accountID + service.InitNewRecord() + + if err := service.Auth.HashSecrets(); err != nil { + return fmt.Errorf("hash secrets: %w", err) + } + + for i, h := range service.Auth.HeaderAuths { + if h != nil && h.Enabled && h.Value == "" { + return status.Errorf(status.InvalidArgument, "header_auths[%d]: value is required", i) + } + } + + keyPair, err := sessionkey.GenerateKeyPair() + if err != nil { + return fmt.Errorf("generate session keys: %w", err) + } + service.SessionPrivateKey = keyPair.PrivateKey + service.SessionPublicKey = keyPair.PublicKey + + return nil +} + +// validateSubdomainRequirement checks whether the domain can be used bare +// (without a subdomain label) on the given cluster. If the cluster reports +// require_subdomain=true and the domain equals the cluster domain, it rejects. +func (m *Manager) validateSubdomainRequirement(ctx context.Context, domain, cluster string) error { + if domain != cluster { + return nil + } + requireSub := m.capabilities.ClusterRequireSubdomain(ctx, cluster) + if requireSub != nil && *requireSub { + return status.Errorf(status.InvalidArgument, "domain %s requires a subdomain label", domain) + } + return nil +} + +func (m *Manager) persistNewService(ctx context.Context, accountID string, svc *service.Service) error { + customPorts := m.clusterCustomPorts(ctx, svc) + + if err := validateTargetReferences(ctx, m.store, accountID, svc.Targets); err != nil { + return err + } + + return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + if svc.Domain != "" { + if err := m.checkDomainAvailable(ctx, transaction, svc.Domain, ""); err != nil { + return err + } + } + + if err := m.ensureL4Port(ctx, transaction, svc, customPorts, false); err != nil { + return err + } + + if err := m.checkPortConflict(ctx, transaction, svc); err != nil { + return err + } + + if err := transaction.CreateService(ctx, svc); err != nil { + return fmt.Errorf("create service: %w", err) + } + + return nil + }) +} + +// clusterCustomPorts queries whether the cluster supports custom ports. +// Must be called before entering a transaction: the underlying query uses +// the main DB handle, which deadlocks when called inside a transaction +// that already holds the connection. +func (m *Manager) clusterCustomPorts(ctx context.Context, svc *service.Service) *bool { + if !service.IsL4Protocol(svc.Mode) { + return nil + } + return m.capabilities.ClusterSupportsCustomPorts(ctx, svc.ProxyCluster) +} + +// ensureL4Port auto-assigns a listen port when needed and validates cluster support. +// customPorts must be pre-computed via clusterCustomPorts before entering a transaction. +func (m *Manager) ensureL4Port(ctx context.Context, tx store.Store, svc *service.Service, customPorts *bool, serviceUpdate bool) error { + if !service.IsL4Protocol(svc.Mode) { + return nil + } + if service.IsPortBasedProtocol(svc.Mode) && svc.ListenPort > 0 && !serviceUpdate && (customPorts == nil || !*customPorts) { + if svc.Source != service.SourceEphemeral { + return status.Errorf(status.InvalidArgument, "custom ports not supported on cluster %s", svc.ProxyCluster) + } + svc.ListenPort = 0 + } + if svc.ListenPort == 0 { + port, err := m.assignPort(ctx, tx, svc.ProxyCluster) + if err != nil { + return err + } + svc.ListenPort = port + svc.PortAutoAssigned = true + } + return nil +} + +// checkPortConflict rejects L4 services that would conflict on the same listener. +// For TCP/UDP: unique per cluster+protocol+port. +// For TLS: unique per cluster+port+domain (SNI routing allows sharing ports). +// Cross-protocol conflicts (TLS vs raw TCP) are intentionally not checked: +// the proxy router multiplexes TLS (via SNI) and raw TCP (via fallback) on the same listener. +func (m *Manager) checkPortConflict(ctx context.Context, transaction store.Store, svc *service.Service) error { + if !service.IsL4Protocol(svc.Mode) || svc.ListenPort == 0 { + return nil + } + + existing, err := transaction.GetServicesByClusterAndPort(ctx, store.LockingStrengthUpdate, svc.ProxyCluster, svc.Mode, svc.ListenPort) + if err != nil { + return fmt.Errorf("query port conflicts: %w", err) + } + for _, s := range existing { + if s.ID == svc.ID { + continue + } + // TLS services on the same port are allowed if they have different domains (SNI routing) + if svc.Mode == service.ModeTLS && s.Domain != svc.Domain { + continue + } + return status.Errorf(status.AlreadyExists, + "%s port %d is already in use by service %q on cluster %s", + svc.Mode, svc.ListenPort, s.Name, svc.ProxyCluster) + } + + return nil +} + +// assignPort picks a random available port on the cluster within the auto-assign range. +func (m *Manager) assignPort(ctx context.Context, tx store.Store, cluster string) (uint16, error) { + services, err := tx.GetServicesByCluster(ctx, store.LockingStrengthUpdate, cluster) + if err != nil { + return 0, fmt.Errorf("query cluster ports: %w", err) + } + + occupied := make(map[uint16]struct{}, len(services)) + for _, s := range services { + if s.ListenPort > 0 { + occupied[s.ListenPort] = struct{}{} + } + } + + portRange := int(autoAssignPortMax-autoAssignPortMin) + 1 + for range 100 { + port := autoAssignPortMin + uint16(rand.IntN(portRange)) + if _, taken := occupied[port]; !taken { + return port, nil + } + } + + for port := autoAssignPortMin; port <= autoAssignPortMax; port++ { + if _, taken := occupied[port]; !taken { + return port, nil + } + } + + return 0, status.Errorf(status.PreconditionFailed, "no available ports on cluster %s", cluster) +} + +// persistNewEphemeralService creates an ephemeral service inside a single transaction +// that also enforces the duplicate and per-peer limit checks atomically. +// The count and exists queries use FOR UPDATE locking to serialize concurrent creates +// for the same peer, preventing the per-peer limit from being bypassed. +func (m *Manager) persistNewEphemeralService(ctx context.Context, accountID, peerID string, svc *service.Service) error { + customPorts := m.clusterCustomPorts(ctx, svc) + + if err := validateTargetReferences(ctx, m.store, accountID, svc.Targets); err != nil { + return err + } + + return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + if err := m.validateEphemeralPreconditions(ctx, transaction, accountID, peerID, svc); err != nil { + return err + } + + if err := m.ensureL4Port(ctx, transaction, svc, customPorts, false); err != nil { + return err + } + + if err := m.checkPortConflict(ctx, transaction, svc); err != nil { + return err + } + + if err := transaction.CreateService(ctx, svc); err != nil { + return fmt.Errorf("create service: %w", err) + } + + return nil + }) +} + +func (m *Manager) validateEphemeralPreconditions(ctx context.Context, transaction store.Store, accountID, peerID string, svc *service.Service) error { + // Lock the peer row to serialize concurrent creates for the same peer. + if _, err := transaction.GetPeerByID(ctx, store.LockingStrengthUpdate, accountID, peerID); err != nil { + return fmt.Errorf("lock peer row: %w", err) + } + + exists, err := transaction.EphemeralServiceExists(ctx, store.LockingStrengthUpdate, accountID, peerID, svc.Domain) + if err != nil { + return fmt.Errorf("check existing expose: %w", err) + } + if exists { + return status.Errorf(status.AlreadyExists, "peer already has an active expose session for this domain") + } + + if err := m.checkDomainAvailable(ctx, transaction, svc.Domain, ""); err != nil { + return err + } + + count, err := transaction.CountEphemeralServicesByPeer(ctx, store.LockingStrengthUpdate, accountID, peerID) + if err != nil { + return fmt.Errorf("count peer exposes: %w", err) + } + if count >= int64(maxExposesPerPeer) { + return status.Errorf(status.PreconditionFailed, "peer has reached the maximum number of active expose sessions (%d)", maxExposesPerPeer) + } + + return nil +} + +// checkDomainAvailable checks that no other service already uses this domain. +func (m *Manager) checkDomainAvailable(ctx context.Context, transaction store.Store, domain, excludeServiceID string) error { + existingService, err := transaction.GetServiceByDomain(ctx, domain) + if err != nil { + if sErr, ok := status.FromError(err); !ok || sErr.Type() != status.NotFound { + return fmt.Errorf("check existing service: %w", err) + } + return nil + } + + if existingService != nil && existingService.ID != excludeServiceID { + return status.Errorf(status.AlreadyExists, "domain already taken") + } + + return nil +} + +func (m *Manager) UpdateService(ctx context.Context, accountID, userID string, service *service.Service) (*service.Service, error) { + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Update) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + if err := service.Auth.HashSecrets(); err != nil { + return nil, fmt.Errorf("hash secrets: %w", err) + } + + updateInfo, err := m.persistServiceUpdate(ctx, accountID, service) + if err != nil { + return nil, err + } + + m.accountManager.StoreEvent(ctx, userID, service.ID, accountID, activity.ServiceUpdated, service.EventMeta()) + + if err := m.replaceHostByLookup(ctx, accountID, service); err != nil { + return nil, fmt.Errorf("failed to replace host by lookup for service %s: %w", service.ID, err) + } + + m.sendServiceUpdateNotifications(ctx, accountID, service, updateInfo) + m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationUpdate}) + + return service, nil +} + +type serviceUpdateInfo struct { + oldCluster string + domainChanged bool + serviceEnabledChanged bool +} + +func (m *Manager) persistServiceUpdate(ctx context.Context, accountID string, service *service.Service) (*serviceUpdateInfo, error) { + effectiveCluster, err := m.resolveEffectiveCluster(ctx, accountID, service) + if err != nil { + return nil, err + } + + svcForCaps := *service + svcForCaps.ProxyCluster = effectiveCluster + customPorts := m.clusterCustomPorts(ctx, &svcForCaps) + + if err := validateTargetReferences(ctx, m.store, accountID, service.Targets); err != nil { + return nil, err + } + + // Validate subdomain requirement *before* the transaction: the underlying + // capability lookup talks to the main DB pool, and SQLite's single-connection + // pool would self-deadlock if this ran while the tx already held the only + // connection. + if err := m.validateSubdomainRequirement(ctx, service.Domain, effectiveCluster); err != nil { + return nil, err + } + + var updateInfo serviceUpdateInfo + + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + return m.executeServiceUpdate(ctx, transaction, accountID, service, &updateInfo, customPorts, effectiveCluster) + }) + + return &updateInfo, err +} + +// resolveEffectiveCluster determines the cluster that will be used after the update. +// It reads the existing service without locking and derives the new cluster if the domain changed. +func (m *Manager) resolveEffectiveCluster(ctx context.Context, accountID string, svc *service.Service) (string, error) { + existing, err := m.store.GetServiceByID(ctx, store.LockingStrengthNone, accountID, svc.ID) + if err != nil { + return "", err + } + + if existing.Domain == svc.Domain { + return existing.ProxyCluster, nil + } + + if m.clusterDeriver != nil { + derived, err := m.clusterDeriver.DeriveClusterFromDomain(ctx, accountID, svc.Domain) + if err != nil { + log.WithError(err).Warnf("could not derive cluster from domain %s", svc.Domain) + } else { + return derived, nil + } + } + + return existing.ProxyCluster, nil +} + +func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.Store, accountID string, service *service.Service, updateInfo *serviceUpdateInfo, customPorts *bool, effectiveCluster string) error { + existingService, err := transaction.GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, service.ID) + if err != nil { + return err + } + + if existingService.Terminated { + return status.Errorf(status.PermissionDenied, "service is terminated and cannot be updated") + } + + if err := validateProtocolChange(existingService.Mode, service.Mode); err != nil { + return err + } + + updateInfo.oldCluster = existingService.ProxyCluster + updateInfo.domainChanged = existingService.Domain != service.Domain + + if updateInfo.domainChanged { + if err := m.handleDomainChange(ctx, transaction, service, effectiveCluster); err != nil { + return err + } + } else { + service.ProxyCluster = existingService.ProxyCluster + } + + m.preserveExistingAuthSecrets(service, existingService) + if err := validateHeaderAuthValues(service.Auth.HeaderAuths); err != nil { + return err + } + m.preserveServiceMetadata(service, existingService) + m.preserveListenPort(service, existingService) + updateInfo.serviceEnabledChanged = existingService.Enabled != service.Enabled + + // if the service is being updated, and we decide in the future to allow mode update, + // we should reconsider the currently assigned port if not 0 for clusters that don't support custom ports + if err := validateL4PortDiffOnClusterDiff(customPorts, service, existingService); err != nil { + return err + } + + if err := m.ensureL4Port(ctx, transaction, service, customPorts, true); err != nil { + return err + } + + // we can try carrying the previous service port into a new cluster, if this becomes a problem for multiple users, + // we should reconsider adding another check + if err := m.checkPortConflict(ctx, transaction, service); err != nil { + return err + } + + if err := transaction.UpdateService(ctx, service); err != nil { + return fmt.Errorf("update service: %w", err) + } + + return nil +} + +// validateL4PortDiffOnClusterDiff checks if custom L4 ports are configured and validates port changes across clusters. +// It ensures no port changes if custom ports are unsupported for a given cluster and protocol mode. +// Returns an error if validation fails, otherwise returns nil. +func validateL4PortDiffOnClusterDiff(customPorts *bool, newSVC, oldSVC *service.Service) error { + if !service.IsPortBasedProtocol(newSVC.Mode) || (customPorts != nil && *customPorts) { + return nil + } + + if newSVC.ListenPort != 0 && newSVC.ListenPort != oldSVC.ListenPort { + return status.Errorf(status.InvalidArgument, "custom ports not supported on target cluster %s", newSVC.ProxyCluster) + } + + return nil +} + +// handleDomainChange validates the new domain is free inside the transaction +// and applies the pre-resolved cluster (computed outside the tx by +// resolveEffectiveCluster). It must NOT call clusterDeriver here: that talks +// to the main DB pool and would self-deadlock under SQLite (max_open_conns=1) +// because the transaction already holds the only connection. +func (m *Manager) handleDomainChange(ctx context.Context, transaction store.Store, svc *service.Service, effectiveCluster string) error { + if err := m.checkDomainAvailable(ctx, transaction, svc.Domain, svc.ID); err != nil { + return err + } + if effectiveCluster != "" { + svc.ProxyCluster = effectiveCluster + } + return nil +} + +// validateProtocolChange rejects mode changes on update. +// Only empty<->HTTP is allowed; all other transitions are rejected. +func validateProtocolChange(oldMode, newMode string) error { + if newMode == "" || newMode == oldMode { + return nil + } + if isHTTPFamily(oldMode) && isHTTPFamily(newMode) { + return nil + } + return status.Errorf(status.InvalidArgument, "cannot change mode from %q to %q", oldMode, newMode) +} + +func isHTTPFamily(mode string) bool { + return mode == "" || mode == "http" +} + +func (m *Manager) preserveExistingAuthSecrets(svc, existingService *service.Service) { + if svc.Auth.PasswordAuth != nil && svc.Auth.PasswordAuth.Enabled && + existingService.Auth.PasswordAuth != nil && existingService.Auth.PasswordAuth.Enabled && + svc.Auth.PasswordAuth.Password == "" { + svc.Auth.PasswordAuth = existingService.Auth.PasswordAuth + } + + if svc.Auth.PinAuth != nil && svc.Auth.PinAuth.Enabled && + existingService.Auth.PinAuth != nil && existingService.Auth.PinAuth.Enabled && + svc.Auth.PinAuth.Pin == "" { + svc.Auth.PinAuth = existingService.Auth.PinAuth + } + + preserveHeaderAuthHashes(svc.Auth.HeaderAuths, existingService.Auth.HeaderAuths) +} + +// preserveHeaderAuthHashes fills in empty header auth values from the existing +// service so that unchanged secrets are not lost on update. +func preserveHeaderAuthHashes(headers, existing []*service.HeaderAuthConfig) { + if len(headers) == 0 || len(existing) == 0 { + return + } + existingByHeader := make(map[string]string, len(existing)) + for _, h := range existing { + if h != nil && h.Value != "" { + existingByHeader[http.CanonicalHeaderKey(h.Header)] = h.Value + } + } + for _, h := range headers { + if h != nil && h.Enabled && h.Value == "" { + if hash, ok := existingByHeader[http.CanonicalHeaderKey(h.Header)]; ok { + h.Value = hash + } + } + } +} + +// validateHeaderAuthValues checks that all enabled header auths have a value +// (either freshly provided or preserved from the existing service). +func validateHeaderAuthValues(headers []*service.HeaderAuthConfig) error { + for i, h := range headers { + if h != nil && h.Enabled && h.Value == "" { + return status.Errorf(status.InvalidArgument, "header_auths[%d]: value is required", i) + } + } + return nil +} + +func (m *Manager) preserveServiceMetadata(service, existingService *service.Service) { + service.Meta = existingService.Meta + service.SessionPrivateKey = existingService.SessionPrivateKey + service.SessionPublicKey = existingService.SessionPublicKey +} + +func (m *Manager) preserveListenPort(svc, existing *service.Service) { + if existing.ListenPort > 0 && svc.ListenPort == 0 { + svc.ListenPort = existing.ListenPort + svc.PortAutoAssigned = existing.PortAutoAssigned + } +} + +func (m *Manager) sendServiceUpdateNotifications(ctx context.Context, accountID string, s *service.Service, updateInfo *serviceUpdateInfo) { + oidcCfg := m.proxyController.GetOIDCValidationConfig() + + switch { + case updateInfo.domainChanged || updateInfo.oldCluster != s.ProxyCluster: + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Delete, "", oidcCfg), updateInfo.oldCluster) + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Create, "", oidcCfg), s.ProxyCluster) + case !s.Enabled && updateInfo.serviceEnabledChanged: + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Delete, "", oidcCfg), s.ProxyCluster) + case s.Enabled && updateInfo.serviceEnabledChanged: + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Create, "", oidcCfg), s.ProxyCluster) + default: + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Update, "", oidcCfg), s.ProxyCluster) + } +} + +// validateTargetReferences checks that all target IDs reference existing peers or resources in the account. +func validateTargetReferences(ctx context.Context, transaction store.Store, accountID string, targets []*service.Target) error { + for _, target := range targets { + switch target.TargetType { + case service.TargetTypePeer: + if err := validatePeerTarget(ctx, transaction, accountID, target); err != nil { + return err + } + case service.TargetTypeHost, service.TargetTypeSubnet, service.TargetTypeDomain: + if err := validateResourceTarget(ctx, transaction, accountID, target); err != nil { + return err + } + case service.TargetTypeCluster: + if err := validateClusterTarget(target); err != nil { + return err + } + default: + return status.Errorf(status.InvalidArgument, "unknown target type %q for target %q", target.TargetType, target.TargetId) + } + } + return nil +} + +func validateClusterTarget(target *service.Target) error { + if !target.Options.DirectUpstream { + return status.Errorf(status.InvalidArgument, "cluster target %s has direct upstream disabled", target.Host) + } + return nil +} + +func validatePeerTarget(ctx context.Context, transaction store.Store, accountID string, target *service.Target) error { + if _, err := transaction.GetPeerByID(ctx, store.LockingStrengthShare, accountID, target.TargetId); err != nil { + if sErr, ok := status.FromError(err); ok && sErr.Type() == status.NotFound { + return status.Errorf(status.InvalidArgument, "peer target %q not found in account", target.TargetId) + } + return fmt.Errorf("look up peer target %q: %w", target.TargetId, err) + } + return nil +} + +func validateResourceTarget(ctx context.Context, transaction store.Store, accountID string, target *service.Target) error { + resource, err := transaction.GetNetworkResourceByID(ctx, store.LockingStrengthShare, accountID, target.TargetId) + if err != nil { + if sErr, ok := status.FromError(err); ok && sErr.Type() == status.NotFound { + return status.Errorf(status.InvalidArgument, "resource target %q not found in account", target.TargetId) + } + return fmt.Errorf("look up resource target %q: %w", target.TargetId, err) + } + return validateResourceTargetType(target, resource) +} + +// validateResourceTargetType checks that target_type matches the actual network resource type. +func validateResourceTargetType(target *service.Target, resource *resourcetypes.NetworkResource) error { + expected := resourcetypes.NetworkResourceType(target.TargetType) + if resource.Type != expected { + return status.Errorf(status.InvalidArgument, + "target %q has target_type %q but resource is of type %q", + target.TargetId, target.TargetType, resource.Type, + ) + } + return nil +} + +func (m *Manager) DeleteService(ctx context.Context, accountID, userID, serviceID string) error { + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete) + if err != nil { + return status.NewPermissionValidationError(err) + } + if !ok { + return status.NewPermissionDeniedError() + } + + var s *service.Service + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + var err error + s, err = transaction.GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID) + if err != nil { + return err + } + + if err = transaction.DeleteServiceTargets(ctx, accountID, serviceID); err != nil { + return fmt.Errorf("failed to delete targets: %w", err) + } + + if err = transaction.DeleteService(ctx, accountID, serviceID); err != nil { + return fmt.Errorf("failed to delete service: %w", err) + } + + return nil + }) + if err != nil { + return err + } + + m.accountManager.StoreEvent(ctx, userID, serviceID, accountID, activity.ServiceDeleted, s.EventMeta()) + + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Delete, "", m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster) + + m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationDelete}) + + return nil +} + +func (m *Manager) DeleteAllServices(ctx context.Context, accountID, userID string) error { + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete) + if err != nil { + return status.NewPermissionValidationError(err) + } + if !ok { + return status.NewPermissionDeniedError() + } + + var services []*service.Service + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + var err error + services, err = transaction.GetAccountServices(ctx, store.LockingStrengthUpdate, accountID) + if err != nil { + return err + } + + for _, svc := range services { + if err = transaction.DeleteServiceTargets(ctx, accountID, svc.ID); err != nil { + return fmt.Errorf("failed to delete service targets: %w", err) + } + + if err = transaction.DeleteService(ctx, accountID, svc.ID); err != nil { + return fmt.Errorf("failed to delete service: %w", err) + } + } + + return nil + }) + if err != nil { + return err + } + + oidcCfg := m.proxyController.GetOIDCValidationConfig() + + for _, svc := range services { + m.accountManager.StoreEvent(ctx, userID, svc.ID, accountID, activity.ServiceDeleted, svc.EventMeta()) + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, svc.ToProtoMapping(service.Delete, "", oidcCfg), svc.ProxyCluster) + } + + m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationDelete}) + + return nil +} + +// SetCertificateIssuedAt sets the certificate issued timestamp to the current time. +// Call this when receiving a gRPC notification that the certificate was issued. +func (m *Manager) SetCertificateIssuedAt(ctx context.Context, accountID, serviceID string) error { + return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + service, err := transaction.GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID) + if err != nil { + return fmt.Errorf("failed to get service: %w", err) + } + + now := time.Now() + service.Meta.CertificateIssuedAt = &now + + if err = transaction.UpdateService(ctx, service); err != nil { + return fmt.Errorf("failed to update service certificate timestamp: %w", err) + } + + return nil + }) +} + +// SetStatus updates the status of the service (e.g., "active", "tunnel_not_created", etc.) +func (m *Manager) SetStatus(ctx context.Context, accountID, serviceID string, status service.Status) error { + return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + service, err := transaction.GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID) + if err != nil { + return fmt.Errorf("failed to get service: %w", err) + } + + service.Meta.Status = string(status) + + if err = transaction.UpdateService(ctx, service); err != nil { + return fmt.Errorf("failed to update service status: %w", err) + } + + return nil + }) +} + +func (m *Manager) ReloadService(ctx context.Context, accountID, serviceID string) error { + s, err := m.store.GetServiceByID(ctx, store.LockingStrengthNone, accountID, serviceID) + if err != nil { + return fmt.Errorf("failed to get service: %w", err) + } + + err = m.replaceHostByLookup(ctx, accountID, s) + if err != nil { + return fmt.Errorf("failed to replace host by lookup for service %s: %w", s.ID, err) + } + + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Update, "", m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster) + + m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationUpdate}) + + return nil +} + +func (m *Manager) ReloadAllServicesForAccount(ctx context.Context, accountID string) error { + services, err := m.store.GetAccountServices(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return fmt.Errorf("failed to get services: %w", err) + } + + oidcCfg := m.proxyController.GetOIDCValidationConfig() + + for _, s := range services { + err = m.replaceHostByLookup(ctx, accountID, s) + if err != nil { + return fmt.Errorf("failed to replace host by lookup for service %s: %w", s.ID, err) + } + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Update, "", oidcCfg), s.ProxyCluster) + } + + return nil +} + +func (m *Manager) GetGlobalServices(ctx context.Context) ([]*service.Service, error) { + services, err := m.store.GetServices(ctx, store.LockingStrengthNone) + if err != nil { + return nil, fmt.Errorf("failed to get services: %w", err) + } + + for _, service := range services { + err = m.replaceHostByLookup(ctx, service.AccountID, service) + if err != nil { + return nil, fmt.Errorf("failed to replace host by lookup for service %s: %w", service.ID, err) + } + } + + return services, nil +} + +func (m *Manager) GetServiceByID(ctx context.Context, accountID, serviceID string) (*service.Service, error) { + service, err := m.store.GetServiceByID(ctx, store.LockingStrengthNone, accountID, serviceID) + if err != nil { + return nil, fmt.Errorf("failed to get service: %w", err) + } + + err = m.replaceHostByLookup(ctx, accountID, service) + if err != nil { + return nil, fmt.Errorf("failed to replace host by lookup for service %s: %w", service.ID, err) + } + + return service, nil +} + +func (m *Manager) GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error) { + services, err := m.store.GetAccountServices(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, fmt.Errorf("failed to get services: %w", err) + } + + for _, service := range services { + err = m.replaceHostByLookup(ctx, accountID, service) + if err != nil { + return nil, fmt.Errorf("failed to replace host by lookup for service %s: %w", service.ID, err) + } + } + + return services, nil +} + +func (m *Manager) GetServiceByDomain(ctx context.Context, domain string) (*service.Service, error) { + return m.store.GetServiceByDomain(ctx, domain) +} + +func (m *Manager) GetServiceIDByTargetID(ctx context.Context, accountID string, resourceID string) (string, error) { + target, err := m.store.GetServiceTargetByTargetID(ctx, store.LockingStrengthNone, accountID, resourceID) + if err != nil { + if s, ok := status.FromError(err); ok && s.Type() == status.NotFound { + return "", nil + } + return "", fmt.Errorf("failed to get service target by resource ID: %w", err) + } + + if target == nil { + return "", nil + } + + return target.ServiceID, nil +} + +// validateExposePermission checks whether the peer is allowed to use the expose feature. +// It verifies the account has peer expose enabled and that the peer belongs to an allowed group. +func (m *Manager) validateExposePermission(ctx context.Context, accountID, peerID string) error { + settings, err := m.store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + if err != nil { + log.WithContext(ctx).Errorf("failed to get account settings: %v", err) + return status.Errorf(status.Internal, "get account settings: %v", err) + } + + if !settings.PeerExposeEnabled { + return status.Errorf(status.PermissionDenied, "peer expose is not enabled for this account") + } + + if len(settings.PeerExposeGroups) == 0 { + return status.Errorf(status.PermissionDenied, "no group is set for peer expose") + } + + peerGroupIDs, err := m.store.GetPeerGroupIDs(ctx, store.LockingStrengthNone, accountID, peerID) + if err != nil { + log.WithContext(ctx).Errorf("failed to get peer group IDs: %v", err) + return status.Errorf(status.Internal, "get peer groups: %v", err) + } + + for _, pg := range peerGroupIDs { + if slices.Contains(settings.PeerExposeGroups, pg) { + return nil + } + } + + return status.Errorf(status.PermissionDenied, "peer is not in an allowed expose group") +} + +func (m *Manager) resolveDefaultDomain(serviceName string) (string, error) { + return m.buildRandomDomain(serviceName) +} + +// CreateServiceFromPeer creates a service initiated by a peer expose request. +// It validates the request, checks expose permissions, enforces the per-peer limit, +// creates the service, and tracks it for TTL-based reaping. +func (m *Manager) CreateServiceFromPeer(ctx context.Context, accountID, peerID string, req *service.ExposeServiceRequest) (*service.ExposeServiceResponse, error) { + if err := req.Validate(); err != nil { + return nil, status.Errorf(status.InvalidArgument, "validate expose request: %v", err) + } + + if err := m.validateExposePermission(ctx, accountID, peerID); err != nil { + return nil, err + } + + serviceName, err := service.GenerateExposeName(req.NamePrefix) + if err != nil { + return nil, status.Errorf(status.InvalidArgument, "generate service name: %v", err) + } + + svc := req.ToService(accountID, peerID, serviceName) + svc.Source = service.SourceEphemeral + + if svc.Domain == "" { + domain, err := m.resolveDefaultDomain(svc.Name) + if err != nil { + return nil, err + } + svc.Domain = domain + } + + if svc.Auth.BearerAuth != nil && svc.Auth.BearerAuth.Enabled { + groupIDs, err := m.getGroupIDsFromNames(ctx, accountID, svc.Auth.BearerAuth.DistributionGroups) + if err != nil { + return nil, fmt.Errorf("get group ids for service %s: %w", svc.Name, err) + } + svc.Auth.BearerAuth.DistributionGroups = groupIDs + } + + if err := m.initializeServiceForCreate(ctx, accountID, svc); err != nil { + return nil, err + } + + peer, err := m.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) + if err != nil { + return nil, err + } + + svc.SourcePeer = peerID + + now := time.Now() + svc.Meta.LastRenewedAt = &now + + if err := m.persistNewEphemeralService(ctx, accountID, peerID, svc); err != nil { + return nil, err + } + + meta := addPeerInfoToEventMeta(svc.EventMeta(), peer) + m.accountManager.StoreEvent(ctx, peerID, svc.ID, accountID, activity.PeerServiceExposed, meta) + + if err := m.replaceHostByLookup(ctx, accountID, svc); err != nil { + return nil, fmt.Errorf("replace host by lookup for service %s: %w", svc.ID, err) + } + + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, svc.ToProtoMapping(service.Create, "", m.proxyController.GetOIDCValidationConfig()), svc.ProxyCluster) + m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationCreate}) + + serviceURL := "https://" + svc.Domain + if service.IsL4Protocol(svc.Mode) { + serviceURL = fmt.Sprintf("%s://%s", svc.Mode, net.JoinHostPort(svc.Domain, strconv.Itoa(int(svc.ListenPort)))) + } + + return &service.ExposeServiceResponse{ + ServiceName: svc.Name, + ServiceURL: serviceURL, + Domain: svc.Domain, + PortAutoAssigned: svc.PortAutoAssigned, + }, nil +} + +func (m *Manager) getGroupIDsFromNames(ctx context.Context, accountID string, groupNames []string) ([]string, error) { + if len(groupNames) == 0 { + return []string{}, fmt.Errorf("no group names provided") + } + groupIDs := make([]string, 0, len(groupNames)) + for _, groupName := range groupNames { + g, err := m.accountManager.GetGroupByName(ctx, groupName, accountID, activity.SystemInitiator) + if err != nil { + return nil, fmt.Errorf("failed to get group by name %s: %w", groupName, err) + } + groupIDs = append(groupIDs, g.ID) + } + return groupIDs, nil +} + +func (m *Manager) getDefaultClusterDomain() (string, error) { + if m.clusterDeriver == nil { + return "", fmt.Errorf("unable to get cluster domain") + } + clusterDomains := m.clusterDeriver.GetClusterDomains() + if len(clusterDomains) == 0 { + return "", fmt.Errorf("no cluster domains available") + } + return clusterDomains[rand.IntN(len(clusterDomains))], nil +} + +func (m *Manager) buildRandomDomain(name string) (string, error) { + domain, err := m.getDefaultClusterDomain() + if err != nil { + return "", err + } + return name + "." + domain, nil +} + +// RenewServiceFromPeer updates the DB timestamp for the peer's ephemeral service. +func (m *Manager) RenewServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error { + return m.store.RenewEphemeralService(ctx, accountID, peerID, serviceID) +} + +// StopServiceFromPeer stops a peer's active expose session by deleting the service from the DB. +func (m *Manager) StopServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string) error { + if err := m.deleteServiceFromPeer(ctx, accountID, peerID, serviceID, false); err != nil { + log.WithContext(ctx).Errorf("failed to delete peer-exposed service %s: %v", serviceID, err) + return err + } + return nil +} + +// deleteServiceFromPeer deletes a peer-initiated service identified by service ID. +// When expired is true, the activity is recorded as PeerServiceExposeExpired instead of PeerServiceUnexposed. +func (m *Manager) deleteServiceFromPeer(ctx context.Context, accountID, peerID, serviceID string, expired bool) error { + activityCode := activity.PeerServiceUnexposed + if expired { + activityCode = activity.PeerServiceExposeExpired + } + return m.deletePeerService(ctx, accountID, peerID, serviceID, activityCode) +} + +func (m *Manager) deletePeerService(ctx context.Context, accountID, peerID, serviceID string, activityCode activity.Activity) error { + var svc *service.Service + err := m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + var err error + svc, err = transaction.GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID) + if err != nil { + return err + } + + if svc.Source != service.SourceEphemeral { + return status.Errorf(status.PermissionDenied, "cannot delete API-created service via peer expose") + } + + if svc.SourcePeer != peerID { + return status.Errorf(status.PermissionDenied, "cannot delete service exposed by another peer") + } + + if err = transaction.DeleteServiceTargets(ctx, accountID, serviceID); err != nil { + return fmt.Errorf("delete service targets: %w", err) + } + + if err = transaction.DeleteService(ctx, accountID, serviceID); err != nil { + return fmt.Errorf("delete service: %w", err) + } + + return nil + }) + if err != nil { + return err + } + + peer, err := m.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) + if err != nil { + log.WithContext(ctx).Debugf("failed to get peer %s for event metadata: %v", peerID, err) + peer = nil + } + + meta := addPeerInfoToEventMeta(svc.EventMeta(), peer) + + m.accountManager.StoreEvent(ctx, peerID, serviceID, accountID, activityCode, meta) + + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, svc.ToProtoMapping(service.Delete, "", m.proxyController.GetOIDCValidationConfig()), svc.ProxyCluster) + + m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationDelete}) + + return nil +} + +// deleteExpiredPeerService deletes an ephemeral service by ID after re-checking +// that it is still expired under a row lock. This prevents deleting a service +// that was renewed between the batch query and this delete, and ensures only one +// management instance processes the deletion +func (m *Manager) deleteExpiredPeerService(ctx context.Context, accountID, peerID, serviceID string) error { + var svc *service.Service + deleted := false + err := m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + var err error + svc, err = transaction.GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID) + if err != nil { + return err + } + + if svc.Source != service.SourceEphemeral || svc.SourcePeer != peerID { + return status.Errorf(status.PermissionDenied, "service does not match expected ephemeral owner") + } + + if svc.Meta.LastRenewedAt != nil && time.Since(*svc.Meta.LastRenewedAt) <= exposeTTL { + return nil + } + + if err = transaction.DeleteServiceTargets(ctx, accountID, serviceID); err != nil { + return fmt.Errorf("delete service targets: %w", err) + } + + if err = transaction.DeleteService(ctx, accountID, serviceID); err != nil { + return fmt.Errorf("delete service: %w", err) + } + deleted = true + + return nil + }) + if err != nil { + return err + } + + if !deleted { + return nil + } + + peer, err := m.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) + if err != nil { + log.WithContext(ctx).Debugf("failed to get peer %s for event metadata: %v", peerID, err) + peer = nil + } + + meta := addPeerInfoToEventMeta(svc.EventMeta(), peer) + m.accountManager.StoreEvent(ctx, peerID, serviceID, accountID, activity.PeerServiceExposeExpired, meta) + m.proxyController.SendServiceUpdateToCluster(ctx, accountID, svc.ToProtoMapping(service.Delete, "", m.proxyController.GetOIDCValidationConfig()), svc.ProxyCluster) + m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationDelete}) + + return nil +} + +func addPeerInfoToEventMeta(meta map[string]any, peer *nbpeer.Peer) map[string]any { + if peer == nil { + return meta + } + meta["peer_name"] = peer.Name + if peer.IP.IsValid() { + meta["peer_ip"] = peer.IP.String() + } + return meta +} diff --git a/management/internals/modules/reverseproxy/service/manager/manager_test.go b/management/internals/modules/reverseproxy/service/manager/manager_test.go new file mode 100644 index 000000000..29a117921 --- /dev/null +++ b/management/internals/modules/reverseproxy/service/manager/manager_test.go @@ -0,0 +1,1479 @@ +package manager + +import ( + "context" + "errors" + "net/netip" + "testing" + "time" + + cachestore "github.com/eko/gocache/lib/v4/store" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/metric/noop" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/server/account" + "github.com/netbirdio/netbird/management/server/activity" + nbcache "github.com/netbirdio/netbird/management/server/cache" + "github.com/netbirdio/netbird/management/server/mock_server" + resourcetypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +func testCacheStore(t *testing.T) cachestore.StoreInterface { + t.Helper() + s, err := nbcache.NewStore(context.Background(), 30*time.Minute, 10*time.Minute, 100) + require.NoError(t, err) + return s +} + +func TestInitializeServiceForCreate(t *testing.T) { + ctx := context.Background() + accountID := "test-account" + + t.Run("successful initialization without cluster deriver", func(t *testing.T) { + mgr := &Manager{ + clusterDeriver: nil, + } + + service := &rpservice.Service{ + Domain: "example.com", + Auth: rpservice.AuthConfig{}, + } + + err := mgr.initializeServiceForCreate(ctx, accountID, service) + + assert.NoError(t, err) + assert.Equal(t, accountID, service.AccountID) + assert.Empty(t, service.ProxyCluster, "proxy cluster should be empty when no deriver") + assert.NotEmpty(t, service.ID, "service ID should be initialized") + assert.NotEmpty(t, service.SessionPrivateKey, "session private key should be generated") + assert.NotEmpty(t, service.SessionPublicKey, "session public key should be generated") + }) + + t.Run("verifies session keys are different", func(t *testing.T) { + mgr := &Manager{ + clusterDeriver: nil, + } + + service1 := &rpservice.Service{Domain: "test1.com", Auth: rpservice.AuthConfig{}} + service2 := &rpservice.Service{Domain: "test2.com", Auth: rpservice.AuthConfig{}} + + err1 := mgr.initializeServiceForCreate(ctx, accountID, service1) + err2 := mgr.initializeServiceForCreate(ctx, accountID, service2) + + assert.NoError(t, err1) + assert.NoError(t, err2) + assert.NotEqual(t, service1.SessionPrivateKey, service2.SessionPrivateKey, "private keys should be unique") + assert.NotEqual(t, service1.SessionPublicKey, service2.SessionPublicKey, "public keys should be unique") + }) +} + +func TestCheckDomainAvailable(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + domain string + excludeServiceID string + setupMock func(*store.MockStore) + expectedError bool + errorType status.Type + }{ + { + name: "domain available - not found", + domain: "available.com", + excludeServiceID: "", + setupMock: func(ms *store.MockStore) { + ms.EXPECT(). + GetServiceByDomain(ctx, "available.com"). + Return(nil, status.Errorf(status.NotFound, "not found")) + }, + expectedError: false, + }, + { + name: "domain already exists", + domain: "exists.com", + excludeServiceID: "", + setupMock: func(ms *store.MockStore) { + ms.EXPECT(). + GetServiceByDomain(ctx, "exists.com"). + Return(&rpservice.Service{ID: "existing-id", Domain: "exists.com"}, nil) + }, + expectedError: true, + errorType: status.AlreadyExists, + }, + { + name: "domain exists but excluded (same ID)", + domain: "exists.com", + excludeServiceID: "service-123", + setupMock: func(ms *store.MockStore) { + ms.EXPECT(). + GetServiceByDomain(ctx, "exists.com"). + Return(&rpservice.Service{ID: "service-123", Domain: "exists.com"}, nil) + }, + expectedError: false, + }, + { + name: "domain exists with different ID", + domain: "exists.com", + excludeServiceID: "service-456", + setupMock: func(ms *store.MockStore) { + ms.EXPECT(). + GetServiceByDomain(ctx, "exists.com"). + Return(&rpservice.Service{ID: "service-123", Domain: "exists.com"}, nil) + }, + expectedError: true, + errorType: status.AlreadyExists, + }, + { + name: "store error (non-NotFound)", + domain: "error.com", + excludeServiceID: "", + setupMock: func(ms *store.MockStore) { + ms.EXPECT(). + GetServiceByDomain(ctx, "error.com"). + Return(nil, errors.New("database error")) + }, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + tt.setupMock(mockStore) + + mgr := &Manager{} + err := mgr.checkDomainAvailable(ctx, mockStore, tt.domain, tt.excludeServiceID) + + if tt.expectedError { + require.Error(t, err) + if tt.errorType != 0 { + sErr, ok := status.FromError(err) + require.True(t, ok, "error should be a status error") + assert.Equal(t, tt.errorType, sErr.Type()) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestCheckDomainAvailable_EdgeCases(t *testing.T) { + ctx := context.Background() + + t.Run("empty domain", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT(). + GetServiceByDomain(ctx, ""). + Return(nil, status.Errorf(status.NotFound, "not found")) + + mgr := &Manager{} + err := mgr.checkDomainAvailable(ctx, mockStore, "", "") + + assert.NoError(t, err) + }) + + t.Run("empty exclude ID with existing service", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT(). + GetServiceByDomain(ctx, "test.com"). + Return(&rpservice.Service{ID: "some-id", Domain: "test.com"}, nil) + + mgr := &Manager{} + err := mgr.checkDomainAvailable(ctx, mockStore, "test.com", "") + + assert.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.AlreadyExists, sErr.Type()) + }) + + t.Run("nil existing service with nil error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT(). + GetServiceByDomain(ctx, "nil.com"). + Return(nil, nil) + + mgr := &Manager{} + err := mgr.checkDomainAvailable(ctx, mockStore, "nil.com", "") + + assert.NoError(t, err) + }) +} + +func TestPersistNewService(t *testing.T) { + ctx := context.Background() + accountID := "test-account" + + t.Run("successful service creation with no targets", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + service := &rpservice.Service{ + ID: "service-123", + Domain: "new.com", + Targets: []*rpservice.Target{}, + } + + // Mock ExecuteInTransaction to execute the function immediately + mockStore.EXPECT(). + ExecuteInTransaction(ctx, gomock.Any()). + DoAndReturn(func(ctx context.Context, fn func(store.Store) error) error { + // Create another mock for the transaction + txMock := store.NewMockStore(ctrl) + txMock.EXPECT(). + GetServiceByDomain(ctx, "new.com"). + Return(nil, status.Errorf(status.NotFound, "not found")) + txMock.EXPECT(). + CreateService(ctx, service). + Return(nil) + + return fn(txMock) + }) + + mgr := &Manager{store: mockStore} + err := mgr.persistNewService(ctx, accountID, service) + + assert.NoError(t, err) + }) + + t.Run("domain already exists", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + service := &rpservice.Service{ + ID: "service-123", + Domain: "existing.com", + Targets: []*rpservice.Target{}, + } + + mockStore.EXPECT(). + ExecuteInTransaction(ctx, gomock.Any()). + DoAndReturn(func(ctx context.Context, fn func(store.Store) error) error { + txMock := store.NewMockStore(ctrl) + txMock.EXPECT(). + GetServiceByDomain(ctx, "existing.com"). + Return(&rpservice.Service{ID: "other-id", Domain: "existing.com"}, nil) + + return fn(txMock) + }) + + mgr := &Manager{store: mockStore} + err := mgr.persistNewService(ctx, accountID, service) + + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.AlreadyExists, sErr.Type()) + }) +} +func TestPreserveExistingAuthSecrets(t *testing.T) { + mgr := &Manager{} + + t.Run("preserve password when empty", func(t *testing.T) { + existing := &rpservice.Service{ + Auth: rpservice.AuthConfig{ + PasswordAuth: &rpservice.PasswordAuthConfig{ + Enabled: true, + Password: "hashed-password", + }, + }, + } + + updated := &rpservice.Service{ + Auth: rpservice.AuthConfig{ + PasswordAuth: &rpservice.PasswordAuthConfig{ + Enabled: true, + Password: "", + }, + }, + } + + mgr.preserveExistingAuthSecrets(updated, existing) + + assert.Equal(t, existing.Auth.PasswordAuth, updated.Auth.PasswordAuth) + }) + + t.Run("preserve pin when empty", func(t *testing.T) { + existing := &rpservice.Service{ + Auth: rpservice.AuthConfig{ + PinAuth: &rpservice.PINAuthConfig{ + Enabled: true, + Pin: "hashed-pin", + }, + }, + } + + updated := &rpservice.Service{ + Auth: rpservice.AuthConfig{ + PinAuth: &rpservice.PINAuthConfig{ + Enabled: true, + Pin: "", + }, + }, + } + + mgr.preserveExistingAuthSecrets(updated, existing) + + assert.Equal(t, existing.Auth.PinAuth, updated.Auth.PinAuth) + }) + + t.Run("do not preserve when password is provided", func(t *testing.T) { + existing := &rpservice.Service{ + Auth: rpservice.AuthConfig{ + PasswordAuth: &rpservice.PasswordAuthConfig{ + Enabled: true, + Password: "old-password", + }, + }, + } + + updated := &rpservice.Service{ + Auth: rpservice.AuthConfig{ + PasswordAuth: &rpservice.PasswordAuthConfig{ + Enabled: true, + Password: "new-password", + }, + }, + } + + mgr.preserveExistingAuthSecrets(updated, existing) + + assert.Equal(t, "new-password", updated.Auth.PasswordAuth.Password) + assert.NotEqual(t, existing.Auth.PasswordAuth, updated.Auth.PasswordAuth) + }) +} + +func TestPreserveServiceMetadata(t *testing.T) { + mgr := &Manager{} + + existing := &rpservice.Service{ + Meta: rpservice.Meta{ + CertificateIssuedAt: func() *time.Time { t := time.Now(); return &t }(), + Status: "active", + }, + SessionPrivateKey: "private-key", + SessionPublicKey: "public-key", + } + + updated := &rpservice.Service{ + Domain: "updated.com", + } + + mgr.preserveServiceMetadata(updated, existing) + + assert.Equal(t, existing.Meta, updated.Meta) + assert.Equal(t, existing.SessionPrivateKey, updated.SessionPrivateKey) + assert.Equal(t, existing.SessionPublicKey, updated.SessionPublicKey) +} + +func TestDeletePeerService_SourcePeerValidation(t *testing.T) { + ctx := context.Background() + accountID := "test-account" + ownerPeerID := "peer-owner" + otherPeerID := "peer-other" + serviceID := "service-123" + + testPeer := &nbpeer.Peer{ + ID: ownerPeerID, + Name: "test-peer", + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), + } + + newEphemeralService := func() *rpservice.Service { + return &rpservice.Service{ + ID: serviceID, + AccountID: accountID, + Name: "test-service", + Domain: "test.example.com", + Source: rpservice.SourceEphemeral, + SourcePeer: ownerPeerID, + } + } + + newPermanentService := func() *rpservice.Service { + return &rpservice.Service{ + ID: serviceID, + AccountID: accountID, + Name: "api-service", + Domain: "api.example.com", + Source: rpservice.SourcePermanent, + } + } + + newProxyServer := func(t *testing.T) *nbgrpc.ProxyServiceServer { + t.Helper() + tokenStore := nbgrpc.NewOneTimeTokenStore(context.Background(), testCacheStore(t)) + pkceStore := nbgrpc.NewPKCEVerifierStore(context.Background(), testCacheStore(t)) + srv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil) + return srv + } + + t.Run("owner peer can delete own service", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + var storedActivity activity.Activity + mockStore := store.NewMockStore(ctrl) + mockAccountMgr := &mock_server.MockAccountManager{ + StoreEventFunc: func(_ context.Context, _, _, _ string, activityID activity.ActivityDescriber, _ map[string]any) { + storedActivity = activityID.(activity.Activity) + }, + UpdateAccountPeersFunc: func(_ context.Context, _ string, _ types.UpdateReason) {}, + } + + mockStore.EXPECT(). + ExecuteInTransaction(ctx, gomock.Any()). + DoAndReturn(func(ctx context.Context, fn func(store.Store) error) error { + txMock := store.NewMockStore(ctrl) + txMock.EXPECT(). + GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID). + Return(newEphemeralService(), nil) + txMock.EXPECT(). + DeleteServiceTargets(ctx, accountID, serviceID). + Return(nil) + txMock.EXPECT(). + DeleteService(ctx, accountID, serviceID). + Return(nil) + return fn(txMock) + }) + mockStore.EXPECT(). + GetPeerByID(ctx, store.LockingStrengthNone, accountID, ownerPeerID). + Return(testPeer, nil) + + mgr := &Manager{ + store: mockStore, + accountManager: mockAccountMgr, + proxyController: func() proxy.Controller { + c, err := proxymanager.NewGRPCController(newProxyServer(t), noop.NewMeterProvider().Meter("")) + require.NoError(t, err) + return c + }(), + } + + err := mgr.deletePeerService(ctx, accountID, ownerPeerID, serviceID, activity.PeerServiceUnexposed) + require.NoError(t, err) + assert.Equal(t, activity.PeerServiceUnexposed, storedActivity, "should store unexposed activity") + }) + + t.Run("different peer cannot delete service", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + + mockStore.EXPECT(). + ExecuteInTransaction(ctx, gomock.Any()). + DoAndReturn(func(ctx context.Context, fn func(store.Store) error) error { + txMock := store.NewMockStore(ctrl) + txMock.EXPECT(). + GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID). + Return(newEphemeralService(), nil) + return fn(txMock) + }) + + mgr := &Manager{ + store: mockStore, + } + + err := mgr.deletePeerService(ctx, accountID, otherPeerID, serviceID, activity.PeerServiceUnexposed) + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok, "should be a status error") + assert.Equal(t, status.PermissionDenied, sErr.Type(), "should be permission denied") + assert.Contains(t, err.Error(), "another peer") + }) + + t.Run("cannot delete API-created service", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStore := store.NewMockStore(ctrl) + + mockStore.EXPECT(). + ExecuteInTransaction(ctx, gomock.Any()). + DoAndReturn(func(ctx context.Context, fn func(store.Store) error) error { + txMock := store.NewMockStore(ctrl) + txMock.EXPECT(). + GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID). + Return(newPermanentService(), nil) + return fn(txMock) + }) + + mgr := &Manager{ + store: mockStore, + } + + err := mgr.deletePeerService(ctx, accountID, ownerPeerID, serviceID, activity.PeerServiceUnexposed) + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok, "should be a status error") + assert.Equal(t, status.PermissionDenied, sErr.Type(), "should be permission denied") + assert.Contains(t, err.Error(), "API-created") + }) + + t.Run("expire uses correct activity code", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + var storedActivity activity.Activity + mockStore := store.NewMockStore(ctrl) + mockAccountMgr := &mock_server.MockAccountManager{ + StoreEventFunc: func(_ context.Context, _, _, _ string, activityID activity.ActivityDescriber, _ map[string]any) { + storedActivity = activityID.(activity.Activity) + }, + UpdateAccountPeersFunc: func(_ context.Context, _ string, _ types.UpdateReason) {}, + } + + mockStore.EXPECT(). + ExecuteInTransaction(ctx, gomock.Any()). + DoAndReturn(func(ctx context.Context, fn func(store.Store) error) error { + txMock := store.NewMockStore(ctrl) + txMock.EXPECT(). + GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID). + Return(newEphemeralService(), nil) + txMock.EXPECT(). + DeleteServiceTargets(ctx, accountID, serviceID). + Return(nil) + txMock.EXPECT(). + DeleteService(ctx, accountID, serviceID). + Return(nil) + return fn(txMock) + }) + mockStore.EXPECT(). + GetPeerByID(ctx, store.LockingStrengthNone, accountID, ownerPeerID). + Return(testPeer, nil) + + mgr := &Manager{ + store: mockStore, + accountManager: mockAccountMgr, + proxyController: func() proxy.Controller { + c, err := proxymanager.NewGRPCController(newProxyServer(t), noop.NewMeterProvider().Meter("")) + require.NoError(t, err) + return c + }(), + } + + err := mgr.deletePeerService(ctx, accountID, ownerPeerID, serviceID, activity.PeerServiceExposeExpired) + require.NoError(t, err) + assert.Equal(t, activity.PeerServiceExposeExpired, storedActivity, "should store expired activity") + }) + + t.Run("event meta includes peer info", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + var storedMeta map[string]any + mockStore := store.NewMockStore(ctrl) + mockAccountMgr := &mock_server.MockAccountManager{ + StoreEventFunc: func(_ context.Context, _, _, _ string, _ activity.ActivityDescriber, meta map[string]any) { + storedMeta = meta + }, + UpdateAccountPeersFunc: func(_ context.Context, _ string, _ types.UpdateReason) {}, + } + + mockStore.EXPECT(). + ExecuteInTransaction(ctx, gomock.Any()). + DoAndReturn(func(ctx context.Context, fn func(store.Store) error) error { + txMock := store.NewMockStore(ctrl) + txMock.EXPECT(). + GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, serviceID). + Return(newEphemeralService(), nil) + txMock.EXPECT(). + DeleteServiceTargets(ctx, accountID, serviceID). + Return(nil) + txMock.EXPECT(). + DeleteService(ctx, accountID, serviceID). + Return(nil) + return fn(txMock) + }) + mockStore.EXPECT(). + GetPeerByID(ctx, store.LockingStrengthNone, accountID, ownerPeerID). + Return(testPeer, nil) + + mgr := &Manager{ + store: mockStore, + accountManager: mockAccountMgr, + proxyController: func() proxy.Controller { + c, err := proxymanager.NewGRPCController(newProxyServer(t), noop.NewMeterProvider().Meter("")) + require.NoError(t, err) + return c + }(), + } + + err := mgr.deletePeerService(ctx, accountID, ownerPeerID, serviceID, activity.PeerServiceUnexposed) + require.NoError(t, err) + require.NotNil(t, storedMeta) + assert.Equal(t, "test-peer", storedMeta["peer_name"], "meta should contain peer name") + assert.Equal(t, "100.64.0.1", storedMeta["peer_ip"], "meta should contain peer IP") + assert.Equal(t, "test-service", storedMeta["name"], "meta should contain service name") + assert.Equal(t, "test.example.com", storedMeta["domain"], "meta should contain service domain") + }) +} + +// testClusterDeriver is a minimal ClusterDeriver that returns a fixed domain list. +type testClusterDeriver struct { + domains []string +} + +func (d *testClusterDeriver) DeriveClusterFromDomain(_ context.Context, _, domain string) (string, error) { + return "test-cluster", nil +} + +func (d *testClusterDeriver) GetClusterDomains() []string { + return d.domains +} + +const ( + testAccountID = "test-account" + testPeerID = "test-peer-1" + testGroupID = "test-group-1" + testUserID = "test-user" +) + +// setupIntegrationTest creates a real SQLite store with seeded test data for integration tests. +func setupIntegrationTest(t *testing.T) (*Manager, store.Store) { + t.Helper() + + ctx := context.Background() + testStore, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanup) + + err = testStore.SaveAccount(ctx, &types.Account{ + Id: testAccountID, + CreatedBy: testUserID, + Settings: &types.Settings{ + PeerExposeEnabled: true, + PeerExposeGroups: []string{testGroupID}, + }, + Users: map[string]*types.User{ + testUserID: { + Id: testUserID, + AccountID: testAccountID, + Role: types.UserRoleAdmin, + }, + }, + Peers: map[string]*nbpeer.Peer{ + testPeerID: { + ID: testPeerID, + AccountID: testAccountID, + Key: "test-key", + DNSLabel: "test-peer", + Name: "test-peer", + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, + Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, + }, + }, + Groups: map[string]*types.Group{ + testGroupID: { + ID: testGroupID, + AccountID: testAccountID, + Name: "Expose Group", + }, + }, + }) + require.NoError(t, err) + + err = testStore.AddPeerToGroup(ctx, testAccountID, testPeerID, testGroupID) + require.NoError(t, err) + + permsMgr := permissions.NewManager(testStore) + + accountMgr := &mock_server.MockAccountManager{ + StoreEventFunc: func(_ context.Context, _, _, _ string, _ activity.ActivityDescriber, _ map[string]any) {}, + UpdateAccountPeersFunc: func(_ context.Context, _ string, _ types.UpdateReason) {}, + GetGroupByNameFunc: func(ctx context.Context, groupName, accountID, userID string) (*types.Group, error) { + return testStore.GetGroupByName(ctx, store.LockingStrengthNone, accountID, groupName) + }, + } + + tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, testCacheStore(t)) + pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, testCacheStore(t)) + proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil) + + proxyController, err := proxymanager.NewGRPCController(proxySrv, noop.NewMeterProvider().Meter("")) + require.NoError(t, err) + + mgr := &Manager{ + store: testStore, + accountManager: accountMgr, + permissionsManager: permsMgr, + proxyController: proxyController, + clusterDeriver: &testClusterDeriver{ + domains: []string{"test.netbird.io"}, + }, + } + mgr.exposeReaper = &exposeReaper{manager: mgr} + + return mgr, testStore +} + +func Test_validateExposePermission(t *testing.T) { + ctx := context.Background() + + t.Run("allowed when peer is in expose group", func(t *testing.T) { + mgr, _ := setupIntegrationTest(t) + err := mgr.validateExposePermission(ctx, testAccountID, testPeerID) + assert.NoError(t, err) + }) + + t.Run("denied when peer is not in expose group", func(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + + // Add a peer that is NOT in the expose group + otherPeerID := "other-peer" + err := testStore.AddPeerToAccount(ctx, &nbpeer.Peer{ + ID: otherPeerID, + AccountID: testAccountID, + Key: "other-key", + DNSLabel: "other-peer", + Name: "other-peer", + IP: netip.MustParseAddr("100.64.0.2"), + IPv6: netip.MustParseAddr("fd00::2"), + Status: &nbpeer.PeerStatus{LastSeen: time.Now()}, + Meta: nbpeer.PeerSystemMeta{Hostname: "other-peer"}, + }) + require.NoError(t, err) + + err = mgr.validateExposePermission(ctx, testAccountID, otherPeerID) + require.Error(t, err) + assert.Contains(t, err.Error(), "not in an allowed expose group") + }) + + t.Run("denied when expose is disabled", func(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + + // Disable peer expose + s, err := testStore.GetAccountSettings(ctx, store.LockingStrengthNone, testAccountID) + require.NoError(t, err) + s.PeerExposeEnabled = false + err = testStore.SaveAccountSettings(ctx, testAccountID, s) + require.NoError(t, err) + + err = mgr.validateExposePermission(ctx, testAccountID, testPeerID) + require.Error(t, err) + assert.Contains(t, err.Error(), "not enabled") + }) + + t.Run("disallowed when no groups configured", func(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + + // Enable expose with empty groups — no groups configured means no peer is allowed + s, err := testStore.GetAccountSettings(ctx, store.LockingStrengthNone, testAccountID) + require.NoError(t, err) + s.PeerExposeGroups = []string{} + err = testStore.SaveAccountSettings(ctx, testAccountID, s) + require.NoError(t, err) + + err = mgr.validateExposePermission(ctx, testAccountID, testPeerID) + assert.Error(t, err) + }) + + t.Run("error when store returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT().GetAccountSettings(gomock.Any(), gomock.Any(), testAccountID).Return(nil, errors.New("store error")) + mgr := &Manager{store: mockStore} + err := mgr.validateExposePermission(ctx, testAccountID, testPeerID) + require.Error(t, err) + assert.Contains(t, err.Error(), "get account settings") + }) +} + +func TestCreateServiceFromPeer(t *testing.T) { + ctx := context.Background() + + t.Run("creates service with random domain", func(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + + req := &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "http", + } + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req) + require.NoError(t, err) + assert.NotEmpty(t, resp.ServiceName, "service name should be generated") + assert.Contains(t, resp.Domain, "test.netbird.io", "domain should use cluster domain") + assert.NotEmpty(t, resp.ServiceURL, "service URL should be set") + + // Verify service is persisted in store + persisted, err := testStore.GetServiceByDomain(ctx, resp.Domain) + require.NoError(t, err) + assert.Equal(t, resp.Domain, persisted.Domain) + assert.Equal(t, rpservice.SourceEphemeral, persisted.Source, "source should be ephemeral") + assert.Equal(t, testPeerID, persisted.SourcePeer, "source peer should be set") + assert.NotNil(t, persisted.Meta.LastRenewedAt, "last renewed should be set") + }) + + t.Run("creates service with custom domain", func(t *testing.T) { + mgr, _ := setupIntegrationTest(t) + + req := &rpservice.ExposeServiceRequest{ + Port: 80, + Mode: "http", + Domain: "example.com", + } + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req) + require.NoError(t, err) + assert.Contains(t, resp.Domain, "example.com", "should use the provided domain") + }) + + t.Run("validates expose permission internally", func(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + + // Disable peer expose + s, err := testStore.GetAccountSettings(ctx, store.LockingStrengthNone, testAccountID) + require.NoError(t, err) + s.PeerExposeEnabled = false + err = testStore.SaveAccountSettings(ctx, testAccountID, s) + require.NoError(t, err) + + req := &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "http", + } + + _, err = mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req) + require.Error(t, err) + assert.Contains(t, err.Error(), "not enabled") + }) + + t.Run("validates request fields", func(t *testing.T) { + mgr, _ := setupIntegrationTest(t) + + req := &rpservice.ExposeServiceRequest{ + Port: 0, + Mode: "http", + } + + _, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req) + require.Error(t, err) + assert.Contains(t, err.Error(), "port") + }) +} + +func TestExposeServiceRequestValidate(t *testing.T) { + tests := []struct { + name string + req rpservice.ExposeServiceRequest + wantErr string + }{ + { + name: "valid http request", + req: rpservice.ExposeServiceRequest{Port: 8080, Mode: "http"}, + wantErr: "", + }, + { + name: "https mode rejected", + req: rpservice.ExposeServiceRequest{Port: 443, Mode: "https", Pin: "123456"}, + wantErr: "unsupported mode", + }, + { + name: "port zero rejected", + req: rpservice.ExposeServiceRequest{Port: 0, Mode: "http"}, + wantErr: "port must be between 1 and 65535", + }, + { + name: "unsupported mode", + req: rpservice.ExposeServiceRequest{Port: 80, Mode: "ftp"}, + wantErr: "unsupported mode", + }, + { + name: "invalid pin format", + req: rpservice.ExposeServiceRequest{Port: 80, Mode: "http", Pin: "abc"}, + wantErr: "invalid pin", + }, + { + name: "pin too short", + req: rpservice.ExposeServiceRequest{Port: 80, Mode: "http", Pin: "12345"}, + wantErr: "invalid pin", + }, + { + name: "valid 6-digit pin", + req: rpservice.ExposeServiceRequest{Port: 80, Mode: "http", Pin: "000000"}, + wantErr: "", + }, + { + name: "empty user group name", + req: rpservice.ExposeServiceRequest{Port: 80, Mode: "http", UserGroups: []string{"valid", ""}}, + wantErr: "user group name cannot be empty", + }, + { + name: "invalid name prefix", + req: rpservice.ExposeServiceRequest{Port: 80, Mode: "http", NamePrefix: "INVALID"}, + wantErr: "invalid name prefix", + }, + { + name: "valid name prefix", + req: rpservice.ExposeServiceRequest{Port: 80, Mode: "http", NamePrefix: "my-service"}, + wantErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.req.Validate() + if tt.wantErr == "" { + assert.NoError(t, err) + } else { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } + }) + } + + t.Run("nil receiver", func(t *testing.T) { + var req *rpservice.ExposeServiceRequest + err := req.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "request cannot be nil") + }) +} + +func TestDeleteServiceFromPeer_ByDomain(t *testing.T) { + ctx := context.Background() + + t.Run("deletes service by domain", func(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + + // First create a service + req := &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "http", + } + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req) + require.NoError(t, err) + + svcID := resolveServiceIDByDomain(t, testStore, resp.Domain) + err = mgr.deleteServiceFromPeer(ctx, testAccountID, testPeerID, svcID, false) + require.NoError(t, err) + + // Verify service is deleted + _, err = testStore.GetServiceByDomain(ctx, resp.Domain) + require.Error(t, err, "service should be deleted") + }) + + t.Run("expire uses correct activity", func(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + + req := &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "http", + } + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req) + require.NoError(t, err) + + svcID := resolveServiceIDByDomain(t, testStore, resp.Domain) + err = mgr.deleteServiceFromPeer(ctx, testAccountID, testPeerID, svcID, true) + require.NoError(t, err) + }) +} + +func TestStopServiceFromPeer(t *testing.T) { + ctx := context.Background() + + t.Run("stops service by domain", func(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + + req := &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "http", + } + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, req) + require.NoError(t, err) + + svcID := resolveServiceIDByDomain(t, testStore, resp.Domain) + err = mgr.StopServiceFromPeer(ctx, testAccountID, testPeerID, svcID) + require.NoError(t, err) + + _, err = testStore.GetServiceByDomain(ctx, resp.Domain) + require.Error(t, err, "service should be deleted") + }) +} + +func TestDeleteService_DeletesEphemeralExpose(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupIntegrationTest(t) + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "http", + }) + require.NoError(t, err) + + count, err := mgr.store.CountEphemeralServicesByPeer(ctx, store.LockingStrengthNone, testAccountID, testPeerID) + require.NoError(t, err) + assert.Equal(t, int64(1), count, "one ephemeral service should exist after create") + + svc, err := testStore.GetServiceByDomain(ctx, resp.Domain) + require.NoError(t, err) + + err = mgr.DeleteService(ctx, testAccountID, testUserID, svc.ID) + require.NoError(t, err) + + count, err = mgr.store.CountEphemeralServicesByPeer(ctx, store.LockingStrengthNone, testAccountID, testPeerID) + require.NoError(t, err) + assert.Equal(t, int64(0), count, "ephemeral service should be deleted after API delete") + + _, err = mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 9090, + Mode: "http", + }) + assert.NoError(t, err, "new expose should succeed after API delete") +} + +func TestDeleteAllServices_DeletesEphemeralExposes(t *testing.T) { + ctx := context.Background() + mgr, _ := setupIntegrationTest(t) + + for i := range 3 { + _, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: uint16(8080 + i), + Mode: "http", + }) + require.NoError(t, err) + } + + count, err := mgr.store.CountEphemeralServicesByPeer(ctx, store.LockingStrengthNone, testAccountID, testPeerID) + require.NoError(t, err) + assert.Equal(t, int64(3), count, "all ephemeral services should exist") + + err = mgr.DeleteAllServices(ctx, testAccountID, testUserID) + require.NoError(t, err) + + count, err = mgr.store.CountEphemeralServicesByPeer(ctx, store.LockingStrengthNone, testAccountID, testPeerID) + require.NoError(t, err) + assert.Equal(t, int64(0), count, "all ephemeral services should be deleted after DeleteAllServices") +} + +func TestRenewServiceFromPeer(t *testing.T) { + ctx := context.Background() + + t.Run("renews tracked expose", func(t *testing.T) { + mgr, testStore := setupIntegrationTest(t) + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "http", + }) + require.NoError(t, err) + + svcID := resolveServiceIDByDomain(t, testStore, resp.Domain) + err = mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, svcID) + require.NoError(t, err) + }) + + t.Run("fails for untracked domain", func(t *testing.T) { + mgr, _ := setupIntegrationTest(t) + err := mgr.RenewServiceFromPeer(ctx, testAccountID, testPeerID, "nonexistent-service-id") + require.Error(t, err) + }) +} + +func TestGetGroupIDsFromNames(t *testing.T) { + ctx := context.Background() + + t.Run("resolves group names to IDs", func(t *testing.T) { + mgr, _ := setupIntegrationTest(t) + ids, err := mgr.getGroupIDsFromNames(ctx, testAccountID, []string{"Expose Group"}) + require.NoError(t, err) + require.Len(t, ids, 1, "should return exactly one group ID") + assert.Equal(t, testGroupID, ids[0]) + }) + + t.Run("returns error for unknown group", func(t *testing.T) { + mgr, _ := setupIntegrationTest(t) + _, err := mgr.getGroupIDsFromNames(ctx, testAccountID, []string{"nonexistent"}) + require.Error(t, err) + }) + + t.Run("returns error for empty group list", func(t *testing.T) { + mgr, _ := setupIntegrationTest(t) + _, err := mgr.getGroupIDsFromNames(ctx, testAccountID, []string{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no group names provided") + }) +} + +func TestDeleteService_DeletesTargets(t *testing.T) { + ctx := context.Background() + accountID := "test-account" + userID := "test-user" + + sqlStore, err := store.NewStore(ctx, types.SqliteStoreEngine, t.TempDir(), nil, false) + require.NoError(t, err) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockPerms := permissions.NewMockManager(ctrl) + mockAcct := account.NewMockManager(ctrl) + + tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, testCacheStore(t)) + pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, testCacheStore(t)) + proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil) + + proxyController, err := proxymanager.NewGRPCController(proxySrv, noop.NewMeterProvider().Meter("")) + require.NoError(t, err) + + mgr := &Manager{ + store: sqlStore, + permissionsManager: mockPerms, + accountManager: mockAcct, + proxyController: proxyController, + } + + service := &rpservice.Service{ + ID: "service-1", + AccountID: accountID, + Domain: "test.example.com", + ProxyCluster: "cluster1", + Enabled: true, + Targets: []*rpservice.Target{ + {AccountID: accountID, ServiceID: "service-1", TargetType: rpservice.TargetTypePeer, TargetId: "peer-1"}, + {AccountID: accountID, ServiceID: "service-1", TargetType: rpservice.TargetTypePeer, TargetId: "peer-2"}, + {AccountID: accountID, ServiceID: "service-1", TargetType: rpservice.TargetTypePeer, TargetId: "peer-3"}, + }, + } + + err = sqlStore.CreateService(ctx, service) + require.NoError(t, err) + + retrievedService, err := sqlStore.GetServiceByID(ctx, store.LockingStrengthNone, accountID, service.ID) + require.NoError(t, err) + require.Len(t, retrievedService.Targets, 3, "Service should have 3 targets before deletion") + + mockPerms.EXPECT(). + ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete). + Return(true, ctx, nil) + mockAcct.EXPECT(). + StoreEvent(ctx, userID, service.ID, accountID, activity.ServiceDeleted, gomock.Any()) + mockAcct.EXPECT(). + UpdateAccountPeers(ctx, accountID, gomock.Any()) + + err = mgr.DeleteService(ctx, accountID, userID, service.ID) + require.NoError(t, err) + + _, err = sqlStore.GetServiceByID(ctx, store.LockingStrengthNone, accountID, service.ID) + require.Error(t, err) + s, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.NotFound, s.Type()) + + targets, err := sqlStore.GetTargetsByServiceID(ctx, store.LockingStrengthNone, accountID, service.ID) + require.NoError(t, err) + assert.Len(t, targets, 0, "All targets should be deleted when service is deleted") +} + +func TestDeleteExpiredPeerService_DeletesTargets(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupIntegrationTest(t) + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "http", + }) + require.NoError(t, err) + + svcID := resolveServiceIDByDomain(t, testStore, resp.Domain) + + targets, err := testStore.GetTargetsByServiceID(ctx, store.LockingStrengthNone, testAccountID, svcID) + require.NoError(t, err) + require.Len(t, targets, 1, "ephemeral peer-exposed service should have exactly one persisted target before reaping") + + expireEphemeralService(t, testStore, testAccountID, resp.Domain) + err = mgr.deleteExpiredPeerService(ctx, testAccountID, testPeerID, svcID) + require.NoError(t, err) + + _, err = testStore.GetServiceByDomain(ctx, resp.Domain) + require.Error(t, err, "expired peer-exposed service should be deleted") + s, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.NotFound, s.Type()) + + targets, err = testStore.GetTargetsByServiceID(ctx, store.LockingStrengthNone, testAccountID, svcID) + require.NoError(t, err) + assert.Len(t, targets, 0, "orphaned target rows must be deleted when an expired peer-exposed service is reaped") +} + +func TestDeleteServiceFromPeer_DeletesTargets(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupIntegrationTest(t) + + resp, err := mgr.CreateServiceFromPeer(ctx, testAccountID, testPeerID, &rpservice.ExposeServiceRequest{ + Port: 8080, + Mode: "http", + }) + require.NoError(t, err) + + svcID := resolveServiceIDByDomain(t, testStore, resp.Domain) + + targets, err := testStore.GetTargetsByServiceID(ctx, store.LockingStrengthNone, testAccountID, svcID) + require.NoError(t, err) + require.Len(t, targets, 1, "ephemeral peer-exposed service should have exactly one persisted target before stopping") + + err = mgr.StopServiceFromPeer(ctx, testAccountID, testPeerID, svcID) + require.NoError(t, err) + + _, err = testStore.GetServiceByDomain(ctx, resp.Domain) + require.Error(t, err, "stopped peer-exposed service should be deleted") + s, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.NotFound, s.Type()) + + targets, err = testStore.GetTargetsByServiceID(ctx, store.LockingStrengthNone, testAccountID, svcID) + require.NoError(t, err) + assert.Len(t, targets, 0, "orphaned target rows must be deleted when a peer stops its exposed service") +} + +func TestValidateProtocolChange(t *testing.T) { + tests := []struct { + name string + oldP string + newP string + wantErr bool + }{ + {"empty to http", "", "http", false}, + {"http to http", "http", "http", false}, + {"same protocol", "tcp", "tcp", false}, + {"empty new proto", "tcp", "", false}, + {"http to tcp", "http", "tcp", true}, + {"tcp to udp", "tcp", "udp", true}, + {"tls to http", "tls", "http", true}, + {"udp to tls", "udp", "tls", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateProtocolChange(tt.oldP, tt.newP) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot change mode") + } else { + require.NoError(t, err) + } + }) + } +} + +func TestValidateTargetReferences_ResourceTypeMismatch(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + mockStore := store.NewMockStore(ctrl) + accountID := "test-account" + + tests := []struct { + name string + targetType rpservice.TargetType + resourceType resourcetypes.NetworkResourceType + wantErr bool + }{ + {"host matches host", rpservice.TargetTypeHost, resourcetypes.Host, false}, + {"domain matches domain", rpservice.TargetTypeDomain, resourcetypes.Domain, false}, + {"subnet matches subnet", rpservice.TargetTypeSubnet, resourcetypes.Subnet, false}, + {"host but resource is domain", rpservice.TargetTypeHost, resourcetypes.Domain, true}, + {"domain but resource is host", rpservice.TargetTypeDomain, resourcetypes.Host, true}, + {"host but resource is subnet", rpservice.TargetTypeHost, resourcetypes.Subnet, true}, + {"subnet but resource is domain", rpservice.TargetTypeSubnet, resourcetypes.Domain, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockStore.EXPECT(). + GetNetworkResourceByID(gomock.Any(), store.LockingStrengthShare, accountID, "resource-1"). + Return(&resourcetypes.NetworkResource{Type: tt.resourceType}, nil) + + targets := []*rpservice.Target{ + {TargetId: "resource-1", TargetType: tt.targetType, Host: "10.0.0.1"}, + } + err := validateTargetReferences(ctx, mockStore, accountID, targets) + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "target_type") + } else { + require.NoError(t, err) + } + }) + } +} + +func TestValidateTargetReferences_PeerValid(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + mockStore := store.NewMockStore(ctrl) + accountID := "test-account" + + mockStore.EXPECT(). + GetPeerByID(gomock.Any(), store.LockingStrengthShare, accountID, "peer-1"). + Return(&nbpeer.Peer{}, nil) + + targets := []*rpservice.Target{ + {TargetId: "peer-1", TargetType: rpservice.TargetTypePeer}, + } + require.NoError(t, validateTargetReferences(ctx, mockStore, accountID, targets)) +} + +func TestValidateSubdomainRequirement(t *testing.T) { + ptrBool := func(b bool) *bool { return &b } + + tests := []struct { + name string + domain string + cluster string + requireSubdomain *bool + wantErr bool + }{ + { + name: "subdomain present, require_subdomain true", + domain: "app.eu1.proxy.netbird.io", + cluster: "eu1.proxy.netbird.io", + requireSubdomain: ptrBool(true), + wantErr: false, + }, + { + name: "bare cluster domain, require_subdomain true", + domain: "eu1.proxy.netbird.io", + cluster: "eu1.proxy.netbird.io", + requireSubdomain: ptrBool(true), + wantErr: true, + }, + { + name: "bare cluster domain, require_subdomain false", + domain: "eu1.proxy.netbird.io", + cluster: "eu1.proxy.netbird.io", + requireSubdomain: ptrBool(false), + wantErr: false, + }, + { + name: "bare cluster domain, require_subdomain nil (default)", + domain: "eu1.proxy.netbird.io", + cluster: "eu1.proxy.netbird.io", + requireSubdomain: nil, + wantErr: false, + }, + { + name: "custom domain apex is not the cluster", + domain: "example.com", + cluster: "eu1.proxy.netbird.io", + requireSubdomain: ptrBool(true), + wantErr: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + + mockCaps := proxy.NewMockManager(ctrl) + mockCaps.EXPECT().ClusterRequireSubdomain(gomock.Any(), tc.cluster).Return(tc.requireSubdomain).AnyTimes() + + mgr := &Manager{capabilities: mockCaps} + err := mgr.validateSubdomainRequirement(context.Background(), tc.domain, tc.cluster) + if tc.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "requires a subdomain label") + } else { + require.NoError(t, err) + } + }) + } +} + +func TestValidateTargetReferences_ClusterTargetSkipsLookup(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + mockStore := store.NewMockStore(ctrl) + accountID := "test-account" + + // No peer or resource lookups must be issued for cluster targets. + targets := []*rpservice.Target{ + { + TargetId: "eu.proxy.netbird.io", + TargetType: rpservice.TargetTypeCluster, + Options: rpservice.TargetOptions{DirectUpstream: true}, + }, + } + require.NoError(t, validateTargetReferences(ctx, mockStore, accountID, targets), "cluster target must validate without store lookups") +} + +// TestValidateTargetReferences_ClusterTargetRequiresDirectUpstream pins the +// store-side check that cluster targets must opt into the host-stack dial +// path. Without DirectUpstream the proxy would route this target through +// the embedded NetBird client and fail on every request. +func TestValidateTargetReferences_ClusterTargetRequiresDirectUpstream(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + mockStore := store.NewMockStore(ctrl) + accountID := "test-account" + + targets := []*rpservice.Target{ + { + TargetId: "eu.proxy.netbird.io", + TargetType: rpservice.TargetTypeCluster, + Host: "backend.lan", + }, + } + err := validateTargetReferences(ctx, mockStore, accountID, targets) + require.Error(t, err, "cluster target without direct_upstream must be rejected") + assert.ErrorContains(t, err, "direct upstream disabled") +} + +func TestReplaceHostByLookup_SkipsClusterTarget(t *testing.T) { + ctx := context.Background() + ctrl := gomock.NewController(t) + mockStore := store.NewMockStore(ctrl) + accountID := "test-account" + + mgr := &Manager{store: mockStore} + + svc := &rpservice.Service{ + ID: "svc-1", + AccountID: accountID, + Targets: []*rpservice.Target{ + { + TargetId: "eu.proxy.netbird.io", + TargetType: rpservice.TargetTypeCluster, + Host: "127.0.0.1", + }, + }, + } + + require.NoError(t, mgr.replaceHostByLookup(ctx, accountID, svc), "cluster target must not trigger peer/resource lookup") + assert.Equal(t, "127.0.0.1", svc.Targets[0].Host, "operator-supplied host must be preserved for cluster target") +} diff --git a/management/internals/modules/reverseproxy/service/service.go b/management/internals/modules/reverseproxy/service/service.go new file mode 100644 index 000000000..b6438abde --- /dev/null +++ b/management/internals/modules/reverseproxy/service/service.go @@ -0,0 +1,1621 @@ +package service + +import ( + "crypto/rand" + "errors" + "fmt" + "math/big" + "net" + "net/http" + "net/netip" + "net/url" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "github.com/rs/xid" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/shared/hash/argon2id" + "github.com/netbirdio/netbird/util/crypt" + + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/proto" +) + +type Operation string + +const ( + Create Operation = "create" + Update Operation = "update" + Delete Operation = "delete" +) + +type Status string +type TargetType string + +const ( + StatusPending Status = "pending" + StatusActive Status = "active" + StatusTunnelNotCreated Status = "tunnel_not_created" + StatusCertificatePending Status = "certificate_pending" + StatusCertificateFailed Status = "certificate_failed" + StatusError Status = "error" + + TargetTypePeer TargetType = "peer" + TargetTypeHost TargetType = "host" + TargetTypeDomain TargetType = "domain" + TargetTypeSubnet TargetType = "subnet" + TargetTypeCluster TargetType = "cluster" + + SourcePermanent = "permanent" + SourceEphemeral = "ephemeral" +) + +type TargetOptions struct { + SkipTLSVerify bool `json:"skip_tls_verify"` + RequestTimeout time.Duration `json:"request_timeout,omitempty"` + SessionIdleTimeout time.Duration `json:"session_idle_timeout,omitempty"` + PathRewrite PathRewriteMode `json:"path_rewrite,omitempty"` + CustomHeaders map[string]string `gorm:"serializer:json" json:"custom_headers,omitempty"` + // DirectUpstream bypasses the proxy's embedded NetBird client and dials + // the target via the proxy host's network stack. Useful for upstreams + // reachable without WireGuard (public APIs, LAN services, localhost + // sidecars). Default false. + DirectUpstream bool `json:"direct_upstream,omitempty"` + // Middlewares carries per-target agent-network middleware configs. Empty + // for private and operator-defined services; populated only by the + // agent-network synthesizer. + Middlewares []MiddlewareConfig `gorm:"serializer:json" json:"middlewares,omitempty"` + CaptureMaxRequestBytes int64 `json:"capture_max_request_bytes,omitempty"` + CaptureMaxResponseBytes int64 `json:"capture_max_response_bytes,omitempty"` + CaptureContentTypes []string `gorm:"serializer:json" json:"capture_content_types,omitempty"` + // AgentNetwork marks targets synthesised from Agent Network state. The + // proxy uses it to gate agent-network-specific behaviour (access log + // tagging, observability, etc.). + AgentNetwork bool `json:"agent_network,omitempty"` + // DisableAccessLog suppresses the per-request access-log emission for this + // target. Defaults false to preserve access-log behaviour for every + // non-agent-network target. The agent-network synthesizer sets this true + // only when the account's EnableLogCollection toggle is off. + DisableAccessLog bool `json:"disable_access_log,omitempty"` +} + +// MiddlewareSlot mirrors proto.MiddlewareSlot / middleware.Slot. +type MiddlewareSlot string + +const ( + MiddlewareSlotOnRequest MiddlewareSlot = "on_request" + MiddlewareSlotOnResponse MiddlewareSlot = "on_response" + MiddlewareSlotTerminal MiddlewareSlot = "terminal" +) + +// MiddlewareFailMode mirrors proto.MiddlewareConfig_FailMode. +type MiddlewareFailMode string + +const ( + MiddlewareFailOpen MiddlewareFailMode = "fail_open" + MiddlewareFailClosed MiddlewareFailMode = "fail_closed" +) + +// MiddlewareConfig is the per-target configuration for a single +// middleware instance. Mirrors proto.MiddlewareConfig. +type MiddlewareConfig struct { + ID string `json:"id"` + Enabled bool `json:"enabled"` + Slot MiddlewareSlot `json:"slot"` + ConfigJSON []byte `json:"config_json,omitempty"` + FailMode MiddlewareFailMode `json:"fail_mode,omitempty"` + TimeoutMs int32 `json:"timeout_ms,omitempty"` + CanMutate bool `json:"can_mutate"` +} + +type Target struct { + ID uint `gorm:"primaryKey" json:"-"` + AccountID string `gorm:"index:idx_target_account;not null" json:"-"` + ServiceID string `gorm:"index:idx_service_targets;not null" json:"-"` + Path *string `json:"path,omitempty"` + Host string `json:"host"` + Port uint16 `gorm:"index:idx_target_port" json:"port"` + Protocol string `gorm:"index:idx_target_protocol" json:"protocol"` + TargetId string `gorm:"index:idx_target_id" json:"target_id"` + TargetType TargetType `gorm:"index:idx_target_type" json:"target_type"` + Enabled bool `gorm:"index:idx_target_enabled" json:"enabled"` + Options TargetOptions `gorm:"embedded" json:"options"` + ProxyProtocol bool `json:"proxy_protocol"` +} + +type PasswordAuthConfig struct { + Enabled bool `json:"enabled"` + Password string `json:"password"` +} + +type PINAuthConfig struct { + Enabled bool `json:"enabled"` + Pin string `json:"pin"` +} + +type BearerAuthConfig struct { + Enabled bool `json:"enabled"` + DistributionGroups []string `json:"distribution_groups,omitempty" gorm:"serializer:json"` +} + +// HeaderAuthConfig defines a static header-value auth check. +// The proxy compares the incoming header value against the stored hash. +type HeaderAuthConfig struct { + Enabled bool `json:"enabled"` + Header string `json:"header"` + Value string `json:"value"` +} + +type AuthConfig struct { + PasswordAuth *PasswordAuthConfig `json:"password_auth,omitempty" gorm:"serializer:json"` + PinAuth *PINAuthConfig `json:"pin_auth,omitempty" gorm:"serializer:json"` + BearerAuth *BearerAuthConfig `json:"bearer_auth,omitempty" gorm:"serializer:json"` + HeaderAuths []*HeaderAuthConfig `json:"header_auths,omitempty" gorm:"serializer:json"` +} + +// AccessRestrictions controls who can connect to the service based on IP or geography. +type AccessRestrictions struct { + AllowedCIDRs []string `json:"allowed_cidrs,omitempty" gorm:"serializer:json"` + BlockedCIDRs []string `json:"blocked_cidrs,omitempty" gorm:"serializer:json"` + AllowedCountries []string `json:"allowed_countries,omitempty" gorm:"serializer:json"` + BlockedCountries []string `json:"blocked_countries,omitempty" gorm:"serializer:json"` + CrowdSecMode string `json:"crowdsec_mode,omitempty" gorm:"serializer:json"` +} + +// Copy returns a deep copy of the AccessRestrictions. +func (r AccessRestrictions) Copy() AccessRestrictions { + return AccessRestrictions{ + AllowedCIDRs: slices.Clone(r.AllowedCIDRs), + BlockedCIDRs: slices.Clone(r.BlockedCIDRs), + AllowedCountries: slices.Clone(r.AllowedCountries), + BlockedCountries: slices.Clone(r.BlockedCountries), + CrowdSecMode: r.CrowdSecMode, + } +} + +func (a *AuthConfig) HashSecrets() error { + if a.PasswordAuth != nil && a.PasswordAuth.Enabled && a.PasswordAuth.Password != "" { + hashedPassword, err := argon2id.Hash(a.PasswordAuth.Password) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + a.PasswordAuth.Password = hashedPassword + } + + if a.PinAuth != nil && a.PinAuth.Enabled && a.PinAuth.Pin != "" { + hashedPin, err := argon2id.Hash(a.PinAuth.Pin) + if err != nil { + return fmt.Errorf("hash pin: %w", err) + } + a.PinAuth.Pin = hashedPin + } + + for i, h := range a.HeaderAuths { + if h != nil && h.Enabled && h.Value != "" { + hashedValue, err := argon2id.Hash(h.Value) + if err != nil { + return fmt.Errorf("hash header auth[%d] value: %w", i, err) + } + h.Value = hashedValue + } + } + + return nil +} + +func (a *AuthConfig) ClearSecrets() { + if a.PasswordAuth != nil { + a.PasswordAuth.Password = "" + } + if a.PinAuth != nil { + a.PinAuth.Pin = "" + } + for _, h := range a.HeaderAuths { + if h != nil { + h.Value = "" + } + } +} + +type Meta struct { + CreatedAt time.Time + CertificateIssuedAt *time.Time + Status string + LastRenewedAt *time.Time +} + +type Service struct { + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index"` + Name string + Domain string `gorm:"type:varchar(255);uniqueIndex"` + ProxyCluster string `gorm:"index"` + Targets []*Target `gorm:"foreignKey:ServiceID;constraint:OnDelete:CASCADE"` + Enabled bool + Terminated bool + PassHostHeader bool + RewriteRedirects bool + Auth AuthConfig `gorm:"serializer:json"` + Restrictions AccessRestrictions `gorm:"serializer:json"` + Meta Meta `gorm:"embedded;embeddedPrefix:meta_"` + SessionPrivateKey string `gorm:"column:session_private_key"` + SessionPublicKey string `gorm:"column:session_public_key"` + Source string `gorm:"default:'permanent';index:idx_service_source_peer"` + SourcePeer string `gorm:"index:idx_service_source_peer"` + // Mode determines the service type: "http", "tcp", "udp", or "tls". + Mode string `gorm:"default:'http'"` + ListenPort uint16 + PortAutoAssigned bool + // Private marks the service as NetBird-only: auth via ValidateTunnelPeer against AccessGroups instead of SSO. HTTP-only. + Private bool + // AccessGroups is the group ID allowlist for inbound peers on private services. Mutually exclusive with bearer SSO. + AccessGroups []string `json:"access_groups,omitempty" gorm:"serializer:json"` +} + +// InitNewRecord generates a new unique ID and resets metadata for a newly created +// Service record. This overwrites any existing ID and Meta fields and should +// only be called during initial creation, not for updates. +func (s *Service) InitNewRecord() { + s.ID = xid.New().String() + s.Meta = Meta{ + CreatedAt: time.Now(), + Status: string(StatusPending), + } +} + +func (s *Service) ToAPIResponse() *api.Service { + authConfig := api.ServiceAuthConfig{} + + if s.Auth.PasswordAuth != nil { + authConfig.PasswordAuth = &api.PasswordAuthConfig{ + Enabled: s.Auth.PasswordAuth.Enabled, + } + } + + if s.Auth.PinAuth != nil { + authConfig.PinAuth = &api.PINAuthConfig{ + Enabled: s.Auth.PinAuth.Enabled, + } + } + + if s.Auth.BearerAuth != nil { + authConfig.BearerAuth = &api.BearerAuthConfig{ + Enabled: s.Auth.BearerAuth.Enabled, + DistributionGroups: &s.Auth.BearerAuth.DistributionGroups, + } + } + + if len(s.Auth.HeaderAuths) > 0 { + apiHeaders := make([]api.HeaderAuthConfig, 0, len(s.Auth.HeaderAuths)) + for _, h := range s.Auth.HeaderAuths { + if h == nil { + continue + } + apiHeaders = append(apiHeaders, api.HeaderAuthConfig{ + Enabled: h.Enabled, + Header: h.Header, + }) + } + authConfig.HeaderAuths = &apiHeaders + } + + // Convert internal targets to API targets + apiTargets := make([]api.ServiceTarget, 0, len(s.Targets)) + for _, target := range s.Targets { + st := api.ServiceTarget{ + Path: target.Path, + Host: &target.Host, + Port: int(target.Port), + Protocol: api.ServiceTargetProtocol(target.Protocol), + TargetId: target.TargetId, + TargetType: api.ServiceTargetTargetType(target.TargetType), + Enabled: target.Enabled && !s.Terminated, + } + opts := targetOptionsToAPI(target.Options) + if opts == nil { + opts = &api.ServiceTargetOptions{} + } + if target.ProxyProtocol { + opts.ProxyProtocol = &target.ProxyProtocol + } + st.Options = opts + apiTargets = append(apiTargets, st) + } + + meta := api.ServiceMeta{ + CreatedAt: s.Meta.CreatedAt, + Status: api.ServiceMetaStatus(s.Meta.Status), + } + + if s.Meta.CertificateIssuedAt != nil { + meta.CertificateIssuedAt = s.Meta.CertificateIssuedAt + } + + mode := api.ServiceMode(s.Mode) + listenPort := int(s.ListenPort) + + resp := &api.Service{ + Id: s.ID, + Name: s.Name, + Domain: s.Domain, + Targets: apiTargets, + Enabled: s.Enabled && !s.Terminated, + Terminated: &s.Terminated, + PassHostHeader: &s.PassHostHeader, + RewriteRedirects: &s.RewriteRedirects, + Auth: authConfig, + AccessRestrictions: restrictionsToAPI(s.Restrictions), + Meta: meta, + Mode: &mode, + ListenPort: &listenPort, + PortAutoAssigned: &s.PortAutoAssigned, + Private: &s.Private, + } + + if len(s.AccessGroups) > 0 { + groups := append([]string(nil), s.AccessGroups...) + resp.AccessGroups = &groups + } + + if s.ProxyCluster != "" { + resp.ProxyCluster = &s.ProxyCluster + } + + return resp +} + +// ToProtoMapping converts the service into the wire format the proxy consumes. +func (s *Service) ToProtoMapping(operation Operation, authToken string, oidcConfig proxy.OIDCValidationConfig) *proto.ProxyMapping { + pathMappings := s.buildPathMappings() + + auth := &proto.Authentication{ + SessionKey: s.SessionPublicKey, + MaxSessionAgeSeconds: int64((time.Hour * 24).Seconds()), + } + + if s.Auth.PasswordAuth != nil && s.Auth.PasswordAuth.Enabled { + auth.Password = true + } + + if s.Auth.PinAuth != nil && s.Auth.PinAuth.Enabled { + auth.Pin = true + } + + if s.Auth.BearerAuth != nil && s.Auth.BearerAuth.Enabled { + auth.Oidc = true + } + + for _, h := range s.Auth.HeaderAuths { + if h != nil && h.Enabled { + auth.HeaderAuths = append(auth.HeaderAuths, &proto.HeaderAuth{ + Header: h.Header, + HashedValue: h.Value, + }) + } + } + + mapping := &proto.ProxyMapping{ + Type: operationToProtoType(operation), + Id: s.ID, + Domain: s.Domain, + Path: pathMappings, + AuthToken: authToken, + Auth: auth, + AccountId: s.AccountID, + PassHostHeader: s.PassHostHeader, + RewriteRedirects: s.RewriteRedirects, + Mode: s.Mode, + ListenPort: int32(s.ListenPort), //nolint:gosec + Private: s.Private, + } + + if r := restrictionsToProto(s.Restrictions); r != nil { + mapping.AccessRestrictions = r + } + + return mapping +} + +// buildPathMappings constructs PathMapping entries from targets. +// For HTTP/HTTPS, each target becomes a path-based route with a full URL. +// For L4/TLS, a single target maps to a host:port address. +func (s *Service) buildPathMappings() []*proto.PathMapping { + pathMappings := make([]*proto.PathMapping, 0, len(s.Targets)) + for _, target := range s.Targets { + if !target.Enabled { + continue + } + + if IsL4Protocol(s.Mode) { + pm := &proto.PathMapping{ + Target: net.JoinHostPort(target.Host, strconv.FormatUint(uint64(target.Port), 10)), + } + opts := l4TargetOptionsToProto(target) + if opts != nil { + pm.Options = opts + } + pathMappings = append(pathMappings, pm) + continue + } + + // HTTP/HTTPS: build full URL + hostNoBrackets := strings.TrimSuffix(strings.TrimPrefix(target.Host, "["), "]") + targetURL := url.URL{ + Scheme: target.Protocol, + Host: bracketIPv6Host(hostNoBrackets), + Path: "/", + } + if target.Port > 0 && !isDefaultPort(target.Protocol, target.Port) { + targetURL.Host = net.JoinHostPort(hostNoBrackets, strconv.FormatUint(uint64(target.Port), 10)) + } + + path := "/" + if target.Path != nil { + path = *target.Path + } + + pm := &proto.PathMapping{ + Path: path, + Target: targetURL.String(), + } + pm.Options = targetOptionsToProto(target.Options) + pathMappings = append(pathMappings, pm) + } + return pathMappings +} + +// bracketIPv6Host wraps host in square brackets when it is an IPv6 literal, as +// required for the Host field of net/url.URL (RFC 3986 §3.2.2). v4-mapped IPv6 +// addresses are bracketed too since their textual form contains colons. +func bracketIPv6Host(host string) string { + if strings.HasPrefix(host, "[") { + return host + } + if addr, err := netip.ParseAddr(host); err == nil && addr.Is6() { + return "[" + host + "]" + } + return host +} + +func operationToProtoType(op Operation) proto.ProxyMappingUpdateType { + switch op { + case Create: + return proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED + case Update: + return proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED + case Delete: + return proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED + default: + panic(fmt.Sprintf("unknown operation type: %v", op)) + } +} + +// isDefaultPort reports whether port is the standard default for the given scheme +// (443 for https, 80 for http). +func isDefaultPort(scheme string, port uint16) bool { + return (scheme == TargetProtoHTTPS && port == 443) || (scheme == TargetProtoHTTP && port == 80) +} + +// PathRewriteMode controls how the request path is rewritten before forwarding. +type PathRewriteMode string + +const ( + PathRewritePreserve PathRewriteMode = "preserve" +) + +func pathRewriteToProto(mode PathRewriteMode) proto.PathRewriteMode { + switch mode { + case PathRewritePreserve: + return proto.PathRewriteMode_PATH_REWRITE_PRESERVE + default: + return proto.PathRewriteMode_PATH_REWRITE_DEFAULT + } +} + +func targetOptionsToAPI(opts TargetOptions) *api.ServiceTargetOptions { + if !opts.SkipTLSVerify && opts.RequestTimeout == 0 && opts.SessionIdleTimeout == 0 && + opts.PathRewrite == "" && len(opts.CustomHeaders) == 0 && !opts.DirectUpstream { + return nil + } + apiOpts := &api.ServiceTargetOptions{} + if opts.SkipTLSVerify { + apiOpts.SkipTlsVerify = &opts.SkipTLSVerify + } + if opts.RequestTimeout != 0 { + s := opts.RequestTimeout.String() + apiOpts.RequestTimeout = &s + } + if opts.SessionIdleTimeout != 0 { + s := opts.SessionIdleTimeout.String() + apiOpts.SessionIdleTimeout = &s + } + if opts.PathRewrite != "" { + pr := api.ServiceTargetOptionsPathRewrite(opts.PathRewrite) + apiOpts.PathRewrite = &pr + } + if len(opts.CustomHeaders) > 0 { + apiOpts.CustomHeaders = &opts.CustomHeaders + } + if opts.DirectUpstream { + apiOpts.DirectUpstream = &opts.DirectUpstream + } + return apiOpts +} + +func targetOptionsToProto(opts TargetOptions) *proto.PathTargetOptions { + if !opts.SkipTLSVerify && opts.PathRewrite == "" && opts.RequestTimeout == 0 && + len(opts.CustomHeaders) == 0 && !opts.DirectUpstream && + len(opts.Middlewares) == 0 && opts.CaptureMaxRequestBytes == 0 && + opts.CaptureMaxResponseBytes == 0 && len(opts.CaptureContentTypes) == 0 && + !opts.AgentNetwork && !opts.DisableAccessLog { + return nil + } + popts := &proto.PathTargetOptions{ + SkipTlsVerify: opts.SkipTLSVerify, + PathRewrite: pathRewriteToProto(opts.PathRewrite), + CustomHeaders: opts.CustomHeaders, + DirectUpstream: opts.DirectUpstream, + AgentNetwork: opts.AgentNetwork, + DisableAccessLog: opts.DisableAccessLog, + } + if opts.RequestTimeout != 0 { + popts.RequestTimeout = durationpb.New(opts.RequestTimeout) + } + if len(opts.Middlewares) > 0 { + popts.Middlewares = middlewaresToProto(opts.Middlewares) + } + popts.CaptureMaxRequestBytes = opts.CaptureMaxRequestBytes + popts.CaptureMaxResponseBytes = opts.CaptureMaxResponseBytes + if len(opts.CaptureContentTypes) > 0 { + popts.CaptureContentTypes = append([]string(nil), opts.CaptureContentTypes...) + } + return popts +} + +// middlewaresToProto converts the internal middleware slice to the proto +// representation sent to the proxy via the mapping stream. +func middlewaresToProto(in []MiddlewareConfig) []*proto.MiddlewareConfig { + out := make([]*proto.MiddlewareConfig, 0, len(in)) + for _, m := range in { + pm := &proto.MiddlewareConfig{ + Id: m.ID, + Enabled: m.Enabled, + Slot: middlewareSlotToProto(m.Slot), + ConfigJson: append([]byte(nil), m.ConfigJSON...), + CanMutate: m.CanMutate, + FailMode: middlewareFailModeToProto(m.FailMode), + } + if m.TimeoutMs > 0 { + pm.Timeout = durationpb.New(time.Duration(m.TimeoutMs) * time.Millisecond) + } + out = append(out, pm) + } + return out +} + +func middlewareSlotToProto(s MiddlewareSlot) proto.MiddlewareSlot { + switch s { + case MiddlewareSlotOnRequest: + return proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST + case MiddlewareSlotOnResponse: + return proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE + case MiddlewareSlotTerminal: + return proto.MiddlewareSlot_MIDDLEWARE_SLOT_TERMINAL + default: + return proto.MiddlewareSlot_MIDDLEWARE_SLOT_UNSPECIFIED + } +} + +func middlewareFailModeToProto(m MiddlewareFailMode) proto.MiddlewareConfig_FailMode { + if m == MiddlewareFailClosed { + return proto.MiddlewareConfig_FAIL_CLOSED + } + return proto.MiddlewareConfig_FAIL_OPEN +} + +// l4TargetOptionsToProto converts L4-relevant target options to proto. +func l4TargetOptionsToProto(target *Target) *proto.PathTargetOptions { + if !target.ProxyProtocol && target.Options.RequestTimeout == 0 && target.Options.SessionIdleTimeout == 0 { + return nil + } + opts := &proto.PathTargetOptions{ + ProxyProtocol: target.ProxyProtocol, + } + if target.Options.RequestTimeout > 0 { + opts.RequestTimeout = durationpb.New(target.Options.RequestTimeout) + } + if target.Options.SessionIdleTimeout > 0 { + opts.SessionIdleTimeout = durationpb.New(target.Options.SessionIdleTimeout) + } + return opts +} + +func targetOptionsFromAPI(idx int, o *api.ServiceTargetOptions) (TargetOptions, error) { + var opts TargetOptions + if o.SkipTlsVerify != nil { + opts.SkipTLSVerify = *o.SkipTlsVerify + } + if o.RequestTimeout != nil { + d, err := time.ParseDuration(*o.RequestTimeout) + if err != nil { + return opts, fmt.Errorf("target %d: parse request_timeout %q: %w", idx, *o.RequestTimeout, err) + } + opts.RequestTimeout = d + } + if o.SessionIdleTimeout != nil { + d, err := time.ParseDuration(*o.SessionIdleTimeout) + if err != nil { + return opts, fmt.Errorf("target %d: parse session_idle_timeout %q: %w", idx, *o.SessionIdleTimeout, err) + } + opts.SessionIdleTimeout = d + } + if o.PathRewrite != nil { + opts.PathRewrite = PathRewriteMode(*o.PathRewrite) + } + if o.CustomHeaders != nil { + opts.CustomHeaders = *o.CustomHeaders + } + if o.DirectUpstream != nil { + opts.DirectUpstream = *o.DirectUpstream + } + return opts, nil +} + +func (s *Service) FromAPIRequest(req *api.ServiceRequest, accountID string) error { + s.Name = req.Name + s.Domain = req.Domain + s.AccountID = accountID + + if req.Mode != nil { + s.Mode = string(*req.Mode) + } + if req.ListenPort != nil { + s.ListenPort = uint16(*req.ListenPort) //nolint:gosec + } + if req.Private != nil { + s.Private = *req.Private + } + if req.AccessGroups != nil { + s.AccessGroups = append([]string(nil), *req.AccessGroups...) + } else { + s.AccessGroups = nil + } + + targets, err := targetsFromAPI(accountID, req.Targets) + if err != nil { + return err + } + s.Targets = targets + s.Enabled = req.Enabled + + if req.PassHostHeader != nil { + s.PassHostHeader = *req.PassHostHeader + } + if req.RewriteRedirects != nil { + s.RewriteRedirects = *req.RewriteRedirects + } + + if req.Auth != nil { + s.Auth = authFromAPI(req.Auth) + } + + if req.AccessRestrictions != nil { + restrictions, err := restrictionsFromAPI(req.AccessRestrictions) + if err != nil { + return err + } + s.Restrictions = restrictions + } + + return nil +} + +func targetsFromAPI(accountID string, apiTargetsPtr *[]api.ServiceTarget) ([]*Target, error) { + var apiTargets []api.ServiceTarget + if apiTargetsPtr != nil { + apiTargets = *apiTargetsPtr + } + + targets := make([]*Target, 0, len(apiTargets)) + for i, apiTarget := range apiTargets { + target := &Target{ + AccountID: accountID, + Path: apiTarget.Path, + Port: uint16(apiTarget.Port), //nolint:gosec // validated by API layer + Protocol: string(apiTarget.Protocol), + TargetId: apiTarget.TargetId, + TargetType: TargetType(apiTarget.TargetType), + Enabled: apiTarget.Enabled, + } + if apiTarget.Host != nil { + target.Host = *apiTarget.Host + } + if apiTarget.Options != nil { + opts, err := targetOptionsFromAPI(i, apiTarget.Options) + if err != nil { + return nil, err + } + target.Options = opts + if apiTarget.Options.ProxyProtocol != nil { + target.ProxyProtocol = *apiTarget.Options.ProxyProtocol + } + } + targets = append(targets, target) + } + return targets, nil +} + +func authFromAPI(reqAuth *api.ServiceAuthConfig) AuthConfig { + var auth AuthConfig + if reqAuth.PasswordAuth != nil { + auth.PasswordAuth = &PasswordAuthConfig{ + Enabled: reqAuth.PasswordAuth.Enabled, + Password: reqAuth.PasswordAuth.Password, + } + } + if reqAuth.PinAuth != nil { + auth.PinAuth = &PINAuthConfig{ + Enabled: reqAuth.PinAuth.Enabled, + Pin: reqAuth.PinAuth.Pin, + } + } + if reqAuth.BearerAuth != nil { + bearerAuth := &BearerAuthConfig{ + Enabled: reqAuth.BearerAuth.Enabled, + } + if reqAuth.BearerAuth.DistributionGroups != nil { + bearerAuth.DistributionGroups = *reqAuth.BearerAuth.DistributionGroups + } + auth.BearerAuth = bearerAuth + } + if reqAuth.HeaderAuths != nil { + for _, h := range *reqAuth.HeaderAuths { + auth.HeaderAuths = append(auth.HeaderAuths, &HeaderAuthConfig{ + Enabled: h.Enabled, + Header: h.Header, + Value: h.Value, + }) + } + } + return auth +} + +func restrictionsFromAPI(r *api.AccessRestrictions) (AccessRestrictions, error) { + if r == nil { + return AccessRestrictions{}, nil + } + var res AccessRestrictions + if r.AllowedCidrs != nil { + res.AllowedCIDRs = *r.AllowedCidrs + } + if r.BlockedCidrs != nil { + res.BlockedCIDRs = *r.BlockedCidrs + } + if r.AllowedCountries != nil { + res.AllowedCountries = *r.AllowedCountries + } + if r.BlockedCountries != nil { + res.BlockedCountries = *r.BlockedCountries + } + if r.CrowdsecMode != nil { + if !r.CrowdsecMode.Valid() { + return AccessRestrictions{}, fmt.Errorf("invalid crowdsec_mode %q", *r.CrowdsecMode) + } + res.CrowdSecMode = string(*r.CrowdsecMode) + } + return res, nil +} + +func restrictionsToAPI(r AccessRestrictions) *api.AccessRestrictions { + if len(r.AllowedCIDRs) == 0 && len(r.BlockedCIDRs) == 0 && + len(r.AllowedCountries) == 0 && len(r.BlockedCountries) == 0 && + r.CrowdSecMode == "" { + return nil + } + res := &api.AccessRestrictions{} + if len(r.AllowedCIDRs) > 0 { + res.AllowedCidrs = &r.AllowedCIDRs + } + if len(r.BlockedCIDRs) > 0 { + res.BlockedCidrs = &r.BlockedCIDRs + } + if len(r.AllowedCountries) > 0 { + res.AllowedCountries = &r.AllowedCountries + } + if len(r.BlockedCountries) > 0 { + res.BlockedCountries = &r.BlockedCountries + } + if r.CrowdSecMode != "" { + mode := api.AccessRestrictionsCrowdsecMode(r.CrowdSecMode) + res.CrowdsecMode = &mode + } + return res +} + +func restrictionsToProto(r AccessRestrictions) *proto.AccessRestrictions { + if len(r.AllowedCIDRs) == 0 && len(r.BlockedCIDRs) == 0 && + len(r.AllowedCountries) == 0 && len(r.BlockedCountries) == 0 && + r.CrowdSecMode == "" { + return nil + } + return &proto.AccessRestrictions{ + AllowedCidrs: r.AllowedCIDRs, + BlockedCidrs: r.BlockedCIDRs, + AllowedCountries: r.AllowedCountries, + BlockedCountries: r.BlockedCountries, + CrowdsecMode: r.CrowdSecMode, + } +} + +func (s *Service) Validate() error { + if s.Name == "" { + return errors.New("service name is required") + } + if len(s.Name) > 255 { + return errors.New("service name exceeds maximum length of 255 characters") + } + + if len(s.Targets) == 0 { + return errors.New("at least one target is required") + } + + if s.Mode == "" { + s.Mode = ModeHTTP + } + + if err := validateHeaderAuths(s.Auth.HeaderAuths); err != nil { + return err + } + if err := validateAccessRestrictions(&s.Restrictions); err != nil { + return err + } + if err := s.validatePrivateRequirements(); err != nil { + return err + } + + switch s.Mode { + case ModeHTTP: + return s.validateHTTPMode() + case ModeTCP, ModeUDP: + return s.validateTCPUDPMode() + case ModeTLS: + return s.validateTLSMode() + default: + return fmt.Errorf("unsupported mode %q", s.Mode) + } +} + +// validatePrivateRequirements enforces the private-service contract: HTTP mode, ≥1 access group, no bearer auth. +func (s *Service) validatePrivateRequirements() error { + if !s.Private { + return nil + } + if s.Mode != "" && s.Mode != ModeHTTP { + return fmt.Errorf("private services only support HTTP mode, got %q", s.Mode) + } + if len(s.AccessGroups) == 0 { + return errors.New("private services require at least one access group") + } + if s.Auth.BearerAuth != nil && s.Auth.BearerAuth.Enabled { + return errors.New("private services cannot enable bearer auth (SSO): NetBird-only access and SSO are mutually exclusive") + } + return nil +} + +func (s *Service) validateHTTPMode() error { + if s.Domain == "" { + return errors.New("service domain is required") + } + if s.ListenPort != 0 { + return errors.New("listen_port is not supported for HTTP services") + } + return s.validateHTTPTargets() +} + +func (s *Service) validateTCPUDPMode() error { + if s.Domain == "" { + return errors.New("domain is required for TCP/UDP services (used for cluster derivation)") + } + if s.isAuthEnabled() { + return errors.New("auth is not supported for TCP/UDP services") + } + if len(s.Targets) != 1 { + return errors.New("TCP/UDP services must have exactly one target") + } + if s.Mode == ModeUDP && s.Targets[0].ProxyProtocol { + return errors.New("proxy_protocol is not supported for UDP services") + } + return s.validateL4Target(s.Targets[0]) +} + +func (s *Service) validateTLSMode() error { + if s.Domain == "" { + return errors.New("domain is required for TLS services (used for SNI matching)") + } + if s.isAuthEnabled() { + return errors.New("auth is not supported for TLS services") + } + if s.ListenPort == 0 { + return errors.New("listen_port is required for TLS services") + } + if len(s.Targets) != 1 { + return errors.New("TLS services must have exactly one target") + } + return s.validateL4Target(s.Targets[0]) +} + +func (s *Service) validateHTTPTargets() error { + for i, target := range s.Targets { + switch target.TargetType { + case TargetTypePeer, TargetTypeHost, TargetTypeDomain: + // Host is normally overwritten by replaceHostByLookup with the + // resolved peer IP / resource address; operator-supplied values + // are honored only when DirectUpstream is set. Validate the + // override here so misconfigured hosts fail fast at API time. + if err := validateDirectUpstreamHost(i, target); err != nil { + return err + } + case TargetTypeSubnet: + if target.Host == "" { + return fmt.Errorf("target %d has empty host but target_type is %q", i, target.TargetType) + } + case TargetTypeCluster: + if err := validateClusterTarget(i, target); err != nil { + return err + } + default: + return fmt.Errorf("target %d has invalid target_type %q", i, target.TargetType) + } + if target.TargetId == "" { + return fmt.Errorf("target %d has empty target_id", i) + } + if target.ProxyProtocol { + return fmt.Errorf("target %d: proxy_protocol is not supported for HTTP services", i) + } + if err := validateTargetOptions(i, &target.Options); err != nil { + return err + } + } + + return nil +} + +// validateClusterTarget cluster targets should not have empty hosts and should have direct upstream enabled. +func validateClusterTarget(idx int, target *Target) error { + host := strings.TrimSpace(target.Host) + if host == "" { + return fmt.Errorf("target %d: has empty host", idx) + } + if !target.Options.DirectUpstream { + return fmt.Errorf("target %d: %s has direct upstream disabled", idx, target.Host) + } + return validateDirectUpstreamHost(idx, target) +} + +// validateDirectUpstreamHost validates the operator-supplied Host on a +// peer/host/domain target when DirectUpstream is set. Empty Host is +// allowed — the lookup fills in the default peer IP / resource address. +// Without DirectUpstream the Host value is silently overwritten by +// replaceHostByLookup, so we don't validate it (preserves the historical +// behaviour where APIs accepted any value and dropped it). Non-empty +// Host with DirectUpstream must look like a hostname or IP and must +// not carry a port (port lives on Target.Port). +func validateDirectUpstreamHost(idx int, target *Target) error { + if !target.Options.DirectUpstream { + return nil + } + host := strings.TrimSpace(target.Host) + if host == "" { + return nil + } + if strings.ContainsAny(host, " \t/") { + return fmt.Errorf("target %d: host %q contains invalid characters", idx, host) + } + if _, _, err := net.SplitHostPort(host); err == nil { + return fmt.Errorf("target %d: host %q must not include a port (set target.port instead)", idx, host) + } + return nil +} + +func (s *Service) validateL4Target(target *Target) error { + // L4 services have a single target; per-target disable is meaningless + // (use the service-level Enabled flag instead). Force it on so that + // buildPathMappings always includes the target in the proto. + target.Enabled = true + + if target.TargetId == "" { + return errors.New("target_id is required for L4 services") + } + // Cluster targets resolve their upstream host:port from the target's + // own Host/Port fields just like the other L4 types — buildPathMappings + // emits net.JoinHostPort(target.Host, target.Port) for every L4 + // target, so allowing port=0 here would let ":0" reach the proxy. + if target.Port == 0 { + return errors.New("target port is required for L4 services") + } + switch target.TargetType { + case TargetTypePeer, TargetTypeHost, TargetTypeDomain: + if err := validateDirectUpstreamHost(0, target); err != nil { + return err + } + case TargetTypeSubnet: + if target.Host == "" { + return errors.New("target host is required for subnet targets") + } + case TargetTypeCluster: + // target_id carries the cluster address; the proxy resolves + // the upstream at request time. + default: + return fmt.Errorf("invalid target_type %q for L4 service", target.TargetType) + } + if target.Path != nil && *target.Path != "" && *target.Path != "/" { + return errors.New("path is not supported for L4 services") + } + if target.Options.SessionIdleTimeout < 0 { + return errors.New("session_idle_timeout must be positive for L4 services") + } + if target.Options.RequestTimeout < 0 { + return errors.New("request_timeout must be positive for L4 services") + } + if target.Options.SkipTLSVerify { + return errors.New("skip_tls_verify is not supported for L4 services") + } + if target.Options.PathRewrite != "" { + return errors.New("path_rewrite is not supported for L4 services") + } + if len(target.Options.CustomHeaders) > 0 { + return errors.New("custom_headers is not supported for L4 services") + } + return nil +} + +// Service mode constants. +const ( + ModeHTTP = "http" + ModeTCP = "tcp" + ModeUDP = "udp" + ModeTLS = "tls" +) + +// Target protocol constants (URL scheme for backend connections). +const ( + TargetProtoHTTP = "http" + TargetProtoHTTPS = "https" + TargetProtoTCP = "tcp" + TargetProtoUDP = "udp" +) + +// IsL4Protocol returns true if the mode requires port-based routing (TCP, UDP, or TLS). +func IsL4Protocol(mode string) bool { + return mode == ModeTCP || mode == ModeUDP || mode == ModeTLS +} + +// IsPortBasedProtocol returns true if the mode relies on dedicated port allocation. +// TLS is excluded because it uses SNI routing and can share ports with other TLS services. +func IsPortBasedProtocol(mode string) bool { + return mode == ModeTCP || mode == ModeUDP +} + +const ( + maxCustomHeaders = 16 + maxHeaderKeyLen = 128 + maxHeaderValueLen = 4096 +) + +// httpHeaderNameRe matches valid HTTP header field names per RFC 7230 token definition. +var httpHeaderNameRe = regexp.MustCompile(`^[!#$%&'*+\-.^_` + "`" + `|~0-9A-Za-z]+$`) + +// hopByHopHeaders are headers that must not be set as custom headers +// because they are connection-level and stripped by the proxy. +var hopByHopHeaders = map[string]struct{}{ + "Connection": {}, + "Keep-Alive": {}, + "Proxy-Authenticate": {}, + "Proxy-Authorization": {}, + "Proxy-Connection": {}, + "Te": {}, + "Trailer": {}, + "Transfer-Encoding": {}, + "Upgrade": {}, +} + +// reservedHeaders are set authoritatively by the proxy or control HTTP framing +// and cannot be overridden. +var reservedHeaders = map[string]struct{}{ + "Content-Length": {}, + "Content-Type": {}, + "Cookie": {}, + "Forwarded": {}, + "X-Forwarded-For": {}, + "X-Forwarded-Host": {}, + "X-Forwarded-Port": {}, + "X-Forwarded-Proto": {}, + "X-Real-Ip": {}, +} + +func validateTargetOptions(idx int, opts *TargetOptions) error { + if opts.PathRewrite != "" && opts.PathRewrite != PathRewritePreserve { + return fmt.Errorf("target %d: unknown path_rewrite mode %q", idx, opts.PathRewrite) + } + + if opts.RequestTimeout < 0 { + return fmt.Errorf("target %d: request_timeout must be positive", idx) + } + + if opts.SessionIdleTimeout < 0 { + return fmt.Errorf("target %d: session_idle_timeout must be positive", idx) + } + + if err := validateCustomHeaders(idx, opts.CustomHeaders); err != nil { + return err + } + + return nil +} + +func validateCustomHeaders(idx int, headers map[string]string) error { + if len(headers) > maxCustomHeaders { + return fmt.Errorf("target %d: custom_headers count %d exceeds maximum of %d", idx, len(headers), maxCustomHeaders) + } + seen := make(map[string]string, len(headers)) + for key, value := range headers { + if !httpHeaderNameRe.MatchString(key) { + return fmt.Errorf("target %d: custom header key %q is not a valid HTTP header name", idx, key) + } + if len(key) > maxHeaderKeyLen { + return fmt.Errorf("target %d: custom header key %q exceeds maximum length of %d", idx, key, maxHeaderKeyLen) + } + if len(value) > maxHeaderValueLen { + return fmt.Errorf("target %d: custom header %q value exceeds maximum length of %d", idx, key, maxHeaderValueLen) + } + if containsCRLF(key) || containsCRLF(value) { + return fmt.Errorf("target %d: custom header %q contains invalid characters", idx, key) + } + canonical := http.CanonicalHeaderKey(key) + if prev, ok := seen[canonical]; ok { + return fmt.Errorf("target %d: custom header keys %q and %q collide (both canonicalize to %q)", idx, prev, key, canonical) + } + seen[canonical] = key + if _, ok := hopByHopHeaders[canonical]; ok { + return fmt.Errorf("target %d: custom header %q is a hop-by-hop header and cannot be set", idx, key) + } + if _, ok := reservedHeaders[canonical]; ok { + return fmt.Errorf("target %d: custom header %q is managed by the proxy and cannot be overridden", idx, key) + } + if canonical == "Host" { + return fmt.Errorf("target %d: use pass_host_header instead of setting Host as a custom header", idx) + } + } + return nil +} + +func containsCRLF(s string) bool { + return strings.ContainsAny(s, "\r\n") +} + +func validateHeaderAuths(headers []*HeaderAuthConfig) error { + for i, h := range headers { + if h == nil || !h.Enabled { + continue + } + if h.Header == "" { + return fmt.Errorf("header_auths[%d]: header name is required", i) + } + if !httpHeaderNameRe.MatchString(h.Header) { + return fmt.Errorf("header_auths[%d]: header name %q is not a valid HTTP header name", i, h.Header) + } + canonical := http.CanonicalHeaderKey(h.Header) + if _, ok := hopByHopHeaders[canonical]; ok { + return fmt.Errorf("header_auths[%d]: header %q is a hop-by-hop header and cannot be used for auth", i, h.Header) + } + if _, ok := reservedHeaders[canonical]; ok { + return fmt.Errorf("header_auths[%d]: header %q is managed by the proxy and cannot be used for auth", i, h.Header) + } + if canonical == "Host" { + return fmt.Errorf("header_auths[%d]: Host header cannot be used for auth", i) + } + if len(h.Value) > maxHeaderValueLen { + return fmt.Errorf("header_auths[%d]: value exceeds maximum length of %d", i, maxHeaderValueLen) + } + } + return nil +} + +const ( + maxCIDREntries = 200 + maxCountryEntries = 50 +) + +// validateAccessRestrictions validates and normalizes access restriction +// entries. Country codes are uppercased in place. +func validateCrowdSecMode(mode string) error { + switch mode { + case "", "off", "enforce", "observe": + return nil + default: + return fmt.Errorf("crowdsec_mode %q is invalid", mode) + } +} + +func validateAccessRestrictions(r *AccessRestrictions) error { + if err := validateCrowdSecMode(r.CrowdSecMode); err != nil { + return err + } + + if len(r.AllowedCIDRs) > maxCIDREntries { + return fmt.Errorf("allowed_cidrs: exceeds maximum of %d entries", maxCIDREntries) + } + if len(r.BlockedCIDRs) > maxCIDREntries { + return fmt.Errorf("blocked_cidrs: exceeds maximum of %d entries", maxCIDREntries) + } + if len(r.AllowedCountries) > maxCountryEntries { + return fmt.Errorf("allowed_countries: exceeds maximum of %d entries", maxCountryEntries) + } + if len(r.BlockedCountries) > maxCountryEntries { + return fmt.Errorf("blocked_countries: exceeds maximum of %d entries", maxCountryEntries) + } + + if err := validateCIDRList("allowed_cidrs", r.AllowedCIDRs); err != nil { + return err + } + if err := validateCIDRList("blocked_cidrs", r.BlockedCIDRs); err != nil { + return err + } + if err := normalizeCountryList("allowed_countries", r.AllowedCountries); err != nil { + return err + } + return normalizeCountryList("blocked_countries", r.BlockedCountries) +} + +func validateCIDRList(field string, cidrs []string) error { + for i, raw := range cidrs { + prefix, err := netip.ParsePrefix(raw) + if err != nil { + return fmt.Errorf("%s[%d]: %w", field, i, err) + } + if prefix != prefix.Masked() { + return fmt.Errorf("%s[%d]: %q has host bits set, use %s instead", field, i, raw, prefix.Masked()) + } + } + return nil +} + +func normalizeCountryList(field string, codes []string) error { + for i, code := range codes { + if len(code) != 2 { + return fmt.Errorf("%s[%d]: %q must be a 2-letter ISO 3166-1 alpha-2 code", field, i, code) + } + codes[i] = strings.ToUpper(code) + } + return nil +} + +func (s *Service) EventMeta() map[string]any { + meta := map[string]any{ + "name": s.Name, + "domain": s.Domain, + "proxy_cluster": s.ProxyCluster, + "source": s.Source, + "auth": s.isAuthEnabled(), + "mode": s.Mode, + } + + if s.ListenPort != 0 { + meta["listen_port"] = s.ListenPort + } + + if len(s.Targets) > 0 { + t := s.Targets[0] + if t.ProxyProtocol { + meta["proxy_protocol"] = true + } + if t.Options.RequestTimeout != 0 { + meta["request_timeout"] = t.Options.RequestTimeout.String() + } + if t.Options.SessionIdleTimeout != 0 { + meta["session_idle_timeout"] = t.Options.SessionIdleTimeout.String() + } + } + + return meta +} + +func (s *Service) isAuthEnabled() bool { + if (s.Auth.PasswordAuth != nil && s.Auth.PasswordAuth.Enabled) || + (s.Auth.PinAuth != nil && s.Auth.PinAuth.Enabled) || + (s.Auth.BearerAuth != nil && s.Auth.BearerAuth.Enabled) { + return true + } + for _, h := range s.Auth.HeaderAuths { + if h != nil && h.Enabled { + return true + } + } + return false +} + +func (s *Service) Copy() *Service { + targets := make([]*Target, len(s.Targets)) + for i, target := range s.Targets { + targetCopy := *target + if target.Path != nil { + p := *target.Path + targetCopy.Path = &p + } + if len(target.Options.CustomHeaders) > 0 { + targetCopy.Options.CustomHeaders = make(map[string]string, len(target.Options.CustomHeaders)) + for k, v := range target.Options.CustomHeaders { + targetCopy.Options.CustomHeaders[k] = v + } + } + targets[i] = &targetCopy + } + + authCopy := s.Auth + if s.Auth.PasswordAuth != nil { + pa := *s.Auth.PasswordAuth + authCopy.PasswordAuth = &pa + } + if s.Auth.PinAuth != nil { + pa := *s.Auth.PinAuth + authCopy.PinAuth = &pa + } + if s.Auth.BearerAuth != nil { + ba := *s.Auth.BearerAuth + if len(s.Auth.BearerAuth.DistributionGroups) > 0 { + ba.DistributionGroups = make([]string, len(s.Auth.BearerAuth.DistributionGroups)) + copy(ba.DistributionGroups, s.Auth.BearerAuth.DistributionGroups) + } + authCopy.BearerAuth = &ba + } + if len(s.Auth.HeaderAuths) > 0 { + authCopy.HeaderAuths = make([]*HeaderAuthConfig, len(s.Auth.HeaderAuths)) + for i, h := range s.Auth.HeaderAuths { + if h == nil { + continue + } + hCopy := *h + authCopy.HeaderAuths[i] = &hCopy + } + } + + var accessGroups []string + if len(s.AccessGroups) > 0 { + accessGroups = append([]string(nil), s.AccessGroups...) + } + + return &Service{ + ID: s.ID, + AccountID: s.AccountID, + Name: s.Name, + Domain: s.Domain, + ProxyCluster: s.ProxyCluster, + Targets: targets, + Enabled: s.Enabled, + Terminated: s.Terminated, + PassHostHeader: s.PassHostHeader, + RewriteRedirects: s.RewriteRedirects, + Auth: authCopy, + Restrictions: s.Restrictions.Copy(), + Meta: s.Meta, + SessionPrivateKey: s.SessionPrivateKey, + SessionPublicKey: s.SessionPublicKey, + Source: s.Source, + SourcePeer: s.SourcePeer, + Mode: s.Mode, + ListenPort: s.ListenPort, + PortAutoAssigned: s.PortAutoAssigned, + Private: s.Private, + AccessGroups: accessGroups, + } +} + +func (s *Service) EncryptSensitiveData(enc *crypt.FieldEncrypt) error { + if enc == nil { + return nil + } + + if s.SessionPrivateKey != "" { + var err error + s.SessionPrivateKey, err = enc.Encrypt(s.SessionPrivateKey) + if err != nil { + return err + } + } + + return nil +} + +func (s *Service) DecryptSensitiveData(enc *crypt.FieldEncrypt) error { + if enc == nil { + return nil + } + + if s.SessionPrivateKey != "" { + var err error + s.SessionPrivateKey, err = enc.Decrypt(s.SessionPrivateKey) + if err != nil { + return err + } + } + + return nil +} + +var pinRegexp = regexp.MustCompile(`^\d{6}$`) + +const alphanumCharset = "abcdefghijklmnopqrstuvwxyz0123456789" + +var validNamePrefix = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$`) + +// ExposeServiceRequest contains the parameters for creating a peer-initiated expose service. +type ExposeServiceRequest struct { + NamePrefix string + Port uint16 + Mode string + // TargetProtocol is the protocol used to connect to the peer backend. + // For HTTP mode: "http" (default) or "https". For L4 modes: "tcp" or "udp". + TargetProtocol string + Domain string + Pin string + Password string + UserGroups []string + ListenPort uint16 +} + +// Validate checks all fields of the expose request. +func (r *ExposeServiceRequest) Validate() error { + if r == nil { + return errors.New("request cannot be nil") + } + + if r.Port == 0 { + return fmt.Errorf("port must be between 1 and 65535, got %d", r.Port) + } + + switch r.Mode { + case ModeHTTP, ModeTCP, ModeUDP, ModeTLS: + default: + return fmt.Errorf("unsupported mode %q", r.Mode) + } + + if IsL4Protocol(r.Mode) { + if r.Pin != "" || r.Password != "" || len(r.UserGroups) > 0 { + return fmt.Errorf("authentication is not supported for %s mode", r.Mode) + } + } + + if r.Pin != "" && !pinRegexp.MatchString(r.Pin) { + return errors.New("invalid pin: must be exactly 6 digits") + } + + for _, g := range r.UserGroups { + if g == "" { + return errors.New("user group name cannot be empty") + } + } + + if r.NamePrefix != "" && !validNamePrefix.MatchString(r.NamePrefix) { + return fmt.Errorf("invalid name prefix %q: must be lowercase alphanumeric with optional hyphens, 1-32 characters", r.NamePrefix) + } + + return nil +} + +// ToService builds a Service from the expose request. +func (r *ExposeServiceRequest) ToService(accountID, peerID, serviceName string) *Service { + svc := &Service{ + AccountID: accountID, + Name: serviceName, + Mode: r.Mode, + Enabled: true, + } + + // If domain is empty, CreateServiceFromPeer generates a unique subdomain. + // When explicitly provided, the service name is prepended as a subdomain. + if r.Domain != "" { + svc.Domain = serviceName + "." + r.Domain + } + + if IsL4Protocol(r.Mode) { + svc.ListenPort = r.Port + if r.ListenPort > 0 { + svc.ListenPort = r.ListenPort + } + } + + var targetProto string + switch { + case !IsL4Protocol(r.Mode): + targetProto = TargetProtoHTTP + if r.TargetProtocol != "" { + targetProto = r.TargetProtocol + } + case r.Mode == ModeUDP: + targetProto = TargetProtoUDP + default: + targetProto = TargetProtoTCP + } + svc.Targets = []*Target{ + { + AccountID: accountID, + Port: r.Port, + Protocol: targetProto, + TargetId: peerID, + TargetType: TargetTypePeer, + Enabled: true, + }, + } + + if r.Pin != "" { + svc.Auth.PinAuth = &PINAuthConfig{ + Enabled: true, + Pin: r.Pin, + } + } + + if r.Password != "" { + svc.Auth.PasswordAuth = &PasswordAuthConfig{ + Enabled: true, + Password: r.Password, + } + } + + if len(r.UserGroups) > 0 { + svc.Auth.BearerAuth = &BearerAuthConfig{ + Enabled: true, + DistributionGroups: r.UserGroups, + } + } + + return svc +} + +// ExposeServiceResponse contains the result of a successful peer expose creation. +type ExposeServiceResponse struct { + ServiceName string + ServiceURL string + Domain string + PortAutoAssigned bool +} + +// GenerateExposeName generates a random service name for peer-exposed services. +// The prefix, if provided, must be a valid DNS label component (lowercase alphanumeric and hyphens). +func GenerateExposeName(prefix string) (string, error) { + if prefix != "" && !validNamePrefix.MatchString(prefix) { + return "", fmt.Errorf("invalid name prefix %q: must be lowercase alphanumeric with optional hyphens, 1-32 characters", prefix) + } + + suffixLen := 12 + if prefix != "" { + suffixLen = 4 + } + + suffix, err := randomAlphanumeric(suffixLen) + if err != nil { + return "", fmt.Errorf("generate random name: %w", err) + } + + if prefix == "" { + return suffix, nil + } + return prefix + "-" + suffix, nil +} + +func randomAlphanumeric(n int) (string, error) { + result := make([]byte, n) + charsetLen := big.NewInt(int64(len(alphanumCharset))) + for i := range result { + idx, err := rand.Int(rand.Reader, charsetLen) + if err != nil { + return "", err + } + result[i] = alphanumCharset[idx.Int64()] + } + return string(result), nil +} diff --git a/management/internals/modules/reverseproxy/service/service_test.go b/management/internals/modules/reverseproxy/service/service_test.go new file mode 100644 index 000000000..a149ac609 --- /dev/null +++ b/management/internals/modules/reverseproxy/service/service_test.go @@ -0,0 +1,1317 @@ +package service + +import ( + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/shared/hash/argon2id" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/proto" +) + +func validProxy() *Service { + return &Service{ + Name: "test", + Domain: "example.com", + Targets: []*Target{ + {TargetId: "peer-1", TargetType: TargetTypePeer, Host: "10.0.0.1", Port: 80, Protocol: "http", Enabled: true}, + }, + } +} + +func TestValidate_Valid(t *testing.T) { + require.NoError(t, validProxy().Validate()) +} + +func TestValidate_EmptyName(t *testing.T) { + rp := validProxy() + rp.Name = "" + assert.ErrorContains(t, rp.Validate(), "name is required") +} + +func TestValidate_EmptyDomain(t *testing.T) { + rp := validProxy() + rp.Domain = "" + assert.ErrorContains(t, rp.Validate(), "domain is required") +} + +func TestValidate_NoTargets(t *testing.T) { + rp := validProxy() + rp.Targets = nil + assert.ErrorContains(t, rp.Validate(), "at least one target is required") +} + +func TestValidate_EmptyTargetId(t *testing.T) { + rp := validProxy() + rp.Targets[0].TargetId = "" + assert.ErrorContains(t, rp.Validate(), "empty target_id") +} + +func TestValidate_InvalidTargetType(t *testing.T) { + rp := validProxy() + rp.Targets[0].TargetType = "invalid" + assert.ErrorContains(t, rp.Validate(), "invalid target_type") +} + +func TestValidate_ResourceTarget(t *testing.T) { + rp := validProxy() + rp.Targets = append(rp.Targets, &Target{ + TargetId: "resource-1", + TargetType: TargetTypeHost, + Host: "example.org", + Port: 443, + Protocol: "https", + Enabled: true, + }) + require.NoError(t, rp.Validate()) +} + +func TestValidate_MultipleTargetsOneInvalid(t *testing.T) { + rp := validProxy() + rp.Targets = append(rp.Targets, &Target{ + TargetId: "", + TargetType: TargetTypePeer, + Host: "10.0.0.2", + Port: 80, + Protocol: "http", + Enabled: true, + }) + err := rp.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "target 1") + assert.Contains(t, err.Error(), "empty target_id") +} + +func TestValidateTargetOptions_PathRewrite(t *testing.T) { + tests := []struct { + name string + mode PathRewriteMode + wantErr string + }{ + {"empty is default", "", ""}, + {"preserve is valid", PathRewritePreserve, ""}, + {"unknown rejected", "regex", "unknown path_rewrite mode"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rp := validProxy() + rp.Targets[0].Options.PathRewrite = tt.mode + err := rp.Validate() + if tt.wantErr == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tt.wantErr) + } + }) + } +} + +func TestValidateTargetOptions_RequestTimeout(t *testing.T) { + tests := []struct { + name string + timeout time.Duration + wantErr string + }{ + {"valid 30s", 30 * time.Second, ""}, + {"valid 2m", 2 * time.Minute, ""}, + {"valid 10m", 10 * time.Minute, ""}, + {"zero is fine", 0, ""}, + {"negative", -1 * time.Second, "must be positive"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rp := validProxy() + rp.Targets[0].Options.RequestTimeout = tt.timeout + err := rp.Validate() + if tt.wantErr == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tt.wantErr) + } + }) + } +} + +func TestValidateTargetOptions_CustomHeaders(t *testing.T) { + t.Run("valid headers", func(t *testing.T) { + rp := validProxy() + rp.Targets[0].Options.CustomHeaders = map[string]string{ + "X-Custom": "value", + "X-Trace": "abc123", + } + assert.NoError(t, rp.Validate()) + }) + + t.Run("CRLF in key", func(t *testing.T) { + rp := validProxy() + rp.Targets[0].Options.CustomHeaders = map[string]string{"X-Bad\r\nKey": "value"} + assert.ErrorContains(t, rp.Validate(), "not a valid HTTP header name") + }) + + t.Run("CRLF in value", func(t *testing.T) { + rp := validProxy() + rp.Targets[0].Options.CustomHeaders = map[string]string{"X-Good": "bad\nvalue"} + assert.ErrorContains(t, rp.Validate(), "invalid characters") + }) + + t.Run("hop-by-hop header rejected", func(t *testing.T) { + for _, h := range []string{"Connection", "Transfer-Encoding", "Keep-Alive", "Upgrade", "Proxy-Connection"} { + rp := validProxy() + rp.Targets[0].Options.CustomHeaders = map[string]string{h: "value"} + assert.ErrorContains(t, rp.Validate(), "hop-by-hop", "header %q should be rejected", h) + } + }) + + t.Run("reserved header rejected", func(t *testing.T) { + for _, h := range []string{"X-Forwarded-For", "X-Real-IP", "X-Forwarded-Proto", "X-Forwarded-Host", "X-Forwarded-Port", "Cookie", "Forwarded", "Content-Length", "Content-Type"} { + rp := validProxy() + rp.Targets[0].Options.CustomHeaders = map[string]string{h: "value"} + assert.ErrorContains(t, rp.Validate(), "managed by the proxy", "header %q should be rejected", h) + } + }) + + t.Run("Host header rejected", func(t *testing.T) { + rp := validProxy() + rp.Targets[0].Options.CustomHeaders = map[string]string{"Host": "evil.com"} + assert.ErrorContains(t, rp.Validate(), "pass_host_header") + }) + + t.Run("too many headers", func(t *testing.T) { + rp := validProxy() + headers := make(map[string]string, 17) + for i := range 17 { + headers[fmt.Sprintf("X-H%d", i)] = "v" + } + rp.Targets[0].Options.CustomHeaders = headers + assert.ErrorContains(t, rp.Validate(), "exceeds maximum of 16") + }) + + t.Run("key too long", func(t *testing.T) { + rp := validProxy() + rp.Targets[0].Options.CustomHeaders = map[string]string{strings.Repeat("X", 129): "v"} + assert.ErrorContains(t, rp.Validate(), "key") + assert.ErrorContains(t, rp.Validate(), "exceeds maximum length") + }) + + t.Run("value too long", func(t *testing.T) { + rp := validProxy() + rp.Targets[0].Options.CustomHeaders = map[string]string{"X-Ok": strings.Repeat("v", 4097)} + assert.ErrorContains(t, rp.Validate(), "value exceeds maximum length") + }) + + t.Run("duplicate canonical keys rejected", func(t *testing.T) { + rp := validProxy() + rp.Targets[0].Options.CustomHeaders = map[string]string{ + "x-custom": "a", + "X-Custom": "b", + } + assert.ErrorContains(t, rp.Validate(), "collide") + }) +} + +func TestToProtoMapping_TargetOptions(t *testing.T) { + rp := &Service{ + ID: "svc-1", + AccountID: "acc-1", + Domain: "example.com", + Targets: []*Target{ + { + TargetId: "peer-1", + TargetType: TargetTypePeer, + Host: "10.0.0.1", + Port: 8080, + Protocol: "http", + Enabled: true, + Options: TargetOptions{ + SkipTLSVerify: true, + RequestTimeout: 30 * time.Second, + PathRewrite: PathRewritePreserve, + CustomHeaders: map[string]string{"X-Custom": "val"}, + }, + }, + }, + } + pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{}) + require.Len(t, pm.Path, 1) + + opts := pm.Path[0].Options + require.NotNil(t, opts, "options should be populated") + assert.True(t, opts.SkipTlsVerify) + assert.Equal(t, proto.PathRewriteMode_PATH_REWRITE_PRESERVE, opts.PathRewrite) + assert.Equal(t, map[string]string{"X-Custom": "val"}, opts.CustomHeaders) + require.NotNil(t, opts.RequestTimeout) + assert.Equal(t, int64(30), opts.RequestTimeout.Seconds) +} + +func TestToProtoMapping_NoOptionsWhenDefault(t *testing.T) { + rp := &Service{ + ID: "svc-1", + AccountID: "acc-1", + Domain: "example.com", + Targets: []*Target{ + { + TargetId: "peer-1", + TargetType: TargetTypePeer, + Host: "10.0.0.1", + Port: 8080, + Protocol: "http", + Enabled: true, + }, + }, + } + pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{}) + require.Len(t, pm.Path, 1) + assert.Nil(t, pm.Path[0].Options, "options should be nil when all defaults") +} + +func TestIsDefaultPort(t *testing.T) { + tests := []struct { + scheme string + port uint16 + want bool + }{ + {"http", 80, true}, + {"https", 443, true}, + {"http", 443, false}, + {"https", 80, false}, + {"http", 8080, false}, + {"https", 8443, false}, + {"http", 0, false}, + {"https", 0, false}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("%s/%d", tt.scheme, tt.port), func(t *testing.T) { + assert.Equal(t, tt.want, isDefaultPort(tt.scheme, tt.port)) + }) + } +} + +func TestToProtoMapping_PortInTargetURL(t *testing.T) { + oidcConfig := proxy.OIDCValidationConfig{} + + tests := []struct { + name string + protocol string + host string + port uint16 + wantTarget string + }{ + { + name: "http with default port 80 omits port", + protocol: "http", + host: "10.0.0.1", + port: 80, + wantTarget: "http://10.0.0.1/", + }, + { + name: "https with default port 443 omits port", + protocol: "https", + host: "10.0.0.1", + port: 443, + wantTarget: "https://10.0.0.1/", + }, + { + name: "port 0 omits port", + protocol: "http", + host: "10.0.0.1", + port: 0, + wantTarget: "http://10.0.0.1/", + }, + { + name: "non-default port is included", + protocol: "http", + host: "10.0.0.1", + port: 8080, + wantTarget: "http://10.0.0.1:8080/", + }, + { + name: "https with non-default port is included", + protocol: "https", + host: "10.0.0.1", + port: 8443, + wantTarget: "https://10.0.0.1:8443/", + }, + { + name: "http port 443 is included", + protocol: "http", + host: "10.0.0.1", + port: 443, + wantTarget: "http://10.0.0.1:443/", + }, + { + name: "https port 80 is included", + protocol: "https", + host: "10.0.0.1", + port: 80, + wantTarget: "https://10.0.0.1:80/", + }, + { + name: "domain host without port is unchanged", + protocol: "http", + host: "example.com", + port: 0, + wantTarget: "http://example.com/", + }, + { + name: "domain host with non-default port is unchanged", + protocol: "http", + host: "example.com", + port: 8080, + wantTarget: "http://example.com:8080/", + }, + { + name: "ipv6 host without port is bracketed", + protocol: "http", + host: "fb00:cafe:1::3", + port: 0, + wantTarget: "http://[fb00:cafe:1::3]/", + }, + { + name: "ipv6 host with default port omits port and brackets host", + protocol: "http", + host: "fb00:cafe:1::3", + port: 80, + wantTarget: "http://[fb00:cafe:1::3]/", + }, + { + name: "ipv6 host with non-default port is bracketed", + protocol: "http", + host: "fb00:cafe:1::3", + port: 8080, + wantTarget: "http://[fb00:cafe:1::3]:8080/", + }, + { + name: "ipv6 loopback without port is bracketed", + protocol: "http", + host: "::1", + port: 0, + wantTarget: "http://[::1]/", + }, + { + name: "ipv6 host with 5-digit port is bracketed", + protocol: "http", + host: "fb00:cafe::1", + port: 18080, + wantTarget: "http://[fb00:cafe::1]:18080/", + }, + { + name: "pre-bracketed ipv6 without port stays single-bracketed", + protocol: "http", + host: "[fb00:cafe::1]", + port: 0, + wantTarget: "http://[fb00:cafe::1]/", + }, + { + name: "pre-bracketed ipv6 with port is not double-bracketed", + protocol: "http", + host: "[fb00:cafe::1]", + port: 8080, + wantTarget: "http://[fb00:cafe::1]:8080/", + }, + { + name: "v4-mapped ipv6 host without port is bracketed", + protocol: "http", + host: "::ffff:10.0.0.1", + port: 0, + wantTarget: "http://[::ffff:10.0.0.1]/", + }, + { + name: "full-form 8-group ipv6 without port is bracketed", + protocol: "http", + host: "fb00:cafe:1:0:0:0:0:3", + port: 0, + wantTarget: "http://[fb00:cafe:1:0:0:0:0:3]/", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rp := &Service{ + ID: "test-id", + AccountID: "acc-1", + Domain: "example.com", + Targets: []*Target{ + { + TargetId: "peer-1", + TargetType: TargetTypePeer, + Host: tt.host, + Port: tt.port, + Protocol: tt.protocol, + Enabled: true, + }, + }, + } + pm := rp.ToProtoMapping(Create, "token", oidcConfig) + require.Len(t, pm.Path, 1, "should have one path mapping") + assert.Equal(t, tt.wantTarget, pm.Path[0].Target) + }) + } +} + +func TestToProtoMapping_DisabledTargetSkipped(t *testing.T) { + rp := &Service{ + ID: "test-id", + AccountID: "acc-1", + Domain: "example.com", + Targets: []*Target{ + {TargetId: "peer-1", TargetType: TargetTypePeer, Host: "10.0.0.1", Port: 8080, Protocol: "http", Enabled: false}, + {TargetId: "peer-2", TargetType: TargetTypePeer, Host: "10.0.0.2", Port: 9090, Protocol: "http", Enabled: true}, + }, + } + pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{}) + require.Len(t, pm.Path, 1) + assert.Equal(t, "http://10.0.0.2:9090/", pm.Path[0].Target) +} + +func TestToProtoMapping_OperationTypes(t *testing.T) { + rp := validProxy() + tests := []struct { + op Operation + want proto.ProxyMappingUpdateType + }{ + {Create, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED}, + {Update, proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED}, + {Delete, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED}, + } + for _, tt := range tests { + t.Run(string(tt.op), func(t *testing.T) { + pm := rp.ToProtoMapping(tt.op, "", proxy.OIDCValidationConfig{}) + assert.Equal(t, tt.want, pm.Type) + }) + } +} + +func TestAuthConfig_HashSecrets(t *testing.T) { + tests := []struct { + name string + config *AuthConfig + wantErr bool + validate func(*testing.T, *AuthConfig) + }{ + { + name: "hash password successfully", + config: &AuthConfig{ + PasswordAuth: &PasswordAuthConfig{ + Enabled: true, + Password: "testPassword123", + }, + }, + wantErr: false, + validate: func(t *testing.T, config *AuthConfig) { + if !strings.HasPrefix(config.PasswordAuth.Password, "$argon2id$") { + t.Errorf("Password not hashed with argon2id, got: %s", config.PasswordAuth.Password) + } + // Verify the hash can be verified + if err := argon2id.Verify("testPassword123", config.PasswordAuth.Password); err != nil { + t.Errorf("Hash verification failed: %v", err) + } + }, + }, + { + name: "hash PIN successfully", + config: &AuthConfig{ + PinAuth: &PINAuthConfig{ + Enabled: true, + Pin: "123456", + }, + }, + wantErr: false, + validate: func(t *testing.T, config *AuthConfig) { + if !strings.HasPrefix(config.PinAuth.Pin, "$argon2id$") { + t.Errorf("PIN not hashed with argon2id, got: %s", config.PinAuth.Pin) + } + // Verify the hash can be verified + if err := argon2id.Verify("123456", config.PinAuth.Pin); err != nil { + t.Errorf("Hash verification failed: %v", err) + } + }, + }, + { + name: "hash both password and PIN", + config: &AuthConfig{ + PasswordAuth: &PasswordAuthConfig{ + Enabled: true, + Password: "password", + }, + PinAuth: &PINAuthConfig{ + Enabled: true, + Pin: "9999", + }, + }, + wantErr: false, + validate: func(t *testing.T, config *AuthConfig) { + if !strings.HasPrefix(config.PasswordAuth.Password, "$argon2id$") { + t.Errorf("Password not hashed with argon2id") + } + if !strings.HasPrefix(config.PinAuth.Pin, "$argon2id$") { + t.Errorf("PIN not hashed with argon2id") + } + if err := argon2id.Verify("password", config.PasswordAuth.Password); err != nil { + t.Errorf("Password hash verification failed: %v", err) + } + if err := argon2id.Verify("9999", config.PinAuth.Pin); err != nil { + t.Errorf("PIN hash verification failed: %v", err) + } + }, + }, + { + name: "skip disabled password auth", + config: &AuthConfig{ + PasswordAuth: &PasswordAuthConfig{ + Enabled: false, + Password: "password", + }, + }, + wantErr: false, + validate: func(t *testing.T, config *AuthConfig) { + if config.PasswordAuth.Password != "password" { + t.Errorf("Disabled password auth should not be hashed") + } + }, + }, + { + name: "skip empty password", + config: &AuthConfig{ + PasswordAuth: &PasswordAuthConfig{ + Enabled: true, + Password: "", + }, + }, + wantErr: false, + validate: func(t *testing.T, config *AuthConfig) { + if config.PasswordAuth.Password != "" { + t.Errorf("Empty password should remain empty") + } + }, + }, + { + name: "skip nil password auth", + config: &AuthConfig{ + PasswordAuth: nil, + PinAuth: &PINAuthConfig{ + Enabled: true, + Pin: "1234", + }, + }, + wantErr: false, + validate: func(t *testing.T, config *AuthConfig) { + if config.PasswordAuth != nil { + t.Errorf("PasswordAuth should remain nil") + } + if !strings.HasPrefix(config.PinAuth.Pin, "$argon2id$") { + t.Errorf("PIN should still be hashed") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.HashSecrets() + if (err != nil) != tt.wantErr { + t.Errorf("HashSecrets() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.validate != nil { + tt.validate(t, tt.config) + } + }) + } +} + +func TestAuthConfig_HashSecrets_VerifyIncorrectSecret(t *testing.T) { + config := &AuthConfig{ + PasswordAuth: &PasswordAuthConfig{ + Enabled: true, + Password: "correctPassword", + }, + } + + if err := config.HashSecrets(); err != nil { + t.Fatalf("HashSecrets() error = %v", err) + } + + // Verify with wrong password should fail + err := argon2id.Verify("wrongPassword", config.PasswordAuth.Password) + if !errors.Is(err, argon2id.ErrMismatchedHashAndPassword) { + t.Errorf("Expected ErrMismatchedHashAndPassword, got %v", err) + } +} + +func TestAuthConfig_ClearSecrets(t *testing.T) { + config := &AuthConfig{ + PasswordAuth: &PasswordAuthConfig{ + Enabled: true, + Password: "hashedPassword", + }, + PinAuth: &PINAuthConfig{ + Enabled: true, + Pin: "hashedPin", + }, + } + + config.ClearSecrets() + + if config.PasswordAuth.Password != "" { + t.Errorf("Password not cleared, got: %s", config.PasswordAuth.Password) + } + if config.PinAuth.Pin != "" { + t.Errorf("PIN not cleared, got: %s", config.PinAuth.Pin) + } +} + +func TestGenerateExposeName(t *testing.T) { + t.Run("no prefix generates 12-char name", func(t *testing.T) { + name, err := GenerateExposeName("") + require.NoError(t, err) + assert.Len(t, name, 12) + assert.Regexp(t, `^[a-z0-9]+$`, name) + }) + + t.Run("with prefix generates prefix-XXXX", func(t *testing.T) { + name, err := GenerateExposeName("myapp") + require.NoError(t, err) + assert.True(t, strings.HasPrefix(name, "myapp-"), "name should start with prefix") + suffix := strings.TrimPrefix(name, "myapp-") + assert.Len(t, suffix, 4, "suffix should be 4 chars") + assert.Regexp(t, `^[a-z0-9]+$`, suffix) + }) + + t.Run("unique names", func(t *testing.T) { + names := make(map[string]bool) + for i := 0; i < 50; i++ { + name, err := GenerateExposeName("") + require.NoError(t, err) + names[name] = true + } + assert.Greater(t, len(names), 45, "should generate mostly unique names") + }) + + t.Run("valid prefixes", func(t *testing.T) { + validPrefixes := []string{"a", "ab", "a1", "my-app", "web-server-01", "a-b"} + for _, prefix := range validPrefixes { + name, err := GenerateExposeName(prefix) + assert.NoError(t, err, "prefix %q should be valid", prefix) + assert.True(t, strings.HasPrefix(name, prefix+"-"), "name should start with %q-", prefix) + } + }) + + t.Run("invalid prefixes", func(t *testing.T) { + invalidPrefixes := []string{ + "-starts-with-dash", + "ends-with-dash-", + "has.dots", + "HAS-UPPER", + "has spaces", + "has/slash", + "a--", + } + for _, prefix := range invalidPrefixes { + _, err := GenerateExposeName(prefix) + assert.Error(t, err, "prefix %q should be invalid", prefix) + assert.Contains(t, err.Error(), "invalid name prefix") + } + }) +} + +func TestExposeServiceRequest_ToService(t *testing.T) { + t.Run("basic HTTP service", func(t *testing.T) { + req := &ExposeServiceRequest{ + Port: 8080, + Mode: "http", + } + + service := req.ToService("account-1", "peer-1", "mysvc") + + assert.Equal(t, "account-1", service.AccountID) + assert.Equal(t, "mysvc", service.Name) + assert.True(t, service.Enabled) + assert.Empty(t, service.Domain, "domain should be empty when not specified") + require.Len(t, service.Targets, 1) + + target := service.Targets[0] + assert.Equal(t, uint16(8080), target.Port) + assert.Equal(t, "http", target.Protocol) + assert.Equal(t, "peer-1", target.TargetId) + assert.Equal(t, TargetTypePeer, target.TargetType) + assert.True(t, target.Enabled) + assert.Equal(t, "account-1", target.AccountID) + }) + + t.Run("with custom domain", func(t *testing.T) { + req := &ExposeServiceRequest{ + Port: 3000, + Domain: "example.com", + } + + service := req.ToService("acc", "peer", "web") + assert.Equal(t, "web.example.com", service.Domain) + }) + + t.Run("with PIN auth", func(t *testing.T) { + req := &ExposeServiceRequest{ + Port: 80, + Pin: "1234", + } + + service := req.ToService("acc", "peer", "svc") + require.NotNil(t, service.Auth.PinAuth) + assert.True(t, service.Auth.PinAuth.Enabled) + assert.Equal(t, "1234", service.Auth.PinAuth.Pin) + assert.Nil(t, service.Auth.PasswordAuth) + assert.Nil(t, service.Auth.BearerAuth) + }) + + t.Run("with password auth", func(t *testing.T) { + req := &ExposeServiceRequest{ + Port: 80, + Password: "secret", + } + + service := req.ToService("acc", "peer", "svc") + require.NotNil(t, service.Auth.PasswordAuth) + assert.True(t, service.Auth.PasswordAuth.Enabled) + assert.Equal(t, "secret", service.Auth.PasswordAuth.Password) + }) + + t.Run("with user groups (bearer auth)", func(t *testing.T) { + req := &ExposeServiceRequest{ + Port: 80, + UserGroups: []string{"admins", "devs"}, + } + + service := req.ToService("acc", "peer", "svc") + require.NotNil(t, service.Auth.BearerAuth) + assert.True(t, service.Auth.BearerAuth.Enabled) + assert.Equal(t, []string{"admins", "devs"}, service.Auth.BearerAuth.DistributionGroups) + }) + + t.Run("with all auth types", func(t *testing.T) { + req := &ExposeServiceRequest{ + Port: 443, + Domain: "myco.com", + Pin: "9999", + Password: "pass", + UserGroups: []string{"ops"}, + } + + service := req.ToService("acc", "peer", "full") + assert.Equal(t, "full.myco.com", service.Domain) + require.NotNil(t, service.Auth.PinAuth) + require.NotNil(t, service.Auth.PasswordAuth) + require.NotNil(t, service.Auth.BearerAuth) + }) +} + +func TestValidate_TLSOnly(t *testing.T) { + rp := &Service{ + Name: "tls-svc", + Mode: "tls", + Domain: "example.com", + ListenPort: 8443, + Targets: []*Target{ + {TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 443, Enabled: true}, + }, + } + require.NoError(t, rp.Validate()) +} + +func TestValidate_TLSMissingListenPort(t *testing.T) { + rp := &Service{ + Name: "tls-svc", + Mode: "tls", + Domain: "example.com", + ListenPort: 0, + Targets: []*Target{ + {TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 443, Enabled: true}, + }, + } + assert.ErrorContains(t, rp.Validate(), "listen_port is required") +} + +func TestValidate_TLSMissingDomain(t *testing.T) { + rp := &Service{ + Name: "tls-svc", + Mode: "tls", + ListenPort: 8443, + Targets: []*Target{ + {TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 443, Enabled: true}, + }, + } + assert.ErrorContains(t, rp.Validate(), "domain is required") +} + +func TestValidate_TCPValid(t *testing.T) { + rp := &Service{ + Name: "tcp-svc", + Mode: "tcp", + Domain: "cluster.test", + ListenPort: 5432, + Targets: []*Target{ + {TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true}, + }, + } + require.NoError(t, rp.Validate()) +} + +func TestValidate_TCPMissingListenPort(t *testing.T) { + rp := &Service{ + Name: "tcp-svc", + Mode: "tcp", + Domain: "cluster.test", + Targets: []*Target{ + {TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true}, + }, + } + require.NoError(t, rp.Validate(), "TCP with listen_port=0 is valid (auto-assigned by manager)") +} + +func TestValidate_L4MultipleTargets(t *testing.T) { + rp := &Service{ + Name: "tcp-svc", + Mode: "tcp", + Domain: "cluster.test", + ListenPort: 5432, + Targets: []*Target{ + {TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true}, + {TargetId: "peer-2", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true}, + }, + } + assert.ErrorContains(t, rp.Validate(), "exactly one target") +} + +func TestValidate_L4TargetMissingPort(t *testing.T) { + rp := &Service{ + Name: "tcp-svc", + Mode: "tcp", + Domain: "cluster.test", + ListenPort: 5432, + Targets: []*Target{ + {TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 0, Enabled: true}, + }, + } + assert.ErrorContains(t, rp.Validate(), "port is required") +} + +func TestValidate_TLSInvalidTargetType(t *testing.T) { + rp := &Service{ + Name: "tls-svc", + Mode: "tls", + Domain: "example.com", + ListenPort: 443, + Targets: []*Target{ + {TargetId: "peer-1", TargetType: "invalid", Protocol: "tcp", Port: 443, Enabled: true}, + }, + } + assert.Error(t, rp.Validate()) +} + +func TestValidate_TLSSubnetValid(t *testing.T) { + rp := &Service{ + Name: "tls-subnet", + Mode: "tls", + Domain: "example.com", + ListenPort: 8443, + Targets: []*Target{ + {TargetId: "subnet-1", TargetType: TargetTypeSubnet, Protocol: "tcp", Port: 443, Host: "10.0.0.5", Enabled: true}, + }, + } + require.NoError(t, rp.Validate()) +} + +func TestValidate_L4DomainTargetValid(t *testing.T) { + modes := []struct { + mode string + port uint16 + proto string + }{ + {"tcp", 5432, "tcp"}, + {"tls", 443, "tcp"}, + {"udp", 5432, "udp"}, + } + for _, m := range modes { + t.Run(m.mode, func(t *testing.T) { + rp := &Service{ + Name: m.mode + "-domain", + Mode: m.mode, + Domain: "cluster.test", + ListenPort: m.port, + Targets: []*Target{ + {TargetId: "resource-1", TargetType: TargetTypeDomain, Protocol: m.proto, Port: m.port, Enabled: true}, + }, + } + require.NoError(t, rp.Validate()) + }) + } +} + +func TestValidate_HTTPProxyProtocolRejected(t *testing.T) { + rp := validProxy() + rp.Targets[0].ProxyProtocol = true + assert.ErrorContains(t, rp.Validate(), "proxy_protocol is not supported for HTTP") +} + +func TestValidate_UDPProxyProtocolRejected(t *testing.T) { + rp := &Service{ + Name: "udp-svc", + Mode: "udp", + Domain: "cluster.test", + Targets: []*Target{ + {TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "udp", Port: 5432, Enabled: true, ProxyProtocol: true}, + }, + } + assert.ErrorContains(t, rp.Validate(), "proxy_protocol is not supported for UDP") +} + +func TestValidate_TCPProxyProtocolAllowed(t *testing.T) { + rp := &Service{ + Name: "tcp-svc", + Mode: "tcp", + Domain: "cluster.test", + ListenPort: 5432, + Targets: []*Target{ + {TargetId: "peer-1", TargetType: TargetTypePeer, Protocol: "tcp", Port: 5432, Enabled: true, ProxyProtocol: true}, + }, + } + require.NoError(t, rp.Validate()) +} + +func TestExposeServiceRequest_Validate_L4RejectsAuth(t *testing.T) { + tests := []struct { + name string + req ExposeServiceRequest + }{ + { + name: "tcp with pin", + req: ExposeServiceRequest{Port: 8080, Mode: "tcp", Pin: "123456"}, + }, + { + name: "udp with password", + req: ExposeServiceRequest{Port: 8080, Mode: "udp", Password: "secret"}, + }, + { + name: "tls with user groups", + req: ExposeServiceRequest{Port: 443, Mode: "tls", UserGroups: []string{"admins"}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.req.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "authentication is not supported") + }) + } +} + +func TestExposeServiceRequest_Validate_HTTPAllowsAuth(t *testing.T) { + req := ExposeServiceRequest{Port: 8080, Mode: "http", Pin: "123456"} + require.NoError(t, req.Validate()) +} + +func TestValidate_HeaderAuths(t *testing.T) { + t.Run("single valid header", func(t *testing.T) { + rp := validProxy() + rp.Auth = AuthConfig{ + HeaderAuths: []*HeaderAuthConfig{ + {Enabled: true, Header: "X-API-Key", Value: "secret"}, + }, + } + require.NoError(t, rp.Validate()) + }) + + t.Run("multiple headers same canonical name allowed", func(t *testing.T) { + rp := validProxy() + rp.Auth = AuthConfig{ + HeaderAuths: []*HeaderAuthConfig{ + {Enabled: true, Header: "Authorization", Value: "Bearer token-1"}, + {Enabled: true, Header: "Authorization", Value: "Bearer token-2"}, + }, + } + require.NoError(t, rp.Validate()) + }) + + t.Run("multiple headers different case same canonical allowed", func(t *testing.T) { + rp := validProxy() + rp.Auth = AuthConfig{ + HeaderAuths: []*HeaderAuthConfig{ + {Enabled: true, Header: "x-api-key", Value: "key-1"}, + {Enabled: true, Header: "X-Api-Key", Value: "key-2"}, + }, + } + require.NoError(t, rp.Validate()) + }) + + t.Run("multiple different headers allowed", func(t *testing.T) { + rp := validProxy() + rp.Auth = AuthConfig{ + HeaderAuths: []*HeaderAuthConfig{ + {Enabled: true, Header: "Authorization", Value: "Bearer tok"}, + {Enabled: true, Header: "X-API-Key", Value: "key"}, + }, + } + require.NoError(t, rp.Validate()) + }) + + t.Run("empty header name rejected", func(t *testing.T) { + rp := validProxy() + rp.Auth = AuthConfig{ + HeaderAuths: []*HeaderAuthConfig{ + {Enabled: true, Header: "", Value: "val"}, + }, + } + err := rp.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "header name is required") + }) + + t.Run("hop-by-hop header rejected", func(t *testing.T) { + rp := validProxy() + rp.Auth = AuthConfig{ + HeaderAuths: []*HeaderAuthConfig{ + {Enabled: true, Header: "Connection", Value: "val"}, + }, + } + err := rp.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "hop-by-hop") + }) + + t.Run("host header rejected", func(t *testing.T) { + rp := validProxy() + rp.Auth = AuthConfig{ + HeaderAuths: []*HeaderAuthConfig{ + {Enabled: true, Header: "Host", Value: "val"}, + }, + } + err := rp.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "Host header cannot be used") + }) + + t.Run("disabled entries skipped", func(t *testing.T) { + rp := validProxy() + rp.Auth = AuthConfig{ + HeaderAuths: []*HeaderAuthConfig{ + {Enabled: false, Header: "", Value: ""}, + {Enabled: true, Header: "X-Key", Value: "val"}, + }, + } + require.NoError(t, rp.Validate()) + }) + + t.Run("value too long rejected", func(t *testing.T) { + rp := validProxy() + rp.Auth = AuthConfig{ + HeaderAuths: []*HeaderAuthConfig{ + {Enabled: true, Header: "X-Key", Value: strings.Repeat("a", maxHeaderValueLen+1)}, + }, + } + err := rp.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds maximum length") + }) +} + +func TestValidate_HTTPClusterTarget(t *testing.T) { + rp := validProxy() + rp.Targets = []*Target{{ + TargetId: "eu.proxy.netbird.io", + TargetType: TargetTypeCluster, + Protocol: "http", + Host: "backend.lan", + Options: TargetOptions{DirectUpstream: true}, + Enabled: true, + }} + require.NoError(t, rp.Validate(), "HTTP cluster target with target_id, host, and direct_upstream must validate") +} + +func TestValidate_HTTPClusterTarget_RequiresTargetId(t *testing.T) { + rp := validProxy() + rp.Targets = []*Target{{ + TargetType: TargetTypeCluster, + Protocol: "http", + Host: "backend.lan", + Options: TargetOptions{DirectUpstream: true}, + Enabled: true, + }} + assert.ErrorContains(t, rp.Validate(), "empty target_id", "cluster target must reject empty target_id") +} + +// TestValidate_HTTPClusterTarget_RequiresHost pins the new cluster-target +// rule that operator-supplied Host is mandatory: cluster targets dial the +// upstream via the host network stack (direct_upstream is implied), so an +// empty Host leaves the proxy with nothing to dial. +func TestValidate_HTTPClusterTarget_RequiresHost(t *testing.T) { + rp := validProxy() + rp.Targets = []*Target{{ + TargetId: "eu.proxy.netbird.io", + TargetType: TargetTypeCluster, + Protocol: "http", + Options: TargetOptions{DirectUpstream: true}, + Enabled: true, + }} + assert.ErrorContains(t, rp.Validate(), "empty host", "cluster target must reject empty host") +} + +// TestValidate_HTTPClusterTarget_RequiresDirectUpstream pins the second +// half of the cluster-target rule: DirectUpstream must be true so the +// stdlib transport branch in MultiTransport is taken. Without it the +// embedded NetBird client would try to dial the cluster address through +// the WG tunnel, which is the wrong network for a cluster upstream. +func TestValidate_HTTPClusterTarget_RequiresDirectUpstream(t *testing.T) { + rp := validProxy() + rp.Targets = []*Target{{ + TargetId: "eu.proxy.netbird.io", + TargetType: TargetTypeCluster, + Protocol: "http", + Host: "backend.lan", + Enabled: true, + }} + assert.ErrorContains(t, rp.Validate(), "direct upstream disabled", "cluster target must reject direct_upstream=false") +} + +// TestValidate_L4ClusterTarget_RequiresPort confirms that an L4 cluster +// target without an explicit port is rejected. buildPathMappings emits +// net.JoinHostPort(target.Host, target.Port) for every L4 target — so +// allowing port=0 would let the proxy ship ":0" upstreams. The port +// requirement is the same as every other L4 target type. +func TestValidate_L4ClusterTarget_RequiresPort(t *testing.T) { + rp := validProxy() + rp.Mode = ModeTCP + rp.ListenPort = 9000 + rp.Targets = []*Target{{ + TargetId: "eu.proxy.netbird.io", + TargetType: TargetTypeCluster, + Protocol: "tcp", + Enabled: true, + }} + assert.ErrorContains(t, rp.Validate(), "port is required", + "L4 cluster target must require an explicit port like other L4 target types") + + rp.Targets[0].Port = 5432 + rp.Targets[0].Host = "db.lan" + require.NoError(t, rp.Validate(), "L4 cluster target with host:port must validate") +} + +func TestService_Copy_RoundtripsPrivate(t *testing.T) { + svc := validProxy() + svc.Private = true + svc.AccessGroups = []string{"grp-admins", "grp-ops"} + cp := svc.Copy() + require.NotNil(t, cp) + assert.True(t, cp.Private) + assert.Equal(t, []string{"grp-admins", "grp-ops"}, cp.AccessGroups) + + cp.Private = false + assert.True(t, svc.Private) + + cp.AccessGroups[0] = "grp-other" + assert.Equal(t, []string{"grp-admins", "grp-ops"}, svc.AccessGroups) +} + +func TestService_APIRoundtrip_Private(t *testing.T) { + enabled := true + private := true + accessGroups := []string{"grp-admins"} + targets := []api.ServiceTarget{{ + TargetId: "eu.proxy.netbird.io", + TargetType: api.ServiceTargetTargetType("cluster"), + Protocol: "http", + Port: 80, + Enabled: true, + }} + req := &api.ServiceRequest{ + Name: "svc-private", + Domain: "myapp.eu.proxy.netbird.io", + Enabled: enabled, + Private: &private, + AccessGroups: &accessGroups, + Targets: &targets, + } + + svc := &Service{} + require.NoError(t, svc.FromAPIRequest(req, "acc-1")) + assert.True(t, svc.Private) + assert.Equal(t, []string{"grp-admins"}, svc.AccessGroups) + + resp := svc.ToAPIResponse() + require.NotNil(t, resp.Private) + assert.True(t, *resp.Private) + require.NotNil(t, resp.AccessGroups) + assert.Equal(t, []string{"grp-admins"}, *resp.AccessGroups) +} + +func TestValidate_Private_RequiresAccessGroups(t *testing.T) { + rp := validProxy() + rp.Private = true + rp.Targets = []*Target{{ + TargetId: "eu.proxy.netbird.io", + TargetType: TargetTypeCluster, + Protocol: "http", + Host: "backend.lan", + Options: TargetOptions{DirectUpstream: true}, + Enabled: true, + }} + assert.ErrorContains(t, rp.Validate(), "access group") +} + +func TestValidate_Private_RejectsBearerAuth(t *testing.T) { + rp := validProxy() + rp.Private = true + rp.AccessGroups = []string{"grp-admins"} + rp.Auth.BearerAuth = &BearerAuthConfig{ + Enabled: true, + DistributionGroups: []string{"grp-sso"}, + } + rp.Targets = []*Target{{ + TargetId: "eu.proxy.netbird.io", + TargetType: TargetTypeCluster, + Protocol: "http", + Host: "backend.lan", + Options: TargetOptions{DirectUpstream: true}, + Enabled: true, + }} + assert.ErrorContains(t, rp.Validate(), "mutually exclusive") +} + +func TestValidate_Private_AcceptsNonClusterTargets(t *testing.T) { + rp := validProxy() + rp.Private = true + rp.AccessGroups = []string{"grp-admins"} + require.NoError(t, rp.Validate()) +} + +func TestValidate_Private_AcceptsClusterTargetWithAccessGroups(t *testing.T) { + rp := validProxy() + rp.Private = true + rp.AccessGroups = []string{"grp-admins"} + rp.Targets = []*Target{{ + TargetId: "eu.proxy.netbird.io", + TargetType: TargetTypeCluster, + Protocol: "http", + Host: "backend.lan", + Options: TargetOptions{DirectUpstream: true}, + Enabled: true, + }} + require.NoError(t, rp.Validate()) +} + +func TestValidate_Private_RejectsNonHTTPMode(t *testing.T) { + rp := validProxy() + rp.Private = true + rp.AccessGroups = []string{"grp-admins"} + rp.Mode = ModeTCP + rp.Targets = []*Target{{ + TargetId: "eu.proxy.netbird.io", + TargetType: TargetTypeCluster, + Protocol: "tcp", + Enabled: true, + }} + assert.ErrorContains(t, rp.Validate(), "HTTP") +} diff --git a/management/internals/modules/reverseproxy/sessionkey/sessionkey.go b/management/internals/modules/reverseproxy/sessionkey/sessionkey.go new file mode 100644 index 000000000..1fb6a323d --- /dev/null +++ b/management/internals/modules/reverseproxy/sessionkey/sessionkey.go @@ -0,0 +1,92 @@ +package sessionkey + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + + "github.com/netbirdio/netbird/proxy/auth" +) + +type KeyPair struct { + PrivateKey string + PublicKey string +} + +type Claims struct { + jwt.RegisteredClaims + Method auth.Method `json:"method"` + // Email is the calling user's email address. Carried so the + // proxy can stamp identity on upstream requests (e.g. + // x-litellm-end-user-id) without an extra management + // round-trip on every cookie-bearing request. + Email string `json:"email,omitempty"` + // Groups carries the user's group IDs so the proxy can stamp them + // onto upstream requests (X-NetBird-Groups) from the cookie path + // without an extra management round-trip. + Groups []string `json:"groups,omitempty"` + // GroupNames carries the human-readable display names for the ids + // in Groups, ordered identically (positional pairing). Slice may be + // shorter than Groups for tokens minted before names were + // resolvable; the consumer falls back to ids for missing positions. + GroupNames []string `json:"group_names,omitempty"` +} + +func GenerateKeyPair() (*KeyPair, error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate ed25519 key: %w", err) + } + + return &KeyPair{ + PrivateKey: base64.StdEncoding.EncodeToString(priv), + PublicKey: base64.StdEncoding.EncodeToString(pub), + }, nil +} + +// SignToken mints a session JWT for the given user and domain. email, +// groups, and groupNames, when non-empty, are embedded so the proxy can +// authorise and stamp identity for policy-aware middlewares without a +// management round-trip on every cookie-bearing request. groupNames +// pairs positionally with groups; pass nil when names couldn't be +// resolved. +func SignToken(privKeyB64, userID, email, domain string, method auth.Method, groups, groupNames []string, expiration time.Duration) (string, error) { + privKeyBytes, err := base64.StdEncoding.DecodeString(privKeyB64) + if err != nil { + return "", fmt.Errorf("decode private key: %w", err) + } + + if len(privKeyBytes) != ed25519.PrivateKeySize { + return "", fmt.Errorf("invalid private key size: got %d, want %d", len(privKeyBytes), ed25519.PrivateKeySize) + } + + privKey := ed25519.PrivateKey(privKeyBytes) + + now := time.Now() + claims := Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: auth.SessionJWTIssuer, + Subject: userID, + Audience: jwt.ClaimStrings{domain}, + ExpiresAt: jwt.NewNumericDate(now.Add(expiration)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + }, + Method: method, + Email: email, + Groups: append([]string(nil), groups...), + GroupNames: append([]string(nil), groupNames...), + } + + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + signedToken, err := token.SignedString(privKey) + if err != nil { + return "", fmt.Errorf("sign token: %w", err) + } + + return signedToken, nil +} diff --git a/management/internals/modules/zones/interface.go b/management/internals/modules/zones/interface.go new file mode 100644 index 000000000..8e2306230 --- /dev/null +++ b/management/internals/modules/zones/interface.go @@ -0,0 +1,13 @@ +package zones + +import ( + "context" +) + +type Manager interface { + GetAllZones(ctx context.Context, accountID, userID string) ([]*Zone, error) + GetZone(ctx context.Context, accountID, userID, zone string) (*Zone, error) + CreateZone(ctx context.Context, accountID, userID string, zone *Zone) (*Zone, error) + UpdateZone(ctx context.Context, accountID, userID string, zone *Zone) (*Zone, error) + DeleteZone(ctx context.Context, accountID, userID, zoneID string) error +} diff --git a/management/internals/modules/zones/manager/api.go b/management/internals/modules/zones/manager/api.go new file mode 100644 index 000000000..919d77d61 --- /dev/null +++ b/management/internals/modules/zones/manager/api.go @@ -0,0 +1,161 @@ +package manager + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/zones" + 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 zones.Manager +} + +func RegisterEndpoints(router *mux.Router, manager zones.Manager) { + h := &handler{ + manager: manager, + } + + router.HandleFunc("/dns/zones", h.getAllZones).Methods("GET", "OPTIONS") + router.HandleFunc("/dns/zones", h.createZone).Methods("POST", "OPTIONS") + router.HandleFunc("/dns/zones/{zoneId}", h.getZone).Methods("GET", "OPTIONS") + router.HandleFunc("/dns/zones/{zoneId}", h.updateZone).Methods("PUT", "OPTIONS") + router.HandleFunc("/dns/zones/{zoneId}", h.deleteZone).Methods("DELETE", "OPTIONS") +} + +func (h *handler) getAllZones(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + allZones, err := h.manager.GetAllZones(r.Context(), userAuth.AccountId, userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + apiZones := make([]*api.Zone, 0, len(allZones)) + for _, zone := range allZones { + apiZones = append(apiZones, zone.ToAPIResponse()) + } + + util.WriteJSONObject(r.Context(), w, apiZones) +} + +func (h *handler) createZone(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.PostApiDnsZonesJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + zone := new(zones.Zone) + zone.FromAPIRequest(&req) + + if err = zone.Validate(); err != nil { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "%s", err.Error()), w) + return + } + + createdZone, err := h.manager.CreateZone(r.Context(), userAuth.AccountId, userAuth.UserId, zone) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, createdZone.ToAPIResponse()) +} + +func (h *handler) getZone(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + zoneID := mux.Vars(r)["zoneId"] + if zoneID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "zone ID is required"), w) + return + } + + zone, err := h.manager.GetZone(r.Context(), userAuth.AccountId, userAuth.UserId, zoneID) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, zone.ToAPIResponse()) +} + +func (h *handler) updateZone(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + zoneID := mux.Vars(r)["zoneId"] + if zoneID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "zone ID is required"), w) + return + } + + var req api.PutApiDnsZonesZoneIdJSONRequestBody + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + zone := new(zones.Zone) + zone.FromAPIRequest(&req) + zone.ID = zoneID + + if err = zone.Validate(); err != nil { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "%s", err.Error()), w) + return + } + + updatedZone, err := h.manager.UpdateZone(r.Context(), userAuth.AccountId, userAuth.UserId, zone) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, updatedZone.ToAPIResponse()) +} + +func (h *handler) deleteZone(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + zoneID := mux.Vars(r)["zoneId"] + if zoneID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "zone ID is required"), w) + return + } + + if err = h.manager.DeleteZone(r.Context(), userAuth.AccountId, userAuth.UserId, zoneID); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} diff --git a/management/internals/modules/zones/manager/manager.go b/management/internals/modules/zones/manager/manager.go new file mode 100644 index 000000000..d5348d3d0 --- /dev/null +++ b/management/internals/modules/zones/manager/manager.go @@ -0,0 +1,230 @@ +package manager + +import ( + "context" + "fmt" + + "github.com/netbirdio/netbird/management/internals/modules/zones" + "github.com/netbirdio/netbird/management/server/account" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +type managerImpl struct { + store store.Store + accountManager account.Manager + permissionsManager permissions.Manager + dnsDomain string +} + +func NewManager(store store.Store, accountManager account.Manager, permissionsManager permissions.Manager, dnsDomain string) zones.Manager { + return &managerImpl{ + store: store, + accountManager: accountManager, + permissionsManager: permissionsManager, + dnsDomain: dnsDomain, + } +} + +func (m *managerImpl) GetAllZones(ctx context.Context, accountID, userID string) ([]*zones.Zone, error) { + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + return m.store.GetAccountZones(ctx, store.LockingStrengthNone, accountID) +} + +func (m *managerImpl) GetZone(ctx context.Context, accountID, userID, zoneID string) (*zones.Zone, error) { + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + return m.store.GetZoneByID(ctx, store.LockingStrengthNone, accountID, zoneID) +} + +func (m *managerImpl) CreateZone(ctx context.Context, accountID, userID string, zone *zones.Zone) (*zones.Zone, error) { + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Create) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + if err = m.validateZoneDomainConflict(ctx, accountID, zone.Domain); err != nil { + return nil, err + } + + zone = zones.NewZone(accountID, zone.Name, zone.Domain, zone.Enabled, zone.EnableSearchDomain, zone.DistributionGroups) + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + existingZone, err := transaction.GetZoneByDomain(ctx, accountID, zone.Domain) + if err != nil { + if sErr, ok := status.FromError(err); !ok || sErr.Type() != status.NotFound { + return fmt.Errorf("failed to check existing zone: %w", err) + } + } + if existingZone != nil { + return status.Errorf(status.AlreadyExists, "zone with domain %s already exists", zone.Domain) + } + + for _, groupID := range zone.DistributionGroups { + _, err = transaction.GetGroupByID(ctx, store.LockingStrengthNone, accountID, groupID) + if err != nil { + return status.Errorf(status.InvalidArgument, "%s", err.Error()) + } + } + + if err = transaction.CreateZone(ctx, zone); err != nil { + return fmt.Errorf("failed to create zone: %w", err) + } + + return nil + }) + if err != nil { + return nil, err + } + + m.accountManager.StoreEvent(ctx, userID, zone.ID, accountID, activity.DNSZoneCreated, zone.EventMeta()) + + return zone, nil +} + +func (m *managerImpl) UpdateZone(ctx context.Context, accountID, userID string, updatedZone *zones.Zone) (*zones.Zone, error) { + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Update) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + zone, err := m.store.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, updatedZone.ID) + if err != nil { + return nil, fmt.Errorf("failed to get zone: %w", err) + } + + if zone.Domain != updatedZone.Domain { + return nil, status.Errorf(status.InvalidArgument, "zone domain cannot be updated") + } + + zone.Name = updatedZone.Name + zone.Enabled = updatedZone.Enabled + zone.EnableSearchDomain = updatedZone.EnableSearchDomain + zone.DistributionGroups = updatedZone.DistributionGroups + + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + for _, groupID := range zone.DistributionGroups { + _, err = transaction.GetGroupByID(ctx, store.LockingStrengthNone, accountID, groupID) + if err != nil { + return status.Errorf(status.InvalidArgument, "%s", err.Error()) + } + } + + if err = transaction.UpdateZone(ctx, zone); err != nil { + return fmt.Errorf("failed to update zone: %w", err) + } + + return nil + }) + if err != nil { + return nil, err + } + + m.accountManager.StoreEvent(ctx, userID, zone.ID, accountID, activity.DNSZoneUpdated, zone.EventMeta()) + + go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZone, Operation: types.UpdateOperationUpdate}) + + return zone, nil +} + +func (m *managerImpl) DeleteZone(ctx context.Context, accountID, userID, zoneID string) error { + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Delete) + if err != nil { + return status.NewPermissionValidationError(err) + } + if !ok { + return status.NewPermissionDeniedError() + } + + zone, err := m.store.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, zoneID) + if err != nil { + return fmt.Errorf("failed to get zone: %w", err) + } + + var eventsToStore []func() + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + records, err := transaction.GetZoneDNSRecords(ctx, store.LockingStrengthNone, accountID, zoneID) + if err != nil { + return fmt.Errorf("failed to get records: %w", err) + } + + err = transaction.DeleteZoneDNSRecords(ctx, accountID, zoneID) + if err != nil { + return fmt.Errorf("failed to delete zone dns records: %w", err) + } + + err = transaction.DeleteZone(ctx, accountID, zoneID) + if err != nil { + return fmt.Errorf("failed to delete zone: %w", err) + } + + err = transaction.IncrementNetworkSerial(ctx, accountID) + if err != nil { + return fmt.Errorf("failed to increment network serial: %w", err) + } + + for _, record := range records { + eventsToStore = append(eventsToStore, func() { + meta := record.EventMeta(zone.ID, zone.Name) + m.accountManager.StoreEvent(ctx, userID, record.ID, accountID, activity.DNSRecordDeleted, meta) + }) + } + + eventsToStore = append(eventsToStore, func() { + m.accountManager.StoreEvent(ctx, userID, zoneID, accountID, activity.DNSZoneDeleted, zone.EventMeta()) + }) + + return nil + }) + if err != nil { + return err + } + + for _, event := range eventsToStore { + event() + } + + go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZone, Operation: types.UpdateOperationDelete}) + + return nil +} + +func (m *managerImpl) validateZoneDomainConflict(ctx context.Context, accountID, domain string) error { + if m.dnsDomain != "" && m.dnsDomain == domain { + return status.Errorf(status.InvalidArgument, "zone domain %s conflicts with peer DNS domain", domain) + } + + settings, err := m.store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return err + } + + if settings.DNSDomain != "" && settings.DNSDomain == domain { + return status.Errorf(status.InvalidArgument, "zone domain %s conflicts with peer DNS domain", domain) + } + + return nil +} diff --git a/management/internals/modules/zones/manager/manager_test.go b/management/internals/modules/zones/manager/manager_test.go new file mode 100644 index 000000000..29e7e8677 --- /dev/null +++ b/management/internals/modules/zones/manager/manager_test.go @@ -0,0 +1,553 @@ +package manager + +import ( + "context" + "fmt" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/zones" + "github.com/netbirdio/netbird/management/internals/modules/zones/records" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/mock_server" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +const ( + testAccountID = "test-account-id" + testUserID = "test-user-id" + testZoneID = "test-zone-id" + testGroupID = "test-group-id" + testDNSDomain = "netbird.selfhosted" +) + +func setupTest(t *testing.T) (*managerImpl, store.Store, *mock_server.MockAccountManager, *permissions.MockManager, *gomock.Controller, func()) { + t.Helper() + + ctx := context.Background() + testStore, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + + err = testStore.SaveAccount(ctx, &types.Account{ + Id: testAccountID, + Groups: map[string]*types.Group{ + testGroupID: { + ID: testGroupID, + Name: "Test Group", + }, + }, + }) + require.NoError(t, err) + + ctrl := gomock.NewController(t) + mockAccountManager := &mock_server.MockAccountManager{} + mockPermissionsManager := permissions.NewMockManager(ctrl) + + manager := &managerImpl{ + store: testStore, + accountManager: mockAccountManager, + permissionsManager: mockPermissionsManager, + dnsDomain: testDNSDomain, + } + + return manager, testStore, mockAccountManager, mockPermissionsManager, ctrl, cleanup +} + +func TestManagerImpl_GetAllZones(t *testing.T) { + ctx := context.Background() + + t.Run("success", func(t *testing.T) { + manager, testStore, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + zone1 := zones.NewZone(testAccountID, "Zone 1", "zone1.example.com", true, true, []string{testGroupID}) + err := testStore.CreateZone(ctx, zone1) + require.NoError(t, err) + + zone2 := zones.NewZone(testAccountID, "Zone 2", "zone2.example.com", false, false, []string{testGroupID}) + err = testStore.CreateZone(ctx, zone2) + require.NoError(t, err) + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read). + Return(true, ctx, nil) + + result, err := manager.GetAllZones(ctx, testAccountID, testUserID) + require.NoError(t, err) + assert.Len(t, result, 2) + assert.Equal(t, zone1.ID, result[0].ID) + assert.Equal(t, zone2.ID, result[1].ID) + }) + + t.Run("permission denied", func(t *testing.T) { + manager, _, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read). + Return(false, ctx, nil) + + result, err := manager.GetAllZones(ctx, testAccountID, testUserID) + require.Error(t, err) + assert.Nil(t, result) + s, ok := status.FromError(err) + assert.True(t, ok) + assert.Equal(t, status.PermissionDenied, s.Type()) + }) + + t.Run("permission validation error", func(t *testing.T) { + manager, _, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read). + Return(false, ctx, status.Errorf(status.Internal, "permission check failed")) + + result, err := manager.GetAllZones(ctx, testAccountID, testUserID) + require.Error(t, err) + assert.Nil(t, result) + }) +} + +func TestManagerImpl_GetZone(t *testing.T) { + ctx := context.Background() + + t.Run("success", func(t *testing.T) { + manager, testStore, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + zone := zones.NewZone(testAccountID, "Test Zone", "test.example.com", true, true, []string{testGroupID}) + err := testStore.CreateZone(ctx, zone) + require.NoError(t, err) + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read). + Return(true, ctx, nil) + + result, err := manager.GetZone(ctx, testAccountID, testUserID, zone.ID) + require.NoError(t, err) + assert.Equal(t, zone.ID, result.ID) + assert.Equal(t, zone.Name, result.Name) + assert.Equal(t, zone.Domain, result.Domain) + }) + + t.Run("permission denied", func(t *testing.T) { + manager, _, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read). + Return(false, ctx, nil) + + result, err := manager.GetZone(ctx, testAccountID, testUserID, testZoneID) + require.Error(t, err) + assert.Nil(t, result) + s, ok := status.FromError(err) + assert.True(t, ok) + assert.Equal(t, status.PermissionDenied, s.Type()) + }) +} + +func TestManagerImpl_CreateZone(t *testing.T) { + ctx := context.Background() + + t.Run("success", func(t *testing.T) { + manager, _, mockAccountManager, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + inputZone := &zones.Zone{ + Name: "New Zone", + Domain: "new.example.com", + Enabled: true, + EnableSearchDomain: true, + DistributionGroups: []string{testGroupID}, + } + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create). + Return(true, ctx, nil) + + mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) { + assert.Equal(t, testUserID, initiatorID) + assert.Equal(t, testAccountID, accountID) + assert.Equal(t, activity.DNSZoneCreated, activityID) + } + + result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone) + require.NoError(t, err) + assert.NotNil(t, result) + assert.NotEmpty(t, result.ID) + assert.Equal(t, testAccountID, result.AccountID) + assert.Equal(t, inputZone.Name, result.Name) + assert.Equal(t, inputZone.Domain, result.Domain) + assert.Equal(t, inputZone.Enabled, result.Enabled) + assert.Equal(t, inputZone.EnableSearchDomain, result.EnableSearchDomain) + assert.Equal(t, inputZone.DistributionGroups, result.DistributionGroups) + }) + + t.Run("permission denied", func(t *testing.T) { + manager, _, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + inputZone := &zones.Zone{ + Name: "New Zone", + Domain: "new.example.com", + DistributionGroups: []string{testGroupID}, + } + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create). + Return(false, ctx, nil) + + result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone) + require.Error(t, err) + assert.Nil(t, result) + s, ok := status.FromError(err) + assert.True(t, ok) + assert.Equal(t, status.PermissionDenied, s.Type()) + }) + + t.Run("invalid group", func(t *testing.T) { + manager, _, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + inputZone := &zones.Zone{ + Name: "New Zone", + Domain: "new.example.com", + DistributionGroups: []string{"invalid-group"}, + } + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create). + Return(true, ctx, nil) + + result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone) + require.Error(t, err) + assert.Nil(t, result) + }) + + t.Run("duplicate domain", func(t *testing.T) { + manager, testStore, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + existingZone := zones.NewZone(testAccountID, "Existing Zone", "duplicate.example.com", true, false, []string{testGroupID}) + err := testStore.CreateZone(ctx, existingZone) + require.NoError(t, err) + + inputZone := &zones.Zone{ + Name: "New Zone", + Domain: "duplicate.example.com", + Enabled: true, + EnableSearchDomain: false, + DistributionGroups: []string{testGroupID}, + } + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create). + Return(true, ctx, nil) + + result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "zone with domain duplicate.example.com already exists") + s, ok := status.FromError(err) + assert.True(t, ok) + assert.Equal(t, status.AlreadyExists, s.Type()) + }) + + t.Run("peer DNS domain conflict", func(t *testing.T) { + manager, testStore, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + account, err := testStore.GetAccount(ctx, testAccountID) + require.NoError(t, err) + account.Settings.DNSDomain = "peers.example.com" + err = testStore.SaveAccount(ctx, account) + require.NoError(t, err) + + inputZone := &zones.Zone{ + Name: "Test Zone", + Domain: "peers.example.com", + Enabled: true, + EnableSearchDomain: false, + DistributionGroups: []string{testGroupID}, + } + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create). + Return(true, ctx, nil) + + result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "zone domain peers.example.com conflicts with peer DNS domain") + s, ok := status.FromError(err) + assert.True(t, ok) + assert.Equal(t, status.InvalidArgument, s.Type()) + }) + + t.Run("default DNS domain conflict", func(t *testing.T) { + manager, _, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + inputZone := &zones.Zone{ + Name: "Test Zone", + Domain: testDNSDomain, + Enabled: true, + EnableSearchDomain: false, + DistributionGroups: []string{testGroupID}, + } + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create). + Return(true, ctx, nil) + + result, err := manager.CreateZone(ctx, testAccountID, testUserID, inputZone) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), fmt.Sprintf("zone domain %s conflicts with peer DNS domain", testDNSDomain)) + s, ok := status.FromError(err) + assert.True(t, ok) + assert.Equal(t, status.InvalidArgument, s.Type()) + }) +} + +func TestManagerImpl_UpdateZone(t *testing.T) { + ctx := context.Background() + + t.Run("success", func(t *testing.T) { + manager, testStore, mockAccountManager, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + existingZone := zones.NewZone(testAccountID, "Old Name", "example.com", false, false, []string{testGroupID}) + err := testStore.CreateZone(ctx, existingZone) + require.NoError(t, err) + + updatedZone := &zones.Zone{ + ID: existingZone.ID, + Name: "Updated Name", + Domain: "example.com", + Enabled: true, + EnableSearchDomain: true, + DistributionGroups: []string{testGroupID}, + } + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update). + Return(true, ctx, nil) + + storeEventCalled := false + mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) { + storeEventCalled = true + assert.Equal(t, testUserID, initiatorID) + assert.Equal(t, existingZone.ID, targetID) + assert.Equal(t, testAccountID, accountID) + assert.Equal(t, activity.DNSZoneUpdated, activityID) + } + + result, err := manager.UpdateZone(ctx, testAccountID, testUserID, updatedZone) + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, updatedZone.Name, result.Name) + assert.Equal(t, updatedZone.Enabled, result.Enabled) + assert.Equal(t, updatedZone.EnableSearchDomain, result.EnableSearchDomain) + assert.True(t, storeEventCalled, "StoreEvent should have been called") + }) + + t.Run("domain change not allowed", func(t *testing.T) { + manager, testStore, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + existingZone := zones.NewZone(testAccountID, "Test Zone", "example.com", true, true, []string{testGroupID}) + err := testStore.CreateZone(ctx, existingZone) + require.NoError(t, err) + + updatedZone := &zones.Zone{ + ID: existingZone.ID, + Name: "Test Zone", + Domain: "different.com", + Enabled: true, + EnableSearchDomain: true, + DistributionGroups: []string{testGroupID}, + } + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update). + Return(true, ctx, nil) + + result, err := manager.UpdateZone(ctx, testAccountID, testUserID, updatedZone) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "zone domain cannot be updated") + s, ok := status.FromError(err) + assert.True(t, ok) + assert.Equal(t, status.InvalidArgument, s.Type()) + }) + + t.Run("permission denied", func(t *testing.T) { + manager, _, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + updatedZone := &zones.Zone{ + ID: testZoneID, + Name: "Updated Name", + Domain: "example.com", + } + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update). + Return(false, ctx, nil) + + result, err := manager.UpdateZone(ctx, testAccountID, testUserID, updatedZone) + require.Error(t, err) + assert.Nil(t, result) + s, ok := status.FromError(err) + assert.True(t, ok) + assert.Equal(t, status.PermissionDenied, s.Type()) + }) + + t.Run("zone not found", func(t *testing.T) { + manager, _, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + updatedZone := &zones.Zone{ + ID: "non-existent-zone", + Name: "Updated Name", + Domain: "example.com", + } + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update). + Return(true, ctx, nil) + + result, err := manager.UpdateZone(ctx, testAccountID, testUserID, updatedZone) + require.Error(t, err) + assert.Nil(t, result) + }) +} + +func TestManagerImpl_DeleteZone(t *testing.T) { + ctx := context.Background() + + t.Run("success with records", func(t *testing.T) { + manager, testStore, mockAccountManager, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + zone := zones.NewZone(testAccountID, "Test Zone", "example.com", true, true, []string{testGroupID}) + err := testStore.CreateZone(ctx, zone) + require.NoError(t, err) + + record1 := records.NewRecord(testAccountID, zone.ID, "api.example.com", records.RecordTypeA, "192.168.1.1", 300) + err = testStore.CreateDNSRecord(ctx, record1) + require.NoError(t, err) + + record2 := records.NewRecord(testAccountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.2", 300) + err = testStore.CreateDNSRecord(ctx, record2) + require.NoError(t, err) + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete). + Return(true, ctx, nil) + + storeEventCallCount := 0 + mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) { + storeEventCallCount++ + assert.Equal(t, testUserID, initiatorID) + assert.Equal(t, testAccountID, accountID) + } + + err = manager.DeleteZone(ctx, testAccountID, testUserID, zone.ID) + require.NoError(t, err) + assert.Equal(t, 3, storeEventCallCount) + + _, err = testStore.GetZoneByID(ctx, store.LockingStrengthNone, testAccountID, zone.ID) + require.Error(t, err) + + zoneRecords, err := testStore.GetZoneDNSRecords(ctx, store.LockingStrengthNone, testAccountID, zone.ID) + require.NoError(t, err) + assert.Empty(t, zoneRecords) + }) + + t.Run("success without records", func(t *testing.T) { + manager, testStore, mockAccountManager, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + zone := zones.NewZone(testAccountID, "Test Zone", "example.com", true, true, []string{testGroupID}) + err := testStore.CreateZone(ctx, zone) + require.NoError(t, err) + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete). + Return(true, ctx, nil) + + storeEventCalled := false + mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) { + storeEventCalled = true + assert.Equal(t, testUserID, initiatorID) + assert.Equal(t, zone.ID, targetID) + assert.Equal(t, testAccountID, accountID) + assert.Equal(t, activity.DNSZoneDeleted, activityID) + } + + err = manager.DeleteZone(ctx, testAccountID, testUserID, zone.ID) + require.NoError(t, err) + assert.True(t, storeEventCalled, "StoreEvent should have been called") + + _, err = testStore.GetZoneByID(ctx, store.LockingStrengthNone, testAccountID, zone.ID) + require.Error(t, err) + }) + + t.Run("permission denied", func(t *testing.T) { + manager, _, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete). + Return(false, ctx, nil) + + err := manager.DeleteZone(ctx, testAccountID, testUserID, testZoneID) + require.Error(t, err) + s, ok := status.FromError(err) + assert.True(t, ok) + assert.Equal(t, status.PermissionDenied, s.Type()) + }) + + t.Run("zone not found", func(t *testing.T) { + manager, _, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete). + Return(true, ctx, nil) + + err := manager.DeleteZone(ctx, testAccountID, testUserID, "non-existent-zone") + require.Error(t, err) + }) +} diff --git a/management/internals/modules/zones/records/interface.go b/management/internals/modules/zones/records/interface.go new file mode 100644 index 000000000..ceb8c5318 --- /dev/null +++ b/management/internals/modules/zones/records/interface.go @@ -0,0 +1,13 @@ +package records + +import ( + "context" +) + +type Manager interface { + GetAllRecords(ctx context.Context, accountID, userID, zoneID string) ([]*Record, error) + GetRecord(ctx context.Context, accountID, userID, zoneID, recordID string) (*Record, error) + CreateRecord(ctx context.Context, accountID, userID, zoneID string, record *Record) (*Record, error) + UpdateRecord(ctx context.Context, accountID, userID, zoneID string, record *Record) (*Record, error) + DeleteRecord(ctx context.Context, accountID, userID, zoneID, recordID string) error +} diff --git a/management/internals/modules/zones/records/manager/api.go b/management/internals/modules/zones/records/manager/api.go new file mode 100644 index 000000000..f8ecfef7d --- /dev/null +++ b/management/internals/modules/zones/records/manager/api.go @@ -0,0 +1,191 @@ +package manager + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/internals/modules/zones/records" + 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 records.Manager +} + +func RegisterEndpoints(router *mux.Router, manager records.Manager) { + h := &handler{ + manager: manager, + } + + router.HandleFunc("/dns/zones/{zoneId}/records", h.getAllRecords).Methods("GET", "OPTIONS") + router.HandleFunc("/dns/zones/{zoneId}/records", h.createRecord).Methods("POST", "OPTIONS") + router.HandleFunc("/dns/zones/{zoneId}/records/{recordId}", h.getRecord).Methods("GET", "OPTIONS") + router.HandleFunc("/dns/zones/{zoneId}/records/{recordId}", h.updateRecord).Methods("PUT", "OPTIONS") + router.HandleFunc("/dns/zones/{zoneId}/records/{recordId}", h.deleteRecord).Methods("DELETE", "OPTIONS") +} + +func (h *handler) getAllRecords(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + zoneID := mux.Vars(r)["zoneId"] + if zoneID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "zone ID is required"), w) + return + } + + allRecords, err := h.manager.GetAllRecords(r.Context(), userAuth.AccountId, userAuth.UserId, zoneID) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + apiRecords := make([]*api.DNSRecord, 0, len(allRecords)) + for _, record := range allRecords { + apiRecords = append(apiRecords, record.ToAPIResponse()) + } + + util.WriteJSONObject(r.Context(), w, apiRecords) +} + +func (h *handler) createRecord(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + zoneID := mux.Vars(r)["zoneId"] + if zoneID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "zone ID is required"), w) + return + } + + var req api.PostApiDnsZonesZoneIdRecordsJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + record := new(records.Record) + record.FromAPIRequest(&req) + + if err = record.Validate(); err != nil { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "%s", err.Error()), w) + return + } + + createdRecord, err := h.manager.CreateRecord(r.Context(), userAuth.AccountId, userAuth.UserId, zoneID, record) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, createdRecord.ToAPIResponse()) +} + +func (h *handler) getRecord(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + zoneID := mux.Vars(r)["zoneId"] + if zoneID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "zone ID is required"), w) + return + } + + recordID := mux.Vars(r)["recordId"] + if recordID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "record ID is required"), w) + return + } + + record, err := h.manager.GetRecord(r.Context(), userAuth.AccountId, userAuth.UserId, zoneID, recordID) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, record.ToAPIResponse()) +} + +func (h *handler) updateRecord(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + zoneID := mux.Vars(r)["zoneId"] + if zoneID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "zone ID is required"), w) + return + } + + recordID := mux.Vars(r)["recordId"] + if recordID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "record ID is required"), w) + return + } + + var req api.PutApiDnsZonesZoneIdRecordsRecordIdJSONRequestBody + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + record := new(records.Record) + record.FromAPIRequest(&req) + record.ID = recordID + + if err = record.Validate(); err != nil { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "%s", err.Error()), w) + return + } + + updatedRecord, err := h.manager.UpdateRecord(r.Context(), userAuth.AccountId, userAuth.UserId, zoneID, record) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, updatedRecord.ToAPIResponse()) +} + +func (h *handler) deleteRecord(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + zoneID := mux.Vars(r)["zoneId"] + if zoneID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "zone ID is required"), w) + return + } + + recordID := mux.Vars(r)["recordId"] + if recordID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "record ID is required"), w) + return + } + + if err = h.manager.DeleteRecord(r.Context(), userAuth.AccountId, userAuth.UserId, zoneID, recordID); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} diff --git a/management/internals/modules/zones/records/manager/manager.go b/management/internals/modules/zones/records/manager/manager.go new file mode 100644 index 000000000..b041aca30 --- /dev/null +++ b/management/internals/modules/zones/records/manager/manager.go @@ -0,0 +1,237 @@ +package manager + +import ( + "context" + "fmt" + "strings" + + "github.com/netbirdio/netbird/management/internals/modules/zones" + "github.com/netbirdio/netbird/management/internals/modules/zones/records" + "github.com/netbirdio/netbird/management/server/account" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +type managerImpl struct { + store store.Store + accountManager account.Manager + permissionsManager permissions.Manager +} + +func NewManager(store store.Store, accountManager account.Manager, permissionsManager permissions.Manager) records.Manager { + return &managerImpl{ + store: store, + accountManager: accountManager, + permissionsManager: permissionsManager, + } +} + +func (m *managerImpl) GetAllRecords(ctx context.Context, accountID, userID, zoneID string) ([]*records.Record, error) { + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + return m.store.GetZoneDNSRecords(ctx, store.LockingStrengthNone, accountID, zoneID) +} + +func (m *managerImpl) GetRecord(ctx context.Context, accountID, userID, zoneID, recordID string) (*records.Record, error) { + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + return m.store.GetDNSRecordByID(ctx, store.LockingStrengthNone, accountID, zoneID, recordID) +} + +func (m *managerImpl) CreateRecord(ctx context.Context, accountID, userID, zoneID string, record *records.Record) (*records.Record, error) { + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Create) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + var zone *zones.Zone + + record = records.NewRecord(accountID, zoneID, record.Name, record.Type, record.Content, record.TTL) + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + zone, err = transaction.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, zoneID) + if err != nil { + return fmt.Errorf("failed to get zone: %w", err) + } + + err = validateRecordConflicts(ctx, transaction, zone, record) + if err != nil { + return err + } + + if err = transaction.CreateDNSRecord(ctx, record); err != nil { + return fmt.Errorf("failed to create dns record: %w", err) + } + + err = transaction.IncrementNetworkSerial(ctx, accountID) + if err != nil { + return fmt.Errorf("failed to increment network serial: %w", err) + } + + return nil + }) + if err != nil { + return nil, err + } + + meta := record.EventMeta(zone.ID, zone.Name) + m.accountManager.StoreEvent(ctx, userID, record.ID, accountID, activity.DNSRecordCreated, meta) + + go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZoneRecord, Operation: types.UpdateOperationCreate}) + + return record, nil +} + +func (m *managerImpl) UpdateRecord(ctx context.Context, accountID, userID, zoneID string, updatedRecord *records.Record) (*records.Record, error) { + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Update) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + var zone *zones.Zone + var record *records.Record + + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + zone, err = transaction.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, zoneID) + if err != nil { + return fmt.Errorf("failed to get zone: %w", err) + } + + record, err = transaction.GetDNSRecordByID(ctx, store.LockingStrengthUpdate, accountID, zoneID, updatedRecord.ID) + if err != nil { + return fmt.Errorf("failed to get record: %w", err) + } + + hasChanges := record.Name != updatedRecord.Name || record.Type != updatedRecord.Type || record.Content != updatedRecord.Content + + record.Name = updatedRecord.Name + record.Type = updatedRecord.Type + record.Content = updatedRecord.Content + record.TTL = updatedRecord.TTL + + if hasChanges { + if err = validateRecordConflicts(ctx, transaction, zone, record); err != nil { + return err + } + } + + if err = transaction.UpdateDNSRecord(ctx, record); err != nil { + return fmt.Errorf("failed to update dns record: %w", err) + } + + err = transaction.IncrementNetworkSerial(ctx, accountID) + if err != nil { + return fmt.Errorf("failed to increment network serial: %w", err) + } + + return nil + }) + if err != nil { + return nil, err + } + + meta := record.EventMeta(zone.ID, zone.Name) + m.accountManager.StoreEvent(ctx, userID, record.ID, accountID, activity.DNSRecordUpdated, meta) + + go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZoneRecord, Operation: types.UpdateOperationUpdate}) + + return record, nil +} + +func (m *managerImpl) DeleteRecord(ctx context.Context, accountID, userID, zoneID, recordID string) error { + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Delete) + if err != nil { + return status.NewPermissionValidationError(err) + } + if !ok { + return status.NewPermissionDeniedError() + } + + var record *records.Record + var zone *zones.Zone + + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + zone, err = transaction.GetZoneByID(ctx, store.LockingStrengthUpdate, accountID, zoneID) + if err != nil { + return fmt.Errorf("failed to get zone: %w", err) + } + + record, err = transaction.GetDNSRecordByID(ctx, store.LockingStrengthUpdate, accountID, zoneID, recordID) + if err != nil { + return fmt.Errorf("failed to get record: %w", err) + } + + err = transaction.DeleteDNSRecord(ctx, accountID, zoneID, recordID) + if err != nil { + return fmt.Errorf("failed to delete dns record: %w", err) + } + + err = transaction.IncrementNetworkSerial(ctx, accountID) + if err != nil { + return fmt.Errorf("failed to increment network serial: %w", err) + } + + return nil + }) + if err != nil { + return err + } + + meta := record.EventMeta(zone.ID, zone.Name) + m.accountManager.StoreEvent(ctx, userID, recordID, accountID, activity.DNSRecordDeleted, meta) + + go m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceZoneRecord, Operation: types.UpdateOperationDelete}) + + return nil +} + +// validateRecordConflicts checks for duplicate records and CNAME conflicts +func validateRecordConflicts(ctx context.Context, transaction store.Store, zone *zones.Zone, record *records.Record) error { + if record.Name != zone.Domain && !strings.HasSuffix(record.Name, "."+zone.Domain) { + return status.Errorf(status.InvalidArgument, "record name does not belong to zone") + } + + existingRecords, err := transaction.GetZoneDNSRecordsByName(ctx, store.LockingStrengthNone, zone.AccountID, zone.ID, record.Name) + if err != nil { + return fmt.Errorf("failed to check existing records: %w", err) + } + + for _, existing := range existingRecords { + if existing.ID == record.ID { + continue + } + + if existing.Type == record.Type && existing.Content == record.Content { + return status.Errorf(status.AlreadyExists, "identical record already exists") + } + + if record.Type == records.RecordTypeCNAME || existing.Type == records.RecordTypeCNAME { + return status.Errorf(status.InvalidArgument, + "An A, AAAA, or CNAME record with name %s already exists", record.Name) + } + } + + return nil +} diff --git a/management/internals/modules/zones/records/manager/manager_test.go b/management/internals/modules/zones/records/manager/manager_test.go new file mode 100644 index 000000000..a5f48c4a9 --- /dev/null +++ b/management/internals/modules/zones/records/manager/manager_test.go @@ -0,0 +1,573 @@ +package manager + +import ( + "context" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/zones" + "github.com/netbirdio/netbird/management/internals/modules/zones/records" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/mock_server" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +const ( + testAccountID = "test-account-id" + testUserID = "test-user-id" + testRecordID = "test-record-id" + testGroupID = "test-group-id" +) + +func setupTest(t *testing.T) (*managerImpl, store.Store, *zones.Zone, *mock_server.MockAccountManager, *permissions.MockManager, *gomock.Controller, func()) { + t.Helper() + + ctx := context.Background() + testStore, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + + err = testStore.SaveAccount(ctx, &types.Account{ + Id: testAccountID, + Groups: map[string]*types.Group{ + testGroupID: { + ID: testGroupID, + Name: "Test Group", + }, + }, + }) + require.NoError(t, err) + + zone := zones.NewZone(testAccountID, "Test Zone", "example.com", true, true, []string{testGroupID}) + err = testStore.CreateZone(ctx, zone) + require.NoError(t, err) + + ctrl := gomock.NewController(t) + mockAccountManager := &mock_server.MockAccountManager{} + mockPermissionsManager := permissions.NewMockManager(ctrl) + + manager := &managerImpl{ + store: testStore, + accountManager: mockAccountManager, + permissionsManager: mockPermissionsManager, + } + + return manager, testStore, zone, mockAccountManager, mockPermissionsManager, ctrl, cleanup +} + +func TestManagerImpl_GetAllRecords(t *testing.T) { + ctx := context.Background() + + t.Run("success", func(t *testing.T) { + manager, testStore, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + record1 := records.NewRecord(testAccountID, zone.ID, "api.example.com", records.RecordTypeA, "192.168.1.1", 300) + err := testStore.CreateDNSRecord(ctx, record1) + require.NoError(t, err) + + record2 := records.NewRecord(testAccountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.2", 300) + err = testStore.CreateDNSRecord(ctx, record2) + require.NoError(t, err) + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read). + Return(true, ctx, nil) + + result, err := manager.GetAllRecords(ctx, testAccountID, testUserID, zone.ID) + require.NoError(t, err) + assert.Len(t, result, 2) + assert.Equal(t, record1.ID, result[0].ID) + assert.Equal(t, record2.ID, result[1].ID) + }) + + t.Run("permission denied", func(t *testing.T) { + manager, _, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read). + Return(false, ctx, nil) + + result, err := manager.GetAllRecords(ctx, testAccountID, testUserID, zone.ID) + require.Error(t, err) + assert.Nil(t, result) + s, ok := status.FromError(err) + assert.True(t, ok) + assert.Equal(t, status.PermissionDenied, s.Type()) + }) + + t.Run("permission validation error", func(t *testing.T) { + manager, _, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read). + Return(false, ctx, status.Errorf(status.Internal, "permission check failed")) + + result, err := manager.GetAllRecords(ctx, testAccountID, testUserID, zone.ID) + require.Error(t, err) + assert.Nil(t, result) + }) +} + +func TestManagerImpl_GetRecord(t *testing.T) { + ctx := context.Background() + + t.Run("success", func(t *testing.T) { + manager, testStore, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + record := records.NewRecord(testAccountID, zone.ID, "api.example.com", records.RecordTypeA, "192.168.1.1", 300) + err := testStore.CreateDNSRecord(ctx, record) + require.NoError(t, err) + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read). + Return(true, ctx, nil) + + result, err := manager.GetRecord(ctx, testAccountID, testUserID, zone.ID, record.ID) + require.NoError(t, err) + assert.Equal(t, record.ID, result.ID) + assert.Equal(t, record.Name, result.Name) + assert.Equal(t, record.Type, result.Type) + assert.Equal(t, record.Content, result.Content) + assert.Equal(t, record.TTL, result.TTL) + }) + + t.Run("permission denied", func(t *testing.T) { + manager, _, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Read). + Return(false, ctx, nil) + + result, err := manager.GetRecord(ctx, testAccountID, testUserID, zone.ID, testRecordID) + require.Error(t, err) + assert.Nil(t, result) + s, ok := status.FromError(err) + assert.True(t, ok) + assert.Equal(t, status.PermissionDenied, s.Type()) + }) +} + +func TestManagerImpl_CreateRecord(t *testing.T) { + ctx := context.Background() + + t.Run("success - A record", func(t *testing.T) { + manager, _, zone, mockAccountManager, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + inputRecord := &records.Record{ + Name: "api.example.com", + Type: records.RecordTypeA, + Content: "192.168.1.1", + TTL: 300, + } + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create). + Return(true, ctx, nil) + + mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) { + assert.Equal(t, testUserID, initiatorID) + assert.Equal(t, testAccountID, accountID) + assert.Equal(t, activity.DNSRecordCreated, activityID) + } + + result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord) + require.NoError(t, err) + assert.NotNil(t, result) + assert.NotEmpty(t, result.ID) + assert.Equal(t, testAccountID, result.AccountID) + assert.Equal(t, zone.ID, result.ZoneID) + assert.Equal(t, inputRecord.Name, result.Name) + assert.Equal(t, inputRecord.Type, result.Type) + assert.Equal(t, inputRecord.Content, result.Content) + assert.Equal(t, inputRecord.TTL, result.TTL) + }) + + t.Run("success - AAAA record", func(t *testing.T) { + manager, _, zone, mockAccountManager, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + inputRecord := &records.Record{ + Name: "ipv6.example.com", + Type: records.RecordTypeAAAA, + Content: "2001:db8::1", + TTL: 600, + } + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create). + Return(true, ctx, nil) + + mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) { + assert.Equal(t, testUserID, initiatorID) + assert.Equal(t, testAccountID, accountID) + assert.Equal(t, activity.DNSRecordCreated, activityID) + } + + result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord) + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, inputRecord.Type, result.Type) + assert.Equal(t, inputRecord.Content, result.Content) + }) + + t.Run("success - CNAME record", func(t *testing.T) { + manager, _, zone, mockAccountManager, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + inputRecord := &records.Record{ + Name: "www.example.com", + Type: records.RecordTypeCNAME, + Content: "example.com", + TTL: 300, + } + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create). + Return(true, ctx, nil) + + mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) { + assert.Equal(t, testUserID, initiatorID) + assert.Equal(t, testAccountID, accountID) + assert.Equal(t, activity.DNSRecordCreated, activityID) + } + + result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord) + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, inputRecord.Type, result.Type) + assert.Equal(t, inputRecord.Content, result.Content) + }) + + t.Run("permission denied", func(t *testing.T) { + manager, _, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + inputRecord := &records.Record{ + Name: "api.example.com", + Type: records.RecordTypeA, + Content: "192.168.1.1", + TTL: 300, + } + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create). + Return(false, ctx, nil) + + result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord) + require.Error(t, err) + assert.Nil(t, result) + s, ok := status.FromError(err) + assert.True(t, ok) + assert.Equal(t, status.PermissionDenied, s.Type()) + }) + + t.Run("record name not in zone", func(t *testing.T) { + manager, _, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + inputRecord := &records.Record{ + Name: "api.different.com", + Type: records.RecordTypeA, + Content: "192.168.1.1", + TTL: 300, + } + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create). + Return(true, ctx, nil) + + result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "does not belong to zone") + }) + + t.Run("duplicate record", func(t *testing.T) { + manager, testStore, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + existingRecord := records.NewRecord(testAccountID, zone.ID, "api.example.com", records.RecordTypeA, "192.168.1.1", 300) + err := testStore.CreateDNSRecord(ctx, existingRecord) + require.NoError(t, err) + + inputRecord := &records.Record{ + Name: "api.example.com", + Type: records.RecordTypeA, + Content: "192.168.1.1", + TTL: 300, + } + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create). + Return(true, ctx, nil) + + result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "identical record already exists") + }) + + t.Run("CNAME conflict with existing A record", func(t *testing.T) { + manager, testStore, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + existingRecord := records.NewRecord(testAccountID, zone.ID, "api.example.com", records.RecordTypeA, "192.168.1.1", 300) + err := testStore.CreateDNSRecord(ctx, existingRecord) + require.NoError(t, err) + + inputRecord := &records.Record{ + Name: "api.example.com", + Type: records.RecordTypeCNAME, + Content: "example.com", + TTL: 300, + } + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Create). + Return(true, ctx, nil) + + result, err := manager.CreateRecord(ctx, testAccountID, testUserID, zone.ID, inputRecord) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "already exists") + }) +} + +func TestManagerImpl_UpdateRecord(t *testing.T) { + ctx := context.Background() + + t.Run("success", func(t *testing.T) { + manager, testStore, zone, mockAccountManager, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + existingRecord := records.NewRecord(testAccountID, zone.ID, "api.example.com", records.RecordTypeA, "192.168.1.1", 300) + err := testStore.CreateDNSRecord(ctx, existingRecord) + require.NoError(t, err) + + updatedRecord := &records.Record{ + ID: existingRecord.ID, + Name: "api.example.com", + Type: records.RecordTypeA, + Content: "192.168.1.100", // Changed IP + TTL: 600, // Changed TTL + } + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update). + Return(true, ctx, nil) + + storeEventCalled := false + mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) { + storeEventCalled = true + assert.Equal(t, testUserID, initiatorID) + assert.Equal(t, existingRecord.ID, targetID) + assert.Equal(t, testAccountID, accountID) + assert.Equal(t, activity.DNSRecordUpdated, activityID) + } + + result, err := manager.UpdateRecord(ctx, testAccountID, testUserID, zone.ID, updatedRecord) + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, updatedRecord.Content, result.Content) + assert.Equal(t, updatedRecord.TTL, result.TTL) + assert.True(t, storeEventCalled, "StoreEvent should have been called") + }) + + t.Run("update only TTL - no validation", func(t *testing.T) { + manager, testStore, zone, mockAccountManager, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + existingRecord := records.NewRecord(testAccountID, zone.ID, "api.example.com", records.RecordTypeA, "192.168.1.1", 300) + err := testStore.CreateDNSRecord(ctx, existingRecord) + require.NoError(t, err) + + updatedRecord := &records.Record{ + ID: existingRecord.ID, + Name: existingRecord.Name, + Type: existingRecord.Type, + Content: existingRecord.Content, + TTL: 600, // Only TTL changed + } + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update). + Return(true, ctx, nil) + + mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) { + // Event should be stored + } + + result, err := manager.UpdateRecord(ctx, testAccountID, testUserID, zone.ID, updatedRecord) + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, 600, result.TTL) + }) + + t.Run("permission denied", func(t *testing.T) { + manager, _, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + updatedRecord := &records.Record{ + ID: testRecordID, + Name: "api.example.com", + Type: records.RecordTypeA, + Content: "192.168.1.100", + TTL: 600, + } + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update). + Return(false, ctx, nil) + + result, err := manager.UpdateRecord(ctx, testAccountID, testUserID, zone.ID, updatedRecord) + require.Error(t, err) + assert.Nil(t, result) + s, ok := status.FromError(err) + assert.True(t, ok) + assert.Equal(t, status.PermissionDenied, s.Type()) + }) + + t.Run("record not found", func(t *testing.T) { + manager, _, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + updatedRecord := &records.Record{ + ID: "non-existent-record", + Name: "api.example.com", + Type: records.RecordTypeA, + Content: "192.168.1.100", + TTL: 600, + } + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update). + Return(true, ctx, nil) + + result, err := manager.UpdateRecord(ctx, testAccountID, testUserID, zone.ID, updatedRecord) + require.Error(t, err) + assert.Nil(t, result) + }) + + t.Run("update creates duplicate", func(t *testing.T) { + manager, testStore, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + record1 := records.NewRecord(testAccountID, zone.ID, "api.example.com", records.RecordTypeA, "192.168.1.1", 300) + err := testStore.CreateDNSRecord(ctx, record1) + require.NoError(t, err) + + record2 := records.NewRecord(testAccountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.2", 300) + err = testStore.CreateDNSRecord(ctx, record2) + require.NoError(t, err) + + updatedRecord := &records.Record{ + ID: record2.ID, + Name: "api.example.com", + Type: records.RecordTypeA, + Content: "192.168.1.1", + TTL: 300, + } + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Update). + Return(true, ctx, nil) + + result, err := manager.UpdateRecord(ctx, testAccountID, testUserID, zone.ID, updatedRecord) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "identical record already exists") + }) +} + +func TestManagerImpl_DeleteRecord(t *testing.T) { + ctx := context.Background() + + t.Run("success", func(t *testing.T) { + manager, testStore, zone, mockAccountManager, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + record := records.NewRecord(testAccountID, zone.ID, "api.example.com", records.RecordTypeA, "192.168.1.1", 300) + err := testStore.CreateDNSRecord(ctx, record) + require.NoError(t, err) + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete). + Return(true, ctx, nil) + + storeEventCalled := false + mockAccountManager.StoreEventFunc = func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) { + storeEventCalled = true + assert.Equal(t, testUserID, initiatorID) + assert.Equal(t, record.ID, targetID) + assert.Equal(t, testAccountID, accountID) + assert.Equal(t, activity.DNSRecordDeleted, activityID) + } + + err = manager.DeleteRecord(ctx, testAccountID, testUserID, zone.ID, record.ID) + require.NoError(t, err) + assert.True(t, storeEventCalled, "StoreEvent should have been called") + + _, err = testStore.GetDNSRecordByID(ctx, store.LockingStrengthNone, testAccountID, zone.ID, record.ID) + require.Error(t, err) + }) + + t.Run("permission denied", func(t *testing.T) { + manager, _, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete). + Return(false, ctx, nil) + + err := manager.DeleteRecord(ctx, testAccountID, testUserID, zone.ID, testRecordID) + require.Error(t, err) + s, ok := status.FromError(err) + assert.True(t, ok) + assert.Equal(t, status.PermissionDenied, s.Type()) + }) + + t.Run("record not found", func(t *testing.T) { + manager, _, zone, _, mockPermissionsManager, ctrl, cleanup := setupTest(t) + defer cleanup() + defer ctrl.Finish() + + mockPermissionsManager.EXPECT(). + ValidateUserPermissions(ctx, testAccountID, testUserID, modules.Dns, operations.Delete). + Return(true, ctx, nil) + + err := manager.DeleteRecord(ctx, testAccountID, testUserID, zone.ID, "non-existent-record") + require.Error(t, err) + }) +} diff --git a/management/internals/modules/zones/records/record.go b/management/internals/modules/zones/records/record.go new file mode 100644 index 000000000..1488febb9 --- /dev/null +++ b/management/internals/modules/zones/records/record.go @@ -0,0 +1,129 @@ +package records + +import ( + "errors" + "net" + + "github.com/rs/xid" + + "github.com/netbirdio/netbird/shared/management/domain" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +type RecordType string + +const ( + RecordTypeA RecordType = "A" + RecordTypeAAAA RecordType = "AAAA" + RecordTypeCNAME RecordType = "CNAME" +) + +type Record struct { + AccountID string `gorm:"index"` + ZoneID string `gorm:"index"` + ID string `gorm:"primaryKey"` + Name string + Type RecordType + Content string + TTL int +} + +func NewRecord(accountID, zoneID, name string, recordType RecordType, content string, ttl int) *Record { + return &Record{ + ID: xid.New().String(), + AccountID: accountID, + ZoneID: zoneID, + Name: name, + Type: recordType, + Content: content, + TTL: ttl, + } +} + +func (r *Record) ToAPIResponse() *api.DNSRecord { + recordType := api.DNSRecordType(r.Type) + return &api.DNSRecord{ + Id: r.ID, + Name: r.Name, + Type: recordType, + Content: r.Content, + Ttl: r.TTL, + } +} + +func (r *Record) FromAPIRequest(req *api.DNSRecordRequest) { + r.Name = req.Name + r.Type = RecordType(req.Type) + r.Content = req.Content + r.TTL = req.Ttl +} + +func (r *Record) Validate() error { + if r.Name == "" { + return errors.New("record name is required") + } + + if !domain.IsValidDomain(r.Name) { + return errors.New("invalid record name format") + } + + if r.Type == "" { + return errors.New("record type is required") + } + + switch r.Type { + case RecordTypeA: + if err := validateIPv4(r.Content); err != nil { + return err + } + case RecordTypeAAAA: + if err := validateIPv6(r.Content); err != nil { + return err + } + case RecordTypeCNAME: + if !domain.IsValidDomainNoWildcard(r.Content) { + return errors.New("invalid CNAME target format") + } + default: + return errors.New("invalid record type, must be A, AAAA, or CNAME") + } + + if r.TTL < 0 { + return errors.New("TTL cannot be negative") + } + + return nil +} + +func (r *Record) EventMeta(zoneID, zoneName string) map[string]any { + return map[string]any{ + "name": r.Name, + "type": string(r.Type), + "content": r.Content, + "ttl": r.TTL, + "zone_id": zoneID, + "zone_name": zoneName, + } +} + +func validateIPv4(content string) error { + if content == "" { + return errors.New("A record is required") //nolint:staticcheck + } + ip := net.ParseIP(content) + if ip == nil || ip.To4() == nil { + return errors.New("A record must be a valid IPv4 address") //nolint:staticcheck + } + return nil +} + +func validateIPv6(content string) error { + if content == "" { + return errors.New("AAAA record is required") + } + ip := net.ParseIP(content) + if ip == nil || ip.To4() != nil { + return errors.New("AAAA record must be a valid IPv6 address") + } + return nil +} diff --git a/management/internals/modules/zones/zone.go b/management/internals/modules/zones/zone.go new file mode 100644 index 000000000..f5ebed26c --- /dev/null +++ b/management/internals/modules/zones/zone.go @@ -0,0 +1,89 @@ +package zones + +import ( + "errors" + + "github.com/rs/xid" + + "github.com/netbirdio/netbird/management/internals/modules/zones/records" + "github.com/netbirdio/netbird/shared/management/domain" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +type Zone struct { + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index"` + Name string + Domain string + Enabled bool + EnableSearchDomain bool + DistributionGroups []string `gorm:"serializer:json"` + Records []*records.Record `gorm:"foreignKey:ZoneID;references:ID"` +} + +func NewZone(accountID, name, domain string, enabled, enableSearchDomain bool, distributionGroups []string) *Zone { + return &Zone{ + ID: xid.New().String(), + AccountID: accountID, + Name: name, + Domain: domain, + Enabled: enabled, + EnableSearchDomain: enableSearchDomain, + DistributionGroups: distributionGroups, + } +} + +func (z *Zone) ToAPIResponse() *api.Zone { + apiRecords := make([]api.DNSRecord, 0, len(z.Records)) + for _, record := range z.Records { + if apiRecord := record.ToAPIResponse(); apiRecord != nil { + apiRecords = append(apiRecords, *apiRecord) + } + } + + return &api.Zone{ + DistributionGroups: z.DistributionGroups, + Domain: z.Domain, + EnableSearchDomain: z.EnableSearchDomain, + Enabled: z.Enabled, + Id: z.ID, + Name: z.Name, + Records: apiRecords, + } +} + +func (z *Zone) FromAPIRequest(req *api.ZoneRequest) { + z.Name = req.Name + z.Domain = req.Domain + z.EnableSearchDomain = req.EnableSearchDomain + z.DistributionGroups = req.DistributionGroups + + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + z.Enabled = enabled +} + +func (z *Zone) Validate() error { + if z.Name == "" { + return errors.New("zone name is required") + } + if len(z.Name) > 255 { + return errors.New("zone name exceeds maximum length of 255 characters") + } + + if !domain.IsValidDomainNoWildcard(z.Domain) { + return errors.New("invalid zone domain format") + } + + if len(z.DistributionGroups) == 0 { + return errors.New("at least one distribution group is required") + } + + return nil +} + +func (z *Zone) EventMeta() map[string]any { + return map[string]any{"name": z.Name, "domain": z.Domain} +} diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index 90481d244..fd8d7e21c 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -10,28 +10,40 @@ import ( "slices" "time" - "github.com/google/uuid" "github.com/gorilla/mux" grpcMiddleware "github.com/grpc-ecosystem/go-grpc-middleware/v2" "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/realip" + "github.com/rs/cors" + "github.com/rs/xid" log "github.com/sirupsen/logrus" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/keepalive" - "github.com/netbirdio/management-integrations/integrations" + cachestore "github.com/eko/gocache/lib/v4/store" + "github.com/netbirdio/netbird/encryption" "github.com/netbirdio/netbird/formatter/hook" - nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" "github.com/netbirdio/netbird/management/server/activity" + activitystore "github.com/netbirdio/netbird/management/server/activity/store" + nbcache "github.com/netbirdio/netbird/management/server/cache" nbContext "github.com/netbirdio/netbird/management/server/context" nbhttp "github.com/netbirdio/netbird/management/server/http" + "github.com/netbirdio/netbird/management/server/http/middleware" + "github.com/netbirdio/netbird/management/server/idp" "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/util/crypt" ) +const apiPrefix = "/api" + var ( kaep = keepalive.EnforcementPolicy{ MinTime: 15 * time.Second, @@ -56,51 +68,61 @@ func (s *BaseServer) Metrics() telemetry.AppMetrics { }) } +// CacheStore returns a shared cache store backed by Redis or in-memory depending on the environment. +// All consumers should reuse this store to avoid creating multiple Redis connections. +func (s *BaseServer) CacheStore() cachestore.StoreInterface { + return Create(s, func() cachestore.StoreInterface { + cs, err := nbcache.NewStore(context.Background(), nbcache.DefaultStoreMaxTimeout, nbcache.DefaultStoreCleanupInterval, nbcache.DefaultStoreMaxConn) + if err != nil { + log.Fatalf("failed to create shared cache store: %v", err) + } + return cs + }) +} + func (s *BaseServer) Store() store.Store { return Create(s, func() store.Store { - store, err := store.NewStore(context.Background(), s.config.StoreConfig.Engine, s.config.Datadir, s.Metrics(), false) + store, err := store.NewStore(context.Background(), s.Config.StoreConfig.Engine, s.Config.Datadir, s.Metrics(), false) if err != nil { log.Fatalf("failed to create store: %v", err) } + if s.Config.DataStoreEncryptionKey != "" { + fieldEncrypt, err := crypt.NewFieldEncrypt(s.Config.DataStoreEncryptionKey) + if err != nil { + log.Fatalf("failed to create field encryptor: %v", err) + } + store.SetFieldEncrypt(fieldEncrypt) + } + return store }) } func (s *BaseServer) EventStore() activity.Store { return Create(s, func() activity.Store { - integrationMetrics, err := integrations.InitIntegrationMetrics(context.Background(), s.Metrics()) - if err != nil { - log.Fatalf("failed to initialize integration metrics: %v", err) + var err error + key := s.Config.DataStoreEncryptionKey + if key == "" { + log.Debugf("generate new activity store encryption key") + key, err = crypt.GenerateKey() + if err != nil { + log.Fatalf("failed to generate event store encryption key: %v", err) + } } - eventStore, key, err := integrations.InitEventStore(context.Background(), s.config.Datadir, s.config.DataStoreEncryptionKey, integrationMetrics) + eventStore, err := activitystore.NewSqlStore(context.Background(), s.Config.Datadir, key) if err != nil { log.Fatalf("failed to initialize event store: %v", err) } - if s.config.DataStoreEncryptionKey != key { - log.WithContext(context.Background()).Infof("update config with activity store key") - s.config.DataStoreEncryptionKey = key - err := updateMgmtConfig(context.Background(), nbconfig.MgmtConfigPath, s.config) - if err != nil { - log.Fatalf("failed to update config with activity store: %v", err) - } - } - return eventStore }) } -func (s *BaseServer) Router() *mux.Router { - return Create(s, func() *mux.Router { - return nbhttp.NewRouter(s.AuthManager(), s.Metrics(), s.AccountManager()) - }) -} - func (s *BaseServer) APIHandler() http.Handler { return Create(s, func() http.Handler { - httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.Router(), s.Metrics(), s.IntegratedValidator(), s.ProxyController(), s.PermissionsManager(), s.PeersManager(), s.SettingsManager(), s.NetworkMapController()) + httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.Router(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.PermissionsManager(), s.SettingsManager(), s.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager(), s.ServiceManager(), s.ReverseProxyDomainManager(), s.AccessLogsManager(), s.ReverseProxyGRPCServer(), s.Config.ReverseProxy.TrustedHTTPProxies, s.RateLimiter(), s.IsValidChildAccount, s.AgentNetworkManager()) if err != nil { log.Fatalf("failed to create API handler: %v", err) } @@ -108,16 +130,41 @@ func (s *BaseServer) APIHandler() http.Handler { }) } +// IDPHandler returns the HTTP handler for the embedded IdP (Dex), or nil if +// the deployment isn't using the embedded variant. +func (s *BaseServer) IDPHandler() http.Handler { + embeddedIdP, ok := s.IdpManager().(*idp.EmbeddedIdPManager) + if !ok || embeddedIdP == nil { + return nil + } + return cors.AllowAll().Handler(embeddedIdP.Handler()) +} + +func (s *BaseServer) Router() *mux.Router { + return Create(s, func() *mux.Router { + return mux.NewRouter().PathPrefix(apiPrefix).Subrouter() + }) +} + +func (s *BaseServer) RateLimiter() *middleware.APIRateLimiter { + return Create(s, func() *middleware.APIRateLimiter { + cfg, enabled := middleware.RateLimiterConfigFromEnv() + limiter := middleware.NewAPIRateLimiter(cfg) + limiter.SetEnabled(enabled) + return limiter + }) +} + func (s *BaseServer) GRPCServer() *grpc.Server { return Create(s, func() *grpc.Server { - trustedPeers := s.config.ReverseProxy.TrustedPeers + trustedPeers := s.Config.ReverseProxy.TrustedPeers defaultTrustedPeers := []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")} if len(trustedPeers) == 0 || slices.Equal[[]netip.Prefix](trustedPeers, defaultTrustedPeers) { log.WithContext(context.Background()).Warn("TrustedPeers are configured to default value '0.0.0.0/0', '::/0'. This allows connection IP spoofing.") trustedPeers = defaultTrustedPeers } - trustedHTTPProxies := s.config.ReverseProxy.TrustedHTTPProxies - trustedProxiesCount := s.config.ReverseProxy.TrustedHTTPProxiesCount + trustedHTTPProxies := s.Config.ReverseProxy.TrustedHTTPProxies + trustedProxiesCount := s.Config.ReverseProxy.TrustedHTTPProxiesCount if len(trustedHTTPProxies) > 0 && trustedProxiesCount > 0 { log.WithContext(context.Background()).Warn("TrustedHTTPProxies and TrustedHTTPProxiesCount both are configured. " + "This is not recommended way to extract X-Forwarded-For. Consider using one of these options.") @@ -128,22 +175,24 @@ func (s *BaseServer) GRPCServer() *grpc.Server { realip.WithTrustedProxiesCount(trustedProxiesCount), realip.WithHeaders([]string{realip.XForwardedFor, realip.XRealIp}), } + proxyUnary, proxyStream, proxyAuthClose := nbgrpc.NewProxyAuthInterceptors(s.Store()) + s.proxyAuthClose = proxyAuthClose gRPCOpts := []grpc.ServerOption{ grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp), - grpc.ChainUnaryInterceptor(realip.UnaryServerInterceptorOpts(realipOpts...), unaryInterceptor), - grpc.ChainStreamInterceptor(realip.StreamServerInterceptorOpts(realipOpts...), streamInterceptor), + grpc.ChainUnaryInterceptor(realip.UnaryServerInterceptorOpts(realipOpts...), unaryInterceptor, proxyUnary), + grpc.ChainStreamInterceptor(realip.StreamServerInterceptorOpts(realipOpts...), streamInterceptor, proxyStream), } - if s.config.HttpConfig.LetsEncryptDomain != "" { - certManager, err := encryption.CreateCertManager(s.config.Datadir, s.config.HttpConfig.LetsEncryptDomain) + if s.Config.HttpConfig.LetsEncryptDomain != "" { + certManager, err := encryption.CreateCertManager(s.Config.Datadir, s.Config.HttpConfig.LetsEncryptDomain) if err != nil { - log.Fatalf("failed to create certificate manager: %v", err) + log.Fatalf("failed to create certificate service: %v", err) } transportCredentials := credentials.NewTLS(certManager.TLSConfig()) gRPCOpts = append(gRPCOpts, grpc.Creds(transportCredentials)) - } else if s.config.HttpConfig.CertFile != "" && s.config.HttpConfig.CertKey != "" { - tlsConfig, err := loadTLSConfig(s.config.HttpConfig.CertFile, s.config.HttpConfig.CertKey) + } else if s.Config.HttpConfig.CertFile != "" && s.Config.HttpConfig.CertKey != "" { + tlsConfig, err := loadTLSConfig(s.Config.HttpConfig.CertFile, s.Config.HttpConfig.CertKey) if err != nil { log.Fatalf("cannot load TLS credentials: %v", err) } @@ -152,16 +201,100 @@ func (s *BaseServer) GRPCServer() *grpc.Server { } gRPCAPIHandler := grpc.NewServer(gRPCOpts...) - srv, err := nbgrpc.NewServer(s.config, s.AccountManager(), s.SettingsManager(), s.PeersUpdateManager(), s.SecretsManager(), s.Metrics(), s.EphemeralManager(), s.AuthManager(), s.IntegratedValidator(), s.NetworkMapController()) + srv, err := nbgrpc.NewServer(s.Config, s.AccountManager(), s.SettingsManager(), s.JobManager(), s.SecretsManager(), s.Metrics(), s.AuthManager(), s.IntegratedValidator(), s.NetworkMapController(), s.OAuthConfigProvider(), s.SessionStore()) if err != nil { log.Fatalf("failed to create management server: %v", err) } + serviceMgr := s.ServiceManager() + srv.SetReverseProxyManager(serviceMgr) + if serviceMgr != nil { + serviceMgr.StartExposeReaper(context.Background()) + } mgmtProto.RegisterManagementServiceServer(gRPCAPIHandler, srv) + mgmtProto.RegisterProxyServiceServer(gRPCAPIHandler, s.ReverseProxyGRPCServer()) + log.Info("ProxyService registered on gRPC server") + return gRPCAPIHandler }) } +func (s *BaseServer) ReverseProxyGRPCServer() *nbgrpc.ProxyServiceServer { + return Create(s, func() *nbgrpc.ProxyServiceServer { + proxyService := nbgrpc.NewProxyServiceServer(s.AccessLogsManager(), s.ProxyTokenStore(), s.PKCEVerifierStore(), s.proxyOIDCConfig(), s.PeersManager(), s.UsersManager(), s.IdpManager(), s.ProxyManager(), s.Store()) + s.AfterInit(func(s *BaseServer) { + proxyService.SetServiceManager(s.ServiceManager()) + proxyService.SetProxyController(s.ServiceProxyController()) + proxyService.SetAgentNetworkSynthesizer(newAgentNetworkSynthesizer(s.Store())) + proxyService.SetAgentNetworkLimitsService(s.AgentNetworkManager()) + }) + return proxyService + }) +} + +// agentNetworkSynthesizerAdapter implements nbgrpc.AgentNetworkSynthesizer by +// delegating to the agentnetwork package's store-backed synthesiser. +type agentNetworkSynthesizerAdapter struct { + store store.Store +} + +func newAgentNetworkSynthesizer(s store.Store) *agentNetworkSynthesizerAdapter { + return &agentNetworkSynthesizerAdapter{store: s} +} + +func (a *agentNetworkSynthesizerAdapter) SynthesizeServicesForCluster(ctx context.Context, clusterAddr string) ([]*rpservice.Service, error) { + return agentnetwork.SynthesizeServicesForCluster(ctx, a.store, clusterAddr) +} + +func (a *agentNetworkSynthesizerAdapter) SynthesizeServicesForAccount(ctx context.Context, accountID string) ([]*rpservice.Service, error) { + return agentnetwork.SynthesizeServices(ctx, a.store, accountID) +} + +func (a *agentNetworkSynthesizerAdapter) SynthesizeServiceForDomain(ctx context.Context, domain string) (*rpservice.Service, error) { + return agentnetwork.SynthesizeServiceForDomain(ctx, a.store, domain) +} + +func (s *BaseServer) proxyOIDCConfig() nbgrpc.ProxyOIDCConfig { + return Create(s, func() nbgrpc.ProxyOIDCConfig { + return nbgrpc.ProxyOIDCConfig{ + Issuer: s.Config.HttpConfig.AuthIssuer, + // todo: double check auth clientID value + ClientID: s.Config.HttpConfig.AuthClientID, // Reuse dashboard client + Scopes: []string{"openid", "profile", "email"}, + CallbackURL: s.Config.HttpConfig.AuthCallbackURL, + HMACKey: []byte(s.Config.DataStoreEncryptionKey), // Use the datastore encryption key for OIDC state HMACs, this should ensure all management instances are using the same key. + Audience: s.Config.HttpConfig.AuthAudience, + KeysLocation: s.Config.HttpConfig.AuthKeysLocation, + } + }) +} + +func (s *BaseServer) ProxyTokenStore() *nbgrpc.OneTimeTokenStore { + return Create(s, func() *nbgrpc.OneTimeTokenStore { + tokenStore := nbgrpc.NewOneTimeTokenStore(context.Background(), s.CacheStore()) + log.Info("One-time token store initialized for proxy authentication") + return tokenStore + }) +} + +func (s *BaseServer) PKCEVerifierStore() *nbgrpc.PKCEVerifierStore { + return Create(s, func() *nbgrpc.PKCEVerifierStore { + return nbgrpc.NewPKCEVerifierStore(context.Background(), s.CacheStore()) + }) +} + +func (s *BaseServer) AccessLogsManager() accesslogs.Manager { + return Create(s, func() accesslogs.Manager { + accessLogManager := accesslogsmanager.NewManager(s.Store(), s.PermissionsManager(), s.GeoLocationManager()) + accessLogManager.StartPeriodicCleanup( + context.Background(), + s.Config.ReverseProxy.AccessLogRetentionDays, + s.Config.ReverseProxy.AccessLogCleanupIntervalHours, + ) + return accessLogManager + }) +} + func loadTLSConfig(certFile string, certKey string) (*tls.Config, error) { // Load server's certificate and private key serverCert, err := tls.LoadX509KeyPair(certFile, certKey) @@ -187,7 +320,7 @@ func unaryInterceptor( info *grpc.UnaryServerInfo, handler grpc.UnaryHandler, ) (interface{}, error) { - reqID := uuid.New().String() + reqID := xid.New().String() //nolint ctx = context.WithValue(ctx, hook.ExecutionContextKey, hook.GRPCSource) //nolint @@ -201,7 +334,7 @@ func streamInterceptor( info *grpc.StreamServerInfo, handler grpc.StreamHandler, ) error { - reqID := uuid.New().String() + reqID := xid.New().String() wrapped := grpcMiddleware.WrapServerStream(ss) //nolint ctx := context.WithValue(ss.Context(), hook.ExecutionContextKey, hook.GRPCSource) diff --git a/management/internals/server/config/config.go b/management/internals/server/config/config.go index 67a017617..a77d5c19b 100644 --- a/management/internals/server/config/config.go +++ b/management/internals/server/config/config.go @@ -57,6 +57,14 @@ type Config struct { // disable default all-to-all policy DisableDefaultPolicy bool + + // EmbeddedIdP contains configuration for the embedded Dex OIDC provider. + // When set, Dex will be embedded in the management server and serve requests at /oauth2/ + EmbeddedIdP *idp.EmbeddedIdPConfig + + HighestSupportedSyncMessageVersion *int + + PerAccountHighestSupportedSyncMessageVersion map[string]int } // GetAuthAudiences returns the audience from the http config and device authorization flow config @@ -96,8 +104,13 @@ type HttpServerConfig struct { CertFile string // CertKey is the location of the certificate private key CertKey string + // AuthClientID is the client id used for proxy SSO auth + AuthClientID string // AuthAudience identifies the recipients that the JWT is intended for (aud in JWT) AuthAudience string + // CLIAuthAudience identifies the client app recipients that the JWT is intended for (aud in JWT) + // Used only in conjunction with EmbeddedIdP + CLIAuthAudience string // AuthIssuer identifies principal that issued the JWT AuthIssuer string // AuthUserIDClaim is the name of the claim that used as user ID @@ -110,6 +123,8 @@ type HttpServerConfig struct { IdpSignKeyRefreshEnabled bool // Extra audience ExtraAuthAudience string + // AuthCallbackDomain contains the callback domain + AuthCallbackURL string } // Host represents a Netbird host (e.g. STUN, TURN, Signal) @@ -189,4 +204,13 @@ type ReverseProxy struct { // request headers if the peer's address falls within one of these // trusted IP prefixes. TrustedPeers []netip.Prefix + + // AccessLogRetentionDays specifies the number of days to retain access logs. + // Logs older than this duration will be automatically deleted during cleanup. + // A value of 0 will default to 7 days. Negative means logs are kept indefinitely (no cleanup). + AccessLogRetentionDays int + + // AccessLogCleanupIntervalHours specifies how often (in hours) to run the cleanup routine. + // Defaults to 24 hours if not set or set to 0. + AccessLogCleanupIntervalHours int } diff --git a/management/internals/server/container.go b/management/internals/server/container.go index e99465f30..ee9e65016 100644 --- a/management/internals/server/container.go +++ b/management/internals/server/container.go @@ -44,6 +44,9 @@ func maybeCreateNamed[T any](s Server, name string, createFunc func() T) (result func maybeCreateKeyed[T any](s Server, key string, createFunc func() T) (result T, isNew bool) { if t, ok := s.GetContainer(key); ok { + if t == nil { + return result, false + } return t.(T), false } diff --git a/management/internals/server/controllers.go b/management/internals/server/controllers.go index b61e33688..1b2556809 100644 --- a/management/internals/server/controllers.go +++ b/management/internals/server/controllers.go @@ -6,31 +6,45 @@ import ( log "github.com/sirupsen/logrus" "github.com/netbirdio/management-integrations/integrations" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager" + "github.com/netbirdio/netbird/management/internals/controllers/network_map" nmapcontroller "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" + "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral" + "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" "github.com/netbirdio/netbird/management/internals/shared/grpc" "github.com/netbirdio/netbird/management/server" "github.com/netbirdio/netbird/management/server/auth" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" + "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/peers/ephemeral" - "github.com/netbirdio/netbird/management/server/peers/ephemeral/manager" + "github.com/netbirdio/netbird/management/server/job" + nbjwt "github.com/netbirdio/netbird/shared/auth/jwt" ) func (s *BaseServer) PeersUpdateManager() network_map.PeersUpdateManager { - return Create(s, func() *update_channel.PeersUpdateManager { + return Create(s, func() network_map.PeersUpdateManager { return update_channel.NewPeersUpdateManager(s.Metrics()) }) } +func (s *BaseServer) JobManager() *job.Manager { + return Create(s, func() *job.Manager { + return job.NewJobManager(s.Metrics(), s.Store(), s.PeersManager()) + }) +} + func (s *BaseServer) IntegratedValidator() integrated_validator.IntegratedValidator { return Create(s, func() integrated_validator.IntegratedValidator { - integratedPeerValidator, err := integrations.NewIntegratedValidator( + integratedPeerValidator, err := validator.NewIntegratedValidator( context.Background(), s.PeersManager(), s.SettingsManager(), - s.EventStore()) + s.EventStore(), + s.CacheStore()) if err != nil { log.Errorf("failed to create integrated peer validator: %v", err) } @@ -44,33 +58,82 @@ func (s *BaseServer) ProxyController() port_forwarding.Controller { }) } -func (s *BaseServer) SecretsManager() *grpc.TimeBasedAuthSecretsManager { - return Create(s, func() *grpc.TimeBasedAuthSecretsManager { - return grpc.NewTimeBasedAuthSecretsManager(s.PeersUpdateManager(), s.config.TURNConfig, s.config.Relay, s.SettingsManager(), s.GroupsManager()) +func (s *BaseServer) SecretsManager() grpc.SecretsManager { + return Create(s, func() grpc.SecretsManager { + secretsManager, err := grpc.NewTimeBasedAuthSecretsManager(s.PeersUpdateManager(), s.Config.TURNConfig, s.Config.Relay, s.SettingsManager(), s.GroupsManager()) + if err != nil { + log.Fatalf("failed to create secrets manager: %v", err) + } + return secretsManager + }) +} + +func (s *BaseServer) SessionStore() *auth.SessionStore { + return Create(s, func() *auth.SessionStore { + return auth.NewSessionStore(s.CacheStore()) }) } func (s *BaseServer) AuthManager() auth.Manager { + audiences := s.Config.GetAuthAudiences() + audience := s.Config.HttpConfig.AuthAudience + keysLocation := s.Config.HttpConfig.AuthKeysLocation + signingKeyRefreshEnabled := s.Config.HttpConfig.IdpSignKeyRefreshEnabled + issuer := s.Config.HttpConfig.AuthIssuer + userIDClaim := s.Config.HttpConfig.AuthUserIDClaim + var keyFetcher nbjwt.KeyFetcher + + // Use embedded IdP configuration if available + if oauthProvider := s.OAuthConfigProvider(); oauthProvider != nil { + audiences = oauthProvider.GetClientIDs() + if len(audiences) > 0 { + audience = audiences[0] // Use the first client ID as the primary audience + } + keyFetcher = oauthProvider.GetKeyFetcher() + // Fall back to default keys location if direct key fetching is not available + if keyFetcher == nil { + keysLocation = oauthProvider.GetLocalKeysLocation() + } + signingKeyRefreshEnabled = true + issuer = oauthProvider.GetIssuer() + userIDClaim = oauthProvider.GetUserIDClaim() + } + return Create(s, func() auth.Manager { return auth.NewManager(s.Store(), - s.config.HttpConfig.AuthIssuer, - s.config.HttpConfig.AuthAudience, - s.config.HttpConfig.AuthKeysLocation, - s.config.HttpConfig.AuthUserIDClaim, - s.config.GetAuthAudiences(), - s.config.HttpConfig.IdpSignKeyRefreshEnabled) + issuer, + audience, + keysLocation, + userIDClaim, + audiences, + signingKeyRefreshEnabled, + keyFetcher) }) } func (s *BaseServer) EphemeralManager() ephemeral.Manager { return Create(s, func() ephemeral.Manager { - return manager.NewEphemeralManager(s.Store(), s.AccountManager()) + em := manager.NewEphemeralManager(s.Store(), s.PeersManager()) + if metrics := s.Metrics(); metrics != nil { + em.SetMetrics(metrics.EphemeralPeersMetrics()) + } + return em }) } func (s *BaseServer) NetworkMapController() network_map.Controller { - return Create(s, func() *nmapcontroller.Controller { - return nmapcontroller.NewController(context.Background(), s.Store(), s.Metrics(), s.PeersUpdateManager(), s.AccountRequestBuffer(), s.IntegratedValidator(), s.SettingsManager(), s.dnsDomain, s.ProxyController()) + return Create(s, func() network_map.Controller { + return nmapcontroller.NewController(context.Background(), s.Store(), s.Metrics(), s.PeersUpdateManager(), s.AccountRequestBuffer(), s.IntegratedValidator(), s.SettingsManager(), s.DNSDomain(), s.ProxyController(), s.EphemeralManager(), s.Config) + }) +} + +func (s *BaseServer) ServiceProxyController() proxy.Controller { + return Create(s, func() proxy.Controller { + controller, err := proxymanager.NewGRPCController(s.ReverseProxyGRPCServer(), s.Metrics().GetMeter()) + if err != nil { + log.Fatalf("failed to create service proxy controller: %v", err) + } + return controller }) } @@ -79,3 +142,7 @@ func (s *BaseServer) AccountRequestBuffer() *server.AccountRequestBuffer { return server.NewAccountRequestBuffer(context.Background(), s.Store()) }) } + +func (s *BaseServer) DNSDomain() string { + return s.dnsDomain +} diff --git a/management/internals/server/modules.go b/management/internals/server/modules.go index 409bdaaba..100106bae 100644 --- a/management/internals/server/modules.go +++ b/management/internals/server/modules.go @@ -2,10 +2,23 @@ package server import ( "context" + "os" log "github.com/sirupsen/logrus" "github.com/netbirdio/management-integrations/integrations" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + "github.com/netbirdio/netbird/management/internals/modules/peers" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + nbreverseproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service/manager" + "github.com/netbirdio/netbird/management/internals/modules/zones" + zonesManager "github.com/netbirdio/netbird/management/internals/modules/zones/manager" + "github.com/netbirdio/netbird/management/internals/modules/zones/records" + recordsManager "github.com/netbirdio/netbird/management/internals/modules/zones/records/manager" "github.com/netbirdio/netbird/management/server" "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/geolocation" @@ -14,20 +27,30 @@ import ( "github.com/netbirdio/netbird/management/server/networks" "github.com/netbirdio/netbird/management/server/networks/resources" "github.com/netbirdio/netbird/management/server/networks/routers" - "github.com/netbirdio/netbird/management/server/peers" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" "github.com/netbirdio/netbird/management/server/users" ) +const ( + geolocationDisabledKey = "NB_DISABLE_GEOLOCATION" +) + func (s *BaseServer) GeoLocationManager() geolocation.Geolocation { + if os.Getenv(geolocationDisabledKey) == "true" { + log.Info("geolocation service is disabled, skipping initialization") + return nil + } + return Create(s, func() geolocation.Geolocation { - geo, err := geolocation.NewGeolocation(context.Background(), s.config.Datadir, !s.disableGeoliteUpdate) + geo, err := geolocation.NewGeolocation(context.Background(), s.Config.Datadir, !s.disableGeoliteUpdate) if err != nil { log.Fatalf("could not initialize geolocation service: %v", err) } - log.Infof("geolocation service has been initialized from %s", s.config.Datadir) + log.Infof("geolocation service has been initialized from %s", s.Config.Datadir) return geo }) @@ -35,13 +58,7 @@ func (s *BaseServer) GeoLocationManager() geolocation.Geolocation { func (s *BaseServer) PermissionsManager() permissions.Manager { return Create(s, func() permissions.Manager { - manager := integrations.InitPermissionsManager(s.Store(), s.Metrics().GetMeter()) - - s.AfterInit(func(s *BaseServer) { - manager.SetAccountManager(s.AccountManager()) - }) - - return manager + return permissions.NewManager(s.Store()) }) } @@ -54,44 +71,106 @@ func (s *BaseServer) UsersManager() users.Manager { func (s *BaseServer) SettingsManager() settings.Manager { return Create(s, func() settings.Manager { extraSettingsManager := integrations.NewManager(s.EventStore()) - return settings.NewManager(s.Store(), s.UsersManager(), extraSettingsManager, s.PermissionsManager()) + + idpConfig := settings.IdpConfig{} + if s.Config.EmbeddedIdP != nil && s.Config.EmbeddedIdP.Enabled { + idpConfig.EmbeddedIdpEnabled = true + idpConfig.LocalAuthDisabled = s.Config.EmbeddedIdP.LocalAuthDisabled + } + + return settings.NewManager(s.Store(), s.UsersManager(), extraSettingsManager, s.PermissionsManager(), idpConfig) }) } func (s *BaseServer) PeersManager() peers.Manager { return Create(s, func() peers.Manager { - return peers.NewManager(s.Store(), s.PermissionsManager()) + manager := peers.NewManager(s.Store(), s.PermissionsManager()) + s.AfterInit(func(s *BaseServer) { + manager.SetNetworkMapController(s.NetworkMapController()) + manager.SetIntegratedPeerValidator(s.IntegratedValidator()) + manager.SetAccountManager(s.AccountManager()) + }) + return manager }) } func (s *BaseServer) AccountManager() account.Manager { return Create(s, func() account.Manager { - accountManager, err := server.BuildManager(context.Background(), s.Store(), s.NetworkMapController(), s.IdpManager(), s.mgmtSingleAccModeDomain, s.EventStore(), s.GeoLocationManager(), s.userDeleteFromIDPEnabled, s.IntegratedValidator(), s.Metrics(), s.ProxyController(), s.SettingsManager(), s.PermissionsManager(), s.config.DisableDefaultPolicy) + accountManager, err := server.BuildManager(context.Background(), s.Config, s.Store(), s.NetworkMapController(), s.JobManager(), s.IdpManager(), s.mgmtSingleAccModeDomain, s.EventStore(), s.GeoLocationManager(), s.userDeleteFromIDPEnabled, s.IntegratedValidator(), s.Metrics(), s.ProxyController(), s.SettingsManager(), s.PermissionsManager(), s.Config.DisableDefaultPolicy, s.CacheStore()) if err != nil { - log.Fatalf("failed to create account manager: %v", err) + log.Fatalf("failed to create account service: %v", err) } s.AfterInit(func(s *BaseServer) { - accountManager.SetEphemeralManager(s.EphemeralManager()) + accountManager.SetServiceManager(s.ServiceManager()) }) + return accountManager }) } +func isMFAEnabledForAccount(accounts []*types.Account) bool { + if len(accounts) != 1 { + return false + } + + settings := accounts[0].Settings + return settings != nil && settings.LocalMfaEnabled +} + func (s *BaseServer) IdpManager() idp.Manager { return Create(s, func() idp.Manager { - var idpManager idp.Manager - var err error - if s.config.IdpManagerConfig != nil { - idpManager, err = idp.NewManager(context.Background(), *s.config.IdpManagerConfig, s.Metrics()) + // Use embedded IdP service if embedded Dex is configured and enabled. + // Legacy IdpManager won't be used anymore even if configured. + embeddedEnabled := s.Config.EmbeddedIdP != nil && s.Config.EmbeddedIdP.Enabled + if embeddedEnabled { + embeddedMgr, err := idp.NewEmbeddedIdPManager(context.Background(), s.Config.EmbeddedIdP, s.Metrics()) if err != nil { - log.Fatalf("failed to create IDP manager: %v", err) + log.Fatalf("failed to create embedded IDP service: %v", err) } + + if val := isMFAEnabledForAccount(s.Store().GetAllAccounts(context.Background())); val { + if err := embeddedMgr.SetMFAEnabled(context.Background(), val); err != nil { + log.Errorf("failed to set MFA enabled on embedded IDP: %v", err) + } + } + + return embeddedMgr } - return idpManager + + // Fall back to external IdP service + if s.Config.IdpManagerConfig != nil { + idpManager, err := idp.NewManager(context.Background(), *s.Config.IdpManagerConfig, s.Metrics()) + if err != nil { + log.Fatalf("failed to create IDP service: %v", err) + } + + return idpManager + } + + return nil }) } +// OAuthConfigProvider is only relevant when we have an embedded IdP service. Otherwise must be nil +func (s *BaseServer) OAuthConfigProvider() idp.OAuthConfigProvider { + if s.Config.EmbeddedIdP == nil || !s.Config.EmbeddedIdP.Enabled { + return nil + } + + idpManager := s.IdpManager() + if idpManager == nil { + return nil + } + + // Reuse the EmbeddedIdPManager instance from IdpManager + // EmbeddedIdPManager implements both idp.Manager and idp.OAuthConfigProvider + if provider, ok := idpManager.(idp.OAuthConfigProvider); ok { + return provider + } + return nil +} + func (s *BaseServer) GroupsManager() groups.Manager { return Create(s, func() groups.Manager { return groups.NewManager(s.Store(), s.PermissionsManager(), s.AccountManager()) @@ -100,7 +179,7 @@ func (s *BaseServer) GroupsManager() groups.Manager { func (s *BaseServer) ResourcesManager() resources.Manager { return Create(s, func() resources.Manager { - return resources.NewManager(s.Store(), s.PermissionsManager(), s.GroupsManager(), s.AccountManager()) + return resources.NewManager(s.Store(), s.PermissionsManager(), s.GroupsManager(), s.AccountManager(), s.ServiceManager()) }) } @@ -115,3 +194,60 @@ func (s *BaseServer) NetworksManager() networks.Manager { return networks.NewManager(s.Store(), s.PermissionsManager(), s.ResourcesManager(), s.RoutesManager(), s.AccountManager()) }) } + +func (s *BaseServer) AgentNetworkManager() agentnetwork.Manager { + return Create(s, func() agentnetwork.Manager { + mgr := agentnetwork.NewManager( + s.Store(), + s.PermissionsManager(), + s.AccountManager(), + s.ServiceProxyController(), + ) + // Sweep expired agent-network access logs per account retention, + // reusing the reverse-proxy cleanup interval config. + mgr.StartAccessLogCleanup( + context.Background(), + s.Config.ReverseProxy.AccessLogCleanupIntervalHours, + ) + return mgr + }) +} + +func (s *BaseServer) ZonesManager() zones.Manager { + return Create(s, func() zones.Manager { + return zonesManager.NewManager(s.Store(), s.AccountManager(), s.PermissionsManager(), s.DNSDomain()) + }) +} + +func (s *BaseServer) RecordsManager() records.Manager { + return Create(s, func() records.Manager { + return recordsManager.NewManager(s.Store(), s.AccountManager(), s.PermissionsManager()) + }) +} + +func (s *BaseServer) ServiceManager() service.Manager { + return Create(s, func() service.Manager { + return nbreverseproxy.NewManager(s.Store(), s.AccountManager(), s.PermissionsManager(), s.ServiceProxyController(), s.ProxyManager(), s.ReverseProxyDomainManager()) + }) +} + +func (s *BaseServer) ProxyManager() proxy.Manager { + return Create(s, func() proxy.Manager { + manager, err := proxymanager.NewManager(s.Store(), s.Metrics().GetMeter()) + if err != nil { + log.Fatalf("failed to create proxy manager: %v", err) + } + return manager + }) +} + +func (s *BaseServer) ReverseProxyDomainManager() *manager.Manager { + return Create(s, func() *manager.Manager { + m := manager.NewManager(s.Store(), s.ProxyManager(), s.PermissionsManager(), s.AccountManager()) + return &m + }) +} + +func (s *BaseServer) IsValidChildAccount(_ context.Context, _, _, _ string) bool { + return false +} diff --git a/management/internals/server/server.go b/management/internals/server/server.go index ab1c2ebe7..7fd06d947 100644 --- a/management/internals/server/server.go +++ b/management/internals/server/server.go @@ -15,22 +15,28 @@ import ( "go.opentelemetry.io/otel/metric" "golang.org/x/crypto/acme/autocert" "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" + "golang.org/x/net/http2/h2c" //nolint:staticcheck "google.golang.org/grpc" "github.com/netbirdio/netbird/encryption" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/netbird/management/server/metrics" "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/util" "github.com/netbirdio/netbird/util/wsproxy" wsproxyserver "github.com/netbirdio/netbird/util/wsproxy/server" "github.com/netbirdio/netbird/version" ) -// ManagementLegacyPort is the port that was used before by the Management gRPC server. -// It is used for backward compatibility now. -const ManagementLegacyPort = 33073 +const ( + // ManagementLegacyPort is the port that was used before by the Management gRPC server. + // It is used for backward compatibility now. + ManagementLegacyPort = 33073 + // DefaultSelfHostedDomain is the default domain used for self-hosted fresh installs. + DefaultSelfHostedDomain = "netbird.selfhosted" + + ContainerKeyBaseServer = "baseServer" +) type Server interface { Start(ctx context.Context) error @@ -40,23 +46,27 @@ type Server interface { SetContainer(key string, container any) } -// Server holds the HTTP BaseServer instance. -// Add any additional fields you need, such as database connections, config, etc. +// BaseServer holds the HTTP server instance. +// Add any additional fields you need, such as database connections, Config, etc. type BaseServer struct { - // config holds the server configuration - config *nbconfig.Config + // Config holds the server configuration + Config *nbconfig.Config // container of dependencies, each dependency is identified by a unique string. container map[string]any // AfterInit is a function that will be called after the server is initialized afterInit []func(s *BaseServer) - disableMetrics bool - dnsDomain string - disableGeoliteUpdate bool - userDeleteFromIDPEnabled bool - mgmtSingleAccModeDomain string - mgmtMetricsPort int - mgmtPort int + disableMetrics bool + dnsDomain string + disableGeoliteUpdate bool + userDeleteFromIDPEnabled bool + mgmtSingleAccModeDomain string + mgmtMetricsPort int + mgmtPort int + disableLegacyManagementPort bool + autoResolveDomains bool + + proxyAuthClose func() listener net.Listener certManager *autocert.Manager @@ -67,19 +77,38 @@ type BaseServer struct { cancel context.CancelFunc } +// Config holds the configuration parameters for creating a new server +type Config struct { + NbConfig *nbconfig.Config + DNSDomain string + MgmtSingleAccModeDomain string + MgmtPort int + MgmtMetricsPort int + DisableLegacyManagementPort bool + DisableMetrics bool + DisableGeoliteUpdate bool + UserDeleteFromIDPEnabled bool + AutoResolveDomains bool +} + // NewServer initializes and configures a new Server instance -func NewServer(config *nbconfig.Config, dnsDomain, mgmtSingleAccModeDomain string, mgmtPort, mgmtMetricsPort int, disableMetrics, disableGeoliteUpdate, userDeleteFromIDPEnabled bool) *BaseServer { - return &BaseServer{ - config: config, - container: make(map[string]any), - dnsDomain: dnsDomain, - mgmtSingleAccModeDomain: mgmtSingleAccModeDomain, - disableMetrics: disableMetrics, - disableGeoliteUpdate: disableGeoliteUpdate, - userDeleteFromIDPEnabled: userDeleteFromIDPEnabled, - mgmtPort: mgmtPort, - mgmtMetricsPort: mgmtMetricsPort, +func NewServer(cfg *Config) *BaseServer { + s := &BaseServer{ + Config: cfg.NbConfig, + container: make(map[string]any), + dnsDomain: cfg.DNSDomain, + mgmtSingleAccModeDomain: cfg.MgmtSingleAccModeDomain, + disableMetrics: cfg.DisableMetrics, + disableGeoliteUpdate: cfg.DisableGeoliteUpdate, + userDeleteFromIDPEnabled: cfg.UserDeleteFromIDPEnabled, + mgmtPort: cfg.MgmtPort, + disableLegacyManagementPort: cfg.DisableLegacyManagementPort, + mgmtMetricsPort: cfg.MgmtMetricsPort, + autoResolveDomains: cfg.AutoResolveDomains, } + s.container[ContainerKeyBaseServer] = s + + return s } func (s *BaseServer) AfterInit(fn func(s *BaseServer)) { @@ -92,6 +121,10 @@ func (s *BaseServer) Start(ctx context.Context) error { s.cancel = cancel s.errCh = make(chan error, 4) + if s.autoResolveDomains { + s.ResolveDomains(srvCtx) + } + s.PeersManager() s.GeoLocationManager() @@ -103,14 +136,14 @@ func (s *BaseServer) Start(ctx context.Context) error { var tlsConfig *tls.Config tlsEnabled := false - if s.config.HttpConfig.LetsEncryptDomain != "" { - s.certManager, err = encryption.CreateCertManager(s.config.Datadir, s.config.HttpConfig.LetsEncryptDomain) + if s.Config.HttpConfig.LetsEncryptDomain != "" { + s.certManager, err = encryption.CreateCertManager(s.Config.Datadir, s.Config.HttpConfig.LetsEncryptDomain) if err != nil { return fmt.Errorf("failed creating LetsEncrypt cert manager: %v", err) } tlsEnabled = true - } else if s.config.HttpConfig.CertFile != "" && s.config.HttpConfig.CertKey != "" { - tlsConfig, err = loadTLSConfig(s.config.HttpConfig.CertFile, s.config.HttpConfig.CertKey) + } else if s.Config.HttpConfig.CertFile != "" && s.Config.HttpConfig.CertKey != "" { + tlsConfig, err = loadTLSConfig(s.Config.HttpConfig.CertFile, s.Config.HttpConfig.CertKey) if err != nil { log.WithContext(srvCtx).Errorf("cannot load TLS credentials: %v", err) return err @@ -126,15 +159,31 @@ func (s *BaseServer) Start(ctx context.Context) error { if !s.disableMetrics { idpManager := "disabled" - if s.config.IdpManagerConfig != nil && s.config.IdpManagerConfig.ManagerType != "" { - idpManager = s.config.IdpManagerConfig.ManagerType + if s.Config.IdpManagerConfig != nil && s.Config.IdpManagerConfig.ManagerType != "" { + idpManager = s.Config.IdpManagerConfig.ManagerType } + + if s.Config.EmbeddedIdP != nil && s.Config.EmbeddedIdP.Enabled { + idpManager = metrics.EmbeddedType + } + metricsWorker := metrics.NewWorker(srvCtx, installationID, s.Store(), s.PeersUpdateManager(), idpManager) go metricsWorker.Run(srvCtx) } + // Eagerly create the gRPC server so that all AfterInit hooks are registered + // before we iterate them. Lazy creation after the loop would miss hooks + // registered during GRPCServer() construction (e.g., SetServiceManager). + s.GRPCServer() + + for _, fn := range s.afterInit { + if fn != nil { + fn(s) + } + } + var compatListener net.Listener - if s.mgmtPort != ManagementLegacyPort { + if s.mgmtPort != ManagementLegacyPort && !s.disableLegacyManagementPort { // The Management gRPC server was running on port 33073 previously. Old agents that are already connected to it // are using port 33073. For compatibility purposes we keep running a 2nd gRPC server on port 33073. compatListener, err = s.serveGRPC(srvCtx, s.GRPCServer(), ManagementLegacyPort) @@ -144,7 +193,7 @@ func (s *BaseServer) Start(ctx context.Context) error { log.WithContext(srvCtx).Infof("running gRPC backward compatibility server: %s", compatListener.Addr().String()) } - rootHandler := s.handlerFunc(s.GRPCServer(), s.APIHandler(), s.Metrics().GetMeter()) + rootHandler := s.handlerFunc(srvCtx, s.GRPCServer(), s.APIHandler(), s.IDPHandler(), s.Metrics().GetMeter()) switch { case s.certManager != nil: // a call to certManager.Listener() always creates a new listener so we do it once @@ -173,17 +222,11 @@ func (s *BaseServer) Start(ctx context.Context) error { } } - for _, fn := range s.afterInit { - if fn != nil { - fn(s) - } - } - log.WithContext(ctx).Infof("management server version %s", version.NetbirdVersion()) log.WithContext(ctx).Infof("running HTTP server and gRPC server on the same port: %s", s.listener.Addr().String()) s.serveGRPCWithHTTP(ctx, s.listener, rootHandler, tlsEnabled) - s.update = version.NewUpdate("nb/management") + s.update = version.NewUpdateAndStart("nb/management") s.update.SetDaemonVersion(version.NetbirdVersion()) s.update.SetOnUpdateListener(func() { log.WithContext(ctx).Infof("your management version, \"%s\", is outdated, a new management version is available. Learn more here: https://github.com/netbirdio/netbird/releases", version.NetbirdVersion()) @@ -210,11 +253,19 @@ func (s *BaseServer) Stop() error { _ = s.certManager.Listener().Close() } s.GRPCServer().Stop() + if s.proxyAuthClose != nil { + s.proxyAuthClose() + s.proxyAuthClose = nil + } _ = s.Store().Close(ctx) _ = s.EventStore().Close(ctx) if s.update != nil { s.update.StopWatch() } + // Stop embedded IdP if configured + if embeddedIdP, ok := s.IdpManager().(*idp.EmbeddedIdPManager); ok { + _ = embeddedIdP.Stop(ctx) + } select { case <-s.Errors(): @@ -246,11 +297,23 @@ func (s *BaseServer) SetContainer(key string, container any) { log.Tracef("container with key %s set successfully", key) } -func updateMgmtConfig(ctx context.Context, path string, config *nbconfig.Config) error { - return util.DirectWriteJson(ctx, path, config) +// SetHandlerFunc allows overriding the default HTTP handler function. +// This is useful for multiplexing additional services on the same port. +func (s *BaseServer) SetHandlerFunc(handler http.Handler) { + s.container["customHandler"] = handler + log.Tracef("custom handler set successfully") } -func (s *BaseServer) handlerFunc(gRPCHandler *grpc.Server, httpHandler http.Handler, meter metric.Meter) http.Handler { +func (s *BaseServer) handlerFunc(_ context.Context, gRPCHandler *grpc.Server, httpHandler http.Handler, idpHandler http.Handler, meter metric.Meter) http.Handler { + // Check if a custom handler was set (for multiplexing additional services) + if customHandler, ok := s.GetContainer("customHandler"); ok { + if handler, ok := customHandler.(http.Handler); ok { + log.Tracef("using custom handler") + return handler + } + } + + // Use default handler wsProxy := wsproxyserver.New(gRPCHandler, wsproxyserver.WithOTelMeter(meter)) return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { @@ -260,6 +323,8 @@ func (s *BaseServer) handlerFunc(gRPCHandler *grpc.Server, httpHandler http.Hand gRPCHandler.ServeHTTP(writer, request) case request.URL.Path == wsproxy.ProxyPath+wsproxy.ManagementComponent: wsProxy.Handler().ServeHTTP(writer, request) + case idpHandler != nil && strings.HasPrefix(request.URL.Path, "/oauth2"): + idpHandler.ServeHTTP(writer, request) default: httpHandler.ServeHTTP(writer, request) } @@ -317,6 +382,7 @@ func (s *BaseServer) serveGRPCWithHTTP(ctx context.Context, listener net.Listene // the following magic is needed to support HTTP2 without TLS // and still share a single port between gRPC and HTTP APIs h1s := &http.Server{ + //nolint:staticcheck // h2c also handles the HTTP/1 Upgrade mechanism, which http.Server's UnencryptedHTTP2 does not Handler: h2c.NewHandler(handler, &http2.Server{}), } err = h1s.Serve(listener) @@ -333,6 +399,60 @@ func (s *BaseServer) serveGRPCWithHTTP(ctx context.Context, listener net.Listene }() } +// ResolveDomains determines dnsDomain and mgmtSingleAccModeDomain based on store state. +// Fresh installs use the default self-hosted domain, while existing installs reuse the +// persisted account domain to keep addressing stable across config changes. +func (s *BaseServer) ResolveDomains(ctx context.Context) { + st := s.Store() + + setDefault := func(logMsg string, args ...any) { + if logMsg != "" { + log.WithContext(ctx).Warnf(logMsg, args...) + } + s.dnsDomain = DefaultSelfHostedDomain + s.mgmtSingleAccModeDomain = DefaultSelfHostedDomain + } + + accountsCount, err := st.GetAccountsCounter(ctx) + if err != nil { + setDefault("resolve domains: failed to read accounts counter: %v; using default domain %q", err, DefaultSelfHostedDomain) + return + } + + if accountsCount == 0 { + s.dnsDomain = DefaultSelfHostedDomain + s.mgmtSingleAccModeDomain = DefaultSelfHostedDomain + log.WithContext(ctx).Infof("resolve domains: fresh install detected, using default domain %q", DefaultSelfHostedDomain) + return + } + + accountID, err := st.GetAnyAccountID(ctx) + if err != nil { + setDefault("resolve domains: failed to get existing account ID: %v; using default domain %q", err, DefaultSelfHostedDomain) + return + } + + if accountID == "" { + setDefault("resolve domains: empty account ID returned for existing accounts; using default domain %q", DefaultSelfHostedDomain) + return + } + + domain, _, err := st.GetAccountDomainAndCategory(ctx, store.LockingStrengthNone, accountID) + if err != nil { + setDefault("resolve domains: failed to get account domain for account %q: %v; using default domain %q", accountID, err, DefaultSelfHostedDomain) + return + } + + if domain == "" { + setDefault("resolve domains: account %q has empty domain; using default domain %q", accountID, DefaultSelfHostedDomain) + return + } + + s.dnsDomain = domain + s.mgmtSingleAccModeDomain = domain + log.WithContext(ctx).Infof("resolve domains: using persisted account domain %q", domain) +} + func getInstallationID(ctx context.Context, store store.Store) (string, error) { installationID := store.GetInstallationID() if installationID != "" { diff --git a/management/internals/server/server_resolve_domains_test.go b/management/internals/server/server_resolve_domains_test.go new file mode 100644 index 000000000..ba9eb3f74 --- /dev/null +++ b/management/internals/server/server_resolve_domains_test.go @@ -0,0 +1,63 @@ +package server + +import ( + "context" + "errors" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" + + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/store" +) + +func TestResolveDomains_FreshInstallUsesDefault(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT().GetAccountsCounter(gomock.Any()).Return(int64(0), nil) + + srv := NewServer(&Config{NbConfig: &nbconfig.Config{}}) + Inject[store.Store](srv, mockStore) + + srv.ResolveDomains(context.Background()) + + require.Equal(t, DefaultSelfHostedDomain, srv.dnsDomain) + require.Equal(t, DefaultSelfHostedDomain, srv.mgmtSingleAccModeDomain) +} + +func TestResolveDomains_ExistingInstallUsesPersistedDomain(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT().GetAccountsCounter(gomock.Any()).Return(int64(1), nil) + mockStore.EXPECT().GetAnyAccountID(gomock.Any()).Return("acc-1", nil) + mockStore.EXPECT().GetAccountDomainAndCategory(gomock.Any(), store.LockingStrengthNone, "acc-1").Return("vpn.mycompany.com", "", nil) + + srv := NewServer(&Config{NbConfig: &nbconfig.Config{}}) + Inject[store.Store](srv, mockStore) + + srv.ResolveDomains(context.Background()) + + require.Equal(t, "vpn.mycompany.com", srv.dnsDomain) + require.Equal(t, "vpn.mycompany.com", srv.mgmtSingleAccModeDomain) +} + +func TestResolveDomains_StoreErrorFallsBackToDefault(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT().GetAccountsCounter(gomock.Any()).Return(int64(0), errors.New("db failed")) + + srv := NewServer(&Config{NbConfig: &nbconfig.Config{}}) + Inject[store.Store](srv, mockStore) + + srv.ResolveDomains(context.Background()) + + require.Equal(t, DefaultSelfHostedDomain, srv.dnsDomain) + require.Equal(t, DefaultSelfHostedDomain, srv.mgmtSingleAccModeDomain) +} diff --git a/management/internals/shared/grpc/components_encoder.go b/management/internals/shared/grpc/components_encoder.go new file mode 100644 index 000000000..7e43cf478 --- /dev/null +++ b/management/internals/shared/grpc/components_encoder.go @@ -0,0 +1,766 @@ +package grpc + +import ( + "encoding/base64" + "strconv" + + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/server/types" + nbroute "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/networkmap" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// wgKeyRawLen is the raw byte length of a WireGuard public key. +const wgKeyRawLen = 32 + +// ComponentsEnvelopeInput bundles the data the component-format encoder needs. +// The envelope is fully self-contained — every field needed by the client's +// local Calculate() comes from the components struct itself. The only +// externally-supplied data is the receiving peer's PeerConfig (which is +// computed alongside the components in the network_map controller and reused +// from the legacy proto path) and the dns_domain string. +type ComponentsEnvelopeInput struct { + Components *types.NetworkMapComponents + PeerConfig *proto.PeerConfig + DNSDomain string + DNSForwarderPort int64 + // UserIDClaim is the OIDC claim name the client should embed in + // SshAuth.UserIDClaim when reconstructing the NetworkMap. Empty value + // is OK — client treats empty as "no SshAuth to build". + UserIDClaim string + // ProxyPatch carries pre-expanded NetworkMap fragments injected by + // external controllers (BYOP/port-forwarding). Nil when no proxy data + // is present; encoder skips the field in that case. + ProxyPatch *proto.ProxyPatch +} + +// EncodeNetworkMapEnvelope converts NetworkMapComponents into the component +// wire envelope. The encoder is intentionally non-deterministic: it iterates +// Go maps in their native (random) order. Indexes inside the envelope +// (peer_indexes, source_group_ids, agent_version_idx, router_peer_indexes) +// are self-consistent within a single encode, so the decoder reconstructs +// the same typed objects regardless of emit order. Tests that need to +// compare envelopes do so semantically via proto round-trip + canonicalize, +// not byte-equal. +// +// Callers must NOT concatenate or merge envelopes from different encodes — +// index spaces are local to a single envelope. +func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvelope { + c := in.Components + + // Graceful degrade when components is nil — matches the legacy path's + // behaviour for missing/unvalidated peers (return a NetworkMap with only + // Network populated). The receiver gets an envelope it can decode + // without crashing; AccountSettings stays non-nil so client-side + // dereferences are safe. + if c.IsEmpty() { + // Match legacy missing-peer minimum: a NetworkMap with only Network + // populated. The receiver gets enough to bootstrap (Network + // identifier, dns_domain, account_settings) and the peer itself. + return &proto.NetworkMapEnvelope{ + Payload: &proto.NetworkMapEnvelope_Full{ + Full: &proto.NetworkMapComponentsFull{ + PeerConfig: in.PeerConfig, + // components.Peers always contains the target peer + Peers: []*proto.PeerCompact{toPeerCompact(c.Peers[c.PeerID])}, + DnsDomain: in.DNSDomain, + DnsForwarderPort: in.DNSForwarderPort, + UserIdClaim: in.UserIDClaim, + AccountSettings: &proto.AccountSettingsCompact{}, + ProxyPatch: in.ProxyPatch, + }, + }, + } + } + + // Phase 1: build dedup tables. Every routing peer (in c.RouterPeers) and + // every regular peer (in c.Peers) must be indexed before any encoder + // looks up indexes via e.peerOrder — otherwise routes / routers_map for + // peers that exist only in c.RouterPeers would silently lose their + // peer_index reference. + enc := newComponentEncoder(c) + enc.indexAllPeers() + routerIdxs := enc.indexRouterPeers(c.RouterPeers) + + // Phase 2: gather every policy that any consumer references (peer-pair + // policies + resource-only policies) so encodeResourcePoliciesMap can + // translate every *Policy pointer to a wire index. + allPolicies := unionPolicies(c.Policies, c.ResourcePoliciesMap) + policies := enc.encodePolicies(allPolicies) + + // Phase 3: emit. Order of struct field expressions no longer matters: + // every encoder either reads from the dedup tables or works on + // independent input. + full := &proto.NetworkMapComponentsFull{ + Serial: networkSerial(c.Network), + PeerConfig: in.PeerConfig, + Network: toAccountNetwork(c.Network), + AccountSettings: toAccountSettingsCompact(c.AccountSettings), + DnsForwarderPort: in.DNSForwarderPort, + UserIdClaim: in.UserIDClaim, + ProxyPatch: in.ProxyPatch, + DnsSettings: enc.encodeDNSSettings(c.DNSSettings), + DnsDomain: in.DNSDomain, + CustomZoneDomain: c.CustomZoneDomain, + AgentVersions: enc.agentVersions, + Peers: enc.peers, + RouterPeerIndexes: routerIdxs, + Policies: policies, + Groups: enc.encodeGroups(), + Routes: enc.encodeRoutes(c.Routes), + NameserverGroups: enc.encodeNameServerGroups(c.NameServerGroups), + AllDnsRecords: encodeSimpleRecords(c.AllDNSRecords), + AccountZones: encodeCustomZones(c.AccountZones), + NetworkResources: enc.encodeNetworkResources(c.NetworkResources), + RoutersMap: enc.encodeRoutersMap(c.RoutersMap), + ResourcePoliciesMap: enc.encodeResourcePoliciesMap(c.ResourcePoliciesMap), + GroupIdToUserIds: enc.encodeGroupIDToUserIDs(c.GroupIDToUserIDs), + AllowedUserIds: stringSetToSlice(c.AllowedUserIDs), + PostureFailedPeers: enc.encodePostureFailedPeers(c.PostureFailedPeers), + } + + return &proto.NetworkMapEnvelope{ + Payload: &proto.NetworkMapEnvelope_Full{Full: full}, + } +} + +// networkSerial returns c.Network.CurrentSerial() with a nil guard. The +// production path always populates c.Network, but the encoder is exported +// and a hand-built components struct may omit it. +func networkSerial(n *types.Network) uint64 { + if n == nil { + return 0 + } + return n.CurrentSerial() +} + +type componentEncoder struct { + components *types.NetworkMapComponents + + peerOrder map[string]uint32 + peers []*proto.PeerCompact + + agentVersionOrder map[string]uint32 + agentVersions []string +} + +func newComponentEncoder(c *types.NetworkMapComponents) *componentEncoder { + return &componentEncoder{ + components: c, + peerOrder: make(map[string]uint32, len(c.Peers)), + peers: make([]*proto.PeerCompact, 0, len(c.Peers)), + agentVersionOrder: make(map[string]uint32), + } +} + +func (e *componentEncoder) indexAllPeers() { + for _, p := range e.components.Peers { + if p == nil { + continue + } + e.appendPeer(p) + } +} + +func (e *componentEncoder) appendPeer(p *types.ComponentPeer) uint32 { + if idx, ok := e.peerOrder[p.ID]; ok { + return idx + } + idx := uint32(len(e.peers)) + e.peerOrder[p.ID] = idx + e.peers = append(e.peers, toPeerCompact(p)) + return idx +} + +// indexRouterPeers ensures every router peer is in the peer dedup table +// (c.RouterPeers may contain peers not in c.Peers when validation rules drop +// them) and returns their wire indexes for the RouterPeerIndexes field. Must +// run before any encoder that resolves peer ids via e.peerOrder. +func (e *componentEncoder) indexRouterPeers(routers map[string]*types.ComponentPeer) []uint32 { + if len(routers) == 0 { + return nil + } + out := make([]uint32, 0, len(routers)) + for _, p := range routers { + if p == nil { + continue + } + out = append(out, e.appendPeer(p)) + } + return out +} + +func (e *componentEncoder) encodeGroups() []*proto.GroupCompact { + if len(e.components.Groups) == 0 { + return nil + } + + out := make([]*proto.GroupCompact, 0, len(e.components.Groups)) + for _, g := range e.components.Groups { + peerIdxs := make([]uint32, 0, len(g.Peers)) + for _, peerID := range g.Peers { + if idx, ok := e.peerOrder[peerID]; ok { + peerIdxs = append(peerIdxs, idx) + } + } + out = append(out, &proto.GroupCompact{ + Id: g.PublicID, + PeerIndexes: peerIdxs, + IsAll: g.IsGroupAll(), + }) + } + return out +} + +// encodePolicies flattens Policy{Rules} → []PolicyCompact. Returns the wire +// list and a map from policy pointer to the indexes of its emitted rules in +// that list — used by encodeResourcePoliciesMap to translate +// ResourcePoliciesMap[resourceID][]*Policy into wire-side indexes. +func (e *componentEncoder) encodePolicies(policies []*types.Policy) []*proto.PolicyCompact { + if len(policies) == 0 { + return nil + } + + out := make([]*proto.PolicyCompact, 0, len(policies)) + + for _, pol := range policies { + if !pol.Enabled { + continue + } + for _, r := range pol.Rules { + if r == nil || !r.Enabled { + continue + } + out = append(out, e.encodePolicyRule(pol, r)) + } + } + return out +} + +// encodePolicyRule maps a single PolicyRule under pol to a PolicyCompact entry. +func (e *componentEncoder) encodePolicyRule(pol *types.Policy, r *types.PolicyRule) *proto.PolicyCompact { + return &proto.PolicyCompact{ + Id: pol.PublicID, + Action: networkmap.GetProtoAction(string(r.Action)), + Protocol: networkmap.GetProtoProtocol(string(r.Protocol)), + Bidirectional: r.Bidirectional, + Ports: portsToUint32(r.Ports), + PortRanges: portRangesToProto(r.PortRanges), + SourceGroupIds: e.groupPublicXids(r.Sources), + DestinationGroupIds: e.groupPublicXids(r.Destinations), + AuthorizedUser: r.AuthorizedUser, + AuthorizedGroups: e.encodeAuthorizedGroups(r.AuthorizedGroups), + SourceResource: e.resourceToProto(r.SourceResource), + DestinationResource: e.resourceToProto(r.DestinationResource), + SourcePostureCheckIds: e.postureCheckSeqs(pol.SourcePostureChecks), + } +} + +// groupPublicXids maps the xid group IDs in src to their public xids, +// dropping any group with invalid public xid. +func (e *componentEncoder) groupPublicXids(src []string) []string { + if len(src) == 0 { + return nil + } + out := make([]string, 0, len(src)) + for _, gid := range src { + if id, ok := e.groupPublicXid(gid); ok { + out = append(out, id) + } + } + return out +} + +// unionPolicies merges c.Policies with every policy referenced by +// c.ResourcePoliciesMap, deduplicating by pointer identity. Resource-only +// policies (relevant to a NetworkResource but not to peer-pair traffic) +// only live in ResourcePoliciesMap; without this union step they'd be lost +// from the wire and the client's resource-policy lookup would come back +// empty. +func unionPolicies(policies []*types.Policy, resourcePolicies map[string][]*types.Policy) []*types.Policy { + // Fast path: non-router peers have no resource-only policies, so the + // "union" is identical to `policies`. Skip the dedup map allocation. + if len(resourcePolicies) == 0 { + return policies + } + seen := make(map[string]struct{}, len(policies)) + out := make([]*types.Policy, 0, len(policies)) + for _, p := range policies { + if p == nil { + continue + } + if _, ok := seen[p.ID]; ok { + continue + } + seen[p.ID] = struct{}{} + out = append(out, p) + } + for _, list := range resourcePolicies { + for _, p := range list { + if p == nil { + continue + } + if _, ok := seen[p.ID]; ok { + continue + } + seen[p.ID] = struct{}{} + out = append(out, p) + } + } + return out +} + +// encodeAuthorizedGroups translates rule.AuthorizedGroups (map keyed by +// group xid → local-user names) to the wire form (map keyed by group +// account_seq_id → UserNameList). Groups without a seq id are dropped — +// matches how source/destination group references handle the same case. +func (e *componentEncoder) encodeAuthorizedGroups(m map[string][]string) map[string]*proto.UserNameList { + if len(m) == 0 { + return nil + } + out := make(map[string]*proto.UserNameList, len(m)) + for groupID, names := range m { + id, ok := e.groupPublicXid(groupID) + if !ok { + continue + } + out[id] = &proto.UserNameList{Names: names} + } + return out +} + +func (e *componentEncoder) groupPublicXid(groupID string) (string, bool) { + g, ok := e.components.Groups[groupID] + if !ok { + return "", false + } + return g.PublicID, true +} + +// resourceToProto translates types.Resource for the wire. For peer-typed +// resources the peer id is converted to a peer index into the envelope's +// peers array. For other resource types only the type string is shipped +// today (Calculate's resource-typed rule path consults SourceResource only +// for "peer" — other types fall through to group-based lookup). +func (e *componentEncoder) resourceToProto(r types.Resource) *proto.ResourceCompact { + if r.ID == "" && r.Type == "" { + return nil + } + out := &proto.ResourceCompact{Type: string(r.Type)} + if r.Type == types.ResourceTypePeer && r.ID != "" { + if idx, ok := e.peerOrder[r.ID]; ok { + out.PeerIndexSet = true + out.PeerIndex = idx + } + } + return out +} + +// postureCheckSeqs translates a slice of posture-check xids to their +// public xids. Unresolvable xids are silently dropped — matches how group/peer +// references handle the same case. +func (e *componentEncoder) postureCheckSeqs(xids []string) []string { + if len(xids) == 0 || len(e.components.PostureCheckXIDToPublicID) == 0 { + return nil + } + out := make([]string, 0, len(xids)) + for _, xid := range xids { + if seq, ok := e.components.PostureCheckXIDToPublicID[xid]; ok { + out = append(out, seq) + } + } + return out +} + +// networkSeq translates a Network xid to its public id using +// the NetworkMapComponents.NetworkXIDToPublicID lookup. Returns (0,false) when +// the xid isn't known — callers decide whether to skip the parent record. +func (e *componentEncoder) networkPublicId(xid string) (string, bool) { + if xid == "" { + return "", false + } + id, ok := e.components.NetworkXIDToPublicID[xid] + if !ok { + return "", false + } + return id, true +} + +func (e *componentEncoder) encodeDNSSettings(s *types.DNSSettings) *proto.DNSSettingsCompact { + if s == nil || len(s.DisabledManagementGroups) == 0 { + return nil + } + out := &proto.DNSSettingsCompact{ + DisabledManagementGroupIds: make([]string, 0, len(s.DisabledManagementGroups)), + } + for _, gid := range s.DisabledManagementGroups { + if id, ok := e.groupPublicXid(gid); ok { + out.DisabledManagementGroupIds = append(out.DisabledManagementGroupIds, id) + } + } + return out +} + +func (e *componentEncoder) encodeRoutes(routes []*nbroute.Route) []*proto.RouteRaw { + if len(routes) == 0 { + return nil + } + out := make([]*proto.RouteRaw, 0, len(routes)) + for _, r := range routes { + if r == nil { + continue + } + rr := &proto.RouteRaw{ + Id: r.PublicID, + NetId: string(r.NetID), + Description: r.Description, + KeepRoute: r.KeepRoute, + NetworkType: int32(r.NetworkType), + Masquerade: r.Masquerade, + Metric: int32(r.Metric), + Enabled: r.Enabled, + SkipAutoApply: r.SkipAutoApply, + Domains: r.Domains.ToPunycodeList(), + GroupIds: e.groupPublicXids(r.Groups), + AccessControlGroupIds: e.groupPublicXids(r.AccessControlGroups), + PeerGroupIds: e.groupPublicXids(r.PeerGroups), + } + if r.Network.IsValid() { + rr.NetworkCidr = r.Network.String() + } + if r.Peer != "" { + if idx, ok := e.peerOrder[r.Peer]; ok { + rr.PeerIndexSet = true + rr.PeerIndex = idx + } + } + out = append(out, rr) + } + return out +} + +func (e *componentEncoder) encodeNameServerGroups(nsgs []*nbdns.NameServerGroup) []*proto.NameServerGroupRaw { + if len(nsgs) == 0 { + return nil + } + out := make([]*proto.NameServerGroupRaw, 0, len(nsgs)) + for _, nsg := range nsgs { + if nsg == nil { + continue + } + entry := &proto.NameServerGroupRaw{ + Id: nsg.PublicID, + Nameservers: encodeNameServers(nsg.NameServers), + GroupIds: e.groupPublicXids(nsg.Groups), + Primary: nsg.Primary, + Domains: nsg.Domains, + Enabled: nsg.Enabled, + SearchDomainsEnabled: nsg.SearchDomainsEnabled, + } + out = append(out, entry) + } + return out +} + +func encodeNameServers(servers []nbdns.NameServer) []*proto.NameServer { + if len(servers) == 0 { + return nil + } + out := make([]*proto.NameServer, 0, len(servers)) + for _, s := range servers { + out = append(out, &proto.NameServer{ + IP: s.IP.String(), + NSType: int64(s.NSType), + Port: int64(s.Port), + }) + } + return out +} + +func encodeSimpleRecords(records []nbdns.SimpleRecord) []*proto.SimpleRecord { + if len(records) == 0 { + return nil + } + out := make([]*proto.SimpleRecord, 0, len(records)) + for _, r := range records { + out = append(out, &proto.SimpleRecord{ + Name: r.Name, + Type: int64(r.Type), + Class: r.Class, + TTL: int64(r.TTL), + RData: r.RData, + }) + } + return out +} + +func encodeCustomZones(zones []nbdns.CustomZone) []*proto.CustomZone { + if len(zones) == 0 { + return nil + } + out := make([]*proto.CustomZone, 0, len(zones)) + for _, z := range zones { + out = append(out, &proto.CustomZone{ + Domain: z.Domain, + Records: encodeSimpleRecords(z.Records), + SearchDomainDisabled: z.SearchDomainDisabled, + NonAuthoritative: z.NonAuthoritative, + }) + } + return out +} + +func (e *componentEncoder) encodeNetworkResources(resources []*types.ComponentResource) []*proto.NetworkResourceRaw { + if len(resources) == 0 { + return nil + } + out := make([]*proto.NetworkResourceRaw, 0, len(resources)) + for _, r := range resources { + if r == nil { + continue + } + entry := &proto.NetworkResourceRaw{ + Id: r.PublicID, + Name: r.Name, + Description: r.Description, + Type: string(r.Type), + Address: r.Address, + DomainValue: r.Domain, + Enabled: r.Enabled, + } + if id, ok := e.networkPublicId(r.NetworkID); ok { + entry.NetworkSeq = id + } + if r.Prefix.IsValid() { + entry.PrefixCidr = r.Prefix.String() + } + out = append(out, entry) + } + return out +} + +func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*types.ComponentRouter) map[string]*proto.NetworkRouterList { + if len(routersMap) == 0 { + return nil + } + out := make(map[string]*proto.NetworkRouterList, len(routersMap)) + for networkXID, routers := range routersMap { + if len(routers) == 0 { + continue + } + id, ok := e.networkPublicId(networkXID) + if !ok { + continue + } + entries := make([]*proto.NetworkRouterEntry, 0, len(routers)) + for peerID, r := range routers { + if r == nil { + continue + } + entry := &proto.NetworkRouterEntry{ + Id: r.PublicID, + PeerGroupIds: e.groupPublicXids(r.PeerGroups), + Masquerade: r.Masquerade, + Metric: int32(r.Metric), + Enabled: r.Enabled, + } + if idx, ok := e.peerOrder[peerID]; ok { + entry.PeerIndexSet = true + entry.PeerIndex = idx + } + entries = append(entries, entry) + } + out[id] = &proto.NetworkRouterList{Entries: entries} + } + return out +} + +func (e *componentEncoder) encodeResourcePoliciesMap(rpm map[string][]*types.Policy) map[string]*proto.PolicyIds { + if len(rpm) == 0 { + return nil + } + // resourceXIDToPublicID is local to one encode — built from components.NetworkResources + // (small slice). Network resources without seq id are dropped, matching how + // other components-without-seq are silently filtered. + resourceXIDToPublicID := make(map[string]string, len(e.components.NetworkResources)) + for _, r := range e.components.NetworkResources { + if r != nil { + resourceXIDToPublicID[r.ID] = r.PublicID + } + } + out := make(map[string]*proto.PolicyIds, len(rpm)) + for resourceXID, policies := range rpm { + resId, ok := resourceXIDToPublicID[resourceXID] + if !ok { + continue + } + ids := make([]string, 0, len(policies)) + for _, pol := range policies { + ids = append(ids, pol.PublicID) + } + if len(ids) == 0 { + continue + } + out[resId] = &proto.PolicyIds{Ids: ids} + } + return out +} + +func (e *componentEncoder) encodeGroupIDToUserIDs(m map[string][]string) map[string]*proto.UserIDList { + if len(m) == 0 { + return nil + } + out := make(map[string]*proto.UserIDList, len(m)) + for groupID, userIDs := range m { + id, ok := e.groupPublicXid(groupID) + if !ok || len(userIDs) == 0 { + continue + } + out[id] = &proto.UserIDList{UserIds: userIDs} + } + return out +} + +func stringSetToSlice(s map[string]struct{}) []string { + if len(s) == 0 { + return nil + } + out := make([]string, 0, len(s)) + for k := range s { + out = append(out, k) + } + return out +} + +func (e *componentEncoder) encodePostureFailedPeers(m map[string]map[string]struct{}) map[string]*proto.PeerIndexSet { + if len(m) == 0 { + return nil + } + out := make(map[string]*proto.PeerIndexSet, len(m)) + for checkXID, failedPeerIDs := range m { + id, ok := e.components.PostureCheckXIDToPublicID[checkXID] + if !ok { + continue + } + idxs := make([]uint32, 0, len(failedPeerIDs)) + for peerID := range failedPeerIDs { + if idx, ok := e.peerOrder[peerID]; ok { + idxs = append(idxs, idx) + } + } + if len(idxs) == 0 { + continue + } + out[id] = &proto.PeerIndexSet{PeerIndexes: idxs} + } + return out +} + +// toAccountSettingsCompact always returns a non-nil message — the client +// dereferences it unconditionally during Calculate(), so a nil here would +// crash the receiver. A missing types.AccountSettingsInfo on the server +// (which shouldn't happen in production but the encoder is exported) +// degrades to login_expiration_enabled = false, which makes +// LoginExpired() return false for every peer. +func toAccountSettingsCompact(s *types.AccountSettingsInfo) *proto.AccountSettingsCompact { + if s == nil { + return &proto.AccountSettingsCompact{} + } + return &proto.AccountSettingsCompact{ + PeerLoginExpirationEnabled: s.PeerLoginExpirationEnabled, + PeerLoginExpirationNs: int64(s.PeerLoginExpiration), + } +} + +func toAccountNetwork(n *types.Network) *proto.AccountNetwork { + if n == nil { + return nil + } + out := &proto.AccountNetwork{ + Identifier: n.Identifier, + NetCidr: n.Net.String(), + Dns: n.Dns, + Serial: n.CurrentSerial(), + } + if len(n.NetV6.IP) > 0 { + out.NetV6Cidr = n.NetV6.String() + } + return out +} + +func toPeerCompact(p *types.ComponentPeer) *proto.PeerCompact { + pc := &proto.PeerCompact{ + WgPubKey: decodeWgKey(p.Key), + SshPubKey: []byte(p.SSHKey), + DnsLabel: p.DNSLabel, + AgentVersion: p.AgentVersion, + AddedWithSsoLogin: p.AddedWithSSOLogin, + LoginExpirationEnabled: p.LoginExpirationEnabled, + SshEnabled: p.SSHEnabled, + SupportsIpv6: p.SupportsIPv6, + SupportsSourcePrefixes: p.SupportsSourcePrefixes, + ServerSshAllowed: p.ServerSSHAllowed, + } + if !p.LastLogin.IsZero() { + pc.LastLoginUnixNano = p.LastLogin.UnixNano() + } + switch { + case !p.IP.IsValid(): + // leave Ip nil + case p.IP.Is4() || p.IP.Is4In6(): + ip := p.IP.Unmap().As4() + pc.Ip = ip[:] + default: + ip := p.IP.As16() + pc.Ip = ip[:] + } + if p.IPv6.IsValid() { + ip := p.IPv6.As16() + pc.Ipv6 = ip[:] + } + return pc +} + +// decodeWgKey returns the raw 32 bytes of a base64-encoded WireGuard public +// key, or nil for an empty / malformed key. +func decodeWgKey(s string) []byte { + if s == "" { + return nil + } + out := make([]byte, wgKeyRawLen) + n, err := base64.StdEncoding.Decode(out, []byte(s)) + if err != nil || n != wgKeyRawLen { + return nil + } + return out +} + +func portsToUint32(ports []string) []uint32 { + if len(ports) == 0 { + return nil + } + out := make([]uint32, 0, len(ports)) + for _, p := range ports { + v, err := strconv.ParseUint(p, 10, 16) + if err != nil { + continue + } + out = append(out, uint32(v)) + } + return out +} + +func portRangesToProto(ranges []types.RulePortRange) []*proto.PortInfo_Range { + if len(ranges) == 0 { + return nil + } + out := make([]*proto.PortInfo_Range, 0, len(ranges)) + for _, r := range ranges { + out = append(out, &proto.PortInfo_Range{ + Start: uint32(r.Start), + End: uint32(r.End), + }) + } + return out +} diff --git a/management/internals/shared/grpc/components_encoder_test.go b/management/internals/shared/grpc/components_encoder_test.go new file mode 100644 index 000000000..100ab0948 --- /dev/null +++ b/management/internals/shared/grpc/components_encoder_test.go @@ -0,0 +1,781 @@ +package grpc + +import ( + "bytes" + "cmp" + "net" + "net/netip" + "slices" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + goproto "google.golang.org/protobuf/proto" + + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/server/types" + nbroute "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/proto" +) + +const testWgKeyA = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq=" +const testWgKeyB = "BBCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq=" +const testWgKeyC = "CBCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq=" + +// canonicalize rewrites a NetworkMapComponentsFull in place into a canonical +// form: peers reordered by wg_pub_key, with the rest of the message rewritten +// to reference the new peer indexes. Groups, policies, and router indexes are +// also sorted. After canonicalize, two envelopes built from the same logical +// input compare byte-equal via proto.Equal. +// +// This lives on the test side — the encoder itself emits in map-iteration +// order. Test-side normalization is the contract for "two encodes are +// equivalent". +func canonicalize(full *proto.NetworkMapComponentsFull) { + if full == nil { + return + } + + type peerEntry struct { + peer *proto.PeerCompact + oldIdx uint32 + } + entries := make([]peerEntry, len(full.Peers)) + for i, p := range full.Peers { + entries[i] = peerEntry{peer: p, oldIdx: uint32(i)} + } + // DnsLabel is unique per peer; it tiebreaks on equal WgPubKey (e.g. both + // nil from malformed keys, or both empty for placeholders). + slices.SortFunc(entries, func(a, b peerEntry) int { + if c := bytes.Compare(a.peer.WgPubKey, b.peer.WgPubKey); c != 0 { + return c + } + return cmp.Compare(a.peer.DnsLabel, b.peer.DnsLabel) + }) + + remap := make(map[uint32]uint32, len(entries)) + newPeers := make([]*proto.PeerCompact, len(entries)) + for newIdx, e := range entries { + remap[e.oldIdx] = uint32(newIdx) + newPeers[newIdx] = e.peer + } + full.Peers = newPeers + + full.RouterPeerIndexes = remapAndSort(full.RouterPeerIndexes, remap) + for _, g := range full.Groups { + g.PeerIndexes = remapAndSort(g.PeerIndexes, remap) + } + slices.SortFunc(full.Groups, func(a, b *proto.GroupCompact) int { return cmp.Compare(a.Id, b.Id) }) + + for _, r := range full.Routes { + if r.PeerIndexSet { + if newIdx, ok := remap[r.PeerIndex]; ok { + r.PeerIndex = newIdx + } + } + slices.Sort(r.GroupIds) + slices.Sort(r.AccessControlGroupIds) + slices.Sort(r.PeerGroupIds) + } + slices.SortFunc(full.Routes, func(a, b *proto.RouteRaw) int { return cmp.Compare(a.Id, b.Id) }) + + for _, list := range full.RoutersMap { + for _, entry := range list.Entries { + if entry.PeerIndexSet { + if newIdx, ok := remap[entry.PeerIndex]; ok { + entry.PeerIndex = newIdx + } + } + slices.Sort(entry.PeerGroupIds) + } + slices.SortFunc(list.Entries, func(a, b *proto.NetworkRouterEntry) int { return cmp.Compare(a.Id, b.Id) }) + } + + for _, set := range full.PostureFailedPeers { + set.PeerIndexes = remapAndSort(set.PeerIndexes, remap) + } + + for _, p := range full.Policies { + slices.Sort(p.SourceGroupIds) + slices.Sort(p.DestinationGroupIds) + } + // Sort policies by (Id, source_group_ids, destination_group_ids) so that + // multiple PolicyCompact entries sharing the same Id (one per rule, when + // a Policy has multiple rules) still get a deterministic order. After + // sorting we remap indexes in ResourcePoliciesMap. + policyOldOrder := make(map[*proto.PolicyCompact]uint32, len(full.Policies)) + for i, p := range full.Policies { + policyOldOrder[p] = uint32(i) + } + slices.SortFunc(full.Policies, func(a, b *proto.PolicyCompact) int { + if c := cmp.Compare(a.Id, b.Id); c != 0 { + return c + } + if c := slices.Compare(a.SourceGroupIds, b.SourceGroupIds); c != 0 { + return c + } + return slices.Compare(a.DestinationGroupIds, b.DestinationGroupIds) + }) + policyRemap := make(map[uint32]uint32, len(full.Policies)) + for newIdx, p := range full.Policies { + policyRemap[policyOldOrder[p]] = uint32(newIdx) + } + for _, idxs := range full.ResourcePoliciesMap { + slices.Sort(idxs.Ids) + } + for _, list := range full.GroupIdToUserIds { + slices.Sort(list.UserIds) + } + slices.Sort(full.AllowedUserIds) +} + +func remapAndSort(idxs []uint32, remap map[uint32]uint32) []uint32 { + out := make([]uint32, 0, len(idxs)) + for _, i := range idxs { + if newIdx, ok := remap[i]; ok { + out = append(out, newIdx) + } + } + slices.Sort(out) + return out +} + +// envelopesEquivalent decodes both envelopes, canonicalizes them, and reports +// whether they're proto.Equal. Use instead of byte-comparing marshaled output: +// the encoder is intentionally non-deterministic. +func envelopesEquivalent(a, b *proto.NetworkMapEnvelope) bool { + canonicalize(a.GetFull()) + canonicalize(b.GetFull()) + return goproto.Equal(a, b) +} + +func newTestComponents() *types.NetworkMapComponents { + peerA := &types.ComponentPeer{ + ID: "peer-a", + Key: testWgKeyA, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + DNSLabel: "peera", + SSHKey: "ssh-a", + AgentVersion: "0.40.0", + } + peerB := &types.ComponentPeer{ + ID: "peer-b", + Key: testWgKeyB, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), + IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}), + DNSLabel: "peerb", + AgentVersion: "0.25.0", + } + peerC := &types.ComponentPeer{ + ID: "peer-c", + Key: testWgKeyC, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), + DNSLabel: "peerc", + AgentVersion: "0.40.0", + } + + return &types.NetworkMapComponents{ + PeerID: "peer-a", + Network: &types.Network{ + Identifier: "net-test", + Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}, + Serial: 7, + }, + AccountSettings: &types.AccountSettingsInfo{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: 2 * time.Hour, + }, + Peers: map[string]*types.ComponentPeer{ + "peer-a": peerA, + "peer-b": peerB, + "peer-c": peerC, + }, + Groups: map[string]*types.ComponentGroup{ + "group-src": {ID: "group-src", PublicID: "1", Name: "Src", Peers: []string{"peer-a"}}, + "group-dst": {ID: "group-dst", PublicID: "2", Name: "Dst", Peers: []string{"peer-b", "peer-c"}}, + }, + Policies: []*types.Policy{ + { + ID: "pol-1", + PublicID: "10", + Enabled: true, + Rules: []*types.PolicyRule{{ + ID: "rule-1", Enabled: true, Action: types.PolicyTrafficActionAccept, + Protocol: types.PolicyRuleProtocolTCP, Bidirectional: true, + Ports: []string{"22", "80"}, + PortRanges: []types.RulePortRange{{Start: 8000, End: 8100}}, + Sources: []string{"group-src"}, + Destinations: []string{"group-dst"}, + }}, + }, + }, + RouterPeers: map[string]*types.ComponentPeer{"peer-c": peerC}, + } +} + +func TestEncodeNetworkMapEnvelope_Basic(t *testing.T) { + c := newTestComponents() + env := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{ + Components: c, + DNSDomain: "netbird.cloud", + }) + + require.NotNil(t, env) + full := env.GetFull() + require.NotNil(t, full, "envelope must contain Full payload") + + assert.EqualValues(t, 7, full.Serial) + assert.Equal(t, "netbird.cloud", full.DnsDomain) + + require.NotNil(t, full.Network) + assert.Equal(t, "net-test", full.Network.Identifier) + assert.Equal(t, "100.64.0.0/10", full.Network.NetCidr) + + require.NotNil(t, full.AccountSettings) + assert.True(t, full.AccountSettings.PeerLoginExpirationEnabled) + assert.EqualValues(t, (2 * time.Hour).Nanoseconds(), full.AccountSettings.PeerLoginExpirationNs) + + require.Len(t, full.Peers, 3) + byLabel := map[string]*proto.PeerCompact{} + for _, p := range full.Peers { + assert.Len(t, p.WgPubKey, 32, "wg key must be raw 32 bytes") + assert.Len(t, p.Ip, 4, "ipv4 must be raw 4 bytes") + byLabel[p.DnsLabel] = p + } + assert.Len(t, byLabel["peerb"].Ipv6, 16, "peer-b has ipv6 → 16 bytes") +} + +func TestEncodeNetworkMapEnvelope_RepeatEncodesEquivalent(t *testing.T) { + c := newTestComponents() + + expected := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}) + + // Hammer it 100 times — Go map iteration is randomized per call, so each + // run produces different wire bytes, but the canonicalized form must + // match. + for i := 0; i < 100; i++ { + got := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}) + require.True(t, envelopesEquivalent(expected, got), + "encode #%d must be semantically equivalent to first encode", i) + } +} + +func TestEncodeNetworkMapEnvelope_ConcurrentEncodesEquivalent(t *testing.T) { + c := newTestComponents() + + expected := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}) + + const goroutines = 50 + var wg sync.WaitGroup + wg.Add(goroutines) + results := make([]*proto.NetworkMapEnvelope, goroutines) + for i := 0; i < goroutines; i++ { + i := i + go func() { + defer wg.Done() + results[i] = EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}) + }() + } + wg.Wait() + + for i, got := range results { + require.NotNil(t, got, "goroutine %d returned nil", i) + require.True(t, envelopesEquivalent(expected, got), + "goroutine %d produced inequivalent envelope", i) + } +} + +func TestEncodeNetworkMapEnvelope_GroupsByAccountPublicId(t *testing.T) { + c := newTestComponents() + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.Groups, 2) + + groupByID := map[string]*proto.GroupCompact{} + for _, g := range full.Groups { + groupByID[g.Id] = g + } + require.Contains(t, groupByID, "1") + require.Contains(t, groupByID, "2") + assert.Len(t, groupByID["1"].PeerIndexes, 1) + assert.Len(t, groupByID["2"].PeerIndexes, 2) +} + +func TestEncodeNetworkMapEnvelope_PolicyExpansion(t *testing.T) { + c := newTestComponents() + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.Policies, 1) + pc := full.Policies[0] + assert.EqualValues(t, "10", pc.Id) + assert.Equal(t, proto.RuleAction_ACCEPT, pc.Action) + assert.Equal(t, proto.RuleProtocol_TCP, pc.Protocol) + assert.True(t, pc.Bidirectional) + assert.Equal(t, []uint32{22, 80}, pc.Ports) + require.Len(t, pc.PortRanges, 1) + assert.EqualValues(t, 8000, pc.PortRanges[0].Start) + assert.EqualValues(t, 8100, pc.PortRanges[0].End) + assert.Equal(t, []string{"1"}, pc.SourceGroupIds) + assert.Equal(t, []string{"2"}, pc.DestinationGroupIds) +} + +func TestEncodeNetworkMapEnvelope_RouterIndexes(t *testing.T) { + c := newTestComponents() + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.RouterPeerIndexes, 1) + idx := full.RouterPeerIndexes[0] + require.Less(t, int(idx), len(full.Peers)) + assert.Equal(t, "peerc", full.Peers[idx].DnsLabel) +} + +func TestEncodeNetworkMapEnvelope_DisabledPolicySkipped(t *testing.T) { + c := newTestComponents() + c.Policies[0].Enabled = false + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + assert.Empty(t, full.Policies) +} + +func TestEncodeNetworkMapEnvelope_TwoPeersSameMalformedKey(t *testing.T) { + // Both peers have nil WgPubKey after decode; canonicalize must still + // produce a stable order using DnsLabel as a tiebreaker, so 100 encodes + // canonicalize identically. + c := newTestComponents() + c.Peers["peer-a"].Key = "garbage-a-!!!" + c.Peers["peer-b"].Key = "garbage-b-!!!" + + expected := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}) + for i := 0; i < 100; i++ { + got := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}) + require.True(t, envelopesEquivalent(expected, got), + "encode #%d with two same-key peers must canonicalize equivalently", i) + } +} + +func TestEncodeNetworkMapEnvelope_MalformedWgKey(t *testing.T) { + c := newTestComponents() + c.Peers["peer-a"].Key = "not-base64-!!!" + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.Peers, 3) + + var byLabel = map[string]*proto.PeerCompact{} + for _, p := range full.Peers { + byLabel[p.DnsLabel] = p + } + assert.Nil(t, byLabel["peera"].WgPubKey, "peer with malformed key encodes nil WgPubKey") + assert.Len(t, byLabel["peerb"].WgPubKey, 32, "other peers retain their key") +} + +func TestEncodeNetworkMapEnvelope_IPv6OnlyPeer(t *testing.T) { + c := newTestComponents() + v6Only := &types.ComponentPeer{ + ID: "peer-v6", + Key: testWgKeyA, + IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9}), + DNSLabel: "peerv6", + AgentVersion: "0.40.0", + } + c.Peers["peer-v6"] = v6Only + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + var found *proto.PeerCompact + for _, p := range full.Peers { + if p.DnsLabel == "peerv6" { + found = p + } + } + require.NotNil(t, found, "ipv6-only peer must be present") + assert.Empty(t, found.Ip, "no IPv4 address → empty Ip") + assert.Len(t, found.Ipv6, 16) +} + +func TestEncodeNetworkMapEnvelope_PeerWithoutIP(t *testing.T) { + c := newTestComponents() + c.Peers["peer-noip"] = &types.ComponentPeer{ + ID: "peer-noip", + Key: testWgKeyA, + DNSLabel: "peernoip", + AgentVersion: "0.40.0", + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + var found *proto.PeerCompact + for _, p := range full.Peers { + if p.DnsLabel == "peernoip" { + found = p + } + } + require.NotNil(t, found) + assert.Empty(t, found.Ip) + assert.Empty(t, found.Ipv6) +} + +func TestEncodeNetworkMapEnvelope_EmptyInput(t *testing.T) { + c := &types.NetworkMapComponents{ + Network: &types.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}}, + } + + env := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}) + + full := env.GetFull() + require.NotNil(t, full) + assert.Empty(t, full.Peers) + assert.Empty(t, full.Groups) + assert.Empty(t, full.Policies) + assert.Empty(t, full.RouterPeerIndexes) + require.NotNil(t, full.AccountSettings, "AccountSettingsCompact must always be emitted (client dereferences it unconditionally)") +} + +func TestEncodeNetworkMapEnvelope_PeerLoginExpirationFields(t *testing.T) { + c := newTestComponents() + now := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + c.Peers["peer-a"].AddedWithSSOLogin = true + c.Peers["peer-a"].LoginExpirationEnabled = true + c.Peers["peer-a"].LastLogin = now + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + var pa *proto.PeerCompact + for _, p := range full.Peers { + if p.DnsLabel == "peera" { + pa = p + } + } + require.NotNil(t, pa) + assert.True(t, pa.AddedWithSsoLogin) + assert.True(t, pa.LoginExpirationEnabled) + assert.Equal(t, now.UnixNano(), pa.LastLoginUnixNano) + + // peer-b has no UserID and no LastLogin → all fields zero-value. + var pb *proto.PeerCompact + for _, p := range full.Peers { + if p.DnsLabel == "peerb" { + pb = p + } + } + require.NotNil(t, pb) + assert.False(t, pb.AddedWithSsoLogin) + assert.False(t, pb.LoginExpirationEnabled) + assert.Zero(t, pb.LastLoginUnixNano) +} + +func TestEncodeNetworkMapEnvelope_RoutesRoundTrip(t *testing.T) { + c := newTestComponents() + c.Routes = []*nbroute.Route{ + { + ID: "route-peer", + PublicID: "100", + NetID: "net-A", + Description: "via peer-c", + Network: netip.MustParsePrefix("10.0.0.0/16"), + Peer: "peer-c", // peer ID, not WG key + Groups: []string{"group-src"}, + AccessControlGroups: []string{"group-dst"}, + Enabled: true, + }, + { + ID: "route-peergroup", + PublicID: "101", + NetID: "net-B", + Network: netip.MustParsePrefix("10.1.0.0/16"), + PeerGroups: []string{"group-src", "group-dst"}, + Enabled: true, + }, + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.Routes, 2) + byNetID := map[string]*proto.RouteRaw{} + for _, r := range full.Routes { + byNetID[r.NetId] = r + } + + r1 := byNetID["net-A"] + require.NotNil(t, r1) + assert.True(t, r1.PeerIndexSet, "route with peer must set peer_index_set") + require.Less(t, int(r1.PeerIndex), len(full.Peers)) + assert.Equal(t, "peerc", full.Peers[r1.PeerIndex].DnsLabel) + assert.Equal(t, []string{"1"}, r1.GroupIds, "group-src has AccountSeqID 1") + assert.Equal(t, []string{"2"}, r1.AccessControlGroupIds, "group-dst has AccountSeqID 2") + assert.Empty(t, r1.PeerGroupIds) + + r2 := byNetID["net-B"] + require.NotNil(t, r2) + assert.False(t, r2.PeerIndexSet, "route with peer_groups must NOT set peer_index_set") + assert.ElementsMatch(t, []string{"1", "2"}, r2.PeerGroupIds) +} + +func TestEncodeNetworkMapEnvelope_RouteWithMissingPeerLeavesIndexUnset(t *testing.T) { + c := newTestComponents() + c.Routes = []*nbroute.Route{{ + ID: "route-x", + PublicID: "100", + Peer: "peer-not-in-components", + Network: netip.MustParsePrefix("10.0.0.0/16"), + Enabled: true, + }} + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.Routes, 1) + assert.False(t, full.Routes[0].PeerIndexSet, + "missing peer reference must not pretend to point at peer index 0") +} + +func TestEncodeNetworkMapEnvelope_ResourceOnlyPolicyShippedAndIndexed(t *testing.T) { + c := newTestComponents() + // Policy that exists ONLY in ResourcePoliciesMap, not in c.Policies. This + // is the I1 case — without unionPolicies the encoder would silently + // drop it from the wire. + resourceOnlyPolicy := &types.Policy{ + ID: "pol-resource", PublicID: "99", Enabled: true, + Rules: []*types.PolicyRule{{ + ID: "rule-r", Enabled: true, Action: types.PolicyTrafficActionAccept, + Protocol: types.PolicyRuleProtocolTCP, + Sources: []string{"group-src"}, + Destinations: []string{"group-dst"}, + }}, + } + c.ResourcePoliciesMap = map[string][]*types.Policy{ + "resource-x": {c.Policies[0], resourceOnlyPolicy}, // shared + resource-only + } + // Resource must appear in components.NetworkResources with a seq id — + // encoder uses that to translate the xid map key to uint32. + c.NetworkResources = []*types.ComponentResource{ + {ID: "resource-x", PublicID: "77", Name: "res-x", Enabled: true}, + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.Policies, 2, "encoded policies must include both peer-traffic and resource-only") + + policyByID := map[string]*proto.PolicyCompact{} + policyIds := make([]string, 0) + for _, p := range full.Policies { + policyByID[p.Id] = p + policyIds = append(policyIds, p.Id) + } + require.Contains(t, policyByID, "10", "original peer-traffic policy id 10") + require.Contains(t, policyByID, "99", "resource-only policy id 99") + + require.Contains(t, full.ResourcePoliciesMap, "77") + ids := full.ResourcePoliciesMap["77"].Ids + require.Len(t, ids, 2) + assert.ElementsMatch(t, policyIds, ids, + "resource policies map must reference both wire policy indexes") +} + +func TestEncodeNetworkMapEnvelope_NameServerGroups(t *testing.T) { + c := newTestComponents() + c.NameServerGroups = []*nbdns.NameServerGroup{{ + ID: "nsg-1", PublicID: "50", Name: "Main", Description: "primary", + NameServers: []nbdns.NameServer{{ + IP: netip.MustParseAddr("8.8.8.8"), NSType: nbdns.UDPNameServerType, Port: 53, + }}, + Groups: []string{"group-src", "group-not-persisted"}, + Primary: true, Enabled: true, + Domains: []string{"corp.example"}, + }} + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.NameserverGroups, 1) + nsg := full.NameserverGroups[0] + assert.EqualValues(t, "50", nsg.Id) + assert.True(t, nsg.Primary) + require.Len(t, nsg.Nameservers, 1) + assert.Equal(t, "8.8.8.8", nsg.Nameservers[0].IP) + assert.Equal(t, []string{"1"}, nsg.GroupIds) +} + +func TestEncodeNetworkMapEnvelope_PostureFailedPeers(t *testing.T) { + c := newTestComponents() + c.PostureCheckXIDToPublicID = map[string]string{"check-1": "33"} + c.PostureFailedPeers = map[string]map[string]struct{}{ + "check-1": { + "peer-a": {}, + "peer-b": {}, + "peer-not-in-account": {}, + }, + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Contains(t, full.PostureFailedPeers, "33") + idxs := full.PostureFailedPeers["33"].PeerIndexes + assert.Len(t, idxs, 2, "missing peer is silently dropped (filterPostureFailedPeers guarantees presence in real data)") +} + +func TestEncodeNetworkMapEnvelope_RoutersMap(t *testing.T) { + c := newTestComponents() + c.NetworkXIDToPublicID = map[string]string{"net-1": "5"} + c.RoutersMap = map[string]map[string]*types.ComponentRouter{ + "net-1": { + "peer-c": { + PublicID: "200", + Peer: "peer-c", Masquerade: true, Metric: 10, Enabled: true, + }, + }, + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Contains(t, full.RoutersMap, "5") + entries := full.RoutersMap["5"].Entries + require.Len(t, entries, 1) + e := entries[0] + assert.EqualValues(t, "200", e.Id) + assert.True(t, e.PeerIndexSet) + require.Less(t, int(e.PeerIndex), len(full.Peers)) + assert.Equal(t, "peerc", full.Peers[e.PeerIndex].DnsLabel) + assert.True(t, e.Masquerade) + assert.EqualValues(t, 10, e.Metric) + assert.True(t, e.Enabled) +} + +func TestEncodeNetworkMapEnvelope_RouterPeerNotInComponentsPeers(t *testing.T) { + // Router peer in c.RouterPeers but NOT in c.Peers (validation may have + // filtered it). indexRouterPeers runs before encodeRoutersMap, so the + // peer_index reference must still resolve. + c := newTestComponents() + delete(c.Peers, "peer-c") + routerPeer := &types.ComponentPeer{ + ID: "peer-c", Key: testWgKeyC, IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), + DNSLabel: "peerc", AgentVersion: "0.40.0", + } + c.RouterPeers = map[string]*types.ComponentPeer{"peer-c": routerPeer} + c.NetworkXIDToPublicID = map[string]string{"net-1": "5"} + c.RoutersMap = map[string]map[string]*types.ComponentRouter{ + "net-1": {"peer-c": {PublicID: "1", Peer: "peer-c", Enabled: true}}, + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Contains(t, full.RoutersMap, "5") + require.Len(t, full.RoutersMap["5"].Entries, 1) + e := full.RoutersMap["5"].Entries[0] + assert.True(t, e.PeerIndexSet, "router peer must be indexed even when not in c.Peers") +} + +func TestEncodeNetworkMapEnvelope_GroupIDToUserIDs(t *testing.T) { + c := newTestComponents() + c.GroupIDToUserIDs = map[string][]string{ + "group-src": {"user-1", "user-2"}, + "group-missing": {"user-4"}, // group not in components → drop + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.Len(t, full.GroupIdToUserIds, 1, "only present groups survive") + require.Contains(t, full.GroupIdToUserIds, "1") + assert.ElementsMatch(t, []string{"user-1", "user-2"}, full.GroupIdToUserIds["1"].UserIds) +} + +func TestToProxyPatch_EmptyInputReturnsNil(t *testing.T) { + assert.Nil(t, toProxyPatch(nil, "netbird.cloud", false, false)) + assert.Nil(t, toProxyPatch(&types.NetworkMap{}, "netbird.cloud", false, false), + "empty NetworkMap (no peers, rules, routes etc) → nil patch so proto3 omits the field") +} + +func TestToProxyPatch_PopulatesAllFields(t *testing.T) { + nm := &types.NetworkMap{ + Peers: []*types.ComponentPeer{{ + ID: "ext-peer", Key: testWgKeyA, IP: netip.AddrFrom4([4]byte{100, 64, 0, 9}), + DNSLabel: "extpeer", AgentVersion: "0.40.0", + }}, + FirewallRules: []*types.FirewallRule{{ + PeerIP: "100.64.0.9", Action: "accept", Direction: 0, Protocol: "tcp", + }}, + } + + patch := toProxyPatch(nm, "netbird.cloud", false, false) + + require.NotNil(t, patch) + assert.Len(t, patch.Peers, 1) + assert.Len(t, patch.FirewallRules, 1) +} + +// TestEncodeNetworkMapEnvelope_ProxyPatchPropagated covers the ProxyPatch +// pass-through in both encoder branches (normal path + nil-Components +// graceful-degrade). Guards against a regression that drops `ProxyPatch:` +// from one of the envelope struct literals. +func TestEncodeNetworkMapEnvelope_ProxyPatchPropagated(t *testing.T) { + patch := &proto.ProxyPatch{ + ForwardingRules: []*proto.ForwardingRule{{ + Protocol: proto.RuleProtocol_TCP, + DestinationPort: &proto.PortInfo{PortSelection: &proto.PortInfo_Port{Port: 80}}, + TranslatedAddress: net.IPv4(10, 0, 0, 1).To4(), + TranslatedPort: &proto.PortInfo{PortSelection: &proto.PortInfo_Port{Port: 8080}}, + }}, + } + + t.Run("normal_path", func(t *testing.T) { + c := newTestComponents() + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{ + Components: c, + ProxyPatch: patch, + }).GetFull() + + require.NotNil(t, full.ProxyPatch, "ProxyPatch must propagate through the normal encode path") + assert.Len(t, full.ProxyPatch.ForwardingRules, 1) + }) + + t.Run("empty_components_graceful_degrade", func(t *testing.T) { + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{ + Components: emptyNetworkMapComponents(), + ProxyPatch: patch, + }).GetFull() + + require.NotNil(t, full.ProxyPatch, "ProxyPatch must propagate through the nil-Components branch too") + assert.Len(t, full.ProxyPatch.ForwardingRules, 1) + }) +} + +func TestEncodeNetworkMapEnvelope_NilComponentsGracefulDegrade(t *testing.T) { + // nil Components → minimal envelope, no crash. Matches the legacy + // behaviour for missing/unvalidated peers. + env := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{ + Components: emptyNetworkMapComponents(), + DNSDomain: "netbird.cloud", + }) + + require.NotNil(t, env) + full := env.GetFull() + require.NotNil(t, full) + require.NotNil(t, full.AccountSettings, "AccountSettings must always be non-nil") + assert.Equal(t, "netbird.cloud", full.DnsDomain) + assert.Len(t, full.Peers, 1) + assert.Empty(t, full.Policies) +} + +func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) { + c := &types.NetworkMapComponents{ + Network: &types.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}}, + // AccountSettings deliberately nil + } + + full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull() + + require.NotNil(t, full.AccountSettings, "client dereferences AccountSettings unconditionally during Calculate(); a nil here would crash the receiver") + assert.False(t, full.AccountSettings.PeerLoginExpirationEnabled) + assert.Zero(t, full.AccountSettings.PeerLoginExpirationNs) +} + +func emptyNetworkMapComponents() *types.NetworkMapComponents { + return types.EmptyNetworkMapComponents( + &types.NetworkMapComponents{ + PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}}}, + ) +} diff --git a/management/internals/shared/grpc/components_envelope_response.go b/management/internals/shared/grpc/components_envelope_response.go new file mode 100644 index 000000000..cedd1b889 --- /dev/null +++ b/management/internals/shared/grpc/components_envelope_response.go @@ -0,0 +1,200 @@ +package grpc + +import ( + "context" + + integrationsConfig "github.com/netbirdio/management-integrations/integrations/config" + + "github.com/netbirdio/netbird/client/ssh/auth" + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/posture" + "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/proto" +) + +// ToComponentSyncResponse builds a SyncResponse carrying the compact +// NetworkMapEnvelope for capability-aware peers. The legacy proto.NetworkMap +// field is intentionally left empty — capable peers ignore it and the +// envelope alone is the authoritative wire shape. +// +// PeerConfig is computed once server-side using the receiving peer's own +// account-level network metadata. EnableSSH inside PeerConfig is left at +// peer.SSHEnabled (the peer's local setting); account-policy-driven SSH is +// computed by the client from the envelope's GroupIDToUserIDs / AllowedUserIDs +// inside Calculate(), so the SshConfig.SshEnabled bit may flip true on the +// client even though the server-side PeerConfig reports false. +func ToComponentSyncResponse( + ctx context.Context, + config *nbconfig.Config, + httpConfig *nbconfig.HttpServerConfig, + deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, + peer *nbpeer.Peer, + turnCredentials *Token, + relayCredentials *Token, + components *types.NetworkMapComponents, + proxyPatch *types.NetworkMap, + dnsName string, + checks []*posture.Checks, + settings *types.Settings, + extraSettings *types.ExtraSettings, + peerGroups []string, + dnsFwdPort int64, +) *proto.SyncResponse { + // + // 'component' parameter is expected to never be nil + // 'peer' parameter is expected to never be nil + // + // TODO (dmitri) consider using invariants? + // + enableSSH := computeSSHEnabledForPeer(components, peer) + peerConfig := toPeerConfig(peer, components.Network, dnsName, settings, httpConfig, deviceFlowConfig, enableSSH) + + includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid() + useSourcePrefixes := peer.SupportsSourcePrefixes() + + userIDClaim := auth.DefaultUserIDClaim + if httpConfig != nil && httpConfig.AuthUserIDClaim != "" { + userIDClaim = httpConfig.AuthUserIDClaim + } + + envelope := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{ + Components: components, + PeerConfig: peerConfig, + DNSDomain: dnsName, + DNSForwarderPort: dnsFwdPort, + UserIDClaim: userIDClaim, + ProxyPatch: toProxyPatch(proxyPatch, dnsName, includeIPv6, useSourcePrefixes), + }) + + resp := &proto.SyncResponse{ + PeerConfig: peerConfig, + NetworkMapEnvelope: envelope, + Checks: toProtocolChecks(ctx, checks), + Version: int32(sharedgrpc.ComponentNetworkMap), + } + + nbConfig := toNetbirdConfig(config, turnCredentials, relayCredentials, extraSettings, settings) + resp.NetbirdConfig = integrationsConfig.ExtendNetBirdConfig(peer.ID, peerGroups, nbConfig, extraSettings) + + // settings == nil → field stays nil → "no info in this snapshot", client + // preserves the deadline it already had. settings non-nil → emit either a + // valid deadline or the explicit-zero "disabled" sentinel via + // encodeSessionExpiresAt. + if settings != nil { + resp.SessionExpiresAt = encodeSessionExpiresAt( + peer.SessionExpiresAt(settings.PeerLoginExpirationEnabled, settings.PeerLoginExpiration), + ) + } + + return resp +} + +// toProxyPatch converts a proxy-injected *types.NetworkMap into the wire +// patch the components envelope ships alongside. Returns nil when there are +// no fragments to merge — proto3 omits a nil message field, so the receiver +// sees no patch and skips the merge step entirely. +// +// We reuse the legacy proto-conversion helpers (toProtocolRoutes, +// toProtocolFirewallRules, toProtocolRoutesFirewallRules, +// appendRemotePeerConfig, ForwardingRule.ToProto) because the proxy +// delivers fragments pre-expanded — there's no raw component shape to +// derive them from. Components purity isn't violated: proxy data isn't +// policy-graph-derived, it's externally injected post-Calculate, so the +// client merges it on top of its locally-computed NetworkMap. +func toProxyPatch(nm *types.NetworkMap, dnsName string, includeIPv6, useSourcePrefixes bool) *proto.ProxyPatch { + if nm == nil { + return nil + } + if len(nm.Peers) == 0 && len(nm.OfflinePeers) == 0 && len(nm.FirewallRules) == 0 && + len(nm.Routes) == 0 && len(nm.RoutesFirewallRules) == 0 && len(nm.ForwardingRules) == 0 { + return nil + } + + patch := &proto.ProxyPatch{ + Peers: networkmap.AppendRemotePeerConfig(nil, nm.Peers, dnsName, includeIPv6), + OfflinePeers: networkmap.AppendRemotePeerConfig(nil, nm.OfflinePeers, dnsName, includeIPv6), + FirewallRules: networkmap.ToProtocolFirewallRules(nm.FirewallRules, includeIPv6, useSourcePrefixes), + Routes: networkmap.ToProtocolRoutes(nm.Routes), + RouteFirewallRules: networkmap.ToProtocolRoutesFirewallRules(nm.RoutesFirewallRules), + } + if len(nm.ForwardingRules) > 0 { + patch.ForwardingRules = make([]*proto.ForwardingRule, 0, len(nm.ForwardingRules)) + for _, r := range nm.ForwardingRules { + patch.ForwardingRules = append(patch.ForwardingRules, r.ToProto()) + } + } + return patch +} + +// computeSSHEnabledForPeer mirrors the SSH-server-activation bit that +// Calculate() folds into NetworkMap.EnableSSH. Components-format peers +// receive a freshly-computed PeerConfig.SshConfig.SshEnabled at sync time; +// without this helper the field would be incorrectly false for any peer +// that's the destination of an SSH-enabling policy without having +// peer.SSHEnabled set locally. +// +// Mirrors the two activation paths Calculate() uses: +// 1. Explicit: rule.Protocol == NetbirdSSH and peer is in the rule's +// destinations. +// 2. Legacy implicit: rule covers TCP/22 or TCP/22022 (or ALL), peer is in +// destinations, AND the peer has SSHEnabled set locally — this is the +// "allow-all/TCP-22 implies SSH activation for SSH-capable peers" path. +// +// The full SSH AuthorizedUsers map is still produced by the client when it +// runs Calculate() over the envelope. +func computeSSHEnabledForPeer(c *types.NetworkMapComponents, peer *nbpeer.Peer) bool { + if c == nil || peer == nil { + return false + } + // Mirror Calculate's `getAllPeersFromGroups` invariant: target peer must + // exist in c.Peers, otherwise no rule applies to it. + if _, ok := c.Peers[peer.ID]; !ok { + return false + } + for _, policy := range c.Policies { + if policy == nil || !policy.Enabled { + continue + } + for _, rule := range policy.Rules { + if ruleEnablesSSHForPeer(c, rule, peer) { + return true + } + } + } + return false +} + +// ruleEnablesSSHForPeer returns true when rule is active, targets peer, and +// either explicitly authorises SSH or covers the legacy TCP/22 path while the +// peer itself has SSH enabled locally. +func ruleEnablesSSHForPeer(c *types.NetworkMapComponents, rule *types.PolicyRule, peer *nbpeer.Peer) bool { + if rule == nil || !rule.Enabled { + return false + } + if !peerInDestinations(c, rule, peer.ID) { + return false + } + if rule.Protocol == types.PolicyRuleProtocolNetbirdSSH { + return true + } + return peer.SSHEnabled && types.PolicyRuleImpliesLegacySSH(rule) +} + +// peerInDestinations reports whether peerID is in any of rule.Destinations' +// groups (or matches DestinationResource if it's a peer-typed resource — +// for non-peer types Calculate falls through to group lookup, so we mirror +// that exactly to avoid silent divergence). +func peerInDestinations(c *types.NetworkMapComponents, rule *types.PolicyRule, peerID string) bool { + if rule.DestinationResource.Type == types.ResourceTypePeer && rule.DestinationResource.ID != "" { + return rule.DestinationResource.ID == peerID + } + for _, groupID := range rule.Destinations { + if c.IsPeerInGroup(peerID, groupID) { + return true + } + } + return false +} diff --git a/management/internals/shared/grpc/components_envelope_response_test.go b/management/internals/shared/grpc/components_envelope_response_test.go new file mode 100644 index 000000000..20f4e6824 --- /dev/null +++ b/management/internals/shared/grpc/components_envelope_response_test.go @@ -0,0 +1,184 @@ +package grpc + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" +) + +// TestComputeSSHEnabledForPeer covers both Calculate-mirroring branches: +// explicit NetbirdSSH protocol, and the legacy implicit case where a +// TCP/22 (or 22022 / ALL / port-range-covering-22) rule activates SSH when +// the destination peer has SSHEnabled=true locally. +func TestComputeSSHEnabledForPeer(t *testing.T) { + const targetPeerID = "target" + const targetGroupID = "g_dst" + + mkComponents := func(rule *types.PolicyRule, sshEnabled bool) (*types.NetworkMapComponents, *nbpeer.Peer) { + peer := &nbpeer.Peer{ID: targetPeerID, SSHEnabled: sshEnabled} + group := &types.ComponentGroup{ID: targetGroupID, Name: "dst", Peers: []string{targetPeerID}} + return &types.NetworkMapComponents{ + Peers: map[string]*types.ComponentPeer{targetPeerID: peer.ToComponent()}, + Groups: map[string]*types.ComponentGroup{targetGroupID: group}, + Policies: []*types.Policy{{ + ID: "p", + Enabled: true, + Rules: []*types.PolicyRule{rule}, + }}, + }, peer + } + + cases := []struct { + name string + peerSSH bool + rule types.PolicyRule + wantEnabled bool + }{ + { + name: "explicit-netbird-ssh-activates-regardless-of-peer-ssh", + peerSSH: false, + rule: types.PolicyRule{ + Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Destinations: []string{targetGroupID}, + }, + wantEnabled: true, + }, + { + name: "implicit-tcp-22-with-peer-ssh", + peerSSH: true, + rule: types.PolicyRule{ + Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22"}, + Destinations: []string{targetGroupID}, + }, + wantEnabled: true, + }, + { + name: "implicit-tcp-22-without-peer-ssh-disabled", + peerSSH: false, + rule: types.PolicyRule{ + Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22"}, + Destinations: []string{targetGroupID}, + }, + wantEnabled: false, + }, + { + name: "implicit-tcp-22022-with-peer-ssh", + peerSSH: true, + rule: types.PolicyRule{ + Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22022"}, + Destinations: []string{targetGroupID}, + }, + wantEnabled: true, + }, + { + name: "implicit-all-protocol-with-peer-ssh", + peerSSH: true, + rule: types.PolicyRule{ + Enabled: true, Protocol: types.PolicyRuleProtocolALL, + Destinations: []string{targetGroupID}, + }, + wantEnabled: true, + }, + { + name: "implicit-port-range-covers-22", + peerSSH: true, + rule: types.PolicyRule{ + Enabled: true, + Protocol: types.PolicyRuleProtocolTCP, + PortRanges: []types.RulePortRange{{Start: 20, End: 30}}, + Destinations: []string{targetGroupID}, + }, + wantEnabled: true, + }, + { + name: "tcp-80-no-ssh", + peerSSH: true, + rule: types.PolicyRule{ + Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"80"}, + Destinations: []string{targetGroupID}, + }, + wantEnabled: false, + }, + { + name: "disabled-rule-skipped", + peerSSH: true, + rule: types.PolicyRule{ + Enabled: false, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Destinations: []string{targetGroupID}, + }, + wantEnabled: false, + }, + { + name: "peer-not-in-destinations", + peerSSH: true, + rule: types.PolicyRule{ + Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Destinations: []string{"g_other"}, // target not in this group + }, + wantEnabled: false, + }, + { + name: "peer-typed-destination-resource-matches", + peerSSH: false, + rule: types.PolicyRule{ + Enabled: true, + Protocol: types.PolicyRuleProtocolNetbirdSSH, + DestinationResource: types.Resource{ID: targetPeerID, Type: types.ResourceTypePeer}, + }, + wantEnabled: true, + }, + { + name: "non-peer-destination-resource-falls-through-to-groups", + peerSSH: false, + rule: types.PolicyRule{ + Enabled: true, + Protocol: types.PolicyRuleProtocolNetbirdSSH, + DestinationResource: types.Resource{ID: targetPeerID, Type: "host"}, // wrong type + Destinations: []string{targetGroupID}, // saved by group fallback + }, + wantEnabled: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, peer := mkComponents(&tc.rule, tc.peerSSH) + got := computeSSHEnabledForPeer(c, peer) + assert.Equal(t, tc.wantEnabled, got) + }) + } +} + +// TestComputeSSHEnabledForPeer_TargetMissingFromComponents covers the +// belt-and-suspenders presence guard mirroring Calculate's +// getAllPeersFromGroups invariant. +func TestComputeSSHEnabledForPeer_TargetMissingFromComponents(t *testing.T) { + peer := &nbpeer.Peer{ID: "missing", SSHEnabled: true} + c := &types.NetworkMapComponents{ + Peers: map[string]*types.ComponentPeer{}, // target peer NOT present + Groups: map[string]*types.ComponentGroup{ + "g": {ID: "g", Peers: []string{"missing"}}, + }, + Policies: []*types.Policy{{ + ID: "p", Enabled: true, + Rules: []*types.PolicyRule{{ + Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Destinations: []string{"g"}, + }}, + }}, + } + assert.False(t, computeSSHEnabledForPeer(c, peer), + "missing target peer must short-circuit to false, not consult policies") +} + +// TestComputeSSHEnabledForPeer_NilInputs guards the cheap nil-checks at +// function entry — Calculate doesn't accept nil either, but the helper is +// exported indirectly via ToComponentSyncResponse and may receive nil +// components on graceful-degrade paths. +func TestComputeSSHEnabledForPeer_NilInputs(t *testing.T) { + assert.False(t, computeSSHEnabledForPeer(nil, &nbpeer.Peer{ID: "x"})) + assert.False(t, computeSSHEnabledForPeer(&types.NetworkMapComponents{}, nil)) +} diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go index 9a4681eae..696d28f5c 100644 --- a/management/internals/shared/grpc/conversion.go +++ b/management/internals/shared/grpc/conversion.go @@ -3,19 +3,51 @@ package grpc import ( "context" "fmt" + "net/netip" + "net/url" + "strings" + "time" + + "github.com/hashicorp/go-version" + nbversion "github.com/netbirdio/netbird/version" + "google.golang.org/protobuf/types/known/timestamppb" integrationsConfig "github.com/netbirdio/management-integrations/integrations/config" - nbdns "github.com/netbirdio/netbird/dns" + + "github.com/netbirdio/netbird/client/ssh/auth" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" 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/route" + "github.com/netbirdio/netbird/shared/management/networkmap" "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/netiputil" ) -func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken *Token, extraSettings *types.ExtraSettings) *proto.NetbirdConfig { +const ( + // deprecatedRemotePeersVersion is the version of Netbird that introduced the NetworkMap.RemotePeers field, deprecated in favor of RemotePeers. + deprecatedRemotePeersVersion = "0.29.3" +) + +// precomputedDeprecatedRemotePeersConstraint is the parsed ">= 0.29.3" constraint, +// built once at init since the bound is a compile-time constant. +var precomputedDeprecatedRemotePeersConstraint version.Constraints + +func init() { + constraint, err := version.NewConstraint(">= " + deprecatedRemotePeersVersion) + if err != nil { + panic("parse deprecated remote peers version constraint: " + err.Error()) + } + precomputedDeprecatedRemotePeersConstraint = constraint +} + +// toNetbirdConfig converts the server configuration to the wire representation. It returns +// nil when no server config is set (the fan-out network-map path) because clients treat any +// non-nil config as authoritative: a config without a relay section is interpreted as relay +// disabled and wipes the clients' relay URLs. +func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken *Token, extraSettings *types.ExtraSettings, settings *types.Settings) *proto.NetbirdConfig { if config == nil { return nil } @@ -78,52 +110,92 @@ func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken Relay: relayCfg, } + if settings != nil { + nbConfig.Metrics = &proto.MetricsConfig{ + Enabled: settings.MetricsPushEnabled, + } + } + return nbConfig } -func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, settings *types.Settings) *proto.PeerConfig { +func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, settings *types.Settings, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool) *proto.PeerConfig { netmask, _ := network.Net.Mask.Size() fqdn := peer.FQDN(dnsName) - return &proto.PeerConfig{ - Address: fmt.Sprintf("%s/%d", peer.IP.String(), netmask), // take it from the network - SshConfig: &proto.SSHConfig{SshEnabled: peer.SSHEnabled}, + + sshConfig := &proto.SSHConfig{ + SshEnabled: peer.SSHEnabled || enableSSH, + } + + if sshConfig.SshEnabled { + sshConfig.JwtConfig = buildJWTConfig(httpConfig, deviceFlowConfig) + } + + peerConfig := &proto.PeerConfig{ + Address: fmt.Sprintf("%s/%d", peer.IP.String(), netmask), + SshConfig: sshConfig, Fqdn: fqdn, RoutingPeerDnsResolutionEnabled: settings.RoutingPeerDNSResolutionEnabled, LazyConnectionEnabled: settings.LazyConnectionEnabled, + AutoUpdate: &proto.AutoUpdateSettings{ + Version: settings.AutoUpdateVersion, + AlwaysUpdate: settings.AutoUpdateAlways, + }, } + + if peer.SupportsIPv6() && peer.IPv6.IsValid() && network.NetV6.IP != nil { + ones, _ := network.NetV6.Mask.Size() + v6Prefix := netip.PrefixFrom(peer.IPv6.Unmap(), ones) + if b, err := netiputil.EncodePrefix(v6Prefix); err == nil { + peerConfig.AddressV6 = b + } + } + + return peerConfig } -func ToSyncResponse(ctx context.Context, config *nbconfig.Config, peer *nbpeer.Peer, turnCredentials *Token, relayCredentials *Token, networkMap *types.NetworkMap, dnsName string, checks []*posture.Checks, dnsCache *cache.DNSConfigCache, settings *types.Settings, extraSettings *types.ExtraSettings, peerGroups []string, dnsFwdPort int64) *proto.SyncResponse { +func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, peer *nbpeer.Peer, turnCredentials *Token, relayCredentials *Token, networkMap *types.NetworkMap, dnsName string, checks []*posture.Checks, dnsCache *cache.DNSConfigCache, settings *types.Settings, extraSettings *types.ExtraSettings, peerGroups []string, dnsFwdPort int64) *proto.SyncResponse { + // IPv6 data in AllowedIPs and SourcePrefixes wildcard expansion depends on + // whether the target peer supports IPv6. Routes and firewall rules are already + // filtered at the source (network map builder). + includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid() + useSourcePrefixes := peer.SupportsSourcePrefixes() + response := &proto.SyncResponse{ - PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings), + PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH), NetworkMap: &proto.NetworkMap{ - Serial: networkMap.Network.CurrentSerial(), - Routes: toProtocolRoutes(networkMap.Routes), - DNSConfig: toProtocolDNSConfig(networkMap.DNSConfig, dnsCache, dnsFwdPort), + Serial: networkMap.Network.CurrentSerial(), + Routes: networkmap.ToProtocolRoutes(networkMap.Routes), + DNSConfig: networkmap.ToProtocolDNSConfig(networkMap.DNSConfig, dnsCache, dnsFwdPort), + PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH), }, Checks: toProtocolChecks(ctx, checks), } - nbConfig := toNetbirdConfig(config, turnCredentials, relayCredentials, extraSettings) + nbConfig := toNetbirdConfig(config, turnCredentials, relayCredentials, extraSettings, settings) extendedConfig := integrationsConfig.ExtendNetBirdConfig(peer.ID, peerGroups, nbConfig, extraSettings) response.NetbirdConfig = extendedConfig response.NetworkMap.PeerConfig = response.PeerConfig remotePeers := make([]*proto.RemotePeerConfig, 0, len(networkMap.Peers)+len(networkMap.OfflinePeers)) - remotePeers = appendRemotePeerConfig(remotePeers, networkMap.Peers, dnsName) - response.RemotePeers = remotePeers + remotePeers = networkmap.AppendRemotePeerConfig(remotePeers, networkMap.Peers, dnsName, includeIPv6) + + if !shouldSkipSendingDeprecatedRemotePeers(peer.Meta.WtVersion) { + response.RemotePeers = remotePeers + } + response.NetworkMap.RemotePeers = remotePeers response.RemotePeersIsEmpty = len(remotePeers) == 0 response.NetworkMap.RemotePeersIsEmpty = response.RemotePeersIsEmpty - response.NetworkMap.OfflinePeers = appendRemotePeerConfig(nil, networkMap.OfflinePeers, dnsName) + response.NetworkMap.OfflinePeers = networkmap.AppendRemotePeerConfig(nil, networkMap.OfflinePeers, dnsName, includeIPv6) - firewallRules := toProtocolFirewallRules(networkMap.FirewallRules) + firewallRules := networkmap.ToProtocolFirewallRules(networkMap.FirewallRules, includeIPv6, useSourcePrefixes) response.NetworkMap.FirewallRules = firewallRules response.NetworkMap.FirewallRulesIsEmpty = len(firewallRules) == 0 - routesFirewallRules := toProtocolRoutesFirewallRules(networkMap.RoutesFirewallRules) + routesFirewallRules := networkmap.ToProtocolRoutesFirewallRules(networkMap.RoutesFirewallRules) response.NetworkMap.RoutesFirewallRules = routesFirewallRules response.NetworkMap.RoutesFirewallRulesIsEmpty = len(routesFirewallRules) == 0 @@ -135,48 +207,58 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, peer *nbpeer.P response.NetworkMap.ForwardingRules = forwardingRules } + if networkMap.AuthorizedUsers != nil { + hashedUsers, machineUsers := networkmap.BuildAuthorizedUsersProto(ctx, networkMap.AuthorizedUsers) + userIDClaim := auth.DefaultUserIDClaim + if httpConfig != nil && httpConfig.AuthUserIDClaim != "" { + userIDClaim = httpConfig.AuthUserIDClaim + } + response.NetworkMap.SshAuth = &proto.SSHAuth{AuthorizedUsers: hashedUsers, MachineUsers: machineUsers, UserIDClaim: userIDClaim} + } + + // settings == nil → field stays nil → "no info in this snapshot", client + // preserves the deadline it already had. settings non-nil → emit either a + // valid deadline or the explicit-zero "disabled" sentinel via + // encodeSessionExpiresAt. + if settings != nil { + response.SessionExpiresAt = encodeSessionExpiresAt( + peer.SessionExpiresAt(settings.PeerLoginExpirationEnabled, settings.PeerLoginExpiration), + ) + } + return response } -func appendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*nbpeer.Peer, dnsName string) []*proto.RemotePeerConfig { - for _, rPeer := range peers { - dst = append(dst, &proto.RemotePeerConfig{ - WgPubKey: rPeer.Key, - AllowedIps: []string{rPeer.IP.String() + "/32"}, - SshConfig: &proto.SSHConfig{SshPubKey: []byte(rPeer.SSHKey)}, - Fqdn: rPeer.FQDN(dnsName), - AgentVersion: rPeer.Meta.WtVersion, - }) +// encodeSessionExpiresAt encodes a server-side deadline into the 3-state wire +// representation used on LoginResponse, SyncResponse and +// ExtendAuthSessionResponse. See the proto comments on those messages. +// +// - deadline.IsZero() → returns &Timestamp{} (seconds=0, nanos=0): the +// "expiry disabled or peer is not SSO-tracked" sentinel; the client clears +// its anchor. +// - deadline non-zero → returns timestamppb.New(deadline): the new absolute +// UTC deadline. +// +// Returning nil ("no info, preserve client's anchor") is the caller's job — +// only meaningful on Sync builds where settings were not resolved. +func encodeSessionExpiresAt(deadline time.Time) *timestamppb.Timestamp { + if deadline.IsZero() { + return ×tamppb.Timestamp{} } - return dst + return timestamppb.New(deadline) } -// toProtocolDNSConfig converts nbdns.Config to proto.DNSConfig using the cache -func toProtocolDNSConfig(update nbdns.Config, cache *cache.DNSConfigCache, forwardPort int64) *proto.DNSConfig { - protoUpdate := &proto.DNSConfig{ - ServiceEnable: update.ServiceEnable, - CustomZones: make([]*proto.CustomZone, 0, len(update.CustomZones)), - NameServerGroups: make([]*proto.NameServerGroup, 0, len(update.NameServerGroups)), - ForwarderPort: forwardPort, +func shouldSkipSendingDeprecatedRemotePeers(peerVersion string) bool { + if nbversion.IsDevelopmentVersion(peerVersion) { + return true } - for _, zone := range update.CustomZones { - protoZone := convertToProtoCustomZone(zone) - protoUpdate.CustomZones = append(protoUpdate.CustomZones, protoZone) + peerNBVersion, err := version.NewVersion(peerVersion) + if err != nil { + return false } - for _, nsGroup := range update.NameServerGroups { - cacheKey := nsGroup.ID - if cachedGroup, exists := cache.GetNameServerGroup(cacheKey); exists { - protoUpdate.NameServerGroups = append(protoUpdate.NameServerGroups, cachedGroup) - } else { - protoGroup := convertToProtoNameServerGroup(nsGroup) - cache.SetNameServerGroup(cacheKey, protoGroup) - protoUpdate.NameServerGroups = append(protoUpdate.NameServerGroups, protoGroup) - } - } - - return protoUpdate + return precomputedDeprecatedRemotePeersConstraint.Check(peerNBVersion) } func ToResponseProto(configProto nbconfig.Protocol) proto.HostConfig_Protocol { @@ -196,157 +278,55 @@ func ToResponseProto(configProto nbconfig.Protocol) proto.HostConfig_Protocol { } } -func toProtocolRoutes(routes []*route.Route) []*proto.Route { - protoRoutes := make([]*proto.Route, 0, len(routes)) - for _, r := range routes { - protoRoutes = append(protoRoutes, toProtocolRoute(r)) +// buildJWTConfig constructs JWT configuration for SSH servers from management server config +func buildJWTConfig(config *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow) *proto.JWTConfig { + if config == nil || config.AuthAudience == "" { + return nil } - return protoRoutes -} -func toProtocolRoute(route *route.Route) *proto.Route { - return &proto.Route{ - ID: string(route.ID), - NetID: string(route.NetID), - Network: route.Network.String(), - Domains: route.Domains.ToPunycodeList(), - NetworkType: int64(route.NetworkType), - Peer: route.Peer, - Metric: int64(route.Metric), - Masquerade: route.Masquerade, - KeepRoute: route.KeepRoute, - SkipAutoApply: route.SkipAutoApply, - } -} - -// toProtocolFirewallRules converts the firewall rules to the protocol firewall rules. -func toProtocolFirewallRules(rules []*types.FirewallRule) []*proto.FirewallRule { - result := make([]*proto.FirewallRule, len(rules)) - for i := range rules { - rule := rules[i] - - fwRule := &proto.FirewallRule{ - PolicyID: []byte(rule.PolicyID), - PeerIP: rule.PeerIP, - Direction: getProtoDirection(rule.Direction), - Action: getProtoAction(rule.Action), - Protocol: getProtoProtocol(rule.Protocol), - Port: rule.Port, - } - - if shouldUsePortRange(fwRule) { - fwRule.PortInfo = rule.PortRange.ToProto() - } - - result[i] = fwRule - } - return result -} - -// getProtoDirection converts the direction to proto.RuleDirection. -func getProtoDirection(direction int) proto.RuleDirection { - if direction == types.FirewallRuleDirectionOUT { - return proto.RuleDirection_OUT - } - return proto.RuleDirection_IN -} - -func toProtocolRoutesFirewallRules(rules []*types.RouteFirewallRule) []*proto.RouteFirewallRule { - result := make([]*proto.RouteFirewallRule, len(rules)) - for i := range rules { - rule := rules[i] - result[i] = &proto.RouteFirewallRule{ - SourceRanges: rule.SourceRanges, - Action: getProtoAction(rule.Action), - Destination: rule.Destination, - Protocol: getProtoProtocol(rule.Protocol), - PortInfo: getProtoPortInfo(rule), - IsDynamic: rule.IsDynamic, - Domains: rule.Domains.ToPunycodeList(), - PolicyID: []byte(rule.PolicyID), - RouteID: string(rule.RouteID), + issuer := strings.TrimSpace(config.AuthIssuer) + if issuer == "" && deviceFlowConfig != nil { + if d := deriveIssuerFromTokenEndpoint(deviceFlowConfig.ProviderConfig.TokenEndpoint); d != "" { + issuer = d } } - - return result -} - -// getProtoAction converts the action to proto.RuleAction. -func getProtoAction(action string) proto.RuleAction { - if action == string(types.PolicyTrafficActionDrop) { - return proto.RuleAction_DROP + if issuer == "" { + return nil } - return proto.RuleAction_ACCEPT -} -// getProtoProtocol converts the protocol to proto.RuleProtocol. -func getProtoProtocol(protocol string) proto.RuleProtocol { - switch types.PolicyRuleProtocolType(protocol) { - case types.PolicyRuleProtocolALL: - return proto.RuleProtocol_ALL - case types.PolicyRuleProtocolTCP: - return proto.RuleProtocol_TCP - case types.PolicyRuleProtocolUDP: - return proto.RuleProtocol_UDP - case types.PolicyRuleProtocolICMP: - return proto.RuleProtocol_ICMP - default: - return proto.RuleProtocol_UNKNOWN + keysLocation := strings.TrimSpace(config.AuthKeysLocation) + if keysLocation == "" { + keysLocation = strings.TrimSuffix(issuer, "/") + "/.well-known/jwks.json" + } + + audience := config.AuthAudience + if config.CLIAuthAudience != "" { + audience = config.CLIAuthAudience + } + + audiences := []string{config.AuthAudience} + if config.CLIAuthAudience != "" && config.CLIAuthAudience != config.AuthAudience { + audiences = append(audiences, config.CLIAuthAudience) + } + + return &proto.JWTConfig{ + Issuer: issuer, + Audience: audience, + Audiences: audiences, + KeysLocation: keysLocation, } } -// getProtoPortInfo converts the port info to proto.PortInfo. -func getProtoPortInfo(rule *types.RouteFirewallRule) *proto.PortInfo { - var portInfo proto.PortInfo - if rule.Port != 0 { - portInfo.PortSelection = &proto.PortInfo_Port{Port: uint32(rule.Port)} - } else if portRange := rule.PortRange; portRange.Start != 0 && portRange.End != 0 { - portInfo.PortSelection = &proto.PortInfo_Range_{ - Range: &proto.PortInfo_Range{ - Start: uint32(portRange.Start), - End: uint32(portRange.End), - }, - } +// deriveIssuerFromTokenEndpoint extracts the issuer URL from a token endpoint +func deriveIssuerFromTokenEndpoint(tokenEndpoint string) string { + if tokenEndpoint == "" { + return "" } - return &portInfo -} -func shouldUsePortRange(rule *proto.FirewallRule) bool { - return rule.Port == "" && (rule.Protocol == proto.RuleProtocol_UDP || rule.Protocol == proto.RuleProtocol_TCP) -} + u, err := url.Parse(tokenEndpoint) + if err != nil { + return "" + } -// Helper function to convert nbdns.CustomZone to proto.CustomZone -func convertToProtoCustomZone(zone nbdns.CustomZone) *proto.CustomZone { - protoZone := &proto.CustomZone{ - Domain: zone.Domain, - Records: make([]*proto.SimpleRecord, 0, len(zone.Records)), - } - for _, record := range zone.Records { - protoZone.Records = append(protoZone.Records, &proto.SimpleRecord{ - Name: record.Name, - Type: int64(record.Type), - Class: record.Class, - TTL: int64(record.TTL), - RData: record.RData, - }) - } - return protoZone -} - -// Helper function to convert nbdns.NameServerGroup to proto.NameServerGroup -func convertToProtoNameServerGroup(nsGroup *nbdns.NameServerGroup) *proto.NameServerGroup { - protoGroup := &proto.NameServerGroup{ - Primary: nsGroup.Primary, - Domains: nsGroup.Domains, - SearchDomainsEnabled: nsGroup.SearchDomainsEnabled, - NameServers: make([]*proto.NameServer, 0, len(nsGroup.NameServers)), - } - for _, ns := range nsGroup.NameServers { - protoGroup.NameServers = append(protoGroup.NameServers, &proto.NameServer{ - IP: ns.IP.String(), - Port: int64(ns.Port), - NSType: int64(ns.NSType), - }) - } - return protoGroup + return fmt.Sprintf("%s://%s/", u.Scheme, u.Host) } diff --git a/management/internals/shared/grpc/conversion_test.go b/management/internals/shared/grpc/conversion_test.go index 701271345..402b4fd07 100644 --- a/management/internals/shared/grpc/conversion_test.go +++ b/management/internals/shared/grpc/conversion_test.go @@ -5,10 +5,17 @@ import ( "net/netip" "reflect" "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" 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" + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/networkmap" ) func TestToProtocolDNSConfigWithCache(t *testing.T) { @@ -58,13 +65,13 @@ func TestToProtocolDNSConfigWithCache(t *testing.T) { } // First run with config1 - result1 := toProtocolDNSConfig(config1, &cache, int64(network_map.DnsForwarderPort)) + result1 := networkmap.ToProtocolDNSConfig(config1, &cache, int64(network_map.DnsForwarderPort)) // Second run with config2 - result2 := toProtocolDNSConfig(config2, &cache, int64(network_map.DnsForwarderPort)) + result2 := networkmap.ToProtocolDNSConfig(config2, &cache, int64(network_map.DnsForwarderPort)) // Third run with config1 again - result3 := toProtocolDNSConfig(config1, &cache, int64(network_map.DnsForwarderPort)) + result3 := networkmap.ToProtocolDNSConfig(config1, &cache, int64(network_map.DnsForwarderPort)) // Verify that result1 and result3 are identical if !reflect.DeepEqual(result1, result3) { @@ -96,15 +103,14 @@ func BenchmarkToProtocolDNSConfig(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - toProtocolDNSConfig(testData, cache, int64(network_map.DnsForwarderPort)) + networkmap.ToProtocolDNSConfig(testData, cache, int64(network_map.DnsForwarderPort)) } }) b.Run(fmt.Sprintf("WithoutCache-Size%d", size), func(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - cache := &cache.DNSConfigCache{} - toProtocolDNSConfig(testData, cache, int64(network_map.DnsForwarderPort)) + networkmap.ToProtocolDNSConfig(testData, nil, int64(network_map.DnsForwarderPort)) } }) } @@ -148,3 +154,150 @@ func generateTestData(size int) nbdns.Config { return config } + +func TestBuildJWTConfig_Audiences(t *testing.T) { + tests := []struct { + name string + authAudience string + cliAuthAudience string + expectedAudiences []string + expectedAudience string + }{ + { + name: "only_auth_audience", + authAudience: "dashboard-aud", + cliAuthAudience: "", + expectedAudiences: []string{"dashboard-aud"}, + expectedAudience: "dashboard-aud", + }, + { + name: "both_audiences_different", + authAudience: "dashboard-aud", + cliAuthAudience: "cli-aud", + expectedAudiences: []string{"dashboard-aud", "cli-aud"}, + expectedAudience: "cli-aud", + }, + { + name: "both_audiences_same", + authAudience: "same-aud", + cliAuthAudience: "same-aud", + expectedAudiences: []string{"same-aud"}, + expectedAudience: "same-aud", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + config := &nbconfig.HttpServerConfig{ + AuthIssuer: "https://issuer.example.com", + AuthAudience: tc.authAudience, + CLIAuthAudience: tc.cliAuthAudience, + } + + result := buildJWTConfig(config, nil) + + assert.NotNil(t, result) + assert.Equal(t, tc.expectedAudiences, result.Audiences, "audiences should match expected") + //nolint:staticcheck // SA1019: Testing backwards compatibility - Audience field must still be populated + assert.Equal(t, tc.expectedAudience, result.Audience, "audience should match expected") + }) + } +} + +// TestShouldSkipSendingDeprecatedRemotePeers covers the version gate that +// stops populating the deprecated top-level SyncResponse.RemotePeers field for +// peers new enough to read RemotePeers off the NetworkMap. Development builds +// are treated as latest and skip the field. The gate otherwise fails safe: a +// release version older than the boundary, or one that can't be parsed (empty, +// garbage, prereleases of the boundary) still receives the deprecated field so +// older/unknown clients keep working. +func TestShouldSkipSendingDeprecatedRemotePeers(t *testing.T) { + tests := []struct { + name string + peerVersion string + wantSkip bool + }{ + {"exact boundary skips", "0.29.3", true}, + {"newer patch skips", "0.29.4", true}, + {"newer minor skips", "0.30.0", true}, + {"newer major skips", "1.0.0", true}, + {"v-prefixed newer skips", "v0.30.0", true}, + {"development build skips", "development", true}, + {"development build with commit skips", "development-abc123def456-dirty", true}, + {"older patch keeps field", "0.29.2", false}, + {"older minor keeps field", "0.28.0", false}, + {"prerelease of boundary keeps field", "0.29.3-SNAPSHOT", false}, + {"tagged dev prerelease keeps field", "v0.31.1-dev", false}, + {"empty version keeps field", "", false}, + {"garbage version keeps field", "not-a-version", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := shouldSkipSendingDeprecatedRemotePeers(tc.peerVersion) + assert.Equal(t, tc.wantSkip, got, "skip decision for peer version %q", tc.peerVersion) + }) + } +} + +// TestEncodeSessionExpiresAt pins the wire encoding the client's +// applySessionDeadline depends on: +// +// - zero deadline → &Timestamp{} (seconds=0, nanos=0): the explicit +// "expiry disabled or peer is not SSO-tracked" sentinel. +// - non-zero → timestamppb.New(deadline): the absolute UTC deadline. +// +// The third state (nil pointer = "no info in this snapshot") is the caller's +// responsibility on the Sync path when settings could not be resolved; the +// helper itself never returns nil. +func TestEncodeSessionExpiresAt(t *testing.T) { + t.Run("zero deadline encodes as explicit-zero sentinel", func(t *testing.T) { + got := encodeSessionExpiresAt(time.Time{}) + assert.NotNil(t, got, "must not return nil; nil means 'no info', not 'disabled'") + assert.Equal(t, int64(0), got.GetSeconds()) + assert.Equal(t, int32(0), got.GetNanos()) + }) + + t.Run("non-zero deadline round-trips", func(t *testing.T) { + deadline := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC) + got := encodeSessionExpiresAt(deadline) + assert.NotNil(t, got) + assert.True(t, got.AsTime().Equal(deadline)) + }) +} + +// TestToNetbirdConfig_RelayInvariant guards against the v0.74.0 relay-wipe regression. +// Clients treat any non-nil NetbirdConfig as authoritative and interpret a missing relay +// section as relay disabled, wiping their relay URLs. toNetbirdConfig must therefore +// return nil when no server config is set (the fan-out network-map path) instead of a +// partial config, and a result built from a relay-enabled config must carry the relay +// section. +func TestToNetbirdConfig_RelayInvariant(t *testing.T) { + settings := &types.Settings{MetricsPushEnabled: true} + + t.Run("nil server config returns nil config", func(t *testing.T) { + nbCfg := toNetbirdConfig(nil, nil, nil, nil, settings) + assert.Nil(t, nbCfg, "fan-out updates must not carry a partial NetbirdConfig even when settings are present") + }) + + t.Run("relay-enabled config carries relay section", func(t *testing.T) { + cfg := &nbconfig.Config{ + Stuns: []*nbconfig.Host{{Proto: nbconfig.UDP, URI: "stun:stun.example.com:3478"}}, + TURNConfig: &nbconfig.TURNConfig{ + Turns: []*nbconfig.Host{{Proto: nbconfig.UDP, URI: "turn:turn.example.com:3478", Username: "user", Password: "pass"}}, + }, + Relay: &nbconfig.Relay{Addresses: []string{"rels://relay.example.com:443"}}, + Signal: &nbconfig.Host{Proto: nbconfig.HTTP, URI: "signal.example.com:10000"}, + } + relayToken := &Token{Payload: "token-payload", Signature: "token-signature"} + + nbCfg := toNetbirdConfig(cfg, nil, relayToken, nil, settings) + require.NotNil(t, nbCfg) + require.NotNil(t, nbCfg.Relay, "non-nil NetbirdConfig must include the relay section") + assert.Equal(t, cfg.Relay.Addresses, nbCfg.Relay.Urls, "relay URLs should match the server config") + assert.Equal(t, relayToken.Payload, nbCfg.Relay.TokenPayload, "relay token payload should be set") + assert.Equal(t, relayToken.Signature, nbCfg.Relay.TokenSignature, "relay token signature should be set") + require.NotNil(t, nbCfg.Metrics) + assert.True(t, nbCfg.Metrics.Enabled, "metrics flag should carry the settings value") + }) +} diff --git a/management/internals/shared/grpc/expose_service.go b/management/internals/shared/grpc/expose_service.go new file mode 100644 index 000000000..1b87f7ede --- /dev/null +++ b/management/internals/shared/grpc/expose_service.go @@ -0,0 +1,251 @@ +package grpc + +import ( + "context" + "fmt" + + pb "github.com/golang/protobuf/proto" // nolint + log "github.com/sirupsen/logrus" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/encryption" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + nbContext "github.com/netbirdio/netbird/management/server/context" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/proto" + internalStatus "github.com/netbirdio/netbird/shared/management/status" +) + +// CreateExpose handles a peer request to create a new expose service. +func (s *Server) CreateExpose(ctx context.Context, req *proto.EncryptedMessage) (*proto.EncryptedMessage, error) { + exposeReq := &proto.ExposeServiceRequest{} + peerKey, err := s.parseRequest(ctx, req, exposeReq) + if err != nil { + return nil, err + } + + accountID, peer, err := s.authenticateExposePeer(ctx, peerKey) + if err != nil { + return nil, err + } + + // nolint:staticcheck + ctx = context.WithValue(ctx, nbContext.AccountIDKey, accountID) + + reverseProxyMgr := s.getReverseProxyManager() + if reverseProxyMgr == nil { + return nil, status.Errorf(codes.Internal, "reverse proxy manager not available") + } + + if exposeReq.Port > 65535 { + return nil, status.Errorf(codes.InvalidArgument, "port out of range: %d", exposeReq.Port) + } + if exposeReq.ListenPort > 65535 { + return nil, status.Errorf(codes.InvalidArgument, "listen_port out of range: %d", exposeReq.ListenPort) + } + + mode, err := exposeProtocolToString(exposeReq.Protocol) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "%v", err) + } + + created, err := reverseProxyMgr.CreateServiceFromPeer(ctx, accountID, peer.ID, &rpservice.ExposeServiceRequest{ + NamePrefix: exposeReq.NamePrefix, + Port: uint16(exposeReq.Port), //nolint:gosec // validated above + Mode: mode, + TargetProtocol: exposeTargetProtocol(exposeReq.Protocol), + Domain: exposeReq.Domain, + Pin: exposeReq.Pin, + Password: exposeReq.Password, + UserGroups: exposeReq.UserGroups, + ListenPort: uint16(exposeReq.ListenPort), //nolint:gosec // validated above + }) + if err != nil { + return nil, mapExposeError(ctx, err) + } + + return s.encryptResponse(peerKey, &proto.ExposeServiceResponse{ + ServiceName: created.ServiceName, + ServiceUrl: created.ServiceURL, + Domain: created.Domain, + PortAutoAssigned: created.PortAutoAssigned, + }) +} + +// RenewExpose extends the TTL of an active expose session. +func (s *Server) RenewExpose(ctx context.Context, req *proto.EncryptedMessage) (*proto.EncryptedMessage, error) { + renewReq := &proto.RenewExposeRequest{} + peerKey, err := s.parseRequest(ctx, req, renewReq) + if err != nil { + return nil, err + } + + accountID, peer, err := s.authenticateExposePeer(ctx, peerKey) + if err != nil { + return nil, err + } + + reverseProxyMgr := s.getReverseProxyManager() + if reverseProxyMgr == nil { + return nil, status.Errorf(codes.Internal, "reverse proxy manager not available") + } + + serviceID, err := s.resolveServiceID(ctx, renewReq.Domain) + if err != nil { + return nil, mapExposeError(ctx, err) + } + + if err := reverseProxyMgr.RenewServiceFromPeer(ctx, accountID, peer.ID, serviceID); err != nil { + return nil, mapExposeError(ctx, err) + } + + return s.encryptResponse(peerKey, &proto.RenewExposeResponse{}) +} + +// StopExpose terminates an active expose session. +func (s *Server) StopExpose(ctx context.Context, req *proto.EncryptedMessage) (*proto.EncryptedMessage, error) { + stopReq := &proto.StopExposeRequest{} + peerKey, err := s.parseRequest(ctx, req, stopReq) + if err != nil { + return nil, err + } + + accountID, peer, err := s.authenticateExposePeer(ctx, peerKey) + if err != nil { + return nil, err + } + + reverseProxyMgr := s.getReverseProxyManager() + if reverseProxyMgr == nil { + return nil, status.Errorf(codes.Internal, "reverse proxy manager not available") + } + + serviceID, err := s.resolveServiceID(ctx, stopReq.Domain) + if err != nil { + return nil, mapExposeError(ctx, err) + } + + if err := reverseProxyMgr.StopServiceFromPeer(ctx, accountID, peer.ID, serviceID); err != nil { + return nil, mapExposeError(ctx, err) + } + + return s.encryptResponse(peerKey, &proto.StopExposeResponse{}) +} + +func mapExposeError(ctx context.Context, err error) error { + s, ok := internalStatus.FromError(err) + if !ok { + log.WithContext(ctx).Errorf("expose service error: %v", err) + return status.Errorf(codes.Internal, "internal error") + } + + switch s.Type() { + case internalStatus.InvalidArgument: + return status.Errorf(codes.InvalidArgument, "%s", s.Message) + case internalStatus.PermissionDenied: + return status.Errorf(codes.PermissionDenied, "%s", s.Message) + case internalStatus.NotFound: + return status.Errorf(codes.NotFound, "%s", s.Message) + case internalStatus.AlreadyExists: + return status.Errorf(codes.AlreadyExists, "%s", s.Message) + case internalStatus.PreconditionFailed: + return status.Errorf(codes.ResourceExhausted, "%s", s.Message) + default: + log.WithContext(ctx).Errorf("expose service error: %v", err) + return status.Errorf(codes.Internal, "internal error") + } +} + +func (s *Server) encryptResponse(peerKey wgtypes.Key, msg pb.Message) (*proto.EncryptedMessage, error) { + wgKey, err := s.secretsManager.GetWGKey() + if err != nil { + return nil, status.Errorf(codes.Internal, "internal error") + } + + encryptedResp, err := encryption.EncryptMessage(peerKey, wgKey, msg) + if err != nil { + return nil, status.Errorf(codes.Internal, "encrypt response") + } + + return &proto.EncryptedMessage{ + WgPubKey: wgKey.PublicKey().String(), + Body: encryptedResp, + }, nil +} + +func (s *Server) authenticateExposePeer(ctx context.Context, peerKey wgtypes.Key) (string, *nbpeer.Peer, error) { + accountID, err := s.accountManager.GetAccountIDForPeerKey(ctx, peerKey.String()) + if err != nil { + if errStatus, ok := internalStatus.FromError(err); ok && errStatus.Type() == internalStatus.NotFound { + return "", nil, status.Errorf(codes.PermissionDenied, "peer is not registered") + } + return "", nil, status.Errorf(codes.Internal, "lookup account for peer") + } + + peer, err := s.accountManager.GetStore().GetPeerByPeerPubKey(ctx, store.LockingStrengthNone, peerKey.String()) + if err != nil { + return "", nil, status.Errorf(codes.PermissionDenied, "peer is not registered") + } + + return accountID, peer, nil +} + +func (s *Server) getReverseProxyManager() rpservice.Manager { + s.reverseProxyMu.RLock() + defer s.reverseProxyMu.RUnlock() + return s.reverseProxyManager +} + +// SetReverseProxyManager sets the reverse proxy manager on the server. +func (s *Server) SetReverseProxyManager(mgr rpservice.Manager) { + s.reverseProxyMu.Lock() + defer s.reverseProxyMu.Unlock() + s.reverseProxyManager = mgr +} + +// resolveServiceID looks up the service by its globally unique domain. +func (s *Server) resolveServiceID(ctx context.Context, domain string) (string, error) { + if domain == "" { + return "", status.Errorf(codes.InvalidArgument, "domain is required") + } + + svc, err := s.accountManager.GetStore().GetServiceByDomain(ctx, domain) + if err != nil { + return "", err + } + return svc.ID, nil +} + +func exposeProtocolToString(p proto.ExposeProtocol) (string, error) { + switch p { + case proto.ExposeProtocol_EXPOSE_HTTP, proto.ExposeProtocol_EXPOSE_HTTPS: + return "http", nil + case proto.ExposeProtocol_EXPOSE_TCP: + return "tcp", nil + case proto.ExposeProtocol_EXPOSE_UDP: + return "udp", nil + case proto.ExposeProtocol_EXPOSE_TLS: + return "tls", nil + default: + return "", fmt.Errorf("unsupported expose protocol: %v", p) + } +} + +// exposeTargetProtocol returns the target protocol for the given expose protocol. +// For HTTP mode, this is http or https (the scheme used to connect to the backend). +// For L4 modes, this is tcp or udp (the transport used to connect to the backend). +func exposeTargetProtocol(p proto.ExposeProtocol) string { + switch p { + case proto.ExposeProtocol_EXPOSE_HTTPS: + return rpservice.TargetProtoHTTPS + case proto.ExposeProtocol_EXPOSE_TCP, proto.ExposeProtocol_EXPOSE_TLS: + return rpservice.TargetProtoTCP + case proto.ExposeProtocol_EXPOSE_UDP: + return rpservice.TargetProtoUDP + default: + return rpservice.TargetProtoHTTP + } +} diff --git a/management/internals/shared/grpc/loginfilter.go b/management/internals/shared/grpc/loginfilter.go index 59f69dd90..cc69b7d6e 100644 --- a/management/internals/shared/grpc/loginfilter.go +++ b/management/internals/shared/grpc/loginfilter.go @@ -13,7 +13,7 @@ const ( reconnThreshold = 5 * time.Minute baseBlockDuration = 10 * time.Minute // Duration for which a peer is banned after exceeding the reconnection limit reconnLimitForBan = 30 // Number of reconnections within the reconnTreshold that triggers a ban - metaChangeLimit = 3 // Number of reconnections with different metadata that triggers a ban of one peer + metaChangeLimit = 5 // Number of reconnections with different metadata that triggers a ban of one peer ) type lfConfig struct { @@ -139,7 +139,7 @@ func (l *loginFilter) addLogin(wgPubKey string, metaHash uint64) { state.lastSeen = now } -func metaHash(meta nbpeer.PeerSystemMeta, pubip string) uint64 { +func metaHash(meta nbpeer.PeerSystemMeta) uint64 { h := fnv.New64a() h.Write([]byte(meta.WtVersion)) @@ -147,14 +147,6 @@ func metaHash(meta nbpeer.PeerSystemMeta, pubip string) uint64 { h.Write([]byte(meta.KernelVersion)) h.Write([]byte(meta.Hostname)) h.Write([]byte(meta.SystemSerialNumber)) - h.Write([]byte(pubip)) - macs := uint64(0) - for _, na := range meta.NetworkAddresses { - for _, r := range na.Mac { - macs += uint64(r) - } - } - - return h.Sum64() + macs + return h.Sum64() } diff --git a/management/internals/shared/grpc/loginfilter_test.go b/management/internals/shared/grpc/loginfilter_test.go index 8b26e14ab..d9df26420 100644 --- a/management/internals/shared/grpc/loginfilter_test.go +++ b/management/internals/shared/grpc/loginfilter_test.go @@ -85,6 +85,7 @@ func (s *LoginFilterTestSuite) TestBanDurationIncreasesExponentially() { s.True(s.filter.logged[pubKey].isBanned) s.Equal(2, s.filter.logged[pubKey].banLevel) secondBanDuration := s.filter.logged[pubKey].banExpiresAt.Sub(s.filter.logged[pubKey].lastSeen) + // nolint expectedSecondDuration := time.Duration(float64(baseBan) * math.Pow(2, 1)) s.InDelta(expectedSecondDuration, secondBanDuration, float64(time.Millisecond)) } @@ -163,9 +164,7 @@ func BenchmarkHashingMethods(b *testing.B) { KernelVersion: "5.15.0-76-generic", Hostname: "prod-server-database-01", SystemSerialNumber: "PC-1234567890", - NetworkAddresses: []nbpeer.NetworkAddress{{Mac: "00:1B:44:11:3A:B7"}, {Mac: "00:1B:44:11:3A:B8"}}, } - pubip := "8.8.8.8" var resultString string var resultUint uint64 @@ -174,7 +173,7 @@ func BenchmarkHashingMethods(b *testing.B) { b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { - resultString = builderString(meta, pubip) + resultString = builderString(meta) } }) @@ -182,7 +181,7 @@ func BenchmarkHashingMethods(b *testing.B) { b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { - resultString = fnvHashToString(meta, pubip) + resultString = fnvHashToString(meta) } }) @@ -190,7 +189,7 @@ func BenchmarkHashingMethods(b *testing.B) { b.ReportAllocs() b.ResetTimer() for i := 0; i < b.N; i++ { - resultUint = metaHash(meta, pubip) + resultUint = metaHash(meta) } }) @@ -198,29 +197,20 @@ func BenchmarkHashingMethods(b *testing.B) { _ = resultUint } -func fnvHashToString(meta nbpeer.PeerSystemMeta, pubip string) string { +func fnvHashToString(meta nbpeer.PeerSystemMeta) string { h := fnv.New64a() - if len(meta.NetworkAddresses) != 0 { - for _, na := range meta.NetworkAddresses { - h.Write([]byte(na.Mac)) - } - } - h.Write([]byte(meta.WtVersion)) h.Write([]byte(meta.OSVersion)) h.Write([]byte(meta.KernelVersion)) h.Write([]byte(meta.Hostname)) h.Write([]byte(meta.SystemSerialNumber)) - h.Write([]byte(pubip)) return strconv.FormatUint(h.Sum64(), 16) } -func builderString(meta nbpeer.PeerSystemMeta, pubip string) string { - mac := getMacAddress(meta.NetworkAddresses) - estimatedSize := len(meta.WtVersion) + len(meta.OSVersion) + len(meta.KernelVersion) + len(meta.Hostname) + len(meta.SystemSerialNumber) + - len(pubip) + len(mac) + 6 +func builderString(meta nbpeer.PeerSystemMeta) string { + estimatedSize := len(meta.WtVersion) + len(meta.OSVersion) + len(meta.KernelVersion) + len(meta.Hostname) + len(meta.SystemSerialNumber) + 4 var b strings.Builder b.Grow(estimatedSize) @@ -234,23 +224,10 @@ func builderString(meta nbpeer.PeerSystemMeta, pubip string) string { b.WriteString(meta.Hostname) b.WriteByte('|') b.WriteString(meta.SystemSerialNumber) - b.WriteByte('|') - b.WriteString(pubip) return b.String() } -func getMacAddress(nas []nbpeer.NetworkAddress) string { - if len(nas) == 0 { - return "" - } - macs := make([]string, 0, len(nas)) - for _, na := range nas { - macs = append(macs, na.Mac) - } - return strings.Join(macs, "/") -} - func BenchmarkLoginFilter_ParallelLoad(b *testing.B) { filter := newLoginFilterWithCfg(testAdvancedCfg()) numKeys := 100000 diff --git a/management/internals/shared/grpc/onetime_token.go b/management/internals/shared/grpc/onetime_token.go new file mode 100644 index 000000000..acfd6eafb --- /dev/null +++ b/management/internals/shared/grpc/onetime_token.go @@ -0,0 +1,129 @@ +package grpc + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "time" + + "github.com/eko/gocache/lib/v4/cache" + "github.com/eko/gocache/lib/v4/store" + log "github.com/sirupsen/logrus" +) + +type tokenMetadata struct { + ServiceID string + AccountID string + ExpiresAt time.Time + CreatedAt time.Time +} + +// OneTimeTokenStore manages single-use authentication tokens for proxy-to-management RPC. +// Supports both in-memory and Redis storage via NB_IDP_CACHE_REDIS_ADDRESS env var. +type OneTimeTokenStore struct { + cache *cache.Cache[string] + ctx context.Context +} + +// NewOneTimeTokenStore creates a token store using the provided shared cache store. +func NewOneTimeTokenStore(ctx context.Context, cacheStore store.StoreInterface) *OneTimeTokenStore { + return &OneTimeTokenStore{ + cache: cache.New[string](cacheStore), + ctx: ctx, + } +} + +// GenerateToken creates a new cryptographically secure one-time token +// with the specified TTL. The token is associated with a specific +// accountID and serviceID for validation purposes. +// +// Returns the generated token string or an error if random generation fails. +func (s *OneTimeTokenStore) GenerateToken(accountID, serviceID string, ttl time.Duration) (string, error) { + randomBytes := make([]byte, 32) + if _, err := rand.Read(randomBytes); err != nil { + return "", fmt.Errorf("failed to generate random token: %w", err) + } + + token := base64.URLEncoding.EncodeToString(randomBytes) + hashedToken := hashToken(token) + + metadata := &tokenMetadata{ + ServiceID: serviceID, + AccountID: accountID, + ExpiresAt: time.Now().Add(ttl), + CreatedAt: time.Now(), + } + + metadataJSON, err := json.Marshal(metadata) + if err != nil { + return "", fmt.Errorf("failed to serialize token metadata: %w", err) + } + + if err := s.cache.Set(s.ctx, hashedToken, string(metadataJSON), store.WithExpiration(ttl)); err != nil { + return "", fmt.Errorf("failed to store token: %w", err) + } + + log.Debugf("Generated one-time token for proxy %s in account %s (expires in %s)", + serviceID, accountID, ttl) + + return token, nil +} + +// ValidateAndConsume verifies the token against the provided accountID and +// serviceID, checks expiration, and then deletes it to enforce single-use. +// +// This method uses constant-time comparison to prevent timing attacks. +// +// Returns nil on success, or an error if: +// - Token doesn't exist +// - Token has expired +// - Account ID doesn't match +// - Reverse proxy ID doesn't match +func (s *OneTimeTokenStore) ValidateAndConsume(token, accountID, serviceID string) error { + hashedToken := hashToken(token) + + metadataJSON, err := s.cache.Get(s.ctx, hashedToken) + if err != nil { + log.Warnf("Token validation failed: token not found (proxy: %s, account: %s)", serviceID, accountID) + return fmt.Errorf("invalid token") + } + + metadata := &tokenMetadata{} + if err := json.Unmarshal([]byte(metadataJSON), metadata); err != nil { + log.Warnf("Token validation failed: failed to unmarshal metadata (proxy: %s, account: %s): %v", serviceID, accountID, err) + return fmt.Errorf("invalid token metadata") + } + + if time.Now().After(metadata.ExpiresAt) { + log.Warnf("Token validation failed: token expired (proxy: %s, account: %s)", serviceID, accountID) + return fmt.Errorf("token expired") + } + + if subtle.ConstantTimeCompare([]byte(metadata.AccountID), []byte(accountID)) != 1 { + log.Warnf("Token validation failed: account ID mismatch (expected: %s, got: %s)", metadata.AccountID, accountID) + return fmt.Errorf("account ID mismatch") + } + + if subtle.ConstantTimeCompare([]byte(metadata.ServiceID), []byte(serviceID)) != 1 { + log.Warnf("Token validation failed: service ID mismatch (expected: %s, got: %s)", metadata.ServiceID, serviceID) + return fmt.Errorf("service ID mismatch") + } + + if err := s.cache.Delete(s.ctx, hashedToken); err != nil { + log.Warnf("Token deletion warning (proxy: %s, account: %s): %v", serviceID, accountID, err) + } + + log.Infof("Token validated and consumed for proxy %s in account %s", serviceID, accountID) + + return nil +} + +func hashToken(token string) string { + hash := sha256.Sum256([]byte(token)) + return hex.EncodeToString(hash[:]) +} diff --git a/management/internals/shared/grpc/pkce_verifier.go b/management/internals/shared/grpc/pkce_verifier.go new file mode 100644 index 000000000..a1325256c --- /dev/null +++ b/management/internals/shared/grpc/pkce_verifier.go @@ -0,0 +1,54 @@ +package grpc + +import ( + "context" + "fmt" + "time" + + "github.com/eko/gocache/lib/v4/cache" + "github.com/eko/gocache/lib/v4/store" + log "github.com/sirupsen/logrus" +) + +// PKCEVerifierStore manages PKCE verifiers for OAuth flows. +// Supports both in-memory and Redis storage via NB_IDP_CACHE_REDIS_ADDRESS env var. +type PKCEVerifierStore struct { + cache *cache.Cache[string] + ctx context.Context +} + +// NewPKCEVerifierStore creates a PKCE verifier store using the provided shared cache store. +func NewPKCEVerifierStore(ctx context.Context, cacheStore store.StoreInterface) *PKCEVerifierStore { + return &PKCEVerifierStore{ + cache: cache.New[string](cacheStore), + ctx: ctx, + } +} + +// Store saves a PKCE verifier associated with an OAuth state parameter. +// The verifier is stored with the specified TTL and will be automatically deleted after expiration. +func (s *PKCEVerifierStore) Store(state, verifier string, ttl time.Duration) error { + if err := s.cache.Set(s.ctx, state, verifier, store.WithExpiration(ttl)); err != nil { + return fmt.Errorf("failed to store PKCE verifier: %w", err) + } + + log.Debugf("Stored PKCE verifier for state (expires in %s)", ttl) + return nil +} + +// LoadAndDelete retrieves and removes a PKCE verifier for the given state. +// Returns the verifier and true if found, or empty string and false if not found. +// This enforces single-use semantics for PKCE verifiers. +func (s *PKCEVerifierStore) LoadAndDelete(state string) (string, bool) { + verifier, err := s.cache.Get(s.ctx, state) + if err != nil { + log.Debugf("PKCE verifier not found for state") + return "", false + } + + if err := s.cache.Delete(s.ctx, state); err != nil { + log.Warnf("Failed to delete PKCE verifier for state: %v", err) + } + + return verifier, true +} diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go new file mode 100644 index 000000000..7100732aa --- /dev/null +++ b/management/internals/shared/grpc/proxy.go @@ -0,0 +1,2020 @@ +package grpc + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "io" + "math" + "net" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/google/uuid" + log "github.com/sirupsen/logrus" + "golang.org/x/oauth2" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/shared/management/domain" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + "github.com/netbirdio/netbird/management/internals/modules/peers" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/management/server/users" + proxyauth "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/shared/hash/argon2id" + "github.com/netbirdio/netbird/shared/management/proto" + nbstatus "github.com/netbirdio/netbird/shared/management/status" +) + +type ProxyOIDCConfig struct { + Issuer string + ClientID string + Scopes []string + CallbackURL string + HMACKey []byte + + Audience string + KeysLocation string +} + +// ProxyTokenChecker checks whether a proxy access token is still valid. +type ProxyTokenChecker interface { + IsProxyAccessTokenValid(ctx context.Context, tokenID string) (bool, error) +} + +// ProxyServiceServer implements the ProxyService gRPC server +// AgentNetworkSynthesizer produces in-memory reverse-proxy services from +// Agent Network provider/policy state for the proxy snapshot path; synthesised +// services never appear in the reverseproxy_services table. +type AgentNetworkSynthesizer interface { + SynthesizeServicesForCluster(ctx context.Context, clusterAddr string) ([]*rpservice.Service, error) + SynthesizeServicesForAccount(ctx context.Context, accountID string) ([]*rpservice.Service, error) + SynthesizeServiceForDomain(ctx context.Context, domain string) (*rpservice.Service, error) +} + +// AgentNetworkLimitsService is the minimal slice of agentnetwork.Manager the +// gRPC layer needs for CheckLLMPolicyLimits + RecordLLMUsage — kept narrow so +// the grpc package doesn't take a hard import on the full manager. +type AgentNetworkLimitsService interface { + SelectPolicyForRequest(ctx context.Context, in agentnetwork.PolicySelectionInput) (*agentnetwork.PolicySelectionResult, error) + RecordUsage(ctx context.Context, in agentnetwork.RecordUsageInput) error +} + +type ProxyServiceServer struct { + proto.UnimplementedProxyServiceServer + + // Map of connected proxies: proxy_id -> proxy connection + connectedProxies sync.Map + + // Manager for access logs + accessLogManager accesslogs.Manager + + mu sync.RWMutex + // Manager for reverse proxy operations + serviceManager rpservice.Manager + // agentNetworkSynth produces synthesised reverse-proxy services from + // Agent Network state. Optional — when nil the snapshot path only ships + // persisted services. + agentNetworkSynth AgentNetworkSynthesizer + // agentNetworkLimits handles the pre-flight selection (CheckLLMPolicyLimits) + // and the post-flight consumption write (RecordLLMUsage). Optional — when + // nil both RPCs return Unimplemented. + agentNetworkLimits AgentNetworkLimitsService + // ProxyController for service updates and cluster management + proxyController proxy.Controller + + // Manager for proxy connections + proxyManager proxy.Manager + + // Manager for peers + peersManager peers.Manager + + // Manager for users + usersManager users.Manager + + // Manager for IdP-enriched user data (may be nil when no IdP is configured) + idpManager idp.Manager + + // Store for one-time authentication tokens + tokenStore *OneTimeTokenStore + + // Checker for proxy access token validity + tokenChecker ProxyTokenChecker + + // OIDC configuration for proxy authentication + oidcConfig ProxyOIDCConfig + + // Store for PKCE verifiers + pkceVerifierStore *PKCEVerifierStore + + // tokenTTL is the lifetime of one-time tokens generated for proxy + // authentication. Defaults to defaultProxyTokenTTL when zero. + tokenTTL time.Duration + + // snapshotBatchSize is the number of mappings per gRPC message during + // initial snapshot delivery. Configurable via NB_PROXY_SNAPSHOT_BATCH_SIZE. + snapshotBatchSize int + + cancel context.CancelFunc +} + +const pkceVerifierTTL = 10 * time.Minute + +const defaultProxyTokenTTL = 5 * time.Minute + +const defaultSnapshotBatchSize = 500 + +func snapshotBatchSizeFromEnv() int { + if v := os.Getenv("NB_PROXY_SNAPSHOT_BATCH_SIZE"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return n + } + } + return defaultSnapshotBatchSize +} + +// proxyTokenTTL returns the configured token TTL or the default when unset. +func (s *ProxyServiceServer) proxyTokenTTL() time.Duration { + if s.tokenTTL > 0 { + return s.tokenTTL + } + return defaultProxyTokenTTL +} + +// proxyConnection represents a connected proxy +type proxyConnection struct { + proxyID string + sessionID string + address string + accountID *string + tokenID string + capabilities *proto.ProxyCapabilities + stream proto.ProxyService_GetMappingUpdateServer + // syncStream is set when the proxy connected via SyncMappings. + // When non-nil, the sender goroutine uses this instead of stream. + syncStream proto.ProxyService_SyncMappingsServer + sendChan chan *proto.GetMappingUpdateResponse + ctx context.Context + cancel context.CancelFunc +} + +func enforceAccountScope(ctx context.Context, requestAccountID string) error { + token := GetProxyTokenFromContext(ctx) + if token == nil || token.AccountID == nil { + return nil + } + if requestAccountID == "" || *token.AccountID != requestAccountID { + return status.Errorf(codes.PermissionDenied, "account-scoped token cannot access account %s", requestAccountID) + } + return nil +} + +// NewProxyServiceServer creates a new proxy service server. +func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeTokenStore, pkceStore *PKCEVerifierStore, oidcConfig ProxyOIDCConfig, peersManager peers.Manager, usersManager users.Manager, idpManager idp.Manager, proxyMgr proxy.Manager, tokenChecker ProxyTokenChecker) *ProxyServiceServer { + ctx, cancel := context.WithCancel(context.Background()) + s := &ProxyServiceServer{ + accessLogManager: accessLogMgr, + oidcConfig: oidcConfig, + tokenStore: tokenStore, + pkceVerifierStore: pkceStore, + peersManager: peersManager, + usersManager: usersManager, + idpManager: idpManager, + proxyManager: proxyMgr, + tokenChecker: tokenChecker, + snapshotBatchSize: snapshotBatchSizeFromEnv(), + cancel: cancel, + } + go s.cleanupStaleProxies(ctx) + return s +} + +// cleanupStaleProxies periodically removes proxies that haven't sent heartbeat in 10 minutes +func (s *ProxyServiceServer) cleanupStaleProxies(ctx context.Context) { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := s.proxyManager.CleanupStale(ctx, 1*time.Hour); err != nil { + log.WithContext(ctx).Debugf("Failed to cleanup stale proxies: %v", err) + } + } + } +} + +// Close stops background goroutines. +func (s *ProxyServiceServer) Close() { + s.cancel() +} + +// SetServiceManager sets the service manager. Must be called before serving. +func (s *ProxyServiceServer) SetServiceManager(manager rpservice.Manager) { + s.mu.Lock() + defer s.mu.Unlock() + s.serviceManager = manager +} + +// SetAgentNetworkSynthesizer wires the agent-network service synthesiser. +// Optional — when nil the snapshot path skips agent-network synthesis. The +// modules layer injects this after both the proxy server and the agent-network +// manager are constructed. +func (s *ProxyServiceServer) SetAgentNetworkSynthesizer(synth AgentNetworkSynthesizer) { + s.mu.Lock() + s.agentNetworkSynth = synth + s.mu.Unlock() +} + +// SetAgentNetworkLimitsService wires the policy-selection + post-flight +// consumption sink. Pass nil to disable; both RPCs return Unimplemented while +// unset so partial wiring surfaces during integration. +func (s *ProxyServiceServer) SetAgentNetworkLimitsService(svc AgentNetworkLimitsService) { + s.mu.Lock() + s.agentNetworkLimits = svc + s.mu.Unlock() +} + +// agentNetworkSynthesizer returns the synthesiser under read lock. +func (s *ProxyServiceServer) agentNetworkSynthesizer() AgentNetworkSynthesizer { + s.mu.RLock() + defer s.mu.RUnlock() + return s.agentNetworkSynth +} + +// CheckLLMPolicyLimits is the pre-flight policy gate the proxy calls before +// forwarding an LLM request upstream. Delegates to the agent-network selector, +// which scores applicable policies by remaining headroom and returns the +// policy that pays for this request (or a deny when all are exhausted). +func (s *ProxyServiceServer) CheckLLMPolicyLimits(ctx context.Context, req *proto.CheckLLMPolicyLimitsRequest) (*proto.CheckLLMPolicyLimitsResponse, error) { + s.mu.RLock() + svc := s.agentNetworkLimits + s.mu.RUnlock() + if svc == nil { + return nil, status.Errorf(codes.Unimplemented, "agent-network limits service not configured on management") + } + if req.GetAccountId() == "" { + return nil, status.Errorf(codes.InvalidArgument, "account_id is required") + } + if err := enforceAccountScope(ctx, req.GetAccountId()); err != nil { + return nil, err + } + + res, err := svc.SelectPolicyForRequest(ctx, agentnetwork.PolicySelectionInput{ + AccountID: req.GetAccountId(), + UserID: req.GetUserId(), + GroupIDs: req.GetGroupIds(), + ProviderID: req.GetProviderId(), + }) + if err != nil { + log.WithContext(ctx).Errorf("select policy for request: %v", err) + return nil, status.Error(codes.Internal, "select policy failed") + } + + if !res.Allow { + return &proto.CheckLLMPolicyLimitsResponse{ + Decision: "deny", + SelectedPolicyId: res.SelectedPolicyID, + AttributionGroupId: res.AttributionGroupID, + WindowSeconds: res.WindowSeconds, + DenyCode: res.DenyCode, + DenyReason: res.DenyReason, + }, nil + } + return &proto.CheckLLMPolicyLimitsResponse{ + Decision: "allow", + SelectedPolicyId: res.SelectedPolicyID, + AttributionGroupId: res.AttributionGroupID, + WindowSeconds: res.WindowSeconds, + }, nil +} + +// RecordLLMUsage increments the per-(dimension, window) consumption counter for +// the user and optional attribution group after a served request. Returns +// Unimplemented when the agent-network limits service hasn't been wired. +func (s *ProxyServiceServer) RecordLLMUsage(ctx context.Context, req *proto.RecordLLMUsageRequest) (*proto.RecordLLMUsageResponse, error) { + s.mu.RLock() + svc := s.agentNetworkLimits + s.mu.RUnlock() + if svc == nil { + return nil, status.Errorf(codes.Unimplemented, "agent-network limits service not configured on management") + } + + accountID := req.GetAccountId() + if accountID == "" { + return nil, status.Errorf(codes.InvalidArgument, "account_id is required") + } + if err := enforceAccountScope(ctx, accountID); err != nil { + return nil, err + } + tokensIn := req.GetTokensInput() + tokensOut := req.GetTokensOutput() + costUSD := req.GetCostUsd() + + // Reject impossible counters at the boundary instead of recording them: + // a negative window, negative tokens, or a negative / non-finite cost + // would otherwise decrement or poison the persisted consumption totals. + if req.GetWindowSeconds() < 0 || tokensIn < 0 || tokensOut < 0 || costUSD < 0 || math.IsNaN(costUSD) || math.IsInf(costUSD, 0) { + return nil, status.Errorf(codes.InvalidArgument, "usage counters must be non-negative and finite") + } + + // Book the policy-window dimensions (when a policy cap bound this request) + // and every applicable account budget rule's window in a single batched + // transaction. + if err := svc.RecordUsage(ctx, agentnetwork.RecordUsageInput{ + AccountID: accountID, + UserID: req.GetUserId(), + AttributionGroupID: req.GetGroupId(), + GroupIDs: req.GetGroupIds(), + WindowSeconds: req.GetWindowSeconds(), + TokensIn: tokensIn, + TokensOut: tokensOut, + CostUSD: costUSD, + }); err != nil { + log.WithContext(ctx).Errorf("record usage: %v", err) + return nil, status.Error(codes.Internal, "record usage failed") + } + return &proto.RecordLLMUsageResponse{}, nil +} + +// SetProxyController sets the proxy controller. Must be called before serving. +func (s *ProxyServiceServer) SetProxyController(proxyController proxy.Controller) { + s.mu.Lock() + defer s.mu.Unlock() + s.proxyController = proxyController +} + +// proxyConnectParams holds the validated parameters extracted from either +// a GetMappingUpdateRequest or a SyncMappingsInit message. +type proxyConnectParams struct { + proxyID string + address string + capabilities *proto.ProxyCapabilities +} + +// GetMappingUpdate handles the control stream with proxy clients +func (s *ProxyServiceServer) GetMappingUpdate(req *proto.GetMappingUpdateRequest, stream proto.ProxyService_GetMappingUpdateServer) error { + params, err := s.validateProxyConnect(req.GetProxyId(), req.GetAddress(), stream.Context()) + if err != nil { + return err + } + params.capabilities = req.GetCapabilities() + + conn, proxyRecord, err := s.registerProxyConnection(stream.Context(), params, &proxyConnection{ + stream: stream, + }) + if err != nil { + return err + } + + if err := s.sendSnapshot(stream.Context(), conn); err != nil { + s.cleanupFailedSnapshot(stream.Context(), conn) + return fmt.Errorf("send snapshot to proxy %s: %w", params.proxyID, err) + } + + errChan := make(chan error, 2) + go s.sender(conn, errChan) + + return s.serveProxyConnection(conn, proxyRecord, errChan, false) +} + +// SyncMappings implements the bidirectional SyncMappings RPC. +// It mirrors GetMappingUpdate but provides application-level back-pressure: +// management waits for an ack from the proxy before sending the next batch. +func (s *ProxyServiceServer) SyncMappings(stream proto.ProxyService_SyncMappingsServer) error { + init, err := recvSyncInit(stream) + if err != nil { + return err + } + + params, err := s.validateProxyConnect(init.GetProxyId(), init.GetAddress(), stream.Context()) + if err != nil { + return err + } + params.capabilities = init.GetCapabilities() + + conn, proxyRecord, err := s.registerProxyConnection(stream.Context(), params, &proxyConnection{ + syncStream: stream, + }) + if err != nil { + return err + } + + if err := s.sendSnapshotSync(stream.Context(), conn, stream); err != nil { + s.cleanupFailedSnapshot(stream.Context(), conn) + return fmt.Errorf("send snapshot to proxy %s: %w", params.proxyID, err) + } + + errChan := make(chan error, 2) + go s.sender(conn, errChan) + go s.drainRecv(stream, errChan) + + return s.serveProxyConnection(conn, proxyRecord, errChan, true) +} + +// recvSyncInit receives and validates the first message on a SyncMappings stream. +func recvSyncInit(stream proto.ProxyService_SyncMappingsServer) (*proto.SyncMappingsInit, error) { + firstMsg, err := stream.Recv() + if err != nil { + return nil, status.Errorf(codes.Internal, "receive init: %v", err) + } + init := firstMsg.GetInit() + if init == nil { + return nil, status.Errorf(codes.InvalidArgument, "first message must be init") + } + return init, nil +} + +// validateProxyConnect validates the proxy ID and address, and checks cluster +// address availability for account-scoped tokens. +func (s *ProxyServiceServer) validateProxyConnect(proxyID, address string, ctx context.Context) (proxyConnectParams, error) { + if proxyID == "" { + return proxyConnectParams{}, status.Errorf(codes.InvalidArgument, "proxy_id is required") + } + if !isProxyAddressValid(address) { + return proxyConnectParams{}, status.Errorf(codes.InvalidArgument, "proxy address is invalid") + } + + token := GetProxyTokenFromContext(ctx) + if token != nil && token.AccountID != nil { + available, err := s.proxyManager.IsClusterAddressAvailable(ctx, address, *token.AccountID) + if err != nil { + return proxyConnectParams{}, status.Errorf(codes.Internal, "check cluster address: %v", err) + } + if !available { + return proxyConnectParams{}, status.Errorf(codes.AlreadyExists, "cluster address %s is already in use", address) + } + } + + return proxyConnectParams{proxyID: proxyID, address: address}, nil +} + +// registerProxyConnection creates a proxyConnection, registers it with the +// proxy manager and cluster, and stores it in connectedProxies. The caller +// provides a partially initialised connSeed with stream-specific fields set; +// the remaining fields are filled in here. +func (s *ProxyServiceServer) registerProxyConnection(ctx context.Context, params proxyConnectParams, connSeed *proxyConnection) (*proxyConnection, *proxy.Proxy, error) { + peerInfo := PeerIPFromContext(ctx) + + var accountID *string + var tokenID string + if token := GetProxyTokenFromContext(ctx); token != nil { + if token.AccountID != nil { + accountID = token.AccountID + } + tokenID = token.ID + } + + sessionID := uuid.NewString() + s.supersedePriorConnection(params.proxyID, sessionID) + + connCtx, cancel := context.WithCancel(ctx) + connSeed.proxyID = params.proxyID + connSeed.sessionID = sessionID + connSeed.address = params.address + connSeed.accountID = accountID + connSeed.tokenID = tokenID + connSeed.capabilities = params.capabilities + connSeed.sendChan = make(chan *proto.GetMappingUpdateResponse, 100) + connSeed.ctx = connCtx + connSeed.cancel = cancel + + var caps *proxy.Capabilities + if c := params.capabilities; c != nil { + caps = &proxy.Capabilities{ + SupportsCustomPorts: c.SupportsCustomPorts, + RequireSubdomain: c.RequireSubdomain, + SupportsCrowdsec: c.SupportsCrowdsec, + Private: c.Private, + } + } + + proxyRecord, err := s.proxyManager.Connect(ctx, params.proxyID, sessionID, params.address, peerInfo, accountID, caps) + if err != nil { + cancel() + if accountID != nil { + return nil, nil, status.Errorf(codes.Internal, "failed to register BYOP proxy: %v", err) + } + log.WithContext(ctx).Warnf("failed to register proxy %s in database: %v", params.proxyID, err) + return nil, nil, status.Errorf(codes.Internal, "register proxy in database: %v", err) + } + + s.connectedProxies.Store(params.proxyID, connSeed) + if err := s.proxyController.RegisterProxyToCluster(ctx, params.address, params.proxyID); err != nil { + log.WithContext(ctx).Warnf("Failed to register proxy %s in cluster: %v", params.proxyID, err) + } + + return connSeed, proxyRecord, nil +} + +// supersedePriorConnection cancels any existing connection for the given proxy. +func (s *ProxyServiceServer) supersedePriorConnection(proxyID, newSessionID string) { + if old, loaded := s.connectedProxies.Load(proxyID); loaded { + oldConn := old.(*proxyConnection) + log.WithFields(log.Fields{ + "proxy_id": proxyID, + "old_session_id": oldConn.sessionID, + "new_session_id": newSessionID, + }).Info("Superseding existing proxy connection") + oldConn.cancel() + } +} + +// cleanupFailedSnapshot removes the connection from the cluster and store +// after a snapshot send failure. +func (s *ProxyServiceServer) cleanupFailedSnapshot(ctx context.Context, conn *proxyConnection) { + if s.connectedProxies.CompareAndDelete(conn.proxyID, conn) { + if err := s.proxyController.UnregisterProxyFromCluster(context.Background(), conn.address, conn.proxyID); err != nil { + log.WithContext(ctx).Debugf("cleanup after snapshot failure for proxy %s: %v", conn.proxyID, err) + } + } + conn.cancel() + if err := s.proxyManager.Disconnect(context.Background(), conn.proxyID, conn.sessionID); err != nil { + log.WithContext(ctx).Debugf("cleanup after snapshot failure for proxy %s: %v", conn.proxyID, err) + } +} + +// drainRecv consumes and discards messages from a bidirectional stream. +// The proxy sends an ack for every incremental update; we don't need them +// after the snapshot phase. Recv errors are forwarded to errChan. +func (s *ProxyServiceServer) drainRecv(stream proto.ProxyService_SyncMappingsServer, errChan chan<- error) { + for { + if _, err := stream.Recv(); err != nil { + errChan <- err + return + } + } +} + +// serveProxyConnection runs the post-snapshot lifecycle: heartbeat, sender, +// and wait for termination. When bidi is true, normal stream closure (EOF, +// canceled) is treated as a clean disconnect rather than an error. +func (s *ProxyServiceServer) serveProxyConnection(conn *proxyConnection, proxyRecord *proxy.Proxy, errChan <-chan error, bidi bool) error { + log.WithFields(log.Fields{ + "proxy_id": conn.proxyID, + "session_id": conn.sessionID, + "address": conn.address, + "cluster_addr": conn.address, + "account_id": conn.accountID, + "total_proxies": len(s.GetConnectedProxies()), + }).Info("Proxy registered in cluster") + + defer s.disconnectProxy(conn) + go s.heartbeat(conn.ctx, conn, proxyRecord) + + select { + case err := <-errChan: + if bidi && isStreamClosed(err) { + log.Infof("Proxy %s stream closed", conn.proxyID) + return nil + } + log.Warnf("Failed to send update: %v", err) + return fmt.Errorf("send update to proxy %s: %w", conn.proxyID, err) + case <-conn.ctx.Done(): + log.Infof("Proxy %s context canceled", conn.proxyID) + return conn.ctx.Err() + } +} + +// disconnectProxy removes the connection from cluster and store, unless it +// has already been superseded by a newer connection. +func (s *ProxyServiceServer) disconnectProxy(conn *proxyConnection) { + if !s.connectedProxies.CompareAndDelete(conn.proxyID, conn) { + log.Infof("Proxy %s session %s: skipping cleanup, superseded by new connection", conn.proxyID, conn.sessionID) + conn.cancel() + return + } + + if err := s.proxyController.UnregisterProxyFromCluster(context.Background(), conn.address, conn.proxyID); err != nil { + log.Warnf("Failed to unregister proxy %s from cluster: %v", conn.proxyID, err) + } + conn.cancel() + if err := s.proxyManager.Disconnect(context.Background(), conn.proxyID, conn.sessionID); err != nil { + log.Warnf("Failed to mark proxy %s as disconnected: %v", conn.proxyID, err) + } + + log.Infof("Proxy %s session %s disconnected", conn.proxyID, conn.sessionID) +} + +// sendSnapshotSync sends the initial snapshot with back-pressure: it sends +// one batch, then waits for the proxy to ack before sending the next. +func (s *ProxyServiceServer) sendSnapshotSync(ctx context.Context, conn *proxyConnection, stream proto.ProxyService_SyncMappingsServer) error { + if !isProxyAddressValid(conn.address) { + return fmt.Errorf("proxy address is invalid") + } + if s.snapshotBatchSize <= 0 { + return fmt.Errorf("invalid snapshot batch size: %d", s.snapshotBatchSize) + } + + mappings, err := s.snapshotServiceMappings(ctx, conn) + if err != nil { + return err + } + + for i := 0; i < len(mappings); i += s.snapshotBatchSize { + end := i + s.snapshotBatchSize + if end > len(mappings) { + end = len(mappings) + } + for _, m := range mappings[i:end] { + token, err := s.tokenStore.GenerateToken(m.AccountId, m.Id, s.proxyTokenTTL()) + if err != nil { + return fmt.Errorf("generate auth token for service %s: %w", m.Id, err) + } + m.AuthToken = token + } + if err := stream.Send(&proto.SyncMappingsResponse{ + Mapping: mappings[i:end], + InitialSyncComplete: end == len(mappings), + }); err != nil { + return fmt.Errorf("send snapshot batch: %w", err) + } + + if err := waitForAck(stream); err != nil { + return err + } + } + + if len(mappings) == 0 { + if err := stream.Send(&proto.SyncMappingsResponse{ + InitialSyncComplete: true, + }); err != nil { + return fmt.Errorf("send snapshot completion: %w", err) + } + + if err := waitForAck(stream); err != nil { + return err + } + } + + return nil +} + +func waitForAck(stream proto.ProxyService_SyncMappingsServer) error { + msg, err := stream.Recv() + if err != nil { + return fmt.Errorf("receive ack: %w", err) + } + if msg.GetAck() == nil { + return fmt.Errorf("expected ack, got %T", msg.GetMsg()) + } + return nil +} + +// heartbeat updates the proxy's last_seen timestamp every minute and +// disconnects the proxy if its access token has been revoked. +func (s *ProxyServiceServer) heartbeat(ctx context.Context, conn *proxyConnection, p *proxy.Proxy) { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := s.proxyManager.Heartbeat(ctx, p); err != nil { + log.WithContext(ctx).Debugf("Failed to update proxy %s heartbeat: %v", p.ID, err) + } + + if conn.tokenID != "" && s.tokenChecker != nil { + valid, err := s.tokenChecker.IsProxyAccessTokenValid(ctx, conn.tokenID) + if err != nil { + log.WithContext(ctx).Warnf("failed to check token validity for proxy %s: %v", conn.proxyID, err) + continue + } + if !valid { + log.WithContext(ctx).Warnf("proxy %s token revoked or expired, disconnecting", conn.proxyID) + conn.cancel() + return + } + } + case <-ctx.Done(): + log.WithContext(ctx).Infof("proxy %s heartbeat stopped: context canceled", p.ID) + return + } + } +} + +func (s *ProxyServiceServer) sendSnapshot(ctx context.Context, conn *proxyConnection) error { + if !isProxyAddressValid(conn.address) { + return fmt.Errorf("proxy address is invalid") + } + if s.snapshotBatchSize <= 0 { + return fmt.Errorf("invalid snapshot batch size: %d", s.snapshotBatchSize) + } + + mappings, err := s.snapshotServiceMappings(ctx, conn) + if err != nil { + return err + } + + // Send mappings in batches to reduce per-message gRPC overhead while + // staying well within the default 4 MB message size limit. + for i := 0; i < len(mappings); i += s.snapshotBatchSize { + end := i + s.snapshotBatchSize + if end > len(mappings) { + end = len(mappings) + } + for _, m := range mappings[i:end] { + token, err := s.tokenStore.GenerateToken(m.AccountId, m.Id, s.proxyTokenTTL()) + if err != nil { + return fmt.Errorf("generate auth token for service %s: %w", m.Id, err) + } + m.AuthToken = token + } + if err := conn.stream.Send(&proto.GetMappingUpdateResponse{ + Mapping: mappings[i:end], + InitialSyncComplete: end == len(mappings), + }); err != nil { + return fmt.Errorf("send snapshot batch: %w", err) + } + } + + if len(mappings) == 0 { + if err := conn.stream.Send(&proto.GetMappingUpdateResponse{ + InitialSyncComplete: true, + }); err != nil { + return fmt.Errorf("send snapshot completion: %w", err) + } + } + + return nil +} + +func (s *ProxyServiceServer) snapshotServiceMappings(ctx context.Context, conn *proxyConnection) ([]*proto.ProxyMapping, error) { + var services []*rpservice.Service + var err error + if conn.accountID != nil { + services, err = s.serviceManager.GetAccountServices(ctx, *conn.accountID) + } else { + services, err = s.serviceManager.GetGlobalServices(ctx) + } + if err != nil { + return nil, fmt.Errorf("get services from store: %w", err) + } + + if synth := s.agentNetworkSynthesizer(); synth != nil { + var synthesised []*rpservice.Service + var serr error + // Account-scoped connections synthesise only their own account, so the + // snapshot can never carry another tenant's mappings (which embed the + // upstream auth header derived from that tenant's provider API key). + // Global connections still see the whole cluster. + if conn.accountID != nil { + synthesised, serr = synth.SynthesizeServicesForAccount(ctx, *conn.accountID) + } else { + synthesised, serr = synth.SynthesizeServicesForCluster(ctx, conn.address) + } + if serr != nil { + // Surface a real synthesis failure instead of silently shipping an + // incomplete snapshot (which would drop the account's agent-network + // routes). Consistent with the persisted-services error above; the + // proxy retries the snapshot on connection error. + return nil, fmt.Errorf("synthesise agent-network services: %w", serr) + } + services = append(services, synthesised...) + } + + oidcCfg := s.GetOIDCValidationConfig() + var mappings []*proto.ProxyMapping + for _, service := range services { + if !service.Enabled || service.ProxyCluster == "" || service.ProxyCluster != conn.address { + continue + } + // Defense in depth: an account-scoped proxy must never receive another + // account's mapping, matching the per-account filtering the incremental + // update path already applies. + if conn.accountID != nil && service.AccountID != *conn.accountID { + continue + } + + m := service.ToProtoMapping(rpservice.Create, "", oidcCfg) + if !proxyAcceptsMapping(conn, m) { + continue + } + mappings = append(mappings, m) + } + return mappings, nil +} + +// isProxyAddressValid validates a proxy address (domain name or IP address) +func isProxyAddressValid(addr string) bool { + if addr == "" { + return false + } + if net.ParseIP(addr) != nil { + return true + } + _, err := domain.ValidateDomains([]string{addr}) + return err == nil +} + +// isStreamClosed returns true for errors that indicate normal stream +// termination: io.EOF, context cancellation, or gRPC Canceled. +func isStreamClosed(err error) bool { + if err == nil { + return false + } + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) { + return true + } + return status.Code(err) == codes.Canceled +} + +// sender handles sending messages to proxy. +// When conn.syncStream is set the message is sent as SyncMappingsResponse; +// otherwise the legacy GetMappingUpdateResponse stream is used. +func (s *ProxyServiceServer) sender(conn *proxyConnection, errChan chan<- error) { + for { + select { + case resp := <-conn.sendChan: + if err := conn.sendResponse(resp); err != nil { + errChan <- err + log.WithContext(conn.ctx).Tracef("Failed to send response to proxy %s: %v", conn.proxyID, err) + return + } + log.WithContext(conn.ctx).Tracef("Send response to proxy %s", conn.proxyID) + case <-conn.ctx.Done(): + return + } + } +} + +// sendResponse sends a mapping update on whichever stream the proxy connected with. +func (conn *proxyConnection) sendResponse(resp *proto.GetMappingUpdateResponse) error { + if conn.syncStream != nil { + return conn.syncStream.Send(&proto.SyncMappingsResponse{ + Mapping: resp.Mapping, + InitialSyncComplete: resp.InitialSyncComplete, + }) + } + return conn.stream.Send(resp) +} + +// SendAccessLog processes access log from proxy +func (s *ProxyServiceServer) SendAccessLog(ctx context.Context, req *proto.SendAccessLogRequest) (*proto.SendAccessLogResponse, error) { + accessLog := req.GetLog() + + if err := enforceAccountScope(ctx, accessLog.GetAccountId()); err != nil { + return nil, err + } + + fields := log.Fields{ + "service_id": accessLog.GetServiceId(), + "account_id": accessLog.GetAccountId(), + "host": accessLog.GetHost(), + "source_ip": accessLog.GetSourceIp(), + } + if mechanism := accessLog.GetAuthMechanism(); mechanism != "" { + fields["auth_mechanism"] = mechanism + } + if userID := accessLog.GetUserId(); userID != "" { + fields["user_id"] = userID + } + if !accessLog.GetAuthSuccess() { + fields["auth_success"] = false + } + log.WithFields(fields).Debugf("%s %s %d (%dms)", + accessLog.GetMethod(), + accessLog.GetPath(), + accessLog.GetResponseCode(), + accessLog.GetDurationMs(), + ) + + logEntry := &accesslogs.AccessLogEntry{} + logEntry.FromProto(accessLog) + + if err := s.accessLogManager.SaveAccessLog(ctx, logEntry); err != nil { + log.WithContext(ctx).Errorf("failed to save access log: %v", err) + return nil, status.Errorf(codes.Internal, "save access log: %v", err) + } + + return &proto.SendAccessLogResponse{}, nil +} + +// SendServiceUpdate broadcasts a service update to all connected proxy servers. +// Management should call this when services are created/updated/removed. +// For create/update operations a unique one-time auth token is generated per +// proxy so that every replica can independently authenticate with management. +// BYOP proxies only receive updates for their own account's services. +func (s *ProxyServiceServer) SendServiceUpdate(update *proto.GetMappingUpdateResponse) { + log.Debugf("Broadcasting service update to all connected proxy servers") + updateAccountIDs := make(map[string]struct{}) + for _, m := range update.Mapping { + if m.AccountId != "" { + updateAccountIDs[m.AccountId] = struct{}{} + } + } + s.connectedProxies.Range(func(key, value interface{}) bool { + conn := value.(*proxyConnection) + connUpdate := update + if conn.accountID != nil && len(updateAccountIDs) > 0 { + if _, ok := updateAccountIDs[*conn.accountID]; !ok { + return true + } + filtered := filterMappingsForAccount(update.Mapping, *conn.accountID) + if len(filtered) == 0 { + return true + } + connUpdate = &proto.GetMappingUpdateResponse{ + Mapping: filtered, + InitialSyncComplete: update.InitialSyncComplete, + } + } + // Drop mappings the proxy lacks capability for (e.g. private without SupportsPrivateService). + connUpdate = filterMappingsForProxy(conn, connUpdate) + if connUpdate == nil || len(connUpdate.Mapping) == 0 { + return true + } + resp := s.perProxyMessage(connUpdate, conn.proxyID) + if resp == nil { + log.Warnf("Token generation failed for proxy %s, disconnecting to force resync", conn.proxyID) + conn.cancel() + return true + } + select { + case conn.sendChan <- resp: + log.Debugf("Sent service update to proxy server %s", conn.proxyID) + default: + log.Warnf("Send channel full for proxy %s, disconnecting to force resync", conn.proxyID) + conn.cancel() + } + return true + }) +} + +// ForceDisconnect cancels the gRPC stream for a connected proxy, causing it to disconnect. +func (s *ProxyServiceServer) ForceDisconnect(proxyID string) { + if connVal, ok := s.connectedProxies.Load(proxyID); ok { + conn := connVal.(*proxyConnection) + conn.cancel() + s.connectedProxies.Delete(proxyID) + log.WithFields(log.Fields{"proxyID": proxyID}).Info("force disconnected proxy") + } +} + +func filterMappingsForAccount(mappings []*proto.ProxyMapping, accountID string) []*proto.ProxyMapping { + var filtered []*proto.ProxyMapping + for _, m := range mappings { + if m.AccountId == accountID { + filtered = append(filtered, m) + } + } + return filtered +} + +// GetConnectedProxies returns a list of connected proxy IDs +func (s *ProxyServiceServer) GetConnectedProxies() []string { + var proxies []string + s.connectedProxies.Range(func(key, value interface{}) bool { + proxies = append(proxies, key.(string)) + return true + }) + return proxies +} + +// GetConnectedProxyURLs returns a deduplicated list of URLs from all connected proxies. +func (s *ProxyServiceServer) GetConnectedProxyURLs() []string { + seenUrls := make(map[string]struct{}) + var urls []string + var proxyCount int + s.connectedProxies.Range(func(key, value interface{}) bool { + proxyCount++ + conn := value.(*proxyConnection) + log.WithFields(log.Fields{ + "proxy_id": conn.proxyID, + "address": conn.address, + }).Debug("checking connected proxy for URL") + if _, seen := seenUrls[conn.address]; conn.address != "" && !seen { + seenUrls[conn.address] = struct{}{} + urls = append(urls, conn.address) + } + return true + }) + log.WithFields(log.Fields{ + "total_proxies": proxyCount, + "unique_urls": len(urls), + "connected_urls": urls, + }).Debug("GetConnectedProxyURLs result") + return urls +} + +// SendServiceUpdateToCluster sends a service update to all proxy servers in a specific cluster. +// If clusterAddr is empty, broadcasts to all connected proxy servers (backward compatibility). +// For create/update operations a unique one-time auth token is generated per +// proxy so that every replica can independently authenticate with management. +func (s *ProxyServiceServer) SendServiceUpdateToCluster(ctx context.Context, update *proto.ProxyMapping, clusterAddr string) { + updateResponse := &proto.GetMappingUpdateResponse{ + Mapping: []*proto.ProxyMapping{update}, + } + + if clusterAddr == "" { + s.SendServiceUpdate(updateResponse) + return + } + + if s.proxyController == nil { + log.WithContext(ctx).Debugf("ProxyController not set, cannot send to cluster %s", clusterAddr) + return + } + + proxyIDs := s.proxyController.GetProxiesForCluster(clusterAddr) + if len(proxyIDs) == 0 { + log.WithContext(ctx).Debugf("No proxies connected for cluster %s", clusterAddr) + return + } + + log.Debugf("Sending service update to cluster %s", clusterAddr) + for _, proxyID := range proxyIDs { + connVal, ok := s.connectedProxies.Load(proxyID) + if !ok { + continue + } + conn := connVal.(*proxyConnection) + if conn.accountID != nil && update.AccountId != "" && *conn.accountID != update.AccountId { + continue + } + if !proxyAcceptsMapping(conn, update) { + log.WithContext(ctx).Debugf("Skipping proxy %s: does not support custom ports for mapping %s", proxyID, update.Id) + continue + } + msg := s.perProxyMessage(updateResponse, proxyID) + if msg == nil { + log.WithContext(ctx).Warnf("Token generation failed for proxy %s in cluster %s, disconnecting to force resync", proxyID, clusterAddr) + conn.cancel() + continue + } + select { + case conn.sendChan <- msg: + log.WithContext(ctx).Debugf("Sent service update with id %s to proxy %s in cluster %s", update.Id, proxyID, clusterAddr) + default: + log.WithContext(ctx).Warnf("Send channel full for proxy %s in cluster %s, disconnecting to force resync", proxyID, clusterAddr) + conn.cancel() + } + } +} + +// proxyAcceptsMapping returns whether the proxy can receive this mapping. +// Private mappings require SupportsPrivateService; custom-port L4 mappings +// require SupportsCustomPorts. Remove operations always pass so proxies can +// clean up. +func proxyAcceptsMapping(conn *proxyConnection, mapping *proto.ProxyMapping) bool { + if mapping.Type == proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED { + return true + } + if mapping.GetPrivate() { + caps := conn.capabilities + if caps == nil || caps.SupportsPrivateService == nil || !*caps.SupportsPrivateService { + return false + } + } + if mapping.ListenPort == 0 || mapping.Mode == "tls" { + return true + } + // Old proxies that never reported capabilities don't understand + // custom port mappings. + return conn.capabilities != nil && conn.capabilities.SupportsCustomPorts != nil +} + +// filterMappingsForProxy drops mappings the proxy cannot safely receive +// (e.g. private mappings to a proxy without SupportsPrivateService). +// Returns the input unchanged when no filtering is needed. +func filterMappingsForProxy(conn *proxyConnection, update *proto.GetMappingUpdateResponse) *proto.GetMappingUpdateResponse { + if update == nil || len(update.Mapping) == 0 { + return update + } + kept := make([]*proto.ProxyMapping, 0, len(update.Mapping)) + for _, m := range update.Mapping { + if !proxyAcceptsMapping(conn, m) { + continue + } + kept = append(kept, m) + } + if len(kept) == len(update.Mapping) { + return update + } + return &proto.GetMappingUpdateResponse{ + Mapping: kept, + InitialSyncComplete: update.InitialSyncComplete, + } +} + +// perProxyMessage returns a copy of update with a fresh one-time token for +// create/update operations. For delete operations the original mapping is +// used unchanged because proxies do not need to authenticate for removal. +// Returns nil if token generation fails; the caller must disconnect the +// proxy so it can resync via a fresh snapshot on reconnect. +func (s *ProxyServiceServer) perProxyMessage(update *proto.GetMappingUpdateResponse, proxyID string) *proto.GetMappingUpdateResponse { + resp := make([]*proto.ProxyMapping, 0, len(update.Mapping)) + for _, mapping := range update.Mapping { + if mapping.Type == proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED { + resp = append(resp, mapping) + continue + } + + token, err := s.tokenStore.GenerateToken(mapping.AccountId, mapping.Id, s.proxyTokenTTL()) + if err != nil { + log.Warnf("Failed to generate token for proxy %s: %v", proxyID, err) + return nil + } + + msg := shallowCloneMapping(mapping) + msg.AuthToken = token + resp = append(resp, msg) + } + + return &proto.GetMappingUpdateResponse{ + Mapping: resp, + } +} + +// shallowCloneMapping creates a shallow copy of a ProxyMapping, reusing the +// same slice/pointer fields. Only scalar fields that differ per proxy (AuthToken) +// should be set on the copy. +func shallowCloneMapping(m *proto.ProxyMapping) *proto.ProxyMapping { + return &proto.ProxyMapping{ + Type: m.Type, + Id: m.Id, + AccountId: m.AccountId, + Domain: m.Domain, + Path: m.Path, + Auth: m.Auth, + PassHostHeader: m.PassHostHeader, + RewriteRedirects: m.RewriteRedirects, + Mode: m.Mode, + ListenPort: m.ListenPort, + AccessRestrictions: m.AccessRestrictions, + Private: m.Private, + } +} + +func (s *ProxyServiceServer) Authenticate(ctx context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) { + if err := enforceAccountScope(ctx, req.GetAccountId()); err != nil { + return nil, err + } + + service, err := s.serviceManager.GetServiceByID(ctx, req.GetAccountId(), req.GetId()) + if err != nil { + log.WithContext(ctx).Debugf("failed to get service from store: %v", err) + return nil, status.Errorf(codes.FailedPrecondition, "get service from store: %v", err) + } + + authenticated, userId, method := s.authenticateRequest(ctx, req, service) + + // Non-OIDC schemes (PIN/Password/Header) authenticate against per-service + // secrets and have no user-level group context, so groups stay nil. Email + // is also empty — these schemes don't resolve a user record at sign time. + token, err := s.generateSessionToken(ctx, authenticated, service, userId, "", method, nil, nil) + if err != nil { + return nil, err + } + + return &proto.AuthenticateResponse{ + Success: authenticated, + SessionToken: token, + }, nil +} + +func (s *ProxyServiceServer) authenticateRequest(ctx context.Context, req *proto.AuthenticateRequest, service *rpservice.Service) (bool, string, proxyauth.Method) { + switch v := req.GetRequest().(type) { + case *proto.AuthenticateRequest_Pin: + return s.authenticatePIN(ctx, req.GetId(), v, service.Auth.PinAuth) + case *proto.AuthenticateRequest_Password: + return s.authenticatePassword(ctx, req.GetId(), v, service.Auth.PasswordAuth) + case *proto.AuthenticateRequest_HeaderAuth: + return s.authenticateHeader(ctx, req.GetId(), v, service.Auth.HeaderAuths) + default: + return false, "", "" + } +} + +func (s *ProxyServiceServer) authenticatePIN(ctx context.Context, serviceID string, req *proto.AuthenticateRequest_Pin, auth *rpservice.PINAuthConfig) (bool, string, proxyauth.Method) { + if auth == nil || !auth.Enabled { + log.WithContext(ctx).Debugf("PIN authentication attempted but not enabled for service %s", serviceID) + return false, "", "" + } + + if err := argon2id.Verify(req.Pin.GetPin(), auth.Pin); err != nil { + s.logAuthenticationError(ctx, err, "PIN") + return false, "", "" + } + + return true, "pin-user", proxyauth.MethodPIN +} + +func (s *ProxyServiceServer) authenticatePassword(ctx context.Context, serviceID string, req *proto.AuthenticateRequest_Password, auth *rpservice.PasswordAuthConfig) (bool, string, proxyauth.Method) { + if auth == nil || !auth.Enabled { + log.WithContext(ctx).Debugf("password authentication attempted but not enabled for service %s", serviceID) + return false, "", "" + } + + if err := argon2id.Verify(req.Password.GetPassword(), auth.Password); err != nil { + s.logAuthenticationError(ctx, err, "Password") + return false, "", "" + } + + return true, "password-user", proxyauth.MethodPassword +} + +func (s *ProxyServiceServer) authenticateHeader(ctx context.Context, serviceID string, req *proto.AuthenticateRequest_HeaderAuth, auths []*rpservice.HeaderAuthConfig) (bool, string, proxyauth.Method) { + if len(auths) == 0 { + log.WithContext(ctx).Debugf("header authentication attempted but no header auths configured for service %s", serviceID) + return false, "", "" + } + + headerName := http.CanonicalHeaderKey(req.HeaderAuth.GetHeaderName()) + + var lastErr error + for _, auth := range auths { + if auth == nil || !auth.Enabled { + continue + } + if headerName != "" && http.CanonicalHeaderKey(auth.Header) != headerName { + continue + } + if err := argon2id.Verify(req.HeaderAuth.GetHeaderValue(), auth.Value); err != nil { + lastErr = err + continue + } + return true, "header-user", proxyauth.MethodHeader + } + + if lastErr != nil { + s.logAuthenticationError(ctx, lastErr, "Header") + } + return false, "", "" +} + +func (s *ProxyServiceServer) logAuthenticationError(ctx context.Context, err error, authType string) { + if errors.Is(err, argon2id.ErrMismatchedHashAndPassword) { + log.WithContext(ctx).Tracef("%s authentication failed: invalid credentials", authType) + } else { + log.WithContext(ctx).Errorf("%s authentication error: %v", authType, err) + } +} + +func (s *ProxyServiceServer) generateSessionToken(ctx context.Context, authenticated bool, service *rpservice.Service, userId, userEmail string, method proxyauth.Method, groupIDs, groupNames []string) (string, error) { + if !authenticated || service.SessionPrivateKey == "" { + return "", nil + } + + token, err := sessionkey.SignToken( + service.SessionPrivateKey, + userId, + userEmail, + service.Domain, + method, + groupIDs, + groupNames, + proxyauth.DefaultSessionExpiry, + ) + if err != nil { + log.WithContext(ctx).WithError(err).Error("failed to sign session token") + return "", status.Errorf(codes.Internal, "sign session token: %v", err) + } + + return token, nil +} + +// pairGroupIDsAndNames splits a slice of resolved *types.Group records +// into parallel id and name slices. ids[i] and names[i] always pair to +// the same group. nil entries (orphan ids the manager couldn't resolve) +// are skipped so the consumer can rely on positional pairing. +func pairGroupIDsAndNames(groups []*types.Group) (ids, names []string) { + if len(groups) == 0 { + return nil, nil + } + ids = make([]string, 0, len(groups)) + names = make([]string, 0, len(groups)) + for _, g := range groups { + if g == nil { + continue + } + ids = append(ids, g.ID) + names = append(names, g.Name) + } + return ids, names +} + +// SendStatusUpdate handles status updates from proxy clients. +func (s *ProxyServiceServer) SendStatusUpdate(ctx context.Context, req *proto.SendStatusUpdateRequest) (*proto.SendStatusUpdateResponse, error) { + if err := enforceAccountScope(ctx, req.GetAccountId()); err != nil { + return nil, err + } + + accountID := req.GetAccountId() + serviceID := req.GetServiceId() + protoStatus := req.GetStatus() + certificateIssued := req.GetCertificateIssued() + + log.WithFields(log.Fields{ + "service_id": serviceID, + "account_id": accountID, + "status": protoStatus, + "certificate_issued": certificateIssued, + "error_message": req.GetErrorMessage(), + }).Debug("Status update from proxy server") + + if serviceID == "" || accountID == "" { + return nil, status.Errorf(codes.InvalidArgument, "service_id and account_id are required") + } + + internalStatus := protoStatusToInternal(protoStatus) + + if err := s.serviceManager.SetStatus(ctx, accountID, serviceID, internalStatus); err != nil { + sErr, isNbErr := nbstatus.FromError(err) + if isNbErr && sErr.Type() == nbstatus.NotFound { + return nil, status.Errorf(codes.NotFound, "service %s not found", serviceID) + } + log.WithContext(ctx).WithError(err).Error("failed to update service status") + return nil, status.Errorf(codes.Internal, "update service status: %v", err) + } + + if certificateIssued { + if err := s.serviceManager.SetCertificateIssuedAt(ctx, accountID, serviceID); err != nil { + log.WithContext(ctx).WithError(err).Error("failed to set certificate issued timestamp") + return nil, status.Errorf(codes.Internal, "update certificate timestamp: %v", err) + } + log.WithFields(log.Fields{ + "service_id": serviceID, + "account_id": accountID, + }).Info("Certificate issued timestamp updated") + } + + log.WithFields(log.Fields{ + "service_id": serviceID, + "account_id": accountID, + "status": internalStatus, + }).Info("Service status updated") + + return &proto.SendStatusUpdateResponse{}, nil +} + +// protoStatusToInternal maps proto status to internal service status. +func protoStatusToInternal(protoStatus proto.ProxyStatus) rpservice.Status { + switch protoStatus { + case proto.ProxyStatus_PROXY_STATUS_PENDING: + return rpservice.StatusPending + case proto.ProxyStatus_PROXY_STATUS_ACTIVE: + return rpservice.StatusActive + case proto.ProxyStatus_PROXY_STATUS_TUNNEL_NOT_CREATED: + return rpservice.StatusTunnelNotCreated + case proto.ProxyStatus_PROXY_STATUS_CERTIFICATE_PENDING: + return rpservice.StatusCertificatePending + case proto.ProxyStatus_PROXY_STATUS_CERTIFICATE_FAILED: + return rpservice.StatusCertificateFailed + case proto.ProxyStatus_PROXY_STATUS_ERROR: + return rpservice.StatusError + default: + return rpservice.StatusError + } +} + +// CreateProxyPeer handles proxy peer creation with one-time token authentication +func (s *ProxyServiceServer) CreateProxyPeer(ctx context.Context, req *proto.CreateProxyPeerRequest) (*proto.CreateProxyPeerResponse, error) { + if err := enforceAccountScope(ctx, req.GetAccountId()); err != nil { + return nil, err + } + + serviceID := req.GetServiceId() + accountID := req.GetAccountId() + token := req.GetToken() + cluster := req.GetCluster() + key := req.WireguardPublicKey + + log.WithFields(log.Fields{ + "service_id": serviceID, + "account_id": accountID, + "cluster": cluster, + }).Debug("CreateProxyPeer request received") + + if serviceID == "" || accountID == "" || token == "" { + log.Warn("CreateProxyPeer: missing required fields") + return &proto.CreateProxyPeerResponse{ + Success: false, + ErrorMessage: strPtr("missing required fields: service_id, account_id, and token are required"), + }, nil + } + + if err := s.tokenStore.ValidateAndConsume(token, accountID, serviceID); err != nil { + log.WithFields(log.Fields{ + "service_id": serviceID, + "account_id": accountID, + }).WithError(err).Warn("CreateProxyPeer: token validation failed") + return &proto.CreateProxyPeerResponse{ + Success: false, + ErrorMessage: strPtr("authentication failed: invalid or expired token"), + }, status.Errorf(codes.Unauthenticated, "token validation: %v", err) + } + + err := s.peersManager.CreateProxyPeer(ctx, accountID, key, cluster) + if err != nil { + log.WithFields(log.Fields{ + "service_id": serviceID, + "account_id": accountID, + }).WithError(err).Error("failed to create proxy peer") + return &proto.CreateProxyPeerResponse{ + Success: false, + ErrorMessage: strPtr(fmt.Sprintf("create proxy peer: %v", err)), + }, status.Errorf(codes.Internal, "create proxy peer: %v", err) + } + + return &proto.CreateProxyPeerResponse{ + Success: true, + }, nil +} + +// strPtr is a helper to create a string pointer for optional proto fields +func strPtr(s string) *string { + return &s +} + +func (s *ProxyServiceServer) GetOIDCURL(ctx context.Context, req *proto.GetOIDCURLRequest) (*proto.GetOIDCURLResponse, error) { + if err := enforceAccountScope(ctx, req.GetAccountId()); err != nil { + return nil, err + } + + redirectURL, err := url.Parse(req.GetRedirectUrl()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "parse redirect url: %v", err) + } + if redirectURL.Scheme != "https" && redirectURL.Scheme != "http" { + return nil, status.Errorf(codes.InvalidArgument, "redirect URL must use http or https scheme") + } + // Validate redirectURL against known service endpoints to avoid abuse of OIDC redirection. + services, err := s.serviceManager.GetAccountServices(ctx, req.GetAccountId()) + if err != nil { + log.WithContext(ctx).Errorf("failed to get account services: %v", err) + return nil, status.Errorf(codes.FailedPrecondition, "get account services: %v", err) + } + var found bool + for _, service := range services { + if service.Domain == redirectURL.Hostname() { + found = true + break + } + } + if !found { + log.WithContext(ctx).Debugf("OIDC redirect URL %q does not match any service domain", redirectURL.Hostname()) + return nil, status.Errorf(codes.FailedPrecondition, "service not found in store") + } + + provider, err := oidc.NewProvider(ctx, s.oidcConfig.Issuer) + if err != nil { + log.WithContext(ctx).Errorf("failed to create OIDC provider: %v", err) + return nil, status.Errorf(codes.FailedPrecondition, "create OIDC provider: %v", err) + } + + scopes := s.oidcConfig.Scopes + if len(scopes) == 0 { + scopes = []string{oidc.ScopeOpenID, "profile", "email"} + } + + // Generate a random nonce to ensure each OIDC request gets a unique state. + // Without this, multiple requests to the same URL would generate the same state + // but different PKCE verifiers, causing the later verifier to overwrite the earlier one. + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + return nil, status.Errorf(codes.Internal, "generate nonce: %v", err) + } + nonceB64 := base64.URLEncoding.EncodeToString(nonce) + + // Using an HMAC here to avoid redirection state being modified. + // State format: base64(redirectURL)|nonce|hmac(redirectURL|nonce) + payload := redirectURL.String() + "|" + nonceB64 + hmacSum := s.generateHMAC(payload) + state := fmt.Sprintf("%s|%s|%s", base64.URLEncoding.EncodeToString([]byte(redirectURL.String())), nonceB64, hmacSum) + + codeVerifier := oauth2.GenerateVerifier() + if err := s.pkceVerifierStore.Store(state, codeVerifier, pkceVerifierTTL); err != nil { + log.WithContext(ctx).Errorf("failed to store PKCE verifier: %v", err) + return nil, status.Errorf(codes.Internal, "store PKCE verifier: %v", err) + } + + return &proto.GetOIDCURLResponse{ + Url: (&oauth2.Config{ + ClientID: s.oidcConfig.ClientID, + Endpoint: provider.Endpoint(), + RedirectURL: s.oidcConfig.CallbackURL, + Scopes: scopes, + }).AuthCodeURL(state, oauth2.S256ChallengeOption(codeVerifier)), + }, nil +} + +// GetOIDCConfig returns the OIDC configuration for token validation. +func (s *ProxyServiceServer) GetOIDCConfig() ProxyOIDCConfig { + return s.oidcConfig +} + +// GetOIDCValidationConfig returns the OIDC configuration for token validation +// in the format needed by ToProtoMapping. +func (s *ProxyServiceServer) GetOIDCValidationConfig() proxy.OIDCValidationConfig { + return proxy.OIDCValidationConfig{ + Issuer: s.oidcConfig.Issuer, + Audiences: []string{s.oidcConfig.Audience}, + KeysLocation: s.oidcConfig.KeysLocation, + MaxTokenAgeSeconds: 0, // No max token age by default + } +} + +func (s *ProxyServiceServer) generateHMAC(input string) string { + mac := hmac.New(sha256.New, s.oidcConfig.HMACKey) + mac.Write([]byte(input)) + return hex.EncodeToString(mac.Sum(nil)) +} + +// ValidateState validates the state parameter from an OAuth callback. +// Returns the original redirect URL if valid, or an error if invalid. +// The HMAC is verified before consuming the PKCE verifier to prevent +// an attacker from invalidating a legitimate user's auth flow. +func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL string, err error) { + // State format: base64(redirectURL)|nonce|hmac(redirectURL|nonce) + parts := strings.Split(state, "|") + if len(parts) != 3 { + return "", "", errors.New("invalid state format") + } + + encodedURL := parts[0] + nonce := parts[1] + providedHMAC := parts[2] + + redirectURLBytes, err := base64.URLEncoding.DecodeString(encodedURL) + if err != nil { + return "", "", fmt.Errorf("invalid state encoding: %w", err) + } + redirectURL = string(redirectURLBytes) + + payload := redirectURL + "|" + nonce + expectedHMAC := s.generateHMAC(payload) + + if !hmac.Equal([]byte(providedHMAC), []byte(expectedHMAC)) { + return "", "", errors.New("invalid state signature") + } + + // Consume the PKCE verifier only after HMAC validation passes. + verifier, ok := s.pkceVerifierStore.LoadAndDelete(state) + if !ok { + return "", "", errors.New("no verifier for state") + } + + return verifier, redirectURL, nil +} + +// GenerateSessionToken creates a signed session JWT for the given domain and +// user. The user's group memberships are embedded in the token so policy-aware +// middlewares on the proxy can authorise without an extra management round-trip. +func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, userID string, method proxyauth.Method) (string, error) { + service, err := s.getServiceByDomain(ctx, domain) + if err != nil { + return "", fmt.Errorf("service not found for domain %s: %w", domain, err) + } + + if service.SessionPrivateKey == "" { + return "", fmt.Errorf("no session key configured for domain: %s", domain) + } + + var ( + email string + groupIDs []string + groupNames []string + ) + if s.usersManager != nil { + user, userGroups, uerr := s.usersManager.GetUserWithGroups(ctx, userID) + if uerr != nil { + log.WithContext(ctx).Debugf("session token mint: lookup user %s: %v", userID, uerr) + } else if user != nil { + email = user.Email + groupIDs, groupNames = pairGroupIDsAndNames(userGroups) + } + } + + return sessionkey.SignToken( + service.SessionPrivateKey, + userID, + email, + domain, + method, + groupIDs, + groupNames, + proxyauth.DefaultSessionExpiry, + ) +} + +// ValidateUserGroupAccess checks if a user has access to a service. +// It looks up the service within the user's account only, then optionally checks +// group membership if BearerAuth with DistributionGroups is configured. +func (s *ProxyServiceServer) ValidateUserGroupAccess(ctx context.Context, domain, userID string) error { + user, err := s.usersManager.GetUser(ctx, userID) + if err != nil { + return fmt.Errorf("user not found: %s", userID) + } + + service, err := s.getAccountServiceByDomain(ctx, user.AccountID, domain) + if err != nil { + return err + } + + if service.Auth.BearerAuth == nil || !service.Auth.BearerAuth.Enabled { + return nil + } + + allowedGroups := service.Auth.BearerAuth.DistributionGroups + if len(allowedGroups) == 0 { + return nil + } + + allowedSet := make(map[string]bool, len(allowedGroups)) + for _, groupID := range allowedGroups { + allowedSet[groupID] = true + } + + for _, groupID := range user.AutoGroups { + if allowedSet[groupID] { + log.WithFields(log.Fields{ + "user_id": user.Id, + "group_id": groupID, + "domain": domain, + }).Debug("User granted access via group membership") + return nil + } + } + + return fmt.Errorf("user %s not in allowed groups for domain %s", user.Id, domain) +} + +func (s *ProxyServiceServer) getAccountServiceByDomain(ctx context.Context, accountID, domain string) (*rpservice.Service, error) { + services, err := s.serviceManager.GetAccountServices(ctx, accountID) + if err != nil { + return nil, fmt.Errorf("get account services: %w", err) + } + + for _, service := range services { + if service.Domain == domain { + return service, nil + } + } + + return nil, fmt.Errorf("service not found for domain %s in account %s", domain, accountID) +} + +// ValidateSession validates a session token and checks if the user has access to the domain. +func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.ValidateSessionRequest) (*proto.ValidateSessionResponse, error) { + domain := req.GetDomain() + sessionToken := req.GetSessionToken() + + if domain == "" || sessionToken == "" { + return &proto.ValidateSessionResponse{ + Valid: false, + DeniedReason: "missing domain or session_token", + }, nil + } + + service, err := s.getServiceByDomain(ctx, domain) + if err != nil { + log.WithFields(log.Fields{ + "domain": domain, + "error": err.Error(), + }).Debug("ValidateSession: service not found") + //nolint:nilerr + return &proto.ValidateSessionResponse{ + Valid: false, + DeniedReason: "service_not_found", + }, nil + } + + if err := enforceAccountScope(ctx, service.AccountID); err != nil { + return nil, err + } + + pubKeyBytes, err := base64.StdEncoding.DecodeString(service.SessionPublicKey) + if err != nil { + log.WithFields(log.Fields{ + "domain": domain, + "error": err.Error(), + }).Error("ValidateSession: decode public key") + //nolint:nilerr + return &proto.ValidateSessionResponse{ + Valid: false, + DeniedReason: "invalid_service_config", + }, nil + } + + userID, _, _, _, _, err := proxyauth.ValidateSessionJWT(sessionToken, domain, pubKeyBytes) + if err != nil { + log.WithFields(log.Fields{ + "domain": domain, + "error": err.Error(), + }).Debug("ValidateSession: invalid session token") + //nolint:nilerr + return &proto.ValidateSessionResponse{ + Valid: false, + DeniedReason: "invalid_token", + }, nil + } + + user, userGroups, err := s.usersManager.GetUserWithGroups(ctx, userID) + if err != nil { + log.WithFields(log.Fields{ + "domain": domain, + "user_id": userID, + "error": err.Error(), + }).Debug("ValidateSession: user not found") + //nolint:nilerr + return &proto.ValidateSessionResponse{ + Valid: false, + DeniedReason: "user_not_found", + }, nil + } + + if user.AccountID != service.AccountID { + log.WithFields(log.Fields{ + "domain": domain, + "user_id": userID, + "user_account": user.AccountID, + "service_account": service.AccountID, + }).Debug("ValidateSession: user account mismatch") + //nolint:nilerr + return &proto.ValidateSessionResponse{ + Valid: false, + DeniedReason: "account_mismatch", + }, nil + } + + if err := s.checkGroupAccess(service, user); err != nil { + log.WithFields(log.Fields{ + "domain": domain, + "user_id": userID, + "error": err.Error(), + }).Debug("ValidateSession: access denied") + groupIDs, groupNames := pairGroupIDsAndNames(userGroups) + //nolint:nilerr + return &proto.ValidateSessionResponse{ + Valid: false, + UserId: user.Id, + UserEmail: user.Email, + DeniedReason: "not_in_group", + PeerGroupIds: groupIDs, + PeerGroupNames: groupNames, + }, nil + } + + log.WithFields(log.Fields{ + "domain": domain, + "user_id": userID, + "email": user.Email, + }).Debug("ValidateSession: access granted") + + groupIDs, groupNames := pairGroupIDsAndNames(userGroups) + return &proto.ValidateSessionResponse{ + Valid: true, + UserId: user.Id, + UserEmail: user.Email, + PeerGroupIds: groupIDs, + PeerGroupNames: groupNames, + }, nil +} + +func (s *ProxyServiceServer) getServiceByDomain(ctx context.Context, domain string) (*rpservice.Service, error) { + service, err := s.serviceManager.GetServiceByDomain(ctx, domain) + if err == nil { + return service, nil + } + + // Fall back to the Agent Network synthesiser scoped directly to the domain's + // account. Synthesised services are never persisted, so they must resolve + // here for OIDC / session / tunnel-peer flows against agent-network + // endpoints. Resolving by domain synthesises only the owning account rather + // than every tenant on the cluster. + if synth := s.agentNetworkSynthesizer(); synth != nil { + svc, serr := synth.SynthesizeServiceForDomain(ctx, domain) + if serr != nil { + // A real synthesis failure must surface, not be masked by the + // original store miss — otherwise a transient DB error looks like + // "no such service". + return nil, fmt.Errorf("synthesize agent-network service for %s: %w", domain, serr) + } + if svc != nil { + return svc, nil + } + } + return nil, err +} + +func (s *ProxyServiceServer) checkGroupAccess(service *rpservice.Service, user *types.User) error { + if service.Auth.BearerAuth == nil || !service.Auth.BearerAuth.Enabled { + return nil + } + + allowedGroups := service.Auth.BearerAuth.DistributionGroups + if len(allowedGroups) == 0 { + return nil + } + + allowedSet := make(map[string]bool, len(allowedGroups)) + for _, groupID := range allowedGroups { + allowedSet[groupID] = true + } + + for _, groupID := range user.AutoGroups { + if allowedSet[groupID] { + return nil + } + } + + return fmt.Errorf("user not in allowed groups") +} + +func ptr[T any](v T) *T { return &v } + +// ValidateTunnelPeer resolves an inbound peer by its WireGuard tunnel IP and +// checks the peer's group membership against the service's access groups. +// Peers without a user (machine agents, automation workloads) are first-class +// callers; authorisation runs off peer-group memberships rather than the +// optional owning user's auto-groups. On success a session JWT is minted so +// the proxy can install a cookie and skip subsequent management round-trips. +func (s *ProxyServiceServer) ValidateTunnelPeer(ctx context.Context, req *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) { + domain := req.GetDomain() + tunnelIPStr := req.GetTunnelIp() + + if domain == "" || tunnelIPStr == "" { + return &proto.ValidateTunnelPeerResponse{ + Valid: false, + DeniedReason: "missing domain or tunnel_ip", + }, nil + } + + tunnelIP := net.ParseIP(tunnelIPStr) + if tunnelIP == nil { + return &proto.ValidateTunnelPeerResponse{ + Valid: false, + DeniedReason: "invalid_tunnel_ip", + }, nil + } + + service, err := s.getServiceByDomain(ctx, domain) + if err != nil { + log.WithFields(log.Fields{"domain": domain, "error": err.Error()}).Debug("ValidateTunnelPeer: service not found") + //nolint:nilerr + return &proto.ValidateTunnelPeerResponse{ + Valid: false, + DeniedReason: "service_not_found", + }, nil + } + + // Mirror ValidateSession: account-scoped (BYOP) proxy tokens may only + // validate and mint session cookies for their own account's domains. + if err := enforceAccountScope(ctx, service.AccountID); err != nil { + return nil, err + } + + peer, err := s.peersManager.GetPeerByTunnelIP(ctx, service.AccountID, tunnelIP) + if err != nil || peer == nil { + log.WithFields(log.Fields{"domain": domain, "tunnel_ip": tunnelIPStr}).Debug("ValidateTunnelPeer: peer not found") + //nolint:nilerr + return &proto.ValidateTunnelPeerResponse{ + Valid: false, + DeniedReason: "peer_not_found", + }, nil + } + + _, peerGroups, err := s.peersManager.GetPeerWithGroups(ctx, service.AccountID, peer.ID) + if err != nil { + log.WithFields(log.Fields{"domain": domain, "peer_id": peer.ID, "error": err.Error()}).Debug("ValidateTunnelPeer: peer groups lookup failed") + //nolint:nilerr + return &proto.ValidateTunnelPeerResponse{ + Valid: false, + DeniedReason: "peer_not_found", + }, nil + } + + groupIDs, groupNames := pairGroupIDsAndNames(peerGroups) + principalID, displayIdentity := s.getTunnelPeerInfo(ctx, domain, service, peer) + + if err := checkPeerGroupAccess(service, groupIDs); err != nil { + log.WithFields(log.Fields{"domain": domain, "peer_id": peer.ID, "error": err.Error()}).Debug("ValidateTunnelPeer: access denied") + //nolint:nilerr + return &proto.ValidateTunnelPeerResponse{ + Valid: false, + UserId: principalID, + UserEmail: displayIdentity, + DeniedReason: "not_in_group", + PeerGroupIds: groupIDs, + PeerGroupNames: groupNames, + }, nil + } + + token, err := s.generateSessionToken(ctx, true, service, principalID, displayIdentity, proxyauth.MethodOIDC, groupIDs, groupNames) + if err != nil { + return nil, err + } + + log.WithFields(log.Fields{ + "domain": domain, + "tunnel_ip": tunnelIPStr, + "peer_id": peer.ID, + "principal_id": principalID, + }).Debug("ValidateTunnelPeer: access granted") + + return &proto.ValidateTunnelPeerResponse{ + Valid: true, + UserId: principalID, + UserEmail: displayIdentity, + SessionToken: token, + PeerGroupIds: groupIDs, + PeerGroupNames: groupNames, + }, nil +} + +// getTunnelPeerInfo returns the principal ID and display name for a peer, e.g. a +// user or peer ID, and peer name or user email. +func (s *ProxyServiceServer) getTunnelPeerInfo(ctx context.Context, domain string, service *rpservice.Service, peer *peer.Peer) (string, string) { + // Resolve the principal: when the peer is linked to a user, the human is the + // principal so multiple peers owned by the same user share a single + // identity. Unlinked peers (machine agents) are their own principal keyed on + // peer.ID. displayIdentity is what upstream gateways tag spend with — + // user.Email when linked, peer.Name when not. + + // If the peer isn't associated with a user, return the peer info directly. + if peer.UserID == "" { + return peer.ID, peer.Name + } + + // Otherwise, if the peer is linked to a user, the user is the principal and + // if an IdP is available, we gather details on the user from it. + principalID := peer.UserID + displayIdentity := peer.Name + // Stored column first (cheap, but often empty for OIDC-provisioned users). + if user, uerr := s.usersManager.GetUser(ctx, peer.UserID); uerr == nil && user != nil { + principalID = user.Id + if user.Email != "" { + displayIdentity = user.Email + } + } + // IdP enrichment wins when available — the stored email column is a + // best-effort cache and is frequently empty for OIDC users. Enrichment + // failures must never fail the RPC; we simply keep the stored/peer identity. + if s.idpManager != nil { + if ud, uerr := s.idpManager.GetUserDataByID(ctx, peer.UserID, idp.AppMetadata{WTAccountID: service.AccountID}); uerr == nil && ud != nil && ud.Email != "" { + displayIdentity = ud.Email + } else if uerr != nil { + log.WithFields(log.Fields{"domain": domain, "user_id": peer.UserID, "error": uerr.Error()}).Debug("ValidateTunnelPeer: IdP user enrichment failed; using stored/peer identity") + } + } + + return principalID, displayIdentity +} + +// checkPeerGroupAccess gates ValidateTunnelPeer by the service's required +// groups. Private services authorise against AccessGroups (empty list fails +// closed — Validate() rejects that at save time but the RPC is the security +// boundary and must not trust upstream state). Bearer-auth services authorise +// against DistributionGroups when populated. Non-private non-bearer services +// are open. +func checkPeerGroupAccess(service *rpservice.Service, peerGroupIDs []string) error { + if service.Private { + if len(service.AccessGroups) == 0 { + return fmt.Errorf("private service has no access groups") + } + return matchAnyGroup(service.AccessGroups, peerGroupIDs) + } + if service.Auth.BearerAuth != nil && service.Auth.BearerAuth.Enabled && len(service.Auth.BearerAuth.DistributionGroups) > 0 { + return matchAnyGroup(service.Auth.BearerAuth.DistributionGroups, peerGroupIDs) + } + return nil +} + +// matchAnyGroup returns nil when peerGroupIDs intersects allowedGroups, +// else a non-nil error. +func matchAnyGroup(allowedGroups, peerGroupIDs []string) error { + if len(allowedGroups) == 0 { + return fmt.Errorf("no allowed groups configured") + } + allowed := make(map[string]struct{}, len(allowedGroups)) + for _, g := range allowedGroups { + allowed[g] = struct{}{} + } + for _, g := range peerGroupIDs { + if _, ok := allowed[g]; ok { + return nil + } + } + return fmt.Errorf("peer not in allowed groups") +} diff --git a/management/internals/shared/grpc/proxy_address_test.go b/management/internals/shared/grpc/proxy_address_test.go new file mode 100644 index 000000000..824a57226 --- /dev/null +++ b/management/internals/shared/grpc/proxy_address_test.go @@ -0,0 +1,29 @@ +package grpc + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsProxyAddressValid(t *testing.T) { + tests := []struct { + name string + addr string + valid bool + }{ + {name: "valid domain", addr: "eu.proxy.netbird.io", valid: true}, + {name: "valid subdomain", addr: "byop.proxy.example.com", valid: true}, + {name: "valid IPv4", addr: "10.0.0.1", valid: true}, + {name: "valid IPv4 public", addr: "203.0.113.10", valid: true}, + {name: "valid IPv6", addr: "::1", valid: true}, + {name: "valid IPv6 full", addr: "2001:db8::1", valid: true}, + {name: "empty string", addr: "", valid: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.valid, isProxyAddressValid(tt.addr)) + }) + } +} diff --git a/management/internals/shared/grpc/proxy_auth.go b/management/internals/shared/grpc/proxy_auth.go new file mode 100644 index 000000000..9888e8eee --- /dev/null +++ b/management/internals/shared/grpc/proxy_auth.go @@ -0,0 +1,231 @@ +package grpc + +import ( + "context" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +const ( + // lastUsedUpdateInterval is the minimum interval between last_used updates for the same token. + lastUsedUpdateInterval = time.Minute + // lastUsedCleanupInterval is how often stale lastUsed entries are removed. + lastUsedCleanupInterval = 2 * time.Minute +) + +type proxyTokenContextKey struct{} + +// ProxyTokenContextKey is the typed key used to store validated token info in context. +var ProxyTokenContextKey = proxyTokenContextKey{} + +// proxyTokenID identifies a proxy access token by its database ID. +type proxyTokenID = string + +// proxyTokenStore defines the store interface needed for token validation +type proxyTokenStore interface { + GetProxyAccessTokenByHashedToken(ctx context.Context, lockStrength store.LockingStrength, hashedToken types.HashedProxyToken) (*types.ProxyAccessToken, error) + MarkProxyAccessTokenUsed(ctx context.Context, tokenID string) error +} + +// proxyAuthInterceptor holds state for proxy authentication interceptors. +type proxyAuthInterceptor struct { + store proxyTokenStore + failureLimiter *authFailureLimiter + + // lastUsedMu protects lastUsedTimes + lastUsedMu sync.Mutex + lastUsedTimes map[proxyTokenID]time.Time + cancel context.CancelFunc +} + +func newProxyAuthInterceptor(tokenStore proxyTokenStore) *proxyAuthInterceptor { + ctx, cancel := context.WithCancel(context.Background()) + i := &proxyAuthInterceptor{ + store: tokenStore, + failureLimiter: newAuthFailureLimiter(), + lastUsedTimes: make(map[proxyTokenID]time.Time), + cancel: cancel, + } + go i.lastUsedCleanupLoop(ctx) + return i +} + +// NewProxyAuthInterceptors creates gRPC unary and stream interceptors that validate proxy access tokens. +// They only intercept ProxyService methods. Both interceptors share state for last-used and failure rate limiting. +// The returned close function must be called on shutdown to stop background goroutines. +func NewProxyAuthInterceptors(tokenStore proxyTokenStore) (grpc.UnaryServerInterceptor, grpc.StreamServerInterceptor, func()) { + interceptor := newProxyAuthInterceptor(tokenStore) + + unary := func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + if !strings.HasPrefix(info.FullMethod, "/management.ProxyService/") { + return handler(ctx, req) + } + + token, err := interceptor.validateProxyToken(ctx) + if err != nil { + // Log auth failures explicitly; gRPC doesn't log these by default. + log.WithContext(ctx).Warnf("proxy auth failed: %v", err) + return nil, err + } + + ctx = context.WithValue(ctx, ProxyTokenContextKey, token) + return handler(ctx, req) + } + + stream := func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + if !strings.HasPrefix(info.FullMethod, "/management.ProxyService/") { + return handler(srv, ss) + } + + token, err := interceptor.validateProxyToken(ss.Context()) + if err != nil { + // Log auth failures explicitly; gRPC doesn't log these by default. + log.WithContext(ss.Context()).Warnf("proxy auth failed: %v", err) + return err + } + + ctx := context.WithValue(ss.Context(), ProxyTokenContextKey, token) + wrapped := &wrappedServerStream{ + ServerStream: ss, + ctx: ctx, + } + + return handler(srv, wrapped) + } + + return unary, stream, interceptor.close +} + +func (i *proxyAuthInterceptor) validateProxyToken(ctx context.Context) (*types.ProxyAccessToken, error) { + clientIP := PeerIPFromContext(ctx) + + if clientIP != "" && i.failureLimiter.isLimited(clientIP) { + return nil, status.Errorf(codes.ResourceExhausted, "too many failed authentication attempts") + } + + token, err := i.doValidateProxyToken(ctx) + if err != nil { + if clientIP != "" { + i.failureLimiter.recordFailure(clientIP) + } + return nil, err + } + + i.maybeUpdateLastUsed(ctx, token.ID) + + return token, nil +} + +func (i *proxyAuthInterceptor) doValidateProxyToken(ctx context.Context) (*types.ProxyAccessToken, error) { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return nil, status.Errorf(codes.Unauthenticated, "missing metadata") + } + + authValues := md.Get("authorization") + if len(authValues) == 0 { + return nil, status.Errorf(codes.Unauthenticated, "missing authorization header") + } + + authValue := authValues[0] + if !strings.HasPrefix(authValue, "Bearer ") { + return nil, status.Errorf(codes.Unauthenticated, "invalid authorization format") + } + + plainToken := types.PlainProxyToken(strings.TrimPrefix(authValue, "Bearer ")) + + if err := plainToken.Validate(); err != nil { + return nil, status.Errorf(codes.Unauthenticated, "invalid token format") + } + + token, err := i.store.GetProxyAccessTokenByHashedToken(ctx, store.LockingStrengthNone, plainToken.Hash()) + if err != nil { + return nil, status.Errorf(codes.Unauthenticated, "invalid token") + } + + if !token.IsValid() { + return nil, status.Errorf(codes.Unauthenticated, "token expired or revoked") + } + + return token, nil +} + +// maybeUpdateLastUsed updates the last_used timestamp if enough time has passed since the last update. +func (i *proxyAuthInterceptor) maybeUpdateLastUsed(ctx context.Context, tokenID string) { + now := time.Now() + + i.lastUsedMu.Lock() + lastUpdate, exists := i.lastUsedTimes[tokenID] + if exists && now.Sub(lastUpdate) < lastUsedUpdateInterval { + i.lastUsedMu.Unlock() + return + } + i.lastUsedTimes[tokenID] = now + i.lastUsedMu.Unlock() + + if err := i.store.MarkProxyAccessTokenUsed(ctx, tokenID); err != nil { + log.WithContext(ctx).Debugf("failed to mark proxy token as used: %v", err) + } +} + +func (i *proxyAuthInterceptor) lastUsedCleanupLoop(ctx context.Context) { + ticker := time.NewTicker(lastUsedCleanupInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + i.cleanupStaleLastUsed() + case <-ctx.Done(): + return + } + } +} + +// cleanupStaleLastUsed removes entries older than 2x the update interval. +func (i *proxyAuthInterceptor) cleanupStaleLastUsed() { + i.lastUsedMu.Lock() + defer i.lastUsedMu.Unlock() + + now := time.Now() + staleThreshold := 2 * lastUsedUpdateInterval + for id, lastUpdate := range i.lastUsedTimes { + if now.Sub(lastUpdate) > staleThreshold { + delete(i.lastUsedTimes, id) + } + } +} + +func (i *proxyAuthInterceptor) close() { + i.cancel() + i.failureLimiter.stop() +} + +// GetProxyTokenFromContext retrieves the validated proxy token from the context +func GetProxyTokenFromContext(ctx context.Context) *types.ProxyAccessToken { + token, ok := ctx.Value(ProxyTokenContextKey).(*types.ProxyAccessToken) + if !ok { + return nil + } + return token +} + +// wrappedServerStream wraps a grpc.ServerStream to provide a custom context +type wrappedServerStream struct { + grpc.ServerStream + ctx context.Context +} + +func (w *wrappedServerStream) Context() context.Context { + return w.ctx +} diff --git a/management/internals/shared/grpc/proxy_auth_ratelimit.go b/management/internals/shared/grpc/proxy_auth_ratelimit.go new file mode 100644 index 000000000..78ab1bd20 --- /dev/null +++ b/management/internals/shared/grpc/proxy_auth_ratelimit.go @@ -0,0 +1,134 @@ +package grpc + +import ( + "context" + "net" + "sync" + "time" + + "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/realip" + "golang.org/x/time/rate" + "google.golang.org/grpc/peer" +) + +const ( + // proxyAuthFailureBurst is the maximum number of failed attempts before rate limiting kicks in. + proxyAuthFailureBurst = 5 + // proxyAuthLimiterCleanup is how often stale limiters are removed. + proxyAuthLimiterCleanup = 5 * time.Minute + // proxyAuthLimiterTTL is how long a limiter is kept after the last failure. + proxyAuthLimiterTTL = 15 * time.Minute +) + +// defaultProxyAuthFailureRate is the token replenishment rate for failed auth attempts. +// One token every 12 seconds = 5 per minute. +var defaultProxyAuthFailureRate = rate.Every(12 * time.Second) + +// clientIP identifies a client by its IP address for rate limiting purposes. +type clientIP = string + +type limiterEntry struct { + limiter *rate.Limiter + lastAccess time.Time +} + +// authFailureLimiter tracks per-IP rate limits for failed proxy authentication attempts. +type authFailureLimiter struct { + mu sync.Mutex + limiters map[clientIP]*limiterEntry + failureRate rate.Limit + cancel context.CancelFunc +} + +func newAuthFailureLimiter() *authFailureLimiter { + return newAuthFailureLimiterWithRate(defaultProxyAuthFailureRate) +} + +func newAuthFailureLimiterWithRate(failureRate rate.Limit) *authFailureLimiter { + ctx, cancel := context.WithCancel(context.Background()) + l := &authFailureLimiter{ + limiters: make(map[clientIP]*limiterEntry), + failureRate: failureRate, + cancel: cancel, + } + go l.cleanupLoop(ctx) + return l +} + +// isLimited returns true if the given IP has exhausted its failure budget. +func (l *authFailureLimiter) isLimited(ip clientIP) bool { + l.mu.Lock() + defer l.mu.Unlock() + + entry, exists := l.limiters[ip] + if !exists { + return false + } + + return entry.limiter.Tokens() < 1 +} + +// recordFailure consumes a token from the rate limiter for the given IP. +func (l *authFailureLimiter) recordFailure(ip clientIP) { + l.mu.Lock() + defer l.mu.Unlock() + + now := time.Now() + entry, exists := l.limiters[ip] + if !exists { + entry = &limiterEntry{ + limiter: rate.NewLimiter(l.failureRate, proxyAuthFailureBurst), + } + l.limiters[ip] = entry + } + entry.lastAccess = now + entry.limiter.Allow() +} + +func (l *authFailureLimiter) cleanupLoop(ctx context.Context) { + ticker := time.NewTicker(proxyAuthLimiterCleanup) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + l.cleanup() + case <-ctx.Done(): + return + } + } +} + +func (l *authFailureLimiter) cleanup() { + l.mu.Lock() + defer l.mu.Unlock() + + now := time.Now() + for ip, entry := range l.limiters { + if now.Sub(entry.lastAccess) > proxyAuthLimiterTTL { + delete(l.limiters, ip) + } + } +} + +func (l *authFailureLimiter) stop() { + l.cancel() +} + +// PeerIPFromContext extracts the client IP from the gRPC context. +// Uses realip (from trusted proxy headers) first, falls back to the transport peer address. +func PeerIPFromContext(ctx context.Context) string { + if addr, ok := realip.FromContext(ctx); ok { + return addr.String() + } + + if p, ok := peer.FromContext(ctx); ok { + host, _, err := net.SplitHostPort(p.Addr.String()) + if err != nil { + return p.Addr.String() + } + return host + } + + return "" +} diff --git a/management/internals/shared/grpc/proxy_auth_ratelimit_test.go b/management/internals/shared/grpc/proxy_auth_ratelimit_test.go new file mode 100644 index 000000000..3577baeb8 --- /dev/null +++ b/management/internals/shared/grpc/proxy_auth_ratelimit_test.go @@ -0,0 +1,98 @@ +package grpc + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/time/rate" +) + +func TestAuthFailureLimiter_NotLimitedInitially(t *testing.T) { + l := newAuthFailureLimiter() + defer l.stop() + + assert.False(t, l.isLimited("192.168.1.1"), "new IP should not be rate limited") +} + +func TestAuthFailureLimiter_LimitedAfterBurst(t *testing.T) { + l := newAuthFailureLimiter() + defer l.stop() + + ip := "192.168.1.1" + for i := 0; i < proxyAuthFailureBurst; i++ { + l.recordFailure(ip) + } + + assert.True(t, l.isLimited(ip), "IP should be limited after exhausting burst") +} + +func TestAuthFailureLimiter_DifferentIPsIndependent(t *testing.T) { + l := newAuthFailureLimiter() + defer l.stop() + + for i := 0; i < proxyAuthFailureBurst; i++ { + l.recordFailure("192.168.1.1") + } + + assert.True(t, l.isLimited("192.168.1.1")) + assert.False(t, l.isLimited("192.168.1.2"), "different IP should not be affected") +} + +func TestAuthFailureLimiter_RecoveryOverTime(t *testing.T) { + l := newAuthFailureLimiterWithRate(rate.Limit(100)) // 100 tokens/sec for fast recovery + defer l.stop() + + ip := "10.0.0.1" + + // Exhaust burst + for i := 0; i < proxyAuthFailureBurst; i++ { + l.recordFailure(ip) + } + require.True(t, l.isLimited(ip)) + + // Wait for token replenishment + time.Sleep(50 * time.Millisecond) + + assert.False(t, l.isLimited(ip), "should recover after tokens replenish") +} + +func TestAuthFailureLimiter_Cleanup(t *testing.T) { + l := newAuthFailureLimiter() + defer l.stop() + + l.recordFailure("10.0.0.1") + + l.mu.Lock() + require.Len(t, l.limiters, 1) + // Backdate the entry so it looks stale + l.limiters["10.0.0.1"].lastAccess = time.Now().Add(-proxyAuthLimiterTTL - time.Minute) + l.mu.Unlock() + + l.cleanup() + + l.mu.Lock() + assert.Empty(t, l.limiters, "stale entries should be cleaned up") + l.mu.Unlock() +} + +func TestAuthFailureLimiter_CleanupKeepsFresh(t *testing.T) { + l := newAuthFailureLimiter() + defer l.stop() + + l.recordFailure("10.0.0.1") + l.recordFailure("10.0.0.2") + + l.mu.Lock() + // Only backdate one entry + l.limiters["10.0.0.1"].lastAccess = time.Now().Add(-proxyAuthLimiterTTL - time.Minute) + l.mu.Unlock() + + l.cleanup() + + l.mu.Lock() + assert.Len(t, l.limiters, 1, "only stale entries should be removed") + assert.Contains(t, l.limiters, "10.0.0.2") + l.mu.Unlock() +} diff --git a/management/internals/shared/grpc/proxy_clone_test.go b/management/internals/shared/grpc/proxy_clone_test.go new file mode 100644 index 000000000..f00d40fae --- /dev/null +++ b/management/internals/shared/grpc/proxy_clone_test.go @@ -0,0 +1,88 @@ +package grpc + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/management/proto" +) + +// authTokenField is the only per-proxy field that shallowCloneMapping must NOT +// copy from the source, since callers assign it individually after cloning. +const authTokenField = "AuthToken" + +// TestShallowCloneMapping_ClonesAllFields populates every exported field of +// ProxyMapping with a non-zero value and verifies the clone carries each one +// (except AuthToken). It uses reflection so adding a new field to ProxyMapping +// without updating shallowCloneMapping fails this test. +func TestShallowCloneMapping_ClonesAllFields(t *testing.T) { + src := &proto.ProxyMapping{} + populated := populateExportedFields(t, reflect.ValueOf(src).Elem()) + require.NotEmpty(t, populated, "ProxyMapping should expose fields to populate") + + clone := shallowCloneMapping(src) + require.NotNil(t, clone, "clone must not be nil") + + srcVal := reflect.ValueOf(src).Elem() + cloneVal := reflect.ValueOf(clone).Elem() + + for _, name := range populated { + srcField := srcVal.FieldByName(name).Interface() + cloneField := cloneVal.FieldByName(name).Interface() + + if name == authTokenField { + assert.Zero(t, cloneField, "AuthToken must not be cloned; it is set per proxy after cloning") + continue + } + + assert.Equal(t, srcField, cloneField, "field %s must be carried over by shallowCloneMapping", name) + } +} + +// populateExportedFields sets a non-zero value on every settable exported field +// of the struct and returns their names. +func populateExportedFields(t *testing.T, v reflect.Value) []string { + t.Helper() + + var names []string + typ := v.Type() + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + structField := typ.Field(i) + + if structField.PkgPath != "" || !field.CanSet() { + continue + } + + setNonZero(t, field, structField.Name) + names = append(names, structField.Name) + } + return names +} + +// setNonZero assigns a deterministic non-zero value based on the field kind. +func setNonZero(t *testing.T, field reflect.Value, name string) { + t.Helper() + + switch field.Kind() { + case reflect.String: + field.SetString("non-zero-" + name) + case reflect.Bool: + field.SetBool(true) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + field.SetInt(7) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + field.SetUint(7) + case reflect.Ptr: + field.Set(reflect.New(field.Type().Elem())) + case reflect.Slice: + field.Set(reflect.MakeSlice(field.Type(), 1, 1)) + case reflect.Map: + field.Set(reflect.MakeMapWithSize(field.Type(), 0)) + default: + t.Fatalf("unhandled field kind %s for field %s; extend setNonZero", field.Kind(), name) + } +} diff --git a/management/internals/shared/grpc/proxy_group_access_test.go b/management/internals/shared/grpc/proxy_group_access_test.go new file mode 100644 index 000000000..532cb7cc3 --- /dev/null +++ b/management/internals/shared/grpc/proxy_group_access_test.go @@ -0,0 +1,681 @@ +package grpc + +import ( + "context" + "errors" + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/peers" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +type mockReverseProxyManager struct { + proxiesByAccount map[string][]*service.Service + err error +} + +func (m *mockReverseProxyManager) DeleteAllServices(ctx context.Context, accountID, userID string) error { + return nil +} + +func (m *mockReverseProxyManager) GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error) { + if m.err != nil { + return nil, m.err + } + return m.proxiesByAccount[accountID], nil +} + +func (m *mockReverseProxyManager) GetGlobalServices(ctx context.Context) ([]*service.Service, error) { + return nil, nil +} + +func (m *mockReverseProxyManager) GetAllServices(ctx context.Context, accountID, userID string) ([]*service.Service, error) { + return []*service.Service{}, nil +} + +func (m *mockReverseProxyManager) GetService(ctx context.Context, accountID, userID, reverseProxyID string) (*service.Service, error) { + return &service.Service{}, nil +} + +func (m *mockReverseProxyManager) CreateService(ctx context.Context, accountID, userID string, rp *service.Service) (*service.Service, error) { + return &service.Service{}, nil +} + +func (m *mockReverseProxyManager) UpdateService(ctx context.Context, accountID, userID string, rp *service.Service) (*service.Service, error) { + return &service.Service{}, nil +} + +func (m *mockReverseProxyManager) DeleteService(ctx context.Context, accountID, userID, reverseProxyID string) error { + return nil +} + +func (m *mockReverseProxyManager) DeleteAccountCluster(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *mockReverseProxyManager) SetCertificateIssuedAt(ctx context.Context, accountID, reverseProxyID string) error { + return nil +} + +func (m *mockReverseProxyManager) SetStatus(ctx context.Context, accountID, reverseProxyID string, status service.Status) error { + return nil +} + +func (m *mockReverseProxyManager) ReloadAllServicesForAccount(ctx context.Context, accountID string) error { + return nil +} + +func (m *mockReverseProxyManager) ReloadService(ctx context.Context, accountID, reverseProxyID string) error { + return nil +} + +func (m *mockReverseProxyManager) GetServiceByID(ctx context.Context, accountID, reverseProxyID string) (*service.Service, error) { + return &service.Service{}, nil +} + +func (m *mockReverseProxyManager) GetServiceIDByTargetID(_ context.Context, _, _ string) (string, error) { + return "", nil +} + +func (m *mockReverseProxyManager) CreateServiceFromPeer(_ context.Context, _, _ string, _ *service.ExposeServiceRequest) (*service.ExposeServiceResponse, error) { + return &service.ExposeServiceResponse{}, nil +} + +func (m *mockReverseProxyManager) RenewServiceFromPeer(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *mockReverseProxyManager) StopServiceFromPeer(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *mockReverseProxyManager) StartExposeReaper(_ context.Context) {} + +func (m *mockReverseProxyManager) GetServiceByDomain(_ context.Context, domain string) (*service.Service, error) { + if m.err != nil { + return nil, m.err + } + for _, services := range m.proxiesByAccount { + for _, svc := range services { + if svc.Domain == domain { + return svc, nil + } + } + } + return nil, errors.New("service not found for domain: " + domain) +} + +func (m *mockReverseProxyManager) GetClusters(_ context.Context, _, _ string) ([]proxy.Cluster, error) { + return nil, nil +} + +type mockUsersManager struct { + users map[string]*types.User + err error +} + +func (m *mockUsersManager) GetUser(ctx context.Context, userID string) (*types.User, error) { + if m.err != nil { + return nil, m.err + } + user, ok := m.users[userID] + if !ok { + return nil, errors.New("user not found") + } + return user, nil +} + +func (m *mockUsersManager) GetUserWithGroups(ctx context.Context, userID string) (*types.User, []*types.Group, error) { + user, err := m.GetUser(ctx, userID) + if err != nil { + return nil, nil, err + } + return user, nil, nil +} + +// mockTunnelPeersManager implements only the two peers.Manager methods that +// ValidateTunnelPeer calls; the embedded interface satisfies the rest (and +// panics if any unexpected method is invoked). +type mockTunnelPeersManager struct { + peers.Manager + peer *peer.Peer + peerErr error + groups []*types.Group + groupsErr error +} + +func (m *mockTunnelPeersManager) GetPeerByTunnelIP(_ context.Context, _ string, _ net.IP) (*peer.Peer, error) { + return m.peer, m.peerErr +} + +func (m *mockTunnelPeersManager) GetPeerWithGroups(_ context.Context, _, _ string) (*peer.Peer, []*types.Group, error) { + return m.peer, m.groups, m.groupsErr +} + +// mockTunnelIdpManager implements only GetUserDataByID; the embedded interface +// satisfies the rest of idp.Manager. hasData==false returns (nil, nil) to model +// an IdP that knows nothing about the user. +type mockTunnelIdpManager struct { + idp.Manager + email string + hasData bool + err error + gotCalls int + gotMeta []idp.AppMetadata +} + +func (m *mockTunnelIdpManager) GetUserDataByID(_ context.Context, userID string, meta idp.AppMetadata) (*idp.UserData, error) { + m.gotCalls++ + m.gotMeta = append(m.gotMeta, meta) + if m.err != nil { + return nil, m.err + } + if !m.hasData { + // This might not be a thing any of the actual IDP implementations do, + // i.e. return a nil value with no error, but it seems valuable to test + // that behavior here. + return nil, nil //nolint:nilnil + } + return &idp.UserData{ID: userID, Email: m.email}, nil +} + +func TestValidateUserGroupAccess(t *testing.T) { + tests := []struct { + name string + domain string + userID string + proxiesByAccount map[string][]*service.Service + users map[string]*types.User + proxyErr error + userErr error + expectErr bool + expectErrMsg string + }{ + { + name: "user not found", + domain: "app.example.com", + userID: "unknown-user", + proxiesByAccount: map[string][]*service.Service{ + "account1": {{Domain: "app.example.com", AccountID: "account1"}}, + }, + users: map[string]*types.User{}, + expectErr: true, + expectErrMsg: "user not found", + }, + { + name: "proxy not found in user's account", + domain: "app.example.com", + userID: "user1", + proxiesByAccount: map[string][]*service.Service{}, + users: map[string]*types.User{ + "user1": {Id: "user1", AccountID: "account1"}, + }, + expectErr: true, + expectErrMsg: "service not found", + }, + { + name: "proxy exists in different account - not accessible", + domain: "app.example.com", + userID: "user1", + proxiesByAccount: map[string][]*service.Service{ + "account2": {{Domain: "app.example.com", AccountID: "account2"}}, + }, + users: map[string]*types.User{ + "user1": {Id: "user1", AccountID: "account1"}, + }, + expectErr: true, + expectErrMsg: "service not found", + }, + { + name: "no bearer auth configured - same account allows access", + domain: "app.example.com", + userID: "user1", + proxiesByAccount: map[string][]*service.Service{ + "account1": {{Domain: "app.example.com", AccountID: "account1", Auth: service.AuthConfig{}}}, + }, + users: map[string]*types.User{ + "user1": {Id: "user1", AccountID: "account1"}, + }, + expectErr: false, + }, + { + name: "bearer auth disabled - same account allows access", + domain: "app.example.com", + userID: "user1", + proxiesByAccount: map[string][]*service.Service{ + "account1": {{ + Domain: "app.example.com", + AccountID: "account1", + Auth: service.AuthConfig{ + BearerAuth: &service.BearerAuthConfig{Enabled: false}, + }, + }}, + }, + users: map[string]*types.User{ + "user1": {Id: "user1", AccountID: "account1"}, + }, + expectErr: false, + }, + { + name: "bearer auth enabled but no groups configured - same account allows access", + domain: "app.example.com", + userID: "user1", + proxiesByAccount: map[string][]*service.Service{ + "account1": {{ + Domain: "app.example.com", + AccountID: "account1", + Auth: service.AuthConfig{ + BearerAuth: &service.BearerAuthConfig{ + Enabled: true, + DistributionGroups: []string{}, + }, + }, + }}, + }, + users: map[string]*types.User{ + "user1": {Id: "user1", AccountID: "account1"}, + }, + expectErr: false, + }, + { + name: "user not in allowed groups", + domain: "app.example.com", + userID: "user1", + proxiesByAccount: map[string][]*service.Service{ + "account1": {{ + Domain: "app.example.com", + AccountID: "account1", + Auth: service.AuthConfig{ + BearerAuth: &service.BearerAuthConfig{ + Enabled: true, + DistributionGroups: []string{"group1", "group2"}, + }, + }, + }}, + }, + users: map[string]*types.User{ + "user1": {Id: "user1", AccountID: "account1", AutoGroups: []string{"group3", "group4"}}, + }, + expectErr: true, + expectErrMsg: "not in allowed groups", + }, + { + name: "user in one of the allowed groups - allow access", + domain: "app.example.com", + userID: "user1", + proxiesByAccount: map[string][]*service.Service{ + "account1": {{ + Domain: "app.example.com", + AccountID: "account1", + Auth: service.AuthConfig{ + BearerAuth: &service.BearerAuthConfig{ + Enabled: true, + DistributionGroups: []string{"group1", "group2"}, + }, + }, + }}, + }, + users: map[string]*types.User{ + "user1": {Id: "user1", AccountID: "account1", AutoGroups: []string{"group2", "group3"}}, + }, + expectErr: false, + }, + { + name: "user in all allowed groups - allow access", + domain: "app.example.com", + userID: "user1", + proxiesByAccount: map[string][]*service.Service{ + "account1": {{ + Domain: "app.example.com", + AccountID: "account1", + Auth: service.AuthConfig{ + BearerAuth: &service.BearerAuthConfig{ + Enabled: true, + DistributionGroups: []string{"group1", "group2"}, + }, + }, + }}, + }, + users: map[string]*types.User{ + "user1": {Id: "user1", AccountID: "account1", AutoGroups: []string{"group1", "group2", "group3"}}, + }, + expectErr: false, + }, + { + name: "proxy manager error", + domain: "app.example.com", + userID: "user1", + proxiesByAccount: nil, + proxyErr: errors.New("database error"), + users: map[string]*types.User{ + "user1": {Id: "user1", AccountID: "account1"}, + }, + expectErr: true, + expectErrMsg: "get account services", + }, + { + name: "multiple proxies in account - finds correct one", + domain: "app2.example.com", + userID: "user1", + proxiesByAccount: map[string][]*service.Service{ + "account1": { + {Domain: "app1.example.com", AccountID: "account1"}, + {Domain: "app2.example.com", AccountID: "account1", Auth: service.AuthConfig{}}, + {Domain: "app3.example.com", AccountID: "account1"}, + }, + }, + users: map[string]*types.User{ + "user1": {Id: "user1", AccountID: "account1"}, + }, + expectErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := &ProxyServiceServer{ + serviceManager: &mockReverseProxyManager{ + proxiesByAccount: tt.proxiesByAccount, + err: tt.proxyErr, + }, + usersManager: &mockUsersManager{ + users: tt.users, + err: tt.userErr, + }, + } + + err := server.ValidateUserGroupAccess(context.Background(), tt.domain, tt.userID) + + if tt.expectErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectErrMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +// TestValidateTunnelPeerUserEmailEnrichment verifies the UserEmail/UserId +// resolution in ValidateTunnelPeer, including the IdP-enrichment fallback order +// (IdP email -> stored User.Email -> peer.Name). +func TestValidateTunnelPeerUserEmailEnrichment(t *testing.T) { + const ( + domain = "app.example.com" + accountID = "account1" + peerID = "peer1" + peerName = "peer-display-name" + userID = "user1" + ) + + storedUser := map[string]*types.User{userID: {Id: userID, AccountID: accountID, Email: "stored@example.com"}} + storedUserNoEmail := map[string]*types.User{userID: {Id: userID, AccountID: accountID, Email: ""}} + + tests := []struct { + name string + peerUserID string + storedUsers map[string]*types.User + storedErr error + noIdP bool + idpEmail string + idpHasData bool + idpErr error + expectEmail string + expectUserID string + expectIdPHit bool + }{ + { + name: "idp email wins over stored email", + peerUserID: userID, + storedUsers: storedUser, + idpEmail: "idp@example.com", + idpHasData: true, + expectEmail: "idp@example.com", + expectUserID: userID, + expectIdPHit: true, + }, + { + name: "stored email when idp returns empty email", + peerUserID: userID, + storedUsers: storedUser, + idpEmail: "", + idpHasData: true, + expectEmail: "stored@example.com", + expectUserID: userID, + expectIdPHit: true, + }, + { + name: "stored email when idp has no data", + peerUserID: userID, + storedUsers: storedUser, + idpHasData: false, + expectEmail: "stored@example.com", + expectUserID: userID, + expectIdPHit: true, + }, + { + name: "stored email when idp errors", + peerUserID: userID, + storedUsers: storedUser, + idpErr: errors.New("idp unreachable"), + expectEmail: "stored@example.com", + expectUserID: userID, + expectIdPHit: true, + }, + { + name: "stored email when no idp manager", + peerUserID: userID, + storedUsers: storedUser, + noIdP: true, + expectEmail: "stored@example.com", + expectUserID: userID, + }, + { + name: "idp email when stored email is empty", + peerUserID: userID, + storedUsers: storedUserNoEmail, + idpEmail: "idp@example.com", + idpHasData: true, + expectEmail: "idp@example.com", + expectUserID: userID, + expectIdPHit: true, + }, + { + name: "idp email when stored user missing keeps peer.UserID as principal", + peerUserID: userID, + storedUsers: map[string]*types.User{}, + idpEmail: "idp@example.com", + idpHasData: true, + expectEmail: "idp@example.com", + expectUserID: userID, + expectIdPHit: true, + }, + { + name: "unlinked peer uses peer name and never consults idp", + peerUserID: "", + storedUsers: storedUser, + idpEmail: "idp@example.com", + idpHasData: true, + expectEmail: peerName, + expectUserID: peerID, + expectIdPHit: false, + }, + { + name: "linked peer with empty stored email and no idp falls back to peer name", + peerUserID: userID, + storedUsers: storedUserNoEmail, + noIdP: true, + expectEmail: peerName, + expectUserID: userID, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := &service.Service{Domain: domain, AccountID: accountID} + server := &ProxyServiceServer{ + serviceManager: &mockReverseProxyManager{ + proxiesByAccount: map[string][]*service.Service{accountID: {svc}}, + }, + peersManager: &mockTunnelPeersManager{ + peer: &peer.Peer{ID: peerID, Name: peerName, UserID: tt.peerUserID}, + }, + usersManager: &mockUsersManager{users: tt.storedUsers, err: tt.storedErr}, + } + + var idpMock *mockTunnelIdpManager + if !tt.noIdP { + idpMock = &mockTunnelIdpManager{email: tt.idpEmail, hasData: tt.idpHasData, err: tt.idpErr} + server.idpManager = idpMock + } + + resp, err := server.ValidateTunnelPeer(context.Background(), &proto.ValidateTunnelPeerRequest{ + Domain: domain, + TunnelIp: "100.64.0.1", + }) + + require.NoError(t, err) + require.NotNil(t, resp) + assert.True(t, resp.GetValid(), "expected access granted") + assert.Equal(t, tt.expectEmail, resp.GetUserEmail()) + assert.Equal(t, tt.expectUserID, resp.GetUserId()) + + if idpMock != nil { + if tt.expectIdPHit { + assert.Equal(t, 1, idpMock.gotCalls, "expected IdP to be consulted") + require.Len(t, idpMock.gotMeta, 1) + assert.Equal(t, accountID, idpMock.gotMeta[0].WTAccountID) + } else { + assert.Equal(t, 0, idpMock.gotCalls, "expected IdP to not be consulted") + } + } + }) + } +} + +func TestGetAccountProxyByDomain(t *testing.T) { + tests := []struct { + name string + accountID string + domain string + proxiesByAccount map[string][]*service.Service + err error + expectProxy bool + expectErr bool + }{ + { + name: "proxy found", + accountID: "account1", + domain: "app.example.com", + proxiesByAccount: map[string][]*service.Service{ + "account1": { + {Domain: "other.example.com", AccountID: "account1"}, + {Domain: "app.example.com", AccountID: "account1"}, + }, + }, + expectProxy: true, + expectErr: false, + }, + { + name: "proxy not found in account", + accountID: "account1", + domain: "unknown.example.com", + proxiesByAccount: map[string][]*service.Service{ + "account1": {{Domain: "app.example.com", AccountID: "account1"}}, + }, + expectProxy: false, + expectErr: true, + }, + { + name: "empty proxy list for account", + accountID: "account1", + domain: "app.example.com", + proxiesByAccount: map[string][]*service.Service{}, + expectProxy: false, + expectErr: true, + }, + { + name: "manager error", + accountID: "account1", + domain: "app.example.com", + proxiesByAccount: nil, + err: errors.New("database error"), + expectProxy: false, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := &ProxyServiceServer{ + serviceManager: &mockReverseProxyManager{ + proxiesByAccount: tt.proxiesByAccount, + err: tt.err, + }, + } + + proxy, err := server.getAccountServiceByDomain(context.Background(), tt.accountID, tt.domain) + + if tt.expectErr { + require.Error(t, err) + assert.Nil(t, proxy) + } else { + require.NoError(t, err) + require.NotNil(t, proxy) + assert.Equal(t, tt.domain, proxy.Domain) + } + }) + } +} + +func TestCheckPeerGroupAccess(t *testing.T) { + t.Run("private with empty AccessGroups denies", func(t *testing.T) { + svc := &service.Service{Private: true, AccessGroups: nil} + err := checkPeerGroupAccess(svc, []string{"grp-admins"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no access groups") + }) + + t.Run("private with peer in AccessGroups allows", func(t *testing.T) { + svc := &service.Service{Private: true, AccessGroups: []string{"grp-admins", "grp-ops"}} + assert.NoError(t, checkPeerGroupAccess(svc, []string{"grp-other", "grp-ops"})) + }) + + t.Run("private with peer outside AccessGroups denies", func(t *testing.T) { + svc := &service.Service{Private: true, AccessGroups: []string{"grp-admins"}} + assert.Error(t, checkPeerGroupAccess(svc, []string{"grp-other"})) + }) + + t.Run("bearer enabled with empty DistributionGroups allows", func(t *testing.T) { + svc := &service.Service{ + Auth: service.AuthConfig{BearerAuth: &service.BearerAuthConfig{Enabled: true}}, + } + assert.NoError(t, checkPeerGroupAccess(svc, []string{"grp-anyone"})) + }) + + t.Run("bearer enabled gates on DistributionGroups", func(t *testing.T) { + svc := &service.Service{ + Auth: service.AuthConfig{ + BearerAuth: &service.BearerAuthConfig{ + Enabled: true, + DistributionGroups: []string{"grp-allowed"}, + }, + }, + } + assert.NoError(t, checkPeerGroupAccess(svc, []string{"grp-allowed"})) + assert.Error(t, checkPeerGroupAccess(svc, []string{"grp-other"})) + }) + + t.Run("non-private non-bearer is open", func(t *testing.T) { + assert.NoError(t, checkPeerGroupAccess(&service.Service{}, nil)) + }) +} diff --git a/management/internals/shared/grpc/proxy_snapshot_test.go b/management/internals/shared/grpc/proxy_snapshot_test.go new file mode 100644 index 000000000..68d2ecfd1 --- /dev/null +++ b/management/internals/shared/grpc/proxy_snapshot_test.go @@ -0,0 +1,227 @@ +package grpc + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// recordingStream captures all messages sent via Send so tests can inspect +// batching behaviour without a real gRPC transport. +type recordingStream struct { + grpc.ServerStream + messages []*proto.GetMappingUpdateResponse +} + +func (s *recordingStream) Send(m *proto.GetMappingUpdateResponse) error { + s.messages = append(s.messages, m) + return nil +} + +func (s *recordingStream) Context() context.Context { return context.Background() } +func (s *recordingStream) SetHeader(metadata.MD) error { return nil } +func (s *recordingStream) SendHeader(metadata.MD) error { return nil } +func (s *recordingStream) SetTrailer(metadata.MD) {} +func (s *recordingStream) SendMsg(any) error { return nil } +func (s *recordingStream) RecvMsg(any) error { return nil } + +// makeServices creates n enabled services assigned to the given cluster. +func makeServices(n int, cluster string) []*rpservice.Service { + services := make([]*rpservice.Service, n) + for i := range n { + services[i] = &rpservice.Service{ + ID: fmt.Sprintf("svc-%d", i), + AccountID: "acct-1", + Name: fmt.Sprintf("svc-%d", i), + Domain: fmt.Sprintf("svc-%d.example.com", i), + ProxyCluster: cluster, + Enabled: true, + Targets: []*rpservice.Target{ + {TargetType: rpservice.TargetTypeHost, TargetId: "host-1"}, + }, + } + } + return services +} + +func newSnapshotTestServer(t *testing.T, batchSize int) *ProxyServiceServer { + t.Helper() + s := &ProxyServiceServer{ + tokenStore: NewOneTimeTokenStore(context.Background(), testCacheStore(t)), + snapshotBatchSize: batchSize, + } + s.SetProxyController(newTestProxyController()) + return s +} + +func TestSendSnapshot_BatchesMappings(t *testing.T) { + const cluster = "cluster.example.com" + const batchSize = 3 + const totalServices = 7 // 3 + 3 + 1 + + ctrl := gomock.NewController(t) + mgr := rpservice.NewMockManager(ctrl) + mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil) + + s := newSnapshotTestServer(t, batchSize) + s.serviceManager = mgr + + stream := &recordingStream{} + conn := &proxyConnection{ + proxyID: "proxy-a", + address: cluster, + stream: stream, + } + + err := s.sendSnapshot(context.Background(), conn) + require.NoError(t, err) + + // Expect ceil(7/3) = 3 messages + require.Len(t, stream.messages, 3, "should send ceil(totalServices/batchSize) messages") + + assert.Len(t, stream.messages[0].Mapping, 3) + assert.False(t, stream.messages[0].InitialSyncComplete, "first batch should not be sync-complete") + + assert.Len(t, stream.messages[1].Mapping, 3) + assert.False(t, stream.messages[1].InitialSyncComplete, "middle batch should not be sync-complete") + + assert.Len(t, stream.messages[2].Mapping, 1) + assert.True(t, stream.messages[2].InitialSyncComplete, "last batch must be sync-complete") + + // Verify all service IDs are present exactly once + seen := make(map[string]bool) + for _, msg := range stream.messages { + for _, m := range msg.Mapping { + assert.False(t, seen[m.Id], "duplicate service ID %s", m.Id) + seen[m.Id] = true + } + } + assert.Len(t, seen, totalServices) +} + +func TestSendSnapshot_ExactBatchMultiple(t *testing.T) { + const cluster = "cluster.example.com" + const batchSize = 3 + const totalServices = 6 // exactly 2 batches + + ctrl := gomock.NewController(t) + mgr := rpservice.NewMockManager(ctrl) + mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil) + + s := newSnapshotTestServer(t, batchSize) + s.serviceManager = mgr + + stream := &recordingStream{} + conn := &proxyConnection{proxyID: "proxy-a", address: cluster, stream: stream} + + require.NoError(t, s.sendSnapshot(context.Background(), conn)) + require.Len(t, stream.messages, 2) + + assert.Len(t, stream.messages[0].Mapping, 3) + assert.False(t, stream.messages[0].InitialSyncComplete) + + assert.Len(t, stream.messages[1].Mapping, 3) + assert.True(t, stream.messages[1].InitialSyncComplete) +} + +func TestSendSnapshot_SingleBatch(t *testing.T) { + const cluster = "cluster.example.com" + const batchSize = 100 + const totalServices = 5 + + ctrl := gomock.NewController(t) + mgr := rpservice.NewMockManager(ctrl) + mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil) + + s := newSnapshotTestServer(t, batchSize) + s.serviceManager = mgr + + stream := &recordingStream{} + conn := &proxyConnection{proxyID: "proxy-a", address: cluster, stream: stream} + + require.NoError(t, s.sendSnapshot(context.Background(), conn)) + require.Len(t, stream.messages, 1, "all mappings should fit in one batch") + assert.Len(t, stream.messages[0].Mapping, totalServices) + assert.True(t, stream.messages[0].InitialSyncComplete) +} + +func TestSendSnapshot_EmptySnapshot(t *testing.T) { + const cluster = "cluster.example.com" + + ctrl := gomock.NewController(t) + mgr := rpservice.NewMockManager(ctrl) + mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(nil, nil) + + s := newSnapshotTestServer(t, 500) + s.serviceManager = mgr + + stream := &recordingStream{} + conn := &proxyConnection{proxyID: "proxy-a", address: cluster, stream: stream} + + require.NoError(t, s.sendSnapshot(context.Background(), conn)) + require.Len(t, stream.messages, 1, "empty snapshot must still send sync-complete") + assert.Empty(t, stream.messages[0].Mapping) + assert.True(t, stream.messages[0].InitialSyncComplete) +} + +type hookingStream struct { + grpc.ServerStream + onSend func(*proto.GetMappingUpdateResponse) +} + +func (s *hookingStream) Send(m *proto.GetMappingUpdateResponse) error { + if s.onSend != nil { + s.onSend(m) + } + return nil +} + +func (s *hookingStream) Context() context.Context { return context.Background() } +func (s *hookingStream) SetHeader(metadata.MD) error { return nil } +func (s *hookingStream) SendHeader(metadata.MD) error { return nil } +func (s *hookingStream) SetTrailer(metadata.MD) {} +func (s *hookingStream) SendMsg(any) error { return nil } +func (s *hookingStream) RecvMsg(any) error { return nil } + +func TestSendSnapshot_TokensRemainValidUnderSlowSend(t *testing.T) { + const cluster = "cluster.example.com" + const batchSize = 2 + const totalServices = 6 + const ttl = 100 * time.Millisecond + const sendDelay = 200 * time.Millisecond + + ctrl := gomock.NewController(t) + mgr := rpservice.NewMockManager(ctrl) + mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil) + + s := newSnapshotTestServer(t, batchSize) + s.serviceManager = mgr + s.tokenTTL = ttl + + var validateErrs []error + stream := &hookingStream{ + onSend: func(resp *proto.GetMappingUpdateResponse) { + for _, m := range resp.Mapping { + if err := s.tokenStore.ValidateAndConsume(m.AuthToken, m.AccountId, m.Id); err != nil { + validateErrs = append(validateErrs, fmt.Errorf("svc %s: %w", m.Id, err)) + } + } + time.Sleep(sendDelay) + }, + } + conn := &proxyConnection{proxyID: "proxy-a", address: cluster, stream: stream} + + require.NoError(t, s.sendSnapshot(context.Background(), conn)) + require.Empty(t, validateErrs, + "tokens must remain valid even when batches are sent slowly: lazy per-batch generation guarantees freshness") +} diff --git a/management/internals/shared/grpc/proxy_test.go b/management/internals/shared/grpc/proxy_test.go new file mode 100644 index 000000000..0379edc6d --- /dev/null +++ b/management/internals/shared/grpc/proxy_test.go @@ -0,0 +1,721 @@ +package grpc + +import ( + "context" + "crypto/rand" + "encoding/base64" + "strings" + "sync" + "testing" + "time" + + cachestore "github.com/eko/gocache/lib/v4/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + grpcstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + nbcache "github.com/netbirdio/netbird/management/server/cache" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +func testCacheStore(t *testing.T) cachestore.StoreInterface { + t.Helper() + s, err := nbcache.NewStore(context.Background(), 30*time.Minute, 10*time.Minute, 100) + require.NoError(t, err) + return s +} + +type testProxyController struct { + mu sync.Mutex + clusterProxies map[string]map[string]struct{} +} + +func newTestProxyController() *testProxyController { + return &testProxyController{ + clusterProxies: make(map[string]map[string]struct{}), + } +} + +func (c *testProxyController) SendServiceUpdateToCluster(_ context.Context, _ string, _ *proto.ProxyMapping, _ string) { +} + +func (c *testProxyController) GetOIDCValidationConfig() proxy.OIDCValidationConfig { + return proxy.OIDCValidationConfig{} +} + +func (c *testProxyController) RegisterProxyToCluster(_ context.Context, clusterAddr, proxyID string) error { + c.mu.Lock() + defer c.mu.Unlock() + if _, ok := c.clusterProxies[clusterAddr]; !ok { + c.clusterProxies[clusterAddr] = make(map[string]struct{}) + } + c.clusterProxies[clusterAddr][proxyID] = struct{}{} + return nil +} + +func (c *testProxyController) UnregisterProxyFromCluster(_ context.Context, clusterAddr, proxyID string) error { + c.mu.Lock() + defer c.mu.Unlock() + if proxies, ok := c.clusterProxies[clusterAddr]; ok { + delete(proxies, proxyID) + } + return nil +} + +func (c *testProxyController) GetProxiesForCluster(clusterAddr string) []string { + c.mu.Lock() + defer c.mu.Unlock() + proxies, ok := c.clusterProxies[clusterAddr] + if !ok { + return nil + } + result := make([]string, 0, len(proxies)) + for id := range proxies { + result = append(result, id) + } + return result +} + +// registerFakeProxy adds a fake proxy connection to the server's internal maps +// and returns the channel where messages will be received. +func registerFakeProxy(s *ProxyServiceServer, proxyID, clusterAddr string) chan *proto.GetMappingUpdateResponse { + return registerFakeProxyWithCaps(s, proxyID, clusterAddr, nil) +} + +// registerFakeProxyWithCaps adds a fake proxy connection with explicit capabilities. +func registerFakeProxyWithCaps(s *ProxyServiceServer, proxyID, clusterAddr string, caps *proto.ProxyCapabilities) chan *proto.GetMappingUpdateResponse { + ch := make(chan *proto.GetMappingUpdateResponse, 10) + ctx, cancel := context.WithCancel(context.Background()) + conn := &proxyConnection{ + proxyID: proxyID, + address: clusterAddr, + capabilities: caps, + sendChan: ch, + ctx: ctx, + cancel: cancel, + } + s.connectedProxies.Store(proxyID, conn) + + _ = s.proxyController.RegisterProxyToCluster(context.Background(), clusterAddr, proxyID) + + return ch +} + +// drainMapping drains a single ProxyMapping from the channel. +func drainMapping(ch chan *proto.GetMappingUpdateResponse) *proto.ProxyMapping { + select { + case resp := <-ch: + if len(resp.Mapping) > 0 { + return resp.Mapping[0] + } + return nil + case <-time.After(time.Second): + return nil + } +} + +// drainEmpty checks if a channel has no message within timeout. +func drainEmpty(ch chan *proto.GetMappingUpdateResponse) bool { + select { + case <-ch: + return false + case <-time.After(100 * time.Millisecond): + return true + } +} + +func TestSendServiceUpdateToCluster_UniqueTokensPerProxy(t *testing.T) { + ctx := context.Background() + tokenStore := NewOneTimeTokenStore(ctx, testCacheStore(t)) + pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t)) + + s := &ProxyServiceServer{ + tokenStore: tokenStore, + pkceVerifierStore: pkceStore, + } + s.SetProxyController(newTestProxyController()) + + const cluster = "proxy.example.com" + const numProxies = 3 + + channels := make([]chan *proto.GetMappingUpdateResponse, numProxies) + for i := range numProxies { + id := "proxy-" + string(rune('a'+i)) + channels[i] = registerFakeProxy(s, id, cluster) + } + + mapping := &proto.ProxyMapping{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, + Id: "service-1", + AccountId: "account-1", + Domain: "test.example.com", + Path: []*proto.PathMapping{ + {Path: "/", Target: "http://10.0.0.1:8080/"}, + }, + } + + s.SendServiceUpdateToCluster(context.Background(), mapping, cluster) + + tokens := make([]string, numProxies) + for i, ch := range channels { + msg := drainMapping(ch) + require.NotNil(t, msg, "proxy %d should receive a message", i) + assert.Equal(t, mapping.Domain, msg.Domain) + assert.Equal(t, mapping.Id, msg.Id) + assert.NotEmpty(t, msg.AuthToken, "proxy %d should have a non-empty token", i) + tokens[i] = msg.AuthToken + } + + // All tokens must be unique + tokenSet := make(map[string]struct{}) + for i, tok := range tokens { + _, exists := tokenSet[tok] + assert.False(t, exists, "proxy %d got duplicate token", i) + tokenSet[tok] = struct{}{} + } + + // Each token must be independently consumable + for i, tok := range tokens { + err := tokenStore.ValidateAndConsume(tok, "account-1", "service-1") + assert.NoError(t, err, "proxy %d token should validate successfully", i) + } +} + +func TestSendServiceUpdateToCluster_DeleteNoToken(t *testing.T) { + ctx := context.Background() + tokenStore := NewOneTimeTokenStore(ctx, testCacheStore(t)) + pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t)) + + s := &ProxyServiceServer{ + tokenStore: tokenStore, + pkceVerifierStore: pkceStore, + } + s.SetProxyController(newTestProxyController()) + + const cluster = "proxy.example.com" + ch1 := registerFakeProxy(s, "proxy-a", cluster) + ch2 := registerFakeProxy(s, "proxy-b", cluster) + + mapping := &proto.ProxyMapping{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, + Id: "service-1", + AccountId: "account-1", + Domain: "test.example.com", + } + + s.SendServiceUpdateToCluster(context.Background(), mapping, cluster) + + msg1 := drainMapping(ch1) + msg2 := drainMapping(ch2) + require.NotNil(t, msg1) + require.NotNil(t, msg2) + + // Delete operations should not generate tokens + assert.Empty(t, msg1.AuthToken) + assert.Empty(t, msg2.AuthToken) +} + +func TestSendServiceUpdate_UniqueTokensPerProxy(t *testing.T) { + ctx := context.Background() + tokenStore := NewOneTimeTokenStore(ctx, testCacheStore(t)) + pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t)) + + s := &ProxyServiceServer{ + tokenStore: tokenStore, + pkceVerifierStore: pkceStore, + } + s.SetProxyController(newTestProxyController()) + + // Register proxies in different clusters (SendServiceUpdate broadcasts to all) + ch1 := registerFakeProxy(s, "proxy-a", "cluster-a") + ch2 := registerFakeProxy(s, "proxy-b", "cluster-b") + + mapping := &proto.ProxyMapping{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, + Id: "service-1", + AccountId: "account-1", + Domain: "test.example.com", + } + + update := &proto.GetMappingUpdateResponse{ + Mapping: []*proto.ProxyMapping{mapping}, + } + + s.SendServiceUpdate(update) + + msg1 := drainMapping(ch1) + msg2 := drainMapping(ch2) + require.NotNil(t, msg1) + require.NotNil(t, msg2) + + assert.NotEmpty(t, msg1.AuthToken) + assert.NotEmpty(t, msg2.AuthToken) + assert.NotEqual(t, msg1.AuthToken, msg2.AuthToken, "tokens must be unique per proxy") + + // Both tokens should validate + assert.NoError(t, tokenStore.ValidateAndConsume(msg1.AuthToken, "account-1", "service-1")) + assert.NoError(t, tokenStore.ValidateAndConsume(msg2.AuthToken, "account-1", "service-1")) +} + +// generateState creates a state using the same format as GetOIDCURL. +func generateState(s *ProxyServiceServer, redirectURL string) string { + nonce := make([]byte, 16) + _, _ = rand.Read(nonce) + nonceB64 := base64.URLEncoding.EncodeToString(nonce) + + payload := redirectURL + "|" + nonceB64 + hmacSum := s.generateHMAC(payload) + return base64.URLEncoding.EncodeToString([]byte(redirectURL)) + "|" + nonceB64 + "|" + hmacSum +} + +func TestOAuthState_NeverTheSame(t *testing.T) { + ctx := context.Background() + pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t)) + + s := &ProxyServiceServer{ + oidcConfig: ProxyOIDCConfig{ + HMACKey: []byte("test-hmac-key"), + }, + pkceVerifierStore: pkceStore, + } + + redirectURL := "https://app.example.com/callback" + + // Generate 100 states for the same redirect URL + states := make(map[string]bool) + for i := 0; i < 100; i++ { + state := generateState(s, redirectURL) + + // State must have 3 parts: base64(url)|nonce|hmac + parts := strings.Split(state, "|") + require.Equal(t, 3, len(parts), "state must have 3 parts") + + // State must be unique + require.False(t, states[state], "state %d is a duplicate", i) + states[state] = true + } +} + +func TestValidateState_RejectsOldTwoPartFormat(t *testing.T) { + ctx := context.Background() + pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t)) + + s := &ProxyServiceServer{ + oidcConfig: ProxyOIDCConfig{ + HMACKey: []byte("test-hmac-key"), + }, + pkceVerifierStore: pkceStore, + } + + // Old format had only 2 parts: base64(url)|hmac + err := s.pkceVerifierStore.Store("base64url|hmac", "test", 10*time.Minute) + require.NoError(t, err) + + _, _, err = s.ValidateState("base64url|hmac") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid state format") +} + +func scopedCtx(accountID string) context.Context { + token := &types.ProxyAccessToken{ + ID: "token-1", + AccountID: &accountID, + } + return context.WithValue(context.Background(), ProxyTokenContextKey, token) +} + +func globalCtx() context.Context { + token := &types.ProxyAccessToken{ + ID: "token-global", + } + return context.WithValue(context.Background(), ProxyTokenContextKey, token) +} + +func TestEnforceAccountScope_AllowsMatchingAccount(t *testing.T) { + err := enforceAccountScope(scopedCtx("acc-1"), "acc-1") + assert.NoError(t, err) +} + +func TestEnforceAccountScope_BlocksMismatchedAccount(t *testing.T) { + err := enforceAccountScope(scopedCtx("acc-1"), "acc-2") + require.Error(t, err) + st, ok := grpcstatus.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.PermissionDenied, st.Code()) +} + +func TestEnforceAccountScope_BlocksEmptyRequestAccountID(t *testing.T) { + err := enforceAccountScope(scopedCtx("acc-1"), "") + require.Error(t, err) + st, ok := grpcstatus.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.PermissionDenied, st.Code()) +} + +func TestEnforceAccountScope_AllowsGlobalToken(t *testing.T) { + err := enforceAccountScope(globalCtx(), "acc-1") + assert.NoError(t, err) + + err = enforceAccountScope(globalCtx(), "acc-2") + assert.NoError(t, err) + + err = enforceAccountScope(globalCtx(), "") + assert.NoError(t, err) +} + +func TestEnforceAccountScope_AllowsNoTokenInContext(t *testing.T) { + err := enforceAccountScope(context.Background(), "acc-1") + assert.NoError(t, err) +} + +func TestValidateState_RejectsInvalidHMAC(t *testing.T) { + ctx := context.Background() + pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t)) + + s := &ProxyServiceServer{ + oidcConfig: ProxyOIDCConfig{ + HMACKey: []byte("test-hmac-key"), + }, + pkceVerifierStore: pkceStore, + } + + // Store with tampered HMAC + err := s.pkceVerifierStore.Store("dGVzdA==|nonce|wrong-hmac", "test", 10*time.Minute) + require.NoError(t, err) + + _, _, err = s.ValidateState("dGVzdA==|nonce|wrong-hmac") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid state signature") +} + +func TestSendServiceUpdateToCluster_FiltersOnCapability(t *testing.T) { + tokenStore := NewOneTimeTokenStore(context.Background(), testCacheStore(t)) + + s := &ProxyServiceServer{ + tokenStore: tokenStore, + } + s.SetProxyController(newTestProxyController()) + + const cluster = "proxy.example.com" + + // Modern proxy reports capabilities. + chModern := registerFakeProxyWithCaps(s, "proxy-modern", cluster, &proto.ProxyCapabilities{SupportsCustomPorts: ptr(true)}) + // Legacy proxy never reported capabilities (nil). + chLegacy := registerFakeProxy(s, "proxy-legacy", cluster) + + ctx := context.Background() + + // TLS passthrough with custom port: all proxies receive it (SNI routing). + tlsMapping := &proto.ProxyMapping{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, + Id: "service-tls", + AccountId: "account-1", + Domain: "db.example.com", + Mode: "tls", + ListenPort: 8443, + Path: []*proto.PathMapping{{Target: "10.0.0.5:5432"}}, + } + + s.SendServiceUpdateToCluster(ctx, tlsMapping, cluster) + + assert.NotNil(t, drainMapping(chModern), "modern proxy should receive TLS mapping") + assert.NotNil(t, drainMapping(chLegacy), "legacy proxy should receive TLS mapping (SNI works on all)") + + // TCP mapping with custom port: only modern proxy receives it. + tcpMapping := &proto.ProxyMapping{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, + Id: "service-tcp", + AccountId: "account-1", + Domain: "db.example.com", + Mode: "tcp", + ListenPort: 5432, + Path: []*proto.PathMapping{{Target: "10.0.0.5:5432"}}, + } + + s.SendServiceUpdateToCluster(ctx, tcpMapping, cluster) + + assert.NotNil(t, drainMapping(chModern), "modern proxy should receive TCP custom-port mapping") + assert.Nil(t, drainMapping(chLegacy), "legacy proxy should NOT receive TCP custom-port mapping") + + // HTTP mapping (no listen port): both receive it. + httpMapping := &proto.ProxyMapping{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, + Id: "service-http", + AccountId: "account-1", + Domain: "app.example.com", + Path: []*proto.PathMapping{{Path: "/", Target: "http://10.0.0.1:80"}}, + } + + s.SendServiceUpdateToCluster(ctx, httpMapping, cluster) + + assert.NotNil(t, drainMapping(chModern), "modern proxy should receive HTTP mapping") + assert.NotNil(t, drainMapping(chLegacy), "legacy proxy should receive HTTP mapping") + + // Proxy that reports SupportsCustomPorts=false still receives custom-port + // mappings because it understands the protocol (it's new enough). + chNewNoCustom := registerFakeProxyWithCaps(s, "proxy-new-no-custom", cluster, &proto.ProxyCapabilities{SupportsCustomPorts: ptr(false)}) + + s.SendServiceUpdateToCluster(ctx, tcpMapping, cluster) + + assert.NotNil(t, drainMapping(chNewNoCustom), "new proxy with SupportsCustomPorts=false should still receive mapping") +} + +func TestSendServiceUpdateToCluster_TLSNotFiltered(t *testing.T) { + tokenStore := NewOneTimeTokenStore(context.Background(), testCacheStore(t)) + + s := &ProxyServiceServer{ + tokenStore: tokenStore, + } + s.SetProxyController(newTestProxyController()) + + const cluster = "proxy.example.com" + + // Legacy proxy (no capabilities) still receives TLS since it uses SNI. + chLegacy := registerFakeProxy(s, "proxy-legacy", cluster) + + tlsMapping := &proto.ProxyMapping{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, + Id: "service-tls", + AccountId: "account-1", + Domain: "db.example.com", + Mode: "tls", + Path: []*proto.PathMapping{{Target: "10.0.0.5:5432"}}, + } + + s.SendServiceUpdateToCluster(context.Background(), tlsMapping, cluster) + + msg := drainMapping(chLegacy) + assert.NotNil(t, msg, "legacy proxy should receive TLS mapping (SNI works without custom port support)") +} + +// TestServiceModifyNotifications exercises every possible modification +// scenario for an existing service, verifying the correct update types +// reach the correct clusters. +func TestServiceModifyNotifications(t *testing.T) { + tokenStore := NewOneTimeTokenStore(context.Background(), testCacheStore(t)) + + newServer := func() (*ProxyServiceServer, map[string]chan *proto.GetMappingUpdateResponse) { + s := &ProxyServiceServer{ + tokenStore: tokenStore, + } + s.SetProxyController(newTestProxyController()) + chs := map[string]chan *proto.GetMappingUpdateResponse{ + "cluster-a": registerFakeProxyWithCaps(s, "proxy-a", "cluster-a", &proto.ProxyCapabilities{SupportsCustomPorts: ptr(true)}), + "cluster-b": registerFakeProxyWithCaps(s, "proxy-b", "cluster-b", &proto.ProxyCapabilities{SupportsCustomPorts: ptr(true)}), + } + return s, chs + } + + httpMapping := func(updateType proto.ProxyMappingUpdateType) *proto.ProxyMapping { + return &proto.ProxyMapping{ + Type: updateType, + Id: "svc-1", + AccountId: "acct-1", + Domain: "app.example.com", + Path: []*proto.PathMapping{{Path: "/", Target: "http://10.0.0.1:8080"}}, + } + } + + tlsOnlyMapping := func(updateType proto.ProxyMappingUpdateType) *proto.ProxyMapping { + return &proto.ProxyMapping{ + Type: updateType, + Id: "svc-1", + AccountId: "acct-1", + Domain: "app.example.com", + Mode: "tls", + ListenPort: 8443, + Path: []*proto.PathMapping{{Target: "10.0.0.1:443"}}, + } + } + + ctx := context.Background() + + t.Run("targets changed sends MODIFIED to same cluster", func(t *testing.T) { + s, chs := newServer() + s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED), "cluster-a") + + msg := drainMapping(chs["cluster-a"]) + require.NotNil(t, msg, "cluster-a should receive update") + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED, msg.Type) + assert.NotEmpty(t, msg.AuthToken, "MODIFIED should include token") + assert.True(t, drainEmpty(chs["cluster-b"]), "cluster-b should not receive update") + }) + + t.Run("auth config changed sends MODIFIED", func(t *testing.T) { + s, chs := newServer() + mapping := httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED) + mapping.Auth = &proto.Authentication{Password: true, Pin: true} + s.SendServiceUpdateToCluster(ctx, mapping, "cluster-a") + + msg := drainMapping(chs["cluster-a"]) + require.NotNil(t, msg) + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED, msg.Type) + assert.True(t, msg.Auth.Password) + assert.True(t, msg.Auth.Pin) + }) + + t.Run("HTTP to TLS transition sends MODIFIED with TLS config", func(t *testing.T) { + s, chs := newServer() + s.SendServiceUpdateToCluster(ctx, tlsOnlyMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED), "cluster-a") + + msg := drainMapping(chs["cluster-a"]) + require.NotNil(t, msg) + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED, msg.Type) + assert.Equal(t, "tls", msg.Mode, "mode should be tls") + assert.Equal(t, int32(8443), msg.ListenPort) + assert.Len(t, msg.Path, 1, "should have one path entry with target address") + assert.Equal(t, "10.0.0.1:443", msg.Path[0].Target) + }) + + t.Run("TLS to HTTP transition sends MODIFIED without TLS", func(t *testing.T) { + s, chs := newServer() + s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED), "cluster-a") + + msg := drainMapping(chs["cluster-a"]) + require.NotNil(t, msg) + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED, msg.Type) + assert.Empty(t, msg.Mode, "mode should be empty for HTTP") + assert.True(t, len(msg.Path) > 0) + }) + + t.Run("TLS port changed sends MODIFIED with new port", func(t *testing.T) { + s, chs := newServer() + mapping := tlsOnlyMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED) + mapping.ListenPort = 9443 + s.SendServiceUpdateToCluster(ctx, mapping, "cluster-a") + + msg := drainMapping(chs["cluster-a"]) + require.NotNil(t, msg) + assert.Equal(t, int32(9443), msg.ListenPort) + }) + + t.Run("disable sends REMOVED to cluster", func(t *testing.T) { + s, chs := newServer() + // Manager sends Delete when service is disabled + s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED), "cluster-a") + + msg := drainMapping(chs["cluster-a"]) + require.NotNil(t, msg) + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, msg.Type) + assert.Empty(t, msg.AuthToken, "DELETE should not have token") + }) + + t.Run("enable sends CREATED to cluster", func(t *testing.T) { + s, chs := newServer() + s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED), "cluster-a") + + msg := drainMapping(chs["cluster-a"]) + require.NotNil(t, msg) + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, msg.Type) + assert.NotEmpty(t, msg.AuthToken) + }) + + t.Run("domain change with cluster change sends DELETE to old CREATE to new", func(t *testing.T) { + s, chs := newServer() + // This is the pattern the manager produces: + // 1. DELETE on old cluster + s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED), "cluster-a") + // 2. CREATE on new cluster + s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED), "cluster-b") + + msgA := drainMapping(chs["cluster-a"]) + require.NotNil(t, msgA, "old cluster should receive DELETE") + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, msgA.Type) + + msgB := drainMapping(chs["cluster-b"]) + require.NotNil(t, msgB, "new cluster should receive CREATE") + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, msgB.Type) + assert.NotEmpty(t, msgB.AuthToken) + }) + + t.Run("domain change same cluster sends DELETE then CREATE", func(t *testing.T) { + s, chs := newServer() + // Domain changes within same cluster: manager sends DELETE (old domain) + CREATE (new domain). + s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED), "cluster-a") + s.SendServiceUpdateToCluster(ctx, httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED), "cluster-a") + + msgDel := drainMapping(chs["cluster-a"]) + require.NotNil(t, msgDel, "same cluster should receive DELETE") + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, msgDel.Type) + + msgCreate := drainMapping(chs["cluster-a"]) + require.NotNil(t, msgCreate, "same cluster should receive CREATE") + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, msgCreate.Type) + assert.NotEmpty(t, msgCreate.AuthToken) + }) + + t.Run("TLS passthrough sent to all proxies", func(t *testing.T) { + s := &ProxyServiceServer{ + tokenStore: tokenStore, + } + s.SetProxyController(newTestProxyController()) + const cluster = "proxy.example.com" + chModern := registerFakeProxyWithCaps(s, "modern", cluster, &proto.ProxyCapabilities{SupportsCustomPorts: ptr(true)}) + chLegacy := registerFakeProxy(s, "legacy", cluster) + + // TLS passthrough works on all proxies regardless of custom port support + s.SendServiceUpdateToCluster(ctx, tlsOnlyMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED), cluster) + + msgModern := drainMapping(chModern) + require.NotNil(t, msgModern, "modern proxy receives TLS update") + assert.Equal(t, "tls", msgModern.Mode) + + msgLegacy := drainMapping(chLegacy) + assert.NotNil(t, msgLegacy, "legacy proxy should also receive TLS passthrough") + }) + + t.Run("TLS on default port NOT filtered for legacy proxy", func(t *testing.T) { + s := &ProxyServiceServer{ + tokenStore: tokenStore, + } + s.SetProxyController(newTestProxyController()) + const cluster = "proxy.example.com" + chLegacy := registerFakeProxy(s, "legacy", cluster) + + mapping := tlsOnlyMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED) + mapping.ListenPort = 0 // default port + s.SendServiceUpdateToCluster(ctx, mapping, cluster) + + msgLegacy := drainMapping(chLegacy) + assert.NotNil(t, msgLegacy, "legacy proxy should receive TLS on default port") + }) + + t.Run("passthrough and rewrite flags propagated", func(t *testing.T) { + s, chs := newServer() + mapping := httpMapping(proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED) + mapping.PassHostHeader = true + mapping.RewriteRedirects = true + s.SendServiceUpdateToCluster(ctx, mapping, "cluster-a") + + msg := drainMapping(chs["cluster-a"]) + require.NotNil(t, msg) + assert.True(t, msg.PassHostHeader) + assert.True(t, msg.RewriteRedirects) + }) + + t.Run("multiple paths propagated in MODIFIED", func(t *testing.T) { + s, chs := newServer() + mapping := &proto.ProxyMapping{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED, + Id: "svc-multi", + AccountId: "acct-1", + Domain: "multi.example.com", + Path: []*proto.PathMapping{ + {Path: "/", Target: "http://10.0.0.1:8080"}, + {Path: "/api", Target: "http://10.0.0.2:9090"}, + {Path: "/ws", Target: "http://10.0.0.3:3000"}, + }, + } + s.SendServiceUpdateToCluster(ctx, mapping, "cluster-a") + + msg := drainMapping(chs["cluster-a"]) + require.NotNil(t, msg) + require.Len(t, msg.Path, 3, "all paths should be present") + assert.Equal(t, "/", msg.Path[0].Path) + assert.Equal(t, "/api", msg.Path[1].Path) + assert.Equal(t, "/ws", msg.Path[2].Path) + }) +} diff --git a/management/internals/shared/grpc/server.go b/management/internals/shared/grpc/server.go index 08a840316..3b7d62ac7 100644 --- a/management/internals/shared/grpc/server.go +++ b/management/internals/shared/grpc/server.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "net" "net/netip" "os" @@ -13,6 +14,7 @@ import ( "sync/atomic" "time" + jwtv5 "github.com/golang-jwt/jwt/v5" pb "github.com/golang/protobuf/proto" // nolint "github.com/golang/protobuf/ptypes/timestamp" "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/realip" @@ -22,9 +24,14 @@ import ( "google.golang.org/grpc/peer" "google.golang.org/grpc/status" + "github.com/netbirdio/netbird/shared/management/client/common" + "github.com/netbirdio/netbird/shared/management/grpc" + "github.com/netbirdio/netbird/management/internals/controllers/network_map" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" - "github.com/netbirdio/netbird/management/server/peers/ephemeral" + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" "github.com/netbirdio/netbird/management/server/store" @@ -55,15 +62,14 @@ const ( type Server struct { accountManager account.Manager settingsManager settings.Manager - wgKey wgtypes.Key proto.UnimplementedManagementServiceServer - peersUpdateManager network_map.PeersUpdateManager - config *nbconfig.Config - secretsManager SecretsManager - appMetrics telemetry.AppMetrics - ephemeralManager ephemeral.Manager - peerLocks sync.Map - authManager auth.Manager + jobManager *job.Manager + config *nbconfig.Config + secretsManager SecretsManager + appMetrics telemetry.AppMetrics + peerLocks sync.Map + authManager auth.Manager + sessionStore *auth.SessionStore logBlockedPeers bool blockPeersWithSameConfig bool @@ -73,8 +79,14 @@ type Server struct { networkMapController network_map.Controller - syncSem atomic.Int32 - syncLim int32 + oAuthConfigProvider idp.OAuthConfigProvider + + syncSem atomic.Int32 + syncLimEnabled bool + syncLim int32 + + reverseProxyManager rpservice.Manager + reverseProxyMu sync.RWMutex } // NewServer creates a new Management server @@ -82,23 +94,19 @@ func NewServer( config *nbconfig.Config, accountManager account.Manager, settingsManager settings.Manager, - peersUpdateManager network_map.PeersUpdateManager, + jobManager *job.Manager, secretsManager SecretsManager, appMetrics telemetry.AppMetrics, - ephemeralManager ephemeral.Manager, authManager auth.Manager, integratedPeerValidator integrated_validator.IntegratedValidator, networkMapController network_map.Controller, + oAuthConfigProvider idp.OAuthConfigProvider, + sessionStore *auth.SessionStore, ) (*Server, error) { - key, err := wgtypes.GeneratePrivateKey() - if err != nil { - return nil, err - } - if appMetrics != nil { // update gauge based on number of connected peers which is equal to open gRPC streams - err = appMetrics.GRPCMetrics().RegisterConnectedStreams(func() int64 { - return int64(peersUpdateManager.CountStreams()) + err := appMetrics.GRPCMetrics().RegisterConnectedStreams(func() int64 { + return int64(networkMapController.CountStreams()) }) if err != nil { return nil, err @@ -109,6 +117,7 @@ func NewServer( blockPeersWithSameConfig := strings.ToLower(os.Getenv(envBlockPeers)) == "true" syncLim := int32(defaultSyncLim) + syncLimEnabled := true if syncLimStr := os.Getenv(envConcurrentSyncs); syncLimStr != "" { syncLimParsed, err := strconv.Atoi(syncLimStr) if err != nil { @@ -116,28 +125,31 @@ func NewServer( } else { //nolint:gosec syncLim = int32(syncLimParsed) + if syncLim < 0 { + syncLimEnabled = false + } } } return &Server{ - wgKey: key, - // peerKey -> event channel - peersUpdateManager: peersUpdateManager, + jobManager: jobManager, accountManager: accountManager, settingsManager: settingsManager, config: config, secretsManager: secretsManager, authManager: authManager, appMetrics: appMetrics, - ephemeralManager: ephemeralManager, logBlockedPeers: logBlockedPeers, blockPeersWithSameConfig: blockPeersWithSameConfig, integratedPeerValidator: integratedPeerValidator, networkMapController: networkMapController, + oAuthConfigProvider: oAuthConfigProvider, + sessionStore: sessionStore, loginFilter: newLoginFilter(), - syncLim: syncLim, + syncLim: syncLim, + syncLimEnabled: syncLimEnabled, }, nil } @@ -149,10 +161,6 @@ func (s *Server) GetServerKey(ctx context.Context, req *proto.Empty) (*proto.Ser } log.WithContext(ctx).Tracef("GetServerKey request from %s", ip) - start := time.Now() - defer func() { - log.WithContext(ctx).Tracef("GetServerKey from %s took %v", ip, time.Since(start)) - }() // todo introduce something more meaningful with the key expiration/rotation if s.appMetrics != nil { @@ -163,8 +171,14 @@ func (s *Server) GetServerKey(ctx context.Context, req *proto.Empty) (*proto.Ser nanos := int32(now.Nanosecond()) expiresAt := ×tamp.Timestamp{Seconds: secs, Nanos: nanos} + key, err := s.secretsManager.GetWGKey() + if err != nil { + log.WithContext(ctx).Errorf("failed to get wireguard key: %v", err) + return nil, errors.New("failed to get wireguard key") + } + return &proto.ServerKeyResponse{ - Key: s.wgKey.PublicKey().String(), + Key: key.PublicKey().String(), ExpiresAt: expiresAt, }, nil } @@ -176,15 +190,50 @@ func getRealIP(ctx context.Context) net.IP { return nil } +func (s *Server) Job(srv proto.ManagementService_JobServer) error { + reqStart := time.Now() + ctx := srv.Context() + + peerKey, err := s.handleHandshake(ctx, srv) + if err != nil { + return err + } + + accountID, err := s.accountManager.GetAccountIDForPeerKey(ctx, peerKey.String()) + if err != nil { + // nolint:staticcheck + ctx = context.WithValue(ctx, nbContext.AccountIDKey, "UNKNOWN") + log.WithContext(ctx).Tracef("peer %s is not registered", peerKey.String()) + if errStatus, ok := internalStatus.FromError(err); ok && errStatus.Type() == internalStatus.NotFound { + return status.Errorf(codes.PermissionDenied, "peer is not registered") + } + return err + } + // nolint:staticcheck + ctx = context.WithValue(ctx, nbContext.AccountIDKey, accountID) + peer, err := s.accountManager.GetStore().GetPeerByPeerPubKey(ctx, store.LockingStrengthNone, peerKey.String()) + if err != nil { + return status.Errorf(codes.Unauthenticated, "peer is not registered") + } + + s.startResponseReceiver(ctx, srv) + + updates := s.jobManager.CreateJobChannel(ctx, accountID, peer.ID) + log.WithContext(ctx).Debugf("Job: took %v", time.Since(reqStart)) + + return s.sendJobsLoop(ctx, accountID, peerKey, peer, updates, srv) +} + // Sync validates the existence of a connecting peer, sends an initial state (all available for the connecting peers) and // notifies the connected peer of any updates (e.g. new peers under the same account) func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_SyncServer) error { - if s.syncSem.Load() >= s.syncLim { + if s.syncLimEnabled && s.syncSem.Load() >= s.syncLim { return status.Errorf(codes.ResourceExhausted, "too many concurrent sync requests, please try again later") } s.syncSem.Add(1) reqStart := time.Now() + syncStart := reqStart.UTC() ctx := srv.Context() @@ -197,13 +246,23 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S realIP := getRealIP(ctx) sRealIP := realIP.String() peerMeta := extractPeerMeta(ctx, syncReq.GetMeta()) - metahashed := metaHash(peerMeta, sRealIP) - if !s.loginFilter.allowLogin(peerKey.String(), metahashed) { + + userID, err := s.accountManager.GetUserIDByPeerKey(ctx, peerKey.String()) + if err != nil { + s.syncSem.Add(-1) + if errStatus, ok := internalStatus.FromError(err); ok && errStatus.Type() == internalStatus.NotFound { + return status.Errorf(codes.PermissionDenied, "peer is not registered") + } + return mapError(ctx, err) + } + + metahashed := metaHash(peerMeta) + if userID == "" && !s.loginFilter.allowLogin(peerKey.String(), metahashed) { if s.appMetrics != nil { s.appMetrics.GRPCMetrics().CountSyncRequestBlocked() } if s.logBlockedPeers { - log.WithContext(ctx).Warnf("peer %s with meta hash %d is blocked from syncing", peerKey.String(), metahashed) + log.WithContext(ctx).Tracef("peer %s with meta hash %d is blocked from syncing", peerKey.String(), metahashed) } if s.blockPeersWithSameConfig { s.syncSem.Add(-1) @@ -231,8 +290,6 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S return err } - log.WithContext(ctx).Debugf("Sync: GetAccountIDForPeerKey since start %v", time.Since(reqStart)) - // nolint:staticcheck ctx = context.WithValue(ctx, nbContext.AccountIDKey, accountID) @@ -244,7 +301,6 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S } }() log.WithContext(ctx).Tracef("acquired peer lock for peer %s took %v", peerKey.String(), time.Since(start)) - log.WithContext(ctx).Debugf("Sync: acquirePeerLockByUID since start %v", time.Since(reqStart)) log.WithContext(ctx).Debugf("Sync request from peer [%s] [%s]", req.WgPubKey, sRealIP) @@ -252,10 +308,10 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S log.WithContext(ctx).Tracef("peer system meta has to be provided on sync. Peer %s, remote addr %s", peerKey.String(), realIP) } - metahash := metaHash(peerMeta, realIP.String()) + metahash := metaHash(peerMeta) s.loginFilter.addLogin(peerKey.String(), metahash) - peer, netMap, postureChecks, dnsFwdPort, err := s.accountManager.SyncAndMarkPeer(ctx, accountID, peerKey.String(), peerMeta, realIP) + peer, netMap, postureChecks, dnsFwdPort, err := s.accountManager.SyncAndMarkPeer(ctx, accountID, peerKey.String(), peerMeta, realIP, syncStart) if err != nil { log.WithContext(ctx).Debugf("error while syncing peer %s: %v", peerKey.String(), err) s.syncSem.Add(-1) @@ -266,33 +322,112 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S if err != nil { log.WithContext(ctx).Debugf("error while sending initial sync for %s: %v", peerKey.String(), err) s.syncSem.Add(-1) + s.cancelPeerRoutinesWithoutLock(ctx, accountID, peer, syncStart) return err } - updates := s.peersUpdateManager.CreateChannel(ctx, peer.ID) - - s.ephemeralManager.OnPeerConnected(ctx, peer) + updates, err := s.networkMapController.OnPeerConnected(ctx, accountID, peer.ID) + if err != nil { + log.WithContext(ctx).Debugf("error while notify peer connected for %s: %v", peerKey.String(), err) + s.syncSem.Add(-1) + s.cancelPeerRoutinesWithoutLock(ctx, accountID, peer, syncStart) + return err + } s.secretsManager.SetupRefresh(ctx, accountID, peer.ID) - if s.appMetrics != nil { - s.appMetrics.GRPCMetrics().CountSyncRequestDuration(time.Since(reqStart), accountID) - } - unlock() unlock = nil + if s.appMetrics != nil { + s.appMetrics.GRPCMetrics().CountSyncRequestDuration(time.Since(reqStart), accountID) + } + log.WithContext(ctx).Debugf("Sync took %s", time.Since(reqStart)) + s.syncSem.Add(-1) - return s.handleUpdates(ctx, accountID, peerKey, peer, updates, srv) + return s.handleUpdates(ctx, accountID, peerKey, peer, updates, srv, syncStart) +} + +func (s *Server) handleHandshake(ctx context.Context, srv proto.ManagementService_JobServer) (wgtypes.Key, error) { + hello, err := srv.Recv() + if err != nil { + return wgtypes.Key{}, status.Errorf(codes.InvalidArgument, "missing hello: %v", err) + } + + jobReq := &proto.JobRequest{} + peerKey, err := s.parseRequest(ctx, hello, jobReq) + if err != nil { + return wgtypes.Key{}, err + } + + return peerKey, nil +} + +func (s *Server) startResponseReceiver(ctx context.Context, srv proto.ManagementService_JobServer) { + go func() { + for { + msg, err := srv.Recv() + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) { + return + } + log.WithContext(ctx).Warnf("recv job response error: %v", err) + return + } + + jobResp := &proto.JobResponse{} + if _, err := s.parseRequest(ctx, msg, jobResp); err != nil { + log.WithContext(ctx).Warnf("invalid job response: %v", err) + continue + } + + if err := s.jobManager.HandleResponse(ctx, jobResp, msg.WgPubKey); err != nil { + log.WithContext(ctx).Errorf("handle job response failed: %v", err) + } + } + }() +} + +func (s *Server) sendJobsLoop(ctx context.Context, accountID string, peerKey wgtypes.Key, peer *nbpeer.Peer, updates *job.Channel, srv proto.ManagementService_JobServer) error { + // todo figure out better error handling strategy + defer s.jobManager.CloseChannel(ctx, accountID, peer.ID) + + for { + event, err := updates.Event(ctx) + if err != nil { + if errors.Is(err, job.ErrJobChannelClosed) { + log.WithContext(ctx).Debugf("jobs channel for peer %s was closed", peerKey.String()) + return nil + } + + // happens when connection drops, e.g. client disconnects + log.WithContext(ctx).Debugf("stream of peer %s has been closed", peerKey.String()) + return ctx.Err() + } + + if err := s.sendJob(ctx, peerKey, event, srv); err != nil { + log.WithContext(ctx).Warnf("send job failed: %v", err) + return nil + } + } } // handleUpdates sends updates to the connected peer until the updates channel is closed. -func (s *Server) handleUpdates(ctx context.Context, accountID string, peerKey wgtypes.Key, peer *nbpeer.Peer, updates chan *network_map.UpdateMessage, srv proto.ManagementService_SyncServer) error { +// It implements a backpressure mechanism that sends the first update immediately, +// then debounces subsequent rapid updates, ensuring only the latest update is sent +// after a quiet period. +func (s *Server) handleUpdates(ctx context.Context, accountID string, peerKey wgtypes.Key, peer *nbpeer.Peer, updates chan *network_map.UpdateMessage, srv proto.ManagementService_SyncServer, streamStartTime time.Time) error { log.WithContext(ctx).Tracef("starting to handle updates for peer %s", peerKey.String()) + + // Create a debouncer for this peer connection + debouncer := NewUpdateDebouncer(1000 * time.Millisecond) + defer debouncer.Stop() + for { select { // condition when there are some updates + // todo set the updates channel size to 1 case update, open := <-updates: if s.appMetrics != nil { s.appMetrics.GRPCMetrics().UpdateChannelQueueLength(len(updates) + 1) @@ -300,21 +435,38 @@ func (s *Server) handleUpdates(ctx context.Context, accountID string, peerKey wg if !open { log.WithContext(ctx).Debugf("updates channel for peer %s was closed", peerKey.String()) - s.cancelPeerRoutines(ctx, accountID, peer) + s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime) return nil } - log.WithContext(ctx).Debugf("received an update for peer %s", peerKey.String()) - if err := s.sendUpdate(ctx, accountID, peerKey, peer, update, srv); err != nil { - log.WithContext(ctx).Debugf("error while sending an update to peer %s: %v", peerKey.String(), err) - return err + log.WithContext(ctx).Tracef("received an update for peer %s", peerKey.String()) + if debouncer.ProcessUpdate(update) { + // Send immediately (first update or after quiet period) + if err := s.sendUpdate(ctx, accountID, peerKey, peer, update, srv, streamStartTime); err != nil { + log.WithContext(ctx).Debugf("error while sending an update to peer %s: %v", peerKey.String(), err) + return err + } + } + + // Timer expired - quiet period reached, send pending updates if any + case <-debouncer.TimerChannel(): + pendingUpdates := debouncer.GetPendingUpdates() + if len(pendingUpdates) == 0 { + continue + } + log.WithContext(ctx).Debugf("sending %d debounced update(s) for peer %s", len(pendingUpdates), peerKey.String()) + for _, pendingUpdate := range pendingUpdates { + if err := s.sendUpdate(ctx, accountID, peerKey, peer, pendingUpdate, srv, streamStartTime); err != nil { + log.WithContext(ctx).Debugf("error while sending an update to peer %s: %v", peerKey.String(), err) + return err + } } // condition when client <-> server connection has been terminated case <-srv.Context().Done(): // happens when connection drops, e.g. client disconnects log.WithContext(ctx).Debugf("stream of peer %s has been closed", peerKey.String()) - s.cancelPeerRoutines(ctx, accountID, peer) + s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime) return srv.Context().Err() } } @@ -322,40 +474,75 @@ func (s *Server) handleUpdates(ctx context.Context, accountID string, peerKey wg // sendUpdate encrypts the update message using the peer key and the server's wireguard key, // then sends the encrypted message to the connected peer via the sync server. -func (s *Server) sendUpdate(ctx context.Context, accountID string, peerKey wgtypes.Key, peer *nbpeer.Peer, update *network_map.UpdateMessage, srv proto.ManagementService_SyncServer) error { - encryptedResp, err := encryption.EncryptMessage(peerKey, s.wgKey, update.Update) +func (s *Server) sendUpdate(ctx context.Context, accountID string, peerKey wgtypes.Key, peer *nbpeer.Peer, update *network_map.UpdateMessage, srv proto.ManagementService_SyncServer, streamStartTime time.Time) error { + key, err := s.secretsManager.GetWGKey() if err != nil { - s.cancelPeerRoutines(ctx, accountID, peer) + s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime) return status.Errorf(codes.Internal, "failed processing update message") } - err = srv.SendMsg(&proto.EncryptedMessage{ - WgPubKey: s.wgKey.PublicKey().String(), + + encryptedResp, err := encryption.EncryptMessage(peerKey, key, update.Update) + if err != nil { + s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime) + return status.Errorf(codes.Internal, "failed processing update message") + } + err = srv.Send(&proto.EncryptedMessage{ + WgPubKey: key.PublicKey().String(), Body: encryptedResp, }) if err != nil { - s.cancelPeerRoutines(ctx, accountID, peer) + s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime) return status.Errorf(codes.Internal, "failed sending update message") } - log.WithContext(ctx).Debugf("sent an update to peer %s", peerKey.String()) + log.WithContext(ctx).Tracef("sent an update to peer %s", peerKey.String()) return nil } -func (s *Server) cancelPeerRoutines(ctx context.Context, accountID string, peer *nbpeer.Peer) { - unlock := s.acquirePeerLockByUID(ctx, peer.Key) +// sendJob encrypts the update message using the peer key and the server's wireguard key, +// then sends the encrypted message to the connected peer via the sync server. +func (s *Server) sendJob(ctx context.Context, peerKey wgtypes.Key, job *job.Event, srv proto.ManagementService_JobServer) error { + wgKey, err := s.secretsManager.GetWGKey() + if err != nil { + log.WithContext(ctx).Errorf("failed to get wg key for peer %s: %v", peerKey.String(), err) + return status.Errorf(codes.Internal, "failed processing job message") + } + + encryptedResp, err := encryption.EncryptMessage(peerKey, wgKey, job.Request) + if err != nil { + log.WithContext(ctx).Errorf("failed to encrypt job for peer %s: %v", peerKey.String(), err) + return status.Errorf(codes.Internal, "failed processing job message") + } + err = srv.Send(&proto.EncryptedMessage{ + WgPubKey: wgKey.PublicKey().String(), + Body: encryptedResp, + }) + if err != nil { + return status.Errorf(codes.Internal, "failed sending job message") + } + log.WithContext(ctx).Debugf("sent a job to peer: %s", peerKey.String()) + return nil +} + +func (s *Server) cancelPeerRoutines(ctx context.Context, accountID string, peer *nbpeer.Peer, streamStartTime time.Time) { + uncanceledCTX := context.WithoutCancel(ctx) + unlock := s.acquirePeerLockByUID(uncanceledCTX, peer.Key) defer unlock() - err := s.accountManager.OnPeerDisconnected(ctx, accountID, peer.Key) + s.cancelPeerRoutinesWithoutLock(uncanceledCTX, accountID, peer, streamStartTime) +} + +func (s *Server) cancelPeerRoutinesWithoutLock(ctx context.Context, accountID string, peer *nbpeer.Peer, streamStartTime time.Time) { + err := s.accountManager.OnPeerDisconnected(ctx, accountID, peer.Key, streamStartTime) if err != nil { log.WithContext(ctx).Errorf("failed to disconnect peer %s properly: %v", peer.Key, err) } - s.peersUpdateManager.CloseChannel(ctx, peer.ID) + s.networkMapController.OnPeerDisconnected(ctx, accountID, peer.ID) s.secretsManager.CancelRefresh(peer.ID) - s.ephemeralManager.OnPeerDisconnected(ctx, peer) - log.WithContext(ctx).Tracef("peer %s has been disconnected", peer.Key) + log.WithContext(ctx).Debugf("peer %s has been disconnected", peer.Key) } -func (s *Server) validateToken(ctx context.Context, jwtToken string) (string, error) { +func (s *Server) validateToken(ctx context.Context, peerKey, jwtToken string) (string, error) { if s.authManager == nil { return "", status.Errorf(codes.Internal, "missing auth manager") } @@ -365,6 +552,10 @@ func (s *Server) validateToken(ctx context.Context, jwtToken string) (string, er return "", status.Errorf(codes.InvalidArgument, "invalid jwt token, err: %v", err) } + if err := s.claimLoginToken(ctx, peerKey, jwtToken, token); err != nil { + return "", err + } + // we need to call this method because if user is new, we will automatically add it to existing or create a new account accountId, _, err := s.accountManager.GetAccountIDFromUserAuth(ctx, userAuth) if err != nil { @@ -492,11 +683,22 @@ func extractPeerMeta(ctx context.Context, meta *proto.PeerSystemMeta) nbpeer.Pee BlockLANAccess: meta.GetFlags().GetBlockLANAccess(), BlockInbound: meta.GetFlags().GetBlockInbound(), LazyConnectionEnabled: meta.GetFlags().GetLazyConnectionEnabled(), + DisableIPv6: meta.GetFlags().GetDisableIPv6(), }, - Files: files, + Files: files, + Capabilities: capabilitiesToInt32(meta.GetCapabilities()), + SyncMessageVersion: int(meta.GetSyncMessageVersion()), } } +func capabilitiesToInt32(caps []proto.PeerCapability) []int32 { + result := make([]int32, len(caps)) + for i, c := range caps { + result[i] = int32(c) + } + return result +} + func (s *Server) parseRequest(ctx context.Context, req *proto.EncryptedMessage, parsed pb.Message) (wgtypes.Key, error) { peerKey, err := wgtypes.ParseKey(req.GetWgPubKey()) if err != nil { @@ -504,7 +706,12 @@ func (s *Server) parseRequest(ctx context.Context, req *proto.EncryptedMessage, return wgtypes.Key{}, status.Errorf(codes.InvalidArgument, "provided wgPubKey %s is invalid", req.WgPubKey) } - err = encryption.DecryptMessage(peerKey, s.wgKey, req.Body, parsed) + key, err := s.secretsManager.GetWGKey() + if err != nil { + return wgtypes.Key{}, status.Errorf(codes.Internal, "failed processing request") + } + + err = encryption.DecryptMessage(peerKey, key, req.Body, parsed) if err != nil { return wgtypes.Key{}, status.Errorf(codes.InvalidArgument, "invalid request message") } @@ -520,7 +727,6 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto reqStart := time.Now() realIP := getRealIP(ctx) sRealIP := realIP.String() - log.WithContext(ctx).Debugf("Login request from peer [%s] [%s]", req.WgPubKey, sRealIP) loginReq := &proto.LoginRequest{} peerKey, err := s.parseRequest(ctx, req, loginReq) @@ -529,10 +735,10 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto } peerMeta := extractPeerMeta(ctx, loginReq.GetMeta()) - metahashed := metaHash(peerMeta, sRealIP) + metahashed := metaHash(peerMeta) if !s.loginFilter.allowLogin(peerKey.String(), metahashed) { if s.logBlockedPeers { - log.WithContext(ctx).Warnf("peer %s with meta hash %d is blocked from login", peerKey.String(), metahashed) + log.WithContext(ctx).Tracef("peer %s with meta hash %d is blocked from login", peerKey.String(), metahashed) } if s.appMetrics != nil { s.appMetrics.GRPCMetrics().CountLoginRequestBlocked() @@ -556,17 +762,7 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto //nolint ctx = context.WithValue(ctx, nbContext.AccountIDKey, accountID) - log.WithContext(ctx).Debugf("Login: GetAccountIDForPeerKey since start %v", time.Since(reqStart)) - - defer func() { - if s.appMetrics != nil { - s.appMetrics.GRPCMetrics().CountLoginRequestDuration(time.Since(reqStart), accountID) - } - took := time.Since(reqStart) - if took > 7*time.Second { - log.WithContext(ctx).Debugf("Login: took %v", time.Since(reqStart)) - } - }() + log.WithContext(ctx).Debugf("Login request from peer [%s] [%s]", req.WgPubKey, sRealIP) if loginReq.GetMeta() == nil { msg := status.Errorf(codes.FailedPrecondition, @@ -585,7 +781,7 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto sshKey = loginReq.GetPeerKeys().GetSshPubKey() } - peer, netMap, postureChecks, err := s.accountManager.LoginPeer(ctx, types.PeerLogin{ + peer, network, postureChecks, enableSSH, err := s.accountManager.LoginPeer(ctx, types.PeerLogin{ WireGuardPubKey: peerKey.String(), SSHKey: string(sshKey), Meta: peerMeta, @@ -595,39 +791,118 @@ func (s *Server) Login(ctx context.Context, req *proto.EncryptedMessage) (*proto ExtraDNSLabels: loginReq.GetDnsLabels(), }) if err != nil { - log.WithContext(ctx).Warnf("failed logging in peer %s: %s", peerKey, err) + if errors.Is(err, internalStatus.ErrNoAuthMethodProvided) { + log.WithContext(ctx).Tracef("failed logging in peer %s: %s", peerKey, err) + } else { + log.WithContext(ctx).Warnf("failed logging in peer %s: %s", peerKey, err) + } return nil, mapError(ctx, err) } - log.WithContext(ctx).Debugf("Login: LoginPeer since start %v", time.Since(reqStart)) - - // if the login request contains setup key then it is a registration request - if loginReq.GetSetupKey() != "" { - s.ephemeralManager.OnPeerDisconnected(ctx, peer) - log.WithContext(ctx).Debugf("Login: OnPeerDisconnected since start %v", time.Since(reqStart)) - } - - loginResp, err := s.prepareLoginResponse(ctx, peer, netMap, postureChecks) + loginResp, err := s.prepareLoginResponse(ctx, peer, network, postureChecks, enableSSH) if err != nil { log.WithContext(ctx).Warnf("failed preparing login response for peer %s: %s", peerKey, err) return nil, status.Errorf(codes.Internal, "failed logging in peer") } - log.WithContext(ctx).Debugf("Login: prepareLoginResponse since start %v", time.Since(reqStart)) + key, err := s.secretsManager.GetWGKey() + if err != nil { + log.WithContext(ctx).Warnf("failed getting server's WireGuard private key: %s", err) + return nil, status.Errorf(codes.Internal, "failed logging in peer") + } - encryptedResp, err := encryption.EncryptMessage(peerKey, s.wgKey, loginResp) + encryptedResp, err := encryption.EncryptMessage(peerKey, key, loginResp) if err != nil { log.WithContext(ctx).Warnf("failed encrypting peer %s message", peer.ID) return nil, status.Errorf(codes.Internal, "failed logging in peer") } + if s.appMetrics != nil { + s.appMetrics.GRPCMetrics().CountLoginRequestDuration(time.Since(reqStart), accountID) + } + log.WithContext(ctx).Debugf("Login took %s", time.Since(reqStart)) + return &proto.EncryptedMessage{ - WgPubKey: s.wgKey.PublicKey().String(), + WgPubKey: key.PublicKey().String(), Body: encryptedResp, }, nil } -func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, netMap *types.NetworkMap, postureChecks []*posture.Checks) (*proto.LoginResponse, error) { +// ExtendAuthSession refreshes the peer's SSO session expiry deadline using a +// fresh JWT. The same JWT validation pipeline as Login is used. The tunnel +// stays up; no network map sync is performed. The new deadline is returned +// in ExtendAuthSessionResponse.SessionExpiresAt. +func (s *Server) ExtendAuthSession(ctx context.Context, req *proto.EncryptedMessage) (*proto.EncryptedMessage, error) { + extendReq := &proto.ExtendAuthSessionRequest{} + peerKey, err := s.parseRequest(ctx, req, extendReq) + if err != nil { + return nil, err + } + + //nolint + ctx = context.WithValue(ctx, nbContext.PeerIDKey, peerKey.String()) + if accountID, accErr := s.accountManager.GetAccountIDForPeerKey(ctx, peerKey.String()); accErr == nil { + //nolint + ctx = context.WithValue(ctx, nbContext.AccountIDKey, accountID) + } + + jwt := extendReq.GetJwtToken() + if jwt == "" { + return nil, status.Errorf(codes.InvalidArgument, "jwt token is required") + } + + var userID string + const attempts = 3 + for i := 0; i < attempts; i++ { + userID, err = s.validateToken(ctx, peerKey.String(), jwt) + if err == nil { + break + } + if i == attempts-1 { + break + } + log.WithContext(ctx).Warnf("failed validating JWT token while extending session for peer %s: %v. Retrying (idP cache).", peerKey.String(), err) + select { + case <-time.After(200 * time.Millisecond): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + if err != nil { + return nil, err + } + if userID == "" { + return nil, status.Errorf(codes.Unauthenticated, "jwt token did not yield a user id") + } + + deadline, err := s.accountManager.ExtendPeerSession(ctx, peerKey.String(), userID) + if err != nil { + log.WithContext(ctx).Warnf("failed extending session for peer %s: %v", peerKey.String(), err) + return nil, mapError(ctx, err) + } + + // Success path normally returns a non-zero deadline. A defensive zero + // would still encode as the explicit "disabled" sentinel rather than nil, + // so the client clears any stale anchor instead of preserving it. + resp := &proto.ExtendAuthSessionResponse{ + SessionExpiresAt: encodeSessionExpiresAt(deadline), + } + + wgKey, err := s.secretsManager.GetWGKey() + if err != nil { + return nil, status.Errorf(codes.Internal, "failed processing request") + } + encrypted, err := encryption.EncryptMessage(peerKey, wgKey, resp) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed encrypting response") + } + return &proto.EncryptedMessage{ + WgPubKey: wgKey.PublicKey().String(), + Body: encrypted, + }, nil +} + +func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, network *types.Network, postureChecks []*posture.Checks, enableSSH bool) (*proto.LoginResponse, error) { var relayToken *Token var err error if s.config.Relay != nil && len(s.config.Relay.Addresses) > 0 { @@ -645,14 +920,45 @@ func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, ne // if peer has reached this point then it has logged in loginResp := &proto.LoginResponse{ - NetbirdConfig: toNetbirdConfig(s.config, nil, relayToken, nil), - PeerConfig: toPeerConfig(peer, netMap.Network, s.networkMapController.GetDNSDomain(settings), settings), + NetbirdConfig: toNetbirdConfig(s.config, nil, relayToken, nil, settings), + PeerConfig: toPeerConfig(peer, network, s.networkMapController.GetDNSDomain(settings), settings, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, enableSSH), Checks: toProtocolChecks(ctx, postureChecks), } + // settings is always non-nil here, so we never emit nil — encoder returns + // either a valid deadline or the explicit-zero "disabled" sentinel. + loginResp.SessionExpiresAt = encodeSessionExpiresAt( + peer.SessionExpiresAt(settings.PeerLoginExpirationEnabled, settings.PeerLoginExpiration), + ) + return loginResp, nil } +func (s *Server) claimLoginToken(ctx context.Context, peerKey, jwtToken string, token *jwtv5.Token) error { + if s.sessionStore == nil || token == nil { + return nil + } + + exp, err := token.Claims.GetExpirationTime() + if err != nil || exp == nil { + log.WithContext(ctx).Warnf("JWT has no usable exp claim for peer %s", peerKey) + return status.Error(codes.Unauthenticated, "jwt token has no expiration") + } + + err = s.sessionStore.RegisterToken(ctx, jwtToken, exp.Time) + if err == nil { + return nil + } + + if errors.Is(err, auth.ErrTokenAlreadyUsed) || errors.Is(err, auth.ErrTokenExpired) { + log.WithContext(ctx).Warnf("%v for peer %s", err, peerKey) + return status.Error(codes.Unauthenticated, err.Error()) + } + + log.WithContext(ctx).Warnf("failed to claim JWT for peer %s: %v", peerKey, err) + return status.Error(codes.Unavailable, "failed to claim jwt token") +} + // processJwtToken validates the existence of a JWT token in the login request, and returns the corresponding user ID if // the token is valid. // @@ -663,7 +969,7 @@ func (s *Server) processJwtToken(ctx context.Context, loginReq *proto.LoginReque if loginReq.GetJwtToken() != "" { var err error for i := 0; i < 3; i++ { - userID, err = s.validateToken(ctx, loginReq.GetJwtToken()) + userID, err = s.validateToken(ctx, peerKey.String(), loginReq.GetJwtToken()) if err == nil { break } @@ -686,8 +992,8 @@ func (s *Server) IsHealthy(ctx context.Context, req *proto.Empty) (*proto.Empty, // sendInitialSync sends initial proto.SyncResponse to the peer requesting synchronization func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer *nbpeer.Peer, networkMap *types.NetworkMap, postureChecks []*posture.Checks, srv proto.ManagementService_SyncServer, dnsFwdPort int64) error { var err error - var turnToken *Token + if s.config.TURNConfig != nil && s.config.TURNConfig.TimeBasedCredentials { turnToken, err = s.secretsManager.GenerateTurnToken() if err != nil { @@ -713,19 +1019,58 @@ func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer return status.Errorf(codes.Internal, "failed to get peer groups %s", err) } - plainResp := ToSyncResponse(ctx, s.config, peer, turnToken, relayToken, networkMap, s.networkMapController.GetDNSDomain(settings), postureChecks, nil, settings, settings.Extra, peerGroups, dnsFwdPort) + dnsName := s.networkMapController.GetDNSDomain(settings) - encryptedResp, err := encryption.EncryptMessage(peerKey, s.wgKey, plainResp) + var plainResp *proto.SyncResponse + + commonSyncMessageVersion := grpc.HighestCommonSyncMessageVersion( + s.perAccountOrGlobalSyncMessageVersions(peer.AccountID), + grpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion)) + + log.WithContext(ctx). + WithFields(log.Fields{ + "sync_message_version": commonSyncMessageVersion, + "server_sync_message_version": s.perAccountOrGlobalSyncMessageVersions(peer.AccountID), + "peer_sync_message_version": grpc.SyncMessageVersionFromConfig(&peer.Meta.SyncMessageVersion), + }).Debug("common highest sync message version") + + if commonSyncMessageVersion == grpc.ComponentNetworkMap { + // Capable peer: discard the legacy NetworkMap that SyncAndMarkPeer + // computed and recompute the raw components instead. This wastes one + // Calculate() call per initial-sync — the component-based wire + // format is what the peer actually consumes. The streaming path + // (network_map.Controller.UpdateAccountPeers) skips this duplication + // because it dispatches by capability before computing. + // + // TODO: refactor SyncPeer / SyncAndMarkPeer / their mocks + manager + // interfaces to return PeerNetworkMapResult so the initial-sync path + // stops doing duplicate work. Deferred until the client-side + // decoder lands and there's a real deployment of capability=3 peers + // worth optimizing for. + freshPeer, components, proxyPatch, freshPostureChecks, freshDnsFwdPort, err := s.networkMapController.GetValidatedPeerWithComponents(ctx, false, peer.AccountID, peer) + if err != nil { + log.WithContext(ctx).Errorf("failed to build components for peer %s on initial sync: %v", peer.ID, err) + return status.Errorf(codes.Internal, "failed to build initial sync envelope") + } + plainResp = ToComponentSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, freshPeer, turnToken, relayToken, components, proxyPatch, dnsName, freshPostureChecks, settings, settings.Extra, peerGroups, freshDnsFwdPort) + } else { + plainResp = ToSyncResponse(ctx, s.config, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, peer, turnToken, relayToken, networkMap, dnsName, postureChecks, nil, settings, settings.Extra, peerGroups, dnsFwdPort) + } + + key, err := s.secretsManager.GetWGKey() + if err != nil { + return status.Errorf(codes.Internal, "failed getting server key") + } + + encryptedResp, err := encryption.EncryptMessage(peerKey, key, plainResp) if err != nil { return status.Errorf(codes.Internal, "error handling request") } - sendStart := time.Now() err = srv.Send(&proto.EncryptedMessage{ - WgPubKey: s.wgKey.PublicKey().String(), + WgPubKey: key.PublicKey().String(), Body: encryptedResp, }) - log.WithContext(ctx).Debugf("sendInitialSync: sending response took %s", time.Since(sendStart)) if err != nil { log.WithContext(ctx).Errorf("failed sending SyncResponse %v", err) @@ -735,15 +1080,18 @@ func (s *Server) sendInitialSync(ctx context.Context, peerKey wgtypes.Key, peer return nil } +func (s *Server) perAccountOrGlobalSyncMessageVersions(accountId string) grpc.SyncMessageVersion { + if version, ok := s.config.PerAccountHighestSupportedSyncMessageVersion[accountId]; ok { + return grpc.SyncMessageVersionFromConfig(&version) + } + return grpc.SyncMessageVersionFromConfig(s.config.HighestSupportedSyncMessageVersion) +} + // GetDeviceAuthorizationFlow returns a device authorization flow information // This is used for initiating an Oauth 2 device authorization grant flow // which will be used by our clients to Login func (s *Server) GetDeviceAuthorizationFlow(ctx context.Context, req *proto.EncryptedMessage) (*proto.EncryptedMessage, error) { log.WithContext(ctx).Tracef("GetDeviceAuthorizationFlow request for pubKey: %s", req.WgPubKey) - start := time.Now() - defer func() { - log.WithContext(ctx).Tracef("GetDeviceAuthorizationFlow for pubKey: %s took %v", req.WgPubKey, time.Since(start)) - }() peerKey, err := wgtypes.ParseKey(req.GetWgPubKey()) if err != nil { @@ -752,43 +1100,64 @@ func (s *Server) GetDeviceAuthorizationFlow(ctx context.Context, req *proto.Encr return nil, status.Error(codes.InvalidArgument, errMSG) } - err = encryption.DecryptMessage(peerKey, s.wgKey, req.Body, &proto.DeviceAuthorizationFlowRequest{}) + key, err := s.secretsManager.GetWGKey() + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get server key") + } + + err = encryption.DecryptMessage(peerKey, key, req.Body, &proto.DeviceAuthorizationFlowRequest{}) if err != nil { errMSG := fmt.Sprintf("error while decrypting peer's message with Wireguard public key %s.", req.WgPubKey) log.WithContext(ctx).Warn(errMSG) return nil, status.Error(codes.InvalidArgument, errMSG) } - if s.config.DeviceAuthorizationFlow == nil || s.config.DeviceAuthorizationFlow.Provider == string(nbconfig.NONE) { - return nil, status.Error(codes.NotFound, "no device authorization flow information available") + var flowInfoResp *proto.DeviceAuthorizationFlow + + // Use embedded IdP configuration if available + if s.oAuthConfigProvider != nil { + flowInfoResp = &proto.DeviceAuthorizationFlow{ + Provider: proto.DeviceAuthorizationFlow_HOSTED, + ProviderConfig: &proto.ProviderConfig{ + ClientID: s.oAuthConfigProvider.GetCLIClientID(), + Audience: s.oAuthConfigProvider.GetCLIClientID(), + DeviceAuthEndpoint: s.oAuthConfigProvider.GetDeviceAuthEndpoint(), + TokenEndpoint: s.oAuthConfigProvider.GetTokenEndpoint(), + Scope: s.oAuthConfigProvider.GetDefaultScopes(), + }, + } + } else { + if s.config.DeviceAuthorizationFlow == nil || s.config.DeviceAuthorizationFlow.Provider == string(nbconfig.NONE) { + return nil, status.Error(codes.NotFound, "no device authorization flow information available") + } + + provider, ok := proto.DeviceAuthorizationFlowProvider_value[strings.ToUpper(s.config.DeviceAuthorizationFlow.Provider)] + if !ok { + return nil, status.Errorf(codes.InvalidArgument, "no provider found in the protocol for %s", s.config.DeviceAuthorizationFlow.Provider) + } + + flowInfoResp = &proto.DeviceAuthorizationFlow{ + Provider: proto.DeviceAuthorizationFlowProvider(provider), + ProviderConfig: &proto.ProviderConfig{ + ClientID: s.config.DeviceAuthorizationFlow.ProviderConfig.ClientID, + ClientSecret: s.config.DeviceAuthorizationFlow.ProviderConfig.ClientSecret, + Domain: s.config.DeviceAuthorizationFlow.ProviderConfig.Domain, + Audience: s.config.DeviceAuthorizationFlow.ProviderConfig.Audience, + DeviceAuthEndpoint: s.config.DeviceAuthorizationFlow.ProviderConfig.DeviceAuthEndpoint, + TokenEndpoint: s.config.DeviceAuthorizationFlow.ProviderConfig.TokenEndpoint, + Scope: s.config.DeviceAuthorizationFlow.ProviderConfig.Scope, + UseIDToken: s.config.DeviceAuthorizationFlow.ProviderConfig.UseIDToken, + }, + } } - provider, ok := proto.DeviceAuthorizationFlowProvider_value[strings.ToUpper(s.config.DeviceAuthorizationFlow.Provider)] - if !ok { - return nil, status.Errorf(codes.InvalidArgument, "no provider found in the protocol for %s", s.config.DeviceAuthorizationFlow.Provider) - } - - flowInfoResp := &proto.DeviceAuthorizationFlow{ - Provider: proto.DeviceAuthorizationFlowProvider(provider), - ProviderConfig: &proto.ProviderConfig{ - ClientID: s.config.DeviceAuthorizationFlow.ProviderConfig.ClientID, - ClientSecret: s.config.DeviceAuthorizationFlow.ProviderConfig.ClientSecret, - Domain: s.config.DeviceAuthorizationFlow.ProviderConfig.Domain, - Audience: s.config.DeviceAuthorizationFlow.ProviderConfig.Audience, - DeviceAuthEndpoint: s.config.DeviceAuthorizationFlow.ProviderConfig.DeviceAuthEndpoint, - TokenEndpoint: s.config.DeviceAuthorizationFlow.ProviderConfig.TokenEndpoint, - Scope: s.config.DeviceAuthorizationFlow.ProviderConfig.Scope, - UseIDToken: s.config.DeviceAuthorizationFlow.ProviderConfig.UseIDToken, - }, - } - - encryptedResp, err := encryption.EncryptMessage(peerKey, s.wgKey, flowInfoResp) + encryptedResp, err := encryption.EncryptMessage(peerKey, key, flowInfoResp) if err != nil { - return nil, status.Error(codes.Internal, "failed to encrypt no device authorization flow information") + return nil, status.Error(codes.Internal, "failed to encrypt device authorization flow information") } return &proto.EncryptedMessage{ - WgPubKey: s.wgKey.PublicKey().String(), + WgPubKey: key.PublicKey().String(), Body: encryptedResp, }, nil } @@ -798,10 +1167,6 @@ func (s *Server) GetDeviceAuthorizationFlow(ctx context.Context, req *proto.Encr // which will be used by our clients to Login func (s *Server) GetPKCEAuthorizationFlow(ctx context.Context, req *proto.EncryptedMessage) (*proto.EncryptedMessage, error) { log.WithContext(ctx).Tracef("GetPKCEAuthorizationFlow request for pubKey: %s", req.WgPubKey) - start := time.Now() - defer func() { - log.WithContext(ctx).Tracef("GetPKCEAuthorizationFlow for pubKey %s took %v", req.WgPubKey, time.Since(start)) - }() peerKey, err := wgtypes.ParseKey(req.GetWgPubKey()) if err != nil { @@ -810,41 +1175,63 @@ func (s *Server) GetPKCEAuthorizationFlow(ctx context.Context, req *proto.Encryp return nil, status.Error(codes.InvalidArgument, errMSG) } - err = encryption.DecryptMessage(peerKey, s.wgKey, req.Body, &proto.PKCEAuthorizationFlowRequest{}) + key, err := s.secretsManager.GetWGKey() + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get server key") + } + + err = encryption.DecryptMessage(peerKey, key, req.Body, &proto.PKCEAuthorizationFlowRequest{}) if err != nil { errMSG := fmt.Sprintf("error while decrypting peer's message with Wireguard public key %s.", req.WgPubKey) log.WithContext(ctx).Warn(errMSG) return nil, status.Error(codes.InvalidArgument, errMSG) } - if s.config.PKCEAuthorizationFlow == nil { - return nil, status.Error(codes.NotFound, "no pkce authorization flow information available") - } + var initInfoFlow *proto.PKCEAuthorizationFlow - initInfoFlow := &proto.PKCEAuthorizationFlow{ - ProviderConfig: &proto.ProviderConfig{ - Audience: s.config.PKCEAuthorizationFlow.ProviderConfig.Audience, - ClientID: s.config.PKCEAuthorizationFlow.ProviderConfig.ClientID, - ClientSecret: s.config.PKCEAuthorizationFlow.ProviderConfig.ClientSecret, - TokenEndpoint: s.config.PKCEAuthorizationFlow.ProviderConfig.TokenEndpoint, - AuthorizationEndpoint: s.config.PKCEAuthorizationFlow.ProviderConfig.AuthorizationEndpoint, - Scope: s.config.PKCEAuthorizationFlow.ProviderConfig.Scope, - RedirectURLs: s.config.PKCEAuthorizationFlow.ProviderConfig.RedirectURLs, - UseIDToken: s.config.PKCEAuthorizationFlow.ProviderConfig.UseIDToken, - DisablePromptLogin: s.config.PKCEAuthorizationFlow.ProviderConfig.DisablePromptLogin, - LoginFlag: uint32(s.config.PKCEAuthorizationFlow.ProviderConfig.LoginFlag), - }, + // Use embedded IdP configuration if available + if s.oAuthConfigProvider != nil { + initInfoFlow = &proto.PKCEAuthorizationFlow{ + ProviderConfig: &proto.ProviderConfig{ + Audience: s.oAuthConfigProvider.GetCLIClientID(), + ClientID: s.oAuthConfigProvider.GetCLIClientID(), + TokenEndpoint: s.oAuthConfigProvider.GetTokenEndpoint(), + AuthorizationEndpoint: s.oAuthConfigProvider.GetAuthorizationEndpoint(), + Scope: s.oAuthConfigProvider.GetDefaultScopes(), + RedirectURLs: s.oAuthConfigProvider.GetCLIRedirectURLs(), + LoginFlag: uint32(common.LoginFlagPromptLogin), + }, + } + } else { + if s.config.PKCEAuthorizationFlow == nil { + return nil, status.Error(codes.NotFound, "no pkce authorization flow information available") + } + + initInfoFlow = &proto.PKCEAuthorizationFlow{ + ProviderConfig: &proto.ProviderConfig{ + Audience: s.config.PKCEAuthorizationFlow.ProviderConfig.Audience, + ClientID: s.config.PKCEAuthorizationFlow.ProviderConfig.ClientID, + ClientSecret: s.config.PKCEAuthorizationFlow.ProviderConfig.ClientSecret, + TokenEndpoint: s.config.PKCEAuthorizationFlow.ProviderConfig.TokenEndpoint, + AuthorizationEndpoint: s.config.PKCEAuthorizationFlow.ProviderConfig.AuthorizationEndpoint, + Scope: s.config.PKCEAuthorizationFlow.ProviderConfig.Scope, + RedirectURLs: s.config.PKCEAuthorizationFlow.ProviderConfig.RedirectURLs, + UseIDToken: s.config.PKCEAuthorizationFlow.ProviderConfig.UseIDToken, + DisablePromptLogin: s.config.PKCEAuthorizationFlow.ProviderConfig.DisablePromptLogin, + LoginFlag: uint32(s.config.PKCEAuthorizationFlow.ProviderConfig.LoginFlag), + }, + } } flowInfoResp := s.integratedPeerValidator.ValidateFlowResponse(ctx, peerKey.String(), initInfoFlow) - encryptedResp, err := encryption.EncryptMessage(peerKey, s.wgKey, flowInfoResp) + encryptedResp, err := encryption.EncryptMessage(peerKey, key, flowInfoResp) if err != nil { - return nil, status.Error(codes.Internal, "failed to encrypt no pkce authorization flow information") + return nil, status.Error(codes.Internal, "failed to encrypt pkce authorization flow information") } return &proto.EncryptedMessage{ - WgPubKey: s.wgKey.PublicKey().String(), + WgPubKey: key.PublicKey().String(), Body: encryptedResp, }, nil } @@ -868,7 +1255,7 @@ func (s *Server) SyncMeta(ctx context.Context, req *proto.EncryptedMessage) (*pr return nil, msg } - err = s.accountManager.SyncPeerMeta(ctx, peerKey.String(), extractPeerMeta(ctx, syncMetaReq.GetMeta())) + err = s.accountManager.SyncPeerMeta(ctx, peerKey.String(), extractPeerMeta(ctx, syncMetaReq.GetMeta()), realIP) if err != nil { return nil, mapError(ctx, err) } @@ -917,7 +1304,10 @@ func (s *Server) Logout(ctx context.Context, req *proto.EncryptedMessage) (*prot func toProtocolChecks(ctx context.Context, postureChecks []*posture.Checks) []*proto.Checks { protoChecks := make([]*proto.Checks, 0, len(postureChecks)) for _, postureCheck := range postureChecks { - protoChecks = append(protoChecks, toProtocolCheck(postureCheck)) + check := toProtocolCheck(postureCheck) + if check != nil { + protoChecks = append(protoChecks, check) + } } return protoChecks @@ -941,5 +1331,9 @@ func toProtocolCheck(postureCheck *posture.Checks) *proto.Checks { } } + if len(protoCheck.Files) == 0 { + return nil + } + return protoCheck } diff --git a/management/internals/shared/grpc/server_test.go b/management/internals/shared/grpc/server_test.go index 9867b38e3..d3a12e986 100644 --- a/management/internals/shared/grpc/server_test.go +++ b/management/internals/shared/grpc/server_test.go @@ -73,15 +73,17 @@ func TestServer_GetDeviceAuthorizationFlow(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { mgmtServer := &Server{ - wgKey: testingServerKey, + secretsManager: &TimeBasedAuthSecretsManager{wgKey: testingServerKey}, config: &config.Config{ DeviceAuthorizationFlow: testCase.inputFlow, }, } message := &mgmtProto.DeviceAuthorizationFlowRequest{} + key, err := mgmtServer.secretsManager.GetWGKey() + require.NoError(t, err, "should be able to get server key") - encryptedMSG, err := encryption.EncryptMessage(testingClientKey.PublicKey(), mgmtServer.wgKey, message) + encryptedMSG, err := encryption.EncryptMessage(testingClientKey.PublicKey(), key, message) require.NoError(t, err, "should be able to encrypt message") resp, err := mgmtServer.GetDeviceAuthorizationFlow( @@ -95,7 +97,7 @@ func TestServer_GetDeviceAuthorizationFlow(t *testing.T) { if testCase.expectedComparisonFunc != nil { flowInfoResp := &mgmtProto.DeviceAuthorizationFlow{} - err = encryption.DecryptMessage(mgmtServer.wgKey.PublicKey(), testingClientKey, resp.Body, flowInfoResp) + err = encryption.DecryptMessage(key.PublicKey(), testingClientKey, resp.Body, flowInfoResp) require.NoError(t, err, "should be able to decrypt") testCase.expectedComparisonFunc(t, testCase.expectedFlow.Provider, flowInfoResp.Provider, testCase.expectedComparisonMSG) diff --git a/management/internals/shared/grpc/sync_mappings_test.go b/management/internals/shared/grpc/sync_mappings_test.go new file mode 100644 index 000000000..97f6183bb --- /dev/null +++ b/management/internals/shared/grpc/sync_mappings_test.go @@ -0,0 +1,411 @@ +package grpc + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// syncRecordingStream is a mock ProxyService_SyncMappingsServer that records +// sent messages and returns pre-loaded ack responses from Recv. +type syncRecordingStream struct { + grpc.ServerStream + + mu sync.Mutex + sent []*proto.SyncMappingsResponse + recvMsgs []*proto.SyncMappingsRequest + recvIdx int +} + +func (s *syncRecordingStream) Send(m *proto.SyncMappingsResponse) error { + s.mu.Lock() + defer s.mu.Unlock() + s.sent = append(s.sent, m) + return nil +} + +func (s *syncRecordingStream) Recv() (*proto.SyncMappingsRequest, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.recvIdx >= len(s.recvMsgs) { + return nil, fmt.Errorf("no more recv messages") + } + msg := s.recvMsgs[s.recvIdx] + s.recvIdx++ + return msg, nil +} + +func (s *syncRecordingStream) Context() context.Context { return context.Background() } +func (s *syncRecordingStream) SetHeader(metadata.MD) error { return nil } +func (s *syncRecordingStream) SendHeader(metadata.MD) error { return nil } +func (s *syncRecordingStream) SetTrailer(metadata.MD) {} +func (s *syncRecordingStream) SendMsg(any) error { return nil } +func (s *syncRecordingStream) RecvMsg(any) error { return nil } + +func ackMsg() *proto.SyncMappingsRequest { + return &proto.SyncMappingsRequest{ + Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}}, + } +} + +func TestSendSnapshotSync_BatchesWithAcks(t *testing.T) { + const cluster = "cluster.example.com" + const batchSize = 3 + const totalServices = 7 // 3 + 3 + 1 → 3 batches, 3 acks (one per batch, including final) + + ctrl := gomock.NewController(t) + mgr := rpservice.NewMockManager(ctrl) + mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil) + + s := newSnapshotTestServer(t, batchSize) + s.serviceManager = mgr + + stream := &syncRecordingStream{ + recvMsgs: []*proto.SyncMappingsRequest{ackMsg(), ackMsg(), ackMsg()}, + } + conn := &proxyConnection{ + proxyID: "proxy-a", + address: cluster, + syncStream: stream, + } + + err := s.sendSnapshotSync(context.Background(), conn, stream) + require.NoError(t, err) + + require.Len(t, stream.sent, 3, "should send ceil(7/3) = 3 batches") + + assert.Len(t, stream.sent[0].Mapping, 3) + assert.False(t, stream.sent[0].InitialSyncComplete) + + assert.Len(t, stream.sent[1].Mapping, 3) + assert.False(t, stream.sent[1].InitialSyncComplete) + + assert.Len(t, stream.sent[2].Mapping, 1) + assert.True(t, stream.sent[2].InitialSyncComplete) + + // All 3 acks consumed — including the final batch. + assert.Equal(t, 3, stream.recvIdx) +} + +func TestSendSnapshotSync_SingleBatchWaitsForAck(t *testing.T) { + const cluster = "cluster.example.com" + const batchSize = 100 + const totalServices = 5 + + ctrl := gomock.NewController(t) + mgr := rpservice.NewMockManager(ctrl) + mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil) + + s := newSnapshotTestServer(t, batchSize) + s.serviceManager = mgr + + stream := &syncRecordingStream{ + recvMsgs: []*proto.SyncMappingsRequest{ackMsg()}, + } + conn := &proxyConnection{ + proxyID: "proxy-a", + address: cluster, + syncStream: stream, + } + + err := s.sendSnapshotSync(context.Background(), conn, stream) + require.NoError(t, err) + + require.Len(t, stream.sent, 1) + assert.Len(t, stream.sent[0].Mapping, totalServices) + assert.True(t, stream.sent[0].InitialSyncComplete) + assert.Equal(t, 1, stream.recvIdx, "final batch ack must be consumed") +} + +func TestSendSnapshotSync_EmptySnapshot(t *testing.T) { + const cluster = "cluster.example.com" + + ctrl := gomock.NewController(t) + mgr := rpservice.NewMockManager(ctrl) + mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(nil, nil) + + s := newSnapshotTestServer(t, 500) + s.serviceManager = mgr + + stream := &syncRecordingStream{ + recvMsgs: []*proto.SyncMappingsRequest{ackMsg()}, + } + conn := &proxyConnection{ + proxyID: "proxy-a", + address: cluster, + syncStream: stream, + } + + err := s.sendSnapshotSync(context.Background(), conn, stream) + require.NoError(t, err) + + require.Len(t, stream.sent, 1, "empty snapshot must still send sync-complete") + assert.Empty(t, stream.sent[0].Mapping) + assert.True(t, stream.sent[0].InitialSyncComplete) + assert.Equal(t, 1, stream.recvIdx, "empty snapshot ack must be consumed") +} + +func TestSendSnapshotSync_MissingAckReturnsError(t *testing.T) { + const cluster = "cluster.example.com" + const batchSize = 2 + const totalServices = 4 // 2 batches → 1 ack needed, but we provide none + + ctrl := gomock.NewController(t) + mgr := rpservice.NewMockManager(ctrl) + mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil) + + s := newSnapshotTestServer(t, batchSize) + s.serviceManager = mgr + + // No acks available — Recv will return error. + stream := &syncRecordingStream{} + conn := &proxyConnection{ + proxyID: "proxy-a", + address: cluster, + syncStream: stream, + } + + err := s.sendSnapshotSync(context.Background(), conn, stream) + require.Error(t, err) + assert.Contains(t, err.Error(), "receive ack") + // First batch should have been sent before the error. + require.Len(t, stream.sent, 1) +} + +func TestSendSnapshotSync_WrongMessageInsteadOfAck(t *testing.T) { + const cluster = "cluster.example.com" + const batchSize = 2 + const totalServices = 4 + + ctrl := gomock.NewController(t) + mgr := rpservice.NewMockManager(ctrl) + mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil) + + s := newSnapshotTestServer(t, batchSize) + s.serviceManager = mgr + + // Send an init message instead of an ack. + stream := &syncRecordingStream{ + recvMsgs: []*proto.SyncMappingsRequest{ + {Msg: &proto.SyncMappingsRequest_Init{Init: &proto.SyncMappingsInit{ProxyId: "bad"}}}, + }, + } + conn := &proxyConnection{ + proxyID: "proxy-a", + address: cluster, + syncStream: stream, + } + + err := s.sendSnapshotSync(context.Background(), conn, stream) + require.Error(t, err) + assert.Contains(t, err.Error(), "expected ack") +} + +func TestSendSnapshotSync_BackPressureOrdering(t *testing.T) { + // Verify batches are sent strictly sequentially — batch N+1 is not sent + // until the ack for batch N is received, including the final batch. + const cluster = "cluster.example.com" + const batchSize = 2 + const totalServices = 6 // 3 batches, 3 acks + + ctrl := gomock.NewController(t) + mgr := rpservice.NewMockManager(ctrl) + mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil) + + s := newSnapshotTestServer(t, batchSize) + s.serviceManager = mgr + + var mu sync.Mutex + var events []string + + // Build a stream that logs send/recv events so we can verify ordering. + ackCh := make(chan struct{}, 3) + stream := &orderTrackingStream{ + mu: &mu, + events: &events, + ackCh: ackCh, + } + conn := &proxyConnection{ + proxyID: "proxy-a", + address: cluster, + syncStream: stream, + } + + // Feed acks asynchronously after a short delay to simulate real proxy. + go func() { + for range 3 { + time.Sleep(10 * time.Millisecond) + ackCh <- struct{}{} + } + }() + + err := s.sendSnapshotSync(context.Background(), conn, stream) + require.NoError(t, err) + + mu.Lock() + defer mu.Unlock() + + // Expected: send, recv-ack, send, recv-ack, send, recv-ack. + require.Len(t, events, 6) + assert.Equal(t, "send", events[0]) + assert.Equal(t, "recv", events[1]) + assert.Equal(t, "send", events[2]) + assert.Equal(t, "recv", events[3]) + assert.Equal(t, "send", events[4]) + assert.Equal(t, "recv", events[5]) +} + +// orderTrackingStream logs "send" and "recv" events and blocks Recv until +// an ack is signaled via ackCh. +type orderTrackingStream struct { + grpc.ServerStream + mu *sync.Mutex + events *[]string + ackCh chan struct{} +} + +func (s *orderTrackingStream) Send(_ *proto.SyncMappingsResponse) error { + s.mu.Lock() + *s.events = append(*s.events, "send") + s.mu.Unlock() + return nil +} + +func (s *orderTrackingStream) Recv() (*proto.SyncMappingsRequest, error) { + <-s.ackCh + s.mu.Lock() + *s.events = append(*s.events, "recv") + s.mu.Unlock() + return ackMsg(), nil +} + +func (s *orderTrackingStream) Context() context.Context { return context.Background() } +func (s *orderTrackingStream) SetHeader(metadata.MD) error { return nil } +func (s *orderTrackingStream) SendHeader(metadata.MD) error { return nil } +func (s *orderTrackingStream) SetTrailer(metadata.MD) {} +func (s *orderTrackingStream) SendMsg(any) error { return nil } +func (s *orderTrackingStream) RecvMsg(any) error { return nil } + +func TestSendSnapshotSync_TokensGeneratedPerBatch(t *testing.T) { + const cluster = "cluster.example.com" + const batchSize = 2 + const totalServices = 4 + const ttl = 100 * time.Millisecond + const ackDelay = 200 * time.Millisecond + + ctrl := gomock.NewController(t) + mgr := rpservice.NewMockManager(ctrl) + mgr.EXPECT().GetGlobalServices(gomock.Any()).Return(makeServices(totalServices, cluster), nil) + + s := newSnapshotTestServer(t, batchSize) + s.serviceManager = mgr + s.tokenTTL = ttl + + // Build a stream that validates tokens immediately on Send, then + // delays the ack to ensure the next batch's tokens are generated fresh. + var validateErrs []error + ackCh := make(chan struct{}, 2) + stream := &tokenValidatingSyncStream{ + tokenStore: s.tokenStore, + validateErrs: &validateErrs, + ackCh: ackCh, + } + conn := &proxyConnection{ + proxyID: "proxy-a", + address: cluster, + syncStream: stream, + } + + go func() { + // Delay first ack so that if tokens were all generated upfront they'd expire. + time.Sleep(ackDelay) + ackCh <- struct{}{} + // Final batch ack — immediate. + ackCh <- struct{}{} + }() + + err := s.sendSnapshotSync(context.Background(), conn, stream) + require.NoError(t, err) + require.Empty(t, validateErrs, + "tokens must remain valid: per-batch generation guarantees freshness") +} + +type tokenValidatingSyncStream struct { + grpc.ServerStream + tokenStore *OneTimeTokenStore + validateErrs *[]error + ackCh chan struct{} +} + +func (s *tokenValidatingSyncStream) Send(m *proto.SyncMappingsResponse) error { + for _, mapping := range m.Mapping { + if err := s.tokenStore.ValidateAndConsume(mapping.AuthToken, mapping.AccountId, mapping.Id); err != nil { + *s.validateErrs = append(*s.validateErrs, fmt.Errorf("svc %s: %w", mapping.Id, err)) + } + } + return nil +} + +func (s *tokenValidatingSyncStream) Recv() (*proto.SyncMappingsRequest, error) { + <-s.ackCh + return ackMsg(), nil +} + +func (s *tokenValidatingSyncStream) Context() context.Context { return context.Background() } +func (s *tokenValidatingSyncStream) SetHeader(metadata.MD) error { return nil } +func (s *tokenValidatingSyncStream) SendHeader(metadata.MD) error { return nil } +func (s *tokenValidatingSyncStream) SetTrailer(metadata.MD) {} +func (s *tokenValidatingSyncStream) SendMsg(any) error { return nil } +func (s *tokenValidatingSyncStream) RecvMsg(any) error { return nil } + +func TestConnectionSendResponse_RoutesToSyncStream(t *testing.T) { + stream := &syncRecordingStream{} + conn := &proxyConnection{ + syncStream: stream, + } + + resp := &proto.GetMappingUpdateResponse{ + Mapping: []*proto.ProxyMapping{ + {Id: "svc-1", AccountId: "acct-1", Domain: "example.com"}, + }, + InitialSyncComplete: true, + } + + err := conn.sendResponse(resp) + require.NoError(t, err) + + require.Len(t, stream.sent, 1) + assert.Len(t, stream.sent[0].Mapping, 1) + assert.Equal(t, "svc-1", stream.sent[0].Mapping[0].Id) + assert.True(t, stream.sent[0].InitialSyncComplete) +} + +func TestConnectionSendResponse_RoutesToLegacyStream(t *testing.T) { + stream := &recordingStream{} + conn := &proxyConnection{ + stream: stream, + } + + resp := &proto.GetMappingUpdateResponse{ + Mapping: []*proto.ProxyMapping{ + {Id: "svc-2", AccountId: "acct-2"}, + }, + } + + err := conn.sendResponse(resp) + require.NoError(t, err) + + require.Len(t, stream.messages, 1) + assert.Equal(t, "svc-2", stream.messages[0].Mapping[0].Id) +} diff --git a/management/internals/shared/grpc/token_mgr.go b/management/internals/shared/grpc/token_mgr.go index e9770db41..fb2d83a9a 100644 --- a/management/internals/shared/grpc/token_mgr.go +++ b/management/internals/shared/grpc/token_mgr.go @@ -10,6 +10,7 @@ import ( "time" log "github.com/sirupsen/logrus" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" integrationsConfig "github.com/netbirdio/management-integrations/integrations/config" "github.com/netbirdio/netbird/management/internals/controllers/network_map" @@ -29,6 +30,7 @@ type SecretsManager interface { GenerateRelayToken() (*Token, error) SetupRefresh(ctx context.Context, accountID, peerKey string) CancelRefresh(peerKey string) + GetWGKey() (wgtypes.Key, error) } // TimeBasedAuthSecretsManager generates credentials with TTL and using pre-shared secret known to TURN server @@ -43,11 +45,17 @@ type TimeBasedAuthSecretsManager struct { groupsManager groups.Manager turnCancelMap map[string]chan struct{} relayCancelMap map[string]chan struct{} + wgKey wgtypes.Key } type Token auth.Token -func NewTimeBasedAuthSecretsManager(updateManager network_map.PeersUpdateManager, turnCfg *nbconfig.TURNConfig, relayCfg *nbconfig.Relay, settingsManager settings.Manager, groupsManager groups.Manager) *TimeBasedAuthSecretsManager { +func NewTimeBasedAuthSecretsManager(updateManager network_map.PeersUpdateManager, turnCfg *nbconfig.TURNConfig, relayCfg *nbconfig.Relay, settingsManager settings.Manager, groupsManager groups.Manager) (*TimeBasedAuthSecretsManager, error) { + key, err := wgtypes.GeneratePrivateKey() + if err != nil { + return nil, err + } + mgr := &TimeBasedAuthSecretsManager{ updateManager: updateManager, turnCfg: turnCfg, @@ -56,6 +64,7 @@ func NewTimeBasedAuthSecretsManager(updateManager network_map.PeersUpdateManager relayCancelMap: make(map[string]chan struct{}), settingsManager: settingsManager, groupsManager: groupsManager, + wgKey: key, } if turnCfg != nil { @@ -81,7 +90,12 @@ func NewTimeBasedAuthSecretsManager(updateManager network_map.PeersUpdateManager } } - return mgr + return mgr, nil +} + +// GetWGKey returns WireGuard private key used to generate peer keys +func (m *TimeBasedAuthSecretsManager) GetWGKey() (wgtypes.Key, error) { + return m.wgKey, nil } // GenerateTurnToken generates new time-based secret credentials for TURN @@ -147,13 +161,17 @@ func (m *TimeBasedAuthSecretsManager) SetupRefresh(ctx context.Context, accountI m.turnCancelMap[peerID] = turnCancel go m.refreshTURNTokens(ctx, accountID, peerID, turnCancel) log.WithContext(ctx).Debugf("starting TURN refresh for %s", peerID) + } else { + log.WithContext(ctx).Debugf("no TURN configuration, skipping TURN refresh for %s", peerID) } if m.relayCfg != nil { relayCancel := make(chan struct{}, 1) m.relayCancelMap[peerID] = relayCancel go m.refreshRelayTokens(ctx, accountID, peerID, relayCancel) - log.WithContext(ctx).Debugf("starting relay refresh for %s", peerID) + log.WithContext(ctx).Tracef("starting relay refresh for %s", peerID) + } else { + log.WithContext(ctx).Tracef("no relay configuration, skipping relay refresh for %s", peerID) } } @@ -164,7 +182,7 @@ func (m *TimeBasedAuthSecretsManager) refreshTURNTokens(ctx context.Context, acc for { select { case <-cancel: - log.WithContext(ctx).Debugf("stopping TURN refresh for %s", peerID) + log.WithContext(ctx).Tracef("stopping TURN refresh for %s", peerID) return case <-ticker.C: m.pushNewTURNAndRelayTokens(ctx, accountID, peerID) @@ -179,7 +197,7 @@ func (m *TimeBasedAuthSecretsManager) refreshRelayTokens(ctx context.Context, ac for { select { case <-cancel: - log.WithContext(ctx).Debugf("stopping relay refresh for %s", peerID) + log.WithContext(ctx).Tracef("stopping relay refresh for %s", peerID) return case <-ticker.C: m.pushNewRelayTokens(ctx, accountID, peerID) @@ -228,7 +246,10 @@ func (m *TimeBasedAuthSecretsManager) pushNewTURNAndRelayTokens(ctx context.Cont m.extendNetbirdConfig(ctx, peerID, accountID, update) log.WithContext(ctx).Debugf("sending new TURN credentials to peer %s", peerID) - m.updateManager.SendUpdate(ctx, peerID, &network_map.UpdateMessage{Update: update}) + m.updateManager.SendUpdate(ctx, peerID, &network_map.UpdateMessage{ + Update: update, + MessageType: network_map.MessageTypeControlConfig, + }) } func (m *TimeBasedAuthSecretsManager) pushNewRelayTokens(ctx context.Context, accountID, peerID string) { @@ -252,7 +273,10 @@ func (m *TimeBasedAuthSecretsManager) pushNewRelayTokens(ctx context.Context, ac m.extendNetbirdConfig(ctx, peerID, accountID, update) log.WithContext(ctx).Debugf("sending new relay credentials to peer %s", peerID) - m.updateManager.SendUpdate(ctx, peerID, &network_map.UpdateMessage{Update: update}) + m.updateManager.SendUpdate(ctx, peerID, &network_map.UpdateMessage{ + Update: update, + MessageType: network_map.MessageTypeControlConfig, + }) } func (m *TimeBasedAuthSecretsManager) extendNetbirdConfig(ctx context.Context, peerID, accountID string, update *proto.SyncResponse) { diff --git a/management/internals/shared/grpc/token_mgr_test.go b/management/internals/shared/grpc/token_mgr_test.go index 06d28d05b..98eb66fb5 100644 --- a/management/internals/shared/grpc/token_mgr_test.go +++ b/management/internals/shared/grpc/token_mgr_test.go @@ -46,12 +46,13 @@ func TestTimeBasedAuthSecretsManager_GenerateCredentials(t *testing.T) { settingsMockManager := settings.NewMockManager(ctrl) groupsManager := groups.NewManagerMock() - tested := NewTimeBasedAuthSecretsManager(peersManager, &config.TURNConfig{ + tested, err := NewTimeBasedAuthSecretsManager(peersManager, &config.TURNConfig{ CredentialsTTL: ttl, Secret: secret, Turns: []*config.Host{TurnTestHost}, TimeBasedCredentials: true, }, rc, settingsMockManager, groupsManager) + require.NoError(t, err) turnCredentials, err := tested.GenerateTurnToken() require.NoError(t, err) @@ -98,12 +99,13 @@ func TestTimeBasedAuthSecretsManager_SetupRefresh(t *testing.T) { settingsMockManager.EXPECT().GetExtraSettings(gomock.Any(), "someAccountID").Return(&types.ExtraSettings{}, nil).AnyTimes() groupsManager := groups.NewManagerMock() - tested := NewTimeBasedAuthSecretsManager(peersManager, &config.TURNConfig{ + tested, err := NewTimeBasedAuthSecretsManager(peersManager, &config.TURNConfig{ CredentialsTTL: ttl, Secret: secret, Turns: []*config.Host{TurnTestHost}, TimeBasedCredentials: true, }, rc, settingsMockManager, groupsManager) + require.NoError(t, err) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -201,12 +203,13 @@ func TestTimeBasedAuthSecretsManager_CancelRefresh(t *testing.T) { settingsMockManager := settings.NewMockManager(ctrl) groupsManager := groups.NewManagerMock() - tested := NewTimeBasedAuthSecretsManager(peersManager, &config.TURNConfig{ + tested, err := NewTimeBasedAuthSecretsManager(peersManager, &config.TURNConfig{ CredentialsTTL: ttl, Secret: secret, Turns: []*config.Host{TurnTestHost}, TimeBasedCredentials: true, }, rc, settingsMockManager, groupsManager) + require.NoError(t, err) tested.SetupRefresh(context.Background(), "someAccountID", peer) if _, ok := tested.turnCancelMap[peer]; !ok { diff --git a/management/internals/shared/grpc/update_debouncer.go b/management/internals/shared/grpc/update_debouncer.go new file mode 100644 index 000000000..8af9c2656 --- /dev/null +++ b/management/internals/shared/grpc/update_debouncer.go @@ -0,0 +1,103 @@ +package grpc + +import ( + "time" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map" +) + +// UpdateDebouncer implements a backpressure mechanism that: +// - Sends the first update immediately +// - Coalesces rapid subsequent network map updates (only latest matters) +// - Queues control/config updates (all must be delivered) +// - Preserves the order of messages (important for control configs between network maps) +// - Ensures pending updates are sent after a quiet period +type UpdateDebouncer struct { + debounceInterval time.Duration + timer *time.Timer + pendingUpdates []*network_map.UpdateMessage // Queue that preserves order + timerC <-chan time.Time +} + +// NewUpdateDebouncer creates a new debouncer with the specified interval +func NewUpdateDebouncer(interval time.Duration) *UpdateDebouncer { + return &UpdateDebouncer{ + debounceInterval: interval, + } +} + +// ProcessUpdate handles an incoming update and returns whether it should be sent immediately +func (d *UpdateDebouncer) ProcessUpdate(update *network_map.UpdateMessage) bool { + if d.timer == nil { + // No active debounce timer, signal to send immediately + // and start the debounce period + d.startTimer() + return true + } + + // Already in debounce period, accumulate this update preserving order + // Check if we should coalesce with the last pending update + if len(d.pendingUpdates) > 0 && + update.MessageType == network_map.MessageTypeNetworkMap && + d.pendingUpdates[len(d.pendingUpdates)-1].MessageType == network_map.MessageTypeNetworkMap { + // Replace the last network map with this one (coalesce consecutive network maps) + d.pendingUpdates[len(d.pendingUpdates)-1] = update + } else { + // Append to the queue (preserves order for control configs and non-consecutive network maps) + d.pendingUpdates = append(d.pendingUpdates, update) + } + d.resetTimer() + return false +} + +// TimerChannel returns the timer channel for select statements +func (d *UpdateDebouncer) TimerChannel() <-chan time.Time { + if d.timer == nil { + return nil + } + return d.timerC +} + +// GetPendingUpdates returns and clears all pending updates after timer expiration. +// Updates are returned in the order they were received, with consecutive network maps +// already coalesced to only the latest one. +// If there were pending updates, it restarts the timer to continue debouncing. +// If there were no pending updates, it clears the timer (true quiet period). +func (d *UpdateDebouncer) GetPendingUpdates() []*network_map.UpdateMessage { + updates := d.pendingUpdates + d.pendingUpdates = nil + + if len(updates) > 0 { + // There were pending updates, so updates are still coming rapidly + // Restart the timer to continue debouncing mode + if d.timer != nil { + d.timer.Reset(d.debounceInterval) + } + } else { + // No pending updates means true quiet period - return to immediate mode + d.timer = nil + d.timerC = nil + } + + return updates +} + +// Stop stops the debouncer and cleans up resources +func (d *UpdateDebouncer) Stop() { + if d.timer != nil { + d.timer.Stop() + d.timer = nil + d.timerC = nil + } + d.pendingUpdates = nil +} + +func (d *UpdateDebouncer) startTimer() { + d.timer = time.NewTimer(d.debounceInterval) + d.timerC = d.timer.C +} + +func (d *UpdateDebouncer) resetTimer() { + d.timer.Stop() + d.timer.Reset(d.debounceInterval) +} diff --git a/management/internals/shared/grpc/update_debouncer_test.go b/management/internals/shared/grpc/update_debouncer_test.go new file mode 100644 index 000000000..075994a2d --- /dev/null +++ b/management/internals/shared/grpc/update_debouncer_test.go @@ -0,0 +1,587 @@ +package grpc + +import ( + "testing" + "time" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map" + "github.com/netbirdio/netbird/shared/management/proto" +) + +func TestUpdateDebouncer_FirstUpdateSentImmediately(t *testing.T) { + debouncer := NewUpdateDebouncer(50 * time.Millisecond) + defer debouncer.Stop() + + update := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + + shouldSend := debouncer.ProcessUpdate(update) + + if !shouldSend { + t.Error("First update should be sent immediately") + } + + if debouncer.TimerChannel() == nil { + t.Error("Timer should be started after first update") + } +} + +func TestUpdateDebouncer_RapidUpdatesCoalesced(t *testing.T) { + debouncer := NewUpdateDebouncer(50 * time.Millisecond) + defer debouncer.Stop() + + update1 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + update2 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + update3 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + + // First update should be sent immediately + if !debouncer.ProcessUpdate(update1) { + t.Error("First update should be sent immediately") + } + + // Rapid subsequent updates should be coalesced + if debouncer.ProcessUpdate(update2) { + t.Error("Second rapid update should not be sent immediately") + } + + if debouncer.ProcessUpdate(update3) { + t.Error("Third rapid update should not be sent immediately") + } + + // Wait for debounce period + select { + case <-debouncer.TimerChannel(): + pendingUpdates := debouncer.GetPendingUpdates() + if len(pendingUpdates) != 1 { + t.Errorf("Should get exactly 1 pending update, got %d", len(pendingUpdates)) + } + if pendingUpdates[0] != update3 { + t.Error("Should get the last update (update3)") + } + case <-time.After(100 * time.Millisecond): + t.Error("Timer should have fired") + } +} + +func TestUpdateDebouncer_LastUpdateAlwaysSent(t *testing.T) { + debouncer := NewUpdateDebouncer(30 * time.Millisecond) + defer debouncer.Stop() + + update1 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + update2 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + + // Send first update + debouncer.ProcessUpdate(update1) + + // Send second update within debounce period + debouncer.ProcessUpdate(update2) + + // Wait for timer + select { + case <-debouncer.TimerChannel(): + pendingUpdates := debouncer.GetPendingUpdates() + if len(pendingUpdates) != 1 { + t.Errorf("Should get exactly 1 pending update, got %d", len(pendingUpdates)) + } + if pendingUpdates[0] != update2 { + t.Error("Should get the last update") + } + if pendingUpdates[0] == update1 { + t.Error("Should not get the first update") + } + case <-time.After(100 * time.Millisecond): + t.Error("Timer should have fired") + } +} + +func TestUpdateDebouncer_TimerResetOnNewUpdate(t *testing.T) { + debouncer := NewUpdateDebouncer(50 * time.Millisecond) + defer debouncer.Stop() + + update1 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + update2 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + update3 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + + // Send first update + debouncer.ProcessUpdate(update1) + + // Wait a bit, but not the full debounce period + time.Sleep(30 * time.Millisecond) + + // Send second update - should reset timer + debouncer.ProcessUpdate(update2) + + // Wait a bit more + time.Sleep(30 * time.Millisecond) + + // Send third update - should reset timer again + debouncer.ProcessUpdate(update3) + + // Now wait for the timer (should fire after last update's reset) + select { + case <-debouncer.TimerChannel(): + pendingUpdates := debouncer.GetPendingUpdates() + if len(pendingUpdates) != 1 { + t.Errorf("Should get exactly 1 pending update, got %d", len(pendingUpdates)) + } + if pendingUpdates[0] != update3 { + t.Error("Should get the last update (update3)") + } + // Timer should be restarted since there was a pending update + if debouncer.TimerChannel() == nil { + t.Error("Timer should be restarted after sending pending update") + } + case <-time.After(150 * time.Millisecond): + t.Error("Timer should have fired") + } +} + +func TestUpdateDebouncer_TimerRestartsAfterPendingUpdateSent(t *testing.T) { + debouncer := NewUpdateDebouncer(30 * time.Millisecond) + defer debouncer.Stop() + + update1 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + update2 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + update3 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + + // First update sent immediately + debouncer.ProcessUpdate(update1) + + // Second update coalesced + debouncer.ProcessUpdate(update2) + + // Wait for timer to expire + select { + case <-debouncer.TimerChannel(): + pendingUpdates := debouncer.GetPendingUpdates() + + if len(pendingUpdates) == 0 { + t.Fatal("Should have pending update") + } + + // After sending pending update, timer is restarted, so next update is NOT immediate + if debouncer.ProcessUpdate(update3) { + t.Error("Update after debounced send should not be sent immediately (timer restarted)") + } + + // Wait for the restarted timer and verify update3 is pending + select { + case <-debouncer.TimerChannel(): + finalUpdates := debouncer.GetPendingUpdates() + if len(finalUpdates) != 1 || finalUpdates[0] != update3 { + t.Error("Should get update3 as pending") + } + case <-time.After(100 * time.Millisecond): + t.Error("Timer should have fired for restarted timer") + } + case <-time.After(100 * time.Millisecond): + t.Error("Timer should have fired") + } +} + +func TestUpdateDebouncer_StopCleansUp(t *testing.T) { + debouncer := NewUpdateDebouncer(50 * time.Millisecond) + + update := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + + // Send update to start timer + debouncer.ProcessUpdate(update) + + // Stop should clean up + debouncer.Stop() + + // Multiple stops should be safe + debouncer.Stop() +} + +func TestUpdateDebouncer_HighFrequencyUpdates(t *testing.T) { + debouncer := NewUpdateDebouncer(50 * time.Millisecond) + defer debouncer.Stop() + + // Simulate high-frequency updates + var lastUpdate *network_map.UpdateMessage + sentImmediately := 0 + for i := 0; i < 100; i++ { + update := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{ + NetworkMap: &proto.NetworkMap{ + Serial: uint64(i), + }, + }, + MessageType: network_map.MessageTypeNetworkMap, + } + lastUpdate = update + if debouncer.ProcessUpdate(update) { + sentImmediately++ + } + time.Sleep(1 * time.Millisecond) // Very rapid updates + } + + // Only first update should be sent immediately + if sentImmediately != 1 { + t.Errorf("Expected only 1 update sent immediately, got %d", sentImmediately) + } + + // Wait for debounce period + select { + case <-debouncer.TimerChannel(): + pendingUpdates := debouncer.GetPendingUpdates() + if len(pendingUpdates) != 1 { + t.Errorf("Should get exactly 1 pending update, got %d", len(pendingUpdates)) + } + if pendingUpdates[0] != lastUpdate { + t.Error("Should get the very last update") + } + if pendingUpdates[0].Update.NetworkMap.Serial != 99 { + t.Errorf("Expected serial 99, got %d", pendingUpdates[0].Update.NetworkMap.Serial) + } + case <-time.After(200 * time.Millisecond): + t.Error("Timer should have fired") + } +} + +func TestUpdateDebouncer_NoUpdatesAfterFirst(t *testing.T) { + debouncer := NewUpdateDebouncer(30 * time.Millisecond) + defer debouncer.Stop() + + update := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + + // Send first update + if !debouncer.ProcessUpdate(update) { + t.Error("First update should be sent immediately") + } + + // Wait for timer to expire with no additional updates (true quiet period) + select { + case <-debouncer.TimerChannel(): + pendingUpdates := debouncer.GetPendingUpdates() + if len(pendingUpdates) != 0 { + t.Error("Should have no pending updates") + } + // After true quiet period, timer should be cleared + if debouncer.TimerChannel() != nil { + t.Error("Timer should be cleared after quiet period") + } + case <-time.After(100 * time.Millisecond): + t.Error("Timer should have fired") + } +} + +func TestUpdateDebouncer_IntermediateUpdatesDropped(t *testing.T) { + debouncer := NewUpdateDebouncer(50 * time.Millisecond) + defer debouncer.Stop() + + updates := make([]*network_map.UpdateMessage, 5) + for i := range updates { + updates[i] = &network_map.UpdateMessage{ + Update: &proto.SyncResponse{ + NetworkMap: &proto.NetworkMap{ + Serial: uint64(i), + }, + }, + MessageType: network_map.MessageTypeNetworkMap, + } + } + + // First update sent immediately + debouncer.ProcessUpdate(updates[0]) + + // Send updates 1, 2, 3, 4 rapidly - only last one should remain pending + debouncer.ProcessUpdate(updates[1]) + debouncer.ProcessUpdate(updates[2]) + debouncer.ProcessUpdate(updates[3]) + debouncer.ProcessUpdate(updates[4]) + + // Wait for debounce + <-debouncer.TimerChannel() + pendingUpdates := debouncer.GetPendingUpdates() + + if len(pendingUpdates) != 1 { + t.Errorf("Should get exactly 1 pending update, got %d", len(pendingUpdates)) + } + if pendingUpdates[0].Update.NetworkMap.Serial != 4 { + t.Errorf("Expected only the last update (serial 4), got serial %d", pendingUpdates[0].Update.NetworkMap.Serial) + } +} + +func TestUpdateDebouncer_TrueQuietPeriodResetsToImmediateMode(t *testing.T) { + debouncer := NewUpdateDebouncer(30 * time.Millisecond) + defer debouncer.Stop() + + update1 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + update2 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{}, + MessageType: network_map.MessageTypeNetworkMap, + } + + // First update sent immediately + if !debouncer.ProcessUpdate(update1) { + t.Error("First update should be sent immediately") + } + + // Wait for timer without sending any more updates (true quiet period) + <-debouncer.TimerChannel() + pendingUpdates := debouncer.GetPendingUpdates() + + if len(pendingUpdates) != 0 { + t.Error("Should have no pending updates during quiet period") + } + + // After true quiet period, next update should be sent immediately + if !debouncer.ProcessUpdate(update2) { + t.Error("Update after true quiet period should be sent immediately") + } +} + +func TestUpdateDebouncer_ContinuousHighFrequencyStaysInDebounceMode(t *testing.T) { + debouncer := NewUpdateDebouncer(50 * time.Millisecond) + defer debouncer.Stop() + + // Simulate continuous high-frequency updates + for i := 0; i < 10; i++ { + update := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{ + NetworkMap: &proto.NetworkMap{ + Serial: uint64(i), + }, + }, + MessageType: network_map.MessageTypeNetworkMap, + } + + if i == 0 { + // First one sent immediately + if !debouncer.ProcessUpdate(update) { + t.Error("First update should be sent immediately") + } + } else { + // All others should be coalesced (not sent immediately) + if debouncer.ProcessUpdate(update) { + t.Errorf("Update %d should not be sent immediately", i) + } + } + + // Wait a bit but send next update before debounce expires + time.Sleep(20 * time.Millisecond) + } + + // Now wait for final debounce + select { + case <-debouncer.TimerChannel(): + pendingUpdates := debouncer.GetPendingUpdates() + if len(pendingUpdates) == 0 { + t.Fatal("Should have the last update pending") + } + if pendingUpdates[0].Update.NetworkMap.Serial != 9 { + t.Errorf("Expected serial 9, got %d", pendingUpdates[0].Update.NetworkMap.Serial) + } + case <-time.After(200 * time.Millisecond): + t.Error("Timer should have fired") + } +} + +func TestUpdateDebouncer_ControlConfigMessagesQueued(t *testing.T) { + debouncer := NewUpdateDebouncer(50 * time.Millisecond) + defer debouncer.Stop() + + netmapUpdate := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{NetworkMap: &proto.NetworkMap{Serial: 1}}, + MessageType: network_map.MessageTypeNetworkMap, + } + tokenUpdate1 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{NetbirdConfig: &proto.NetbirdConfig{}}, + MessageType: network_map.MessageTypeControlConfig, + } + tokenUpdate2 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{NetbirdConfig: &proto.NetbirdConfig{}}, + MessageType: network_map.MessageTypeControlConfig, + } + + // First update sent immediately + debouncer.ProcessUpdate(netmapUpdate) + + // Send multiple control config updates - they should all be queued + debouncer.ProcessUpdate(tokenUpdate1) + debouncer.ProcessUpdate(tokenUpdate2) + + // Wait for debounce period + select { + case <-debouncer.TimerChannel(): + pendingUpdates := debouncer.GetPendingUpdates() + // Should get both control config updates + if len(pendingUpdates) != 2 { + t.Errorf("Expected 2 control config updates, got %d", len(pendingUpdates)) + } + // Control configs should come first + if pendingUpdates[0] != tokenUpdate1 { + t.Error("First pending update should be tokenUpdate1") + } + if pendingUpdates[1] != tokenUpdate2 { + t.Error("Second pending update should be tokenUpdate2") + } + case <-time.After(200 * time.Millisecond): + t.Error("Timer should have fired") + } +} + +func TestUpdateDebouncer_MixedMessageTypes(t *testing.T) { + debouncer := NewUpdateDebouncer(50 * time.Millisecond) + defer debouncer.Stop() + + netmapUpdate1 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{NetworkMap: &proto.NetworkMap{Serial: 1}}, + MessageType: network_map.MessageTypeNetworkMap, + } + netmapUpdate2 := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{NetworkMap: &proto.NetworkMap{Serial: 2}}, + MessageType: network_map.MessageTypeNetworkMap, + } + tokenUpdate := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{NetbirdConfig: &proto.NetbirdConfig{}}, + MessageType: network_map.MessageTypeControlConfig, + } + + // First update sent immediately + debouncer.ProcessUpdate(netmapUpdate1) + + // Send token update and network map update + debouncer.ProcessUpdate(tokenUpdate) + debouncer.ProcessUpdate(netmapUpdate2) + + // Wait for debounce period + select { + case <-debouncer.TimerChannel(): + pendingUpdates := debouncer.GetPendingUpdates() + // Should get 2 updates in order: token, then network map + if len(pendingUpdates) != 2 { + t.Errorf("Expected 2 pending updates, got %d", len(pendingUpdates)) + } + // Token update should come first (preserves order) + if pendingUpdates[0] != tokenUpdate { + t.Error("First pending update should be tokenUpdate") + } + // Network map update should come second + if pendingUpdates[1] != netmapUpdate2 { + t.Error("Second pending update should be netmapUpdate2") + } + case <-time.After(200 * time.Millisecond): + t.Error("Timer should have fired") + } +} + +func TestUpdateDebouncer_OrderPreservation(t *testing.T) { + debouncer := NewUpdateDebouncer(50 * time.Millisecond) + defer debouncer.Stop() + + // Simulate: 50 network maps -> 1 control config -> 50 network maps + // Expected result: 3 messages (netmap, controlConfig, netmap) + + // Send first network map immediately + firstNetmap := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{NetworkMap: &proto.NetworkMap{Serial: 0}}, + MessageType: network_map.MessageTypeNetworkMap, + } + if !debouncer.ProcessUpdate(firstNetmap) { + t.Error("First update should be sent immediately") + } + + // Send 49 more network maps (will be coalesced to last one) + var lastNetmapBatch1 *network_map.UpdateMessage + for i := 1; i < 50; i++ { + lastNetmapBatch1 = &network_map.UpdateMessage{ + Update: &proto.SyncResponse{NetworkMap: &proto.NetworkMap{Serial: uint64(i)}}, + MessageType: network_map.MessageTypeNetworkMap, + } + debouncer.ProcessUpdate(lastNetmapBatch1) + } + + // Send 1 control config + controlConfig := &network_map.UpdateMessage{ + Update: &proto.SyncResponse{NetbirdConfig: &proto.NetbirdConfig{}}, + MessageType: network_map.MessageTypeControlConfig, + } + debouncer.ProcessUpdate(controlConfig) + + // Send 50 more network maps (will be coalesced to last one) + var lastNetmapBatch2 *network_map.UpdateMessage + for i := 50; i < 100; i++ { + lastNetmapBatch2 = &network_map.UpdateMessage{ + Update: &proto.SyncResponse{NetworkMap: &proto.NetworkMap{Serial: uint64(i)}}, + MessageType: network_map.MessageTypeNetworkMap, + } + debouncer.ProcessUpdate(lastNetmapBatch2) + } + + // Wait for debounce period + select { + case <-debouncer.TimerChannel(): + pendingUpdates := debouncer.GetPendingUpdates() + // Should get exactly 3 updates: netmap, controlConfig, netmap + if len(pendingUpdates) != 3 { + t.Errorf("Expected 3 pending updates, got %d", len(pendingUpdates)) + } + // First should be the last netmap from batch 1 + if pendingUpdates[0] != lastNetmapBatch1 { + t.Error("First pending update should be last netmap from batch 1") + } + if pendingUpdates[0].Update.NetworkMap.Serial != 49 { + t.Errorf("Expected serial 49, got %d", pendingUpdates[0].Update.NetworkMap.Serial) + } + // Second should be the control config + if pendingUpdates[1] != controlConfig { + t.Error("Second pending update should be control config") + } + // Third should be the last netmap from batch 2 + if pendingUpdates[2] != lastNetmapBatch2 { + t.Error("Third pending update should be last netmap from batch 2") + } + if pendingUpdates[2].Update.NetworkMap.Serial != 99 { + t.Errorf("Expected serial 99, got %d", pendingUpdates[2].Update.NetworkMap.Serial) + } + case <-time.After(200 * time.Millisecond): + t.Error("Timer should have fired") + } +} diff --git a/management/internals/shared/grpc/validate_session_test.go b/management/internals/shared/grpc/validate_session_test.go new file mode 100644 index 000000000..d649102a1 --- /dev/null +++ b/management/internals/shared/grpc/validate_session_test.go @@ -0,0 +1,428 @@ +//go:build integration + +package grpc + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/shared/management/proto" +) + +type validateSessionTestSetup struct { + proxyService *ProxyServiceServer + store store.Store + cleanup func() +} + +func setupValidateSessionTest(t *testing.T) *validateSessionTestSetup { + t.Helper() + + ctx := context.Background() + testStore, storeCleanup, err := store.NewTestStoreFromSQL(ctx, "../../../server/testdata/auth_callback.sql", t.TempDir()) + require.NoError(t, err) + + serviceManager := &testValidateSessionServiceManager{store: testStore} + usersManager := &testValidateSessionUsersManager{store: testStore} + proxyManager := &testValidateSessionProxyManager{} + + tokenStore := NewOneTimeTokenStore(ctx, testCacheStore(t)) + pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t)) + + proxyService := NewProxyServiceServer(nil, tokenStore, pkceStore, ProxyOIDCConfig{}, nil, usersManager, nil, proxyManager, nil) + proxyService.SetServiceManager(serviceManager) + + createTestProxies(t, ctx, testStore) + + return &validateSessionTestSetup{ + proxyService: proxyService, + store: testStore, + cleanup: storeCleanup, + } +} + +func createTestProxies(t *testing.T, ctx context.Context, testStore store.Store) { + t.Helper() + + pubKey, privKey := generateSessionKeyPair(t) + + testProxy := &service.Service{ + ID: "testProxyId", + AccountID: "testAccountId", + Name: "Test Proxy", + Domain: "test-proxy.example.com", + Enabled: true, + SessionPrivateKey: privKey, + SessionPublicKey: pubKey, + Auth: service.AuthConfig{ + BearerAuth: &service.BearerAuthConfig{ + Enabled: true, + }, + }, + } + require.NoError(t, testStore.CreateService(ctx, testProxy)) + + restrictedProxy := &service.Service{ + ID: "restrictedProxyId", + AccountID: "testAccountId", + Name: "Restricted Proxy", + Domain: "restricted-proxy.example.com", + Enabled: true, + SessionPrivateKey: privKey, + SessionPublicKey: pubKey, + Auth: service.AuthConfig{ + BearerAuth: &service.BearerAuthConfig{ + Enabled: true, + DistributionGroups: []string{"allowedGroupId"}, + }, + }, + } + require.NoError(t, testStore.CreateService(ctx, restrictedProxy)) +} + +func generateSessionKeyPair(t *testing.T) (string, string) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + return base64.StdEncoding.EncodeToString(pub), base64.StdEncoding.EncodeToString(priv) +} + +func createSessionToken(t *testing.T, privKeyB64, userID, domain string) string { + t.Helper() + token, err := sessionkey.SignToken(privKeyB64, userID, "", domain, auth.MethodOIDC, nil, nil, time.Hour) + require.NoError(t, err) + return token +} + +func TestValidateSession_UserAllowed(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "testProxyId") + require.NoError(t, err) + + token := createSessionToken(t, proxy.SessionPrivateKey, "allowedUserId", "test-proxy.example.com") + + resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: "test-proxy.example.com", + SessionToken: token, + }) + + require.NoError(t, err) + assert.True(t, resp.Valid, "User should be allowed access") + assert.Equal(t, "allowedUserId", resp.UserId) + assert.Empty(t, resp.DeniedReason) + assert.Equal(t, []string{"allowedGroupId"}, resp.GetPeerGroupIds(), "PeerGroupIds must mirror the resolved user's group memberships") +} + +func TestValidateSession_UserNotInAllowedGroup(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "restrictedProxyId") + require.NoError(t, err) + + token := createSessionToken(t, proxy.SessionPrivateKey, "nonGroupUserId", "restricted-proxy.example.com") + + resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: "restricted-proxy.example.com", + SessionToken: token, + }) + + require.NoError(t, err) + assert.False(t, resp.Valid, "User not in group should be denied") + assert.Equal(t, "not_in_group", resp.DeniedReason) + assert.Equal(t, "nonGroupUserId", resp.UserId) + assert.Empty(t, resp.GetPeerGroupIds(), "PeerGroupIds must mirror the resolved user's actual (empty) memberships on denial") +} + +func TestValidateSession_UserInDifferentAccount(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "testProxyId") + require.NoError(t, err) + + token := createSessionToken(t, proxy.SessionPrivateKey, "otherAccountUserId", "test-proxy.example.com") + + resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: "test-proxy.example.com", + SessionToken: token, + }) + + require.NoError(t, err) + assert.False(t, resp.Valid, "User in different account should be denied") + assert.Equal(t, "account_mismatch", resp.DeniedReason) +} + +func TestValidateSession_UserNotFound(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "testProxyId") + require.NoError(t, err) + + token := createSessionToken(t, proxy.SessionPrivateKey, "nonExistentUserId", "test-proxy.example.com") + + resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: "test-proxy.example.com", + SessionToken: token, + }) + + require.NoError(t, err) + assert.False(t, resp.Valid, "Non-existent user should be denied") + assert.Equal(t, "user_not_found", resp.DeniedReason) +} + +func TestValidateSession_ProxyNotFound(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "testProxyId") + require.NoError(t, err) + + token := createSessionToken(t, proxy.SessionPrivateKey, "allowedUserId", "unknown-proxy.example.com") + + resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: "unknown-proxy.example.com", + SessionToken: token, + }) + + require.NoError(t, err) + assert.False(t, resp.Valid, "Unknown proxy should be denied") + assert.Equal(t, "service_not_found", resp.DeniedReason) +} + +func TestValidateSession_InvalidToken(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: "test-proxy.example.com", + SessionToken: "invalid-token", + }) + + require.NoError(t, err) + assert.False(t, resp.Valid, "Invalid token should be denied") + assert.Equal(t, "invalid_token", resp.DeniedReason) +} + +func TestValidateSession_MissingDomain(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + SessionToken: "some-token", + }) + + require.NoError(t, err) + assert.False(t, resp.Valid) + assert.Contains(t, resp.DeniedReason, "missing") +} + +func TestValidateSession_MissingToken(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: "test-proxy.example.com", + }) + + require.NoError(t, err) + assert.False(t, resp.Valid) + assert.Contains(t, resp.DeniedReason, "missing") +} + +type testValidateSessionServiceManager struct { + store store.Store +} + +func (m *testValidateSessionServiceManager) GetAllServices(_ context.Context, _, _ string) ([]*service.Service, error) { + return nil, nil +} + +func (m *testValidateSessionServiceManager) GetService(_ context.Context, _, _, _ string) (*service.Service, error) { + return nil, nil +} + +func (m *testValidateSessionServiceManager) CreateService(_ context.Context, _, _ string, _ *service.Service) (*service.Service, error) { + return nil, nil +} + +func (m *testValidateSessionServiceManager) UpdateService(_ context.Context, _, _ string, _ *service.Service) (*service.Service, error) { + return nil, nil +} + +func (m *testValidateSessionServiceManager) DeleteService(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *testValidateSessionServiceManager) DeleteAllServices(_ context.Context, _, _ string) error { + return nil +} + +func (m *testValidateSessionServiceManager) SetCertificateIssuedAt(_ context.Context, _, _ string) error { + return nil +} + +func (m *testValidateSessionServiceManager) SetStatus(_ context.Context, _, _ string, _ service.Status) error { + return nil +} + +func (m *testValidateSessionServiceManager) ReloadAllServicesForAccount(_ context.Context, _ string) error { + return nil +} + +func (m *testValidateSessionServiceManager) ReloadService(_ context.Context, _, _ string) error { + return nil +} + +func (m *testValidateSessionServiceManager) GetGlobalServices(ctx context.Context) ([]*service.Service, error) { + return m.store.GetServices(ctx, store.LockingStrengthNone) +} + +func (m *testValidateSessionServiceManager) GetServiceByID(ctx context.Context, accountID, proxyID string) (*service.Service, error) { + return m.store.GetServiceByID(ctx, store.LockingStrengthNone, accountID, proxyID) +} + +func (m *testValidateSessionServiceManager) GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error) { + return m.store.GetAccountServices(ctx, store.LockingStrengthNone, accountID) +} + +func (m *testValidateSessionServiceManager) GetServiceIDByTargetID(_ context.Context, _, _ string) (string, error) { + return "", nil +} + +func (m *testValidateSessionServiceManager) CreateServiceFromPeer(_ context.Context, _, _ string, _ *service.ExposeServiceRequest) (*service.ExposeServiceResponse, error) { + return nil, nil +} + +func (m *testValidateSessionServiceManager) RenewServiceFromPeer(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *testValidateSessionServiceManager) StopServiceFromPeer(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *testValidateSessionServiceManager) StartExposeReaper(_ context.Context) {} + +func (m *testValidateSessionServiceManager) GetServiceByDomain(ctx context.Context, domain string) (*service.Service, error) { + return m.store.GetServiceByDomain(ctx, domain) +} + +func (m *testValidateSessionServiceManager) GetClusters(_ context.Context, _, _ string) ([]proxy.Cluster, error) { + return nil, nil +} + +func (m *testValidateSessionServiceManager) DeleteAccountCluster(_ context.Context, _, _, _ string) error { + return nil +} + +type testValidateSessionProxyManager struct{} + +func (m *testValidateSessionProxyManager) Connect(_ context.Context, _, _, _, _ string, _ *string, _ *proxy.Capabilities) (*proxy.Proxy, error) { + return nil, nil +} + +func (m *testValidateSessionProxyManager) Disconnect(_ context.Context, _, _ string) error { + return nil +} + +func (m *testValidateSessionProxyManager) Heartbeat(_ context.Context, _ *proxy.Proxy) error { + return nil +} + +func (m *testValidateSessionProxyManager) DeleteAccountCluster(_ context.Context, _, _ string) error { + return nil +} + +func (m *testValidateSessionProxyManager) GetActiveClusterAddresses(_ context.Context) ([]string, error) { + return nil, nil +} + +func (m *testValidateSessionProxyManager) GetActiveClusterAddressesForAccount(_ context.Context, _ string) ([]string, error) { + return nil, nil +} + +func (m *testValidateSessionProxyManager) GetActiveClusters(_ context.Context) ([]proxy.Cluster, error) { + return nil, nil +} + +func (m *testValidateSessionProxyManager) CleanupStale(_ context.Context, _ time.Duration) error { + return nil +} + +func (m *testValidateSessionProxyManager) GetAccountProxy(_ context.Context, _ string) (*proxy.Proxy, error) { + return nil, nil +} + +func (m *testValidateSessionProxyManager) CountAccountProxies(_ context.Context, _ string) (int64, error) { + return 0, nil +} + +func (m *testValidateSessionProxyManager) IsClusterAddressAvailable(_ context.Context, _, _ string) (bool, error) { + return true, nil +} + +func (m *testValidateSessionProxyManager) DeleteProxy(_ context.Context, _ string) error { + return nil +} + +func (m *testValidateSessionProxyManager) ClusterSupportsCustomPorts(_ context.Context, _ string) *bool { + return nil +} + +func (m *testValidateSessionProxyManager) ClusterRequireSubdomain(_ context.Context, _ string) *bool { + return nil +} + +func (m *testValidateSessionProxyManager) ClusterSupportsCrowdSec(_ context.Context, _ string) *bool { + return nil +} + +func (m *testValidateSessionProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) *bool { + return nil +} + +type testValidateSessionUsersManager struct { + store store.Store +} + +func (m *testValidateSessionUsersManager) GetUser(ctx context.Context, userID string) (*types.User, error) { + return m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) +} + +func (m *testValidateSessionUsersManager) GetUserWithGroups(ctx context.Context, userID string) (*types.User, []*types.Group, error) { + user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) + if err != nil { + return nil, nil, err + } + if len(user.AutoGroups) == 0 { + return user, nil, nil + } + groupsMap, err := m.store.GetGroupsByIDs(ctx, store.LockingStrengthNone, user.AccountID, user.AutoGroups) + if err != nil { + return nil, nil, err + } + groups := make([]*types.Group, 0, len(user.AutoGroups)) + for _, id := range user.AutoGroups { + if g, ok := groupsMap[id]; ok && g != nil { + groups = append(groups, g) + } + } + return user, groups, nil +} diff --git a/management/main.go b/management/main.go index ff8482f97..a19b741a9 100644 --- a/management/main.go +++ b/management/main.go @@ -1,19 +1,24 @@ package main import ( - "log" "net/http" // nolint:gosec _ "net/http/pprof" "os" + log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/management/cmd" ) func main() { - go func() { - log.Println(http.ListenAndServe("localhost:6060", nil)) - }() + if pprofAddr := os.Getenv("NB_PPROF_ADDR"); pprofAddr != "" { + log.Infof("pprof enabled, listening on: %s", pprofAddr) + go func() { + log.Println(http.ListenAndServe(pprofAddr, nil)) + }() + } + if err := cmd.Execute(); err != nil { os.Exit(1) } diff --git a/management/server/account.go b/management/server/account.go index a4b2a752b..619036d0c 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -15,6 +15,10 @@ import ( "sync" "time" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/job" + "github.com/netbirdio/netbird/shared/auth" + cacheStore "github.com/eko/gocache/lib/v4/store" "github.com/eko/gocache/store/redis/v4" "github.com/rs/xid" @@ -24,7 +28,9 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/formatter/hook" + "github.com/netbirdio/netbird/idp/dex" "github.com/netbirdio/netbird/management/internals/controllers/network_map" + nbconfig "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" nbcache "github.com/netbirdio/netbird/management/server/cache" @@ -34,7 +40,6 @@ import ( "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/management/server/peers/ephemeral" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" @@ -45,6 +50,7 @@ import ( "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/route" + nbdomain "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/status" ) @@ -68,18 +74,22 @@ type DefaultAccountManager struct { // cacheLoading keeps the accountIDs that are currently reloading. The accountID has to be removed once cache has been reloaded cacheLoading map[string]chan struct{} networkMapController network_map.Controller + jobManager *job.Manager idpManager idp.Manager cacheManager *nbcache.AccountUserDataCache externalCacheManager nbcache.UserDataCache ctx context.Context eventStore activity.Store geo geolocation.Geolocation - ephemeralManager ephemeral.Manager requestBuffer *AccountRequestBuffer proxyController port_forwarding.Controller settingsManager settings.Manager + serviceManager service.Manager + + // config contains the management server configuration + config *nbconfig.Config // singleAccountMode indicates whether the instance has a single account. // If true, then every new user will end up under the same account. @@ -106,6 +116,10 @@ type DefaultAccountManager struct { var _ account.Manager = (*DefaultAccountManager)(nil) +func (am *DefaultAccountManager) SetServiceManager(serviceManager service.Manager) { + am.serviceManager = serviceManager +} + func isUniqueConstraintError(err error) bool { switch { case strings.Contains(err.Error(), "(SQLSTATE 23505)"), @@ -168,11 +182,13 @@ func (am *DefaultAccountManager) getJWTGroupsChanges(user *types.User, groups [] return modified, newUserAutoGroups, newGroupsToCreate, nil } -// BuildManager creates a new DefaultAccountManager with a provided Store +// BuildManager creates a new DefaultAccountManager with all dependencies. func BuildManager( ctx context.Context, + config *nbconfig.Config, store store.Store, networkMapController network_map.Controller, + jobManager *job.Manager, idpManager idp.Manager, singleAccountModeDomain string, eventStore activity.Store, @@ -184,6 +200,7 @@ func BuildManager( settingsManager settings.Manager, permissionsManager permissions.Manager, disableDefaultPolicy bool, + sharedCacheStore cacheStore.StoreInterface, ) (*DefaultAccountManager, error) { start := time.Now() defer func() { @@ -192,8 +209,10 @@ func BuildManager( am := &DefaultAccountManager{ Store: store, + config: config, geo: geo, networkMapController: networkMapController, + jobManager: jobManager, idpManager: idpManager, ctx: context.Background(), cacheMux: sync.Mutex{}, @@ -221,7 +240,7 @@ func BuildManager( // enable single account mode only if configured by user and number of existing accounts is not grater than 1 am.singleAccountMode = singleAccountModeDomain != "" && accountsCounter <= 1 if am.singleAccountMode { - if !isDomainValid(singleAccountModeDomain) { + if !nbdomain.IsValidDomainNoWildcard(singleAccountModeDomain) { return nil, status.Errorf(status.InvalidArgument, "invalid domain \"%s\" provided for a single account mode. Please review your input for --single-account-mode-domain", singleAccountModeDomain) } am.singleAccountModeDomain = singleAccountModeDomain @@ -230,16 +249,12 @@ func BuildManager( log.WithContext(ctx).Infof("single account mode disabled, accounts number %d", accountsCounter) } - cacheStore, err := nbcache.NewStore(ctx, nbcache.DefaultIDPCacheExpirationMax, nbcache.DefaultIDPCacheCleanupInterval) - if err != nil { - return nil, fmt.Errorf("getting cache store: %s", err) - } - am.externalCacheManager = nbcache.NewUserDataCache(cacheStore) - am.cacheManager = nbcache.NewAccountUserDataCache(am.loadAccount, cacheStore) + am.externalCacheManager = nbcache.NewUserDataCache(sharedCacheStore) + am.cacheManager = nbcache.NewAccountUserDataCache(am.loadAccount, sharedCacheStore) - if !isNil(am.idpManager) { + if !isNil(am.idpManager) && !IsEmbeddedIdp(am.idpManager) { go func() { - err := am.warmupIDPCache(ctx, cacheStore) + err := am.warmupIDPCache(ctx, sharedCacheStore) if err != nil { log.WithContext(ctx).Warnf("failed warming up cache due to error: %v", err) // todo retry? @@ -255,10 +270,6 @@ func BuildManager( return am, nil } -func (am *DefaultAccountManager) SetEphemeralManager(em ephemeral.Manager) { - am.ephemeralManager = em -} - func (am *DefaultAccountManager) GetExternalCacheManager() account.ExternalCacheManager { return am.externalCacheManager } @@ -272,7 +283,7 @@ func (am *DefaultAccountManager) GetIdpManager() idp.Manager { // User that performs the update has to belong to the account. // Returns an updated Settings func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Update) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Update) if err != nil { return nil, fmt.Errorf("failed to validate user permissions: %w", err) } @@ -281,9 +292,15 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco return nil, status.NewPermissionDeniedError() } + // Canonicalize the incoming range so a caller-supplied prefix with host bits + // (e.g. 100.64.1.1/16) compares equal to the masked form stored on network.Net. + newSettings.NetworkRange = newSettings.NetworkRange.Masked() + var oldSettings *types.Settings var updateAccountPeers bool var groupChangesAffectPeers bool + var reloadReverseProxy bool + var effectiveOldNetworkRange netip.Prefix err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { var groupsUpdated bool @@ -297,26 +314,74 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco return err } - if oldSettings.NetworkRange != newSettings.NetworkRange { + // No lock: the transaction already holds Settings(Update), and network.Net is + // only mutated by reallocateAccountPeerIPs, which is reachable only through + // this same code path. A Share lock here would extend an unnecessary row lock + // and complicate ordering against updatePeerIPv6InTransaction. + network, err := transaction.GetAccountNetwork(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return fmt.Errorf("get account network: %w", err) + } + effectiveOldNetworkRange = prefixFromIPNet(network.Net) + + if oldSettings.Extra != nil && newSettings.Extra != nil && + oldSettings.Extra.PeerApprovalEnabled && !newSettings.Extra.PeerApprovalEnabled { + approvedCount, err := transaction.ApproveAccountPeers(ctx, accountID) + if err != nil { + return fmt.Errorf("failed to approve pending peers: %w", err) + } + + if approvedCount > 0 { + log.WithContext(ctx).Debugf("approved %d pending peers in account %s", approvedCount, accountID) + updateAccountPeers = true + } + } + + if newSettings.NetworkRange.IsValid() && newSettings.NetworkRange != effectiveOldNetworkRange { if err = am.reallocateAccountPeerIPs(ctx, transaction, accountID, newSettings.NetworkRange); err != nil { return err } + reloadReverseProxy = true + updateAccountPeers = true + } + + if ipv6SettingsChanged(oldSettings, newSettings) { + if err = am.updatePeerIPv6Addresses(ctx, transaction, accountID, newSettings); err != nil { + return err + } updateAccountPeers = true } if oldSettings.RoutingPeerDNSResolutionEnabled != newSettings.RoutingPeerDNSResolutionEnabled || oldSettings.LazyConnectionEnabled != newSettings.LazyConnectionEnabled || - oldSettings.DNSDomain != newSettings.DNSDomain { + oldSettings.DNSDomain != newSettings.DNSDomain || + oldSettings.AutoUpdateVersion != newSettings.AutoUpdateVersion || + oldSettings.AutoUpdateAlways != newSettings.AutoUpdateAlways || + oldSettings.PeerLoginExpirationEnabled != newSettings.PeerLoginExpirationEnabled || + oldSettings.PeerLoginExpiration != newSettings.PeerLoginExpiration || + oldSettings.MetricsPushEnabled != newSettings.MetricsPushEnabled { + // Session deadline is derived from LastLogin + PeerLoginExpiration + // on every Login/Sync response. Without a fan-out push, connected + // peers keep the deadline they received at login time and only see + // the new value after the next unrelated NetworkMap change. Add + // these two fields to the trigger list so admin-side expiry tweaks + // (e.g. shortening from 24h to 1h) reach every connected peer + // within seconds, which is what the proactive-warning feature + // relies on (see client/internal/auth/sessionwatch). updateAccountPeers = true } if oldSettings.GroupsPropagationEnabled != newSettings.GroupsPropagationEnabled && newSettings.GroupsPropagationEnabled { - groupsUpdated, groupChangesAffectPeers, err = propagateUserGroupMemberships(ctx, transaction, accountID) + groupsUpdated, groupChangesAffectPeers, err = am.propagateUserGroupMemberships(ctx, transaction, accountID) if err != nil { return err } } + if newSettings.Extra == nil { + newSettings.Extra = oldSettings.Extra + } + if err = transaction.SaveAccountSettings(ctx, accountID, newSettings); err != nil { return err } @@ -342,9 +407,16 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco am.handleLazyConnectionSettings(ctx, oldSettings, newSettings, userID, accountID) am.handlePeerLoginExpirationSettings(ctx, oldSettings, newSettings, userID, accountID) am.handleGroupsPropagationSettings(ctx, oldSettings, newSettings, userID, accountID) + am.handleAutoUpdateVersionSettings(ctx, oldSettings, newSettings, userID, accountID) + am.handleAutoUpdateAlwaysSettings(ctx, oldSettings, newSettings, userID, accountID) + am.handlePeerExposeSettings(ctx, oldSettings, newSettings, userID, accountID) + am.handleMetricsPushSettings(ctx, oldSettings, newSettings, userID, accountID) if err = am.handleInactivityExpirationSettings(ctx, oldSettings, newSettings, userID, accountID); err != nil { return nil, err } + if err = am.handleLocalMfaSettings(ctx, oldSettings, newSettings, userID, accountID); err != nil { + return nil, err + } if oldSettings.DNSDomain != newSettings.DNSDomain { eventMeta := map[string]any{ "old_dns_domain": oldSettings.DNSDomain, @@ -352,21 +424,69 @@ func (am *DefaultAccountManager) UpdateAccountSettings(ctx context.Context, acco } am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountDNSDomainUpdated, eventMeta) } - if oldSettings.NetworkRange != newSettings.NetworkRange { + if newSettings.NetworkRange.IsValid() && newSettings.NetworkRange != effectiveOldNetworkRange { eventMeta := map[string]any{ - "old_network_range": oldSettings.NetworkRange.String(), + "old_network_range": effectiveOldNetworkRange.String(), "new_network_range": newSettings.NetworkRange.String(), } am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountNetworkRangeUpdated, eventMeta) } + oldIPv6On := len(oldSettings.IPv6EnabledGroups) > 0 + newIPv6On := len(newSettings.IPv6EnabledGroups) > 0 + if oldIPv6On != newIPv6On { + if newIPv6On { + am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountIPv6Enabled, nil) + } else { + am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountIPv6Disabled, nil) + } + } + if oldSettings.NetworkRangeV6 != newSettings.NetworkRangeV6 { + eventMeta := map[string]any{ + "old_network_range_v6": oldSettings.NetworkRangeV6.String(), + "new_network_range_v6": newSettings.NetworkRangeV6.String(), + } + am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountNetworkRangeUpdated, eventMeta) + } + if reloadReverseProxy { + if err = am.serviceManager.ReloadAllServicesForAccount(ctx, accountID); err != nil { + log.WithContext(ctx).Warnf("failed to reload all services for account %s: %v", accountID, err) + } + } if updateAccountPeers || extraSettingsChanged || groupChangesAffectPeers { - go am.UpdateAccountPeers(ctx, accountID) + go am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceAccountSettings, Operation: types.UpdateOperationUpdate}) } return newSettings, nil } +func ipv6SettingsChanged(old, updated *types.Settings) bool { + if old.NetworkRangeV6 != updated.NetworkRangeV6 { + return true + } + oldGroups := slices.Clone(old.IPv6EnabledGroups) + newGroups := slices.Clone(updated.IPv6EnabledGroups) + slices.Sort(oldGroups) + slices.Sort(newGroups) + return !slices.Equal(oldGroups, newGroups) +} + +// prefixFromIPNet returns the overlay prefix actually allocated on the account +// network, or an invalid prefix if none is set. Settings.NetworkRange is a +// user-facing override that is empty on legacy accounts, so the effective +// range must be read from network.Net to compare against an incoming update. +func prefixFromIPNet(ipNet net.IPNet) netip.Prefix { + if ipNet.IP == nil { + return netip.Prefix{} + } + addr, ok := netip.AddrFromSlice(ipNet.IP) + if !ok { + return netip.Prefix{} + } + ones, _ := ipNet.Mask.Size() + return netip.PrefixFrom(addr.Unmap(), ones) +} + func (am *DefaultAccountManager) validateSettingsUpdate(ctx context.Context, transaction store.Store, newSettings, oldSettings *types.Settings, userID, accountID string) error { halfYearLimit := 180 * 24 * time.Hour if newSettings.PeerLoginExpiration > halfYearLimit { @@ -377,21 +497,52 @@ func (am *DefaultAccountManager) validateSettingsUpdate(ctx context.Context, tra return status.Errorf(status.InvalidArgument, "peer login expiration can't be smaller than one hour") } - if newSettings.DNSDomain != "" && !isDomainValid(newSettings.DNSDomain) { + if newSettings.DNSDomain != "" && !nbdomain.IsValidDomainNoWildcard(newSettings.DNSDomain) { return status.Errorf(status.InvalidArgument, "invalid domain \"%s\" provided for DNS domain", newSettings.DNSDomain) } - peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") - if err != nil { + if newSettings.DNSDomain != oldSettings.DNSDomain && newSettings.DNSDomain != "" { + existingZone, err := transaction.GetZoneByDomain(ctx, accountID, newSettings.DNSDomain) + if err != nil { + if sErr, ok := status.FromError(err); !ok || sErr.Type() != status.NotFound { + return fmt.Errorf("failed to check existing zone: %w", err) + } + } + if existingZone != nil { + return status.Errorf(status.InvalidArgument, "peer DNS domain %s conflicts with existing custom DNS zone", newSettings.DNSDomain) + } + } + + if err := validateIPv6EnabledGroups(ctx, transaction, accountID, newSettings.IPv6EnabledGroups); err != nil { return err } - peersMap := make(map[string]*nbpeer.Peer, len(peers)) - for _, peer := range peers { - peersMap[peer.ID] = peer + return am.integratedPeerValidator.ValidateExtraSettings(ctx, newSettings.Extra, oldSettings.Extra, userID, accountID) +} + +// validateIPv6EnabledGroups checks that all referenced IPv6-enabled group IDs exist in the account. +func validateIPv6EnabledGroups(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) error { + if len(groupIDs) == 0 { + return nil } - return am.integratedPeerValidator.ValidateExtraSettings(ctx, newSettings.Extra, oldSettings.Extra, peersMap, userID, accountID) + groups, err := transaction.GetAccountGroups(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return fmt.Errorf("get groups for IPv6 validation: %w", err) + } + + existing := make(map[string]struct{}, len(groups)) + for _, g := range groups { + existing[g.ID] = struct{}{} + } + + for _, gid := range groupIDs { + if _, ok := existing[gid]; !ok { + return status.Errorf(status.InvalidArgument, "IPv6 enabled group %s does not exist", gid) + } + } + + return nil } func (am *DefaultAccountManager) handleRoutingPeerDNSResolutionSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) { @@ -414,6 +565,16 @@ func (am *DefaultAccountManager) handleLazyConnectionSettings(ctx context.Contex } } +func (am *DefaultAccountManager) handleMetricsPushSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) { + if oldSettings.MetricsPushEnabled != newSettings.MetricsPushEnabled { + if newSettings.MetricsPushEnabled { + am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountMetricsPushEnabled, nil) + } else { + am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountMetricsPushDisabled, nil) + } + } +} + func (am *DefaultAccountManager) handlePeerLoginExpirationSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) { if oldSettings.PeerLoginExpirationEnabled != newSettings.PeerLoginExpirationEnabled { event := activity.AccountPeerLoginExpirationEnabled @@ -443,6 +604,39 @@ func (am *DefaultAccountManager) handleGroupsPropagationSettings(ctx context.Con } } +func (am *DefaultAccountManager) handleAutoUpdateVersionSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) { + if oldSettings.AutoUpdateVersion != newSettings.AutoUpdateVersion { + am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountAutoUpdateVersionUpdated, map[string]any{ + "version": newSettings.AutoUpdateVersion, + }) + } +} + +func (am *DefaultAccountManager) handleAutoUpdateAlwaysSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) { + if oldSettings.AutoUpdateAlways != newSettings.AutoUpdateAlways { + if newSettings.AutoUpdateAlways { + am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountAutoUpdateAlwaysEnabled, nil) + } else { + am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountAutoUpdateAlwaysDisabled, nil) + } + } +} + +func (am *DefaultAccountManager) handlePeerExposeSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) { + oldEnabled := oldSettings.PeerExposeEnabled + newEnabled := newSettings.PeerExposeEnabled + + if oldEnabled == newEnabled { + return + } + + event := activity.AccountPeerExposeEnabled + if !newEnabled { + event = activity.AccountPeerExposeDisabled + } + am.StoreEvent(ctx, userID, accountID, accountID, event, nil) +} + func (am *DefaultAccountManager) handleInactivityExpirationSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) error { if newSettings.PeerInactivityExpirationEnabled { if oldSettings.PeerInactivityExpiration != newSettings.PeerInactivityExpiration { @@ -465,6 +659,29 @@ func (am *DefaultAccountManager) handleInactivityExpirationSettings(ctx context. return nil } +func (am *DefaultAccountManager) handleLocalMfaSettings(ctx context.Context, oldSettings, newSettings *types.Settings, userID, accountID string) error { + if oldSettings.LocalMfaEnabled == newSettings.LocalMfaEnabled { + return nil + } + + embeddedIdp, ok := am.idpManager.(*idp.EmbeddedIdPManager) + if !ok { + return nil + } + + if err := embeddedIdp.SetMFAEnabled(ctx, newSettings.LocalMfaEnabled); err != nil { + return fmt.Errorf("failed to toggle MFA: %w", err) + } + + if newSettings.LocalMfaEnabled { + am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountLocalMfaEnabled, nil) + } else { + am.StoreEvent(ctx, userID, accountID, accountID, activity.AccountLocalMfaDisabled, nil) + } + + return nil +} + func (am *DefaultAccountManager) peerLoginExpirationJob(ctx context.Context, accountID string) func() (time.Duration, bool) { return func() (time.Duration, bool) { //nolint @@ -484,7 +701,7 @@ func (am *DefaultAccountManager) peerLoginExpirationJob(ctx context.Context, acc log.WithContext(ctx).Debugf("discovered %d peers to expire for account %s", len(peerIDs), accountID) - if err := am.expireAndUpdatePeers(ctx, accountID, expiredPeers); err != nil { + if err := am.expireAndUpdatePeers(ctx, accountID, expiredPeers, peerExpirationSessionExpired); err != nil { log.WithContext(ctx).Errorf("failed updating account peers while expiring peers for account %s", accountID) return peerSchedulerRetryInterval, true } @@ -519,7 +736,7 @@ func (am *DefaultAccountManager) peerInactivityExpirationJob(ctx context.Context log.Debugf("discovered %d peers to expire for account %s", len(peerIDs), accountID) - if err := am.expireAndUpdatePeers(ctx, accountID, inactivePeers); err != nil { + if err := am.expireAndUpdatePeers(ctx, accountID, inactivePeers, peerExpirationInactivity); err != nil { log.Errorf("failed updating account peers while expiring peers for account %s", accountID) return peerSchedulerRetryInterval, true } @@ -538,7 +755,7 @@ func (am *DefaultAccountManager) checkAndSchedulePeerInactivityExpiration(ctx co // newAccount creates a new Account with a generated ID and generated default setup keys. // If ID is already in use (due to collision) we try one more time before returning error -func (am *DefaultAccountManager) newAccount(ctx context.Context, userID, domain string) (*types.Account, error) { +func (am *DefaultAccountManager) newAccount(ctx context.Context, userID, domain, email, name string) (*types.Account, error) { for i := 0; i < 2; i++ { accountId := xid.New().String() @@ -549,7 +766,7 @@ func (am *DefaultAccountManager) newAccount(ctx context.Context, userID, domain log.WithContext(ctx).Warnf("an account with ID already exists, retrying...") continue case statusErr.Type() == status.NotFound: - newAccount := newAccountWithId(ctx, accountId, userID, domain, am.disableDefaultPolicy) + newAccount := newAccountWithId(ctx, accountId, userID, domain, email, name, am.disableDefaultPolicy) am.StoreEvent(ctx, userID, newAccount.Id, accountId, activity.AccountCreated, nil) return newAccount, nil default: @@ -651,7 +868,7 @@ func (am *DefaultAccountManager) DeleteAccount(ctx context.Context, accountID, u return err } - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Delete) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Delete) if err != nil { return fmt.Errorf("failed to validate user permissions: %w", err) } @@ -665,37 +882,8 @@ func (am *DefaultAccountManager) DeleteAccount(ctx context.Context, accountID, u return status.Errorf(status.Internal, "failed to build user infos for account %s: %v", accountID, err) } - for _, otherUser := range account.Users { - if otherUser.Id == userID { - continue - } - - if otherUser.IsServiceUser { - err = am.deleteServiceUser(ctx, accountID, userID, otherUser) - if err != nil { - return err - } - continue - } - - userInfo, ok := userInfosMap[otherUser.Id] - if !ok { - return status.Errorf(status.NotFound, "user info not found for user %s", otherUser.Id) - } - - _, deleteUserErr := am.deleteRegularUser(ctx, accountID, userID, userInfo) - if deleteUserErr != nil { - return deleteUserErr - } - } - - userInfo, ok := userInfosMap[userID] - if ok { - _, err = am.deleteRegularUser(ctx, accountID, userID, userInfo) - if err != nil { - log.WithContext(ctx).Errorf("failed deleting user %s. error: %s", userID, err) - return err - } + if err = am.deleteAccountUsers(ctx, accountID, userID, account.Users, userInfosMap); err != nil { + return err } err = am.Store.DeleteAccount(ctx, account) @@ -713,6 +901,40 @@ func (am *DefaultAccountManager) DeleteAccount(ctx context.Context, accountID, u return nil } +func (am *DefaultAccountManager) deleteAccountUsers(ctx context.Context, accountID, initiatorUserID string, users map[string]*types.User, userInfosMap map[string]*types.UserInfo) error { + for _, otherUser := range users { + if otherUser.Id == initiatorUserID { + continue + } + + if otherUser.IsServiceUser { + if err := am.deleteServiceUser(ctx, accountID, initiatorUserID, otherUser); err != nil { + return err + } + continue + } + + userInfo, ok := userInfosMap[otherUser.Id] + if !ok { + return status.Errorf(status.NotFound, "user info not found for user %s", otherUser.Id) + } + + if _, err := am.deleteRegularUser(ctx, accountID, initiatorUserID, userInfo); err != nil { + return err + } + } + + userInfo, ok := userInfosMap[initiatorUserID] + if ok { + if _, err := am.deleteRegularUser(ctx, accountID, initiatorUserID, userInfo); err != nil { + log.WithContext(ctx).Errorf("failed deleting user %s. error: %s", initiatorUserID, err) + return err + } + } + + return nil +} + // AccountExists checks if an account exists. func (am *DefaultAccountManager) AccountExists(ctx context.Context, accountID string) (bool, error) { return am.Store.AccountExists(ctx, store.LockingStrengthNone, accountID) @@ -722,23 +944,23 @@ func (am *DefaultAccountManager) AccountExists(ctx context.Context, accountID st // If user does have an account, it returns the user's account ID. // If the user doesn't have an account, it creates one using the provided domain. // Returns the account ID or an error if none is found or created. -func (am *DefaultAccountManager) GetAccountIDByUserID(ctx context.Context, userID, domain string) (string, error) { - if userID == "" { +func (am *DefaultAccountManager) GetAccountIDByUserID(ctx context.Context, userAuth auth.UserAuth) (string, error) { + if userAuth.UserId == "" { return "", status.Errorf(status.NotFound, "no valid userID provided") } - accountID, err := am.Store.GetAccountIDByUserID(ctx, store.LockingStrengthNone, userID) + accountID, err := am.Store.GetAccountIDByUserID(ctx, store.LockingStrengthNone, userAuth.UserId) if err != nil { if s, ok := status.FromError(err); ok && s.Type() == status.NotFound { - account, err := am.GetOrCreateAccountByUser(ctx, userID, domain) + acc, err := am.GetOrCreateAccountByUser(ctx, userAuth) if err != nil { - return "", status.Errorf(status.NotFound, "account not found or created for user id: %s", userID) + return "", status.Errorf(status.NotFound, "account not found or created for user id: %s", userAuth.UserId) } - if err = am.addAccountIDToIDPAppMeta(ctx, userID, account.Id); err != nil { + if err = am.addAccountIDToIDPAppMeta(ctx, userAuth.UserId, acc.Id); err != nil { return "", err } - return account.Id, nil + return acc.Id, nil } return "", err } @@ -749,9 +971,32 @@ func isNil(i idp.Manager) bool { return i == nil || reflect.ValueOf(i).IsNil() } +// IsEmbeddedIdp checks if the IDP manager is an embedded IDP (data stored locally in DB). +// When true, user cache should be skipped and data fetched directly from the IDP manager. +func IsEmbeddedIdp(i idp.Manager) bool { + if isNil(i) { + return false + } + _, ok := i.(*idp.EmbeddedIdPManager) + return ok +} + +// IsLocalAuthDisabled checks if local (email/password) authentication is disabled. +// Returns true only when using embedded IDP with local auth disabled in config. +func IsLocalAuthDisabled(ctx context.Context, i idp.Manager) bool { + if isNil(i) { + return false + } + embeddedIdp, ok := i.(*idp.EmbeddedIdPManager) + if !ok { + return false + } + return embeddedIdp.IsLocalAuthDisabled() +} + // addAccountIDToIDPAppMeta update user's app metadata in idp manager func (am *DefaultAccountManager) addAccountIDToIDPAppMeta(ctx context.Context, userID string, accountID string) error { - if !isNil(am.idpManager) { + if !isNil(am.idpManager) && !IsEmbeddedIdp(am.idpManager) { // user can be nil if it wasn't found (e.g., just created) user, err := am.lookupUserInCache(ctx, userID, accountID) if err != nil { @@ -782,6 +1027,13 @@ func (am *DefaultAccountManager) loadAccount(ctx context.Context, accountID any) log.WithContext(ctx).Debugf("account %s not found in cache, reloading", accountID) accountIDString := fmt.Sprintf("%v", accountID) + if ctx == nil { + ctx = context.Background() + } + + // nolint:staticcheck + ctx = context.WithValue(ctx, nbcontext.AccountIDKey, accountID) + accountUsers, err := am.Store.GetAccountUsers(ctx, store.LockingStrengthNone, accountIDString) if err != nil { return nil, nil, err @@ -970,7 +1222,7 @@ func (am *DefaultAccountManager) isCacheFresh(ctx context.Context, accountUsers for user, loggedInOnce := range accountUsers { if datum, ok := userDataMap[user]; ok { // check if the matching user data has a pending invite and if the user has logged in once, forcing the cache to be refreshed - if datum.AppMetadata.WTPendingInvite != nil && *datum.AppMetadata.WTPendingInvite && loggedInOnce == true { //nolint:gosimple + if datum.AppMetadata.WTPendingInvite != nil && *datum.AppMetadata.WTPendingInvite && loggedInOnce == true { //nolint log.WithContext(ctx).Infof("user %s has a pending invite and has logged in once, cache invalid", user) return false } @@ -990,6 +1242,9 @@ func (am *DefaultAccountManager) isCacheFresh(ctx context.Context, accountUsers } func (am *DefaultAccountManager) removeUserFromCache(ctx context.Context, accountID, userID string) error { + if IsEmbeddedIdp(am.idpManager) { + return nil + } data, err := am.getAccountFromCache(ctx, accountID, false) if err != nil { return err @@ -1006,7 +1261,7 @@ func (am *DefaultAccountManager) removeUserFromCache(ctx context.Context, accoun } // updateAccountDomainAttributesIfNotUpToDate updates the account domain attributes if they are not up to date and then, saves the account changes -func (am *DefaultAccountManager) updateAccountDomainAttributesIfNotUpToDate(ctx context.Context, accountID string, userAuth nbcontext.UserAuth, +func (am *DefaultAccountManager) updateAccountDomainAttributesIfNotUpToDate(ctx context.Context, accountID string, userAuth auth.UserAuth, primaryDomain bool, ) error { if userAuth.Domain == "" { @@ -1055,7 +1310,7 @@ func (am *DefaultAccountManager) handleExistingUserAccount( ctx context.Context, userAccountID string, domainAccountID string, - userAuth nbcontext.UserAuth, + userAuth auth.UserAuth, ) error { primaryDomain := domainAccountID == "" || userAccountID == domainAccountID err := am.updateAccountDomainAttributesIfNotUpToDate(ctx, userAccountID, userAuth, primaryDomain) @@ -1074,14 +1329,14 @@ func (am *DefaultAccountManager) handleExistingUserAccount( // addNewPrivateAccount validates if there is an existing primary account for the domain, if so it adds the new user to that account, // otherwise it will create a new account and make it primary account for the domain. -func (am *DefaultAccountManager) addNewPrivateAccount(ctx context.Context, domainAccountID string, userAuth nbcontext.UserAuth) (string, error) { +func (am *DefaultAccountManager) addNewPrivateAccount(ctx context.Context, domainAccountID string, userAuth auth.UserAuth) (string, error) { if userAuth.UserId == "" { return "", fmt.Errorf("user ID is empty") } lowerDomain := strings.ToLower(userAuth.Domain) - newAccount, err := am.newAccount(ctx, userAuth.UserId, lowerDomain) + newAccount, err := am.newAccount(ctx, userAuth.UserId, lowerDomain, userAuth.Email, userAuth.Name) if err != nil { return "", err } @@ -1105,8 +1360,8 @@ func (am *DefaultAccountManager) addNewPrivateAccount(ctx context.Context, domai return newAccount.Id, nil } -func (am *DefaultAccountManager) addNewUserToDomainAccount(ctx context.Context, domainAccountID string, userAuth nbcontext.UserAuth) (string, error) { - newUser := types.NewRegularUser(userAuth.UserId) +func (am *DefaultAccountManager) addNewUserToDomainAccount(ctx context.Context, domainAccountID string, userAuth auth.UserAuth) (string, error) { + newUser := types.NewRegularUser(userAuth.UserId, userAuth.Email, userAuth.Name) newUser.AccountID = domainAccountID settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, domainAccountID) @@ -1180,7 +1435,7 @@ func (am *DefaultAccountManager) GetAccount(ctx context.Context, accountID strin // GetAccountByID returns an account associated with this account ID. func (am *DefaultAccountManager) GetAccountByID(ctx context.Context, accountID string, userID string) (*types.Account, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -1193,7 +1448,7 @@ func (am *DefaultAccountManager) GetAccountByID(ctx context.Context, accountID s // GetAccountMeta returns the account metadata associated with this account ID. func (am *DefaultAccountManager) GetAccountMeta(ctx context.Context, accountID string, userID string) (*types.AccountMeta, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -1206,7 +1461,7 @@ func (am *DefaultAccountManager) GetAccountMeta(ctx context.Context, accountID s // GetAccountOnboarding retrieves the onboarding information for a specific account. func (am *DefaultAccountManager) GetAccountOnboarding(ctx context.Context, accountID string, userID string) (*types.AccountOnboarding, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Accounts, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -1217,7 +1472,7 @@ func (am *DefaultAccountManager) GetAccountOnboarding(ctx context.Context, accou onboarding, err := am.Store.GetAccountOnboarding(ctx, accountID) if err != nil && err.Error() != status.NewAccountOnboardingNotFoundError(accountID).Error() { - log.Errorf("failed to get account onboarding for accountssssssss %s: %v", accountID, err) + log.Errorf("failed to get account onboarding for account %s: %v", accountID, err) return nil, err } @@ -1231,7 +1486,7 @@ func (am *DefaultAccountManager) GetAccountOnboarding(ctx context.Context, accou } func (am *DefaultAccountManager) UpdateAccountOnboarding(ctx context.Context, accountID, userID string, newOnboarding *types.AccountOnboarding) (*types.AccountOnboarding, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Update) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Update) if err != nil { return nil, fmt.Errorf("failed to validate user permissions: %w", err) } @@ -1269,16 +1524,17 @@ func (am *DefaultAccountManager) UpdateAccountOnboarding(ctx context.Context, ac return newOnboarding, nil } -func (am *DefaultAccountManager) GetAccountIDFromUserAuth(ctx context.Context, userAuth nbcontext.UserAuth) (string, string, error) { +func (am *DefaultAccountManager) GetAccountIDFromUserAuth(ctx context.Context, userAuth auth.UserAuth) (string, string, error) { if userAuth.UserId == "" { return "", "", errors.New(emptyUserID) } if am.singleAccountMode && am.singleAccountModeDomain != "" { // This section is mostly related to self-hosted installations. // We override incoming domain claims to group users under a single account. - userAuth.Domain = am.singleAccountModeDomain - userAuth.DomainCategory = types.PrivateCategory - log.WithContext(ctx).Debugf("overriding JWT Domain and DomainCategory claims since single account mode is enabled") + err := am.updateUserAuthWithSingleMode(ctx, &userAuth) + if err != nil { + return "", "", err + } } accountID, err := am.getAccountIDWithAuthorizationClaims(ctx, userAuth) @@ -1289,6 +1545,7 @@ func (am *DefaultAccountManager) GetAccountIDFromUserAuth(ctx context.Context, u user, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userAuth.UserId) if err != nil { // this is not really possible because we got an account by user ID + log.Errorf("failed to get user by ID %s: %v", userAuth.UserId, err) return "", "", status.Errorf(status.NotFound, "user %s not found", userAuth.UserId) } @@ -1296,7 +1553,8 @@ func (am *DefaultAccountManager) GetAccountIDFromUserAuth(ctx context.Context, u return accountID, user.Id, nil } - if err := am.permissionsManager.ValidateAccountAccess(ctx, accountID, user, false); err != nil { + ctx, err = am.permissionsManager.ValidateAccountAccess(ctx, accountID, user, false) + if err != nil { return "", "", err } @@ -1310,11 +1568,43 @@ func (am *DefaultAccountManager) GetAccountIDFromUserAuth(ctx context.Context, u return accountID, user.Id, nil } +// updateUserAuthWithSingleMode modifies the userAuth with the single account domain, or if there is an existing account, with the domain of that account +func (am *DefaultAccountManager) updateUserAuthWithSingleMode(ctx context.Context, userAuth *auth.UserAuth) error { + userAuth.DomainCategory = types.PrivateCategory + userAuth.Domain = am.singleAccountModeDomain + + accountID, err := am.Store.GetAnyAccountID(ctx) + if err != nil { + if e, ok := status.FromError(err); !ok || e.Type() != status.NotFound { + return err + } + log.WithContext(ctx).Debugf("using singleAccountModeDomain to override JWT Domain and DomainCategory claims in single account mode") + return nil + } + + if accountID == "" { + log.WithContext(ctx).Debugf("using singleAccountModeDomain to override JWT Domain and DomainCategory claims in single account mode") + return nil + } + + domain, _, err := am.Store.GetAccountDomainAndCategory(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return err + } + userAuth.Domain = domain + + log.WithContext(ctx).Debugf("overriding JWT Domain and DomainCategory claims since single account mode is enabled") + return nil +} + // syncJWTGroups processes the JWT groups for a user, updates the account based on the groups, // and propagates changes to peers if group propagation is enabled. // requires userAuth to have been ValidateAndParseToken and EnsureUserAccessByJWTGroups by the AuthManager -func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth nbcontext.UserAuth) error { - if userAuth.IsChild || userAuth.IsPAT { +func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth auth.UserAuth) error { + // Child accounts and PAT-authenticated requests do not sync JWT groups. + // Embedded-Dex local users also skip sync because local password authentication + // does not provide external IdP group claims. + if userAuth.IsChild || userAuth.IsPAT || dex.IsLocalUserID(userAuth.UserId) { return nil } @@ -1358,6 +1648,10 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth return nil } + for _, g := range newGroupsToCreate { + g.PublicID = xid.New().String() + } + if err = transaction.CreateGroups(ctx, userAuth.AccountId, newGroupsToCreate); err != nil { return fmt.Errorf("error saving groups: %w", err) } @@ -1390,6 +1684,11 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth } } + allGroupChanges := slices.Concat(addNewGroups, removeOldGroups) + if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, userAuth.AccountId, allGroupChanges); err != nil { + return fmt.Errorf("reconcile IPv6 for group changes: %w", err) + } + if err = transaction.IncrementNetworkSerial(ctx, userAuth.AccountId); err != nil { return fmt.Errorf("error incrementing network serial: %w", err) } @@ -1431,21 +1730,19 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth } } - if settings.GroupsPropagationEnabled { - removedGroupAffectsPeers, err := areGroupChangesAffectPeers(ctx, am.Store, userAuth.AccountId, removeOldGroups) - if err != nil { - return err - } + removedGroupAffectsPeers, err := areGroupChangesAffectPeers(ctx, am.Store, userAuth.AccountId, removeOldGroups) + if err != nil { + return err + } - newGroupsAffectsPeers, err := areGroupChangesAffectPeers(ctx, am.Store, userAuth.AccountId, addNewGroups) - if err != nil { - return err - } + newGroupsAffectsPeers, err := areGroupChangesAffectPeers(ctx, am.Store, userAuth.AccountId, addNewGroups) + if err != nil { + return err + } - if removedGroupAffectsPeers || newGroupsAffectsPeers { - log.WithContext(ctx).Tracef("user %s: JWT group membership changed, updating account peers", userAuth.UserId) - am.BufferUpdateAccountPeers(ctx, userAuth.AccountId) - } + if removedGroupAffectsPeers || newGroupsAffectsPeers { + log.WithContext(ctx).Tracef("user %s: JWT group membership changed, updating account peers", userAuth.UserId) + am.BufferUpdateAccountPeers(ctx, userAuth.AccountId, types.UpdateReason{Resource: types.UpdateResourceUser, Operation: types.UpdateOperationUpdate}) } return nil @@ -1471,7 +1768,7 @@ func (am *DefaultAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth // Existing user + Existing account + Existing domain reclassified Domain as private -> Nothing changes (index domain) // // UserAuth IsChild -> checks that account exists -func (am *DefaultAccountManager) getAccountIDWithAuthorizationClaims(ctx context.Context, userAuth nbcontext.UserAuth) (string, error) { +func (am *DefaultAccountManager) getAccountIDWithAuthorizationClaims(ctx context.Context, userAuth auth.UserAuth) (string, error) { log.WithContext(ctx).Tracef("getting account with authorization claims. User ID: \"%s\", Account ID: \"%s\", Domain: \"%s\", Domain Category: \"%s\"", userAuth.UserId, userAuth.AccountId, userAuth.Domain, userAuth.DomainCategory) @@ -1488,7 +1785,7 @@ func (am *DefaultAccountManager) getAccountIDWithAuthorizationClaims(ctx context } if userAuth.DomainCategory != types.PrivateCategory || !isDomainValid(userAuth.Domain) { - return am.GetAccountIDByUserID(ctx, userAuth.UserId, userAuth.Domain) + return am.GetAccountIDByUserID(ctx, userAuth) } if userAuth.AccountId != "" { @@ -1550,7 +1847,7 @@ func (am *DefaultAccountManager) getPrivateDomainWithGlobalLock(ctx context.Cont return domainAccountID, cancel, nil } -func (am *DefaultAccountManager) handlePrivateAccountWithIDFromClaim(ctx context.Context, userAuth nbcontext.UserAuth) (string, error) { +func (am *DefaultAccountManager) handlePrivateAccountWithIDFromClaim(ctx context.Context, userAuth auth.UserAuth) (string, error) { userAccountID, err := am.Store.GetAccountIDByUserID(ctx, store.LockingStrengthNone, userAuth.UserId) if err != nil { log.WithContext(ctx).Errorf("error getting account ID by user ID: %v", err) @@ -1598,49 +1895,60 @@ func handleNotFound(err error) error { return nil } -func domainIsUpToDate(domain string, domainCategory string, userAuth nbcontext.UserAuth) bool { +func domainIsUpToDate(domain string, domainCategory string, userAuth auth.UserAuth) bool { return domainCategory == types.PrivateCategory || userAuth.DomainCategory != types.PrivateCategory || domain != userAuth.Domain } -func (am *DefaultAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { - peer, netMap, postureChecks, dnsfwdPort, err := am.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peerPubKey, Meta: meta}, accountID) +// SyncAndMarkPeer is the per-Sync entry point: it refreshes the peer's +// network map and then marks the peer connected with a session token +// derived from syncTime (the moment the gRPC stream opened). Any +// concurrent stream that started earlier loses the optimistic-lock race +// in MarkPeerConnected and bails without writing. +func (am *DefaultAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { + peer, netMap, postureChecks, dnsfwdPort, err := am.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peerPubKey, Meta: meta, RealIP: realIP}, accountID) if err != nil { return nil, nil, nil, 0, fmt.Errorf("error syncing peer: %w", err) } - err = am.MarkPeerConnected(ctx, peerPubKey, true, realIP, accountID) - if err != nil { + if err := am.MarkPeerConnected(ctx, peerPubKey, accountID, syncTime.UnixNano(), netMap); err != nil { log.WithContext(ctx).Warnf("failed marking peer as connected %s %v", peerPubKey, err) } return peer, netMap, postureChecks, dnsfwdPort, nil } -func (am *DefaultAccountManager) OnPeerDisconnected(ctx context.Context, accountID string, peerPubKey string) error { - err := am.MarkPeerConnected(ctx, peerPubKey, false, nil, accountID) - if err != nil { +// OnPeerDisconnected is invoked when a sync stream ends. It marks the +// peer disconnected only when the stored SessionStartedAt matches the +// nanosecond token derived from streamStartTime — i.e. only when this +// is the stream that currently owns the peer's session. A mismatch +// means a newer stream has already replaced us, so the disconnect is +// dropped. +func (am *DefaultAccountManager) OnPeerDisconnected(ctx context.Context, accountID string, peerPubKey string, streamStartTime time.Time) error { + if err := am.MarkPeerDisconnected(ctx, peerPubKey, accountID, streamStartTime.UnixNano()); err != nil { log.WithContext(ctx).Warnf("failed marking peer as disconnected %s %v", peerPubKey, err) } return nil } -func (am *DefaultAccountManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta) error { +func (am *DefaultAccountManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) error { accountID, err := am.Store.GetAccountIDByPeerPubKey(ctx, peerPubKey) if err != nil { return err } - _, _, _, _, err = am.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peerPubKey, Meta: meta, UpdateAccountPeers: true}, accountID) + _, _, _, _, err = am.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peerPubKey, Meta: meta, RealIP: realIP, UpdateAccountPeers: true}, accountID) if err != nil { return err } return nil } -var invalidDomainRegexp = regexp.MustCompile(`^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$`) +// isDomainValid validates public/IDP domains using stricter rules than internal DNS domains. +// Requires at least 2-char alphabetic TLD and no single-label domains. +var publicDomainRegexp = regexp.MustCompile(`^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$`) func isDomainValid(domain string) bool { - return invalidDomainRegexp.MatchString(domain) + return publicDomainRegexp.MatchString(domain) } func (am *DefaultAccountManager) onPeersInvalidated(ctx context.Context, accountID string, peerIDs []string) { @@ -1657,7 +1965,7 @@ func (am *DefaultAccountManager) onPeersInvalidated(ctx context.Context, account } } if len(peers) > 0 { - err := am.expireAndUpdatePeers(ctx, accountID, peers) + err := am.expireAndUpdatePeers(ctx, accountID, peers, peerExpirationValidationFailed) if err != nil { log.WithContext(ctx).Errorf("failed to expire and update invalidated peers for account %s: %v", accountID, err) return @@ -1699,7 +2007,7 @@ func (am *DefaultAccountManager) handleUserPeer(ctx context.Context, transaction } func (am *DefaultAccountManager) GetAccountSettings(ctx context.Context, accountID string, userID string) (*types.Settings, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Read) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -1710,7 +2018,7 @@ func (am *DefaultAccountManager) GetAccountSettings(ctx context.Context, account } // newAccountWithId creates a new Account with a default SetupKey (doesn't store in a Store) and provided id -func newAccountWithId(ctx context.Context, accountID, userID, domain string, disableDefaultPolicy bool) *types.Account { +func newAccountWithId(ctx context.Context, accountID, userID, domain, email, name string, disableDefaultPolicy bool) *types.Account { log.WithContext(ctx).Debugf("creating new account") network := types.NewNetwork() @@ -1720,7 +2028,7 @@ func newAccountWithId(ctx context.Context, accountID, userID, domain string, dis setupKeys := map[string]*types.SetupKey{} nameServersGroups := make(map[string]*nbdns.NameServerGroup) - owner := types.NewOwnerUser(userID) + owner := types.NewOwnerUser(userID, email, name) owner.AccountID = accountID users[userID] = owner @@ -1753,6 +2061,7 @@ func newAccountWithId(ctx context.Context, accountID, userID, domain string, dis Extra: &types.ExtraSettings{ UserApprovalRequired: true, }, + LazyConnectionEnabled: true, }, Onboarding: types.AccountOnboarding{ OnboardingFlowPending: true, @@ -1763,6 +2072,11 @@ func newAccountWithId(ctx context.Context, accountID, userID, domain string, dis if err := acc.AddAllGroup(disableDefaultPolicy); err != nil { log.WithContext(ctx).Errorf("error adding all group to account %s: %v", acc.Id, err) } + + if allGroup, err := acc.GetGroupAll(); err == nil { + acc.Settings.IPv6EnabledGroups = []string{allGroup.ID} + } + return acc } @@ -1869,6 +2183,10 @@ func (am *DefaultAccountManager) GetOrCreateAccountByPrivateDomain(ctx context.C return nil, false, status.Errorf(status.Internal, "failed to add all group to new account by private domain") } + if allGroup, err := newAccount.GetGroupAll(); err == nil { + newAccount.Settings.IPv6EnabledGroups = []string{allGroup.ID} + } + if err := am.Store.SaveAccount(ctx, newAccount); err != nil { log.WithContext(ctx).WithFields(log.Fields{ "accountId": newAccount.Id, @@ -1930,7 +2248,7 @@ func (am *DefaultAccountManager) UpdateToPrimaryAccount(ctx context.Context, acc // propagateUserGroupMemberships propagates all account users' group memberships to their peers. // Returns true if any groups were modified, true if those updates affect peers and an error. -func propagateUserGroupMemberships(ctx context.Context, transaction store.Store, accountID string) (groupsUpdated bool, peersAffected bool, err error) { +func (am *DefaultAccountManager) propagateUserGroupMemberships(ctx context.Context, transaction store.Store, accountID string) (groupsUpdated bool, peersAffected bool, err error) { users, err := transaction.GetAccountUsers(ctx, store.LockingStrengthNone, accountID) if err != nil { return false, false, err @@ -1952,29 +2270,13 @@ func propagateUserGroupMemberships(ctx context.Context, transaction store.Store, } } - updatedGroups := []string{} - for _, user := range users { - userPeers, err := transaction.GetUserPeers(ctx, store.LockingStrengthNone, accountID, user.Id) - if err != nil { - return false, false, err - } + updatedGroups, err := propagateAutoGroupsForUsers(ctx, transaction, accountID, users, accountGroupPeers) + if err != nil { + return false, false, err + } - for _, peer := range userPeers { - for _, groupID := range user.AutoGroups { - if _, exists := accountGroupPeers[groupID]; !exists { - // we do not wanna create the groups here - log.WithContext(ctx).Warnf("group %s does not exist for user group propagation", groupID) - continue - } - if _, exists := accountGroupPeers[groupID][peer.ID]; exists { - continue - } - if err := transaction.AddPeerToGroup(ctx, accountID, peer.ID, groupID); err != nil { - return false, false, fmt.Errorf("error adding peer %s to group %s: %w", peer.ID, groupID, err) - } - updatedGroups = append(updatedGroups, groupID) - } - } + if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, updatedGroups); err != nil { + return false, false, fmt.Errorf("reconcile IPv6 for group changes: %w", err) } peersAffected, err = areGroupChangesAffectPeers(ctx, transaction, accountID, updatedGroups) @@ -1985,6 +2287,35 @@ func propagateUserGroupMemberships(ctx context.Context, transaction store.Store, return len(updatedGroups) > 0, peersAffected, nil } +// propagateAutoGroupsForUsers adds each user's peers to their AutoGroups where not already present. +// Returns the list of group IDs that were modified. +func propagateAutoGroupsForUsers(ctx context.Context, transaction store.Store, accountID string, users []*types.User, accountGroupPeers map[string]map[string]struct{}) ([]string, error) { + var updatedGroups []string + for _, user := range users { + userPeers, err := transaction.GetUserPeers(ctx, store.LockingStrengthNone, accountID, user.Id) + if err != nil { + return nil, err + } + + for _, peer := range userPeers { + for _, groupID := range user.AutoGroups { + if _, exists := accountGroupPeers[groupID]; !exists { + log.WithContext(ctx).Warnf("group %s does not exist for user group propagation", groupID) + continue + } + if _, exists := accountGroupPeers[groupID][peer.ID]; exists { + continue + } + if err := transaction.AddPeerToGroup(ctx, accountID, peer.ID, groupID); err != nil { + return nil, fmt.Errorf("error adding peer %s to group %s: %w", peer.ID, groupID, err) + } + updatedGroups = append(updatedGroups, groupID) + } + } + } + return updatedGroups, nil +} + // reallocateAccountPeerIPs re-allocates all peer IPs when the network range changes func (am *DefaultAccountManager) reallocateAccountPeerIPs(ctx context.Context, transaction store.Store, accountID string, newNetworkRange netip.Prefix) error { if !newNetworkRange.IsValid() { @@ -2006,10 +2337,10 @@ func (am *DefaultAccountManager) reallocateAccountPeerIPs(ctx context.Context, t return err } - var takenIPs []net.IP + var takenIPs []netip.Addr for _, peer := range peers { - newIP, err := types.AllocatePeerIP(newIPNet, takenIPs) + newIP, err := types.AllocatePeerIP(newNetworkRange, takenIPs) if err != nil { return status.Errorf(status.Internal, "allocate IP for peer %s: %v", peer.ID, err) } @@ -2033,13 +2364,211 @@ func (am *DefaultAccountManager) reallocateAccountPeerIPs(ctx context.Context, t return nil } +// updatePeerIPv6Addresses assigns or removes IPv6 addresses for all peers +// based on the current IPv6 settings. When IPv6 is enabled, peers without a +// v6 address get one allocated. When disabled, all v6 addresses are cleared. +// When the v6 range changes, all v6 addresses are reallocated. +func (am *DefaultAccountManager) checkIPv6Collision(ctx context.Context, transaction store.Store, accountID, peerID string, newIPv6 netip.Addr) error { + peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthShare, accountID, "", "") + if err != nil { + return fmt.Errorf("get peers: %w", err) + } + for _, p := range peers { + if p.ID != peerID && p.IPv6.IsValid() && p.IPv6 == newIPv6 { + return status.Errorf(status.InvalidArgument, "IPv6 %s is already assigned to peer %s", newIPv6, p.Name) + } + } + return nil +} + +func (am *DefaultAccountManager) updatePeerIPv6Addresses(ctx context.Context, transaction store.Store, accountID string, settings *types.Settings) error { + peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthUpdate, accountID, "", "") + if err != nil { + return fmt.Errorf("get peers: %w", err) + } + + network, err := transaction.GetAccountNetwork(ctx, store.LockingStrengthUpdate, accountID) + if err != nil { + return fmt.Errorf("get network: %w", err) + } + + if err := am.ensureIPv6Subnet(ctx, transaction, accountID, settings, network); err != nil { + return err + } + + allowedPeers, err := am.buildIPv6AllowedPeers(ctx, transaction, accountID, settings) + if err != nil { + return err + } + + v6Prefix, err := netip.ParsePrefix(network.NetV6.String()) + if err != nil { + return fmt.Errorf("parse IPv6 prefix: %w", err) + } + + if err := am.assignPeerIPv6Addresses(ctx, transaction, accountID, peers, network, allowedPeers, v6Prefix); err != nil { + return err + } + + log.WithContext(ctx).Infof("updated IPv6 addresses for %d peers in account %s (groups=%d)", + len(peers), accountID, len(settings.IPv6EnabledGroups)) + + return nil +} + +// reconcileIPv6ForGroupChanges checks whether the given group IDs overlap with +// the account's IPv6EnabledGroups. If they do, it runs a full IPv6 address +// reconciliation so that peers gaining or losing membership in an IPv6-enabled +// group get their addresses assigned or removed. +func (am *DefaultAccountManager) reconcileIPv6ForGroupChanges(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) error { + settings, err := transaction.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return fmt.Errorf("get account settings: %w", err) + } + + if len(settings.IPv6EnabledGroups) == 0 { + return nil + } + + enabledSet := make(map[string]struct{}, len(settings.IPv6EnabledGroups)) + for _, gid := range settings.IPv6EnabledGroups { + enabledSet[gid] = struct{}{} + } + + affected := false + for _, gid := range groupIDs { + if _, ok := enabledSet[gid]; ok { + affected = true + break + } + } + + if !affected { + return nil + } + + return am.updatePeerIPv6Addresses(ctx, transaction, accountID, settings) +} + +func (am *DefaultAccountManager) ensureIPv6Subnet(ctx context.Context, transaction store.Store, accountID string, settings *types.Settings, network *types.Network) error { + if settings.NetworkRangeV6.IsValid() { + network.NetV6 = net.IPNet{ + IP: settings.NetworkRangeV6.Masked().Addr().AsSlice(), + Mask: net.CIDRMask(settings.NetworkRangeV6.Bits(), 128), + } + return transaction.UpdateAccountNetworkV6(ctx, accountID, network.NetV6) + } + if network.NetV6.IP == nil { + r := rand.New(rand.NewSource(time.Now().UnixNano())) + network.NetV6 = types.AllocateIPv6Subnet(r) + + // Sync settings to match the allocated subnet so SaveAccountSettings persists it. + ones, _ := network.NetV6.Mask.Size() + addr, _ := netip.AddrFromSlice(network.NetV6.IP) + settings.NetworkRangeV6 = netip.PrefixFrom(addr.Unmap(), ones) + + return transaction.UpdateAccountNetworkV6(ctx, accountID, network.NetV6) + } + return nil +} + +func (am *DefaultAccountManager) assignPeerIPv6Addresses( + ctx context.Context, transaction store.Store, accountID string, + peers []*nbpeer.Peer, network *types.Network, + allowedPeers map[string]struct{}, v6Prefix netip.Prefix, +) error { + takenV6 := make(map[netip.Addr]struct{}) + for _, peer := range peers { + if _, ok := allowedPeers[peer.ID]; ok && peer.IPv6.IsValid() && network.NetV6.Contains(peer.IPv6.AsSlice()) { + takenV6[peer.IPv6] = struct{}{} + } + } + + for _, peer := range peers { + _, allowed := allowedPeers[peer.ID] + oldIPv6 := peer.IPv6 + + if !allowed { + peer.IPv6 = netip.Addr{} + } else if !peer.IPv6.IsValid() || !network.NetV6.Contains(peer.IPv6.AsSlice()) { + newIP, err := allocateIPv6WithRetry(v6Prefix, takenV6, peer.ID) + if err != nil { + return err + } + peer.IPv6 = newIP + } + + if peer.IPv6 == oldIPv6 { + continue + } + + if err := transaction.SavePeer(ctx, accountID, peer); err != nil { + return fmt.Errorf("save peer %s: %w", peer.ID, err) + } + } + return nil +} + +func allocateIPv6WithRetry(prefix netip.Prefix, taken map[netip.Addr]struct{}, peerID string) (netip.Addr, error) { + for attempts := 0; attempts < 10; attempts++ { + newIP, err := types.AllocateRandomPeerIPv6(prefix) + if err != nil { + return netip.Addr{}, fmt.Errorf("allocate v6 for peer %s: %w", peerID, err) + } + if _, ok := taken[newIP]; !ok { + taken[newIP] = struct{}{} + return newIP, nil + } + } + return netip.Addr{}, fmt.Errorf("allocate v6 for peer %s: exhausted 10 attempts", peerID) +} + +func (am *DefaultAccountManager) buildIPv6AllowedPeers(ctx context.Context, transaction store.Store, accountID string, settings *types.Settings) (map[string]struct{}, error) { + if len(settings.IPv6EnabledGroups) == 0 { + return make(map[string]struct{}), nil + } + + groups, err := transaction.GetAccountGroups(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, fmt.Errorf("get groups: %w", err) + } + + enabledSet := make(map[string]struct{}, len(settings.IPv6EnabledGroups)) + for _, gid := range settings.IPv6EnabledGroups { + enabledSet[gid] = struct{}{} + } + + allowedPeers := make(map[string]struct{}) + for _, group := range groups { + if _, ok := enabledSet[group.ID]; !ok { + continue + } + for _, peerID := range group.Peers { + allowedPeers[peerID] = struct{}{} + } + } + + // Embedded proxy peers sit outside regular group membership but must + // participate in any v6-enabled overlay to reach v6-only peers. + peers, err := transaction.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + if err != nil { + return nil, fmt.Errorf("get peers: %w", err) + } + for _, p := range peers { + if p.ProxyMeta.Embedded { + allowedPeers[p.ID] = struct{}{} + } + } + return allowedPeers, nil +} + func (am *DefaultAccountManager) validateIPForUpdate(account *types.Account, peers []*nbpeer.Peer, peerID string, newIP netip.Addr) error { if !account.Network.Net.Contains(newIP.AsSlice()) { return status.Errorf(status.InvalidArgument, "IP %s is not within the account network range %s", newIP.String(), account.Network.Net.String()) } for _, peer := range peers { - if peer.ID != peerID && peer.IP.Equal(newIP.AsSlice()) { + if peer.ID != peerID && peer.IP == newIP { return status.Errorf(status.InvalidArgument, "IP %s is already assigned to peer %s", newIP.String(), peer.ID) } } @@ -2047,7 +2576,7 @@ func (am *DefaultAccountManager) validateIPForUpdate(account *types.Account, pee } func (am *DefaultAccountManager) UpdatePeerIP(ctx context.Context, accountID, userID, peerID string, newIP netip.Addr) error { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update) if err != nil { return fmt.Errorf("validate user permissions: %w", err) } @@ -2065,7 +2594,12 @@ func (am *DefaultAccountManager) UpdatePeerIP(ctx context.Context, accountID, us if err != nil { return err } - am.networkMapController.OnPeerUpdated(peer.AccountID, peer) + changedPeerIDs := []string{peerID} + affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs) + err = am.networkMapController.OnPeersUpdated(ctx, peer.AccountID, changedPeerIDs, affectedPeerIDs) + if err != nil { + return fmt.Errorf("notify network map controller of peer update: %w", err) + } } return nil } @@ -2083,7 +2617,7 @@ func (am *DefaultAccountManager) updatePeerIPInTransaction(ctx context.Context, return fmt.Errorf("get peer: %w", err) } - if existingPeer.IP.Equal(newIP.AsSlice()) { + if existingPeer.IP == newIP { return nil } @@ -2118,7 +2652,7 @@ func (am *DefaultAccountManager) savePeerIPUpdate(ctx context.Context, transacti eventMeta := peer.EventMeta(dnsDomain) oldIP := peer.IP.String() - peer.IP = newIP.AsSlice() + peer.IP = newIP err = transaction.SavePeer(ctx, accountID, peer) if err != nil { return fmt.Errorf("save peer: %w", err) @@ -2130,3 +2664,87 @@ func (am *DefaultAccountManager) savePeerIPUpdate(ctx context.Context, transacti return nil } + +// UpdatePeerIPv6 updates the IPv6 overlay address of a peer, validating it's +// within the account's v6 network range and not already taken. +func (am *DefaultAccountManager) UpdatePeerIPv6(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error { + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update) + if err != nil { + return fmt.Errorf("validate user permissions: %w", err) + } + if !allowed { + return status.NewPermissionDeniedError() + } + + var updateNetworkMap bool + err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + var txErr error + updateNetworkMap, txErr = am.updatePeerIPv6InTransaction(ctx, transaction, accountID, peerID, newIPv6) + return txErr + }) + if err != nil { + return err + } + + if updateNetworkMap { + changedPeerIDs := []string{peerID} + affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs) + if err := am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil { + return fmt.Errorf("notify network map controller: %w", err) + } + } + return nil +} + +// updatePeerIPv6InTransaction validates and applies an IPv6 address change within a store transaction. +func (am *DefaultAccountManager) updatePeerIPv6InTransaction(ctx context.Context, transaction store.Store, accountID, peerID string, newIPv6 netip.Addr) (bool, error) { + network, err := transaction.GetAccountNetwork(ctx, store.LockingStrengthShare, accountID) + if err != nil { + return false, fmt.Errorf("get network: %w", err) + } + + if network.NetV6.IP == nil { + return false, status.Errorf(status.PreconditionFailed, "IPv6 is not configured for this account") + } + + if !network.NetV6.Contains(newIPv6.AsSlice()) { + return false, status.Errorf(status.InvalidArgument, "IP %s is not within the account IPv6 range %s", newIPv6, network.NetV6.String()) + } + + settings, err := transaction.GetAccountSettings(ctx, store.LockingStrengthShare, accountID) + if err != nil { + return false, fmt.Errorf("get settings: %w", err) + } + + allowedPeers, err := am.buildIPv6AllowedPeers(ctx, transaction, accountID, settings) + if err != nil { + return false, err + } + if _, ok := allowedPeers[peerID]; !ok { + return false, status.Errorf(status.PreconditionFailed, "peer is not in any IPv6-enabled group") + } + + peer, err := transaction.GetPeerByID(ctx, store.LockingStrengthUpdate, accountID, peerID) + if err != nil { + return false, fmt.Errorf("get peer: %w", err) + } + + if peer.IPv6.IsValid() && peer.IPv6 == newIPv6 { + return false, nil + } + + if err := am.checkIPv6Collision(ctx, transaction, accountID, peerID, newIPv6); err != nil { + return false, err + } + + peer.IPv6 = newIPv6 + if err := transaction.SavePeer(ctx, accountID, peer); err != nil { + return false, fmt.Errorf("save peer: %w", err) + } + + return true, nil +} + +func (am *DefaultAccountManager) GetUserIDByPeerKey(ctx context.Context, peerKey string) (string, error) { + return am.Store.GetUserIDByPeerKey(ctx, store.LockingStrengthNone, peerKey) +} diff --git a/management/server/account/manager.go b/management/server/account/manager.go index 7c174a481..1e738c274 100644 --- a/management/server/account/manager.go +++ b/management/server/account/manager.go @@ -1,18 +1,22 @@ package account +//go:generate go run github.com/golang/mock/mockgen -package account -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod + import ( "context" "net" "net/netip" "time" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/shared/auth" + nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" nbcache "github.com/netbirdio/netbird/management/server/cache" - nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/server/idp" nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/management/server/peers/ephemeral" "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/types" @@ -24,14 +28,21 @@ import ( type ExternalCacheManager nbcache.UserDataCache type Manager interface { - GetOrCreateAccountByUser(ctx context.Context, userId, domain string) (*types.Account, error) + GetOrCreateAccountByUser(ctx context.Context, userAuth auth.UserAuth) (*types.Account, error) GetAccount(ctx context.Context, accountID string) (*types.Account, error) CreateSetupKey(ctx context.Context, accountID string, keyName string, keyType types.SetupKeyType, expiresIn time.Duration, autoGroups []string, usageLimit int, userID string, ephemeral bool, allowExtraDNSLabels bool) (*types.SetupKey, error) SaveSetupKey(ctx context.Context, accountID string, key *types.SetupKey, userID string) (*types.SetupKey, error) CreateUser(ctx context.Context, accountID, initiatorUserID string, key *types.UserInfo) (*types.UserInfo, error) + CreateUserInvite(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) + AcceptUserInvite(ctx context.Context, token, password string) error + RegenerateUserInvite(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error) + GetUserInviteInfo(ctx context.Context, token string) (*types.UserInviteInfo, error) + ListUserInvites(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error) + DeleteUserInvite(ctx context.Context, accountID, initiatorUserID, inviteID string) error DeleteUser(ctx context.Context, accountID, initiatorUserID string, targetUserID string) error DeleteRegularUsers(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string, userInfos map[string]*types.UserInfo) error + UpdateUserPassword(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error InviteUser(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) error ApproveUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) (*types.UserInfo, error) RejectUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) error @@ -44,20 +55,22 @@ type Manager interface { GetAccountMeta(ctx context.Context, accountID string, userID string) (*types.AccountMeta, error) GetAccountOnboarding(ctx context.Context, accountID string, userID string) (*types.AccountOnboarding, error) AccountExists(ctx context.Context, accountID string) (bool, error) - GetAccountIDByUserID(ctx context.Context, userID, domain string) (string, error) - GetAccountIDFromUserAuth(ctx context.Context, userAuth nbcontext.UserAuth) (string, string, error) + GetAccountIDByUserID(ctx context.Context, userAuth auth.UserAuth) (string, error) + GetAccountIDFromUserAuth(ctx context.Context, userAuth auth.UserAuth) (string, string, error) DeleteAccount(ctx context.Context, accountID, userID string) error GetUserByID(ctx context.Context, id string) (*types.User, error) - GetUserFromUserAuth(ctx context.Context, userAuth nbcontext.UserAuth) (*types.User, error) + GetUserFromUserAuth(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) ListUsers(ctx context.Context, accountID string) ([]*types.User, error) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) - MarkPeerConnected(ctx context.Context, peerKey string, connected bool, realIP net.IP, accountID string) error + MarkPeerConnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error + MarkPeerDisconnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error DeletePeer(ctx context.Context, accountID, peerID, userID string) error - UpdatePeer(ctx context.Context, accountID, userID string, peer *nbpeer.Peer) (*nbpeer.Peer, error) + UpdatePeer(ctx context.Context, accountID, userID string, p *nbpeer.Peer) (*nbpeer.Peer, error) UpdatePeerIP(ctx context.Context, accountID, userID, peerID string, newIP netip.Addr) error + UpdatePeerIPv6(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error) GetPeerNetwork(ctx context.Context, peerID string) (*types.Network, error) - AddPeer(ctx context.Context, accountID, setupKey, userID string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) + AddPeer(ctx context.Context, accountID, setupKey, userID string, p *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) CreatePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) DeletePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) error GetPAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) (*types.PersonalAccessToken, error) @@ -65,7 +78,7 @@ type Manager interface { GetUsersFromAccount(ctx context.Context, accountID, userID string) (map[string]*types.UserInfo, error) GetGroup(ctx context.Context, accountId, groupID, userID string) (*types.Group, error) GetAllGroups(ctx context.Context, accountID, userID string) ([]*types.Group, error) - GetGroupByName(ctx context.Context, groupName, accountID string) (*types.Group, error) + GetGroupByName(ctx context.Context, groupName, accountID, userID string) (*types.Group, error) CreateGroup(ctx context.Context, accountID, userID string, group *types.Group) error UpdateGroup(ctx context.Context, accountID, userID string, group *types.Group) error CreateGroups(ctx context.Context, accountID, userID string, newGroups []*types.Group) error @@ -96,7 +109,8 @@ type Manager interface { GetPeer(ctx context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error) UpdateAccountSettings(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error) UpdateAccountOnboarding(ctx context.Context, accountID, userID string, newOnboarding *types.AccountOnboarding) (*types.AccountOnboarding, error) - LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) // used by peer gRPC API + LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) // used by peer gRPC API + ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) // used by peer gRPC API for ExtendAuthSession SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) // used by peer gRPC API GetExternalCacheManager() ExternalCacheManager GetPostureChecks(ctx context.Context, accountID, postureChecksID, userID string) (*posture.Checks, error) @@ -107,21 +121,31 @@ type Manager interface { UpdateIntegratedValidator(ctx context.Context, accountID, userID, validator string, groups []string) error GroupValidation(ctx context.Context, accountId string, groups []string) (bool, error) GetValidatedPeers(ctx context.Context, accountID string) (map[string]struct{}, map[string]string, error) - SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) - OnPeerDisconnected(ctx context.Context, accountID string, peerPubKey string) error - SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta) error + SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) + OnPeerDisconnected(ctx context.Context, accountID string, peerPubKey string, streamStartTime time.Time) error + SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) error FindExistingPostureCheck(accountID string, checks *posture.ChecksDefinition) (*posture.Checks, error) GetAccountIDForPeerKey(ctx context.Context, peerKey string) (string, error) GetAccountSettings(ctx context.Context, accountID string, userID string) (*types.Settings, error) DeleteSetupKey(ctx context.Context, accountID, userID, keyID string) error - UpdateAccountPeers(ctx context.Context, accountID string) - BufferUpdateAccountPeers(ctx context.Context, accountID string) + UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) + ExpandAndUpdateAffected(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change) + BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) BuildUserInfosForAccount(ctx context.Context, accountID, initiatorUserID string, accountUsers []*types.User) (map[string]*types.UserInfo, error) - SyncUserJWTGroups(ctx context.Context, userAuth nbcontext.UserAuth) error + SyncUserJWTGroups(ctx context.Context, userAuth auth.UserAuth) error GetStore() store.Store GetOrCreateAccountByPrivateDomain(ctx context.Context, initiatorId, domain string) (*types.Account, bool, error) UpdateToPrimaryAccount(ctx context.Context, accountId string) error GetOwnerInfo(ctx context.Context, accountId string) (*types.UserInfo, error) - GetCurrentUserInfo(ctx context.Context, userAuth nbcontext.UserAuth) (*users.UserInfoWithPermissions, error) - SetEphemeralManager(em ephemeral.Manager) + GetCurrentUserInfo(ctx context.Context, userAuth auth.UserAuth) (*users.UserInfoWithPermissions, error) + GetUserIDByPeerKey(ctx context.Context, peerKey string) (string, error) + GetIdentityProvider(ctx context.Context, accountID, idpID, userID string) (*types.IdentityProvider, error) + GetIdentityProviders(ctx context.Context, accountID, userID string) ([]*types.IdentityProvider, error) + CreateIdentityProvider(ctx context.Context, accountID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error) + UpdateIdentityProvider(ctx context.Context, accountID, idpID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error) + DeleteIdentityProvider(ctx context.Context, accountID, idpID, userID string) error + CreatePeerJob(ctx context.Context, accountID, peerID, userID string, job *types.Job) error + GetAllPeerJobs(ctx context.Context, accountID, userID, peerID string) ([]*types.Job, error) + GetPeerJobByID(ctx context.Context, accountID, userID, peerID, jobID string) (*types.Job, error) + SetServiceManager(serviceManager service.Manager) } diff --git a/management/server/account/manager_mock.go b/management/server/account/manager_mock.go new file mode 100644 index 000000000..274e4c683 --- /dev/null +++ b/management/server/account/manager_mock.go @@ -0,0 +1,1794 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./manager.go + +// Package account is a generated GoMock package. +package account + +import ( + context "context" + net "net" + netip "net/netip" + reflect "reflect" + time "time" + + gomock "github.com/golang/mock/gomock" + dns "github.com/netbirdio/netbird/dns" + service "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + activity "github.com/netbirdio/netbird/management/server/activity" + affectedpeers "github.com/netbirdio/netbird/management/server/affectedpeers" + idp "github.com/netbirdio/netbird/management/server/idp" + peer "github.com/netbirdio/netbird/management/server/peer" + posture "github.com/netbirdio/netbird/management/server/posture" + store "github.com/netbirdio/netbird/management/server/store" + types "github.com/netbirdio/netbird/management/server/types" + users "github.com/netbirdio/netbird/management/server/users" + route "github.com/netbirdio/netbird/route" + auth "github.com/netbirdio/netbird/shared/auth" + domain "github.com/netbirdio/netbird/shared/management/domain" +) + +// MockManager is a mock of Manager interface. +type MockManager struct { + ctrl *gomock.Controller + recorder *MockManagerMockRecorder +} + +// MockManagerMockRecorder is the mock recorder for MockManager. +type MockManagerMockRecorder struct { + mock *MockManager +} + +// NewMockManager creates a new mock instance. +func NewMockManager(ctrl *gomock.Controller) *MockManager { + mock := &MockManager{ctrl: ctrl} + mock.recorder = &MockManagerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockManager) EXPECT() *MockManagerMockRecorder { + return m.recorder +} + +// AcceptUserInvite mocks base method. +func (m *MockManager) AcceptUserInvite(ctx context.Context, token, password string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AcceptUserInvite", ctx, token, password) + ret0, _ := ret[0].(error) + return ret0 +} + +// AcceptUserInvite indicates an expected call of AcceptUserInvite. +func (mr *MockManagerMockRecorder) AcceptUserInvite(ctx, token, password interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AcceptUserInvite", reflect.TypeOf((*MockManager)(nil).AcceptUserInvite), ctx, token, password) +} + +// AccountExists mocks base method. +func (m *MockManager) AccountExists(ctx context.Context, accountID string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AccountExists", ctx, accountID) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AccountExists indicates an expected call of AccountExists. +func (mr *MockManagerMockRecorder) AccountExists(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AccountExists", reflect.TypeOf((*MockManager)(nil).AccountExists), ctx, accountID) +} + +// AddPeer mocks base method. +func (m *MockManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, p *peer.Peer, temporary bool) (*peer.Peer, *types.Network, []*posture.Checks, bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddPeer", ctx, accountID, setupKey, userID, p, temporary) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].(*types.Network) + ret2, _ := ret[2].([]*posture.Checks) + ret3, _ := ret[3].(bool) + ret4, _ := ret[4].(error) + return ret0, ret1, ret2, ret3, ret4 +} + +// AddPeer indicates an expected call of AddPeer. +func (mr *MockManagerMockRecorder) AddPeer(ctx, accountID, setupKey, userID, p, temporary interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddPeer", reflect.TypeOf((*MockManager)(nil).AddPeer), ctx, accountID, setupKey, userID, p, temporary) +} + +// ApproveUser mocks base method. +func (m *MockManager) ApproveUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) (*types.UserInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ApproveUser", ctx, accountID, initiatorUserID, targetUserID) + ret0, _ := ret[0].(*types.UserInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ApproveUser indicates an expected call of ApproveUser. +func (mr *MockManagerMockRecorder) ApproveUser(ctx, accountID, initiatorUserID, targetUserID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApproveUser", reflect.TypeOf((*MockManager)(nil).ApproveUser), ctx, accountID, initiatorUserID, targetUserID) +} + +// BufferUpdateAccountPeers mocks base method. +func (m *MockManager) BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "BufferUpdateAccountPeers", ctx, accountID, reason) +} + +// BufferUpdateAccountPeers indicates an expected call of BufferUpdateAccountPeers. +func (mr *MockManagerMockRecorder) BufferUpdateAccountPeers(ctx, accountID, reason interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BufferUpdateAccountPeers", reflect.TypeOf((*MockManager)(nil).BufferUpdateAccountPeers), ctx, accountID, reason) +} + +// BuildUserInfosForAccount mocks base method. +func (m *MockManager) BuildUserInfosForAccount(ctx context.Context, accountID, initiatorUserID string, accountUsers []*types.User) (map[string]*types.UserInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BuildUserInfosForAccount", ctx, accountID, initiatorUserID, accountUsers) + ret0, _ := ret[0].(map[string]*types.UserInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// BuildUserInfosForAccount indicates an expected call of BuildUserInfosForAccount. +func (mr *MockManagerMockRecorder) BuildUserInfosForAccount(ctx, accountID, initiatorUserID, accountUsers interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildUserInfosForAccount", reflect.TypeOf((*MockManager)(nil).BuildUserInfosForAccount), ctx, accountID, initiatorUserID, accountUsers) +} + +// CreateGroup mocks base method. +func (m *MockManager) CreateGroup(ctx context.Context, accountID, userID string, group *types.Group) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateGroup", ctx, accountID, userID, group) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateGroup indicates an expected call of CreateGroup. +func (mr *MockManagerMockRecorder) CreateGroup(ctx, accountID, userID, group interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateGroup", reflect.TypeOf((*MockManager)(nil).CreateGroup), ctx, accountID, userID, group) +} + +// CreateGroups mocks base method. +func (m *MockManager) CreateGroups(ctx context.Context, accountID, userID string, newGroups []*types.Group) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateGroups", ctx, accountID, userID, newGroups) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateGroups indicates an expected call of CreateGroups. +func (mr *MockManagerMockRecorder) CreateGroups(ctx, accountID, userID, newGroups interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateGroups", reflect.TypeOf((*MockManager)(nil).CreateGroups), ctx, accountID, userID, newGroups) +} + +// CreateIdentityProvider mocks base method. +func (m *MockManager) CreateIdentityProvider(ctx context.Context, accountID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateIdentityProvider", ctx, accountID, userID, idp) + ret0, _ := ret[0].(*types.IdentityProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateIdentityProvider indicates an expected call of CreateIdentityProvider. +func (mr *MockManagerMockRecorder) CreateIdentityProvider(ctx, accountID, userID, idp interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateIdentityProvider", reflect.TypeOf((*MockManager)(nil).CreateIdentityProvider), ctx, accountID, userID, idp) +} + +// CreateNameServerGroup mocks base method. +func (m *MockManager) CreateNameServerGroup(ctx context.Context, accountID, name, description string, nameServerList []dns.NameServer, groups []string, primary bool, domains []string, enabled bool, userID string, searchDomainsEnabled bool) (*dns.NameServerGroup, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateNameServerGroup", ctx, accountID, name, description, nameServerList, groups, primary, domains, enabled, userID, searchDomainsEnabled) + ret0, _ := ret[0].(*dns.NameServerGroup) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateNameServerGroup indicates an expected call of CreateNameServerGroup. +func (mr *MockManagerMockRecorder) CreateNameServerGroup(ctx, accountID, name, description, nameServerList, groups, primary, domains, enabled, userID, searchDomainsEnabled interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateNameServerGroup", reflect.TypeOf((*MockManager)(nil).CreateNameServerGroup), ctx, accountID, name, description, nameServerList, groups, primary, domains, enabled, userID, searchDomainsEnabled) +} + +// CreatePAT mocks base method. +func (m *MockManager) CreatePAT(ctx context.Context, accountID, initiatorUserID, targetUserID, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreatePAT", ctx, accountID, initiatorUserID, targetUserID, tokenName, expiresIn) + ret0, _ := ret[0].(*types.PersonalAccessTokenGenerated) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreatePAT indicates an expected call of CreatePAT. +func (mr *MockManagerMockRecorder) CreatePAT(ctx, accountID, initiatorUserID, targetUserID, tokenName, expiresIn interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePAT", reflect.TypeOf((*MockManager)(nil).CreatePAT), ctx, accountID, initiatorUserID, targetUserID, tokenName, expiresIn) +} + +// CreatePeerJob mocks base method. +func (m *MockManager) CreatePeerJob(ctx context.Context, accountID, peerID, userID string, job *types.Job) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreatePeerJob", ctx, accountID, peerID, userID, job) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreatePeerJob indicates an expected call of CreatePeerJob. +func (mr *MockManagerMockRecorder) CreatePeerJob(ctx, accountID, peerID, userID, job interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePeerJob", reflect.TypeOf((*MockManager)(nil).CreatePeerJob), ctx, accountID, peerID, userID, job) +} + +// CreateRoute mocks base method. +func (m *MockManager) CreateRoute(ctx context.Context, accountID string, prefix netip.Prefix, networkType route.NetworkType, domains domain.List, peerID string, peerGroupIDs []string, description string, netID route.NetID, masquerade bool, metric int, groups, accessControlGroupIDs []string, enabled bool, userID string, keepRoute, skipAutoApply bool) (*route.Route, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateRoute", ctx, accountID, prefix, networkType, domains, peerID, peerGroupIDs, description, netID, masquerade, metric, groups, accessControlGroupIDs, enabled, userID, keepRoute, skipAutoApply) + ret0, _ := ret[0].(*route.Route) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateRoute indicates an expected call of CreateRoute. +func (mr *MockManagerMockRecorder) CreateRoute(ctx, accountID, prefix, networkType, domains, peerID, peerGroupIDs, description, netID, masquerade, metric, groups, accessControlGroupIDs, enabled, userID, keepRoute, skipAutoApply interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateRoute", reflect.TypeOf((*MockManager)(nil).CreateRoute), ctx, accountID, prefix, networkType, domains, peerID, peerGroupIDs, description, netID, masquerade, metric, groups, accessControlGroupIDs, enabled, userID, keepRoute, skipAutoApply) +} + +// CreateSetupKey mocks base method. +func (m *MockManager) CreateSetupKey(ctx context.Context, accountID, keyName string, keyType types.SetupKeyType, expiresIn time.Duration, autoGroups []string, usageLimit int, userID string, ephemeral, allowExtraDNSLabels bool) (*types.SetupKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateSetupKey", ctx, accountID, keyName, keyType, expiresIn, autoGroups, usageLimit, userID, ephemeral, allowExtraDNSLabels) + ret0, _ := ret[0].(*types.SetupKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateSetupKey indicates an expected call of CreateSetupKey. +func (mr *MockManagerMockRecorder) CreateSetupKey(ctx, accountID, keyName, keyType, expiresIn, autoGroups, usageLimit, userID, ephemeral, allowExtraDNSLabels interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSetupKey", reflect.TypeOf((*MockManager)(nil).CreateSetupKey), ctx, accountID, keyName, keyType, expiresIn, autoGroups, usageLimit, userID, ephemeral, allowExtraDNSLabels) +} + +// CreateUser mocks base method. +func (m *MockManager) CreateUser(ctx context.Context, accountID, initiatorUserID string, key *types.UserInfo) (*types.UserInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateUser", ctx, accountID, initiatorUserID, key) + ret0, _ := ret[0].(*types.UserInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateUser indicates an expected call of CreateUser. +func (mr *MockManagerMockRecorder) CreateUser(ctx, accountID, initiatorUserID, key interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUser", reflect.TypeOf((*MockManager)(nil).CreateUser), ctx, accountID, initiatorUserID, key) +} + +// CreateUserInvite mocks base method. +func (m *MockManager) CreateUserInvite(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateUserInvite", ctx, accountID, initiatorUserID, invite, expiresIn) + ret0, _ := ret[0].(*types.UserInvite) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateUserInvite indicates an expected call of CreateUserInvite. +func (mr *MockManagerMockRecorder) CreateUserInvite(ctx, accountID, initiatorUserID, invite, expiresIn interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUserInvite", reflect.TypeOf((*MockManager)(nil).CreateUserInvite), ctx, accountID, initiatorUserID, invite, expiresIn) +} + +// DeleteAccount mocks base method. +func (m *MockManager) DeleteAccount(ctx context.Context, accountID, userID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAccount", ctx, accountID, userID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAccount indicates an expected call of DeleteAccount. +func (mr *MockManagerMockRecorder) DeleteAccount(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccount", reflect.TypeOf((*MockManager)(nil).DeleteAccount), ctx, accountID, userID) +} + +// DeleteGroup mocks base method. +func (m *MockManager) DeleteGroup(ctx context.Context, accountId, userId, groupID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteGroup", ctx, accountId, userId, groupID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteGroup indicates an expected call of DeleteGroup. +func (mr *MockManagerMockRecorder) DeleteGroup(ctx, accountId, userId, groupID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteGroup", reflect.TypeOf((*MockManager)(nil).DeleteGroup), ctx, accountId, userId, groupID) +} + +// DeleteGroups mocks base method. +func (m *MockManager) DeleteGroups(ctx context.Context, accountId, userId string, groupIDs []string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteGroups", ctx, accountId, userId, groupIDs) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteGroups indicates an expected call of DeleteGroups. +func (mr *MockManagerMockRecorder) DeleteGroups(ctx, accountId, userId, groupIDs interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteGroups", reflect.TypeOf((*MockManager)(nil).DeleteGroups), ctx, accountId, userId, groupIDs) +} + +// DeleteIdentityProvider mocks base method. +func (m *MockManager) DeleteIdentityProvider(ctx context.Context, accountID, idpID, userID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteIdentityProvider", ctx, accountID, idpID, userID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteIdentityProvider indicates an expected call of DeleteIdentityProvider. +func (mr *MockManagerMockRecorder) DeleteIdentityProvider(ctx, accountID, idpID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteIdentityProvider", reflect.TypeOf((*MockManager)(nil).DeleteIdentityProvider), ctx, accountID, idpID, userID) +} + +// DeleteNameServerGroup mocks base method. +func (m *MockManager) DeleteNameServerGroup(ctx context.Context, accountID, nsGroupID, userID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteNameServerGroup", ctx, accountID, nsGroupID, userID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteNameServerGroup indicates an expected call of DeleteNameServerGroup. +func (mr *MockManagerMockRecorder) DeleteNameServerGroup(ctx, accountID, nsGroupID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNameServerGroup", reflect.TypeOf((*MockManager)(nil).DeleteNameServerGroup), ctx, accountID, nsGroupID, userID) +} + +// DeletePAT mocks base method. +func (m *MockManager) DeletePAT(ctx context.Context, accountID, initiatorUserID, targetUserID, tokenID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeletePAT", ctx, accountID, initiatorUserID, targetUserID, tokenID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeletePAT indicates an expected call of DeletePAT. +func (mr *MockManagerMockRecorder) DeletePAT(ctx, accountID, initiatorUserID, targetUserID, tokenID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePAT", reflect.TypeOf((*MockManager)(nil).DeletePAT), ctx, accountID, initiatorUserID, targetUserID, tokenID) +} + +// DeletePeer mocks base method. +func (m *MockManager) DeletePeer(ctx context.Context, accountID, peerID, userID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeletePeer", ctx, accountID, peerID, userID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeletePeer indicates an expected call of DeletePeer. +func (mr *MockManagerMockRecorder) DeletePeer(ctx, accountID, peerID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePeer", reflect.TypeOf((*MockManager)(nil).DeletePeer), ctx, accountID, peerID, userID) +} + +// DeletePolicy mocks base method. +func (m *MockManager) DeletePolicy(ctx context.Context, accountID, policyID, userID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeletePolicy", ctx, accountID, policyID, userID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeletePolicy indicates an expected call of DeletePolicy. +func (mr *MockManagerMockRecorder) DeletePolicy(ctx, accountID, policyID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePolicy", reflect.TypeOf((*MockManager)(nil).DeletePolicy), ctx, accountID, policyID, userID) +} + +// DeletePostureChecks mocks base method. +func (m *MockManager) DeletePostureChecks(ctx context.Context, accountID, postureChecksID, userID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeletePostureChecks", ctx, accountID, postureChecksID, userID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeletePostureChecks indicates an expected call of DeletePostureChecks. +func (mr *MockManagerMockRecorder) DeletePostureChecks(ctx, accountID, postureChecksID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePostureChecks", reflect.TypeOf((*MockManager)(nil).DeletePostureChecks), ctx, accountID, postureChecksID, userID) +} + +// DeleteRegularUsers mocks base method. +func (m *MockManager) DeleteRegularUsers(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string, userInfos map[string]*types.UserInfo) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteRegularUsers", ctx, accountID, initiatorUserID, targetUserIDs, userInfos) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteRegularUsers indicates an expected call of DeleteRegularUsers. +func (mr *MockManagerMockRecorder) DeleteRegularUsers(ctx, accountID, initiatorUserID, targetUserIDs, userInfos interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteRegularUsers", reflect.TypeOf((*MockManager)(nil).DeleteRegularUsers), ctx, accountID, initiatorUserID, targetUserIDs, userInfos) +} + +// DeleteRoute mocks base method. +func (m *MockManager) DeleteRoute(ctx context.Context, accountID string, routeID route.ID, userID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteRoute", ctx, accountID, routeID, userID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteRoute indicates an expected call of DeleteRoute. +func (mr *MockManagerMockRecorder) DeleteRoute(ctx, accountID, routeID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteRoute", reflect.TypeOf((*MockManager)(nil).DeleteRoute), ctx, accountID, routeID, userID) +} + +// DeleteSetupKey mocks base method. +func (m *MockManager) DeleteSetupKey(ctx context.Context, accountID, userID, keyID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteSetupKey", ctx, accountID, userID, keyID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteSetupKey indicates an expected call of DeleteSetupKey. +func (mr *MockManagerMockRecorder) DeleteSetupKey(ctx, accountID, userID, keyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteSetupKey", reflect.TypeOf((*MockManager)(nil).DeleteSetupKey), ctx, accountID, userID, keyID) +} + +// DeleteUser mocks base method. +func (m *MockManager) DeleteUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteUser", ctx, accountID, initiatorUserID, targetUserID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteUser indicates an expected call of DeleteUser. +func (mr *MockManagerMockRecorder) DeleteUser(ctx, accountID, initiatorUserID, targetUserID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUser", reflect.TypeOf((*MockManager)(nil).DeleteUser), ctx, accountID, initiatorUserID, targetUserID) +} + +// DeleteUserInvite mocks base method. +func (m *MockManager) DeleteUserInvite(ctx context.Context, accountID, initiatorUserID, inviteID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteUserInvite", ctx, accountID, initiatorUserID, inviteID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteUserInvite indicates an expected call of DeleteUserInvite. +func (mr *MockManagerMockRecorder) DeleteUserInvite(ctx, accountID, initiatorUserID, inviteID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserInvite", reflect.TypeOf((*MockManager)(nil).DeleteUserInvite), ctx, accountID, initiatorUserID, inviteID) +} + +// FindExistingPostureCheck mocks base method. +func (m *MockManager) FindExistingPostureCheck(accountID string, checks *posture.ChecksDefinition) (*posture.Checks, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindExistingPostureCheck", accountID, checks) + ret0, _ := ret[0].(*posture.Checks) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindExistingPostureCheck indicates an expected call of FindExistingPostureCheck. +func (mr *MockManagerMockRecorder) FindExistingPostureCheck(accountID, checks interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindExistingPostureCheck", reflect.TypeOf((*MockManager)(nil).FindExistingPostureCheck), accountID, checks) +} + +// GetAccount mocks base method. +func (m *MockManager) GetAccount(ctx context.Context, accountID string) (*types.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccount", ctx, accountID) + ret0, _ := ret[0].(*types.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccount indicates an expected call of GetAccount. +func (mr *MockManagerMockRecorder) GetAccount(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccount", reflect.TypeOf((*MockManager)(nil).GetAccount), ctx, accountID) +} + +// GetAccountByID mocks base method. +func (m *MockManager) GetAccountByID(ctx context.Context, accountID, userID string) (*types.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountByID", ctx, accountID, userID) + ret0, _ := ret[0].(*types.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountByID indicates an expected call of GetAccountByID. +func (mr *MockManagerMockRecorder) GetAccountByID(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountByID", reflect.TypeOf((*MockManager)(nil).GetAccountByID), ctx, accountID, userID) +} + +// GetAccountIDByUserID mocks base method. +func (m *MockManager) GetAccountIDByUserID(ctx context.Context, userAuth auth.UserAuth) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountIDByUserID", ctx, userAuth) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountIDByUserID indicates an expected call of GetAccountIDByUserID. +func (mr *MockManagerMockRecorder) GetAccountIDByUserID(ctx, userAuth interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDByUserID", reflect.TypeOf((*MockManager)(nil).GetAccountIDByUserID), ctx, userAuth) +} + +// GetAccountIDForPeerKey mocks base method. +func (m *MockManager) GetAccountIDForPeerKey(ctx context.Context, peerKey string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountIDForPeerKey", ctx, peerKey) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountIDForPeerKey indicates an expected call of GetAccountIDForPeerKey. +func (mr *MockManagerMockRecorder) GetAccountIDForPeerKey(ctx, peerKey interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDForPeerKey", reflect.TypeOf((*MockManager)(nil).GetAccountIDForPeerKey), ctx, peerKey) +} + +// GetAccountIDFromUserAuth mocks base method. +func (m *MockManager) GetAccountIDFromUserAuth(ctx context.Context, userAuth auth.UserAuth) (string, string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountIDFromUserAuth", ctx, userAuth) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetAccountIDFromUserAuth indicates an expected call of GetAccountIDFromUserAuth. +func (mr *MockManagerMockRecorder) GetAccountIDFromUserAuth(ctx, userAuth interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDFromUserAuth", reflect.TypeOf((*MockManager)(nil).GetAccountIDFromUserAuth), ctx, userAuth) +} + +// GetAccountMeta mocks base method. +func (m *MockManager) GetAccountMeta(ctx context.Context, accountID, userID string) (*types.AccountMeta, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountMeta", ctx, accountID, userID) + ret0, _ := ret[0].(*types.AccountMeta) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountMeta indicates an expected call of GetAccountMeta. +func (mr *MockManagerMockRecorder) GetAccountMeta(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountMeta", reflect.TypeOf((*MockManager)(nil).GetAccountMeta), ctx, accountID, userID) +} + +// GetAccountOnboarding mocks base method. +func (m *MockManager) GetAccountOnboarding(ctx context.Context, accountID, userID string) (*types.AccountOnboarding, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountOnboarding", ctx, accountID, userID) + ret0, _ := ret[0].(*types.AccountOnboarding) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountOnboarding indicates an expected call of GetAccountOnboarding. +func (mr *MockManagerMockRecorder) GetAccountOnboarding(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountOnboarding", reflect.TypeOf((*MockManager)(nil).GetAccountOnboarding), ctx, accountID, userID) +} + +// GetAccountSettings mocks base method. +func (m *MockManager) GetAccountSettings(ctx context.Context, accountID, userID string) (*types.Settings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountSettings", ctx, accountID, userID) + ret0, _ := ret[0].(*types.Settings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountSettings indicates an expected call of GetAccountSettings. +func (mr *MockManagerMockRecorder) GetAccountSettings(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountSettings", reflect.TypeOf((*MockManager)(nil).GetAccountSettings), ctx, accountID, userID) +} + +// GetAllGroups mocks base method. +func (m *MockManager) GetAllGroups(ctx context.Context, accountID, userID string) ([]*types.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllGroups", ctx, accountID, userID) + ret0, _ := ret[0].([]*types.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllGroups indicates an expected call of GetAllGroups. +func (mr *MockManagerMockRecorder) GetAllGroups(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllGroups", reflect.TypeOf((*MockManager)(nil).GetAllGroups), ctx, accountID, userID) +} + +// GetAllPATs mocks base method. +func (m *MockManager) GetAllPATs(ctx context.Context, accountID, initiatorUserID, targetUserID string) ([]*types.PersonalAccessToken, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllPATs", ctx, accountID, initiatorUserID, targetUserID) + ret0, _ := ret[0].([]*types.PersonalAccessToken) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllPATs indicates an expected call of GetAllPATs. +func (mr *MockManagerMockRecorder) GetAllPATs(ctx, accountID, initiatorUserID, targetUserID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllPATs", reflect.TypeOf((*MockManager)(nil).GetAllPATs), ctx, accountID, initiatorUserID, targetUserID) +} + +// GetAllPeerJobs mocks base method. +func (m *MockManager) GetAllPeerJobs(ctx context.Context, accountID, userID, peerID string) ([]*types.Job, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllPeerJobs", ctx, accountID, userID, peerID) + ret0, _ := ret[0].([]*types.Job) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllPeerJobs indicates an expected call of GetAllPeerJobs. +func (mr *MockManagerMockRecorder) GetAllPeerJobs(ctx, accountID, userID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllPeerJobs", reflect.TypeOf((*MockManager)(nil).GetAllPeerJobs), ctx, accountID, userID, peerID) +} + +// GetCurrentUserInfo mocks base method. +func (m *MockManager) GetCurrentUserInfo(ctx context.Context, userAuth auth.UserAuth) (*users.UserInfoWithPermissions, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCurrentUserInfo", ctx, userAuth) + ret0, _ := ret[0].(*users.UserInfoWithPermissions) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCurrentUserInfo indicates an expected call of GetCurrentUserInfo. +func (mr *MockManagerMockRecorder) GetCurrentUserInfo(ctx, userAuth interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCurrentUserInfo", reflect.TypeOf((*MockManager)(nil).GetCurrentUserInfo), ctx, userAuth) +} + +// GetDNSSettings mocks base method. +func (m *MockManager) GetDNSSettings(ctx context.Context, accountID, userID string) (*types.DNSSettings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDNSSettings", ctx, accountID, userID) + ret0, _ := ret[0].(*types.DNSSettings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetDNSSettings indicates an expected call of GetDNSSettings. +func (mr *MockManagerMockRecorder) GetDNSSettings(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDNSSettings", reflect.TypeOf((*MockManager)(nil).GetDNSSettings), ctx, accountID, userID) +} + +// GetEvents mocks base method. +func (m *MockManager) GetEvents(ctx context.Context, accountID, userID string) ([]*activity.Event, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEvents", ctx, accountID, userID) + ret0, _ := ret[0].([]*activity.Event) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetEvents indicates an expected call of GetEvents. +func (mr *MockManagerMockRecorder) GetEvents(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEvents", reflect.TypeOf((*MockManager)(nil).GetEvents), ctx, accountID, userID) +} + +// GetExternalCacheManager mocks base method. +func (m *MockManager) GetExternalCacheManager() ExternalCacheManager { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetExternalCacheManager") + ret0, _ := ret[0].(ExternalCacheManager) + return ret0 +} + +// GetExternalCacheManager indicates an expected call of GetExternalCacheManager. +func (mr *MockManagerMockRecorder) GetExternalCacheManager() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExternalCacheManager", reflect.TypeOf((*MockManager)(nil).GetExternalCacheManager)) +} + +// GetGroup mocks base method. +func (m *MockManager) GetGroup(ctx context.Context, accountId, groupID, userID string) (*types.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroup", ctx, accountId, groupID, userID) + ret0, _ := ret[0].(*types.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGroup indicates an expected call of GetGroup. +func (mr *MockManagerMockRecorder) GetGroup(ctx, accountId, groupID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroup", reflect.TypeOf((*MockManager)(nil).GetGroup), ctx, accountId, groupID, userID) +} + +// GetGroupByName mocks base method. +func (m *MockManager) GetGroupByName(ctx context.Context, groupName, accountID, userID string) (*types.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroupByName", ctx, groupName, accountID, userID) + ret0, _ := ret[0].(*types.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGroupByName indicates an expected call of GetGroupByName. +func (mr *MockManagerMockRecorder) GetGroupByName(ctx, groupName, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupByName", reflect.TypeOf((*MockManager)(nil).GetGroupByName), ctx, groupName, accountID, userID) +} + +// GetIdentityProvider mocks base method. +func (m *MockManager) GetIdentityProvider(ctx context.Context, accountID, idpID, userID string) (*types.IdentityProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetIdentityProvider", ctx, accountID, idpID, userID) + ret0, _ := ret[0].(*types.IdentityProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetIdentityProvider indicates an expected call of GetIdentityProvider. +func (mr *MockManagerMockRecorder) GetIdentityProvider(ctx, accountID, idpID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetIdentityProvider", reflect.TypeOf((*MockManager)(nil).GetIdentityProvider), ctx, accountID, idpID, userID) +} + +// GetIdentityProviders mocks base method. +func (m *MockManager) GetIdentityProviders(ctx context.Context, accountID, userID string) ([]*types.IdentityProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetIdentityProviders", ctx, accountID, userID) + ret0, _ := ret[0].([]*types.IdentityProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetIdentityProviders indicates an expected call of GetIdentityProviders. +func (mr *MockManagerMockRecorder) GetIdentityProviders(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetIdentityProviders", reflect.TypeOf((*MockManager)(nil).GetIdentityProviders), ctx, accountID, userID) +} + +// GetIdpManager mocks base method. +func (m *MockManager) GetIdpManager() idp.Manager { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetIdpManager") + ret0, _ := ret[0].(idp.Manager) + return ret0 +} + +// GetIdpManager indicates an expected call of GetIdpManager. +func (mr *MockManagerMockRecorder) GetIdpManager() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetIdpManager", reflect.TypeOf((*MockManager)(nil).GetIdpManager)) +} + +// GetNameServerGroup mocks base method. +func (m *MockManager) GetNameServerGroup(ctx context.Context, accountID, userID, nsGroupID string) (*dns.NameServerGroup, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNameServerGroup", ctx, accountID, userID, nsGroupID) + ret0, _ := ret[0].(*dns.NameServerGroup) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNameServerGroup indicates an expected call of GetNameServerGroup. +func (mr *MockManagerMockRecorder) GetNameServerGroup(ctx, accountID, userID, nsGroupID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNameServerGroup", reflect.TypeOf((*MockManager)(nil).GetNameServerGroup), ctx, accountID, userID, nsGroupID) +} + +// GetNetworkMap mocks base method. +func (m *MockManager) GetNetworkMap(ctx context.Context, peerID string) (*types.NetworkMap, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNetworkMap", ctx, peerID) + ret0, _ := ret[0].(*types.NetworkMap) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNetworkMap indicates an expected call of GetNetworkMap. +func (mr *MockManagerMockRecorder) GetNetworkMap(ctx, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkMap", reflect.TypeOf((*MockManager)(nil).GetNetworkMap), ctx, peerID) +} + +// GetOrCreateAccountByPrivateDomain mocks base method. +func (m *MockManager) GetOrCreateAccountByPrivateDomain(ctx context.Context, initiatorId, domain string) (*types.Account, bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOrCreateAccountByPrivateDomain", ctx, initiatorId, domain) + ret0, _ := ret[0].(*types.Account) + ret1, _ := ret[1].(bool) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetOrCreateAccountByPrivateDomain indicates an expected call of GetOrCreateAccountByPrivateDomain. +func (mr *MockManagerMockRecorder) GetOrCreateAccountByPrivateDomain(ctx, initiatorId, domain interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrCreateAccountByPrivateDomain", reflect.TypeOf((*MockManager)(nil).GetOrCreateAccountByPrivateDomain), ctx, initiatorId, domain) +} + +// GetOrCreateAccountByUser mocks base method. +func (m *MockManager) GetOrCreateAccountByUser(ctx context.Context, userAuth auth.UserAuth) (*types.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOrCreateAccountByUser", ctx, userAuth) + ret0, _ := ret[0].(*types.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetOrCreateAccountByUser indicates an expected call of GetOrCreateAccountByUser. +func (mr *MockManagerMockRecorder) GetOrCreateAccountByUser(ctx, userAuth interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrCreateAccountByUser", reflect.TypeOf((*MockManager)(nil).GetOrCreateAccountByUser), ctx, userAuth) +} + +// GetOwnerInfo mocks base method. +func (m *MockManager) GetOwnerInfo(ctx context.Context, accountId string) (*types.UserInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOwnerInfo", ctx, accountId) + ret0, _ := ret[0].(*types.UserInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetOwnerInfo indicates an expected call of GetOwnerInfo. +func (mr *MockManagerMockRecorder) GetOwnerInfo(ctx, accountId interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOwnerInfo", reflect.TypeOf((*MockManager)(nil).GetOwnerInfo), ctx, accountId) +} + +// GetPAT mocks base method. +func (m *MockManager) GetPAT(ctx context.Context, accountID, initiatorUserID, targetUserID, tokenID string) (*types.PersonalAccessToken, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPAT", ctx, accountID, initiatorUserID, targetUserID, tokenID) + ret0, _ := ret[0].(*types.PersonalAccessToken) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPAT indicates an expected call of GetPAT. +func (mr *MockManagerMockRecorder) GetPAT(ctx, accountID, initiatorUserID, targetUserID, tokenID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPAT", reflect.TypeOf((*MockManager)(nil).GetPAT), ctx, accountID, initiatorUserID, targetUserID, tokenID) +} + +// GetPeer mocks base method. +func (m *MockManager) GetPeer(ctx context.Context, accountID, peerID, userID string) (*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeer", ctx, accountID, peerID, userID) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeer indicates an expected call of GetPeer. +func (mr *MockManagerMockRecorder) GetPeer(ctx, accountID, peerID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeer", reflect.TypeOf((*MockManager)(nil).GetPeer), ctx, accountID, peerID, userID) +} + +// GetPeerGroups mocks base method. +func (m *MockManager) GetPeerGroups(ctx context.Context, accountID, peerID string) ([]*types.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerGroups", ctx, accountID, peerID) + ret0, _ := ret[0].([]*types.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerGroups indicates an expected call of GetPeerGroups. +func (mr *MockManagerMockRecorder) GetPeerGroups(ctx, accountID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerGroups", reflect.TypeOf((*MockManager)(nil).GetPeerGroups), ctx, accountID, peerID) +} + +// GetPeerJobByID mocks base method. +func (m *MockManager) GetPeerJobByID(ctx context.Context, accountID, userID, peerID, jobID string) (*types.Job, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerJobByID", ctx, accountID, userID, peerID, jobID) + ret0, _ := ret[0].(*types.Job) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerJobByID indicates an expected call of GetPeerJobByID. +func (mr *MockManagerMockRecorder) GetPeerJobByID(ctx, accountID, userID, peerID, jobID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerJobByID", reflect.TypeOf((*MockManager)(nil).GetPeerJobByID), ctx, accountID, userID, peerID, jobID) +} + +// GetPeerNetwork mocks base method. +func (m *MockManager) GetPeerNetwork(ctx context.Context, peerID string) (*types.Network, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerNetwork", ctx, peerID) + ret0, _ := ret[0].(*types.Network) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerNetwork indicates an expected call of GetPeerNetwork. +func (mr *MockManagerMockRecorder) GetPeerNetwork(ctx, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerNetwork", reflect.TypeOf((*MockManager)(nil).GetPeerNetwork), ctx, peerID) +} + +// GetPeers mocks base method. +func (m *MockManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeers", ctx, accountID, userID, nameFilter, ipFilter) + ret0, _ := ret[0].([]*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeers indicates an expected call of GetPeers. +func (mr *MockManagerMockRecorder) GetPeers(ctx, accountID, userID, nameFilter, ipFilter interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeers", reflect.TypeOf((*MockManager)(nil).GetPeers), ctx, accountID, userID, nameFilter, ipFilter) +} + +// GetPolicy mocks base method. +func (m *MockManager) GetPolicy(ctx context.Context, accountID, policyID, userID string) (*types.Policy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPolicy", ctx, accountID, policyID, userID) + ret0, _ := ret[0].(*types.Policy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPolicy indicates an expected call of GetPolicy. +func (mr *MockManagerMockRecorder) GetPolicy(ctx, accountID, policyID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPolicy", reflect.TypeOf((*MockManager)(nil).GetPolicy), ctx, accountID, policyID, userID) +} + +// GetPostureChecks mocks base method. +func (m *MockManager) GetPostureChecks(ctx context.Context, accountID, postureChecksID, userID string) (*posture.Checks, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPostureChecks", ctx, accountID, postureChecksID, userID) + ret0, _ := ret[0].(*posture.Checks) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPostureChecks indicates an expected call of GetPostureChecks. +func (mr *MockManagerMockRecorder) GetPostureChecks(ctx, accountID, postureChecksID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostureChecks", reflect.TypeOf((*MockManager)(nil).GetPostureChecks), ctx, accountID, postureChecksID, userID) +} + +// GetRoute mocks base method. +func (m *MockManager) GetRoute(ctx context.Context, accountID string, routeID route.ID, userID string) (*route.Route, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRoute", ctx, accountID, routeID, userID) + ret0, _ := ret[0].(*route.Route) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRoute indicates an expected call of GetRoute. +func (mr *MockManagerMockRecorder) GetRoute(ctx, accountID, routeID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRoute", reflect.TypeOf((*MockManager)(nil).GetRoute), ctx, accountID, routeID, userID) +} + +// GetSetupKey mocks base method. +func (m *MockManager) GetSetupKey(ctx context.Context, accountID, userID, keyID string) (*types.SetupKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSetupKey", ctx, accountID, userID, keyID) + ret0, _ := ret[0].(*types.SetupKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSetupKey indicates an expected call of GetSetupKey. +func (mr *MockManagerMockRecorder) GetSetupKey(ctx, accountID, userID, keyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSetupKey", reflect.TypeOf((*MockManager)(nil).GetSetupKey), ctx, accountID, userID, keyID) +} + +// GetStore mocks base method. +func (m *MockManager) GetStore() store.Store { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetStore") + ret0, _ := ret[0].(store.Store) + return ret0 +} + +// GetStore indicates an expected call of GetStore. +func (mr *MockManagerMockRecorder) GetStore() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStore", reflect.TypeOf((*MockManager)(nil).GetStore)) +} + +// GetUserByID mocks base method. +func (m *MockManager) GetUserByID(ctx context.Context, id string) (*types.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserByID", ctx, id) + ret0, _ := ret[0].(*types.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserByID indicates an expected call of GetUserByID. +func (mr *MockManagerMockRecorder) GetUserByID(ctx, id interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByID", reflect.TypeOf((*MockManager)(nil).GetUserByID), ctx, id) +} + +// GetUserFromUserAuth mocks base method. +func (m *MockManager) GetUserFromUserAuth(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserFromUserAuth", ctx, userAuth) + ret0, _ := ret[0].(*types.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserFromUserAuth indicates an expected call of GetUserFromUserAuth. +func (mr *MockManagerMockRecorder) GetUserFromUserAuth(ctx, userAuth interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserFromUserAuth", reflect.TypeOf((*MockManager)(nil).GetUserFromUserAuth), ctx, userAuth) +} + +// GetUserIDByPeerKey mocks base method. +func (m *MockManager) GetUserIDByPeerKey(ctx context.Context, peerKey string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserIDByPeerKey", ctx, peerKey) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserIDByPeerKey indicates an expected call of GetUserIDByPeerKey. +func (mr *MockManagerMockRecorder) GetUserIDByPeerKey(ctx, peerKey interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserIDByPeerKey", reflect.TypeOf((*MockManager)(nil).GetUserIDByPeerKey), ctx, peerKey) +} + +// GetUserInviteInfo mocks base method. +func (m *MockManager) GetUserInviteInfo(ctx context.Context, token string) (*types.UserInviteInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserInviteInfo", ctx, token) + ret0, _ := ret[0].(*types.UserInviteInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserInviteInfo indicates an expected call of GetUserInviteInfo. +func (mr *MockManagerMockRecorder) GetUserInviteInfo(ctx, token interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserInviteInfo", reflect.TypeOf((*MockManager)(nil).GetUserInviteInfo), ctx, token) +} + +// GetUsersFromAccount mocks base method. +func (m *MockManager) GetUsersFromAccount(ctx context.Context, accountID, userID string) (map[string]*types.UserInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUsersFromAccount", ctx, accountID, userID) + ret0, _ := ret[0].(map[string]*types.UserInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUsersFromAccount indicates an expected call of GetUsersFromAccount. +func (mr *MockManagerMockRecorder) GetUsersFromAccount(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUsersFromAccount", reflect.TypeOf((*MockManager)(nil).GetUsersFromAccount), ctx, accountID, userID) +} + +// GetValidatedPeers mocks base method. +func (m *MockManager) GetValidatedPeers(ctx context.Context, accountID string) (map[string]struct{}, map[string]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetValidatedPeers", ctx, accountID) + ret0, _ := ret[0].(map[string]struct{}) + ret1, _ := ret[1].(map[string]string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetValidatedPeers indicates an expected call of GetValidatedPeers. +func (mr *MockManagerMockRecorder) GetValidatedPeers(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetValidatedPeers", reflect.TypeOf((*MockManager)(nil).GetValidatedPeers), ctx, accountID) +} + +// GroupAddPeer mocks base method. +func (m *MockManager) GroupAddPeer(ctx context.Context, accountId, groupID, peerID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GroupAddPeer", ctx, accountId, groupID, peerID) + ret0, _ := ret[0].(error) + return ret0 +} + +// GroupAddPeer indicates an expected call of GroupAddPeer. +func (mr *MockManagerMockRecorder) GroupAddPeer(ctx, accountId, groupID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GroupAddPeer", reflect.TypeOf((*MockManager)(nil).GroupAddPeer), ctx, accountId, groupID, peerID) +} + +// GroupDeletePeer mocks base method. +func (m *MockManager) GroupDeletePeer(ctx context.Context, accountId, groupID, peerID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GroupDeletePeer", ctx, accountId, groupID, peerID) + ret0, _ := ret[0].(error) + return ret0 +} + +// GroupDeletePeer indicates an expected call of GroupDeletePeer. +func (mr *MockManagerMockRecorder) GroupDeletePeer(ctx, accountId, groupID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GroupDeletePeer", reflect.TypeOf((*MockManager)(nil).GroupDeletePeer), ctx, accountId, groupID, peerID) +} + +// GroupValidation mocks base method. +func (m *MockManager) GroupValidation(ctx context.Context, accountId string, groups []string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GroupValidation", ctx, accountId, groups) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GroupValidation indicates an expected call of GroupValidation. +func (mr *MockManagerMockRecorder) GroupValidation(ctx, accountId, groups interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GroupValidation", reflect.TypeOf((*MockManager)(nil).GroupValidation), ctx, accountId, groups) +} + +// InviteUser mocks base method. +func (m *MockManager) InviteUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InviteUser", ctx, accountID, initiatorUserID, targetUserID) + ret0, _ := ret[0].(error) + return ret0 +} + +// InviteUser indicates an expected call of InviteUser. +func (mr *MockManagerMockRecorder) InviteUser(ctx, accountID, initiatorUserID, targetUserID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InviteUser", reflect.TypeOf((*MockManager)(nil).InviteUser), ctx, accountID, initiatorUserID, targetUserID) +} + +// ListNameServerGroups mocks base method. +func (m *MockManager) ListNameServerGroups(ctx context.Context, accountID, userID string) ([]*dns.NameServerGroup, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListNameServerGroups", ctx, accountID, userID) + ret0, _ := ret[0].([]*dns.NameServerGroup) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListNameServerGroups indicates an expected call of ListNameServerGroups. +func (mr *MockManagerMockRecorder) ListNameServerGroups(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListNameServerGroups", reflect.TypeOf((*MockManager)(nil).ListNameServerGroups), ctx, accountID, userID) +} + +// ListPolicies mocks base method. +func (m *MockManager) ListPolicies(ctx context.Context, accountID, userID string) ([]*types.Policy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListPolicies", ctx, accountID, userID) + ret0, _ := ret[0].([]*types.Policy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListPolicies indicates an expected call of ListPolicies. +func (mr *MockManagerMockRecorder) ListPolicies(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListPolicies", reflect.TypeOf((*MockManager)(nil).ListPolicies), ctx, accountID, userID) +} + +// ListPostureChecks mocks base method. +func (m *MockManager) ListPostureChecks(ctx context.Context, accountID, userID string) ([]*posture.Checks, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListPostureChecks", ctx, accountID, userID) + ret0, _ := ret[0].([]*posture.Checks) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListPostureChecks indicates an expected call of ListPostureChecks. +func (mr *MockManagerMockRecorder) ListPostureChecks(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListPostureChecks", reflect.TypeOf((*MockManager)(nil).ListPostureChecks), ctx, accountID, userID) +} + +// ListRoutes mocks base method. +func (m *MockManager) ListRoutes(ctx context.Context, accountID, userID string) ([]*route.Route, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListRoutes", ctx, accountID, userID) + ret0, _ := ret[0].([]*route.Route) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListRoutes indicates an expected call of ListRoutes. +func (mr *MockManagerMockRecorder) ListRoutes(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRoutes", reflect.TypeOf((*MockManager)(nil).ListRoutes), ctx, accountID, userID) +} + +// ListSetupKeys mocks base method. +func (m *MockManager) ListSetupKeys(ctx context.Context, accountID, userID string) ([]*types.SetupKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListSetupKeys", ctx, accountID, userID) + ret0, _ := ret[0].([]*types.SetupKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListSetupKeys indicates an expected call of ListSetupKeys. +func (mr *MockManagerMockRecorder) ListSetupKeys(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListSetupKeys", reflect.TypeOf((*MockManager)(nil).ListSetupKeys), ctx, accountID, userID) +} + +// ListUserInvites mocks base method. +func (m *MockManager) ListUserInvites(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListUserInvites", ctx, accountID, initiatorUserID) + ret0, _ := ret[0].([]*types.UserInvite) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListUserInvites indicates an expected call of ListUserInvites. +func (mr *MockManagerMockRecorder) ListUserInvites(ctx, accountID, initiatorUserID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUserInvites", reflect.TypeOf((*MockManager)(nil).ListUserInvites), ctx, accountID, initiatorUserID) +} + +// ListUsers mocks base method. +func (m *MockManager) ListUsers(ctx context.Context, accountID string) ([]*types.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListUsers", ctx, accountID) + ret0, _ := ret[0].([]*types.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListUsers indicates an expected call of ListUsers. +func (mr *MockManagerMockRecorder) ListUsers(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListUsers", reflect.TypeOf((*MockManager)(nil).ListUsers), ctx, accountID) +} + +// LoginPeer mocks base method. +func (m *MockManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*peer.Peer, *types.Network, []*posture.Checks, bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoginPeer", ctx, login) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].(*types.Network) + ret2, _ := ret[2].([]*posture.Checks) + ret3, _ := ret[3].(bool) + ret4, _ := ret[4].(error) + return ret0, ret1, ret2, ret3, ret4 +} + +// LoginPeer indicates an expected call of LoginPeer. +func (mr *MockManagerMockRecorder) LoginPeer(ctx, login interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoginPeer", reflect.TypeOf((*MockManager)(nil).LoginPeer), ctx, login) +} + +// ExtendPeerSession mocks base method. +func (m *MockManager) ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ExtendPeerSession", ctx, peerPubKey, userID) + ret0, _ := ret[0].(time.Time) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ExtendPeerSession indicates an expected call of ExtendPeerSession. +func (mr *MockManagerMockRecorder) ExtendPeerSession(ctx, peerPubKey, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExtendPeerSession", reflect.TypeOf((*MockManager)(nil).ExtendPeerSession), ctx, peerPubKey, userID) +} + +// MarkPeerConnected mocks base method. +func (m *MockManager) MarkPeerConnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkPeerConnected", ctx, peerKey, accountID, sessionStartedAt, nmap) + ret0, _ := ret[0].(error) + return ret0 +} + +// MarkPeerConnected indicates an expected call of MarkPeerConnected. +func (mr *MockManagerMockRecorder) MarkPeerConnected(ctx, peerKey, accountID, sessionStartedAt, nmap interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerConnected", reflect.TypeOf((*MockManager)(nil).MarkPeerConnected), ctx, peerKey, accountID, sessionStartedAt, nmap) +} + +// MarkPeerDisconnected mocks base method. +func (m *MockManager) MarkPeerDisconnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkPeerDisconnected", ctx, peerKey, accountID, sessionStartedAt) + ret0, _ := ret[0].(error) + return ret0 +} + +// MarkPeerDisconnected indicates an expected call of MarkPeerDisconnected. +func (mr *MockManagerMockRecorder) MarkPeerDisconnected(ctx, peerKey, accountID, sessionStartedAt interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerDisconnected", reflect.TypeOf((*MockManager)(nil).MarkPeerDisconnected), ctx, peerKey, accountID, sessionStartedAt) +} + +// OnPeerDisconnected mocks base method. +func (m *MockManager) OnPeerDisconnected(ctx context.Context, accountID, peerPubKey string, streamStartTime time.Time) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "OnPeerDisconnected", ctx, accountID, peerPubKey, streamStartTime) + ret0, _ := ret[0].(error) + return ret0 +} + +// OnPeerDisconnected indicates an expected call of OnPeerDisconnected. +func (mr *MockManagerMockRecorder) OnPeerDisconnected(ctx, accountID, peerPubKey, streamStartTime interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnPeerDisconnected", reflect.TypeOf((*MockManager)(nil).OnPeerDisconnected), ctx, accountID, peerPubKey, streamStartTime) +} + +// RegenerateUserInvite mocks base method. +func (m *MockManager) RegenerateUserInvite(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RegenerateUserInvite", ctx, accountID, initiatorUserID, inviteID, expiresIn) + ret0, _ := ret[0].(*types.UserInvite) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RegenerateUserInvite indicates an expected call of RegenerateUserInvite. +func (mr *MockManagerMockRecorder) RegenerateUserInvite(ctx, accountID, initiatorUserID, inviteID, expiresIn interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegenerateUserInvite", reflect.TypeOf((*MockManager)(nil).RegenerateUserInvite), ctx, accountID, initiatorUserID, inviteID, expiresIn) +} + +// RejectUser mocks base method. +func (m *MockManager) RejectUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RejectUser", ctx, accountID, initiatorUserID, targetUserID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RejectUser indicates an expected call of RejectUser. +func (mr *MockManagerMockRecorder) RejectUser(ctx, accountID, initiatorUserID, targetUserID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RejectUser", reflect.TypeOf((*MockManager)(nil).RejectUser), ctx, accountID, initiatorUserID, targetUserID) +} + +// SaveDNSSettings mocks base method. +func (m *MockManager) SaveDNSSettings(ctx context.Context, accountID, userID string, dnsSettingsToSave *types.DNSSettings) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveDNSSettings", ctx, accountID, userID, dnsSettingsToSave) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveDNSSettings indicates an expected call of SaveDNSSettings. +func (mr *MockManagerMockRecorder) SaveDNSSettings(ctx, accountID, userID, dnsSettingsToSave interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveDNSSettings", reflect.TypeOf((*MockManager)(nil).SaveDNSSettings), ctx, accountID, userID, dnsSettingsToSave) +} + +// SaveNameServerGroup mocks base method. +func (m *MockManager) SaveNameServerGroup(ctx context.Context, accountID, userID string, nsGroupToSave *dns.NameServerGroup) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveNameServerGroup", ctx, accountID, userID, nsGroupToSave) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveNameServerGroup indicates an expected call of SaveNameServerGroup. +func (mr *MockManagerMockRecorder) SaveNameServerGroup(ctx, accountID, userID, nsGroupToSave interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveNameServerGroup", reflect.TypeOf((*MockManager)(nil).SaveNameServerGroup), ctx, accountID, userID, nsGroupToSave) +} + +// SaveOrAddUser mocks base method. +func (m *MockManager) SaveOrAddUser(ctx context.Context, accountID, initiatorUserID string, update *types.User, addIfNotExists bool) (*types.UserInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveOrAddUser", ctx, accountID, initiatorUserID, update, addIfNotExists) + ret0, _ := ret[0].(*types.UserInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SaveOrAddUser indicates an expected call of SaveOrAddUser. +func (mr *MockManagerMockRecorder) SaveOrAddUser(ctx, accountID, initiatorUserID, update, addIfNotExists interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveOrAddUser", reflect.TypeOf((*MockManager)(nil).SaveOrAddUser), ctx, accountID, initiatorUserID, update, addIfNotExists) +} + +// SaveOrAddUsers mocks base method. +func (m *MockManager) SaveOrAddUsers(ctx context.Context, accountID, initiatorUserID string, updates []*types.User, addIfNotExists bool) ([]*types.UserInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveOrAddUsers", ctx, accountID, initiatorUserID, updates, addIfNotExists) + ret0, _ := ret[0].([]*types.UserInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SaveOrAddUsers indicates an expected call of SaveOrAddUsers. +func (mr *MockManagerMockRecorder) SaveOrAddUsers(ctx, accountID, initiatorUserID, updates, addIfNotExists interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveOrAddUsers", reflect.TypeOf((*MockManager)(nil).SaveOrAddUsers), ctx, accountID, initiatorUserID, updates, addIfNotExists) +} + +// SavePolicy mocks base method. +func (m *MockManager) SavePolicy(ctx context.Context, accountID, userID string, policy *types.Policy, create bool) (*types.Policy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SavePolicy", ctx, accountID, userID, policy, create) + ret0, _ := ret[0].(*types.Policy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SavePolicy indicates an expected call of SavePolicy. +func (mr *MockManagerMockRecorder) SavePolicy(ctx, accountID, userID, policy, create interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePolicy", reflect.TypeOf((*MockManager)(nil).SavePolicy), ctx, accountID, userID, policy, create) +} + +// SavePostureChecks mocks base method. +func (m *MockManager) SavePostureChecks(ctx context.Context, accountID, userID string, postureChecks *posture.Checks, create bool) (*posture.Checks, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SavePostureChecks", ctx, accountID, userID, postureChecks, create) + ret0, _ := ret[0].(*posture.Checks) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SavePostureChecks indicates an expected call of SavePostureChecks. +func (mr *MockManagerMockRecorder) SavePostureChecks(ctx, accountID, userID, postureChecks, create interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePostureChecks", reflect.TypeOf((*MockManager)(nil).SavePostureChecks), ctx, accountID, userID, postureChecks, create) +} + +// SaveRoute mocks base method. +func (m *MockManager) SaveRoute(ctx context.Context, accountID, userID string, route *route.Route) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveRoute", ctx, accountID, userID, route) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveRoute indicates an expected call of SaveRoute. +func (mr *MockManagerMockRecorder) SaveRoute(ctx, accountID, userID, route interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveRoute", reflect.TypeOf((*MockManager)(nil).SaveRoute), ctx, accountID, userID, route) +} + +// SaveSetupKey mocks base method. +func (m *MockManager) SaveSetupKey(ctx context.Context, accountID string, key *types.SetupKey, userID string) (*types.SetupKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveSetupKey", ctx, accountID, key, userID) + ret0, _ := ret[0].(*types.SetupKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SaveSetupKey indicates an expected call of SaveSetupKey. +func (mr *MockManagerMockRecorder) SaveSetupKey(ctx, accountID, key, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveSetupKey", reflect.TypeOf((*MockManager)(nil).SaveSetupKey), ctx, accountID, key, userID) +} + +// SaveUser mocks base method. +func (m *MockManager) SaveUser(ctx context.Context, accountID, initiatorUserID string, update *types.User) (*types.UserInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveUser", ctx, accountID, initiatorUserID, update) + ret0, _ := ret[0].(*types.UserInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SaveUser indicates an expected call of SaveUser. +func (mr *MockManagerMockRecorder) SaveUser(ctx, accountID, initiatorUserID, update interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveUser", reflect.TypeOf((*MockManager)(nil).SaveUser), ctx, accountID, initiatorUserID, update) +} + +// SetServiceManager mocks base method. +func (m *MockManager) SetServiceManager(serviceManager service.Manager) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetServiceManager", serviceManager) +} + +// SetServiceManager indicates an expected call of SetServiceManager. +func (mr *MockManagerMockRecorder) SetServiceManager(serviceManager interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetServiceManager", reflect.TypeOf((*MockManager)(nil).SetServiceManager), serviceManager) +} + +// StoreEvent mocks base method. +func (m *MockManager) StoreEvent(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "StoreEvent", ctx, initiatorID, targetID, accountID, activityID, meta) +} + +// StoreEvent indicates an expected call of StoreEvent. +func (mr *MockManagerMockRecorder) StoreEvent(ctx, initiatorID, targetID, accountID, activityID, meta interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreEvent", reflect.TypeOf((*MockManager)(nil).StoreEvent), ctx, initiatorID, targetID, accountID, activityID, meta) +} + +// SyncAndMarkPeer mocks base method. +func (m *MockManager) SyncAndMarkPeer(ctx context.Context, accountID, peerPubKey string, meta peer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*peer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SyncAndMarkPeer", ctx, accountID, peerPubKey, meta, realIP, syncTime) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].(*types.NetworkMap) + ret2, _ := ret[2].([]*posture.Checks) + ret3, _ := ret[3].(int64) + ret4, _ := ret[4].(error) + return ret0, ret1, ret2, ret3, ret4 +} + +// SyncAndMarkPeer indicates an expected call of SyncAndMarkPeer. +func (mr *MockManagerMockRecorder) SyncAndMarkPeer(ctx, accountID, peerPubKey, meta, realIP, syncTime interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncAndMarkPeer", reflect.TypeOf((*MockManager)(nil).SyncAndMarkPeer), ctx, accountID, peerPubKey, meta, realIP, syncTime) +} + +// SyncPeer mocks base method. +func (m *MockManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*peer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SyncPeer", ctx, sync, accountID) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].(*types.NetworkMap) + ret2, _ := ret[2].([]*posture.Checks) + ret3, _ := ret[3].(int64) + ret4, _ := ret[4].(error) + return ret0, ret1, ret2, ret3, ret4 +} + +// SyncPeer indicates an expected call of SyncPeer. +func (mr *MockManagerMockRecorder) SyncPeer(ctx, sync, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncPeer", reflect.TypeOf((*MockManager)(nil).SyncPeer), ctx, sync, accountID) +} + +// SyncPeerMeta mocks base method. +func (m *MockManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta peer.PeerSystemMeta, realIP net.IP) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SyncPeerMeta", ctx, peerPubKey, meta, realIP) + ret0, _ := ret[0].(error) + return ret0 +} + +// SyncPeerMeta indicates an expected call of SyncPeerMeta. +func (mr *MockManagerMockRecorder) SyncPeerMeta(ctx, peerPubKey, meta, realIP interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncPeerMeta", reflect.TypeOf((*MockManager)(nil).SyncPeerMeta), ctx, peerPubKey, meta, realIP) +} + +// SyncUserJWTGroups mocks base method. +func (m *MockManager) SyncUserJWTGroups(ctx context.Context, userAuth auth.UserAuth) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SyncUserJWTGroups", ctx, userAuth) + ret0, _ := ret[0].(error) + return ret0 +} + +// SyncUserJWTGroups indicates an expected call of SyncUserJWTGroups. +func (mr *MockManagerMockRecorder) SyncUserJWTGroups(ctx, userAuth interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SyncUserJWTGroups", reflect.TypeOf((*MockManager)(nil).SyncUserJWTGroups), ctx, userAuth) +} + +// UpdateAccountOnboarding mocks base method. +func (m *MockManager) UpdateAccountOnboarding(ctx context.Context, accountID, userID string, newOnboarding *types.AccountOnboarding) (*types.AccountOnboarding, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateAccountOnboarding", ctx, accountID, userID, newOnboarding) + ret0, _ := ret[0].(*types.AccountOnboarding) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateAccountOnboarding indicates an expected call of UpdateAccountOnboarding. +func (mr *MockManagerMockRecorder) UpdateAccountOnboarding(ctx, accountID, userID, newOnboarding interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountOnboarding", reflect.TypeOf((*MockManager)(nil).UpdateAccountOnboarding), ctx, accountID, userID, newOnboarding) +} + +// UpdateAccountPeers mocks base method. +func (m *MockManager) UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "UpdateAccountPeers", ctx, accountID, reason) +} + +// UpdateAccountPeers indicates an expected call of UpdateAccountPeers. +func (mr *MockManagerMockRecorder) UpdateAccountPeers(ctx, accountID, reason interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountPeers", reflect.TypeOf((*MockManager)(nil).UpdateAccountPeers), ctx, accountID, reason) +} + +// ExpandAndUpdateAffected mocks base method. +func (m *MockManager) ExpandAndUpdateAffected(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "ExpandAndUpdateAffected", ctx, accountID, snap, change) +} + +// ExpandAndUpdateAffected indicates an expected call of ExpandAndUpdateAffected. +func (mr *MockManagerMockRecorder) ExpandAndUpdateAffected(ctx, accountID, snap, change interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExpandAndUpdateAffected", reflect.TypeOf((*MockManager)(nil).ExpandAndUpdateAffected), ctx, accountID, snap, change) +} + +// UpdateAccountSettings mocks base method. +func (m *MockManager) UpdateAccountSettings(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateAccountSettings", ctx, accountID, userID, newSettings) + ret0, _ := ret[0].(*types.Settings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateAccountSettings indicates an expected call of UpdateAccountSettings. +func (mr *MockManagerMockRecorder) UpdateAccountSettings(ctx, accountID, userID, newSettings interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountSettings", reflect.TypeOf((*MockManager)(nil).UpdateAccountSettings), ctx, accountID, userID, newSettings) +} + +// UpdateGroup mocks base method. +func (m *MockManager) UpdateGroup(ctx context.Context, accountID, userID string, group *types.Group) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateGroup", ctx, accountID, userID, group) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateGroup indicates an expected call of UpdateGroup. +func (mr *MockManagerMockRecorder) UpdateGroup(ctx, accountID, userID, group interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateGroup", reflect.TypeOf((*MockManager)(nil).UpdateGroup), ctx, accountID, userID, group) +} + +// UpdateGroups mocks base method. +func (m *MockManager) UpdateGroups(ctx context.Context, accountID, userID string, newGroups []*types.Group) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateGroups", ctx, accountID, userID, newGroups) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateGroups indicates an expected call of UpdateGroups. +func (mr *MockManagerMockRecorder) UpdateGroups(ctx, accountID, userID, newGroups interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateGroups", reflect.TypeOf((*MockManager)(nil).UpdateGroups), ctx, accountID, userID, newGroups) +} + +// UpdateIdentityProvider mocks base method. +func (m *MockManager) UpdateIdentityProvider(ctx context.Context, accountID, idpID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateIdentityProvider", ctx, accountID, idpID, userID, idp) + ret0, _ := ret[0].(*types.IdentityProvider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateIdentityProvider indicates an expected call of UpdateIdentityProvider. +func (mr *MockManagerMockRecorder) UpdateIdentityProvider(ctx, accountID, idpID, userID, idp interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateIdentityProvider", reflect.TypeOf((*MockManager)(nil).UpdateIdentityProvider), ctx, accountID, idpID, userID, idp) +} + +// UpdateIntegratedValidator mocks base method. +func (m *MockManager) UpdateIntegratedValidator(ctx context.Context, accountID, userID, validator string, groups []string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateIntegratedValidator", ctx, accountID, userID, validator, groups) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateIntegratedValidator indicates an expected call of UpdateIntegratedValidator. +func (mr *MockManagerMockRecorder) UpdateIntegratedValidator(ctx, accountID, userID, validator, groups interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateIntegratedValidator", reflect.TypeOf((*MockManager)(nil).UpdateIntegratedValidator), ctx, accountID, userID, validator, groups) +} + +// UpdatePeer mocks base method. +func (m *MockManager) UpdatePeer(ctx context.Context, accountID, userID string, p *peer.Peer) (*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdatePeer", ctx, accountID, userID, p) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdatePeer indicates an expected call of UpdatePeer. +func (mr *MockManagerMockRecorder) UpdatePeer(ctx, accountID, userID, p interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePeer", reflect.TypeOf((*MockManager)(nil).UpdatePeer), ctx, accountID, userID, p) +} + +// UpdatePeerIP mocks base method. +func (m *MockManager) UpdatePeerIP(ctx context.Context, accountID, userID, peerID string, newIP netip.Addr) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdatePeerIP", ctx, accountID, userID, peerID, newIP) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdatePeerIP indicates an expected call of UpdatePeerIP. +func (mr *MockManagerMockRecorder) UpdatePeerIP(ctx, accountID, userID, peerID, newIP interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePeerIP", reflect.TypeOf((*MockManager)(nil).UpdatePeerIP), ctx, accountID, userID, peerID, newIP) +} + +func (m *MockManager) UpdatePeerIPv6(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdatePeerIPv6", ctx, accountID, userID, peerID, newIPv6) + ret0, _ := ret[0].(error) + return ret0 +} + +func (mr *MockManagerMockRecorder) UpdatePeerIPv6(ctx, accountID, userID, peerID, newIPv6 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePeerIPv6", reflect.TypeOf((*MockManager)(nil).UpdatePeerIPv6), ctx, accountID, userID, peerID, newIPv6) +} + +// UpdateToPrimaryAccount mocks base method. +func (m *MockManager) UpdateToPrimaryAccount(ctx context.Context, accountId string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateToPrimaryAccount", ctx, accountId) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateToPrimaryAccount indicates an expected call of UpdateToPrimaryAccount. +func (mr *MockManagerMockRecorder) UpdateToPrimaryAccount(ctx, accountId interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateToPrimaryAccount", reflect.TypeOf((*MockManager)(nil).UpdateToPrimaryAccount), ctx, accountId) +} + +// UpdateUserPassword mocks base method. +func (m *MockManager) UpdateUserPassword(ctx context.Context, accountID, currentUserID, targetUserID, oldPassword, newPassword string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserPassword", ctx, accountID, currentUserID, targetUserID, oldPassword, newPassword) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateUserPassword indicates an expected call of UpdateUserPassword. +func (mr *MockManagerMockRecorder) UpdateUserPassword(ctx, accountID, currentUserID, targetUserID, oldPassword, newPassword interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserPassword", reflect.TypeOf((*MockManager)(nil).UpdateUserPassword), ctx, accountID, currentUserID, targetUserID, oldPassword, newPassword) +} diff --git a/management/server/account/pat.go b/management/server/account/pat.go new file mode 100644 index 000000000..8e5e3e3f9 --- /dev/null +++ b/management/server/account/pat.go @@ -0,0 +1,8 @@ +package account + +const ( + // PATMinExpireDays is the minimum allowed Personal Access Token expiration in days. + PATMinExpireDays = 1 + // PATMaxExpireDays is the maximum allowed Personal Access Token expiration in days. + PATMaxExpireDays = 365 +) diff --git a/management/server/account_request_buffer.go b/management/server/account_request_buffer.go index fa6c45856..e1672c2d0 100644 --- a/management/server/account_request_buffer.go +++ b/management/server/account_request_buffer.go @@ -86,7 +86,14 @@ func (ac *AccountRequestBuffer) processGetAccountBatch(ctx context.Context, acco result := &AccountResult{Account: account, Err: err} for _, req := range requests { - req.ResultChan <- result + if account != nil { + // Shallow copy the account so each goroutine gets its own struct value. + // This prevents data races when callers mutate fields like Policies. + accountCopy := *account + req.ResultChan <- &AccountResult{Account: &accountCopy, Err: err} + } else { + req.ResultChan <- result + } close(req.ResultChan) } } diff --git a/management/server/account_test.go b/management/server/account_test.go index ee9950796..3c0bb25da 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -19,19 +19,33 @@ import ( log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/metric/noop" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + "github.com/netbirdio/netbird/shared/management/status" + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/idp/dex" "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/update_channel" + "github.com/netbirdio/netbird/management/internals/modules/peers" + ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + reverseproxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service/manager" + "github.com/netbirdio/netbird/management/internals/modules/zones" + "github.com/netbirdio/netbird/management/internals/server/config" + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" nbAccount "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/cache" - nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" "github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" + "github.com/netbirdio/netbird/management/server/job" 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" @@ -45,6 +59,7 @@ import ( "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/auth" ) func verifyCanAddPeerToAccount(t *testing.T, manager nbAccount.Manager, account *types.Account, userID string) { @@ -69,7 +84,7 @@ func verifyCanAddPeerToAccount(t *testing.T, manager nbAccount.Manager, account setupKey = key.Key } - _, _, _, err := manager.AddPeer(context.Background(), "", setupKey, userID, peer, false) + _, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey, userID, peer, false) if err != nil { t.Error("expected to add new peer successfully after creating new account, but failed", err) } @@ -146,7 +161,8 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { "peer-1": { ID: peerID1, Key: "peer-1-key", - IP: net.IP{100, 64, 0, 1}, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + IPv6: netip.MustParseAddr("fd00::6440:1"), Name: peerID1, DNSLabel: peerID1, Status: &nbpeer.PeerStatus{ @@ -160,7 +176,8 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { "peer-2": { ID: peerID2, Key: "peer-2-key", - IP: net.IP{100, 64, 0, 1}, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + IPv6: netip.MustParseAddr("fd00::6440:1"), Name: peerID2, DNSLabel: peerID2, Status: &nbpeer.PeerStatus{ @@ -184,7 +201,8 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { "peer-1": { ID: peerID1, Key: "peer-1-key", - IP: net.IP{100, 64, 0, 1}, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + IPv6: netip.MustParseAddr("fd00::6440:1"), Name: peerID1, DNSLabel: peerID1, Status: &nbpeer.PeerStatus{ @@ -199,7 +217,8 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { "peer-2": { ID: peerID2, Key: "peer-2-key", - IP: net.IP{100, 64, 0, 1}, + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + IPv6: netip.MustParseAddr("fd00::6440:1"), Name: peerID2, DNSLabel: peerID2, Status: &nbpeer.PeerStatus{ @@ -223,7 +242,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { // "peer-1": { // ID: peerID1, // Key: "peer-1-key", - // IP: net.IP{100, 64, 0, 1}, + // IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), // Name: peerID1, // DNSLabel: peerID1, // Status: &PeerStatus{ @@ -237,7 +256,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { // "peer-2": { // ID: peerID2, // Key: "peer-2-key", - // IP: net.IP{100, 64, 0, 1}, + // IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), // Name: peerID2, // DNSLabel: peerID2, // Status: &PeerStatus{ @@ -251,7 +270,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { // "peer-3": { // ID: peerID3, // Key: "peer-3-key", - // IP: net.IP{100, 64, 0, 1}, + // IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), // Name: peerID3, // DNSLabel: peerID3, // Status: &PeerStatus{ @@ -274,7 +293,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { // "peer-1": { // ID: peerID1, // Key: "peer-1-key", - // IP: net.IP{100, 64, 0, 1}, + // IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), // Name: peerID1, // DNSLabel: peerID1, // Status: &PeerStatus{ @@ -288,7 +307,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { // "peer-2": { // ID: peerID2, // Key: "peer-2-key", - // IP: net.IP{100, 64, 0, 1}, + // IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), // Name: peerID2, // DNSLabel: peerID2, // Status: &PeerStatus{ @@ -302,7 +321,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { // "peer-3": { // ID: peerID3, // Key: "peer-3-key", - // IP: net.IP{100, 64, 0, 1}, + // IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), // Name: peerID3, // DNSLabel: peerID3, // Status: &PeerStatus{ @@ -325,7 +344,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { // "peer-1": { // ID: peerID1, // Key: "peer-1-key", - // IP: net.IP{100, 64, 0, 1}, + // IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), // Name: peerID1, // DNSLabel: peerID1, // Status: &PeerStatus{ @@ -339,7 +358,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { // "peer-2": { // ID: peerID2, // Key: "peer-2-key", - // IP: net.IP{100, 64, 0, 1}, + // IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), // Name: peerID2, // DNSLabel: peerID2, // Status: &PeerStatus{ @@ -353,7 +372,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { // "peer-3": { // ID: peerID3, // Key: "peer-3-key", - // IP: net.IP{100, 64, 0, 1}, + // IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), // Name: peerID3, // DNSLabel: peerID3, // Status: &PeerStatus{ @@ -379,7 +398,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { } for _, testCase := range tt { - account := newAccountWithId(context.Background(), "account-1", userID, "netbird.io", false) + account := newAccountWithId(context.Background(), "account-1", userID, "netbird.io", "", "", false) account.UpdateSettings(&testCase.accountSettings) account.Network = network account.Peers = testCase.peers @@ -394,7 +413,7 @@ func TestAccount_GetPeerNetworkMap(t *testing.T) { } customZone := account.GetPeersCustomZone(context.Background(), "netbird.io") - networkMap := account.GetPeerNetworkMap(context.Background(), testCase.peerID, customZone, validatedPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil) + networkMap := account.GetPeerNetworkMapFromComponents(context.Background(), testCase.peerID, customZone, nil, validatedPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers()) assert.Len(t, networkMap.Peers, len(testCase.expectedPeers)) assert.Len(t, networkMap.OfflinePeers, len(testCase.expectedOfflinePeers)) } @@ -404,7 +423,7 @@ func TestNewAccount(t *testing.T) { domain := "netbird.io" userId := "account_creator" accountID := "account_id" - account := newAccountWithId(context.Background(), accountID, userId, domain, false) + account := newAccountWithId(context.Background(), accountID, userId, domain, "", "", false) verifyNewAccountHasDefaultFields(t, account, userId, domain, []string{userId}) } @@ -415,7 +434,7 @@ func TestAccountManager_GetOrCreateAccountByUser(t *testing.T) { return } - account, err := manager.GetOrCreateAccountByUser(context.Background(), userID, "") + account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID, Domain: ""}) if err != nil { t.Fatal(err) } @@ -445,7 +464,7 @@ func TestAccountManager_GetOrCreateAccountByUser(t *testing.T) { } func TestDefaultAccountManager_GetAccountIDFromToken(t *testing.T) { - type initUserParams nbcontext.UserAuth + type initUserParams auth.UserAuth var ( publicDomain = "public.com" @@ -468,7 +487,7 @@ func TestDefaultAccountManager_GetAccountIDFromToken(t *testing.T) { testCases := []struct { name string - inputClaims nbcontext.UserAuth + inputClaims auth.UserAuth inputInitUserParams initUserParams inputUpdateAttrs bool inputUpdateClaimAccount bool @@ -483,7 +502,7 @@ func TestDefaultAccountManager_GetAccountIDFromToken(t *testing.T) { }{ { name: "New User With Public Domain", - inputClaims: nbcontext.UserAuth{ + inputClaims: auth.UserAuth{ Domain: publicDomain, UserId: "pub-domain-user", DomainCategory: types.PublicCategory, @@ -500,7 +519,7 @@ func TestDefaultAccountManager_GetAccountIDFromToken(t *testing.T) { }, { name: "New User With Unknown Domain", - inputClaims: nbcontext.UserAuth{ + inputClaims: auth.UserAuth{ Domain: unknownDomain, UserId: "unknown-domain-user", DomainCategory: types.UnknownCategory, @@ -517,7 +536,7 @@ func TestDefaultAccountManager_GetAccountIDFromToken(t *testing.T) { }, { name: "New User With Private Domain", - inputClaims: nbcontext.UserAuth{ + inputClaims: auth.UserAuth{ Domain: privateDomain, UserId: "pvt-domain-user", DomainCategory: types.PrivateCategory, @@ -534,7 +553,7 @@ func TestDefaultAccountManager_GetAccountIDFromToken(t *testing.T) { }, { name: "New Regular User With Existing Private Domain", - inputClaims: nbcontext.UserAuth{ + inputClaims: auth.UserAuth{ Domain: privateDomain, UserId: "new-pvt-domain-user", DomainCategory: types.PrivateCategory, @@ -552,7 +571,7 @@ func TestDefaultAccountManager_GetAccountIDFromToken(t *testing.T) { }, { name: "Existing User With Existing Reclassified Private Domain", - inputClaims: nbcontext.UserAuth{ + inputClaims: auth.UserAuth{ Domain: defaultInitAccount.Domain, UserId: defaultInitAccount.UserId, DomainCategory: types.PrivateCategory, @@ -569,7 +588,7 @@ func TestDefaultAccountManager_GetAccountIDFromToken(t *testing.T) { }, { name: "Existing Account Id With Existing Reclassified Private Domain", - inputClaims: nbcontext.UserAuth{ + inputClaims: auth.UserAuth{ Domain: defaultInitAccount.Domain, UserId: defaultInitAccount.UserId, DomainCategory: types.PrivateCategory, @@ -587,7 +606,7 @@ func TestDefaultAccountManager_GetAccountIDFromToken(t *testing.T) { }, { name: "User With Private Category And Empty Domain", - inputClaims: nbcontext.UserAuth{ + inputClaims: auth.UserAuth{ Domain: "", UserId: "pvt-domain-user", DomainCategory: types.PrivateCategory, @@ -609,14 +628,14 @@ func TestDefaultAccountManager_GetAccountIDFromToken(t *testing.T) { manager, _, err := createManager(t) require.NoError(t, err, "unable to create account manager") - accountID, err := manager.GetAccountIDByUserID(context.Background(), testCase.inputInitUserParams.UserId, testCase.inputInitUserParams.Domain) + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: testCase.inputInitUserParams.UserId, Domain: testCase.inputInitUserParams.Domain}) require.NoError(t, err, "create init user failed") initAccount, err := manager.Store.GetAccount(context.Background(), accountID) require.NoError(t, err, "get init account failed") if testCase.inputUpdateAttrs { - err = manager.updateAccountDomainAttributesIfNotUpToDate(context.Background(), initAccount.Id, nbcontext.UserAuth{UserId: testCase.inputInitUserParams.UserId, Domain: testCase.inputInitUserParams.Domain, DomainCategory: testCase.inputInitUserParams.DomainCategory}, true) + err = manager.updateAccountDomainAttributesIfNotUpToDate(context.Background(), initAccount.Id, auth.UserAuth{UserId: testCase.inputInitUserParams.UserId, Domain: testCase.inputInitUserParams.Domain, DomainCategory: testCase.inputInitUserParams.DomainCategory}, true) require.NoError(t, err, "update init user failed") } @@ -646,17 +665,17 @@ func TestDefaultAccountManager_GetAccountIDFromToken(t *testing.T) { func TestDefaultAccountManager_SyncUserJWTGroups(t *testing.T) { userId := "user-id" domain := "test.domain" - _ = newAccountWithId(context.Background(), "", userId, domain, false) + _ = newAccountWithId(context.Background(), "", userId, domain, "", "", false) manager, _, err := createManager(t) require.NoError(t, err, "unable to create account manager") - accountID, err := manager.GetAccountIDByUserID(context.Background(), userId, domain) + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userId, Domain: domain}) require.NoError(t, err, "create init user failed") // as initAccount was created without account id we have to take the id after account initialization // that happens inside the GetAccountIDByUserID where the id is getting generated // it is important to set the id as it help to avoid creating additional account with empty Id and re-pointing indices to it initAccount, err := manager.Store.GetAccount(context.Background(), accountID) require.NoError(t, err, "get init account failed") - claims := nbcontext.UserAuth{ + claims := auth.UserAuth{ AccountId: accountID, // is empty as it is based on accountID right after initialization of initAccount Domain: domain, UserId: userId, @@ -705,6 +724,28 @@ func TestDefaultAccountManager_SyncUserJWTGroups(t *testing.T) { require.Equal(t, g2.Name, "group2", "group2 name should match") require.Equal(t, g2.Issued, types.GroupIssuedJWT, "group2 issued should match") }) + t.Run("local embedded-Dex user is skipped", func(t *testing.T) { + initAccount.Settings.JWTGroupsEnabled = true + initAccount.Settings.JWTGroupsClaimName = "idp-groups" + err := manager.Store.SaveAccount(context.Background(), initAccount) + require.NoError(t, err, "save account failed") + + localClaims := auth.UserAuth{ + AccountId: accountID, + Domain: domain, + UserId: dex.EncodeDexUserID("local-owner", "local"), + Groups: []string{"group3", "group4"}, + } + err = manager.SyncUserJWTGroups(context.Background(), localClaims) + require.NoError(t, err, "sync should be a no-op for local users") + + account, err := manager.Store.GetAccount(context.Background(), accountID) + require.NoError(t, err, "get account failed") + for _, g := range account.Groups { + require.NotEqual(t, "group3", g.Name, "local user JWT groups must not be synced") + require.NotEqual(t, "group4", g.Name, "local user JWT groups must not be synced") + } + }) } func TestAccountManager_PrivateAccount(t *testing.T) { @@ -715,7 +756,7 @@ func TestAccountManager_PrivateAccount(t *testing.T) { } userId := "test_user" - account, err := manager.GetOrCreateAccountByUser(context.Background(), userId, "") + account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userId, Domain: ""}) if err != nil { t.Fatal(err) } @@ -742,7 +783,7 @@ func TestAccountManager_SetOrUpdateDomain(t *testing.T) { userId := "test_user" domain := "hotmail.com" - account, err := manager.GetOrCreateAccountByUser(context.Background(), userId, domain) + account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userId, Domain: domain}) if err != nil { t.Fatal(err) } @@ -750,13 +791,13 @@ func TestAccountManager_SetOrUpdateDomain(t *testing.T) { t.Fatalf("expected to create an account for a user %s", userId) } - if account != nil && account.Domain != domain { + if account.Domain != domain { t.Errorf("setting account domain failed, expected %s, got %s", domain, account.Domain) } domain = "gmail.com" - account, err = manager.GetOrCreateAccountByUser(context.Background(), userId, domain) + account, err = manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userId, Domain: domain}) if err != nil { t.Fatalf("got the following error while retrieving existing acc: %v", err) } @@ -765,7 +806,7 @@ func TestAccountManager_SetOrUpdateDomain(t *testing.T) { t.Fatalf("expected to get an account for a user %s", userId) } - if account != nil && account.Domain != domain { + if account.Domain != domain { t.Errorf("updating domain. expected %s got %s", domain, account.Domain) } } @@ -779,7 +820,7 @@ func TestAccountManager_GetAccountByUserID(t *testing.T) { userId := "test_user" - accountID, err := manager.GetAccountIDByUserID(context.Background(), userId, "") + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userId, Domain: ""}) if err != nil { t.Fatal(err) } @@ -792,14 +833,14 @@ func TestAccountManager_GetAccountByUserID(t *testing.T) { assert.NoError(t, err) assert.True(t, exists, "expected to get existing account after creation using userid") - _, err = manager.GetAccountIDByUserID(context.Background(), "", "") + _, err = manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: "", Domain: ""}) if err == nil { t.Errorf("expected an error when user ID is empty") } } func createAccount(am *DefaultAccountManager, accountID, userID, domain string) (*types.Account, error) { - account := newAccountWithId(context.Background(), accountID, userID, domain, false) + account := newAccountWithId(context.Background(), accountID, userID, domain, "", "", false) err := am.Store.SaveAccount(context.Background(), account) if err != nil { return nil, err @@ -915,13 +956,13 @@ func TestAccountManager_DeleteAccount(t *testing.T) { } func BenchmarkTest_GetAccountWithclaims(b *testing.B) { - claims := nbcontext.UserAuth{ + claims := auth.UserAuth{ Domain: "example.com", UserId: "pvt-domain-user", DomainCategory: types.PrivateCategory, } - publicClaims := nbcontext.UserAuth{ + publicClaims := auth.UserAuth{ Domain: "test.com", UserId: "public-domain-user", DomainCategory: types.PublicCategory, @@ -1051,7 +1092,7 @@ func TestAccountManager_AddPeer(t *testing.T) { } expectedPeerKey := key.PublicKey().String() - peer, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: expectedPeerKey, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, }, false) @@ -1070,7 +1111,7 @@ func TestAccountManager_AddPeer(t *testing.T) { t.Errorf("expecting just added peer to have key = %s, got %s", expectedPeerKey, peer.Key) } - if !account.Network.Net.Contains(peer.IP) { + if !account.Network.Net.Contains(peer.IP.AsSlice()) { t.Errorf("expecting just added peer's IP %s to be in a network range %s", peer.IP.String(), account.Network.Net.String()) } @@ -1095,7 +1136,7 @@ func TestAccountManager_AddPeerWithUserID(t *testing.T) { return } - account, err := manager.GetOrCreateAccountByUser(context.Background(), userID, "netbird.cloud") + account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID, Domain: "netbird.cloud"}) if err != nil { t.Fatal(err) } @@ -1115,7 +1156,7 @@ func TestAccountManager_AddPeerWithUserID(t *testing.T) { expectedPeerKey := key.PublicKey().String() expectedUserID := userID - peer, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + peer, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: expectedPeerKey, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, }, false) @@ -1134,7 +1175,7 @@ func TestAccountManager_AddPeerWithUserID(t *testing.T) { t.Errorf("expecting just added peer to have key = %s, got %s", expectedPeerKey, peer.Key) } - if !account.Network.Net.Contains(peer.IP) { + if !account.Network.Net.Contains(peer.IP.AsSlice()) { t.Errorf("expecting just added peer's IP %s to be in a network range %s", peer.IP.String(), account.Network.Net.String()) } @@ -1157,11 +1198,6 @@ func TestAccountManager_AddPeerWithUserID(t *testing.T) { assert.Equal(t, peer.IP.String(), fmt.Sprint(ev.Meta["ip"])) } -func TestAccountManager_NetworkUpdates_SaveGroup_Experimental(t *testing.T) { - t.Setenv(network_map.EnvNewNetworkMapBuilder, "true") - testAccountManager_NetworkUpdates_SaveGroup(t) -} - func TestAccountManager_NetworkUpdates_SaveGroup(t *testing.T) { testAccountManager_NetworkUpdates_SaveGroup(t) } @@ -1217,11 +1253,6 @@ func testAccountManager_NetworkUpdates_SaveGroup(t *testing.T) { wg.Wait() } -func TestAccountManager_NetworkUpdates_DeletePolicy_Experimental(t *testing.T) { - t.Setenv(network_map.EnvNewNetworkMapBuilder, "true") - testAccountManager_NetworkUpdates_DeletePolicy(t) -} - func TestAccountManager_NetworkUpdates_DeletePolicy(t *testing.T) { testAccountManager_NetworkUpdates_DeletePolicy(t) } @@ -1260,11 +1291,6 @@ func testAccountManager_NetworkUpdates_DeletePolicy(t *testing.T) { wg.Wait() } -func TestAccountManager_NetworkUpdates_SavePolicy_Experimental(t *testing.T) { - t.Setenv(network_map.EnvNewNetworkMapBuilder, "true") - testAccountManager_NetworkUpdates_SavePolicy(t) -} - func TestAccountManager_NetworkUpdates_SavePolicy(t *testing.T) { testAccountManager_NetworkUpdates_SavePolicy(t) } @@ -1318,11 +1344,6 @@ func testAccountManager_NetworkUpdates_SavePolicy(t *testing.T) { wg.Wait() } -func TestAccountManager_NetworkUpdates_DeletePeer_Experimental(t *testing.T) { - t.Setenv(network_map.EnvNewNetworkMapBuilder, "true") - testAccountManager_NetworkUpdates_DeletePeer(t) -} - func TestAccountManager_NetworkUpdates_DeletePeer(t *testing.T) { testAccountManager_NetworkUpdates_DeletePeer(t) } @@ -1383,11 +1404,6 @@ func testAccountManager_NetworkUpdates_DeletePeer(t *testing.T) { wg.Wait() } -func TestAccountManager_NetworkUpdates_DeleteGroup_Experimental(t *testing.T) { - t.Setenv(network_map.EnvNewNetworkMapBuilder, "true") - testAccountManager_NetworkUpdates_DeleteGroup(t) -} - func TestAccountManager_NetworkUpdates_DeleteGroup(t *testing.T) { testAccountManager_NetworkUpdates_DeleteGroup(t) } @@ -1488,7 +1504,7 @@ func TestAccountManager_DeletePeer(t *testing.T) { peerKey := key.PublicKey().String() - peer, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey, Meta: nbpeer.PeerSystemMeta{Hostname: peerKey}, }, false) @@ -1619,75 +1635,6 @@ func TestFileStore_GetRoutesByPrefix(t *testing.T) { assert.Contains(t, routeIDs, route.ID("route-2")) } -func TestAccount_GetRoutesToSync(t *testing.T) { - _, prefix, err := route.ParseNetwork("192.168.64.0/24") - if err != nil { - t.Fatal(err) - } - _, prefix2, err := route.ParseNetwork("192.168.0.0/24") - if err != nil { - t.Fatal(err) - } - account := &types.Account{ - Peers: map[string]*nbpeer.Peer{ - "peer-1": {Key: "peer-1", Meta: nbpeer.PeerSystemMeta{GoOS: "linux"}}, "peer-2": {Key: "peer-2", Meta: nbpeer.PeerSystemMeta{GoOS: "linux"}}, "peer-3": {Key: "peer-1", Meta: nbpeer.PeerSystemMeta{GoOS: "linux"}}, - }, - Groups: map[string]*types.Group{"group1": {ID: "group1", Peers: []string{"peer-1", "peer-2"}}}, - Routes: map[route.ID]*route.Route{ - "route-1": { - ID: "route-1", - Network: prefix, - NetID: "network-1", - Description: "network-1", - Peer: "peer-1", - NetworkType: 0, - Masquerade: false, - Metric: 999, - Enabled: true, - Groups: []string{"group1"}, - }, - "route-2": { - ID: "route-2", - Network: prefix2, - NetID: "network-2", - Description: "network-2", - Peer: "peer-2", - NetworkType: 0, - Masquerade: false, - Metric: 999, - Enabled: true, - Groups: []string{"group1"}, - }, - "route-3": { - ID: "route-3", - Network: prefix, - NetID: "network-1", - Description: "network-1", - Peer: "peer-2", - NetworkType: 0, - Masquerade: false, - Metric: 999, - Enabled: true, - Groups: []string{"group1"}, - }, - }, - } - - routes := account.GetRoutesToSync(context.Background(), "peer-2", []*nbpeer.Peer{{Key: "peer-1"}, {Key: "peer-3"}}) - - assert.Len(t, routes, 2) - routeIDs := make(map[route.ID]struct{}, 2) - for _, r := range routes { - routeIDs[r.ID] = struct{}{} - } - assert.Contains(t, routeIDs, route.ID("route-2")) - assert.Contains(t, routeIDs, route.ID("route-3")) - - emptyRoutes := account.GetRoutesToSync(context.Background(), "peer-3", []*nbpeer.Peer{{Key: "peer-1"}, {Key: "peer-2"}}) - - assert.Len(t, emptyRoutes, 0) -} - func TestAccount_Copy(t *testing.T) { account := &types.Account{ Id: "account1", @@ -1795,9 +1742,22 @@ func TestAccount_Copy(t *testing.T) { Address: "172.12.6.1/24", }, }, - NetworkMapCache: &types.NetworkMapBuilder{}, + Services: []*service.Service{ + { + ID: "service1", + Name: "test-service", + AccountID: "account1", + Targets: []*service.Target{}, + }, + }, + Domains: []*domain.Domain{ + { + ID: "domain1", + Domain: "test.com", + AccountID: "account1", + }, + }, } - account.InitOnce() err := hasNilField(account) if err != nil { t.Fatal(err) @@ -1828,7 +1788,7 @@ func hasNilField(x interface{}) error { if f := rv.Field(i); f.IsValid() { k := f.Kind() switch k { - case reflect.Ptr: + case reflect.Pointer: if f.IsNil() { return fmt.Errorf("field %s is nil", f.String()) } @@ -1846,7 +1806,7 @@ func TestDefaultAccountManager_DefaultAccountSettings(t *testing.T) { manager, _, err := createManager(t) require.NoError(t, err, "unable to create account manager") - accountID, err := manager.GetAccountIDByUserID(context.Background(), userID, "") + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) require.NoError(t, err, "unable to create an account") settings, err := manager.Store.GetAccountSettings(context.Background(), store.LockingStrengthNone, accountID) @@ -1861,22 +1821,22 @@ func TestDefaultAccountManager_UpdatePeer_PeerLoginExpiration(t *testing.T) { manager, _, err := createManager(t) require.NoError(t, err, "unable to create account manager") - _, err = manager.GetAccountIDByUserID(context.Background(), userID, "") + _, err = manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) require.NoError(t, err, "unable to create an account") key, err := wgtypes.GenerateKey() require.NoError(t, err, "unable to generate WireGuard key") - peer, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + peer, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: key.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, LoginExpirationEnabled: true, }, false) require.NoError(t, err, "unable to add peer") - accountID, err := manager.GetAccountIDByUserID(context.Background(), userID, "") + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) require.NoError(t, err, "unable to get the account") - err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), true, nil, accountID) + err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), accountID, time.Now().UTC().UnixNano(), nil) require.NoError(t, err, "unable to mark peer connected") _, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{ @@ -1917,12 +1877,12 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing. manager, _, err := createManager(t) require.NoError(t, err, "unable to create account manager") - accountID, err := manager.GetAccountIDByUserID(context.Background(), userID, "") + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) require.NoError(t, err, "unable to create an account") key, err := wgtypes.GenerateKey() require.NoError(t, err, "unable to generate WireGuard key") - _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: key.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, LoginExpirationEnabled: true, @@ -1943,11 +1903,11 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing. }, } - accountID, err = manager.GetAccountIDByUserID(context.Background(), userID, "") + accountID, err = manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) require.NoError(t, err, "unable to get the account") // when we mark peer as connected, the peer login expiration routine should trigger - err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), true, nil, accountID) + err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), accountID, time.Now().UTC().UnixNano(), nil) require.NoError(t, err, "unable to mark peer connected") failed := waitTimeout(wg, time.Second) @@ -1956,29 +1916,295 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing. } } -func TestDefaultAccountManager_UpdateAccountSettings_PeerLoginExpiration(t *testing.T) { +func TestDefaultAccountManager_MarkPeerDisconnected_SchedulesInactivityExpiration(t *testing.T) { manager, _, err := createManager(t) require.NoError(t, err, "unable to create account manager") - _, err = manager.GetAccountIDByUserID(context.Background(), userID, "") + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) require.NoError(t, err, "unable to create an account") key, err := wgtypes.GenerateKey() require.NoError(t, err, "unable to generate WireGuard key") - _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + peerPubKey := key.PublicKey().String() + + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + Key: peerPubKey, + Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, + InactivityExpirationEnabled: true, + }, false) + require.NoError(t, err, "unable to add peer") + + _, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{ + PeerLoginExpiration: time.Hour, + PeerLoginExpirationEnabled: true, + PeerInactivityExpiration: time.Hour, + PeerInactivityExpirationEnabled: true, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err, "expecting to update account settings successfully but got error") + + // Establish a session so the matching-token disconnect is actually applied. + streamStartTime := time.Now().UTC() + err = manager.MarkPeerConnected(context.Background(), peerPubKey, accountID, streamStartTime.UnixNano(), nil) + require.NoError(t, err, "unable to mark peer connected") + + // Install the mock only now, so the assertion observes the disconnect, not + // the earlier connect. + scheduled := make(chan struct{}, 1) + manager.peerInactivityExpiry = &MockScheduler{ + CancelFunc: func(ctx context.Context, IDs []string) {}, + ScheduleFunc: func(ctx context.Context, in time.Duration, ID string, job func() (nextRunIn time.Duration, reschedule bool)) { + select { + case scheduled <- struct{}{}: + default: + } + }, + } + + err = manager.MarkPeerDisconnected(context.Background(), peerPubKey, accountID, streamStartTime.UnixNano()) + require.NoError(t, err, "unable to mark peer disconnected") + + select { + case <-scheduled: + // expected: disconnect re-armed the inactivity expiry timer + case <-time.After(time.Second): + t.Fatal("expected inactivity expiration to be rescheduled when an eligible peer disconnects") + } +} + +func TestDefaultAccountManager_MarkPeerDisconnected_SkipsInactivityExpirationWhenDisabled(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err, "unable to create account manager") + + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err, "unable to create an account") + + key, err := wgtypes.GenerateKey() + require.NoError(t, err, "unable to generate WireGuard key") + peerPubKey := key.PublicKey().String() + + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + Key: peerPubKey, + Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, + InactivityExpirationEnabled: true, + }, false) + require.NoError(t, err, "unable to add peer") + + // Peer is eligible (SSO + inactivity enabled) but the account-level setting + // stays disabled, so disconnect must not schedule anything. + _, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{ + PeerLoginExpiration: time.Hour, + PeerLoginExpirationEnabled: true, + PeerInactivityExpiration: time.Hour, + PeerInactivityExpirationEnabled: false, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err, "expecting to update account settings successfully but got error") + + streamStartTime := time.Now().UTC() + err = manager.MarkPeerConnected(context.Background(), peerPubKey, accountID, streamStartTime.UnixNano(), nil) + require.NoError(t, err, "unable to mark peer connected") + + scheduled := make(chan struct{}, 1) + manager.peerInactivityExpiry = &MockScheduler{ + CancelFunc: func(ctx context.Context, IDs []string) {}, + ScheduleFunc: func(ctx context.Context, in time.Duration, ID string, job func() (nextRunIn time.Duration, reschedule bool)) { + select { + case scheduled <- struct{}{}: + default: + } + }, + } + + err = manager.MarkPeerDisconnected(context.Background(), peerPubKey, accountID, streamStartTime.UnixNano()) + require.NoError(t, err, "unable to mark peer disconnected") + + select { + case <-scheduled: + t.Fatal("inactivity expiration must not be scheduled while the account-level setting is disabled") + case <-time.After(200 * time.Millisecond): + // expected: nothing scheduled + } +} + +func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err, "unable to create account manager") + + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err, "unable to create an account") + + key, err := wgtypes.GenerateKey() + require.NoError(t, err, "unable to generate WireGuard key") + peerPubKey := key.PublicKey().String() + + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + Key: peerPubKey, + Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, + }, false) + require.NoError(t, err, "unable to add peer") + + t.Run("disconnect peer when session token matches", func(t *testing.T) { + streamStartTime := time.Now().UTC() + err = manager.MarkPeerConnected(context.Background(), peerPubKey, accountID, streamStartTime.UnixNano(), nil) + require.NoError(t, err, "unable to mark peer connected") + + peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) + require.NoError(t, err, "unable to get peer") + require.True(t, peer.Status.Connected, "peer should be connected") + require.Equal(t, streamStartTime.UnixNano(), peer.Status.SessionStartedAt, + "SessionStartedAt should equal the token we passed in") + + err = manager.OnPeerDisconnected(context.Background(), accountID, peerPubKey, streamStartTime) + require.NoError(t, err) + + peer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) + require.NoError(t, err) + require.False(t, peer.Status.Connected, "peer should be disconnected") + require.Equal(t, int64(0), peer.Status.SessionStartedAt, "SessionStartedAt should be reset to 0") + }) + + t.Run("skip disconnect when stored session is newer (zombie stream protection)", func(t *testing.T) { + // Newer stream wins on connect (sets SessionStartedAt = now ns). + streamStartTime := time.Now().UTC() + err = manager.MarkPeerConnected(context.Background(), peerPubKey, accountID, streamStartTime.UnixNano(), nil) + require.NoError(t, err, "unable to mark peer connected") + + peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) + require.NoError(t, err) + require.True(t, peer.Status.Connected, "peer should be connected") + + // Older stream tries to mark disconnect with its own (older) session token — + // fencing kicks in and the write is dropped. + staleStreamStartTime := streamStartTime.Add(-1 * time.Hour) + + err = manager.OnPeerDisconnected(context.Background(), accountID, peerPubKey, staleStreamStartTime) + require.NoError(t, err) + + peer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) + require.NoError(t, err) + require.True(t, peer.Status.Connected, + "peer should remain connected because the stored session is newer than the disconnect token") + require.Equal(t, streamStartTime.UnixNano(), peer.Status.SessionStartedAt, + "SessionStartedAt should still hold the winning stream's token") + }) + + t.Run("skip stale connect when stored session is newer (blocked goroutine protection)", func(t *testing.T) { + node2SyncTime := time.Now().UTC() + err = manager.MarkPeerConnected(context.Background(), peerPubKey, accountID, node2SyncTime.UnixNano(), nil) + require.NoError(t, err, "node 2 should connect peer") + + peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) + require.NoError(t, err) + require.True(t, peer.Status.Connected, "peer should be connected") + require.Equal(t, node2SyncTime.UnixNano(), peer.Status.SessionStartedAt, + "SessionStartedAt should equal node2SyncTime token") + + node1StaleSyncTime := node2SyncTime.Add(-1 * time.Minute) + err = manager.MarkPeerConnected(context.Background(), peerPubKey, accountID, node1StaleSyncTime.UnixNano(), nil) + require.NoError(t, err, "stale connect should not return error") + + peer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) + require.NoError(t, err) + require.True(t, peer.Status.Connected, "peer should still be connected") + require.Equal(t, node2SyncTime.UnixNano(), peer.Status.SessionStartedAt, + "SessionStartedAt should NOT be overwritten by stale token from blocked goroutine") + }) +} + +// TestDefaultAccountManager_MarkPeerConnected_ConcurrentRace exercises the +// fencing protocol under contention: many goroutines race to mark the +// same peer connected with distinct session tokens at the same time. +// The contract is that the highest token always wins and is what remains +// in the store, regardless of execution order. +func TestDefaultAccountManager_MarkPeerConnected_ConcurrentRace(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err, "unable to create account manager") + + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err, "unable to get account") + + key, err := wgtypes.GenerateKey() + require.NoError(t, err, "unable to generate WireGuard key") + peerPubKey := key.PublicKey().String() + + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + Key: peerPubKey, + Meta: nbpeer.PeerSystemMeta{Hostname: "race-peer"}, + }, false) + require.NoError(t, err, "unable to add peer") + + const workers = 16 + base := time.Now().UTC().UnixNano() + tokens := make([]int64, workers) + for i := range tokens { + // Spread tokens by 1ms so the comparison is unambiguous; the + // largest is index workers-1. + tokens[i] = base + int64(i)*int64(time.Millisecond) + } + expected := tokens[workers-1] + + var ready sync.WaitGroup + ready.Add(workers) + var start sync.WaitGroup + start.Add(1) + var done sync.WaitGroup + done.Add(workers) + + // require.* calls t.FailNow which is documented as unsafe from + // non-test goroutines (it calls runtime.Goexit on the wrong stack and + // races with the WaitGroup). Collect errors here and assert from the + // main goroutine after done.Wait(). + errs := make(chan error, workers) + + for i := 0; i < workers; i++ { + token := tokens[i] + go func() { + defer done.Done() + ready.Done() + start.Wait() + errs <- manager.MarkPeerConnected(context.Background(), peerPubKey, accountID, token, nil) + }() + } + + ready.Wait() + start.Done() + done.Wait() + close(errs) + for err := range errs { + require.NoError(t, err, "MarkPeerConnected must not error under contention") + } + + peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey) + require.NoError(t, err) + require.True(t, peer.Status.Connected, "peer should be connected after the race") + require.Equal(t, expected, peer.Status.SessionStartedAt, + "the largest token must win regardless of execution order") +} + +func TestDefaultAccountManager_UpdateAccountSettings_PeerLoginExpiration(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err, "unable to create account manager") + + _, err = manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err, "unable to create an account") + + key, err := wgtypes.GenerateKey() + require.NoError(t, err, "unable to generate WireGuard key") + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: key.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"}, LoginExpirationEnabled: true, }, false) require.NoError(t, err, "unable to add peer") - accountID, err := manager.GetAccountIDByUserID(context.Background(), userID, "") + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) require.NoError(t, err, "unable to get the account") account, err := manager.Store.GetAccount(context.Background(), accountID) require.NoError(t, err, "unable to get the account") - err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), true, nil, accountID) + err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), accountID, time.Now().UTC().UnixNano(), nil) require.NoError(t, err, "unable to mark peer connected") wg := &sync.WaitGroup{} @@ -2022,7 +2248,7 @@ func TestDefaultAccountManager_UpdateAccountSettings(t *testing.T) { manager, _, err := createManager(t) require.NoError(t, err, "unable to create account manager") - accountID, err := manager.GetAccountIDByUserID(context.Background(), userID, "") + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) require.NoError(t, err, "unable to create an account") updatedSettings, err := manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{ @@ -2055,6 +2281,72 @@ func TestDefaultAccountManager_UpdateAccountSettings(t *testing.T) { require.Error(t, err, "expecting to fail when providing PeerLoginExpiration more than 180 days") } +func TestDefaultAccountManager_UpdateAccountSettings_PeerApproval(t *testing.T) { + manager, _, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + + accountID := account.Id + userID := account.Users[account.CreatedBy].Id + ctx := context.Background() + + newSettings := account.Settings.Copy() + newSettings.Extra = &types.ExtraSettings{ + PeerApprovalEnabled: true, + } + _, err := manager.UpdateAccountSettings(ctx, accountID, userID, newSettings) + require.NoError(t, err) + + peer1.Status.RequiresApproval = true + peer2.Status.RequiresApproval = true + peer3.Status.RequiresApproval = false + + require.NoError(t, manager.Store.SavePeer(ctx, accountID, peer1)) + require.NoError(t, manager.Store.SavePeer(ctx, accountID, peer2)) + require.NoError(t, manager.Store.SavePeer(ctx, accountID, peer3)) + + newSettings = account.Settings.Copy() + newSettings.Extra = &types.ExtraSettings{ + PeerApprovalEnabled: false, + } + _, err = manager.UpdateAccountSettings(ctx, accountID, userID, newSettings) + require.NoError(t, err) + + accountPeers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + require.NoError(t, err) + + for _, peer := range accountPeers { + assert.False(t, peer.Status.RequiresApproval, "peer %s should not require approval after disabling peer approval", peer.ID) + } +} + +func TestDefaultAccountManager_UpdateAccountSettings_DNSDomainConflict(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err, "unable to create account manager") + + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err, "unable to create an account") + + ctx := context.Background() + err = manager.Store.CreateZone(ctx, &zones.Zone{ + ID: "test-zone-id", + AccountID: accountID, + Name: "Test Zone", + Domain: "custom.example.com", + Enabled: true, + EnableSearchDomain: false, + DistributionGroups: []string{}, + }) + require.NoError(t, err, "unable to create custom DNS zone") + + _, err = manager.UpdateAccountSettings(ctx, accountID, userID, &types.Settings{ + DNSDomain: "custom.example.com", + PeerLoginExpiration: time.Hour, + PeerLoginExpirationEnabled: false, + Extra: &types.ExtraSettings{}, + }) + require.Error(t, err, "expecting to fail when DNS domain conflicts with custom zone") + assert.Contains(t, err.Error(), "conflicts with existing custom DNS zone") +} + func TestAccount_GetExpiredPeers(t *testing.T) { type test struct { name string @@ -2140,6 +2432,29 @@ func TestAccount_GetExpiredPeers(t *testing.T) { } } +func TestGetExpiredPeers_SkipsAlreadyExpired(t *testing.T) { + ctx := context.Background() + + testStore, cleanUp, err := store.NewTestStoreFromSQL(ctx, "testdata/store_with_expired_peers.sql", t.TempDir()) + t.Cleanup(cleanUp) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + // Verify the already-expired peer is excluded at the store level + peers, err := testStore.GetAccountPeersWithExpiration(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + + for _, peer := range peers { + assert.NotEqual(t, "cg05lnblo1hkg2j514p0", peer.ID, "already expired peer should be excluded by the store query") + assert.False(t, peer.Status.LoginExpired, "returned peers should not already be marked as login expired") + } + + // Only the non-expired peer with expiration enabled should be returned + require.Len(t, peers, 1) + assert.Equal(t, "notexpired01", peers[0].ID) +} + func TestAccount_GetInactivePeers(t *testing.T) { type test struct { name string @@ -2690,11 +3005,46 @@ func TestAccount_SetJWTGroups(t *testing.T) { account := &types.Account{ Id: "accountID", Peers: map[string]*nbpeer.Peer{ - "peer1": {ID: "peer1", Key: "key1", UserID: "user1", IP: net.IP{1, 1, 1, 1}, DNSLabel: "peer1.domain.test"}, - "peer2": {ID: "peer2", Key: "key2", UserID: "user1", IP: net.IP{2, 2, 2, 2}, DNSLabel: "peer2.domain.test"}, - "peer3": {ID: "peer3", Key: "key3", UserID: "user1", IP: net.IP{3, 3, 3, 3}, DNSLabel: "peer3.domain.test"}, - "peer4": {ID: "peer4", Key: "key4", UserID: "user2", IP: net.IP{4, 4, 4, 4}, DNSLabel: "peer4.domain.test"}, - "peer5": {ID: "peer5", Key: "key5", UserID: "user2", IP: net.IP{5, 5, 5, 5}, DNSLabel: "peer5.domain.test"}, + "peer1": { + ID: "peer1", + Key: "key1", + UserID: "user1", + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + IPv6: netip.MustParseAddr("fd00::1"), + DNSLabel: "peer1.domain.test", + }, + "peer2": { + ID: "peer2", + Key: "key2", + UserID: "user1", + IP: netip.AddrFrom4([4]byte{2, 2, 2, 2}), + IPv6: netip.MustParseAddr("fd00::2"), + DNSLabel: "peer2.domain.test", + }, + "peer3": { + ID: "peer3", + Key: "key3", + UserID: "user1", + IP: netip.AddrFrom4([4]byte{3, 3, 3, 3}), + IPv6: netip.MustParseAddr("fd00::3"), + DNSLabel: "peer3.domain.test", + }, + "peer4": { + ID: "peer4", + Key: "key4", + UserID: "user2", + IP: netip.AddrFrom4([4]byte{4, 4, 4, 4}), + IPv6: netip.MustParseAddr("fd00::4"), + DNSLabel: "peer4.domain.test", + }, + "peer5": { + ID: "peer5", + Key: "key5", + UserID: "user2", + IP: netip.AddrFrom4([4]byte{5, 5, 5, 5}), + IPv6: netip.MustParseAddr("fd00::5"), + DNSLabel: "peer5.domain.test", + }, }, Groups: map[string]*types.Group{ "group1": {ID: "group1", Name: "group1", Issued: types.GroupIssuedAPI, Peers: []string{}}, @@ -2709,7 +3059,7 @@ func TestAccount_SetJWTGroups(t *testing.T) { assert.NoError(t, manager.Store.SaveAccount(context.Background(), account), "unable to save account") t.Run("skip sync for token auth type", func(t *testing.T) { - claims := nbcontext.UserAuth{ + claims := auth.UserAuth{ UserId: "user1", AccountId: "accountID", Groups: []string{"group3"}, @@ -2724,7 +3074,7 @@ func TestAccount_SetJWTGroups(t *testing.T) { }) t.Run("empty jwt groups", func(t *testing.T) { - claims := nbcontext.UserAuth{ + claims := auth.UserAuth{ UserId: "user1", AccountId: "accountID", Groups: []string{}, @@ -2738,7 +3088,7 @@ func TestAccount_SetJWTGroups(t *testing.T) { }) t.Run("jwt match existing api group", func(t *testing.T) { - claims := nbcontext.UserAuth{ + claims := auth.UserAuth{ UserId: "user1", AccountId: "accountID", Groups: []string{"group1"}, @@ -2759,7 +3109,7 @@ func TestAccount_SetJWTGroups(t *testing.T) { account.Users["user1"].AutoGroups = []string{"group1"} assert.NoError(t, manager.Store.SaveUser(context.Background(), account.Users["user1"])) - claims := nbcontext.UserAuth{ + claims := auth.UserAuth{ UserId: "user1", AccountId: "accountID", Groups: []string{"group1"}, @@ -2777,7 +3127,7 @@ func TestAccount_SetJWTGroups(t *testing.T) { }) t.Run("add jwt group", func(t *testing.T) { - claims := nbcontext.UserAuth{ + claims := auth.UserAuth{ UserId: "user1", AccountId: "accountID", Groups: []string{"group1", "group2"}, @@ -2791,7 +3141,7 @@ func TestAccount_SetJWTGroups(t *testing.T) { }) t.Run("existed group not update", func(t *testing.T) { - claims := nbcontext.UserAuth{ + claims := auth.UserAuth{ UserId: "user1", AccountId: "accountID", Groups: []string{"group2"}, @@ -2805,7 +3155,7 @@ func TestAccount_SetJWTGroups(t *testing.T) { }) t.Run("add new group", func(t *testing.T) { - claims := nbcontext.UserAuth{ + claims := auth.UserAuth{ UserId: "user2", AccountId: "accountID", Groups: []string{"group1", "group3"}, @@ -2820,10 +3170,20 @@ func TestAccount_SetJWTGroups(t *testing.T) { user, err := manager.Store.GetUserByUserID(context.Background(), store.LockingStrengthNone, "user2") assert.NoError(t, err, "unable to get user") assert.Len(t, user.AutoGroups, 1, "new group should be added") + + var newJWTGroup *types.Group + for _, g := range groups { + if g.Name == "group3" { + newJWTGroup = g + break + } + } + require.NotNil(t, newJWTGroup, "JIT-created JWT group not found") + assert.NotEqual(t, "", newJWTGroup.PublicID, "JIT-created JWT group must have a non-empty PublicID") }) t.Run("remove all JWT groups when list is empty", func(t *testing.T) { - claims := nbcontext.UserAuth{ + claims := auth.UserAuth{ UserId: "user1", AccountId: "accountID", Groups: []string{}, @@ -2838,7 +3198,7 @@ func TestAccount_SetJWTGroups(t *testing.T) { }) t.Run("remove all JWT groups when claim does not exist", func(t *testing.T) { - claims := nbcontext.UserAuth{ + claims := auth.UserAuth{ UserId: "user2", AccountId: "accountID", Groups: []string{}, @@ -2953,17 +3313,36 @@ func createManager(t testing.TB) (*DefaultAccountManager, *update_channel.PeersU AnyTimes() permissionsManager := permissions.NewManager(store) + peersManager := peers.NewManager(store, permissionsManager) + + proxyManager := proxy.NewMockManager(ctrl) + proxyManager.EXPECT(). + CleanupStale(gomock.Any(), gomock.Any()). + Return(nil). + AnyTimes() ctx := context.Background() - updateManager := update_channel.NewPeersUpdateManager(metrics) - requestBuffer := NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock()) - manager, err := BuildManager(ctx, store, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false) + cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100) if err != nil { return nil, nil, err } + updateManager := update_channel.NewPeersUpdateManager(metrics) + requestBuffer := NewAccountRequestBuffer(ctx, store) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}) + manager, err := BuildManager(ctx, &config.Config{}, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + if err != nil { + return nil, nil, err + } + + proxyGrpcServer := nbgrpc.NewProxyServiceServer(nil, nil, nil, nbgrpc.ProxyOIDCConfig{}, peersManager, nil, nil, proxyManager, nil) + proxyController, err := proxymanager.NewGRPCController(proxyGrpcServer, noop.Meter{}) + if err != nil { + return nil, nil, err + } + manager.SetServiceManager(reverseproxymanager.NewManager(store, manager, permissionsManager, proxyController, proxyManager, nil)) + return manager, updateManager, nil } @@ -3018,7 +3397,7 @@ func setupNetworkMapTest(t *testing.T) (*DefaultAccountManager, *update_channel. } expectedPeerKey := key.PublicKey().String() - peer, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: expectedPeerKey, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, Status: &nbpeer.PeerStatus{ @@ -3040,6 +3419,26 @@ func setupNetworkMapTest(t *testing.T) (*DefaultAccountManager, *update_channel. return manager, updateManager, account, peer1, peer2, peer3 } +// peerUpdateTimeout bounds how long peerShouldReceiveUpdate and its outer +// wrappers wait for an expected update message. Sized for slow CI runners +// (MySQL, FreeBSD, loaded sqlite) where the channel publish can take +// seconds. Only runs down on failure; passing tests return immediately +// when the channel delivers. +const peerUpdateTimeout = 5 * time.Second + +func drainPeerUpdates(ch <-chan *network_map.UpdateMessage) { + for { + select { + case _, ok := <-ch: + if !ok { + return + } + case <-time.After(200 * time.Millisecond): + return + } + } +} + func peerShouldNotReceiveUpdate(t *testing.T, updateMessage <-chan *network_map.UpdateMessage) { t.Helper() select { @@ -3058,7 +3457,7 @@ func peerShouldReceiveUpdate(t *testing.T, updateMessage <-chan *network_map.Upd if msg == nil { t.Errorf("Received nil update message, expected valid message") } - case <-time.After(500 * time.Millisecond): + case <-time.After(peerUpdateTimeout): t.Error("Timed out waiting for update message") } } @@ -3104,7 +3503,7 @@ func BenchmarkSyncAndMarkPeer(b *testing.B) { b.ResetTimer() start := time.Now() for i := 0; i < b.N; i++ { - _, _, _, _, err := manager.SyncAndMarkPeer(context.Background(), account.Id, account.Peers["peer-1"].Key, nbpeer.PeerSystemMeta{Hostname: strconv.Itoa(i)}, net.IP{1, 1, 1, 1}) + _, _, _, _, err := manager.SyncAndMarkPeer(context.Background(), account.Id, account.Peers["peer-1"].Key, nbpeer.PeerSystemMeta{Hostname: strconv.Itoa(i)}, net.IP{1, 1, 1, 1}, time.Now().UTC()) assert.NoError(b, err) } @@ -3166,7 +3565,7 @@ func BenchmarkLoginPeer_ExistingPeer(b *testing.B) { b.ResetTimer() start := time.Now() for i := 0; i < b.N; i++ { - _, _, _, err := manager.LoginPeer(context.Background(), types.PeerLogin{ + _, _, _, _, err := manager.LoginPeer(context.Background(), types.PeerLogin{ WireGuardPubKey: account.Peers["peer-1"].Key, SSHKey: "someKey", Meta: nbpeer.PeerSystemMeta{Hostname: strconv.Itoa(i)}, @@ -3235,7 +3634,7 @@ func BenchmarkLoginPeer_NewPeer(b *testing.B) { b.ResetTimer() start := time.Now() for i := 0; i < b.N; i++ { - _, _, _, err := manager.LoginPeer(context.Background(), types.PeerLogin{ + _, _, _, _, err := manager.LoginPeer(context.Background(), types.PeerLogin{ WireGuardPubKey: "some-new-key" + strconv.Itoa(i), SSHKey: "someKey", Meta: nbpeer.PeerSystemMeta{Hostname: strconv.Itoa(i)}, @@ -3370,7 +3769,7 @@ func TestDefaultAccountManager_IsCacheCold(t *testing.T) { t.Run("memory cache", func(t *testing.T) { t.Run("should always return true", func(t *testing.T) { - cacheStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond) + cacheStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100) require.NoError(t, err) cold, err := manager.isCacheCold(context.Background(), cacheStore) @@ -3385,7 +3784,7 @@ func TestDefaultAccountManager_IsCacheCold(t *testing.T) { t.Cleanup(cleanup) t.Setenv(cache.RedisStoreEnvVar, redisURL) - cacheStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond) + cacheStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100) require.NoError(t, err) t.Run("should return true when no account exists", func(t *testing.T) { @@ -3394,7 +3793,7 @@ func TestDefaultAccountManager_IsCacheCold(t *testing.T) { assert.True(t, cold) }) - account, err := manager.GetOrCreateAccountByUser(context.Background(), userID, "") + account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID}) require.NoError(t, err) t.Run("should return true when account is not found in cache", func(t *testing.T) { @@ -3422,19 +3821,35 @@ func TestPropagateUserGroupMemberships(t *testing.T) { initiatorId := "test-user" domain := "example.com" - account, err := manager.GetOrCreateAccountByUser(ctx, initiatorId, domain) + account, err := manager.GetOrCreateAccountByUser(ctx, auth.UserAuth{UserId: initiatorId, Domain: domain}) require.NoError(t, err) - peer1 := &nbpeer.Peer{ID: "peer1", AccountID: account.Id, UserID: initiatorId, IP: net.IP{1, 1, 1, 1}, DNSLabel: "peer1.domain.test"} + peer1 := &nbpeer.Peer{ + ID: "peer1", + AccountID: account.Id, + Key: "key1", + UserID: initiatorId, + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + IPv6: netip.MustParseAddr("fd00::1"), + DNSLabel: "peer1.domain.test", + } err = manager.Store.AddPeerToAccount(ctx, peer1) require.NoError(t, err) - peer2 := &nbpeer.Peer{ID: "peer2", AccountID: account.Id, UserID: initiatorId, IP: net.IP{2, 2, 2, 2}, DNSLabel: "peer2.domain.test"} + peer2 := &nbpeer.Peer{ + ID: "peer2", + AccountID: account.Id, + Key: "key2", + UserID: initiatorId, + IP: netip.AddrFrom4([4]byte{2, 2, 2, 2}), + IPv6: netip.MustParseAddr("fd00::2"), + DNSLabel: "peer2.domain.test", + } err = manager.Store.AddPeerToAccount(ctx, peer2) require.NoError(t, err) t.Run("should skip propagation when the user has no groups", func(t *testing.T) { - groupsUpdated, groupChangesAffectPeers, err := propagateUserGroupMemberships(ctx, manager.Store, account.Id) + groupsUpdated, groupChangesAffectPeers, err := manager.propagateUserGroupMemberships(ctx, manager.Store, account.Id) require.NoError(t, err) assert.False(t, groupsUpdated) assert.False(t, groupChangesAffectPeers) @@ -3450,7 +3865,7 @@ func TestPropagateUserGroupMemberships(t *testing.T) { user.AutoGroups = append(user.AutoGroups, group1.ID) require.NoError(t, manager.Store.SaveUser(ctx, user)) - groupsUpdated, groupChangesAffectPeers, err := propagateUserGroupMemberships(ctx, manager.Store, account.Id) + groupsUpdated, groupChangesAffectPeers, err := manager.propagateUserGroupMemberships(ctx, manager.Store, account.Id) require.NoError(t, err) assert.True(t, groupsUpdated) assert.False(t, groupChangesAffectPeers) @@ -3488,7 +3903,7 @@ func TestPropagateUserGroupMemberships(t *testing.T) { }, true) require.NoError(t, err) - groupsUpdated, groupChangesAffectPeers, err := propagateUserGroupMemberships(ctx, manager.Store, account.Id) + groupsUpdated, groupChangesAffectPeers, err := manager.propagateUserGroupMemberships(ctx, manager.Store, account.Id) require.NoError(t, err) assert.True(t, groupsUpdated) assert.True(t, groupChangesAffectPeers) @@ -3503,7 +3918,7 @@ func TestPropagateUserGroupMemberships(t *testing.T) { }) t.Run("should not update membership or account peers when no changes", func(t *testing.T) { - groupsUpdated, groupChangesAffectPeers, err := propagateUserGroupMemberships(ctx, manager.Store, account.Id) + groupsUpdated, groupChangesAffectPeers, err := manager.propagateUserGroupMemberships(ctx, manager.Store, account.Id) require.NoError(t, err) assert.False(t, groupsUpdated) assert.False(t, groupChangesAffectPeers) @@ -3516,7 +3931,7 @@ func TestPropagateUserGroupMemberships(t *testing.T) { user.AutoGroups = []string{"group1"} require.NoError(t, manager.Store.SaveUser(ctx, user)) - groupsUpdated, groupChangesAffectPeers, err := propagateUserGroupMemberships(ctx, manager.Store, account.Id) + groupsUpdated, groupChangesAffectPeers, err := manager.propagateUserGroupMemberships(ctx, manager.Store, account.Id) require.NoError(t, err) assert.False(t, groupsUpdated) assert.False(t, groupChangesAffectPeers) @@ -3535,7 +3950,7 @@ func TestDefaultAccountManager_GetAccountOnboarding(t *testing.T) { manager, _, err := createManager(t) require.NoError(t, err) - account, err := manager.GetOrCreateAccountByUser(context.Background(), userID, "") + account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID}) require.NoError(t, err) t.Run("should return account onboarding when onboarding exist", func(t *testing.T) { @@ -3567,7 +3982,7 @@ func TestDefaultAccountManager_UpdateAccountOnboarding(t *testing.T) { manager, _, err := createManager(t) require.NoError(t, err) - account, err := manager.GetOrCreateAccountByUser(context.Background(), userID, "") + account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID}) require.NoError(t, err) onboarding := &types.AccountOnboarding{ @@ -3606,7 +4021,7 @@ func TestDefaultAccountManager_UpdatePeerIP(t *testing.T) { manager, _, err := createManager(t) require.NoError(t, err, "unable to create account manager") - accountID, err := manager.GetAccountIDByUserID(context.Background(), userID, "") + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) require.NoError(t, err, "unable to create an account") key1, err := wgtypes.GenerateKey() @@ -3614,13 +4029,13 @@ func TestDefaultAccountManager_UpdatePeerIP(t *testing.T) { key2, err := wgtypes.GenerateKey() require.NoError(t, err, "unable to generate WireGuard key") - peer1, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + peer1, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: key1.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"}, }, false) require.NoError(t, err, "unable to add peer1") - peer2, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + peer2, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ Key: key2.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"}, }, false) @@ -3630,11 +4045,10 @@ func TestDefaultAccountManager_UpdatePeerIP(t *testing.T) { account, err := manager.Store.GetAccount(context.Background(), accountID) require.NoError(t, err, "unable to get account") - newIP, err := types.AllocatePeerIP(account.Network.Net, []net.IP{peer1.IP, peer2.IP}) + newIP, err := types.AllocatePeerIP(netip.MustParsePrefix(account.Network.Net.String()), []netip.Addr{peer1.IP, peer2.IP}) require.NoError(t, err, "unable to allocate new IP") - newAddr := netip.MustParseAddr(newIP.String()) - err = manager.UpdatePeerIP(context.Background(), accountID, userID, peer1.ID, newAddr) + err = manager.UpdatePeerIP(context.Background(), accountID, userID, peer1.ID, newIP) require.NoError(t, err, "unable to update peer IP") updatedPeer, err := manager.GetPeer(context.Background(), accountID, peer1.ID, userID) @@ -3677,7 +4091,7 @@ func TestAddNewUserToDomainAccountWithApproval(t *testing.T) { // Create a domain-based account with user approval enabled existingAccountID := "existing-account" - account := newAccountWithId(context.Background(), existingAccountID, "owner-user", "example.com", false) + account := newAccountWithId(context.Background(), existingAccountID, "owner-user", "example.com", "", "", false) account.Settings.Extra = &types.ExtraSettings{ UserApprovalRequired: true, } @@ -3692,7 +4106,7 @@ func TestAddNewUserToDomainAccountWithApproval(t *testing.T) { // Test adding new user to existing account with approval required newUserID := "new-user-id" - userAuth := nbcontext.UserAuth{ + userAuth := auth.UserAuth{ UserId: newUserID, Domain: "example.com", DomainCategory: types.PrivateCategory, @@ -3722,7 +4136,7 @@ func TestAddNewUserToDomainAccountWithoutApproval(t *testing.T) { } // Create a domain-based account without user approval - ownerUserAuth := nbcontext.UserAuth{ + ownerUserAuth := auth.UserAuth{ UserId: "owner-user", Domain: "example.com", DomainCategory: types.PrivateCategory, @@ -3741,7 +4155,7 @@ func TestAddNewUserToDomainAccountWithoutApproval(t *testing.T) { // Test adding new user to existing account without approval required newUserID := "new-user-id" - userAuth := nbcontext.UserAuth{ + userAuth := auth.UserAuth{ UserId: newUserID, Domain: "example.com", DomainCategory: types.PrivateCategory, @@ -3758,3 +4172,342 @@ func TestAddNewUserToDomainAccountWithoutApproval(t *testing.T) { assert.False(t, user.PendingApproval, "User should not be pending approval") assert.Equal(t, existingAccountID, user.AccountID) } + +// TestDefaultAccountManager_UpdateAccountSettings_NetworkRangeChange verifies that +// changing NetworkRange via UpdateAccountSettings does not deadlock. +// The deadlock occurs because ReloadAllServicesForAccount is called inside a DB +// transaction but uses the main store connection, which blocks on the transaction lock. +func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangeChange(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err) + + ctx := context.Background() + + // Use a channel to detect if the call completes or hangs + done := make(chan error, 1) + go func() { + _, err := manager.UpdateAccountSettings(ctx, accountID, userID, &types.Settings{ + PeerLoginExpiration: time.Hour, + PeerLoginExpirationEnabled: true, + NetworkRange: netip.MustParsePrefix("10.100.0.0/16"), + Extra: &types.ExtraSettings{}, + }) + done <- err + }() + + select { + case err := <-done: + require.NoError(t, err, "UpdateAccountSettings should complete without error") + case <-time.After(10 * time.Second): + t.Fatal("UpdateAccountSettings deadlocked when changing NetworkRange") + } +} + +// TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved guards against +// peer IP reallocation when a settings update carries the network range that is already +// in use. Legacy accounts have Settings.NetworkRange unset in the DB while network.Net +// holds the actual allocated overlay; the dashboard backfills the GET response from +// network.Net and echoes the value back on PUT, so the diff must be against the +// effective range to avoid renumbering every peer on an unrelated settings change. +func TestDefaultAccountManager_UpdateAccountSettings_NetworkRangePreserved(t *testing.T) { + manager, _, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + + settings, err := manager.Store.GetAccountSettings(ctx, store.LockingStrengthNone, account.Id) + require.NoError(t, err) + require.False(t, settings.NetworkRange.IsValid(), "precondition: new accounts leave Settings.NetworkRange unset") + + network, err := manager.Store.GetAccountNetwork(ctx, store.LockingStrengthNone, account.Id) + require.NoError(t, err) + require.NotNil(t, network.Net.IP, "precondition: network.Net should be allocated") + addr, ok := netip.AddrFromSlice(network.Net.IP) + require.True(t, ok) + ones, _ := network.Net.Mask.Size() + effective := netip.PrefixFrom(addr.Unmap(), ones) + require.True(t, effective.IsValid()) + + before := map[string]netip.Addr{peer1.ID: peer1.IP, peer2.ID: peer2.IP, peer3.ID: peer3.IP} + + // Round-trip the effective range as if the dashboard echoed back the GET-backfilled value. + _, err = manager.UpdateAccountSettings(ctx, account.Id, userID, &types.Settings{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: types.DefaultPeerLoginExpiration, + NetworkRange: effective, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err) + + peers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "") + require.NoError(t, err) + require.Len(t, peers, len(before)) + for _, p := range peers { + assert.Equal(t, before[p.ID], p.IP, "peer %s IP should not change when range matches effective", p.ID) + } + + // Carrying the same range with host bits set must also be a no-op once canonicalized. + hostBitsForm := netip.PrefixFrom(peer1.IP, ones) + require.NotEqual(t, effective, hostBitsForm, "precondition: host-bit form should differ before masking") + _, err = manager.UpdateAccountSettings(ctx, account.Id, userID, &types.Settings{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: types.DefaultPeerLoginExpiration, + NetworkRange: hostBitsForm, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err) + + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "") + require.NoError(t, err) + for _, p := range peers { + assert.Equal(t, before[p.ID], p.IP, "peer %s IP should not change for host-bit-set equivalent range", p.ID) + } + + // Omitting NetworkRange (invalid prefix) must also be a no-op. + _, err = manager.UpdateAccountSettings(ctx, account.Id, userID, &types.Settings{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: types.DefaultPeerLoginExpiration, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err) + + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "") + require.NoError(t, err) + for _, p := range peers { + assert.Equal(t, before[p.ID], p.IP, "peer %s IP should not change when NetworkRange omitted", p.ID) + } + + // Sanity: an actually different range still triggers reallocation. + newRange := netip.MustParsePrefix("100.60.0.0/16") + _, err = manager.UpdateAccountSettings(ctx, account.Id, userID, &types.Settings{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: types.DefaultPeerLoginExpiration, + NetworkRange: newRange, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err) + + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, account.Id, "", "") + require.NoError(t, err) + for _, p := range peers { + assert.True(t, newRange.Contains(p.IP), "peer %s should be in new range %s, got %s", p.ID, newRange, p.IP) + assert.NotEqual(t, before[p.ID], p.IP, "peer %s IP should change on real range update", p.ID) + } +} + +func TestDefaultAccountManager_UpdateAccountSettings_IPv6EnabledGroups(t *testing.T) { + manager, _, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + // New accounts default to All group in IPv6EnabledGroups, so all 3 peers should have IPv6. + settings, err := manager.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + require.NotEmpty(t, settings.IPv6EnabledGroups, "new account should have IPv6 enabled for All group") + + peers, err := manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + require.NoError(t, err) + for _, p := range peers { + assert.True(t, p.IPv6.IsValid(), "peer %s should have IPv6 with All group enabled", p.ID) + } + + // Create a group with only peer1 and peer2. + partialGroup := &types.Group{ + ID: "ipv6-partial-group", + AccountID: accountID, + Name: "IPv6Partial", + } + err = manager.Store.CreateGroup(ctx, partialGroup) + require.NoError(t, err) + require.NoError(t, manager.Store.AddPeerToGroup(ctx, accountID, peer1.ID, partialGroup.ID)) + require.NoError(t, manager.Store.AddPeerToGroup(ctx, accountID, peer2.ID, partialGroup.ID)) + + // Switch IPv6EnabledGroups to only the partial group. + updatedSettings, err := manager.UpdateAccountSettings(ctx, accountID, userID, &types.Settings{ + PeerLoginExpiration: types.DefaultPeerLoginExpiration, + PeerLoginExpirationEnabled: true, + IPv6EnabledGroups: []string{partialGroup.ID}, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err) + assert.Equal(t, []string{partialGroup.ID}, updatedSettings.IPv6EnabledGroups) + + // peer1 and peer2 should have IPv6; peer3 should not. + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + require.NoError(t, err) + peerMap := make(map[string]*nbpeer.Peer, len(peers)) + for _, p := range peers { + peerMap[p.ID] = p + } + assert.True(t, peerMap[peer1.ID].IPv6.IsValid(), "peer1 in partial group should keep IPv6") + assert.True(t, peerMap[peer2.ID].IPv6.IsValid(), "peer2 in partial group should keep IPv6") + assert.False(t, peerMap[peer3.ID].IPv6.IsValid(), "peer3 not in partial group should lose IPv6") + + // Clearing all groups disables IPv6 for everyone. + updatedSettings, err = manager.UpdateAccountSettings(ctx, accountID, userID, &types.Settings{ + PeerLoginExpiration: types.DefaultPeerLoginExpiration, + PeerLoginExpirationEnabled: true, + IPv6EnabledGroups: []string{}, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err) + assert.Empty(t, updatedSettings.IPv6EnabledGroups) + + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + require.NoError(t, err) + for _, p := range peers { + assert.False(t, p.IPv6.IsValid(), "peer %s should have no IPv6 when groups cleared", p.ID) + } + + // Re-enabling with the partial group should allocate IPv6 only for peer1 and peer2. + _, err = manager.UpdateAccountSettings(ctx, accountID, userID, &types.Settings{ + PeerLoginExpiration: types.DefaultPeerLoginExpiration, + PeerLoginExpirationEnabled: true, + IPv6EnabledGroups: []string{partialGroup.ID}, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err) + + peers, err = manager.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") + require.NoError(t, err) + peerMap = make(map[string]*nbpeer.Peer, len(peers)) + for _, p := range peers { + peerMap[p.ID] = p + } + assert.True(t, peerMap[peer1.ID].IPv6.IsValid(), "peer1 should get IPv6 back") + assert.True(t, peerMap[peer2.ID].IPv6.IsValid(), "peer2 should get IPv6 back") + assert.False(t, peerMap[peer3.ID].IPv6.IsValid(), "peer3 still excluded") + + // No-op update with the same groups should not cause errors. + _, err = manager.UpdateAccountSettings(ctx, accountID, userID, &types.Settings{ + PeerLoginExpiration: types.DefaultPeerLoginExpiration, + PeerLoginExpirationEnabled: true, + IPv6EnabledGroups: []string{partialGroup.ID}, + Extra: &types.ExtraSettings{}, + }) + require.NoError(t, err) + + // Setting a nonexistent group ID should fail. + _, err = manager.UpdateAccountSettings(ctx, accountID, userID, &types.Settings{ + PeerLoginExpiration: types.DefaultPeerLoginExpiration, + PeerLoginExpirationEnabled: true, + IPv6EnabledGroups: []string{"nonexistent-group-id"}, + Extra: &types.ExtraSettings{}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not exist") +} + +func TestUpdateUserAuthWithSingleMode(t *testing.T) { + t.Run("sets defaults and overrides domain from store", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT(). + GetAnyAccountID(gomock.Any()). + Return("account-1", nil) + mockStore.EXPECT(). + GetAccountDomainAndCategory(gomock.Any(), store.LockingStrengthNone, "account-1"). + Return("real-domain.com", "private", nil) + + am := &DefaultAccountManager{ + Store: mockStore, + singleAccountModeDomain: "fallback.com", + } + + userAuth := &auth.UserAuth{} + err := am.updateUserAuthWithSingleMode(context.Background(), userAuth) + require.NoError(t, err) + assert.Equal(t, "real-domain.com", userAuth.Domain) + assert.Equal(t, types.PrivateCategory, userAuth.DomainCategory) + }) + + t.Run("falls back to singleAccountModeDomain when account ID is empty", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT(). + GetAnyAccountID(gomock.Any()). + Return("", nil) + + am := &DefaultAccountManager{ + Store: mockStore, + singleAccountModeDomain: "fallback.com", + } + + userAuth := &auth.UserAuth{} + err := am.updateUserAuthWithSingleMode(context.Background(), userAuth) + require.NoError(t, err) + assert.Equal(t, "fallback.com", userAuth.Domain) + assert.Equal(t, types.PrivateCategory, userAuth.DomainCategory) + }) + + t.Run("falls back to singleAccountModeDomain on NotFound error", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT(). + GetAnyAccountID(gomock.Any()). + Return("", status.Errorf(status.NotFound, "no accounts")) + + am := &DefaultAccountManager{ + Store: mockStore, + singleAccountModeDomain: "fallback.com", + } + + userAuth := &auth.UserAuth{} + err := am.updateUserAuthWithSingleMode(context.Background(), userAuth) + require.NoError(t, err) + assert.Equal(t, "fallback.com", userAuth.Domain) + assert.Equal(t, types.PrivateCategory, userAuth.DomainCategory) + }) + + t.Run("propagates non-NotFound error from GetAnyAccountID", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT(). + GetAnyAccountID(gomock.Any()). + Return("", status.Errorf(status.Internal, "db down")) + + am := &DefaultAccountManager{ + Store: mockStore, + singleAccountModeDomain: "fallback.com", + } + + userAuth := &auth.UserAuth{} + err := am.updateUserAuthWithSingleMode(context.Background(), userAuth) + require.Error(t, err) + assert.Contains(t, err.Error(), "db down") + // Defaults should still be set before error path + assert.Equal(t, types.PrivateCategory, userAuth.DomainCategory) + }) + + t.Run("propagates error from GetAccountDomainAndCategory", func(t *testing.T) { + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT(). + GetAnyAccountID(gomock.Any()). + Return("account-1", nil) + mockStore.EXPECT(). + GetAccountDomainAndCategory(gomock.Any(), store.LockingStrengthNone, "account-1"). + Return("", "", status.Errorf(status.Internal, "query failed")) + + am := &DefaultAccountManager{ + Store: mockStore, + singleAccountModeDomain: "fallback.com", + } + + userAuth := &auth.UserAuth{} + err := am.updateUserAuthWithSingleMode(context.Background(), userAuth) + require.Error(t, err) + assert.Contains(t, err.Error(), "query failed") + }) +} diff --git a/management/server/activity/codes.go b/management/server/activity/codes.go index 5c5989f84..eaa638fac 100644 --- a/management/server/activity/codes.go +++ b/management/server/activity/codes.go @@ -179,6 +179,107 @@ const ( PeerIPUpdated Activity = 88 UserApproved Activity = 89 UserRejected Activity = 90 + UserCreated Activity = 91 + + AccountAutoUpdateVersionUpdated Activity = 92 + + IdentityProviderCreated Activity = 93 + IdentityProviderUpdated Activity = 94 + IdentityProviderDeleted Activity = 95 + + DNSZoneCreated Activity = 96 + DNSZoneUpdated Activity = 97 + DNSZoneDeleted Activity = 98 + + DNSRecordCreated Activity = 99 + DNSRecordUpdated Activity = 100 + DNSRecordDeleted Activity = 101 + + JobCreatedByUser Activity = 102 + + UserPasswordChanged Activity = 103 + + UserInviteLinkCreated Activity = 104 + UserInviteLinkAccepted Activity = 105 + UserInviteLinkRegenerated Activity = 106 + UserInviteLinkDeleted Activity = 107 + + ServiceCreated Activity = 108 + ServiceUpdated Activity = 109 + ServiceDeleted Activity = 110 + + // PeerServiceExposed indicates that a peer exposed a service via the reverse proxy + PeerServiceExposed Activity = 111 + // PeerServiceUnexposed indicates that a peer-exposed service was removed + PeerServiceUnexposed Activity = 112 + // PeerServiceExposeExpired indicates that a peer-exposed service was removed due to TTL expiration + PeerServiceExposeExpired Activity = 113 + + // AccountPeerExposeEnabled indicates that a user enabled peer expose for the account + AccountPeerExposeEnabled Activity = 114 + // AccountPeerExposeDisabled indicates that a user disabled peer expose for the account + AccountPeerExposeDisabled Activity = 115 + + // AccountAutoUpdateAlwaysEnabled indicates that a user enabled always auto-update for the account + AccountAutoUpdateAlwaysEnabled Activity = 116 + // AccountAutoUpdateAlwaysDisabled indicates that a user disabled always auto-update for the account + AccountAutoUpdateAlwaysDisabled Activity = 117 + + // DomainAdded indicates that a user added a custom domain + DomainAdded Activity = 118 + // DomainDeleted indicates that a user deleted a custom domain + DomainDeleted Activity = 119 + // DomainValidated indicates that a custom domain was validated + DomainValidated Activity = 120 + // AccountIPv6Enabled indicates that a user enabled IPv6 overlay for the account + AccountIPv6Enabled Activity = 121 + // AccountIPv6Disabled indicates that a user disabled IPv6 overlay for the account + AccountIPv6Disabled Activity = 122 + + // AccountLocalMfaEnabled indicates that a user enabled TOTP MFA for local users + AccountLocalMfaEnabled Activity = 123 + // AccountLocalMfaDisabled indicates that a user disabled TOTP MFA for local users + AccountLocalMfaDisabled Activity = 124 + // UserExtendedPeerSession indicates that a user refreshed their peer's + // SSO session deadline via ExtendAuthSession without re-establishing the + // tunnel. Distinct from UserLoggedInPeer (full interactive login). + UserExtendedPeerSession Activity = 125 + + // AgentNetworkProviderCreated indicates that a user created an Agent Network provider + AgentNetworkProviderCreated Activity = 126 + // AgentNetworkProviderUpdated indicates that a user updated an Agent Network provider + AgentNetworkProviderUpdated Activity = 127 + // AgentNetworkProviderDeleted indicates that a user deleted an Agent Network provider + AgentNetworkProviderDeleted Activity = 128 + + // AgentNetworkPolicyCreated indicates that a user created an Agent Network policy + AgentNetworkPolicyCreated Activity = 129 + // AgentNetworkPolicyUpdated indicates that a user updated an Agent Network policy + AgentNetworkPolicyUpdated Activity = 130 + // AgentNetworkPolicyDeleted indicates that a user deleted an Agent Network policy + AgentNetworkPolicyDeleted Activity = 131 + + // AgentNetworkGuardrailCreated indicates that a user created an Agent Network guardrail + AgentNetworkGuardrailCreated Activity = 132 + // AgentNetworkGuardrailUpdated indicates that a user updated an Agent Network guardrail + AgentNetworkGuardrailUpdated Activity = 133 + // AgentNetworkGuardrailDeleted indicates that a user deleted an Agent Network guardrail + AgentNetworkGuardrailDeleted Activity = 134 + + // AgentNetworkBudgetRuleCreated indicates that a user created an Agent Network budget rule + AgentNetworkBudgetRuleCreated Activity = 135 + // AgentNetworkBudgetRuleUpdated indicates that a user updated an Agent Network budget rule + AgentNetworkBudgetRuleUpdated Activity = 136 + // AgentNetworkBudgetRuleDeleted indicates that a user deleted an Agent Network budget rule + AgentNetworkBudgetRuleDeleted Activity = 137 + + // AgentNetworkSettingsUpdated indicates that a user updated Agent Network account settings + AgentNetworkSettingsUpdated Activity = 139 + + // AccountMetricsPushEnabled indicates that a user enabled metrics push for the account + AccountMetricsPushEnabled Activity = 140 + // AccountMetricsPushDisabled indicates that a user disabled metrics push for the account + AccountMetricsPushDisabled Activity = 141 AccountDeleted Activity = 99999 ) @@ -286,8 +387,79 @@ var activityMap = map[Activity]Code{ AccountNetworkRangeUpdated: {"Account network range updated", "account.network.range.update"}, PeerIPUpdated: {"Peer IP updated", "peer.ip.update"}, - UserApproved: {"User approved", "user.approve"}, - UserRejected: {"User rejected", "user.reject"}, + + UserApproved: {"User approved", "user.approve"}, + UserRejected: {"User rejected", "user.reject"}, + UserCreated: {"User created", "user.create"}, + + AccountAutoUpdateVersionUpdated: {"Account AutoUpdate Version updated", "account.settings.auto.version.update"}, + AccountAutoUpdateAlwaysEnabled: {"Account auto-update always enabled", "account.setting.auto.update.always.enable"}, + AccountAutoUpdateAlwaysDisabled: {"Account auto-update always disabled", "account.setting.auto.update.always.disable"}, + + AccountIPv6Enabled: {"Account IPv6 overlay enabled", "account.setting.ipv6.enable"}, + AccountIPv6Disabled: {"Account IPv6 overlay disabled", "account.setting.ipv6.disable"}, + + IdentityProviderCreated: {"Identity provider created", "identityprovider.create"}, + IdentityProviderUpdated: {"Identity provider updated", "identityprovider.update"}, + IdentityProviderDeleted: {"Identity provider deleted", "identityprovider.delete"}, + + DNSZoneCreated: {"DNS zone created", "dns.zone.create"}, + DNSZoneUpdated: {"DNS zone updated", "dns.zone.update"}, + DNSZoneDeleted: {"DNS zone deleted", "dns.zone.delete"}, + + DNSRecordCreated: {"DNS zone record created", "dns.zone.record.create"}, + DNSRecordUpdated: {"DNS zone record updated", "dns.zone.record.update"}, + DNSRecordDeleted: {"DNS zone record deleted", "dns.zone.record.delete"}, + + JobCreatedByUser: {"Create Job for peer", "peer.job.create"}, + + UserPasswordChanged: {"User password changed", "user.password.change"}, + + UserInviteLinkCreated: {"User invite link created", "user.invite.link.create"}, + UserInviteLinkAccepted: {"User invite link accepted", "user.invite.link.accept"}, + UserInviteLinkRegenerated: {"User invite link regenerated", "user.invite.link.regenerate"}, + UserInviteLinkDeleted: {"User invite link deleted", "user.invite.link.delete"}, + + ServiceCreated: {"Service created", "service.create"}, + ServiceUpdated: {"Service updated", "service.update"}, + ServiceDeleted: {"Service deleted", "service.delete"}, + + PeerServiceExposed: {"Peer exposed service", "service.peer.expose"}, + PeerServiceUnexposed: {"Peer unexposed service", "service.peer.unexpose"}, + PeerServiceExposeExpired: {"Peer exposed service expired", "service.peer.expose.expire"}, + + AccountPeerExposeEnabled: {"Account peer expose enabled", "account.setting.peer.expose.enable"}, + AccountPeerExposeDisabled: {"Account peer expose disabled", "account.setting.peer.expose.disable"}, + + AccountLocalMfaEnabled: {"Account local MFA enabled", "account.setting.local.mfa.enable"}, + AccountLocalMfaDisabled: {"Account local MFA disabled", "account.setting.local.mfa.disable"}, + + UserExtendedPeerSession: {"User extended peer session", "user.peer.session.extend"}, + + AgentNetworkProviderCreated: {"Agent Network provider created", "agent_network.provider.create"}, + AgentNetworkProviderUpdated: {"Agent Network provider updated", "agent_network.provider.update"}, + AgentNetworkProviderDeleted: {"Agent Network provider deleted", "agent_network.provider.delete"}, + + AgentNetworkPolicyCreated: {"Agent Network policy created", "agent_network.policy.create"}, + AgentNetworkPolicyUpdated: {"Agent Network policy updated", "agent_network.policy.update"}, + AgentNetworkPolicyDeleted: {"Agent Network policy deleted", "agent_network.policy.delete"}, + + AgentNetworkGuardrailCreated: {"Agent Network guardrail created", "agent_network.guardrail.create"}, + AgentNetworkGuardrailUpdated: {"Agent Network guardrail updated", "agent_network.guardrail.update"}, + AgentNetworkGuardrailDeleted: {"Agent Network guardrail deleted", "agent_network.guardrail.delete"}, + + AgentNetworkBudgetRuleCreated: {"Agent Network budget rule created", "agent_network.budget_rule.create"}, + AgentNetworkBudgetRuleUpdated: {"Agent Network budget rule updated", "agent_network.budget_rule.update"}, + AgentNetworkBudgetRuleDeleted: {"Agent Network budget rule deleted", "agent_network.budget_rule.delete"}, + + AgentNetworkSettingsUpdated: {"Agent Network settings updated", "agent_network.settings.update"}, + + AccountMetricsPushEnabled: {"Account metrics push enabled", "account.setting.metrics.push.enable"}, + AccountMetricsPushDisabled: {"Account metrics push disabled", "account.setting.metrics.push.disable"}, + + DomainAdded: {"Domain added", "domain.add"}, + DomainDeleted: {"Domain deleted", "domain.delete"}, + DomainValidated: {"Domain validated", "domain.validate"}, } // StringCode returns a string code of the activity diff --git a/management/server/activity/store/crypt.go b/management/server/activity/store/crypt.go deleted file mode 100644 index ce97347d4..000000000 --- a/management/server/activity/store/crypt.go +++ /dev/null @@ -1,136 +0,0 @@ -package store - -import ( - "bytes" - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "encoding/base64" - "errors" -) - -var iv = []byte{10, 22, 13, 79, 05, 8, 52, 91, 87, 98, 88, 98, 35, 25, 13, 05} - -type FieldEncrypt struct { - block cipher.Block - gcm cipher.AEAD -} - -func GenerateKey() (string, error) { - key := make([]byte, 32) - _, err := rand.Read(key) - if err != nil { - return "", err - } - readableKey := base64.StdEncoding.EncodeToString(key) - return readableKey, nil -} - -func NewFieldEncrypt(key string) (*FieldEncrypt, error) { - binKey, err := base64.StdEncoding.DecodeString(key) - if err != nil { - return nil, err - } - - block, err := aes.NewCipher(binKey) - if err != nil { - return nil, err - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, err - } - - ec := &FieldEncrypt{ - block: block, - gcm: gcm, - } - - return ec, nil -} - -func (ec *FieldEncrypt) LegacyEncrypt(payload string) string { - plainText := pkcs5Padding([]byte(payload)) - cipherText := make([]byte, len(plainText)) - cbc := cipher.NewCBCEncrypter(ec.block, iv) - cbc.CryptBlocks(cipherText, plainText) - return base64.StdEncoding.EncodeToString(cipherText) -} - -// Encrypt encrypts plaintext using AES-GCM -func (ec *FieldEncrypt) Encrypt(payload string) (string, error) { - plaintext := []byte(payload) - nonceSize := ec.gcm.NonceSize() - - nonce := make([]byte, nonceSize, len(plaintext)+nonceSize+ec.gcm.Overhead()) - if _, err := rand.Read(nonce); err != nil { - return "", err - } - - ciphertext := ec.gcm.Seal(nonce, nonce, plaintext, nil) - - return base64.StdEncoding.EncodeToString(ciphertext), nil -} - -func (ec *FieldEncrypt) LegacyDecrypt(data string) (string, error) { - cipherText, err := base64.StdEncoding.DecodeString(data) - if err != nil { - return "", err - } - cbc := cipher.NewCBCDecrypter(ec.block, iv) - cbc.CryptBlocks(cipherText, cipherText) - payload, err := pkcs5UnPadding(cipherText) - if err != nil { - return "", err - } - - return string(payload), nil -} - -// Decrypt decrypts ciphertext using AES-GCM -func (ec *FieldEncrypt) Decrypt(data string) (string, error) { - cipherText, err := base64.StdEncoding.DecodeString(data) - if err != nil { - return "", err - } - - nonceSize := ec.gcm.NonceSize() - if len(cipherText) < nonceSize { - return "", errors.New("cipher text too short") - } - - nonce, cipherText := cipherText[:nonceSize], cipherText[nonceSize:] - plainText, err := ec.gcm.Open(nil, nonce, cipherText, nil) - if err != nil { - return "", err - } - - return string(plainText), nil -} - -func pkcs5Padding(ciphertext []byte) []byte { - padding := aes.BlockSize - len(ciphertext)%aes.BlockSize - padText := bytes.Repeat([]byte{byte(padding)}, padding) - return append(ciphertext, padText...) -} -func pkcs5UnPadding(src []byte) ([]byte, error) { - srcLen := len(src) - if srcLen == 0 { - return nil, errors.New("input data is empty") - } - - paddingLen := int(src[srcLen-1]) - if paddingLen == 0 || paddingLen > aes.BlockSize || paddingLen > srcLen { - return nil, errors.New("invalid padding size") - } - - // Verify that all padding bytes are the same - for i := 0; i < paddingLen; i++ { - if src[srcLen-1-i] != byte(paddingLen) { - return nil, errors.New("invalid padding") - } - } - - return src[:srcLen-paddingLen], nil -} diff --git a/management/server/activity/store/crypt_test.go b/management/server/activity/store/crypt_test.go deleted file mode 100644 index 700bbcd6b..000000000 --- a/management/server/activity/store/crypt_test.go +++ /dev/null @@ -1,310 +0,0 @@ -package store - -import ( - "bytes" - "testing" -) - -func TestGenerateKey(t *testing.T) { - testData := "exampl@netbird.io" - key, err := GenerateKey() - if err != nil { - t.Fatalf("failed to generate key: %s", err) - } - ee, err := NewFieldEncrypt(key) - if err != nil { - t.Fatalf("failed to init email encryption: %s", err) - } - - encrypted, err := ee.Encrypt(testData) - if err != nil { - t.Fatalf("failed to encrypt data: %s", err) - } - - if encrypted == "" { - t.Fatalf("invalid encrypted text") - } - - decrypted, err := ee.Decrypt(encrypted) - if err != nil { - t.Fatalf("failed to decrypt data: %s", err) - } - - if decrypted != testData { - t.Fatalf("decrypted data is not match with test data: %s, %s", testData, decrypted) - } -} - -func TestGenerateKeyLegacy(t *testing.T) { - testData := "exampl@netbird.io" - key, err := GenerateKey() - if err != nil { - t.Fatalf("failed to generate key: %s", err) - } - ee, err := NewFieldEncrypt(key) - if err != nil { - t.Fatalf("failed to init email encryption: %s", err) - } - - encrypted := ee.LegacyEncrypt(testData) - if encrypted == "" { - t.Fatalf("invalid encrypted text") - } - - decrypted, err := ee.LegacyDecrypt(encrypted) - if err != nil { - t.Fatalf("failed to decrypt data: %s", err) - } - - if decrypted != testData { - t.Fatalf("decrypted data is not match with test data: %s, %s", testData, decrypted) - } -} - -func TestCorruptKey(t *testing.T) { - testData := "exampl@netbird.io" - key, err := GenerateKey() - if err != nil { - t.Fatalf("failed to generate key: %s", err) - } - ee, err := NewFieldEncrypt(key) - if err != nil { - t.Fatalf("failed to init email encryption: %s", err) - } - - encrypted, err := ee.Encrypt(testData) - if err != nil { - t.Fatalf("failed to encrypt data: %s", err) - } - - if encrypted == "" { - t.Fatalf("invalid encrypted text") - } - - newKey, err := GenerateKey() - if err != nil { - t.Fatalf("failed to generate key: %s", err) - } - - ee, err = NewFieldEncrypt(newKey) - if err != nil { - t.Fatalf("failed to init email encryption: %s", err) - } - - res, _ := ee.Decrypt(encrypted) - if res == testData { - t.Fatalf("incorrect decryption, the result is: %s", res) - } -} - -func TestEncryptDecrypt(t *testing.T) { - // Generate a key for encryption/decryption - key, err := GenerateKey() - if err != nil { - t.Fatalf("Failed to generate key: %v", err) - } - - // Initialize the FieldEncrypt with the generated key - ec, err := NewFieldEncrypt(key) - if err != nil { - t.Fatalf("Failed to create FieldEncrypt: %v", err) - } - - // Test cases - testCases := []struct { - name string - input string - }{ - { - name: "Empty String", - input: "", - }, - { - name: "Short String", - input: "Hello", - }, - { - name: "String with Spaces", - input: "Hello, World!", - }, - { - name: "Long String", - input: "The quick brown fox jumps over the lazy dog.", - }, - { - name: "Unicode Characters", - input: "こんにちは世界", - }, - { - name: "Special Characters", - input: "!@#$%^&*()_+-=[]{}|;':\",./<>?", - }, - { - name: "Numeric String", - input: "1234567890", - }, - { - name: "Repeated Characters", - input: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - }, - { - name: "Multi-block String", - input: "This is a longer string that will span multiple blocks in the encryption algorithm.", - }, - { - name: "Non-ASCII and ASCII Mix", - input: "Hello 世界 123", - }, - } - - for _, tc := range testCases { - t.Run(tc.name+" - Legacy", func(t *testing.T) { - // Legacy Encryption - encryptedLegacy := ec.LegacyEncrypt(tc.input) - if encryptedLegacy == "" { - t.Errorf("LegacyEncrypt returned empty string for input '%s'", tc.input) - } - - // Legacy Decryption - decryptedLegacy, err := ec.LegacyDecrypt(encryptedLegacy) - if err != nil { - t.Errorf("LegacyDecrypt failed for input '%s': %v", tc.input, err) - } - - // Verify that the decrypted value matches the original input - if decryptedLegacy != tc.input { - t.Errorf("LegacyDecrypt output '%s' does not match original input '%s'", decryptedLegacy, tc.input) - } - }) - - t.Run(tc.name+" - New", func(t *testing.T) { - // New Encryption - encryptedNew, err := ec.Encrypt(tc.input) - if err != nil { - t.Errorf("Encrypt failed for input '%s': %v", tc.input, err) - } - if encryptedNew == "" { - t.Errorf("Encrypt returned empty string for input '%s'", tc.input) - } - - // New Decryption - decryptedNew, err := ec.Decrypt(encryptedNew) - if err != nil { - t.Errorf("Decrypt failed for input '%s': %v", tc.input, err) - } - - // Verify that the decrypted value matches the original input - if decryptedNew != tc.input { - t.Errorf("Decrypt output '%s' does not match original input '%s'", decryptedNew, tc.input) - } - }) - } -} - -func TestPKCS5UnPadding(t *testing.T) { - tests := []struct { - name string - input []byte - expected []byte - expectError bool - }{ - { - name: "Valid Padding", - input: append([]byte("Hello, World!"), bytes.Repeat([]byte{4}, 4)...), - expected: []byte("Hello, World!"), - }, - { - name: "Empty Input", - input: []byte{}, - expectError: true, - }, - { - name: "Padding Length Zero", - input: append([]byte("Hello, World!"), bytes.Repeat([]byte{0}, 4)...), - expectError: true, - }, - { - name: "Padding Length Exceeds Block Size", - input: append([]byte("Hello, World!"), bytes.Repeat([]byte{17}, 17)...), - expectError: true, - }, - { - name: "Padding Length Exceeds Input Length", - input: []byte{5, 5, 5}, - expectError: true, - }, - { - name: "Invalid Padding Bytes", - input: append([]byte("Hello, World!"), []byte{2, 3, 4, 5}...), - expectError: true, - }, - { - name: "Valid Single Byte Padding", - input: append([]byte("Hello, World!"), byte(1)), - expected: []byte("Hello, World!"), - }, - { - name: "Invalid Mixed Padding Bytes", - input: append([]byte("Hello, World!"), []byte{3, 3, 2}...), - expectError: true, - }, - { - name: "Valid Full Block Padding", - input: append([]byte("Hello, World!"), bytes.Repeat([]byte{16}, 16)...), - expected: []byte("Hello, World!"), - }, - { - name: "Non-Padding Byte at End", - input: append([]byte("Hello, World!"), []byte{4, 4, 4, 5}...), - expectError: true, - }, - { - name: "Valid Padding with Different Text Length", - input: append([]byte("Test"), bytes.Repeat([]byte{12}, 12)...), - expected: []byte("Test"), - }, - { - name: "Padding Length Equal to Input Length", - input: bytes.Repeat([]byte{8}, 8), - expected: []byte{}, - }, - { - name: "Invalid Padding Length Zero (Again)", - input: append([]byte("Test"), byte(0)), - expectError: true, - }, - { - name: "Padding Length Greater Than Input", - input: []byte{10}, - expectError: true, - }, - { - name: "Input Length Not Multiple of Block Size", - input: append([]byte("Invalid Length"), byte(1)), - expected: []byte("Invalid Length"), - }, - { - name: "Valid Padding with Non-ASCII Characters", - input: append([]byte("こんにちは"), bytes.Repeat([]byte{2}, 2)...), - expected: []byte("こんにちは"), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := pkcs5UnPadding(tt.input) - if tt.expectError { - if err == nil { - t.Errorf("Expected error but got nil") - } - } else { - if err != nil { - t.Errorf("Did not expect error but got: %v", err) - } - if !bytes.Equal(result, tt.expected) { - t.Errorf("Expected output %v, got %v", tt.expected, result) - } - } - }) - } -} diff --git a/management/server/activity/store/migration.go b/management/server/activity/store/migration.go index af19a34eb..d0f165d5f 100644 --- a/management/server/activity/store/migration.go +++ b/management/server/activity/store/migration.go @@ -10,9 +10,10 @@ import ( "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/migration" + "github.com/netbirdio/netbird/util/crypt" ) -func migrate(ctx context.Context, crypt *FieldEncrypt, db *gorm.DB) error { +func migrate(ctx context.Context, crypt *crypt.FieldEncrypt, db *gorm.DB) error { migrations := getMigrations(ctx, crypt) for _, m := range migrations { @@ -26,7 +27,7 @@ func migrate(ctx context.Context, crypt *FieldEncrypt, db *gorm.DB) error { type migrationFunc func(*gorm.DB) error -func getMigrations(ctx context.Context, crypt *FieldEncrypt) []migrationFunc { +func getMigrations(ctx context.Context, crypt *crypt.FieldEncrypt) []migrationFunc { return []migrationFunc{ func(db *gorm.DB) error { return migration.MigrateNewField[activity.DeletedUser](ctx, db, "name", "") @@ -45,7 +46,7 @@ func getMigrations(ctx context.Context, crypt *FieldEncrypt) []migrationFunc { // migrateLegacyEncryptedUsersToGCM migrates previously encrypted data using // legacy CBC encryption with a static IV to the new GCM encryption method. -func migrateLegacyEncryptedUsersToGCM(ctx context.Context, db *gorm.DB, crypt *FieldEncrypt) error { +func migrateLegacyEncryptedUsersToGCM(ctx context.Context, db *gorm.DB, crypt *crypt.FieldEncrypt) error { model := &activity.DeletedUser{} if !db.Migrator().HasTable(model) { @@ -80,7 +81,7 @@ func migrateLegacyEncryptedUsersToGCM(ctx context.Context, db *gorm.DB, crypt *F return nil } -func updateDeletedUserData(transaction *gorm.DB, user activity.DeletedUser, crypt *FieldEncrypt) error { +func updateDeletedUserData(transaction *gorm.DB, user activity.DeletedUser, crypt *crypt.FieldEncrypt) error { var err error var decryptedEmail, decryptedName string diff --git a/management/server/activity/store/migration_test.go b/management/server/activity/store/migration_test.go index e3261d9fa..5c6f5ade8 100644 --- a/management/server/activity/store/migration_test.go +++ b/management/server/activity/store/migration_test.go @@ -12,6 +12,7 @@ import ( "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/migration" "github.com/netbirdio/netbird/management/server/testutil" + "github.com/netbirdio/netbird/util/crypt" ) const ( @@ -40,10 +41,10 @@ func setupDatabase(t *testing.T) *gorm.DB { func TestMigrateLegacyEncryptedUsersToGCM(t *testing.T) { db := setupDatabase(t) - key, err := GenerateKey() + key, err := crypt.GenerateKey() require.NoError(t, err, "Failed to generate key") - crypt, err := NewFieldEncrypt(key) + crypt, err := crypt.NewFieldEncrypt(key) require.NoError(t, err, "Failed to initialize FieldEncrypt") t.Run("empty table, no migration required", func(t *testing.T) { diff --git a/management/server/activity/store/sql_store.go b/management/server/activity/store/sql_store.go index ffecb6b8f..73e8e295c 100644 --- a/management/server/activity/store/sql_store.go +++ b/management/server/activity/store/sql_store.go @@ -18,6 +18,7 @@ import ( "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/util/crypt" ) const ( @@ -45,12 +46,12 @@ type eventWithNames struct { // Store is the implementation of the activity.Store interface backed by SQLite type Store struct { db *gorm.DB - fieldEncrypt *FieldEncrypt + fieldEncrypt *crypt.FieldEncrypt } // NewSqlStore creates a new Store with an event table if not exists. func NewSqlStore(ctx context.Context, dataDir string, encryptionKey string) (*Store, error) { - crypt, err := NewFieldEncrypt(encryptionKey) + fieldEncrypt, err := crypt.NewFieldEncrypt(encryptionKey) if err != nil { return nil, err @@ -61,7 +62,7 @@ func NewSqlStore(ctx context.Context, dataDir string, encryptionKey string) (*St return nil, fmt.Errorf("initialize database: %w", err) } - if err = migrate(ctx, crypt, db); err != nil { + if err = migrate(ctx, fieldEncrypt, db); err != nil { return nil, fmt.Errorf("events database migration: %w", err) } @@ -72,7 +73,7 @@ func NewSqlStore(ctx context.Context, dataDir string, encryptionKey string) (*St return &Store{ db: db, - fieldEncrypt: crypt, + fieldEncrypt: fieldEncrypt, }, nil } @@ -248,7 +249,15 @@ func initDatabase(ctx context.Context, dataDir string) (*gorm.DB, error) { switch storeEngine { case types.SqliteStoreEngine: - dialector = sqlite.Open(filepath.Join(dataDir, eventSinkDB)) + dbFile := eventSinkDB + if envFile, ok := os.LookupEnv("NB_ACTIVITY_EVENT_SQLITE_FILE"); ok && envFile != "" { + dbFile = envFile + } + connStr := dbFile + if !filepath.IsAbs(dbFile) { + connStr = filepath.Join(dataDir, dbFile) + } + dialector = sqlite.Open(connStr) case types.PostgresStoreEngine: dsn, ok := os.LookupEnv(postgresDsnEnv) if !ok { diff --git a/management/server/activity/store/sql_store_idp_migration.go b/management/server/activity/store/sql_store_idp_migration.go new file mode 100644 index 000000000..1b3a9ecd9 --- /dev/null +++ b/management/server/activity/store/sql_store_idp_migration.go @@ -0,0 +1,61 @@ +package store + +// This file contains migration-only methods on Store. +// They satisfy the migration.MigrationEventStore interface via duck typing. +// Delete this file when migration tooling is no longer needed. + +import ( + "context" + "fmt" + + "gorm.io/gorm" + + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/idp/migration" +) + +// CheckSchema verifies that all tables and columns required by the migration exist in the event database. +func (store *Store) CheckSchema(checks []migration.SchemaCheck) []migration.SchemaError { + migrator := store.db.Migrator() + var errs []migration.SchemaError + + for _, check := range checks { + if !migrator.HasTable(check.Table) { + errs = append(errs, migration.SchemaError{Table: check.Table}) + continue + } + for _, col := range check.Columns { + if !migrator.HasColumn(check.Table, col) { + errs = append(errs, migration.SchemaError{Table: check.Table, Column: col}) + } + } + } + + return errs +} + +// UpdateUserID updates all references to oldUserID in events and deleted_users tables. +func (store *Store) UpdateUserID(ctx context.Context, oldUserID, newUserID string) error { + return store.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&activity.Event{}). + Where("initiator_id = ?", oldUserID). + Update("initiator_id", newUserID).Error; err != nil { + return fmt.Errorf("update events.initiator_id: %w", err) + } + + if err := tx.Model(&activity.Event{}). + Where("target_id = ?", oldUserID). + Update("target_id", newUserID).Error; err != nil { + return fmt.Errorf("update events.target_id: %w", err) + } + + // Raw exec: GORM can't update a PK via Model().Update() + if err := tx.Exec( + "UPDATE deleted_users SET id = ? WHERE id = ?", newUserID, oldUserID, + ).Error; err != nil { + return fmt.Errorf("update deleted_users.id: %w", err) + } + + return nil + }) +} diff --git a/management/server/activity/store/sql_store_idp_migration_test.go b/management/server/activity/store/sql_store_idp_migration_test.go new file mode 100644 index 000000000..98b6e1327 --- /dev/null +++ b/management/server/activity/store/sql_store_idp_migration_test.go @@ -0,0 +1,161 @@ +package store + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/util/crypt" +) + +func TestUpdateUserID(t *testing.T) { + ctx := context.Background() + + newStore := func(t *testing.T) *Store { + t.Helper() + key, _ := crypt.GenerateKey() + s, err := NewSqlStore(ctx, t.TempDir(), key) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { s.Close(ctx) }) //nolint + return s + } + + t.Run("updates initiator_id in events", func(t *testing.T) { + store := newStore(t) + accountID := "account_1" + + _, err := store.Save(ctx, &activity.Event{ + Timestamp: time.Now().UTC(), + Activity: activity.PeerAddedByUser, + InitiatorID: "old-user", + TargetID: "some-peer", + AccountID: accountID, + }) + assert.NoError(t, err) + + err = store.UpdateUserID(ctx, "old-user", "new-user") + assert.NoError(t, err) + + result, err := store.Get(ctx, accountID, 0, 10, false) + assert.NoError(t, err) + assert.Len(t, result, 1) + assert.Equal(t, "new-user", result[0].InitiatorID) + }) + + t.Run("updates target_id in events", func(t *testing.T) { + store := newStore(t) + accountID := "account_1" + + _, err := store.Save(ctx, &activity.Event{ + Timestamp: time.Now().UTC(), + Activity: activity.PeerAddedByUser, + InitiatorID: "some-admin", + TargetID: "old-user", + AccountID: accountID, + }) + assert.NoError(t, err) + + err = store.UpdateUserID(ctx, "old-user", "new-user") + assert.NoError(t, err) + + result, err := store.Get(ctx, accountID, 0, 10, false) + assert.NoError(t, err) + assert.Len(t, result, 1) + assert.Equal(t, "new-user", result[0].TargetID) + }) + + t.Run("updates deleted_users id", func(t *testing.T) { + store := newStore(t) + accountID := "account_1" + + // Save an event with email/name meta to create a deleted_users row for "old-user" + _, err := store.Save(ctx, &activity.Event{ + Timestamp: time.Now().UTC(), + Activity: activity.PeerAddedByUser, + InitiatorID: "admin", + TargetID: "old-user", + AccountID: accountID, + Meta: map[string]any{ + "email": "user@example.com", + "name": "Test User", + }, + }) + assert.NoError(t, err) + + err = store.UpdateUserID(ctx, "old-user", "new-user") + assert.NoError(t, err) + + // Save another event referencing new-user with email/name meta. + // This should upsert (not conflict) because the PK was already migrated. + _, err = store.Save(ctx, &activity.Event{ + Timestamp: time.Now().UTC(), + Activity: activity.PeerAddedByUser, + InitiatorID: "admin", + TargetID: "new-user", + AccountID: accountID, + Meta: map[string]any{ + "email": "user@example.com", + "name": "Test User", + }, + }) + assert.NoError(t, err) + + // The deleted user info should be retrievable via Get (joined on target_id) + result, err := store.Get(ctx, accountID, 0, 10, false) + assert.NoError(t, err) + assert.Len(t, result, 2) + for _, ev := range result { + assert.Equal(t, "new-user", ev.TargetID) + } + }) + + t.Run("no-op when old user ID does not exist", func(t *testing.T) { + store := newStore(t) + + err := store.UpdateUserID(ctx, "nonexistent-user", "new-user") + assert.NoError(t, err) + }) + + t.Run("only updates matching user leaves others unchanged", func(t *testing.T) { + store := newStore(t) + accountID := "account_1" + + _, err := store.Save(ctx, &activity.Event{ + Timestamp: time.Now().UTC(), + Activity: activity.PeerAddedByUser, + InitiatorID: "user-a", + TargetID: "peer-1", + AccountID: accountID, + }) + assert.NoError(t, err) + + _, err = store.Save(ctx, &activity.Event{ + Timestamp: time.Now().UTC(), + Activity: activity.PeerAddedByUser, + InitiatorID: "user-b", + TargetID: "peer-2", + AccountID: accountID, + }) + assert.NoError(t, err) + + err = store.UpdateUserID(ctx, "user-a", "user-a-new") + assert.NoError(t, err) + + result, err := store.Get(ctx, accountID, 0, 10, false) + assert.NoError(t, err) + assert.Len(t, result, 2) + + for _, ev := range result { + if ev.TargetID == "peer-1" { + assert.Equal(t, "user-a-new", ev.InitiatorID) + } else { + assert.Equal(t, "user-b", ev.InitiatorID) + } + } + }) +} diff --git a/management/server/activity/store/sql_store_test.go b/management/server/activity/store/sql_store_test.go index 8c0d159df..d723f1623 100644 --- a/management/server/activity/store/sql_store_test.go +++ b/management/server/activity/store/sql_store_test.go @@ -9,11 +9,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/util/crypt" ) func TestNewSqlStore(t *testing.T) { dataDir := t.TempDir() - key, _ := GenerateKey() + key, _ := crypt.GenerateKey() store, err := NewSqlStore(context.Background(), dataDir, key) if err != nil { t.Fatal(err) diff --git a/management/server/affected_peers_coverage_test.go b/management/server/affected_peers_coverage_test.go new file mode 100644 index 000000000..ae5b92f49 --- /dev/null +++ b/management/server/affected_peers_coverage_test.go @@ -0,0 +1,115 @@ +package server + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/affectedpeers" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + networkTypes "github.com/netbirdio/netbird/management/server/networks/types" + "github.com/netbirdio/netbird/management/server/posture" + "github.com/netbirdio/netbird/management/server/types" +) + +// TestAffectedPeers_DependencyCoverageMatrix enumerates each network-map +// dependency crossed with the change-type that can alter it, asserting the +// resolver folds in exactly the peers whose map changes. A new dependency that +// the resolver fails to walk should fail one of these rows; a new change-type +// without a row is a coverage gap to add here. +func TestAffectedPeers_DependencyCoverageMatrix(t *testing.T) { + type row struct { + name string + build func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) + } + + rows := []row{ + { + name: "policy-groups/source-group-change refreshes source+routing, excludes unrelated", + build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) { + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + return affectedpeers.Change{ChangedGroupIDs: []string{s.sourceGroupID}}, + []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID} + }, + }, + { + name: "resource-routing-bridge/router-peer-change refreshes policy sources", + build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) { + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + return affectedpeers.Change{ChangedPeerIDs: []string{s.routerPeerID}}, + []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID} + }, + }, + { + name: "policy-change/explicit-policy refreshes source+routing", + build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) { + policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID) + return affectedpeers.Change{Policies: []*types.Policy{policy}}, + []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID} + }, + }, + { + name: "policy-destinationresource/explicit-policy bridges to routing peer", + build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) { + policy := peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID) + return affectedpeers.Change{Policies: []*types.Policy{policy}}, + []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID} + }, + }, + { + name: "resource-change refreshes source+routing on its network", + build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) { + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + return affectedpeers.Change{Resources: []*resourceTypes.NetworkResource{ + {ID: s.resourceID, NetworkID: s.networkID, GroupIDs: []string{s.resourceGroupID}}, + }}, + []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID} + }, + }, + { + name: "network-change refreshes source+routing on that network", + build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) { + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + return affectedpeers.Change{Networks: []*networkTypes.Network{{ID: s.networkID}}}, + []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID} + }, + }, + { + name: "posture-check-change refreshes source+routing of gated policy", + build: func(t *testing.T, s *routerScenario, ctx context.Context) (affectedpeers.Change, []string, []string) { + check, err := s.manager.SavePostureChecks(ctx, s.accountID, userID, &posture.Checks{ + Name: "cov-min-version", + Checks: posture.ChecksDefinition{NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.30.0"}}, + }, true) + require.NoError(t, err) + policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID) + policy.SourcePostureChecks = []string{check.ID} + _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, true) + require.NoError(t, err) + return affectedpeers.Change{PostureCheckIDs: []string{check.ID}}, + []string{s.sourcePeerID, s.routerPeerID}, []string{s.unrelatedPeerID} + }, + }, + } + + for _, r := range rows { + t.Run(r.name, func(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + change, mustContain, mustExclude := r.build(t, s, ctx) + affected := resolveAffected(t, s.manager.Store, s.accountID, change) + + assert.ElementsMatch(t, affected, mustContain, "expected peer to be affected") + for _, peerID := range mustExclude { + assert.NotContains(t, affected, peerID, "peer must not be affected") + } + }) + } +} diff --git a/management/server/affected_peers_oldstate_test.go b/management/server/affected_peers_oldstate_test.go new file mode 100644 index 000000000..bcb78a660 --- /dev/null +++ b/management/server/affected_peers_oldstate_test.go @@ -0,0 +1,143 @@ +package server + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +// An update spans an old and a new state. The affected set must be the UNION of +// peers reachable before and after the change; resolving only against the final +// state drops peers that were reachable but no longer are. These tests pin the +// two paths where the old state is reachable only by the changed object's +// previous references: detaching a resource group, and re-pointing a router peer. + +// TestAffectedPeers_E2E_UpdateResource_DetachGroup_RefreshesOldGroupSources: +// a resource is reachable by a source group via two destination resource groups; +// detaching one of them must still refresh that group's policy source peers, even +// though the post-update resource no longer maps to it. +func TestAffectedPeers_E2E_UpdateResource_DetachGroup_RefreshesOldGroupSources(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + // A second resource group + a second source group/peer that reaches the + // resource only through that second group. + const detachGroupID = "rs-detach-grp" + require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ID: detachGroupID, Name: "rs-detach"})) + + const secondSourceGroupID = "rs-source-grp-2" + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-detach-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + secondSourcePeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ + ID: secondSourceGroupID, Name: "rs-source-2", Peers: []string{secondSourcePeer.ID}, + })) + + resourcesManager, _, _ := s.managers() + + // Attach the resource to the detach group as well: now in [resourceGroup, detachGroup]. + _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/24", + GroupIDs: []string{s.resourceGroupID, detachGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + // Policy granting the second source group access via the detach group. + _, err = s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(secondSourceGroupID, detachGroupID), true) + require.NoError(t, err) + + secondSrcCh := s.updateManager.CreateChannel(ctx, secondSourcePeer.ID) + t.Cleanup(func() { s.updateManager.CloseChannel(ctx, secondSourcePeer.ID) }) + settleAffectedUpdates(secondSrcCh) + + done := make(chan struct{}) + go func() { + // Detaching the resource from detachGroup removes the second source's + // access; that source peer must be refreshed even though the post-update + // resource no longer maps to detachGroup. + peerShouldReceiveUpdate(t, secondSrcCh) + close(done) + }() + + _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/24", + GroupIDs: []string{s.resourceGroupID}, // detached detachGroup + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: detaching a resource group did not refresh the old group's policy source peer") + } +} + +// TestAffectedPeers_E2E_UpdateRouter_RepointPeer_RefreshesOldRoutingPeer: +// changing router.Peer within the same network must still refresh the OLD routing +// peer, which loses its routing role. +func TestAffectedPeers_E2E_UpdateRouter_RepointPeer_RefreshesOldRoutingPeer(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + _, routersManager, _ := s.managers() + + routers, err := s.manager.Store.GetNetworkRoutersByNetID(ctx, store.LockingStrengthNone, s.accountID, s.networkID) + require.NoError(t, err) + require.Len(t, routers, 1) + router := routers[0] + oldRoutingPeer := router.Peer + require.NotEmpty(t, oldRoutingPeer) + + // A new peer to become the routing peer in place of the old one. + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-newrouter-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + newRoutingPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + + oldCh := s.updateManager.CreateChannel(ctx, oldRoutingPeer) + t.Cleanup(func() { s.updateManager.CloseChannel(ctx, oldRoutingPeer) }) + settleAffectedUpdates(oldCh) + + done := make(chan struct{}) + go func() { + // The old routing peer stops serving the resource and must be refreshed. + peerShouldReceiveUpdate(t, oldCh) + close(done) + }() + + _, err = routersManager.UpdateRouter(ctx, userID, &routerTypes.NetworkRouter{ + ID: router.ID, + NetworkID: s.networkID, + AccountID: s.accountID, + Peer: newRoutingPeer.ID, // repoint within the same network + Masquerade: true, + Metric: 9999, + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: re-pointing the router peer did not refresh the old routing peer") + } +} diff --git a/management/server/affected_peers_property_test.go b/management/server/affected_peers_property_test.go new file mode 100644 index 000000000..f393465bc --- /dev/null +++ b/management/server/affected_peers_property_test.go @@ -0,0 +1,255 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "math/rand" + "sort" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/exp/maps" + + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/server/affectedpeers" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +// allPeerMaps computes the serialized per-peer network map for every peer in the +// account, mirroring the controller's compute path so the property test compares +// against real output. +func allPeerMaps(t *testing.T, manager *DefaultAccountManager, accountID string) map[string]string { + t.Helper() + ctx := context.Background() + + account, err := manager.Store.GetAccount(ctx, accountID) + require.NoError(t, err) + + account.InjectProxyPolicies(ctx) + + validated := make(map[string]struct{}, len(account.Peers)) + for id := range account.Peers { + validated[id] = struct{}{} + } + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + out := make(map[string]string, len(account.Peers)) + for peerID := range account.Peers { + nm := account.GetPeerNetworkMapFromComponents(ctx, peerID, nbdns.CustomZone{}, nil, validated, resourcePolicies, routers, nil, groupIDToUserIDs) + // Network.Serial is an account-global counter bumped on every change; it + // is not a per-peer dependency, so normalize it out of the comparison. + if nm.Network != nil { + nm.Network.Serial = 0 + } + out[peerID] = canonicalJSON(t, nm) + } + return out +} + +// canonicalJSON marshals v and returns an order-insensitive string form: every +// JSON array is sorted by the canonical form of its elements. The network map's +// Peers/Routes/FirewallRules/SourceRanges slices have nondeterministic order, so +// a raw JSON compare would report spurious changes. +func canonicalJSON(t *testing.T, v interface{}) string { + t.Helper() + b, err := json.Marshal(v) + require.NoError(t, err) + var parsed interface{} + require.NoError(t, json.Unmarshal(b, &parsed)) + canonicalized, err := json.Marshal(sortAny(parsed)) + require.NoError(t, err) + return string(canonicalized) +} + +func sortAny(v interface{}) interface{} { + switch val := v.(type) { + case []interface{}: + for i := range val { + val[i] = sortAny(val[i]) + } + sort.Slice(val, func(i, j int) bool { + bi, _ := json.Marshal(val[i]) + bj, _ := json.Marshal(val[j]) + return string(bi) < string(bj) + }) + return val + case map[string]interface{}: + for k := range val { + val[k] = sortAny(val[k]) + } + return val + default: + return v + } +} + +// changedPeers returns the peer IDs whose serialized map differs between before +// and after. +func changedPeers(before, after map[string]string) []string { + var changed []string + for id, b := range before { + a, ok := after[id] + if !ok || a != b { + changed = append(changed, id) + } + } + for id := range after { + if _, ok := before[id]; !ok { + changed = append(changed, id) + } + } + return changed +} + +// TestAffectedPeers_Property_ResolverSupersetsRealChanges builds a topology, +// applies random changes, and asserts that the resolver's affected set is a +// superset of the peers whose real network map actually changed. If the resolver +// ever misses a dependency, a change will alter a peer's map without that peer +// appearing in the affected set, failing here. +func TestAffectedPeers_Property_ResolverSupersetsRealChanges(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + // A pre-existing peer->resource policy so the resource/router bridge is live. + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + // Extra peers and groups to give mutations room to move membership around. + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "prop-key", types.SetupKeyReusable, 0, nil, 999, userID, false, false) + require.NoError(t, err) + extraPeers := make([]string, 0, 4) + for i := 0; i < 4; i++ { + p := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + extraPeers = append(extraPeers, p.ID) + } + extraGroups := []string{"prop-grp-0", "prop-grp-1"} + for _, g := range extraGroups { + require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ID: g, Name: g})) + } + + rng := rand.New(rand.NewSource(1)) + allGroups := append([]string{s.sourceGroupID, s.resourceGroupID, s.routerPeerGroupID}, extraGroups...) + allPeers := append([]string{s.sourcePeerID, s.routerPeerID, s.routerGroupPeerID, s.unrelatedPeerID}, extraPeers...) + + for iter := 0; iter < 60; iter++ { + change, apply := s.randomMutation(t, rng, allGroups, allPeers) + if apply == nil { + continue + } + + before := allPeerMaps(t, s.manager, s.accountID) + + resolvedSet := make(map[string]struct{}) + resolve := func() { + require.NoError(t, s.manager.Store.ExecuteInTransaction(ctx, func(tx store.Store) error { + snap, err := affectedpeers.Load(ctx, tx, s.accountID, change) + if err != nil { + return err + } + for _, id := range snap.Expand(ctx, s.accountID, change) { + resolvedSet[id] = struct{}{} + } + return nil + })) + } + + // Resolve on both sides of the mutation and union: removals are visible + // only pre-apply (the leaving peer is still a member), additions only + // post-apply (the joining peer is now a member). Production captures both + // via per-path handling (e.g. UpdateGroup passes peersToRemove); the union + // models that without coupling the test to each path's ordering. + resolve() + changedIDs := change.ChangedPeerIDs + apply() + resolve() + + after := allPeerMaps(t, s.manager, s.accountID) + + // The explicitly-changed peer's own map refresh is the caller's + // responsibility (the resolver returns the peers to propagate to), so it + // is allowed to be absent from the resolved set. + changedExplicitly := make(map[string]struct{}, len(changedIDs)) + for _, id := range changedIDs { + changedExplicitly[id] = struct{}{} + } + + for _, id := range changedPeers(before, after) { + if _, stillExists := after[id]; !stillExists { + continue + } + if _, isExplicit := changedExplicitly[id]; isExplicit { + continue + } + _, ok := resolvedSet[id] + require.Truef(t, ok, + "iter %d: peer %s network map changed but was not in the resolver's affected set %v (change=%+v)", + iter, id, maps.Keys(resolvedSet), change) + } + } +} + +// randomMutation picks a random change, returns the Change to resolve and a +// function that applies the underlying store mutation. apply is nil when the +// drawn mutation is a no-op for the current state. +func (s *routerScenario) randomMutation(t *testing.T, rng *rand.Rand, allGroups, allPeers []string) (affectedpeers.Change, func()) { + t.Helper() + ctx := context.Background() + + switch rng.Intn(3) { + case 0: + groupID := allGroups[rng.Intn(len(allGroups))] + peerID := allPeers[rng.Intn(len(allPeers))] + grp, err := s.manager.Store.GetGroupByID(ctx, store.LockingStrengthNone, s.accountID, groupID) + require.NoError(t, err) + if slicesContains(grp.Peers, peerID) { + return affectedpeers.Change{}, nil + } + return affectedpeers.Change{ChangedGroupIDs: []string{groupID}, ChangedPeerIDs: []string{peerID}}, + func() { + require.NoError(t, s.manager.GroupAddPeer(ctx, s.accountID, groupID, peerID)) + } + case 1: + groupID := allGroups[rng.Intn(len(allGroups))] + grp, err := s.manager.Store.GetGroupByID(ctx, store.LockingStrengthNone, s.accountID, groupID) + require.NoError(t, err) + if len(grp.Peers) == 0 { + return affectedpeers.Change{}, nil + } + peerID := grp.Peers[rng.Intn(len(grp.Peers))] + return affectedpeers.Change{ChangedGroupIDs: []string{groupID}, ChangedPeerIDs: []string{peerID}}, + func() { + require.NoError(t, s.manager.GroupDeletePeer(ctx, s.accountID, groupID, peerID)) + } + default: + src := allGroups[rng.Intn(len(allGroups))] + dst := allGroups[rng.Intn(len(allGroups))] + policy := &types.Policy{ + Enabled: true, + Name: fmt.Sprintf("prop-policy-%d", rng.Int()), + Rules: []*types.PolicyRule{{ + Enabled: true, + Sources: []string{src}, + Destinations: []string{dst}, + Action: types.PolicyTrafficActionAccept, + }}, + } + return affectedpeers.Change{Policies: []*types.Policy{policy}}, + func() { + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, policy, true) + require.NoError(t, err) + } + } +} + +func slicesContains(s []string, v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} diff --git a/management/server/affected_peers_querycount_test.go b/management/server/affected_peers_querycount_test.go new file mode 100644 index 000000000..d451a0a29 --- /dev/null +++ b/management/server/affected_peers_querycount_test.go @@ -0,0 +1,164 @@ +package server + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbdns "github.com/netbirdio/netbird/dns" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/affectedpeers" + 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" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/route" +) + +// countingStore wraps a real store and counts the per-account collection loads +// the resolver performs, so a test can assert each is read at most once and that +// irrelevant collections are skipped entirely. +type countingStore struct { + store.Store + mu sync.Mutex + counts map[string]int +} + +func newCountingStore(s store.Store) *countingStore { + return &countingStore{Store: s, counts: map[string]int{}} +} + +func (c *countingStore) bump(name string) { + c.mu.Lock() + c.counts[name]++ + c.mu.Unlock() +} + +func (c *countingStore) count(name string) int { + c.mu.Lock() + defer c.mu.Unlock() + return c.counts[name] +} + +func (c *countingStore) total() int { + c.mu.Lock() + defer c.mu.Unlock() + n := 0 + for _, v := range c.counts { + n += v + } + return n +} + +func (c *countingStore) GetAccountPolicies(ctx context.Context, ls store.LockingStrength, accountID string) ([]*types.Policy, error) { + c.bump("policies") + return c.Store.GetAccountPolicies(ctx, ls, accountID) +} + +func (c *countingStore) GetAccountRoutes(ctx context.Context, ls store.LockingStrength, accountID string) ([]*route.Route, error) { + c.bump("routes") + return c.Store.GetAccountRoutes(ctx, ls, accountID) +} + +func (c *countingStore) GetAccountNameServerGroups(ctx context.Context, ls store.LockingStrength, accountID string) ([]*nbdns.NameServerGroup, error) { + c.bump("nameservers") + return c.Store.GetAccountNameServerGroups(ctx, ls, accountID) +} + +func (c *countingStore) GetAccountDNSSettings(ctx context.Context, ls store.LockingStrength, accountID string) (*types.DNSSettings, error) { + c.bump("dnssettings") + return c.Store.GetAccountDNSSettings(ctx, ls, accountID) +} + +func (c *countingStore) GetNetworkRoutersByAccountID(ctx context.Context, ls store.LockingStrength, accountID string) ([]*routerTypes.NetworkRouter, error) { + c.bump("routers") + return c.Store.GetNetworkRoutersByAccountID(ctx, ls, accountID) +} + +func (c *countingStore) GetNetworkResourcesByAccountID(ctx context.Context, ls store.LockingStrength, accountID string) ([]*resourceTypes.NetworkResource, error) { + c.bump("resources") + return c.Store.GetNetworkResourcesByAccountID(ctx, ls, accountID) +} + +func (c *countingStore) GetAccountServices(ctx context.Context, ls store.LockingStrength, accountID string) ([]*rpservice.Service, error) { + c.bump("services") + return c.Store.GetAccountServices(ctx, ls, accountID) +} + +// TestAffectedPeers_QueryCount_NoRedundantFullTableLoads asserts the resolver +// loads each per-account collection at most once per Resolve (memoization) even +// on a change that drives every bridge, and skips the services table when the +// account has no embedded proxy peers. +func TestAffectedPeers_QueryCount_NoRedundantFullTableLoads(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + cs := newCountingStore(s.manager.Store) + + // A group change that exercises policies, routers, resources and the bridge. + change := affectedpeers.Change{ChangedGroupIDs: []string{s.sourceGroupID}} + snap, err := affectedpeers.Load(ctx, cs, s.accountID, change) + require.NoError(t, err) + affected := snap.Expand(ctx, s.accountID, change) + assert.Contains(t, affected, s.routerPeerID, "bridge must still resolve the routing peer") + + for _, name := range []string{"policies", "routes", "nameservers", "dnssettings", "routers", "resources"} { + assert.LessOrEqualf(t, cs.count(name), 1, + "%s must be loaded at most once per Resolve, got %d", name, cs.count(name)) + } + assert.Equal(t, 0, cs.count("services"), + "services must not be loaded when the account has no embedded proxy peers") +} + +// TestAffectedPeers_QueryCount_NarrowChangeSkipsLoads asserts that a change with +// no group/peer signal touches no per-account collections beyond what its inputs +// require. +func TestAffectedPeers_QueryCount_NarrowChangeSkipsLoads(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + cs := newCountingStore(s.manager.Store) + + // A bare network change drives only the router->source bridge: routers and + // resources are needed, but routes/nameservers/dnssettings/services are not. + _, err := affectedpeers.Load(ctx, cs, s.accountID, affectedpeers.Change{Networks: []*networkTypes.Network{{ID: s.networkID}}}) + require.NoError(t, err) + + assert.Equal(t, 0, cs.count("routes"), "routes must not be loaded for a network-only change") + assert.Equal(t, 0, cs.count("nameservers"), "nameservers must not be loaded for a network-only change") + assert.Equal(t, 0, cs.count("dnssettings"), "dnssettings must not be loaded for a network-only change") + assert.Equal(t, 0, cs.count("services"), "services must not be loaded for a network-only change") +} + +// TestAffectedPeers_QueryCount_ExpandReadsNothing is the core invariant of the +// Load/Expand split: Load (run inside the transaction) does all store reads; +// Expand (run after commit) must touch the store ZERO times, so it never holds +// the write lock and never reads post-commit state. +func TestAffectedPeers_QueryCount_ExpandReadsNothing(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + change := affectedpeers.Change{ChangedGroupIDs: []string{s.sourceGroupID}} + + cs := newCountingStore(s.manager.Store) + snap, err := affectedpeers.Load(ctx, cs, s.accountID, change) + require.NoError(t, err) + require.Greater(t, cs.total(), 0, "Load must read the store") + + // Any store access during Expand would increment the same counter. Expand + // operates purely on the snapshot, so the count must not move. + readsAfterLoad := cs.total() + affected := snap.Expand(ctx, s.accountID, change) + assert.Contains(t, affected, s.routerPeerID, "Expand must still produce the affected peers from the snapshot") + assert.Equal(t, readsAfterLoad, cs.total(), "Expand must perform zero store reads — it operates purely on the loaded snapshot") +} diff --git a/management/server/affected_peers_router_paths_test.go b/management/server/affected_peers_router_paths_test.go new file mode 100644 index 000000000..5d83367fd --- /dev/null +++ b/management/server/affected_peers_router_paths_test.go @@ -0,0 +1,340 @@ +package server + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/affectedpeers" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + "github.com/netbirdio/netbird/management/server/posture" + "github.com/netbirdio/netbird/management/server/types" +) + +func (s *routerScenario) resolveGroupChangeAffected(ctx context.Context, changedGroupIDs []string) []string { + change := affectedpeers.Change{ChangedGroupIDs: changedGroupIDs} + snap, err := affectedpeers.Load(ctx, s.manager.Store, s.accountID, change) + if err != nil { + return nil + } + return snap.Expand(ctx, s.accountID, change) +} + +func (s *routerScenario) resolvePeerChangeAffected(ctx context.Context, changedPeerIDs []string) []string { + change := affectedpeers.Change{ChangedPeerIDs: changedPeerIDs} + snap, err := affectedpeers.Load(ctx, s.manager.Store, s.accountID, change) + if err != nil { + return nil + } + return snap.Expand(ctx, s.accountID, change) +} + +func TestAffectedPeers_GroupChange_SourceGroupMembership_RefreshesRoutingPeer_DirectRouter(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + affected := s.resolveGroupChangeAffected(ctx, []string{s.sourceGroupID}) + + assert.Contains(t, affected, s.sourcePeerID, "source group member must be affected") + assert.Contains(t, affected, s.routerPeerID, + "changing the source group of a peer->resource policy must refresh the resource's routing peer") + assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected") +} + +func TestAffectedPeers_GroupChange_SourceGroupMembership_RefreshesRoutingPeer_RouterPeerGroups(t *testing.T) { + s := setupRouterScenario(t, false) + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + affected := s.resolveGroupChangeAffected(ctx, []string{s.sourceGroupID}) + + assert.Contains(t, affected, s.routerGroupPeerID, + "changing the source group must refresh the routing peer defined via router.PeerGroups") + assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected") +} + +func TestAffectedPeers_GroupChange_RouterPeerGroupMembership_RefreshesPolicySources(t *testing.T) { + s := setupRouterScenario(t, false) + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + affected := s.resolveGroupChangeAffected(ctx, []string{s.routerPeerGroupID}) + + assert.Contains(t, affected, s.routerGroupPeerID, "the routing peer itself must be affected") + assert.Contains(t, affected, s.sourcePeerID, + "changing the router's PeerGroups must refresh the source peers of policies serving the resource") + assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected") +} + +func TestAffectedPeers_PeerChange_SourcePeer_RefreshesRoutingPeer(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + affected := s.resolvePeerChangeAffected(ctx, []string{s.sourcePeerID}) + + assert.Contains(t, affected, s.routerPeerID, + "a status change on a source peer must refresh the resource's routing peer that serves it") + assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected") +} + +func TestAffectedPeers_PeerChange_SourcePeer_ByDestinationResource_RefreshesRoutingPeer(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) + require.NoError(t, err) + + affected := s.resolvePeerChangeAffected(ctx, []string{s.sourcePeerID}) + + assert.Contains(t, affected, s.routerPeerID, + "DestinationResource-targeted policy must still bridge a source-peer change to the routing peer") + assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected") +} + +func TestAffectedPeers_E2E_DeleteGroup_ResolvesAffectedPeers(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + const memberOnlyGroupID = "rs-memberonly-grp" + require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ + ID: memberOnlyGroupID, Name: "rs-memberonly", Peers: []string{s.sourcePeerID}, + })) + + affected := s.resolveGroupChangeAffected(ctx, []string{memberOnlyGroupID}) + assert.Empty(t, affected, "an unlinked group has no network-map impact, so no peer is affected") + + require.NoError(t, s.manager.DeleteGroup(ctx, s.accountID, userID, memberOnlyGroupID)) +} + +func TestAffectedPeers_GroupAddResource_RefreshesRoutingPeer(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + const extraResourceGroupID = "rs-resource-grp-extra" + require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ + ID: extraResourceGroupID, Name: "rs-resource-extra", + })) + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, extraResourceGroupID), true) + require.NoError(t, err) + + require.NoError(t, s.manager.GroupAddResource(ctx, s.accountID, extraResourceGroupID, types.Resource{ + ID: s.resourceID, + Type: types.ResourceTypeHost, + })) + + affected := s.resolveGroupChangeAffected(ctx, []string{extraResourceGroupID}) + + assert.Contains(t, affected, s.routerPeerID, + "attaching a resource to a policy destination group must refresh the resource's routing peer") + assert.Contains(t, affected, s.sourcePeerID, "policy source peers must refresh") + assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected") +} + +func (s *routerScenario) createPostureCheckGatedPolicy(t *testing.T, ctx context.Context) string { + t.Helper() + + check, err := s.manager.SavePostureChecks(ctx, s.accountID, userID, &posture.Checks{ + Name: "rs-min-version", + Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.30.0"}, + }, + }, true) + require.NoError(t, err) + + policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID) + policy.SourcePostureChecks = []string{check.ID} + _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, true) + require.NoError(t, err) + + return check.ID +} + +func TestAffectedPeers_E2E_SavePostureCheck_RefreshesRoutingPeer(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + checkID := s.createPostureCheckGatedPolicy(t, ctx) + + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh, unrelatedCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + peerShouldNotReceiveUpdate(t, unrelatedCh) + close(done) + }() + + _, err := s.manager.SavePostureChecks(ctx, s.accountID, userID, &posture.Checks{ + ID: checkID, + Name: "rs-min-version", + Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.31.0"}, + }, + }, false) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: editing a posture check did not refresh source + routing peers") + } +} + +func TestAffectedPeers_E2E_UpdateResource_DestinationResourcePolicy_RefreshesSourcePeer(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) + require.NoError(t, err) + + resourcesManager, _, _ := s.managers() + + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh, unrelatedCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + peerShouldNotReceiveUpdate(t, unrelatedCh) + close(done) + }() + + _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/25", + GroupIDs: []string{s.resourceGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: updating a DestinationResource-targeted resource did not refresh its policy source peer") + } +} + +// A disabled sibling router routes to nobody, so updating a resource on its network +// must NOT refresh its peer (the enabled router carries the bridge instead). +func TestAffectedPeers_E2E_UpdateResource_DisabledSiblingRouterNotBridged(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + resourcesManager, routersManager, _ := s.managers() + + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-disabled", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + disabledRouterPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + _, err = routersManager.CreateRouter(ctx, userID, &routerTypes.NetworkRouter{ + NetworkID: s.networkID, + AccountID: s.accountID, + Peer: disabledRouterPeer.ID, + Masquerade: true, + Metric: 9000, + Enabled: false, + }) + require.NoError(t, err) + + disabledCh := s.updateManager.CreateChannel(ctx, disabledRouterPeer.ID) + enabledCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, disabledRouterPeer.ID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + settleAffectedUpdates(disabledCh, enabledCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, enabledCh) + peerShouldNotReceiveUpdate(t, disabledCh) + close(done) + }() + + _, err = resourcesManager.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/25", + GroupIDs: []string{s.resourceGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } +} + +func TestAffectedPeers_GroupChange_RouterInOtherNetworkNotAffected(t *testing.T) { + s := setupRouterScenario(t, true) + second := s.addSecondTopology(t, "groupiso") + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + affected := s.resolveGroupChangeAffected(ctx, []string{s.sourceGroupID}) + + assert.Contains(t, affected, s.routerPeerID, "network A's routing peer must be affected") + assert.NotContains(t, affected, second.routerPeerID, + "a router in an unrelated network must not be affected by a source-group change for another resource") +} + +func TestAffectedPeers_PeerChange_RouterInOtherNetworkNotAffected(t *testing.T) { + s := setupRouterScenario(t, true) + second := s.addSecondTopology(t, "peeriso") + ctx := context.Background() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + affected := s.resolvePeerChangeAffected(ctx, []string{s.sourcePeerID}) + + assert.Contains(t, affected, s.routerPeerID, "network A's routing peer must be affected") + assert.NotContains(t, affected, second.routerPeerID, + "a router in an unrelated network must not be affected by a source-peer change for another resource") +} diff --git a/management/server/affected_peers_router_test.go b/management/server/affected_peers_router_test.go new file mode 100644 index 000000000..cc9df0a6a --- /dev/null +++ b/management/server/affected_peers_router_test.go @@ -0,0 +1,776 @@ +package server + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" + "github.com/netbirdio/netbird/management/server/affectedpeers" + "github.com/netbirdio/netbird/management/server/groups" + "github.com/netbirdio/netbird/management/server/networks" + "github.com/netbirdio/netbird/management/server/networks/resources" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + "github.com/netbirdio/netbird/management/server/networks/routers" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + networkTypes "github.com/netbirdio/netbird/management/server/networks/types" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +// routerScenario captures the topology from the bug report: +// +// network ── router (routing peer) ── resource (in resourceGroup) +// independent peer ──(policy: source -> resource)──> resource +// +// The routing peer must be refreshed when a policy grants a source peer access +// to the resource, because the network map connects the source peer to the +// routing peer at compute time (Account.GetPoliciesForNetworkResource + +// addNetworksRoutingPeers). The routing peer is NOT a member of the resource +// group, so static group/peer resolution alone cannot find it. +type routerScenario struct { + manager *DefaultAccountManager + updateManager *update_channel.PeersUpdateManager + accountID string + networkID string + + sourcePeerID string // independent peer that the policy grants access from + sourceGroupID string // group containing the source peer + + routerPeerID string // peer acting as the routing peer (direct router.Peer) + routerGroupPeerID string // peer that is a member of routerPeerGroup + routerPeerGroupID string // group used for router.PeerGroups + + resourceID string // network resource + resourceGroupID string // group whose member is the resource (no peers) + + unrelatedPeerID string // peer in no relevant entity +} + +// setupRouterScenario builds the topology above with the default policy removed +// and channels NOT yet created, so callers control exactly when updates can flow. +func setupRouterScenario(t *testing.T, directRouterPeer bool) *routerScenario { + t.Helper() + + manager, updateManager, err := createManager(t) + require.NoError(t, err) + + ctx := context.Background() + + account, err := createAccount(manager, "router_scenario", userID, "") + require.NoError(t, err) + accountID := account.Id + + // Remove the default policy so AddPeer/CreateGroup don't schedule unrelated updates. + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + require.NoError(t, manager.Store.DeletePolicy(ctx, accountID, p.ID)) + } + + setupKey, err := manager.CreateSetupKey(ctx, accountID, "rs-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + + sourcePeer := addPeerToAccount(t, manager, accountID, setupKey.Key) + routerPeer := addPeerToAccount(t, manager, accountID, setupKey.Key) + routerGroupPeer := addPeerToAccount(t, manager, accountID, setupKey.Key) + unrelatedPeer := addPeerToAccount(t, manager, accountID, setupKey.Key) + + const ( + sourceGroupID = "rs-source-grp" + routerPeerGroupID = "rs-router-grp" + resourceGroupID = "rs-resource-grp" + ) + + for _, g := range []*types.Group{ + {ID: sourceGroupID, Name: "rs-source", Peers: []string{sourcePeer.ID}}, + {ID: routerPeerGroupID, Name: "rs-router", Peers: []string{routerGroupPeer.ID}}, + {ID: resourceGroupID, Name: "rs-resource"}, // intentionally peerless; the resource is its only member + } { + require.NoError(t, manager.CreateGroup(ctx, accountID, userID, g)) + } + + permissionsManager := permissions.NewManager(manager.Store) + groupsManager := groups.NewManager(manager.Store, permissionsManager, manager) + resourcesManager := resources.NewManager(manager.Store, permissionsManager, groupsManager, manager, manager.serviceManager) + routersManager := routers.NewManager(manager.Store, permissionsManager, manager) + networksManager := networks.NewManager(manager.Store, permissionsManager, resourcesManager, routersManager, manager) + + network, err := networksManager.CreateNetwork(ctx, userID, &networkTypes.Network{ + ID: "rs-network", + AccountID: accountID, + Name: "rs-network", + }) + require.NoError(t, err) + + resource, err := resourcesManager.CreateResource(ctx, userID, &resourceTypes.NetworkResource{ + AccountID: accountID, + NetworkID: network.ID, + Name: "rs-resource-host", + Address: "10.20.30.0/24", + GroupIDs: []string{resourceGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + router := &routerTypes.NetworkRouter{ + ID: "rs-router", + NetworkID: network.ID, + AccountID: accountID, + Masquerade: true, + Metric: 9999, + Enabled: true, + } + if directRouterPeer { + router.Peer = routerPeer.ID + } else { + router.PeerGroups = []string{routerPeerGroupID} + } + _, err = routersManager.CreateRouter(ctx, userID, router) + require.NoError(t, err) + + return &routerScenario{ + manager: manager, + updateManager: updateManager, + accountID: accountID, + networkID: network.ID, + sourcePeerID: sourcePeer.ID, + sourceGroupID: sourceGroupID, + routerPeerID: routerPeer.ID, + routerGroupPeerID: routerGroupPeer.ID, + routerPeerGroupID: routerPeerGroupID, + resourceID: resource.ID, + resourceGroupID: resourceGroupID, + unrelatedPeerID: unrelatedPeer.ID, + } +} + +// peerToResourcePolicy builds a policy granting the source group access to the +// resource, referencing the resource by its group in the rule destination. +func peerToResourcePolicyByGroup(sourceGroupID, resourceGroupID string) *types.Policy { + return &types.Policy{ + Enabled: true, + Name: "peer-to-resource-by-group", + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{sourceGroupID}, + Destinations: []string{resourceGroupID}, + Action: types.PolicyTrafficActionAccept, + }, + }, + } +} + +// peerToResourcePolicyByResource builds a policy referencing the resource +// directly via DestinationResource rather than its group. +func peerToResourcePolicyByResource(sourceGroupID, resourceID string) *types.Policy { + return &types.Policy{ + Enabled: true, + Name: "peer-to-resource-by-resource", + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{sourceGroupID}, + DestinationResource: types.Resource{ID: resourceID, Type: types.ResourceTypeHost}, + Action: types.PolicyTrafficActionAccept, + }, + }, + } +} + +// resolvePolicyAffected mirrors SavePolicy's resolution: resolve the affected +// peers for the given policy. +func (s *routerScenario) resolvePolicyAffected(ctx context.Context, policy *types.Policy) []string { + change := affectedpeers.Change{Policies: []*types.Policy{policy}} + snap, err := affectedpeers.Load(ctx, s.manager.Store, s.accountID, change) + if err != nil { + return nil + } + return snap.Expand(ctx, s.accountID, change) +} + +func TestAffectedPeers_SourcePeer_DirectRouter(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID) + affected := s.resolvePolicyAffected(ctx, policy) + + assert.Contains(t, affected, s.sourcePeerID, "source peer must be affected") +} + +func TestAffectedPeers_RoutingPeer_DirectRouter(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID) + affected := s.resolvePolicyAffected(ctx, policy) + + // BUG: the direct routing peer serves the resource's subnet to the source + // peer, so it must be refreshed when the policy is created. The policy path + // only resolves the literal rule groups (source group + resource group); + // the resource group has no peer members and the router peer is reachable + // only through the network, so it is dropped. + assert.Contains(t, affected, s.routerPeerID, + "routing peer (router.Peer) serving the resource must be affected by a policy granting access to it") +} + +func TestAffectedPeers_RoutingPeer_RouterPeerGroups(t *testing.T) { + s := setupRouterScenario(t, false) + ctx := context.Background() + + policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID) + affected := s.resolvePolicyAffected(ctx, policy) + + // Router defined via PeerGroups instead of a direct peer. + assert.Contains(t, affected, s.routerGroupPeerID, + "routing peer (router.PeerGroups member) serving the resource must be affected") +} + +func TestAffectedPeers_DestResource_RoutingPeer_DirectRouter(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + policy := peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID) + affected := s.resolvePolicyAffected(ctx, policy) + + // When the resource is referenced via DestinationResource, RuleGroups() + // returns only the source group and the resource ID is not a peer, so + // collectPolicyAffectedGroupsAndPeers yields nothing for the destination at + // all. The routing peer is dropped here too. + assert.Contains(t, affected, s.routerPeerID, + "routing peer must be affected when the resource is referenced via DestinationResource") +} + +func TestAffectedPeers_DestResource_RoutingPeer_RouterPeerGroups(t *testing.T) { + s := setupRouterScenario(t, false) + ctx := context.Background() + + policy := peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID) + affected := s.resolvePolicyAffected(ctx, policy) + + assert.Contains(t, affected, s.routerGroupPeerID, + "routing peer (PeerGroups) must be affected when the resource is referenced via DestinationResource") +} + +func TestAffectedPeers_SourceResourcePeer_RoutingPeer(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + // Source expressed as a direct peer (SourceResource), destination as resource group. + policy := &types.Policy{ + Enabled: true, + Name: "sourceResource-peer-to-resource", + Rules: []*types.PolicyRule{ + { + Enabled: true, + SourceResource: types.Resource{ID: s.sourcePeerID, Type: types.ResourceTypePeer}, + Destinations: []string{s.resourceGroupID}, + Action: types.PolicyTrafficActionAccept, + }, + }, + } + affected := s.resolvePolicyAffected(ctx, policy) + + // The direct source peer IS picked up (collectPolicyAffectedGroupsAndPeers + // handles SourceResource peers), but the routing peer is still missing. + assert.Contains(t, affected, s.sourcePeerID, "direct source peer must be affected") + assert.Contains(t, affected, s.routerPeerID, "routing peer must be affected") +} + +func TestAffectedPeers_PolicyToResource_UnrelatedPeerNotAffected(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID) + affected := s.resolvePolicyAffected(ctx, policy) + + // Guard against an over-broad fix: a peer in no relevant entity must never + // be pulled in. + assert.NotContains(t, affected, s.unrelatedPeerID, "unrelated peer must not be affected") +} + +func TestAffectedPeers_ResourceSideBridgesToRoutingPeer_DirectRouter(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + // A pre-existing policy grants the source group access to the resource. + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + // Drive an update through the resource manager and assert the routing peer + // is among the affected set by observing the channel. This path walks + // policies whose destinations reference the resource's groups, folds in the + // source groups, and loads the network's routers, so it reaches both the + // source peer and the routing peer. + permissionsManager := permissions.NewManager(s.manager.Store) + groupsManager := groups.NewManager(s.manager.Store, permissionsManager, s.manager) + rm := resources.NewManager(s.manager.Store, permissionsManager, groupsManager, s.manager, s.manager.serviceManager) + + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + _, err = rm.UpdateResource(ctx, userID, &resourceTypes.NetworkResource{ + ID: s.resourceID, + AccountID: s.accountID, + NetworkID: s.networkID, + Name: "rs-resource-host", + Address: "10.20.30.0/24", + GroupIDs: []string{s.resourceGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: resource update did not refresh source peer + routing peer") + } +} + +// settleAffectedUpdates waits for in-flight async updates to arrive, then drains +// every given channel so subsequent assertions start from a clean slate. +// +// Setup (CreateNetwork/CreateResource/CreateRouter) fires async UpdateAffectedPeers +// goroutines; draining first means the assertion only observes updates from the +// action under test, not setup stragglers. +func settleAffectedUpdates(chans ...<-chan *network_map.UpdateMessage) { + time.Sleep(300 * time.Millisecond) + for _, ch := range chans { + drainPeerUpdates(ch) + } +} + +func TestAffectedPeers_E2E_CreatePolicy_RoutingPeer_DirectRouter(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + unrelatedCh := s.updateManager.CreateChannel(ctx, s.unrelatedPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + s.updateManager.CloseChannel(ctx, s.unrelatedPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh, unrelatedCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + peerShouldNotReceiveUpdate(t, unrelatedCh) + close(done) + }() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: creating peer->resource policy did not refresh the routing peer") + } +} + +func TestAffectedPeers_E2E_CreatePolicy_RoutingPeer_RouterPeerGroups(t *testing.T) { + s := setupRouterScenario(t, false) + ctx := context.Background() + + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerGroupPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerGroupPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: routing peer (PeerGroups) not refreshed on policy create") + } +} + +func TestAffectedPeers_E2E_DestResource_RoutingPeer(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: routing peer not refreshed when policy targets DestinationResource") + } +} + +func TestAffectedPeers_E2E_DeletePolicy_RoutingPeer(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + require.NoError(t, s.manager.DeletePolicy(ctx, s.accountID, policy.ID, userID)) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: deleting peer->resource policy did not refresh the routing peer") + } +} + +func (s *routerScenario) managers() (resources.Manager, routers.Manager, networks.Manager) { + permissionsManager := permissions.NewManager(s.manager.Store) + groupsManager := groups.NewManager(s.manager.Store, permissionsManager, s.manager) + resourcesManager := resources.NewManager(s.manager.Store, permissionsManager, groupsManager, s.manager, s.manager.serviceManager) + routersManager := routers.NewManager(s.manager.Store, permissionsManager, s.manager) + networksManager := networks.NewManager(s.manager.Store, permissionsManager, resourcesManager, routersManager, s.manager) + return resourcesManager, routersManager, networksManager +} + +type secondTopology struct { + networkID string + resourceID string + resourceGroupID string + routerPeerID string +} + +func (s *routerScenario) addSecondTopology(t *testing.T, suffix string) secondTopology { + t.Helper() + ctx := context.Background() + resourcesManager, routersManager, networksManager := s.managers() + + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-"+suffix, types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + routerPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + + resourceGroupID := "rs-resource-grp-" + suffix + require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ + ID: resourceGroupID, Name: "rs-resource-" + suffix, + })) + + network, err := networksManager.CreateNetwork(ctx, userID, &networkTypes.Network{ + ID: "rs-network-" + suffix, + AccountID: s.accountID, + Name: "rs-network-" + suffix, + }) + require.NoError(t, err) + + resource, err := resourcesManager.CreateResource(ctx, userID, &resourceTypes.NetworkResource{ + AccountID: s.accountID, + NetworkID: network.ID, + Name: "rs-resource-host-" + suffix, + Address: "10.40.50.0/24", + GroupIDs: []string{resourceGroupID}, + Enabled: true, + }) + require.NoError(t, err) + + _, err = routersManager.CreateRouter(ctx, userID, &routerTypes.NetworkRouter{ + NetworkID: network.ID, + AccountID: s.accountID, + Peer: routerPeer.ID, + Masquerade: true, + Metric: 9999, + Enabled: true, + }) + require.NoError(t, err) + + return secondTopology{ + networkID: network.ID, + resourceID: resource.ID, + resourceGroupID: resourceGroupID, + routerPeerID: routerPeer.ID, + } +} + +func TestAffectedPeers_E2E_UpdatePolicy_BothRoutingPeers(t *testing.T) { + s := setupRouterScenario(t, true) + second := s.addSecondTopology(t, "b") + ctx := context.Background() + + policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerACh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + routerBCh := s.updateManager.CreateChannel(ctx, second.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + s.updateManager.CloseChannel(ctx, second.routerPeerID) + }) + + settleAffectedUpdates(srcCh, routerACh, routerBCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerACh) + peerShouldReceiveUpdate(t, routerBCh) + close(done) + }() + + policy.Rules[0].Destinations = []string{second.resourceGroupID} + _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, false) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: re-pointing the policy destination did not refresh both routing peers") + } +} + +func TestAffectedPeers_E2E_UpdatePolicy_AddSource(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + const secondSourceGroupID = "rs-source-grp-2" + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-2", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + secondSourcePeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + require.NoError(t, s.manager.CreateGroup(ctx, s.accountID, userID, &types.Group{ + ID: secondSourceGroupID, Name: "rs-source-2", Peers: []string{secondSourcePeer.ID}, + })) + + policy, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID), true) + require.NoError(t, err) + + newSrcCh := s.updateManager.CreateChannel(ctx, secondSourcePeer.ID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, secondSourcePeer.ID) + s.updateManager.CloseChannel(ctx, s.routerPeerID) + }) + + settleAffectedUpdates(newSrcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, newSrcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + policy.Rules[0].Sources = []string{s.sourceGroupID, secondSourceGroupID} + _, err = s.manager.SavePolicy(ctx, s.accountID, userID, policy, false) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: adding a source group did not refresh the new source peer + routing peer") + } +} + +func TestAffectedPeers_E2E_DestResource_RouterPeerGroups(t *testing.T) { + s := setupRouterScenario(t, false) + ctx := context.Background() + + srcCh := s.updateManager.CreateChannel(ctx, s.sourcePeerID) + routerCh := s.updateManager.CreateChannel(ctx, s.routerGroupPeerID) + t.Cleanup(func() { + s.updateManager.CloseChannel(ctx, s.sourcePeerID) + s.updateManager.CloseChannel(ctx, s.routerGroupPeerID) + }) + + settleAffectedUpdates(srcCh, routerCh) + + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, srcCh) + peerShouldReceiveUpdate(t, routerCh) + close(done) + }() + + _, err := s.manager.SavePolicy(ctx, s.accountID, userID, peerToResourcePolicyByResource(s.sourceGroupID, s.resourceID), true) + require.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout: DestinationResource policy with PeerGroups router did not refresh the routing peer") + } +} + +func TestAffectedPeers_AllRoutingPeers_Network(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + _, routersManager, _ := s.managers() + setupKey, err := s.manager.CreateSetupKey(ctx, s.accountID, "rs-key-r2", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + secondRouterPeer := addPeerToAccount(t, s.manager, s.accountID, setupKey.Key) + _, err = routersManager.CreateRouter(ctx, userID, &routerTypes.NetworkRouter{ + NetworkID: s.networkID, + AccountID: s.accountID, + Peer: secondRouterPeer.ID, + Masquerade: true, + Metric: 9998, + Enabled: true, + }) + require.NoError(t, err) + + affected := s.resolvePolicyAffected(ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)) + + assert.Contains(t, affected, s.routerPeerID, "first routing peer must be affected") + assert.Contains(t, affected, secondRouterPeer.ID, "second routing peer on the same network must also be affected") +} + +// A disabled router in the snapshot routes to nobody, so it is skipped when the +// walk scans existing account data: a policy edit still folds the literal source +// group, but not the disabled router's peer. +func TestAffectedPeers_DisabledRouter(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + routers, err := s.manager.Store.GetNetworkRoutersByNetID(ctx, store.LockingStrengthNone, s.accountID, s.networkID) + require.NoError(t, err) + require.Len(t, routers, 1) + routers[0].Enabled = false + require.NoError(t, s.manager.Store.UpdateNetworkRouter(ctx, routers[0])) + + affected := s.resolvePolicyAffected(ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)) + + assert.Contains(t, affected, s.sourcePeerID, "source peer (literal policy source group) must be affected") + assert.NotContains(t, affected, s.routerPeerID, + "a disabled router routes to nobody, so its peer must not be folded from snapshot data") +} + +// A disabled resource in the snapshot is skipped: the policy edit still folds the +// literal source group, but the resource no longer bridges to its network's router. +func TestAffectedPeers_DisabledResource(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + res, err := s.manager.Store.GetNetworkResourceByID(ctx, store.LockingStrengthNone, s.accountID, s.resourceID) + require.NoError(t, err) + res.Enabled = false + require.NoError(t, s.manager.Store.SaveNetworkResource(ctx, res)) + + affected := s.resolvePolicyAffected(ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)) + + assert.Contains(t, affected, s.sourcePeerID, "source peer (literal policy source group) must be affected") + assert.NotContains(t, affected, s.routerPeerID, + "a disabled resource routes to nobody, so its network's router must not be folded from snapshot data") +} + +func TestAffectedPeers_DisabledRule(t *testing.T) { + s := setupRouterScenario(t, true) + ctx := context.Background() + + policy := peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID) + policy.Rules[0].Enabled = false + + affected := s.resolvePolicyAffected(ctx, policy) + + assert.Contains(t, affected, s.routerPeerID, + "disabled rule must still resolve the routing peer: Enabled must not gate affected-peers") +} + +func TestAffectedPeers_MultiRule(t *testing.T) { + s := setupRouterScenario(t, true) + second := s.addSecondTopology(t, "c") + ctx := context.Background() + + policy := &types.Policy{ + Enabled: true, + Name: "multi-rule-two-resources", + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{s.sourceGroupID}, + Destinations: []string{s.resourceGroupID}, + Action: types.PolicyTrafficActionAccept, + }, + { + Enabled: true, + Sources: []string{s.sourceGroupID}, + Destinations: []string{second.resourceGroupID}, + Action: types.PolicyTrafficActionAccept, + }, + }, + } + + affected := s.resolvePolicyAffected(ctx, policy) + + assert.Contains(t, affected, s.routerPeerID, "routing peer for resource A must be affected") + assert.Contains(t, affected, second.routerPeerID, "routing peer for resource B must be affected") +} + +func TestAffectedPeers_RouterOtherNetwork(t *testing.T) { + s := setupRouterScenario(t, true) + second := s.addSecondTopology(t, "d") + ctx := context.Background() + + affected := s.resolvePolicyAffected(ctx, peerToResourcePolicyByGroup(s.sourceGroupID, s.resourceGroupID)) + + assert.Contains(t, affected, s.routerPeerID, "network A's routing peer must be affected") + assert.NotContains(t, affected, second.routerPeerID, + "a router in an unrelated network must not be affected by a policy that does not target its resource") +} diff --git a/management/server/affected_peers_test.go b/management/server/affected_peers_test.go new file mode 100644 index 000000000..235128693 --- /dev/null +++ b/management/server/affected_peers_test.go @@ -0,0 +1,1853 @@ +package server + +import ( + "context" + "fmt" + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + nbdns "github.com/netbirdio/netbird/dns" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/affectedpeers" + 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/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/route" +) + +// resolveAffected is a test helper for the resolver's Load+Expand, used where a +// test asserts on the fully expanded affected peer set. +func resolveAffected(t *testing.T, s store.Store, accountID string, change affectedpeers.Change) []string { + t.Helper() + ctx := context.Background() + snap, err := affectedpeers.Load(ctx, s, accountID, change) + require.NoError(t, err) + return snap.Expand(ctx, accountID, change) +} + +// Thin test adapters over affectedpeers.Collect, preserving the (groups, peers) +// shape these tests assert on after the resolver was unified. +func collectGroupChangeAffectedGroups(ctx context.Context, s store.Store, accountID string, changedGroupIDs []string) ([]string, []string) { + return affectedpeers.Collect(ctx, s, accountID, affectedpeers.Change{ChangedGroupIDs: changedGroupIDs}) +} + +func collectPeerChangeAffectedGroups(ctx context.Context, s store.Store, accountID string, changedGroupIDs, changedPeerIDs []string) ([]string, []string) { + return affectedpeers.Collect(ctx, s, accountID, affectedpeers.Change{ChangedGroupIDs: changedGroupIDs, ChangedPeerIDs: changedPeerIDs}) +} + +func collectPostureCheckAffectedGroupsAndPeers(ctx context.Context, s store.Store, accountID, postureCheckID string) ([]string, []string) { + return affectedpeers.Collect(ctx, s, accountID, affectedpeers.Change{PostureCheckIDs: []string{postureCheckID}}) +} + +// setupAffectedPeersTest creates a manager with a clean account (default policy deleted) +// and 5 peers, each in its own group: peer0->group0, peer1->group1, ..., peer4->group4. +func setupAffectedPeersTest(t *testing.T) (*DefaultAccountManager, store.Store, string, []string, []string) { + t.Helper() + + manager, _, err := createManager(t) + require.NoError(t, err) + + account, err := createAccount(manager, "affected_test", userID, "") + require.NoError(t, err) + + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + setupKey, err := manager.CreateSetupKey(ctx, accountID, "test-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + + peerIDs := make([]string, 5) + for i := 0; i < 5; i++ { + peer := addPeerToAccount(t, manager, accountID, setupKey.Key) + peerIDs[i] = peer.ID + } + + groupIDs := make([]string, 5) + for i := 0; i < 5; i++ { + g := &types.Group{ + ID: affectedGroupID(i), + Name: affectedGroupName(i), + Peers: []string{peerIDs[i]}, + } + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + groupIDs[i] = g.ID + } + + return manager, manager.Store, accountID, peerIDs, groupIDs +} + +func affectedGroupID(i int) string { return fmt.Sprintf("affected-grp-%d", i) } +func affectedGroupName(i int) string { return fmt.Sprintf("AffectedGroup%d", i) } + +func TestCollectGroupChange_PolicyLinked(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + SourceResource: types.Resource{ID: peerIDs[0], Type: types.ResourceTypePeer}, + DestinationResource: types.Resource{ID: peerIDs[1], Type: types.ResourceTypePeer}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + SourceResource: types.Resource{ID: peerIDs[2], Type: types.ResourceTypeHost}, + DestinationResource: types.Resource{ID: peerIDs[3], Type: types.ResourceTypeHost}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + SourceResource: types.Resource{ID: "", Type: types.ResourceTypePeer}, + DestinationResource: types.Resource{ID: "", Type: types.ResourceTypePeer}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) + assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]}) + assert.ElementsMatch(t, directPeers, []string{peerIDs[1]}) + + groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]}) + assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]}) + assert.ElementsMatch(t, directPeers, []string{peerIDs[0]}) + + groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]}) + assert.Empty(t, groups) + assert.Empty(t, directPeers) +} + +func TestCollectGroupChange_PolicyWithDirectPeerResource(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0]}, + SourceResource: types.Resource{ID: peerIDs[3], Type: types.ResourceTypePeer}, + DestinationResource: types.Resource{ID: peerIDs[4], Type: types.ResourceTypePeer}, + Destinations: []string{groupIDs[1]}, + Action: types.PolicyTrafficActionAccept, + }, + { + Enabled: true, + Sources: []string{groupIDs[0]}, + SourceResource: types.Resource{ID: peerIDs[1], Type: types.ResourceTypeHost}, + DestinationResource: types.Resource{ID: peerIDs[2], Type: types.ResourceTypeHost}, + Destinations: []string{groupIDs[1]}, + Action: types.PolicyTrafficActionAccept, + }, + { + Enabled: true, + Sources: []string{groupIDs[0]}, + SourceResource: types.Resource{ID: "", Type: types.ResourceTypePeer}, + DestinationResource: types.Resource{ID: "", Type: types.ResourceTypePeer}, + Destinations: []string{groupIDs[1]}, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) + assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]}) + assert.ElementsMatch(t, directPeers, []string{peerIDs[4]}) + + groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]}) + assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]}) + assert.ElementsMatch(t, directPeers, []string{peerIDs[3]}) + + groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]}) + assert.Empty(t, groups) + assert.Empty(t, directPeers) +} + +func TestCollectGroupChange_PolicyWithNonPeerResource_NoDirectPeers(t *testing.T) { + manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0]}, + SourceResource: types.Resource{ID: "some-domain", Type: types.ResourceTypeDomain}, + Destinations: []string{groupIDs[1]}, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) + assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]}) + assert.Empty(t, directPeers, "non-peer resources should not produce direct peer IDs") +} + +func TestCollectGroupChange_RouteLinked(t *testing.T) { + manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.0.0.0/24"), + route.IPv4Network, + nil, + "", + []string{groupIDs[0]}, + "test route", + "testnet", + false, + 9999, + []string{groupIDs[1]}, + []string{groupIDs[2]}, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) + assert.Contains(t, groups, groupIDs[0]) + assert.Contains(t, groups, groupIDs[1]) + assert.Contains(t, groups, groupIDs[2]) + + groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]}) + assert.Contains(t, groups, groupIDs[0]) + assert.Contains(t, groups, groupIDs[1]) + assert.Contains(t, groups, groupIDs[2]) + + groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[3]}) + assert.Empty(t, groups) +} + +func TestCollectGroupChange_RouteWithDirectPeer(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.1.0.0/24"), + route.IPv4Network, + nil, + peerIDs[4], + nil, + "test route peer", + "testnet2", + false, + 9999, + []string{groupIDs[1]}, + nil, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]}) + assert.Contains(t, groups, groupIDs[1]) + assert.Contains(t, directPeers, peerIDs[4]) +} + +func TestCollectGroupChange_NameServerGroupLinked(t *testing.T) { + manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.CreateNameServerGroup(ctx, accountID, "ns1", "NS Group 1", + []nbdns.NameServer{{ + IP: netip.MustParseAddr("1.1.1.1"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{groupIDs[0]}, + true, nil, true, userID, false, + ) + require.NoError(t, err) + + groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) + assert.Contains(t, groups, groupIDs[0]) + + groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]}) + assert.Empty(t, groups) +} + +func TestCollectGroupChange_DNSSettingsLinked(t *testing.T) { + manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + err := manager.SaveDNSSettings(ctx, accountID, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{groupIDs[2]}, + }) + require.NoError(t, err) + + groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]}) + assert.Contains(t, groups, groupIDs[2]) + + groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) + assert.Empty(t, groups) +} + +func TestCollectGroupChange_NetworkRouterLinked(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + net1 := &networkTypes.Network{ + ID: "net-test-1", + AccountID: accountID, + Name: "test-network", + } + err := manager.Store.SaveNetwork(ctx, net1) + require.NoError(t, err) + + err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{ + ID: "router1", + NetworkID: net1.ID, + AccountID: accountID, + PeerGroups: []string{groupIDs[0]}, + Peer: peerIDs[3], + Enabled: true, + }) + require.NoError(t, err) + + groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) + assert.Contains(t, groups, groupIDs[0]) + assert.Contains(t, directPeers, peerIDs[3]) + + groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[1]}) + assert.Empty(t, groups) + assert.Empty(t, directPeers) +} + +func TestCollectGroupChange_NetworkRouterPeerOnlyNoGroups(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + net1 := &networkTypes.Network{ + ID: "net-peer-only", + AccountID: accountID, + Name: "peer-only-network", + } + err := manager.Store.SaveNetwork(ctx, net1) + require.NoError(t, err) + + // Router with only a direct peer, no PeerGroups + err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{ + ID: "router-peer-only", + NetworkID: net1.ID, + AccountID: accountID, + Peer: peerIDs[4], + Enabled: true, + }) + require.NoError(t, err) + + // None of the groups should match since router has no PeerGroups + for i := 0; i < 5; i++ { + groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[i]}) + assert.Empty(t, groups, "group%d should not match router with only direct peer", i) + assert.Empty(t, directPeers, "group%d should not produce direct peers", i) + } +} + +func TestCollectGroupChange_MultipleEntities(t *testing.T) { + manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + _, err = manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.2.0.0/24"), + route.IPv4Network, + nil, + "", + []string{groupIDs[2]}, + "multi route", + "multinet", + false, + 9999, + []string{groupIDs[3]}, + nil, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + groups, directPeers := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) + assert.ElementsMatch(t, groups, []string{groupIDs[0], groupIDs[1]}) + assert.Empty(t, directPeers) + + groups, directPeers = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[3]}) + assert.ElementsMatch(t, groups, []string{groupIDs[2], groupIDs[3]}) + assert.Empty(t, directPeers) +} + +func TestCollectGroupChange_MultipleNameServerGroups_OnlyLinkedAffected(t *testing.T) { + manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + // Create two nameserver groups using different groups + _, err := manager.CreateNameServerGroup(ctx, accountID, "ns-a", "NS-A", + []nbdns.NameServer{{ + IP: netip.MustParseAddr("1.1.1.1"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{groupIDs[0]}, + true, nil, true, userID, false, + ) + require.NoError(t, err) + + _, err = manager.CreateNameServerGroup(ctx, accountID, "ns-b", "NS-B", + []nbdns.NameServer{{ + IP: netip.MustParseAddr("8.8.8.8"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{groupIDs[2]}, + true, nil, true, userID, false, + ) + require.NoError(t, err) + + // Changing group0 should only find group0 (from ns-a), not group2 (from ns-b) + groups, _ := collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[0]}) + assert.Contains(t, groups, groupIDs[0]) + assert.NotContains(t, groups, groupIDs[2]) + + groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[2]}) + assert.Contains(t, groups, groupIDs[2]) + assert.NotContains(t, groups, groupIDs[0]) + + // Unrelated group + groups, _ = collectGroupChangeAffectedGroups(ctx, s, accountID, []string{groupIDs[4]}) + assert.Empty(t, groups) +} + +func TestResolveAffectedPeers_PolicyBetweenTwoGroups(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result) + + result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[1]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result) + + // peerIDs[2] is unrelated to the route; only its own map can change. + result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]}) + assert.ElementsMatch(t, []string{peerIDs[2]}, result) +} + +func TestResolveAffectedPeers_PolicyThreeGroups(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0], groupIDs[1]}, + Destinations: []string{groupIDs[2]}, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[2]}, result) +} + +func TestResolveAffectedPeers_RoutePeerGroups(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.3.0.0/24"), + route.IPv4Network, + nil, + "", + []string{groupIDs[0]}, + "test route", + "routenet", + false, + 9999, + []string{groupIDs[1]}, + nil, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result) + + result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[1]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result) + + // peerIDs[2] is in no policy; only its own map can change, so it refreshes itself. + result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]}) + assert.ElementsMatch(t, []string{peerIDs[2]}, result) +} + +func TestResolveAffectedPeers_RouteWithDirectPeer(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.4.0.0/24"), + route.IPv4Network, + nil, + peerIDs[4], + nil, + "route with peer", + "routenet2", + false, + 9999, + []string{groupIDs[1]}, + nil, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[1]}) + assert.ElementsMatch(t, []string{peerIDs[1], peerIDs[4]}, result) +} + +func TestResolveAffectedPeers_RouteWithAccessControlGroups(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.7.0.0/24"), + route.IPv4Network, + nil, + "", + []string{groupIDs[0]}, + "acl route", + "aclnet", + false, + 9999, + []string{groupIDs[1]}, + []string{groupIDs[2]}, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + // peer2 is only in AccessControlGroups, still should be affected + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2]}, result) + + // peer3 is unrelated to the route; only its own map can change. + result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[3]}) + assert.ElementsMatch(t, []string{peerIDs[3]}, result) +} + +func TestResolveAffectedPeers_NetworkRouter(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + net1 := &networkTypes.Network{ + ID: "net-test-2", + AccountID: accountID, + Name: "test-net", + } + err := manager.Store.SaveNetwork(ctx, net1) + require.NoError(t, err) + + err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{ + ID: "router-test", + NetworkID: net1.ID, + AccountID: accountID, + PeerGroups: []string{groupIDs[0]}, + Peer: peerIDs[3], + Enabled: true, + }) + require.NoError(t, err) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[3]}, result) +} + +func TestResolveAffectedPeers_NameServerGroup(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.CreateNameServerGroup(ctx, accountID, "ns-test", "NS Test", + []nbdns.NameServer{{ + IP: netip.MustParseAddr("8.8.8.8"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{groupIDs[0]}, + true, nil, true, userID, false, + ) + require.NoError(t, err) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + assert.Contains(t, result, peerIDs[0]) +} + +func TestResolveAffectedPeers_DNSSettings(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + err := manager.SaveDNSSettings(ctx, accountID, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{groupIDs[0]}, + }) + require.NoError(t, err) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + assert.Contains(t, result, peerIDs[0]) +} + +func TestResolveAffectedPeers_PeerInMultipleGroups(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + err := manager.GroupAddPeer(ctx, accountID, groupIDs[1], peerIDs[0]) + require.NoError(t, err) + + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[2]}, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[1]}, + Destinations: []string{groupIDs[3]}, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + // peer0 is in group0 AND group1, so both policies apply. A peer change folds + // only the changed peer plus the opposite side of each rule: group2 (peer2) via + // the group0 policy and group3 (peer3) via the group1 policy. peer1, a co-member + // of group1, is a sibling of the changed peer and must NOT refresh. + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[2], peerIDs[3]}, result) + assert.NotContains(t, result, peerIDs[1], "co-member of the changed peer's group must not refresh") +} + +func TestResolveAffectedPeers_MultipleChangedPeers(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[2]}, + Destinations: []string{groupIDs[3]}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0], peerIDs[2]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[2], peerIDs[1], peerIDs[3]}, result) +} + +func TestResolveAffectedPeers_SharedGroupAcrossPolicyAndRoute(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + _, err = manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.5.0.0/24"), + route.IPv4Network, + nil, + "", + []string{groupIDs[2]}, + "shared group route", + "sharednet", + false, + 9999, + []string{groupIDs[0]}, + nil, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + // group0 is shared: policy gives peer0+peer1, route gives peer0+peer2 + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1], peerIDs[2]}, result) +} + +func TestResolveAffectedPeers_NoDuplicates(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + err := manager.GroupAddPeer(ctx, accountID, groupIDs[1], peerIDs[0]) + require.NoError(t, err) + err = manager.GroupAddPeer(ctx, accountID, groupIDs[2], peerIDs[0]) + require.NoError(t, err) + + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0], groupIDs[1]}, + Destinations: []string{groupIDs[2]}, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + count := 0 + for _, id := range result { + if id == peerIDs[0] { + count++ + } + } + assert.Equal(t, 1, count, "peer0 should appear exactly once") +} + +func TestCollectPostureCheckAffected_LinkedToPolicy(t *testing.T) { + manager, s, accountID, _, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + // Create the posture check in the store so the policy validation keeps the reference. + err := s.SavePostureChecks(ctx, &posture.Checks{ + ID: "pc-1", + Name: "test-posture-check", + AccountID: accountID, + }) + require.NoError(t, err) + + policy, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + SourcePostureChecks: []string{"pc-1"}, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + _ = policy + + groups, directPeers := collectPostureCheckAffectedGroupsAndPeers(ctx, s, accountID, "pc-1") + assert.Contains(t, groups, groupIDs[0]) + assert.Contains(t, groups, groupIDs[1]) + assert.Empty(t, directPeers) + + // Different posture check ID should not match + groups, directPeers = collectPostureCheckAffectedGroupsAndPeers(ctx, s, accountID, "pc-other") + assert.Empty(t, groups) + assert.Empty(t, directPeers) +} + +func TestAffectedPeers_IsolatedPolicies(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[2]}, + Destinations: []string{groupIDs[3]}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result) + assert.NotContains(t, result, peerIDs[2]) + assert.NotContains(t, result, peerIDs[3]) + + result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]}) + assert.ElementsMatch(t, []string{peerIDs[2], peerIDs[3]}, result) + assert.NotContains(t, result, peerIDs[0]) + assert.NotContains(t, result, peerIDs[1]) + + // peerIDs[4] is in neither isolated policy; only its own map can change. + result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[4]}) + assert.ElementsMatch(t, []string{peerIDs[4]}, result) +} + +func TestAffectedPeers_IsolatedRouteAndPolicy(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{groupIDs[0]}, + Destinations: []string{groupIDs[1]}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + _, err = manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.6.0.0/24"), + route.IPv4Network, + nil, + "", + []string{groupIDs[2]}, + "isolated route", + "isonet", + false, + 9999, + []string{groupIDs[3]}, + nil, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + assert.ElementsMatch(t, []string{peerIDs[0], peerIDs[1]}, result) + assert.NotContains(t, result, peerIDs[2]) + assert.NotContains(t, result, peerIDs[3]) + + result = manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[2]}) + assert.ElementsMatch(t, []string{peerIDs[2], peerIDs[3]}, result) + assert.NotContains(t, result, peerIDs[0]) + assert.NotContains(t, result, peerIDs[1]) +} + +func TestAffectedPeers_GroupUpdateOnlyAffectsLinkedPeers(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + for _, g := range []*types.Group{ + {ID: "ap-grpA", Name: "AP-A", Peers: []string{peer1.ID}}, + {ID: "ap-grpB", Name: "AP-B", Peers: []string{peer2.ID}}, + {ID: "ap-grpC", Name: "AP-C", Peers: []string{peer3.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"ap-grpA"}, + Destinations: []string{"ap-grpB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + result := manager.resolveAffectedPeersForPeerChanges(ctx, manager.Store, accountID, []string{peer1.ID}) + assert.ElementsMatch(t, []string{peer1.ID, peer2.ID}, result) + + t.Run("group change updates all peers in policy groups", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ + ID: "ap-grpA", + Name: "AP-A", + Peers: []string{peer1.ID, peer3.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) +} + +// A peer in no policy/route refreshes only itself — no other peer is affected. +func TestAffectedPeers_UnlinkedPeerChange_RefreshesSelfOnly(t *testing.T) { + manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t) + ctx := context.Background() + + result := manager.resolveAffectedPeersForPeerChanges(ctx, s, accountID, []string{peerIDs[0]}) + assert.ElementsMatch(t, []string{peerIDs[0]}, result) +} + +// TestAffectedPeers_PolicyChange_UnrelatedPeerNoUpdate verifies that creating/deleting a +// policy only sends updates to peers in the policy's groups, not to unrelated peers. +func TestAffectedPeers_PolicyChange_UnrelatedPeerNoUpdate(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + for _, g := range []*types.Group{ + {ID: "pol-grpA", Name: "Pol-A", Peers: []string{peer1.ID}}, + {ID: "pol-grpB", Name: "Pol-B", Peers: []string{peer2.ID}}, + {ID: "pol-grpC", Name: "Pol-C", Peers: []string{peer3.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + t.Run("create policy only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + _, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"pol-grpA"}, + Destinations: []string{"pol-grpB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) +} + +// TestAffectedPeers_RouteChange_UnrelatedPeerNoUpdate verifies that creating a route +// only sends updates to peers in the route's groups, not to unrelated peers. +func TestAffectedPeers_RouteChange_UnrelatedPeerNoUpdate(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + for _, g := range []*types.Group{ + {ID: "rt-grpA", Name: "Rt-A", Peers: []string{peer1.ID}}, + {ID: "rt-grpB", Name: "Rt-B", Peers: []string{peer2.ID}}, + {ID: "rt-grpC", Name: "Rt-C", Peers: []string{peer3.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + t.Run("create route only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + _, err := manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.10.0.0/24"), + route.IPv4Network, + nil, + "", + []string{"rt-grpA"}, + "test route", + "routenoaffect", + false, + 9999, + []string{"rt-grpB"}, + nil, + true, + userID, + false, + false, + ) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) +} + +// TestAffectedPeers_NameServerChange_UnrelatedPeerNoUpdate verifies that creating a +// nameserver group only sends updates to peers in its groups, not to unrelated peers. +func TestAffectedPeers_NameServerChange_UnrelatedPeerNoUpdate(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + for _, g := range []*types.Group{ + {ID: "ns-grpA", Name: "NS-A", Peers: []string{peer1.ID}}, + {ID: "ns-grpB", Name: "NS-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + t.Run("create nameserver group only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + _, err := manager.CreateNameServerGroup(ctx, accountID, "ns-unrelated", "NS Unrelated", + []nbdns.NameServer{{ + IP: netip.MustParseAddr("1.1.1.1"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"ns-grpA"}, + true, nil, true, userID, false, + ) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) +} + +// TestAffectedPeers_DNSSettingsChange_UnrelatedPeerNoUpdate verifies that changing DNS +// settings only sends updates to peers in the affected groups, not to unrelated peers. +func TestAffectedPeers_DNSSettingsChange_UnrelatedPeerNoUpdate(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + for _, g := range []*types.Group{ + {ID: "dns-grpA", Name: "DNS-A", Peers: []string{peer1.ID}}, + {ID: "dns-grpB", Name: "DNS-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + t.Run("dns settings change only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.SaveDNSSettings(ctx, accountID, userID, &types.DNSSettings{ + DisabledManagementGroups: []string{"dns-grpA"}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) +} + +// TestAffectedPeers_UnlinkedGroupChange_NoUpdateIntegration tests the full integration: +// updating a group that is NOT referenced by any policy/route/ns/dns should not send +// updates to any peer. +func TestAffectedPeers_UnlinkedGroupChange_NoUpdateIntegration(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + err = manager.CreateGroup(ctx, accountID, userID, &types.Group{ + ID: "unlinked-grp", + Name: "Unlinked", + Peers: []string{peer1.ID}, + }) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + t.Run("updating unlinked group sends no peer updates", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldNotReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ + ID: "unlinked-grp", + Name: "Unlinked", + Peers: []string{peer1.ID, peer2.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) +} + +// TestAffectedPeers_NetworkRouterUnlinkedPeerNoUpdate: a network router with peer +// groups updates only those groups' peers (and resource policy sources), not others. +func TestAffectedPeers_NetworkRouterUnlinkedPeerNoUpdate(t *testing.T) { + // Delete the default policy before adding peers so AddPeer schedules no async + // update that races with the test. + manager, updateManager, err := createManager(t) + require.NoError(t, err) + + ctx := context.Background() + + account, err := createAccount(manager, "nr_test_account", userID, "") + require.NoError(t, err) + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + setupKey, err := manager.CreateSetupKey(ctx, accountID, "test-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + + peer1 := addPeerToAccount(t, manager, accountID, setupKey.Key) + peer2 := addPeerToAccount(t, manager, accountID, setupKey.Key) + peer3 := addPeerToAccount(t, manager, accountID, setupKey.Key) + + for _, g := range []*types.Group{ + {ID: "nr-grpA", Name: "NR-A", Peers: []string{peer1.ID}}, + {ID: "nr-grpB", Name: "NR-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + net1 := &networkTypes.Network{ + ID: "nr-net-test", + AccountID: accountID, + Name: "nr-test-network", + } + err = manager.Store.SaveNetwork(ctx, net1) + require.NoError(t, err) + + err = manager.Store.CreateNetworkRouter(ctx, &routerTypes.NetworkRouter{ + ID: "nr-router-test", + NetworkID: net1.ID, + AccountID: accountID, + PeerGroups: []string{"nr-grpA"}, + Enabled: true, + }) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + t.Run("network router group change only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldReceiveUpdate(t, updMsg3) + close(done) + }() + + err = manager.UpdateGroup(ctx, accountID, userID, &types.Group{ + ID: "nr-grpA", + Name: "NR-A", + Peers: []string{peer1.ID, peer3.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) +} + +// TestAffectedPeers_IsolatedEntitiesOnlyAffectTheirPeers: with a policy (peer1<->peer2) +// and a separate route (peer3), changing one entity's groups affects only its peers. +func TestAffectedPeers_IsolatedEntitiesOnlyAffectTheirPeers(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + for _, g := range []*types.Group{ + {ID: "iso-grpA", Name: "ISO-A", Peers: []string{peer1.ID}}, + {ID: "iso-grpB", Name: "ISO-B", Peers: []string{peer2.ID}}, + {ID: "iso-grpC", Name: "ISO-C", Peers: []string{peer3.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + _, err = manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"iso-grpA"}, + Destinations: []string{"iso-grpB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + _, err = manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.20.0.0/24"), + route.IPv4Network, + nil, + "", + []string{"iso-grpC"}, + "isolated route", + "isonet2", + false, + 9999, + []string{"iso-grpC"}, + nil, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + // The setup policy/route above dispatch affected-peer updates asynchronously; + // drain any in-flight ones so the assertions only observe the UpdateGroup below. + settleAffectedUpdates(updMsg1, updMsg2, updMsg3) + + t.Run("policy group change does not affect route-only peer", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.UpdateGroup(ctx, accountID, userID, &types.Group{ + ID: "iso-grpA", + Name: "ISO-A-updated", + Peers: []string{peer1.ID}, + }) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) +} + +// TestAffectedPeers_DeleteRoute_UnrelatedPeerNoUpdate verifies that deleting a route +// only sends updates to peers in the route's groups. +func TestAffectedPeers_DeleteRoute_UnrelatedPeerNoUpdate(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + for _, g := range []*types.Group{ + {ID: "del-rt-grpA", Name: "Del-Rt-A", Peers: []string{peer1.ID}}, + {ID: "del-rt-grpB", Name: "Del-Rt-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + newRoute, err := manager.CreateRoute(ctx, accountID, + netip.MustParsePrefix("10.30.0.0/24"), + route.IPv4Network, + nil, + "", + []string{"del-rt-grpA"}, + "deletable route", + "delnet", + false, + 9999, + []string{"del-rt-grpB"}, + nil, + true, + userID, + false, + false, + ) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + t.Run("delete route only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.DeleteRoute(ctx, accountID, newRoute.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) +} + +// TestAffectedPeers_DeletePolicy_UnrelatedPeerNoUpdate verifies that deleting a policy +// only sends updates to peers in the policy's groups. +func TestAffectedPeers_DeletePolicy_UnrelatedPeerNoUpdate(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + for _, g := range []*types.Group{ + {ID: "del-pol-grpA", Name: "Del-Pol-A", Peers: []string{peer1.ID}}, + {ID: "del-pol-grpB", Name: "Del-Pol-B", Peers: []string{peer2.ID}}, + } { + err := manager.CreateGroup(ctx, accountID, userID, g) + require.NoError(t, err) + } + + policy, err := manager.SavePolicy(ctx, accountID, userID, &types.Policy{ + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"del-pol-grpA"}, + Destinations: []string{"del-pol-grpB"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + }, true) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + t.Run("delete policy only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.DeletePolicy(ctx, accountID, policy.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) +} + +// TestAffectedPeers_DeleteNameServer_UnrelatedPeerNoUpdate verifies that deleting a +// nameserver group only sends updates to peers in its groups. +func TestAffectedPeers_DeleteNameServer_UnrelatedPeerNoUpdate(t *testing.T) { + manager, updateManager, account, peer1, peer2, peer3 := setupNetworkMapTest(t) + ctx := context.Background() + accountID := account.Id + + policies, err := manager.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + for _, p := range policies { + err := manager.Store.DeletePolicy(ctx, accountID, p.ID) + require.NoError(t, err) + } + + err = manager.CreateGroup(ctx, accountID, userID, &types.Group{ + ID: "del-ns-grpA", + Name: "Del-NS-A", + Peers: []string{peer1.ID}, + }) + require.NoError(t, err) + + nsGroup, err := manager.CreateNameServerGroup(ctx, accountID, "del-ns", "Del NS", + []nbdns.NameServer{{ + IP: netip.MustParseAddr("8.8.4.4"), + NSType: nbdns.UDPNameServerType, + Port: nbdns.DefaultDNSPort, + }}, + []string{"del-ns-grpA"}, + true, nil, true, userID, false, + ) + require.NoError(t, err) + + updMsg1 := updateManager.CreateChannel(ctx, peer1.ID) + updMsg2 := updateManager.CreateChannel(ctx, peer2.ID) + updMsg3 := updateManager.CreateChannel(ctx, peer3.ID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, peer1.ID) + updateManager.CloseChannel(ctx, peer2.ID) + updateManager.CloseChannel(ctx, peer3.ID) + }) + + t.Run("delete nameserver group only affects linked peers", func(t *testing.T) { + done := make(chan struct{}) + go func() { + peerShouldReceiveUpdate(t, updMsg1) + peerShouldNotReceiveUpdate(t, updMsg2) + peerShouldNotReceiveUpdate(t, updMsg3) + close(done) + }() + + err := manager.DeleteNameServerGroup(ctx, accountID, nsGroup.ID, userID) + assert.NoError(t, err) + + select { + case <-done: + case <-time.After(peerUpdateTimeout): + t.Error("timeout") + } + }) +} + +func addPeerToAccount(t *testing.T, manager *DefaultAccountManager, _, setupKeyKey string) *nbpeer.Peer { + t.Helper() + + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + + peer, _, _, _, err := manager.AddPeer(context.Background(), "", setupKeyKey, "", &nbpeer.Peer{ + Key: key.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: key.PublicKey().String()}, + }, false) + require.NoError(t, err) + return peer +} + +// markPeerAsProxy flips an existing peer's ProxyMeta to mark it as an embedded +// proxy peer in the given cluster. +func markPeerAsProxy(t *testing.T, s store.Store, accountID, peerID, cluster string) { + t.Helper() + ctx := context.Background() + peer, err := s.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) + require.NoError(t, err) + peer.ProxyMeta = nbpeer.ProxyMeta{Embedded: true, Cluster: cluster} + require.NoError(t, s.SavePeer(ctx, accountID, peer)) +} + +// createServiceWithTargets persists a service with the given cluster and targets +// directly in the store, bypassing the proxy-service manager (which would also +// run cluster derivation and trigger UpdateAccountPeers). +func createServiceWithTargets(t *testing.T, s store.Store, accountID, cluster string, targets []*rpservice.Target) *rpservice.Service { + t.Helper() + svc := &rpservice.Service{ + AccountID: accountID, + Name: fmt.Sprintf("svc-%s", cluster), + Domain: fmt.Sprintf("%s.example.com", cluster), + ProxyCluster: cluster, + Enabled: true, + Mode: "tcp", + Targets: targets, + } + svc.InitNewRecord() + for _, target := range targets { + target.AccountID = accountID + target.ServiceID = svc.ID + } + require.NoError(t, s.CreateService(context.Background(), svc)) + return svc +} + +func TestCollectAffectedFromProxyServices_TargetPeerChanged(t *testing.T) { + manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t) + ctx := context.Background() + + cluster := "cluster-a" + markPeerAsProxy(t, s, accountID, peerIDs[0], cluster) + + createServiceWithTargets(t, s, accountID, cluster, []*rpservice.Target{ + {TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[1], Enabled: true, Port: 80, Protocol: "tcp"}, + }) + + _, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, nil, []string{peerIDs[1]}) + assert.Contains(t, directPeers, peerIDs[0], "proxy peer must be refreshed when its target peer changes") + assert.Contains(t, directPeers, peerIDs[1], "target peer must be refreshed") +} + +func TestCollectAffectedFromProxyServices_ProxyPeerChanged(t *testing.T) { + manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t) + ctx := context.Background() + + cluster := "cluster-a" + markPeerAsProxy(t, s, accountID, peerIDs[0], cluster) + + createServiceWithTargets(t, s, accountID, cluster, []*rpservice.Target{ + {TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[1], Enabled: true, Port: 80, Protocol: "tcp"}, + {TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[2], Enabled: true, Port: 80, Protocol: "tcp"}, + }) + + _, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, nil, []string{peerIDs[0]}) + assert.Contains(t, directPeers, peerIDs[0], "changed proxy peer is itself refreshed") + assert.Contains(t, directPeers, peerIDs[1], "target peer 1 must be refreshed when proxy peer changes") + assert.Contains(t, directPeers, peerIDs[2], "target peer 2 must be refreshed when proxy peer changes") +} + +func TestCollectAffectedFromProxyServices_GroupContainingTargetPeerChanged(t *testing.T) { + manager, s, accountID, peerIDs, groupIDs := setupAffectedPeersTest(t) + ctx := context.Background() + + cluster := "cluster-a" + markPeerAsProxy(t, s, accountID, peerIDs[0], cluster) + + createServiceWithTargets(t, s, accountID, cluster, []*rpservice.Target{ + {TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[1], Enabled: true, Port: 80, Protocol: "tcp"}, + }) + + _, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, []string{groupIDs[1]}, nil) + assert.Contains(t, directPeers, peerIDs[0], "proxy peer must be refreshed when a group containing its target peer changes") + assert.Contains(t, directPeers, peerIDs[1], "target peer must be refreshed") +} + +// A disabled service in the snapshot proxies nothing, so it is skipped: a changed +// target peer does not pull in the service's proxy peer. +func TestCollectAffectedFromProxyServices_DisabledServiceSkipped(t *testing.T) { + manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t) + ctx := context.Background() + + cluster := "cluster-a" + markPeerAsProxy(t, s, accountID, peerIDs[0], cluster) + + svc := &rpservice.Service{ + AccountID: accountID, + Name: "disabled-svc", + Domain: "disabled.example.com", + ProxyCluster: cluster, + Enabled: false, + Mode: "tcp", + Targets: []*rpservice.Target{ + {TargetType: rpservice.TargetTypePeer, TargetId: peerIDs[1], Enabled: false, Port: 80, Protocol: "tcp"}, + }, + } + svc.InitNewRecord() + for _, target := range svc.Targets { + target.AccountID = accountID + target.ServiceID = svc.ID + } + require.NoError(t, s.CreateService(ctx, svc)) + + _, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, nil, []string{peerIDs[1]}) + assert.NotContains(t, directPeers, peerIDs[0], "a disabled service proxies nothing, so its proxy peer must not be folded") +} + +func TestCollectAffectedFromProxyServices_NonPeerTargetType(t *testing.T) { + manager, s, accountID, peerIDs, _ := setupAffectedPeersTest(t) + ctx := context.Background() + + cluster := "cluster-a" + markPeerAsProxy(t, s, accountID, peerIDs[0], cluster) + + createServiceWithTargets(t, s, accountID, cluster, []*rpservice.Target{ + {TargetType: rpservice.TargetTypeHost, TargetId: "10.0.0.1", Host: "10.0.0.1", Enabled: true, Port: 80, Protocol: "tcp"}, + }) + + _, directPeers := collectPeerChangeAffectedGroups(ctx, manager.Store, accountID, nil, []string{peerIDs[0]}) + assert.Contains(t, directPeers, peerIDs[0], "host target service still refreshes its proxy peer when the proxy peer changes") + assert.NotContains(t, directPeers, "10.0.0.1", "non-peer target ids must not appear as affected peer IDs") +} diff --git a/management/server/affectedpeers/proxy_synth_test.go b/management/server/affectedpeers/proxy_synth_test.go new file mode 100644 index 000000000..07273b18b --- /dev/null +++ b/management/server/affectedpeers/proxy_synth_test.go @@ -0,0 +1,95 @@ +package affectedpeers + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/store" +) + +// fakeProxyStore implements only the two store methods loadProxyServices calls; +// the embedded nil store.Store panics if anything else is invoked, which keeps +// the test honest about the surface under test. +type fakeProxyStore struct { + store.Store + proxyByCluster map[string][]string + persisted []*rpservice.Service +} + +func (f *fakeProxyStore) GetEmbeddedProxyPeerIDsByCluster(_ context.Context, _ string) (map[string][]string, error) { + return f.proxyByCluster, nil +} + +func (f *fakeProxyStore) GetAccountServices(_ context.Context, _ store.LockingStrength, _ string) ([]*rpservice.Service, error) { + return f.persisted, nil +} + +func serviceIDs(svcs []*rpservice.Service) []string { + ids := make([]string, 0, len(svcs)) + for _, s := range svcs { + ids = append(ids, s.ID) + } + return ids +} + +// loadProxyServices must merge the synthesised agent-network services (which are +// never persisted) with the persisted ones, so the proxy-affected expansion can +// see agent-network AccessGroups. Without this the embedded proxy peer is never +// flagged on a client group change and only a full resync (restart) recovers. +func TestLoadProxyServices_MergesSynthesizedAgentNetworkServices(t *testing.T) { + prev := agentNetworkSynthesizer + t.Cleanup(func() { agentNetworkSynthesizer = prev }) + SetAgentNetworkSynthesizer(func(_ context.Context, _ store.Store, _ string) ([]*rpservice.Service, error) { + return []*rpservice.Service{ + {ID: "agent-net-svc-acc", ProxyCluster: "proxy.netbird.local", Private: true, AccessGroups: []string{"gB"}}, + }, nil + }) + + s := &fakeProxyStore{ + proxyByCluster: map[string][]string{"proxy.netbird.local": {"proxy-peer-1"}}, + persisted: []*rpservice.Service{{ID: "persisted-rp-svc", ProxyCluster: "proxy.netbird.local"}}, + } + snap := &Snapshot{} + require.NoError(t, snap.loadProxyServices(context.Background(), s, "acc")) + + ids := serviceIDs(snap.services) + assert.Contains(t, ids, "persisted-rp-svc", "persisted services must be kept") + assert.Contains(t, ids, "agent-net-svc-acc", "synthesised agent-network service must be merged in") +} + +// With no synthesiser registered, loadProxyServices falls back to persisted +// services only (no panic, no behaviour change for non-agent-network builds). +func TestLoadProxyServices_NoSynthesizerRegistered(t *testing.T) { + prev := agentNetworkSynthesizer + t.Cleanup(func() { agentNetworkSynthesizer = prev }) + agentNetworkSynthesizer = nil + + s := &fakeProxyStore{ + proxyByCluster: map[string][]string{"c": {"proxy-1"}}, + persisted: []*rpservice.Service{{ID: "persisted"}}, + } + snap := &Snapshot{} + require.NoError(t, snap.loadProxyServices(context.Background(), s, "acc")) + assert.Equal(t, []string{"persisted"}, serviceIDs(snap.services)) +} + +// No embedded proxy peers → skip entirely (don't even call the synthesiser). +func TestLoadProxyServices_NoEmbeddedProxyPeersSkips(t *testing.T) { + prev := agentNetworkSynthesizer + t.Cleanup(func() { agentNetworkSynthesizer = prev }) + called := false + SetAgentNetworkSynthesizer(func(_ context.Context, _ store.Store, _ string) ([]*rpservice.Service, error) { + called = true + return nil, nil + }) + + s := &fakeProxyStore{proxyByCluster: map[string][]string{}} + snap := &Snapshot{} + require.NoError(t, snap.loadProxyServices(context.Background(), s, "acc")) + assert.False(t, called, "synthesiser must not run for accounts without embedded proxy peers") + assert.Empty(t, snap.services) +} diff --git a/management/server/affectedpeers/resolver.go b/management/server/affectedpeers/resolver.go new file mode 100644 index 000000000..16a795539 --- /dev/null +++ b/management/server/affectedpeers/resolver.go @@ -0,0 +1,1124 @@ +// Package affectedpeers computes which peers' network maps a change touches, so +// only those peers are refreshed instead of the whole account. +// +// Two phases keep the dependency walk off the write transaction: +// - Load: reads the needed collections. Call INSIDE the mutating tx (consistent, +// and before a delete/removal severs the old state). +// - Snapshot.Expand: in-memory walk, no store access. Run AFTER the tx commits. +// +// Enabled handling differs by source. Disabled objects in the SNAPSHOT (existing +// account policies/resources/routers/routes/proxy services and their rules/targets) +// route to nobody and are skipped — they cannot affect any peer's map. Objects in +// the CHANGE itself are processed regardless of Enabled, so disabling one still +// refreshes the peers that lose access (the toggle is the observable change, and the +// update carries the old∪new state). +package affectedpeers + +import ( + "context" + + log "github.com/sirupsen/logrus" + + nbdns "github.com/netbirdio/netbird/dns" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + networkTypes "github.com/netbirdio/netbird/management/server/networks/types" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/route" +) + +// agentNetworkSynthesizer returns the account's synthesised (never-persisted) +// agent-network reverse-proxy services. It is registered at boot via +// SetAgentNetworkSynthesizer to avoid an import cycle (agentnetwork → account → +// affectedpeers). nil when agent-network is not wired, in which case only +// persisted services are considered. +var agentNetworkSynthesizer func(ctx context.Context, s store.Store, accountID string) ([]*rpservice.Service, error) + +// SetAgentNetworkSynthesizer registers the agent-network service synthesiser. +// Called once during boot, before any request is served. +func SetAgentNetworkSynthesizer(fn func(ctx context.Context, s store.Store, accountID string) ([]*rpservice.Service, error)) { + agentNetworkSynthesizer = fn +} + +// Snapshot is an in-memory view of the collections needed to expand a Change. +// Loaded in-tx, walked by Expand after commit. Only the collections the Change +// can touch are loaded; the rest stay nil (see Load). +type Snapshot struct { + policies []*types.Policy + routes []*route.Route + nsGroups []*nbdns.NameServerGroup + dnsSettings *types.DNSSettings + routers []*routerTypes.NetworkRouter + resources []*resourceTypes.NetworkResource + services []*rpservice.Service + proxyByCluster map[string][]string + groups map[string]*types.Group + groupPeers map[string]map[string]struct{} // groupID -> member peer IDs +} + +// Load reads the collections a Change requires, inside the caller's tx. It mirrors +// Expand's walker preconditions, loading only what the change can touch. +func Load(ctx context.Context, s store.Store, accountID string, c Change) (*Snapshot, error) { + snap := &Snapshot{} + if c.isEmpty() { + return snap, nil + } + + if err := snap.loadCollections(ctx, s, accountID, c); err != nil { + return nil, err + } + if err := snap.loadGroupIndex(ctx, s, accountID); err != nil { + return nil, err + } + + return snap, nil +} + +// loadCollections reads the policy/route/nameserver/dns/router/resource/proxy +// collections a Change can touch, gated to what the walk needs. +func (snap *Snapshot) loadCollections(ctx context.Context, s store.Store, accountID string, c Change) error { + // LinkGroups drive the same policy/route/dns walk as a changed group or peer. + hasGroupOrPeerChange := len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 || len(c.LinkGroups) > 0 || len(c.Resources) > 0 + hasNetworkObject := len(c.Routers) > 0 || len(c.Resources) > 0 || len(c.Networks) > 0 + // the resource<->router bridge can fire for any of these + needsRoutersResources := hasGroupOrPeerChange || len(c.PostureCheckIDs) > 0 || len(c.Policies) > 0 || hasNetworkObject + + if needsRoutersResources { + if err := snap.loadPolicyRoutersResources(ctx, s, accountID); err != nil { + return err + } + } + if hasGroupOrPeerChange { + if err := snap.loadRoutesAndProxy(ctx, s, accountID); err != nil { + return err + } + } + if len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 || len(c.LinkGroups) > 0 { + if err := snap.loadDNS(ctx, s, accountID); err != nil { + return err + } + } + return nil +} + +// loadPolicyRoutersResources loads the policies plus the routers and resources +// the resource<->router bridge walks. +func (snap *Snapshot) loadPolicyRoutersResources(ctx context.Context, s store.Store, accountID string) error { + var err error + if snap.policies, err = s.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID); err != nil { + return err + } + if snap.routers, err = s.GetNetworkRoutersByAccountID(ctx, store.LockingStrengthNone, accountID); err != nil { + return err + } + snap.resources, err = s.GetNetworkResourcesByAccountID(ctx, store.LockingStrengthNone, accountID) + return err +} + +// loadRoutesAndProxy loads the routes and the embedded-proxy services index. +func (snap *Snapshot) loadRoutesAndProxy(ctx context.Context, s store.Store, accountID string) error { + var err error + if snap.routes, err = s.GetAccountRoutes(ctx, store.LockingStrengthNone, accountID); err != nil { + return err + } + return snap.loadProxyServices(ctx, s, accountID) +} + +// loadDNS loads the nameserver groups and account DNS settings. +func (snap *Snapshot) loadDNS(ctx context.Context, s store.Store, accountID string) error { + var err error + if snap.nsGroups, err = s.GetAccountNameServerGroups(ctx, store.LockingStrengthNone, accountID); err != nil { + return err + } + snap.dnsSettings, err = s.GetAccountDNSSettings(ctx, store.LockingStrengthNone, accountID) + return err +} + +// loadProxyServices loads the embedded-proxy cluster index, and the services only +// when the account actually has embedded proxy peers. Both the persisted +// reverse-proxy services and the synthesised agent-network services are loaded: +// agent-network services are never persisted, so without synthesising them here +// collectFromProxyServices can't fold the embedded proxy peer into the affected +// set when a client's group changes, and the proxy never learns a newly +// authorised client until it reconnects (full network-map resync). +func (snap *Snapshot) loadProxyServices(ctx context.Context, s store.Store, accountID string) error { + var err error + if snap.proxyByCluster, err = s.GetEmbeddedProxyPeerIDsByCluster(ctx, accountID); err != nil { + return err + } + if len(snap.proxyByCluster) == 0 { + return nil + } + if snap.services, err = s.GetAccountServices(ctx, store.LockingStrengthNone, accountID); err != nil { + return err + } + if agentNetworkSynthesizer == nil { + return nil + } + synth, serr := agentNetworkSynthesizer(ctx, s, accountID) + if serr != nil { + // Non-fatal: fall back to persisted services. The next full + // network-map resync still converges the proxy. + log.WithContext(ctx).Warnf("affectedpeers: synthesise agent-network services for account %s: %v", accountID, serr) + return nil + } + snap.services = append(snap.services, synth...) + return nil +} + +// loadGroupIndex loads all groups (for group.Resources) and builds the +// group->member-peers index. Always needed: the bridge resolves group.Resources +// and Expand maps groups to member peers. +func (snap *Snapshot) loadGroupIndex(ctx context.Context, s store.Store, accountID string) error { + groups, err := s.GetAccountGroups(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return err + } + snap.groups = make(map[string]*types.Group, len(groups)) + snap.groupPeers = make(map[string]map[string]struct{}, len(groups)) + for _, g := range groups { + snap.groups[g.ID] = g + members := make(map[string]struct{}, len(g.Peers)) + for _, pID := range g.Peers { + members[pID] = struct{}{} + } + snap.groupPeers[g.ID] = members + } + return nil +} + +// Change describes what changed in an account. +type Change struct { + ChangedGroupIDs []string + ChangedPeerIDs []string + Policies []*types.Policy + Routes []*route.Route + Routers []*routerTypes.NetworkRouter + Resources []*resourceTypes.NetworkResource + Networks []*networkTypes.Network + PostureCheckIDs []string + + // DistributionGroupIDs are groups whose members are directly affected, with no + // dependency walk — the change distributes config to the groups' member peers + // only (nameserver groups, DNS DisabledManagementGroups), not through the + // policy/route reachability graph. Pass old∪new so both states refresh. + DistributionGroupIDs []string + + // RemovedPeersByGroup: peers that left a group, keyed by that group. They are no + // longer in the group's member index but still lose its reachability, so they are + // folded in — but only when the group is linked (an unlinked group has no map + // impact), matching how current members are handled. + RemovedPeersByGroup map[string][]string + + // OutputPeerIDs are peers folded straight into the result without seeding their + // group memberships into the walk. Use for the peer whose group membership changed: + // the peer itself must refresh, but its OTHER groups did not change, so they must + // not be walked. Contrast ChangedPeerIDs, which seeds ALL of the peer's groups + // (correct when the peer's own attributes changed, e.g. IP/status). + OutputPeerIDs []string + + // LinkGroups are groups used ONLY to match policies/routes/routers and walk to the + // OPPOSITE side — they are never expanded to their own members. Use this when a + // peer's group membership changed: pass the peer in ChangedPeerIDs and its + // group(s) here. The opposite side of the policies the group participates in + // refreshes, but the group's other members (siblings) do not — nothing changed for + // them. For an intra-group policy (A→A) the opposite side IS the group, so its + // members still refresh via the opposite-side fold, exactly when they genuinely + // gain/lose the changed peer. Unlike ChangedGroupIDs, a LinkGroup is not added to + // the output, so a one-sided membership change never wakes the whole group. + LinkGroups []string +} + +func (c Change) isEmpty() bool { + return len(c.ChangedGroupIDs) == 0 && + len(c.ChangedPeerIDs) == 0 && + len(c.Policies) == 0 && + len(c.Routes) == 0 && + len(c.Routers) == 0 && + len(c.Resources) == 0 && + len(c.Networks) == 0 && + len(c.PostureCheckIDs) == 0 && + len(c.DistributionGroupIDs) == 0 && + len(c.RemovedPeersByGroup) == 0 && + len(c.LinkGroups) == 0 && + len(c.OutputPeerIDs) == 0 +} + +// Expand returns the deduplicated affected peer IDs from the preloaded Snapshot, +// no store access. Run after the producing tx commits. Logs the full walk at +// trace level for diagnosing a miscalculation. +func (snap *Snapshot) Expand(ctx context.Context, accountID string, c Change) []string { + if c.isEmpty() { + return nil + } + r := newResolver(ctx, snap, accountID, c) + log.WithContext(ctx).Tracef("affectedpeers expand start: account=%s changedGroups=%v changedPeers=%v linkGroups=%v policies=%d routes=%d routers=%d resources=%d networks=%d postureChecks=%v distributionGroups=%v", + accountID, c.ChangedGroupIDs, c.ChangedPeerIDs, c.LinkGroups, len(c.Policies), len(c.Routes), len(c.Routers), len(c.Resources), len(c.Networks), c.PostureCheckIDs, c.DistributionGroupIDs) + r.walk() + return r.expand() +} + +// Collect returns the affected group and direct-peer IDs without expanding groups +// to members. Test-only introspection; use Resolve otherwise. +func Collect(ctx context.Context, s store.Store, accountID string, c Change) (groupIDs []string, directPeerIDs []string) { + if c.isEmpty() { + return nil, nil + } + snap, err := Load(ctx, s, accountID, c) + if err != nil { + log.WithContext(ctx).Errorf("failed to load snapshot for affected peers collect: %v", err) + return nil, nil + } + r := newResolver(ctx, snap, accountID, c) + r.walk() + return setToSlice(r.affectedGroups), setToSlice(r.affectedPeers) +} + +func newResolver(ctx context.Context, snap *Snapshot, accountID string, c Change) *resolver { + r := &resolver{ + ctx: ctx, + snap: snap, + accountID: accountID, + change: c, + linkGroups: toSet(c.ChangedGroupIDs), + outputGroups: toSet(c.ChangedGroupIDs), + changedPeers: toSet(c.ChangedPeerIDs), + affectedGroups: make(map[string]struct{}), + affectedPeers: make(map[string]struct{}), + } + // LinkGroups match policies/routes to find the opposite side but are NOT output: + // they go into linkGroups only, never outputGroups, so their members never fold in. + addAll(r.linkGroups, c.LinkGroups) + // Resolve each changed peer to its groups here so callers pass only ChangedPeerIDs. + r.seedChangedGroupsFromPeers() + return r +} + +// seedChangedGroupsFromPeers adds each changed peer's groups to linkGroups so +// the group-driven walkers fire for memberships, not just direct peer references. +// These seeded groups are for MATCHING only — folding the changed entity's own +// side is gated on outputGroups (the caller-reported groups), so a seeded group +// never folds its whole membership; only the changed peer itself folds in. +func (r *resolver) seedChangedGroupsFromPeers() { + if len(r.changedPeers) == 0 { + return + } + for groupID, members := range r.snap.groupPeers { + for pID := range r.changedPeers { + if _, ok := members[pID]; ok { + r.linkGroups[groupID] = struct{}{} + break + } + } + } +} + +// policySide selects which side of a policy rule to walk. +type policySide int + +const ( + sideSource policySide = iota + sideDestination +) + +func (s policySide) opposite() policySide { + if s == sideSource { + return sideDestination + } + return sideSource +} + +// walk resolves affected peers in two buckets, by how far each change propagates. +// +// BOTH-SIDES — the rule itself changed (an explicit policy edit, or a policy whose +// posture check changed). Source AND destination refresh, so each such policy is +// walked on both sides. +// +// OPPOSITE-SIDE — an endpoint moved but no rule changed. For each policy the change +// touches we fold only the side AWAY from the change: +// - a changed peer/group sits ON a policy side -> fold the opposite side; +// - a changed router/resource/network sits on a NETWORK -> fold the SOURCE side of +// the policies whose destination reaches it (and the routers it implies). +// +// Routes, nameserver groups, DNS and embedded-proxy services distribute to their own +// member peers, outside the policy graph, and are folded here too. +func (r *resolver) walk() { + for _, policy := range r.bothSidesPolicies() { + r.foldPolicySide(policy, sideSource) + r.foldPolicySide(policy, sideDestination) + } + + if len(r.linkGroups) > 0 || len(r.changedPeers) > 0 { + r.collectFromPolicies() + r.collectFromRoutes() + r.collectFromNameServers() + r.collectFromDNSSettings() + r.collectFromNetworkRouters() + r.collectFromProxyServices() + } + + r.collectFromChangedRoutes(r.change.Routes) + r.collectFromChangedRouters(r.change.Routers) + r.collectFromChangedResources(r.change.Resources) + r.collectFromChangedNetworks(r.change.Networks) + + // The explicitly changed peers always refresh their own maps. OnPeersUpdated only + // refreshes the resolver's output (it ignores the separately-passed changed peers), + // so the changed peer reaches its own new map only via here. An offline/deleted + // peer in the set is filtered downstream (filterConnectedAffectedPeers). + addAll(r.affectedPeers, setToSlice(r.changedPeers)) + // OutputPeerIDs refresh themselves too, but unlike changedPeers their group + // memberships were not seeded into the walk (only the changed group was). + addAll(r.affectedPeers, r.change.OutputPeerIDs) + + // Distribution groups (nameserver/DNS) affect only their member peers: fold them + // straight into affectedGroups so expand() maps them to members, without the + // policy/route walk that linkGroups would trigger. + addAll(r.affectedGroups, r.change.DistributionGroupIDs) +} + +// bothSidesPolicies are the policies whose rule changed: the explicitly edited ones +// plus those gated by a changed posture check. walk folds both their sides. +func (r *resolver) bothSidesPolicies() []*types.Policy { + policies := append([]*types.Policy(nil), r.change.Policies...) + return r.appendPoliciesForPostureChecks(policies, r.change.PostureCheckIDs) +} + +type resolver struct { + ctx context.Context + snap *Snapshot + accountID string + change Change + + // Inputs — what changed. Set once at construction, read-only during the walk + // (except linkGroups, which collectFromExplicitResources also seeds). + // + // linkGroups is the MATCH set: caller-changed groups ∪ the groups of changed + // peers ∪ changed-resource groups. A rule/route/router matches the change when + // one of its groups is here — used only to find the opposite side to fold. + // + // outputGroups is the FOLD-WHOLE-GROUP set: ONLY Change.ChangedGroupIDs. When a + // matched group is here, its whole membership is affected. A peer-seeded group + // is in linkGroups but NOT outputGroups, so it folds only the changed peer + // (changedPeers), never its siblings. + linkGroups map[string]struct{} + outputGroups map[string]struct{} + changedPeers map[string]struct{} + + // Outputs — the answer. The only sets the walk accumulates into. affectedGroups + // is expanded to its member peers in expand(). + affectedGroups map[string]struct{} + affectedPeers map[string]struct{} +} + +// policies returns the account's ENABLED policies from the snapshot. Disabled +// policies grant no access, so the walk skips them when scanning existing account +// data. Explicitly changed policies (Change.Policies, via bothSidesPolicies) are +// processed regardless of Enabled, so disabling one still refreshes its peers. +func (r *resolver) policies() []*types.Policy { + enabled := make([]*types.Policy, 0, len(r.snap.policies)) + for _, policy := range r.snap.policies { + if policy != nil && policy.Enabled { + enabled = append(enabled, policy) + } + } + return enabled +} + +// networkResources / networkRouters return the account's ENABLED resources/routers +// from the snapshot. Disabled objects route to nobody, so the walk skips them when +// it scans existing account data. The explicitly changed objects in the Change are +// processed regardless of Enabled (collectFromChanged*), so disabling one still +// refreshes the peers that lose access. +func (r *resolver) networkResources() []*resourceTypes.NetworkResource { + enabled := make([]*resourceTypes.NetworkResource, 0, len(r.snap.resources)) + for _, resource := range r.snap.resources { + if resource.Enabled { + enabled = append(enabled, resource) + } + } + return enabled +} + +func (r *resolver) networkRouters() []*routerTypes.NetworkRouter { + enabled := make([]*routerTypes.NetworkRouter, 0, len(r.snap.routers)) + for _, router := range r.snap.routers { + if router.Enabled { + enabled = append(enabled, router) + } + } + return enabled +} + +// peerIDsForGroups maps a group set to its member peer IDs via the preloaded index. +func (r *resolver) peerIDsForGroups(groups map[string]struct{}) []string { + seen := make(map[string]struct{}) + var ids []string + for gID := range groups { + for pID := range r.snap.groupPeers[gID] { + if _, ok := seen[pID]; ok { + continue + } + seen[pID] = struct{}{} + ids = append(ids, pID) + } + } + return ids +} + +func (r *resolver) expand() []string { + peerIDs := r.peerIDsForGroups(r.affectedGroups) + + log.WithContext(r.ctx).Tracef("affectedpeers expand: account=%s affectedGroups=%v -> %d group-member peers; direct peers=%v", + r.accountID, setToSlice(r.affectedGroups), len(peerIDs), setToSlice(r.affectedPeers)) + + seen := make(map[string]struct{}, len(peerIDs)) + for _, id := range peerIDs { + seen[id] = struct{}{} + } + for id := range r.affectedPeers { + if _, ok := seen[id]; !ok { + peerIDs = append(peerIDs, id) + seen[id] = struct{}{} + } + } + + // Fold in removed peers only when their group is linked (in affectedGroups). + for groupID, removed := range r.change.RemovedPeersByGroup { + if _, linked := r.affectedGroups[groupID]; !linked { + continue + } + for _, id := range removed { + if _, ok := seen[id]; !ok { + peerIDs = append(peerIDs, id) + seen[id] = struct{}{} + log.WithContext(r.ctx).Tracef("affectedpeers expand: removed peer %s from linked group %s -> affected", id, groupID) + } + } + } + + log.WithContext(r.ctx).Tracef("affectedpeers expand done: account=%s -> %d affected peers: %v", r.accountID, len(peerIDs), peerIDs) + return peerIDs +} + +// ruleSideGroups / ruleSideResource return the groups and the resource on the given +// side of a rule. +func ruleSideGroups(rule *types.PolicyRule, side policySide) []string { + if side == sideDestination { + return rule.Destinations + } + return rule.Sources +} + +func ruleSideResource(rule *types.PolicyRule, side policySide) types.Resource { + if side == sideDestination { + return rule.DestinationResource + } + return rule.SourceResource +} + +// foldPolicySide folds one side of a policy down to affected peers: its groups +// (resolved to members in expand) and its direct peer. When the side is the +// DESTINATION and references a network resource (directly or via a destination +// group's resources), it also folds the routers that serve that resource's network +// — a destination resource is reached through its routers. A resource on the SOURCE +// side routes to nobody (GetPoliciesForNetworkResource matches destinations only), +// so the router hop is destination-only. +func (r *resolver) foldPolicySide(policy *types.Policy, side policySide) { + if policy == nil { + return + } + for _, rule := range policy.Rules { + addAll(r.affectedGroups, ruleSideGroups(rule, side)) + res := ruleSideResource(rule, side) + if res.Type == types.ResourceTypePeer && res.ID != "" { + r.affectedPeers[res.ID] = struct{}{} + } + } + if side == sideDestination { + r.foldRoutersForResources(r.policyDestinationResourceIDs(policy)) + } +} + +// appendPoliciesForPostureChecks appends every policy that references a changed +// posture check (a rule change, so walk both sides). +func (r *resolver) appendPoliciesForPostureChecks(policies []*types.Policy, postureCheckIDs []string) []*types.Policy { + if len(postureCheckIDs) == 0 { + return policies + } + ids := toSet(postureCheckIDs) + for _, policy := range r.policies() { + if !policyReferencesPostureChecks(policy, ids) || !policy.Enabled { + continue + } + log.WithContext(r.ctx).Tracef("appendPoliciesForPostureChecks: policy %s (%s) references changed posture checks %v -> both-sides policy", + policy.ID, policy.Name, postureCheckIDs) + policies = append(policies, policy) + } + return policies +} + +// collectFromPolicies folds, for every policy whose rule a changed group or peer +// touches, only the OPPOSITE side (down to peers, incl. destination routers), plus +// the changed entity's own side: the changed group's whole membership when the +// group itself changed (outputGroups), or the changed peer alone when matched via a +// peer-seeded group (never its co-members). +func (r *resolver) collectFromPolicies() { + for _, policy := range r.policies() { + for _, rule := range policy.Rules { + if !rule.Enabled { + continue // a disabled rule grants no access + } + r.foldRuleSideIfChanged(policy, rule, sideSource) + r.foldRuleSideIfChanged(policy, rule, sideDestination) + } + } +} + +// foldRuleSideIfChanged: when a changed group or direct peer sits on `side` of the +// rule, fold the opposite side fully (groups/peers + destination routers) and fold +// the changed entity's own side (the whole changed group, or the changed peer alone). +func (r *resolver) foldRuleSideIfChanged(policy *types.Policy, rule *types.PolicyRule, side policySide) { + nearGroups := ruleSideGroups(rule, side) + nearResource := ruleSideResource(rule, side) + + matchedByGroup := anyInSet(nearGroups, r.linkGroups) + matchedByPeer := isDirectPeerInSet(nearResource, r.changedPeers) + if !matchedByGroup && !matchedByPeer { + return + } + + // Opposite side, fully down to peers (a destination opposite also folds routers). + r.foldPolicySideForRule(policy, rule, side.opposite()) + + // Own side: fold the whole changed group's members only when the group itself + // changed (outputGroups). A peer-seeded or link-only group is not folded here — + // its siblings never refresh. The changed peers themselves are folded once, after + // the walk (see walk()). + for _, gID := range nearGroups { + if _, ok := r.outputGroups[gID]; ok { + r.affectedGroups[gID] = struct{}{} + } + } + + // When the changed side IS a destination, the resources it targets are reached + // through their network's routers, so those routers refresh too (e.g. attaching a + // resource to a destination group, or a changed destination group/resource). + if side == sideDestination { + r.foldRoutersForResources(r.ruleDestinationResourceIDs(rule)) + } +} + +// foldPolicySideForRule folds one side of a single rule (groups + direct peer), and +// for a destination side the routers of that rule's destination resources. +func (r *resolver) foldPolicySideForRule(policy *types.Policy, rule *types.PolicyRule, side policySide) { + addAll(r.affectedGroups, ruleSideGroups(rule, side)) + res := ruleSideResource(rule, side) + if res.Type == types.ResourceTypePeer && res.ID != "" { + r.affectedPeers[res.ID] = struct{}{} + } + if side == sideDestination { + r.foldRoutersForResources(r.ruleDestinationResourceIDs(rule)) + } +} + +// collectFromChangedRoutes folds an explicitly changed route's own groups and peer. +func (r *resolver) collectFromChangedRoutes(routes []*route.Route) { + for _, rt := range routes { + if rt == nil { + continue + } + log.WithContext(r.ctx).Tracef("collectFromChangedRoutes: changed route %s -> folding groups=%v peerGroups=%v accessControlGroups=%v peer=%q", + rt.ID, rt.Groups, rt.PeerGroups, rt.AccessControlGroups, rt.Peer) + addAll(r.affectedGroups, rt.Groups, rt.PeerGroups, rt.AccessControlGroups) + if rt.Peer != "" { + r.affectedPeers[rt.Peer] = struct{}{} + } + } +} + +// collectFromChangedRouters: a changed router refreshes its OWN backing peer/groups +// (the changed entity) and the SOURCE side of every policy reaching a resource on +// its network (the router serves the whole network). Sibling routers on the network +// are independent and are NOT folded. Passing the old router state keeps a repointed +// router's previous backing affected without a post-commit read. +func (r *resolver) collectFromChangedRouters(routers []*routerTypes.NetworkRouter) { + for _, router := range routers { + if router == nil { + continue + } + log.WithContext(r.ctx).Tracef("collectFromChangedRouters: changed router %s on network %s -> folding its own peerGroups=%v peer=%q + sources reaching network resources", + router.ID, router.NetworkID, router.PeerGroups, router.Peer) + addAll(r.affectedGroups, router.PeerGroups) + if router.Peer != "" { + r.affectedPeers[router.Peer] = struct{}{} + } + if router.NetworkID != "" { + r.foldPolicySourcesForResources(r.networkResourceIDs(router.NetworkID)) + } + } +} + +// collectFromChangedResources: a changed resource refreshes the SOURCE side of the +// policies targeting EXACTLY that resource — directly, or via one of the resource's +// own groups (old∪new across the change, so a now-detached group's sources still +// refresh) — plus the routers serving its network (the resource is reached through +// them). It does not touch sibling resources on the same network. +func (r *resolver) collectFromChangedResources(resources []*resourceTypes.NetworkResource) { + for _, resource := range resources { + if resource == nil { + continue + } + log.WithContext(r.ctx).Tracef("collectFromChangedResources: changed resource %s on network %s (groups %v) -> folding sources of policies targeting it + its network's routers", + resource.ID, resource.NetworkID, resource.GroupIDs) + r.foldPolicySourcesForResource(resource.ID, resource.GroupIDs) + if resource.NetworkID != "" { + r.foldRoutersOnNetworks(map[string]struct{}{resource.NetworkID: {}}) + } + } +} + +// foldPolicySourcesForResource folds the source side of every policy whose +// destination is the given resource — referenced directly, or via any of the given +// groups (the resource's own old∪new groups, which captures a detached group). +func (r *resolver) foldPolicySourcesForResource(resourceID string, groupIDs []string) { + groups := toSet(groupIDs) + for _, policy := range r.policies() { + if !policyTargetsResourceOrGroups(policy, resourceID, groups) { + continue + } + log.WithContext(r.ctx).Tracef("foldPolicySourcesForResource: policy %s (%s) targets changed resource %s -> folding its source groups/peers", policy.ID, policy.Name, resourceID) + collectPolicySources(policy, r.affectedGroups, r.affectedPeers) + } +} + +// policyTargetsResourceOrGroups reports whether a policy's destination is the given +// resource directly, or one of the given destination groups. +func policyTargetsResourceOrGroups(policy *types.Policy, resourceID string, groups map[string]struct{}) bool { + if policy == nil { + return false + } + for _, rule := range policy.Rules { + if !rule.Enabled { + continue + } + if rule.DestinationResource.Type != types.ResourceTypePeer && rule.DestinationResource.ID == resourceID && resourceID != "" { + return true + } + if anyInSet(rule.Destinations, groups) { + return true + } + } + return false +} + +// collectFromChangedNetworks: a changed network refreshes the SOURCE side of the +// policies reaching any of its resources, plus its routers. A network has no +// groups/peers of its own. +func (r *resolver) collectFromChangedNetworks(networks []*networkTypes.Network) { + for _, network := range networks { + if network == nil || network.ID == "" { + continue + } + log.WithContext(r.ctx).Tracef("collectFromChangedNetworks: changed network %s -> folding sources reaching its resources + its routers", network.ID) + resourceIDs := r.networkResourceIDs(network.ID) + r.foldPolicySourcesForResources(resourceIDs) + r.foldRoutersOnNetworks(map[string]struct{}{network.ID: {}}) + } +} + +// foldPolicySourcesForResources folds the source groups/peers of every policy whose +// destination targets one of resourceIDs (directly or via a destination group). +func (r *resolver) foldPolicySourcesForResources(resourceIDs map[string]struct{}) { + if len(resourceIDs) == 0 { + return + } + for _, policy := range r.policies() { + if r.policyTargetsResources(policy, resourceIDs) { + log.WithContext(r.ctx).Tracef("foldPolicySourcesForResources: policy %s (%s) targets a changed resource -> folding its source groups/peers", policy.ID, policy.Name) + collectPolicySources(policy, r.affectedGroups, r.affectedPeers) + } + } +} + +// collectFromRoutes folds, per matched route, the OPPOSITE side(s) fully and the +// matched side's own groups only on a whole-group change (outputGroups). A route has +// three peer sides — routing (Peer/PeerGroups), consumer (Groups) and ACL +// (AccessControlGroups) — that each refresh the others; the changed side's own group +// folds its siblings only when the group itself changed, never on a one-peer move. +func (r *resolver) collectFromRoutes() { + for _, rt := range r.snap.routes { + if !rt.Enabled { + continue // disabled routes route to nobody; skip existing account data + } + routing := anyInSet(rt.PeerGroups, r.linkGroups) || (rt.Peer != "" && isInSet(rt.Peer, r.changedPeers)) + consumer := anyInSet(rt.Groups, r.linkGroups) + acl := anyInSet(rt.AccessControlGroups, r.linkGroups) + if !routing && !consumer && !acl { + continue + } + log.WithContext(r.ctx).Tracef("collectFromRoutes: route %s matched (routing=%t consumer=%t acl=%t) -> folding opposite sides; own side gated on outputGroups", + rt.ID, routing, consumer, acl) + r.foldRouteSide(rt.PeerGroups, routing) + r.foldRouteSide(rt.Groups, consumer) + r.foldRouteSide(rt.AccessControlGroups, acl) + // The single routing Peer folds when the routing side is the OPPOSITE of the + // match (consumer/acl need it), or when that very peer is the change. + if rt.Peer != "" && (consumer || acl || isInSet(rt.Peer, r.changedPeers)) { + r.affectedPeers[rt.Peer] = struct{}{} + } + } +} + +// foldRouteSide folds a route side: when this side is the one that matched, fold its +// groups only on a whole-group change (outputGroups) so siblings of a single moved +// peer stay put; otherwise it is an opposite side and folds fully. +func (r *resolver) foldRouteSide(groups []string, matchedHere bool) { + if matchedHere { + r.foldOutputGroups(groups) + return + } + addAll(r.affectedGroups, groups) +} + +// foldOutputGroups folds only the groups that the caller reported as wholly changed +// (outputGroups). Used for a matched object's OWN side, where a peer-seeded or +// link-only group must not pull in its siblings. +func (r *resolver) foldOutputGroups(groups ...[]string) { + for _, gs := range groups { + for _, gID := range gs { + if _, ok := r.outputGroups[gID]; ok { + r.affectedGroups[gID] = struct{}{} + } + } + } +} + +func (r *resolver) collectFromNameServers() { + if len(r.linkGroups) == 0 { + return + } + for _, ns := range r.snap.nsGroups { + if anyInSet(ns.Groups, r.linkGroups) { + // A nameserver group has no opposite side: a peer's DNS config depends only + // on its own membership, so a one-peer move refreshes that peer alone (folded + // elsewhere). Fold the referenced groups only on a whole-group change. + log.WithContext(r.ctx).Tracef("collectFromNameServers: nameserver group %s references a linked group -> folding its groups %v (outputGroups only)", ns.ID, ns.Groups) + r.foldOutputGroups(ns.Groups) + } + } +} + +func (r *resolver) collectFromDNSSettings() { + if len(r.linkGroups) == 0 || r.snap.dnsSettings == nil { + return + } + for _, gID := range r.snap.dnsSettings.DisabledManagementGroups { + if _, ok := r.linkGroups[gID]; ok { + log.WithContext(r.ctx).Tracef("collectFromDNSSettings: changed group %s is in DisabledManagementGroups -> folding it", gID) + r.affectedGroups[gID] = struct{}{} + } + } +} + +// collectFromNetworkRouters handles a changed group/peer that BACKS a router (the +// routing peer set moved): the router's own peers refresh and so do the sources of +// the policies reaching its network's resources. Sibling routers on the network are +// independent and are not folded. +func (r *resolver) collectFromNetworkRouters() { + for _, router := range r.networkRouters() { + matchedByGroup := anyInSet(router.PeerGroups, r.linkGroups) + matchedByPeer := router.Peer != "" && len(r.changedPeers) > 0 && isInSet(router.Peer, r.changedPeers) + if !matchedByGroup && !matchedByPeer { + continue + } + log.WithContext(r.ctx).Tracef("collectFromNetworkRouters: router %s on network %s matched (byGroup=%t byPeer=%t) -> folding its peerGroups=%v peer=%q (own groups on outputGroups) + sources reaching network resources", + router.ID, router.NetworkID, matchedByGroup, matchedByPeer, router.PeerGroups, router.Peer) + // The backing PeerGroups are the matched (own) side: fold them only on a + // whole-group change so a one-peer move does not wake sibling backing peers. The + // opposite side (policy sources reaching the network) is folded below. + r.foldOutputGroups(router.PeerGroups) + if router.Peer != "" { + r.affectedPeers[router.Peer] = struct{}{} + } + if router.NetworkID != "" { + r.foldPolicySourcesForResources(r.networkResourceIDs(router.NetworkID)) + } + } +} + +func (r *resolver) collectFromProxyServices() { + if len(r.snap.proxyByCluster) == 0 || len(r.snap.services) == 0 { + return + } + services, proxyByCluster := r.snap.services, r.snap.proxyByCluster + + expanded := r.expandChangedPeersWithGroups() + + for _, svc := range services { + if svc == nil || !svc.Enabled { + continue // a disabled service proxies nothing; skip existing account data + } + proxyPeers := proxyByCluster[svc.ProxyCluster] + if len(proxyPeers) == 0 { + continue + } + matchedByPeer := serviceMatchesChangedPeers(svc, proxyPeers, expanded) + matchedByAccessGroup := anyInSet(svc.AccessGroups, r.linkGroups) + if !matchedByPeer && !matchedByAccessGroup { + continue + } + log.WithContext(r.ctx).Tracef("collectFromProxyServices: service %s (cluster=%s) matched (byProxyOrTargetPeer=%t byAccessGroup=%t) -> folding %d proxy peers, peer targets; access groups %v on outputGroups only", + svc.ID, svc.ProxyCluster, matchedByPeer, matchedByAccessGroup, len(proxyPeers), svc.AccessGroups) + for _, pid := range proxyPeers { + r.affectedPeers[pid] = struct{}{} + } + for _, target := range svc.Targets { + if !target.Enabled { + continue // a disabled target forwards nothing + } + if target.TargetType == rpservice.TargetTypePeer && target.TargetId != "" { + r.affectedPeers[target.TargetId] = struct{}{} + } + } + // AccessGroups are the matched (own) side with no opposite to fold: a member's + // proxy access is self-contained, so a one-peer move refreshes that peer alone. + // Fold the groups only on a whole-group change. + r.foldOutputGroups(svc.AccessGroups) + } +} + +func (r *resolver) expandChangedPeersWithGroups() map[string]struct{} { + if len(r.linkGroups) == 0 { + return r.changedPeers + } + ids := r.peerIDsForGroups(r.linkGroups) + if len(ids) == 0 { + return r.changedPeers + } + merged := make(map[string]struct{}, len(r.changedPeers)+len(ids)) + for id := range r.changedPeers { + merged[id] = struct{}{} + } + for _, id := range ids { + merged[id] = struct{}{} + } + return merged +} + +// foldRoutersForResources folds the routers serving the networks of the given +// resources (a destination resource is reached through its network's routers). It is +// the resource -> network -> router hop used by foldPolicySide for a destination. +func (r *resolver) foldRoutersForResources(resourceIDs map[string]struct{}) { + if len(resourceIDs) == 0 { + return + } + r.foldRoutersOnNetworks(r.resourceNetworkIDs(resourceIDs)) +} + +// ruleDestinationResourceIDs returns the destination resource IDs of a single rule: +// the direct DestinationResource plus the resources of its destination groups. +func (r *resolver) ruleDestinationResourceIDs(rule *types.PolicyRule) map[string]struct{} { + resourceIDs := make(map[string]struct{}) + if rule.DestinationResource.Type != types.ResourceTypePeer && rule.DestinationResource.ID != "" { + resourceIDs[rule.DestinationResource.ID] = struct{}{} + } + r.addGroupResourceIDs(toSet(rule.Destinations), resourceIDs) + return resourceIDs +} + +// networkResourceIDs returns the IDs of all resources on the given network. +func (r *resolver) networkResourceIDs(networkID string) map[string]struct{} { + resourceIDs := make(map[string]struct{}) + for _, resource := range r.networkResources() { + if resource.NetworkID == networkID { + resourceIDs[resource.ID] = struct{}{} + } + } + return resourceIDs +} + +func (r *resolver) foldRoutersOnNetworks(networkIDs map[string]struct{}) { + for _, router := range r.networkRouters() { + if _, ok := networkIDs[router.NetworkID]; !ok { + continue + } + log.WithContext(r.ctx).Tracef("bridgeRoutersToSources: router %s serves affected network %s -> folding peerGroups=%v peer=%q", + router.ID, router.NetworkID, router.PeerGroups, router.Peer) + addAll(r.affectedGroups, router.PeerGroups) + if router.Peer != "" { + r.affectedPeers[router.Peer] = struct{}{} + } + } +} + +func (r *resolver) resourceNetworkIDs(resourceIDs map[string]struct{}) map[string]struct{} { + networkIDs := make(map[string]struct{}) + for _, resource := range r.networkResources() { + if _, ok := resourceIDs[resource.ID]; ok { + networkIDs[resource.NetworkID] = struct{}{} + } + } + return networkIDs +} + +func (r *resolver) policyTargetsResources(policy *types.Policy, resourceIDs map[string]struct{}) bool { + if policy == nil { + return false + } + destGroupSet := make(map[string]struct{}) + for _, rule := range policy.Rules { + if !rule.Enabled { + continue + } + if rule.DestinationResource.Type != types.ResourceTypePeer && isInSet(rule.DestinationResource.ID, resourceIDs) { + return true + } + for _, gID := range rule.Destinations { + destGroupSet[gID] = struct{}{} + } + } + if len(destGroupSet) == 0 { + return false + } + for gID := range destGroupSet { + group := r.snap.groups[gID] + if group == nil { + continue + } + for _, res := range group.Resources { + if isInSet(res.ID, resourceIDs) { + return true + } + } + } + return false +} + +func (r *resolver) policyDestinationResourceIDs(policies ...*types.Policy) map[string]struct{} { + resourceIDs := make(map[string]struct{}) + destGroupSet := collectPolicyDestinations(resourceIDs, policies...) + r.addGroupResourceIDs(destGroupSet, resourceIDs) + return resourceIDs +} + +// collectPolicyDestinations adds direct destination resource IDs to resourceIDs and +// returns the referenced destination group IDs. +func collectPolicyDestinations(resourceIDs map[string]struct{}, policies ...*types.Policy) map[string]struct{} { + destGroupSet := make(map[string]struct{}) + for _, policy := range policies { + if policy == nil { + continue + } + for _, rule := range policy.Rules { + addAll(destGroupSet, rule.Destinations) + if rule.DestinationResource.Type != types.ResourceTypePeer && rule.DestinationResource.ID != "" { + resourceIDs[rule.DestinationResource.ID] = struct{}{} + } + } + } + return destGroupSet +} + +// addGroupResourceIDs folds the resource IDs of the given groups into resourceIDs. +func (r *resolver) addGroupResourceIDs(groupIDs map[string]struct{}, resourceIDs map[string]struct{}) { + for gID := range groupIDs { + group := r.snap.groups[gID] + if group == nil { + continue + } + for _, res := range group.Resources { + if res.ID != "" { + resourceIDs[res.ID] = struct{}{} + } + } + } +} + +// collectPolicySources folds the source groups/peers of a snapshot policy's enabled +// rules (a disabled rule grants no access). +func collectPolicySources(policy *types.Policy, groups, peers map[string]struct{}) { + for _, rule := range policy.Rules { + if !rule.Enabled { + continue + } + addAll(groups, rule.Sources) + if rule.SourceResource.Type == types.ResourceTypePeer && rule.SourceResource.ID != "" { + peers[rule.SourceResource.ID] = struct{}{} + } + } +} + +func policyReferencesPostureChecks(policy *types.Policy, ids map[string]struct{}) bool { + for _, id := range policy.SourcePostureChecks { + if _, ok := ids[id]; ok { + return true + } + } + return false +} + +func isDirectPeerInSet(res types.Resource, set map[string]struct{}) bool { + if res.Type != types.ResourceTypePeer || res.ID == "" { + return false + } + _, ok := set[res.ID] + return ok +} + +func serviceMatchesChangedPeers(svc *rpservice.Service, proxyPeers []string, changedPeers map[string]struct{}) bool { + for _, pid := range proxyPeers { + if _, ok := changedPeers[pid]; ok { + return true + } + } + for _, target := range svc.Targets { + if !target.Enabled || target.TargetType != rpservice.TargetTypePeer || target.TargetId == "" { + continue + } + if _, ok := changedPeers[target.TargetId]; ok { + return true + } + } + return false +} + +func anyInSet(ids []string, set map[string]struct{}) bool { + for _, id := range ids { + if _, ok := set[id]; ok { + return true + } + } + return false +} + +func isInSet(id string, set map[string]struct{}) bool { + _, ok := set[id] + return ok +} + +func addAll(set map[string]struct{}, slices ...[]string) { + for _, s := range slices { + for _, id := range s { + set[id] = struct{}{} + } + } +} + +func toSet(ids []string) map[string]struct{} { + set := make(map[string]struct{}, len(ids)) + for _, id := range ids { + set[id] = struct{}{} + } + return set +} + +func setToSlice(set map[string]struct{}) []string { + s := make([]string, 0, len(set)) + for id := range set { + s = append(s, id) + } + return s +} diff --git a/management/server/affectedpeers/resolver_test.go b/management/server/affectedpeers/resolver_test.go new file mode 100644 index 000000000..fe6ada347 --- /dev/null +++ b/management/server/affectedpeers/resolver_test.go @@ -0,0 +1,112 @@ +package affectedpeers + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + networkTypes "github.com/netbirdio/netbird/management/server/networks/types" + "github.com/netbirdio/netbird/management/server/types" +) + +// policyGroupsAndPeers mirrors the both-sides extraction (RuleGroups + direct peers) +// the resolver folds in for a changed policy, for asserting the pure logic. +func policyGroupsAndPeers(policies ...*types.Policy) (groups []string, peers []string) { + peerSet := map[string]struct{}{} + for _, p := range policies { + if p == nil { + continue + } + groups = append(groups, p.RuleGroups()...) + for _, rule := range p.Rules { + if rule.SourceResource.Type == types.ResourceTypePeer && rule.SourceResource.ID != "" { + peerSet[rule.SourceResource.ID] = struct{}{} + } + if rule.DestinationResource.Type == types.ResourceTypePeer && rule.DestinationResource.ID != "" { + peerSet[rule.DestinationResource.ID] = struct{}{} + } + } + } + for id := range peerSet { + peers = append(peers, id) + } + return groups, peers +} + +func TestPolicyGroupsAndPeers_Basic(t *testing.T) { + policy := &types.Policy{Rules: []*types.PolicyRule{{Sources: []string{"g1", "g2"}, Destinations: []string{"g3"}}}} + groups, peers := policyGroupsAndPeers(policy) + assert.ElementsMatch(t, []string{"g1", "g2", "g3"}, groups) + assert.Empty(t, peers) +} + +func TestPolicyGroupsAndPeers_WithPeerResources(t *testing.T) { + policy := &types.Policy{Rules: []*types.PolicyRule{{ + Sources: []string{"g1"}, + SourceResource: types.Resource{ID: "p1", Type: types.ResourceTypePeer}, + Destinations: []string{"g2"}, + DestinationResource: types.Resource{ID: "p2", Type: types.ResourceTypePeer}, + }}} + groups, peers := policyGroupsAndPeers(policy) + assert.ElementsMatch(t, []string{"g1", "g2"}, groups) + assert.ElementsMatch(t, []string{"p1", "p2"}, peers) +} + +func TestPolicyGroupsAndPeers_NilPolicy(t *testing.T) { + groups, peers := policyGroupsAndPeers(nil) + assert.Nil(t, groups) + assert.Nil(t, peers) +} + +func TestPolicyGroupsAndPeers_MultiplePolicies(t *testing.T) { + old := &types.Policy{Rules: []*types.PolicyRule{{Sources: []string{"g1"}, Destinations: []string{"g2"}}}} + updated := &types.Policy{Rules: []*types.PolicyRule{{Sources: []string{"g3"}, Destinations: []string{"g4"}}}} + groups, _ := policyGroupsAndPeers(updated, old) + assert.ElementsMatch(t, []string{"g1", "g2", "g3", "g4"}, groups) +} + +func TestPolicyGroupsAndPeers_NonPeerResource(t *testing.T) { + policy := &types.Policy{Rules: []*types.PolicyRule{{ + Sources: []string{"g1"}, + SourceResource: types.Resource{ID: "domain-1", Type: types.ResourceTypeDomain}, + Destinations: []string{"g2"}, + }}} + groups, peers := policyGroupsAndPeers(policy) + assert.ElementsMatch(t, []string{"g1", "g2"}, groups) + assert.Empty(t, peers, "domain resource type should not produce direct peer IDs") +} + +func TestChangeIsEmpty(t *testing.T) { + assert.True(t, Change{}.isEmpty()) + assert.False(t, Change{ChangedGroupIDs: []string{"g"}}.isEmpty()) + assert.False(t, Change{ChangedPeerIDs: []string{"p"}}.isEmpty()) + assert.False(t, Change{Policies: []*types.Policy{{}}}.isEmpty()) + assert.False(t, Change{Resources: []*resourceTypes.NetworkResource{{ID: "r"}}}.isEmpty()) + assert.False(t, Change{Networks: []*networkTypes.Network{{ID: "n"}}}.isEmpty()) + assert.False(t, Change{PostureCheckIDs: []string{"pc"}}.isEmpty()) +} + +func TestPolicyReferencesPostureChecks(t *testing.T) { + policy := &types.Policy{SourcePostureChecks: []string{"pc1", "pc2"}} + + assert.True(t, policyReferencesPostureChecks(policy, map[string]struct{}{"pc1": {}})) + assert.False(t, policyReferencesPostureChecks(policy, map[string]struct{}{"pc3": {}})) +} + +func TestCollectPolicySources(t *testing.T) { + policy := &types.Policy{Rules: []*types.PolicyRule{{ + Enabled: true, + Sources: []string{"g1"}, + SourceResource: types.Resource{Type: types.ResourceTypePeer, ID: "p1"}, + Destinations: []string{"g2"}, + }}} + + groupSet := map[string]struct{}{} + peerSet := map[string]struct{}{} + collectPolicySources(policy, groupSet, peerSet) + + assert.Contains(t, groupSet, "g1") + assert.NotContains(t, groupSet, "g2", "destination groups must not be collected as sources") + assert.Contains(t, peerSet, "p1") +} diff --git a/management/server/agentnetwork_budgetrule_realstack_test.go b/management/server/agentnetwork_budgetrule_realstack_test.go new file mode 100644 index 000000000..d17f2e26a --- /dev/null +++ b/management/server/agentnetwork_budgetrule_realstack_test.go @@ -0,0 +1,126 @@ +package server + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + agenttypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/store" +) + +// TestAgentNetwork_BudgetRuleCRUD_RealManager is the GC-1 no-mock guard for the +// account budget-rule manager surface: real DefaultAccountManager, real store, +// real permissions. It exercises create/get/list/update/delete through the +// permission-gated manager (not the store directly) and asserts the reused +// PolicyLimits cap shape and targets survive each step. +func TestAgentNetwork_BudgetRuleCRUD_RealManager(t *testing.T) { + am, _, err := createManager(t) + require.NoError(t, err, "createManager must succeed") + ctx := context.Background() + + const ( + accountID = "agent-net-budget-acct" + adminUserID = "agent-net-budget-admin" + ) + account := newAccountWithId(ctx, accountID, adminUserID, "agent-net.test", "", "", false) + require.NoError(t, am.Store.SaveAccount(ctx, account), "SaveAccount must succeed") + + mgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil) + + created, err := mgr.CreateBudgetRule(ctx, adminUserID, &agenttypes.AccountBudgetRule{ + AccountID: accountID, + Name: "eng-monthly", + Enabled: true, + TargetGroups: []string{"grp-eng"}, + TargetUsers: []string{"user-alice"}, + Limits: agenttypes.PolicyLimits{ + TokenLimit: agenttypes.PolicyTokenLimit{Enabled: true, GroupCap: 100_000, UserCap: 10_000, WindowSeconds: 2_592_000}, + BudgetLimit: agenttypes.PolicyBudgetLimit{Enabled: true, GroupCapUsd: 500, WindowSeconds: 2_592_000}, + }, + }) + require.NoError(t, err, "CreateBudgetRule must succeed") + require.NotEmpty(t, created.ID, "create must mint an ID") + + got, err := mgr.GetBudgetRule(ctx, accountID, adminUserID, created.ID) + require.NoError(t, err, "GetBudgetRule must succeed") + assert.Equal(t, "eng-monthly", got.Name, "name round-trips through the manager") + assert.Equal(t, []string{"grp-eng"}, got.TargetGroups, "target groups round-trip") + assert.Equal(t, int64(100_000), got.Limits.TokenLimit.GroupCap, "token group cap round-trips") + + list, err := mgr.GetAllBudgetRules(ctx, accountID, adminUserID) + require.NoError(t, err, "GetAllBudgetRules must succeed") + require.Len(t, list, 1, "exactly the one created rule must be listed") + + created.Limits.TokenLimit.GroupCap = 200_000 + updated, err := mgr.UpdateBudgetRule(ctx, adminUserID, created) + require.NoError(t, err, "UpdateBudgetRule must succeed") + assert.Equal(t, int64(200_000), updated.Limits.TokenLimit.GroupCap, "updated cap must persist") + + require.NoError(t, mgr.DeleteBudgetRule(ctx, accountID, adminUserID, created.ID), "DeleteBudgetRule must succeed") + _, err = mgr.GetBudgetRule(ctx, accountID, adminUserID, created.ID) + assert.Error(t, err, "get after delete must fail") +} + +// TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection is the +// GC-1 guard for UpdateSettings: it must apply the collection toggles while +// preserving the immutable Cluster/Subdomain pinned at bootstrap. +func TestAgentNetwork_UpdateSettings_PreservesImmutableAndTogglesCollection(t *testing.T) { + am, _, err := createManager(t) + require.NoError(t, err, "createManager must succeed") + ctx := context.Background() + + const ( + accountID = "agent-net-settings-acct" + adminUserID = "agent-net-settings-admin" + clusterAddr = "eu.proxy.netbird.io" + ) + account := newAccountWithId(ctx, accountID, adminUserID, "agent-net.test", "", "", false) + require.NoError(t, am.Store.SaveAccount(ctx, account), "SaveAccount must succeed") + + mgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil) + + // Creating a provider bootstraps the settings row (cluster + subdomain). + _, err = mgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{ + AccountID: accountID, + ProviderID: "openai_api", + Name: "openai", + UpstreamURL: "https://api.openai.com", + APIKey: "sk-test", + Enabled: true, + Models: []agenttypes.ProviderModel{{ID: "gpt-5.4"}}, + }, clusterAddr) + require.NoError(t, err, "CreateProvider must bootstrap settings") + + before, err := mgr.GetSettings(ctx, accountID, adminUserID) + require.NoError(t, err, "GetSettings must succeed after bootstrap") + require.Equal(t, clusterAddr, before.Cluster, "cluster pinned at bootstrap") + require.NotEmpty(t, before.Subdomain, "subdomain pinned at bootstrap") + assert.False(t, before.EnablePromptCollection, "prompt collection defaults off") + + // Attempt to flip toggles AND smuggle a different cluster/subdomain — the + // immutable fields must be ignored. + updated, err := mgr.UpdateSettings(ctx, adminUserID, &agenttypes.Settings{ + AccountID: accountID, + Cluster: "attacker.cluster", + Subdomain: "evil", + EnableLogCollection: true, + EnablePromptCollection: true, + RedactPii: true, + }) + require.NoError(t, err, "UpdateSettings must succeed") + assert.Equal(t, before.Cluster, updated.Cluster, "cluster is immutable and must be preserved") + assert.Equal(t, before.Subdomain, updated.Subdomain, "subdomain is immutable and must be preserved") + assert.True(t, updated.EnableLogCollection, "log collection toggle must apply") + assert.True(t, updated.EnablePromptCollection, "prompt collection toggle must apply") + assert.True(t, updated.RedactPii, "redact toggle must apply") + + reloaded, err := am.Store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + assert.Equal(t, before.Cluster, reloaded.Cluster, "persisted cluster unchanged") + assert.True(t, reloaded.EnablePromptCollection, "persisted prompt collection toggled on") +} diff --git a/management/server/agentnetwork_realstack_test.go b/management/server/agentnetwork_realstack_test.go new file mode 100644 index 000000000..e7855c575 --- /dev/null +++ b/management/server/agentnetwork_realstack_test.go @@ -0,0 +1,212 @@ +package server + +import ( + "context" + "net/netip" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + networkmap "github.com/netbirdio/netbird/management/internals/controllers/network_map" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + agenttypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + nbproto "github.com/netbirdio/netbird/shared/management/proto" +) + +// TestAgentNetwork_ProviderCRUD_FansOutToProxyAndClientPeers is the no-mock +// integration test for the live propagation path: a provider/policy mutation +// through the real agentnetwork.Manager triggers the real +// DefaultAccountManager.UpdateAccountPeers, which runs the real network-map +// controller (including AN-2b's injectAllProxyPolicies), and a network map is +// computed and fanned out to BOTH the embedded proxy peer and the client peer. +// +// Unlike the synthesizer/reconcile unit tests, nothing here is mocked: real +// SQLite store, real account manager + network-map controller, real +// agentnetwork manager, real peer update channels. The client peer's delivered +// map is asserted to actually carry the synth DNS surface, and provider +// create/delete are exercised end to end. +func TestAgentNetwork_ProviderCRUD_FansOutToProxyAndClientPeers(t *testing.T) { + am, updateManager, err := createManager(t) + require.NoError(t, err, "createManager must succeed") + ctx := context.Background() + + const ( + accountID = "agent-net-acct-1" + adminUserID = "agent-net-admin-1" + groupAID = "agent-net-grp-A" + clusterAddr = "eu.proxy.netbird.io" + clientKey = "BhRPtynAAYRDy08+q4HTMsos8fs4plTP4NOSh7C1ry8=" + proxyPeerID = "agent-net-proxy-peer-1" + proxyPeerKey = "/yF0+vCfv+mRR5k0dca0TrGdO/oiNeAI58gToZm5NyI=" + proxyIP = "100.64.0.99" + ) + + account := newAccountWithId(ctx, accountID, adminUserID, "agent-net.test", "", "", false) + require.NoError(t, am.Store.SaveAccount(ctx, account), "SaveAccount must succeed") + + // Real client peer through the production AddPeer path. + clientPeer := &nbpeer.Peer{ + Key: clientKey, + Name: "agent-net-client", + DNSLabel: "agent-net-client", + Meta: nbpeer.PeerSystemMeta{Hostname: "agent-net-client", GoOS: "linux", WtVersion: "development"}, + } + addedClient, _, _, _, err := am.AddPeer(ctx, "", "", adminUserID, clientPeer, false) + require.NoError(t, err, "AddPeer must add the client peer") + + // Inject a connected embedded proxy peer + put the client in the source group. + account, err = am.Store.GetAccount(ctx, accountID) + require.NoError(t, err) + account.Peers[proxyPeerID] = &nbpeer.Peer{ + ID: proxyPeerID, + AccountID: accountID, + Key: proxyPeerKey, + IP: netip.MustParseAddr(proxyIP), + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, + ProxyMeta: nbpeer.ProxyMeta{Embedded: true, Cluster: clusterAddr}, + DNSLabel: "agent-net-proxy", + } + account.Groups[groupAID] = &types.Group{ID: groupAID, Name: "groupA", Peers: []string{addedClient.ID}} + require.NoError(t, am.Store.SaveAccount(ctx, account), "SaveAccount must persist proxy peer + group") + + // Subscribe to BOTH peers' update channels — this is how we observe the + // real fan-out. + clientCh := updateManager.CreateChannel(ctx, addedClient.ID) + proxyCh := updateManager.CreateChannel(ctx, proxyPeerID) + t.Cleanup(func() { + updateManager.CloseChannel(ctx, addedClient.ID) + updateManager.CloseChannel(ctx, proxyPeerID) + }) + drain(clientCh) + drain(proxyCh) + + // Real agentnetwork manager wired to the real account manager. proxyController + // is nil (no gRPC cluster fan-out here) — the reconcile still fires + // UpdateAccountPeers, which is the path under test. + agentMgr := agentnetwork.NewManager(am.Store, permissions.NewManager(am.Store), am, nil) + + provider, err := agentMgr.CreateProvider(ctx, adminUserID, &agenttypes.Provider{ + AccountID: accountID, + ProviderID: "openai_api", + Name: "openai-test", + UpstreamURL: "https://api.openai.com", + APIKey: "sk-test-key", + Enabled: true, + Models: []agenttypes.ProviderModel{{ID: "gpt-5.4"}}, + }, clusterAddr) + require.NoError(t, err, "CreateProvider must succeed") + + policy, err := agentMgr.CreatePolicy(ctx, adminUserID, &agenttypes.Policy{ + AccountID: accountID, + Name: "p1", + Enabled: true, + SourceGroups: []string{groupAID}, + DestinationProviderIDs: []string{provider.ID}, + }) + require.NoError(t, err, "CreatePolicy must succeed") + + settings, err := am.Store.GetAgentNetworkSettings(ctx, store.LockingStrengthNone, accountID) + require.NoError(t, err) + fqdn := settings.Endpoint() + + // Both peers must receive a fan-out. The provider-create reconcile fires + // before the policy exists (synth service then has no AccessGroups, so no + // zone), and the async update buffer can collapse/reorder updates — so we + // poll until the client's delivered map actually carries the synth record. + rdata := awaitZoneRData(clientCh, clusterAddr, fqdn, true) + assert.Equal(t, proxyIP, rdata, + "client peer's delivered network map must contain the synth DNS record pointing at the embedded proxy peer") + require.True(t, awaitUpdate(proxyCh), "embedded proxy peer must also receive a netmap update after create") + + // UPDATE the provider — a new model on the existing service must still + // reconcile and keep the private surface routable (the live MODIFIED path). + provider.Models = append(provider.Models, agenttypes.ProviderModel{ID: "gpt-5.4-mini"}) + _, err = agentMgr.UpdateProvider(ctx, adminUserID, provider) + require.NoError(t, err, "UpdateProvider must succeed") + assert.Equal(t, proxyIP, awaitZoneRData(clientCh, clusterAddr, fqdn, true), + "client peer must still resolve the synth record after the provider is updated") + require.True(t, awaitUpdate(proxyCh), "embedded proxy peer must also receive a netmap update after update") + + // DELETE: detach the policy first (provider is in use), then drop the + // provider. Both peers update again and the synth surface disappears. + require.NoError(t, agentMgr.DeletePolicy(ctx, accountID, adminUserID, policy.ID), "DeletePolicy must succeed") + require.NoError(t, agentMgr.DeleteProvider(ctx, accountID, adminUserID, provider.ID), "DeleteProvider must succeed") + + require.True(t, awaitUpdate(proxyCh), "embedded proxy peer must also receive a netmap update after delete") + assert.Empty(t, awaitZoneRData(clientCh, clusterAddr, fqdn, false), + "synth DNS record must be gone from the client's map after the provider is deleted") +} + +// awaitZoneRData drains the channel for up to 8s. When wantPresent is true it +// returns as soon as the synth record appears (its RData). When false it drains +// to quiescence and returns the RData of the last delivered map (expected empty +// once the provider is gone), tolerating stale buffered updates that still +// carry the zone. +func awaitZoneRData(ch <-chan *networkmap.UpdateMessage, clusterAddr, fqdn string, wantPresent bool) string { + deadline := time.After(8 * time.Second) + last := "" + for { + select { + case m := <-ch: + if m == nil { + continue + } + last = synthZoneRData(m.Update, clusterAddr, fqdn) + if wantPresent && last != "" { + return last + } + case <-time.After(750 * time.Millisecond): + return last + case <-deadline: + return last + } + } +} + +// awaitUpdate reports whether at least one update arrives within the window. +func awaitUpdate(ch <-chan *networkmap.UpdateMessage) bool { + select { + case m := <-ch: + return m != nil + case <-time.After(5 * time.Second): + return false + } +} + +// drain empties any buffered updates (e.g. from AddPeer/SaveAccount) so the +// next observation reflects the operation under test. +func drain(ch <-chan *networkmap.UpdateMessage) { + for { + select { + case <-ch: + case <-time.After(200 * time.Millisecond): + return + } + } +} + +// synthZoneRData returns the RData of the synth A record (record name == fqdn) +// inside the cluster's custom zone, or "" when absent. +func synthZoneRData(sync *nbproto.SyncResponse, clusterAddr, fqdn string) string { + if sync == nil { + return "" + } + for _, zone := range sync.GetNetworkMap().GetDNSConfig().GetCustomZones() { + if zone.GetDomain() != dns.Fqdn(clusterAddr) { + continue + } + for _, rec := range zone.GetRecords() { + if rec.GetName() == dns.Fqdn(fqdn) { + return rec.GetRData() + } + } + } + return "" +} diff --git a/management/server/auth/manager.go b/management/server/auth/manager.go index ece9dc321..9498789f2 100644 --- a/management/server/auth/manager.go +++ b/management/server/auth/manager.go @@ -9,18 +9,20 @@ import ( "github.com/golang-jwt/jwt/v5" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/base62" - nbjwt "github.com/netbirdio/netbird/management/server/auth/jwt" - nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/idp/dex" "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/types" + nbjwt "github.com/netbirdio/netbird/shared/auth/jwt" ) var _ Manager = (*manager)(nil) type Manager interface { - ValidateAndParseToken(ctx context.Context, value string) (nbcontext.UserAuth, *jwt.Token, error) - EnsureUserAccessByJWTGroups(ctx context.Context, userAuth nbcontext.UserAuth, token *jwt.Token) (nbcontext.UserAuth, error) + ValidateAndParseToken(ctx context.Context, value string) (auth.UserAuth, *jwt.Token, error) + EnsureUserAccessByJWTGroups(ctx context.Context, userAuth auth.UserAuth, token *jwt.Token) (auth.UserAuth, error) MarkPATUsed(ctx context.Context, tokenID string) error GetPATInfo(ctx context.Context, token string) (user *types.User, pat *types.PersonalAccessToken, domain string, category string, err error) } @@ -32,15 +34,20 @@ type manager struct { extractor *nbjwt.ClaimsExtractor } -func NewManager(store store.Store, issuer, audience, keysLocation, userIdClaim string, allAudiences []string, idpRefreshKeys bool) Manager { - // @note if invalid/missing parameters are sent the validator will instantiate - // but it will fail when validating and parsing the token - jwtValidator := nbjwt.NewValidator( - issuer, - allAudiences, - keysLocation, - idpRefreshKeys, - ) +func NewManager(store store.Store, issuer, audience, keysLocation, userIdClaim string, allAudiences []string, idpRefreshKeys bool, keyFetcher nbjwt.KeyFetcher) Manager { + var jwtValidator *nbjwt.Validator + if keyFetcher != nil { + jwtValidator = nbjwt.NewValidatorWithKeyFetcher(issuer, allAudiences, keyFetcher) + } else { + // @note if invalid/missing parameters are sent the validator will instantiate + // but it will fail when validating and parsing the token + jwtValidator = nbjwt.NewValidator( + issuer, + allAudiences, + keysLocation, + idpRefreshKeys, + ) + } claimsExtractor := nbjwt.NewClaimsExtractor( nbjwt.WithAudience(audience), @@ -48,28 +55,30 @@ func NewManager(store store.Store, issuer, audience, keysLocation, userIdClaim s ) return &manager{ - store: store, - + store: store, validator: jwtValidator, extractor: claimsExtractor, } } -func (m *manager) ValidateAndParseToken(ctx context.Context, value string) (nbcontext.UserAuth, *jwt.Token, error) { +func (m *manager) ValidateAndParseToken(ctx context.Context, value string) (auth.UserAuth, *jwt.Token, error) { token, err := m.validator.ValidateAndParse(ctx, value) if err != nil { - return nbcontext.UserAuth{}, nil, err + return auth.UserAuth{}, nil, err } userAuth, err := m.extractor.ToUserAuth(token) if err != nil { - return nbcontext.UserAuth{}, nil, err + return auth.UserAuth{}, nil, err } return userAuth, token, err } -func (m *manager) EnsureUserAccessByJWTGroups(ctx context.Context, userAuth nbcontext.UserAuth, token *jwt.Token) (nbcontext.UserAuth, error) { - if userAuth.IsChild || userAuth.IsPAT { +func (m *manager) EnsureUserAccessByJWTGroups(ctx context.Context, userAuth auth.UserAuth, token *jwt.Token) (auth.UserAuth, error) { + // Child accounts and PAT-authenticated requests do not use JWT group access checks. + // Embedded-Dex local users also skip them because local password authentication + // does not provide external IdP group claims. + if userAuth.IsChild || userAuth.IsPAT || dex.IsLocalUserID(userAuth.UserId) { return userAuth, nil } diff --git a/management/server/auth/manager_mock.go b/management/server/auth/manager_mock.go index 30a7a7161..edf158a49 100644 --- a/management/server/auth/manager_mock.go +++ b/management/server/auth/manager_mock.go @@ -3,9 +3,10 @@ package auth import ( "context" + "github.com/netbirdio/netbird/shared/auth" + "github.com/golang-jwt/jwt/v5" - nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/server/types" ) @@ -15,18 +16,18 @@ var ( // @note really dislike this mocking approach but rather than have to do additional test refactoring. type MockManager struct { - ValidateAndParseTokenFunc func(ctx context.Context, value string) (nbcontext.UserAuth, *jwt.Token, error) - EnsureUserAccessByJWTGroupsFunc func(ctx context.Context, userAuth nbcontext.UserAuth, token *jwt.Token) (nbcontext.UserAuth, error) + ValidateAndParseTokenFunc func(ctx context.Context, value string) (auth.UserAuth, *jwt.Token, error) + EnsureUserAccessByJWTGroupsFunc func(ctx context.Context, userAuth auth.UserAuth, token *jwt.Token) (auth.UserAuth, error) MarkPATUsedFunc func(ctx context.Context, tokenID string) error GetPATInfoFunc func(ctx context.Context, token string) (user *types.User, pat *types.PersonalAccessToken, domain string, category string, err error) } // EnsureUserAccessByJWTGroups implements Manager. -func (m *MockManager) EnsureUserAccessByJWTGroups(ctx context.Context, userAuth nbcontext.UserAuth, token *jwt.Token) (nbcontext.UserAuth, error) { +func (m *MockManager) EnsureUserAccessByJWTGroups(ctx context.Context, userAuth auth.UserAuth, token *jwt.Token) (auth.UserAuth, error) { if m.EnsureUserAccessByJWTGroupsFunc != nil { return m.EnsureUserAccessByJWTGroupsFunc(ctx, userAuth, token) } - return nbcontext.UserAuth{}, nil + return auth.UserAuth{}, nil } // GetPATInfo implements Manager. @@ -46,9 +47,9 @@ func (m *MockManager) MarkPATUsed(ctx context.Context, tokenID string) error { } // ValidateAndParseToken implements Manager. -func (m *MockManager) ValidateAndParseToken(ctx context.Context, value string) (nbcontext.UserAuth, *jwt.Token, error) { +func (m *MockManager) ValidateAndParseToken(ctx context.Context, value string) (auth.UserAuth, *jwt.Token, error) { if m.ValidateAndParseTokenFunc != nil { return m.ValidateAndParseTokenFunc(ctx, value) } - return nbcontext.UserAuth{}, &jwt.Token{}, nil + return auth.UserAuth{}, &jwt.Token{}, nil } diff --git a/management/server/auth/manager_test.go b/management/server/auth/manager_test.go index c8015eb37..af8a30ef1 100644 --- a/management/server/auth/manager_test.go +++ b/management/server/auth/manager_test.go @@ -16,11 +16,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/netbirdio/netbird/idp/dex" "github.com/netbirdio/netbird/management/server/auth" - nbjwt "github.com/netbirdio/netbird/management/server/auth/jwt" - nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/types" + nbauth "github.com/netbirdio/netbird/shared/auth" + nbjwt "github.com/netbirdio/netbird/shared/auth/jwt" ) func TestAuthManager_GetAccountInfoFromPAT(t *testing.T) { @@ -52,7 +53,7 @@ func TestAuthManager_GetAccountInfoFromPAT(t *testing.T) { t.Fatalf("Error when saving account: %s", err) } - manager := auth.NewManager(store, "", "", "", "", []string{}, false) + manager := auth.NewManager(store, "", "", "", "", []string{}, false, nil) user, pat, _, _, err := manager.GetPATInfo(context.Background(), token) if err != nil { @@ -92,7 +93,7 @@ func TestAuthManager_MarkPATUsed(t *testing.T) { t.Fatalf("Error when saving account: %s", err) } - manager := auth.NewManager(store, "", "", "", "", []string{}, false) + manager := auth.NewManager(store, "", "", "", "", []string{}, false, nil) err = manager.MarkPATUsed(context.Background(), "tokenId") if err != nil { @@ -131,7 +132,7 @@ func TestAuthManager_EnsureUserAccessByJWTGroups(t *testing.T) { } // this has been validated and parsed by ValidateAndParseToken - userAuth := nbcontext.UserAuth{ + userAuth := nbauth.UserAuth{ AccountId: account.Id, Domain: domain, UserId: userId, @@ -142,7 +143,7 @@ func TestAuthManager_EnsureUserAccessByJWTGroups(t *testing.T) { // these tests only assert groups are parsed from token as per account settings token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{"idp-groups": []interface{}{"group1", "group2"}}) - manager := auth.NewManager(store, "", "", "", "", []string{}, false) + manager := auth.NewManager(store, "", "", "", "", []string{}, false, nil) t.Run("JWT groups disabled", func(t *testing.T) { userAuth, err := manager.EnsureUserAccessByJWTGroups(context.Background(), userAuth, token) @@ -206,6 +207,43 @@ func TestAuthManager_EnsureUserAccessByJWTGroups(t *testing.T) { _, err = manager.EnsureUserAccessByJWTGroups(context.Background(), userAuth, token) require.Error(t, err, "ensure user access is not in allowed groups") }) + + t.Run("Local embedded-Dex user is exempt from JWT allow-groups", func(t *testing.T) { + account.Settings.JWTGroupsEnabled = true + account.Settings.JWTGroupsClaimName = "idp-groups" + account.Settings.JWTAllowGroups = []string{"not-a-group"} + err := store.SaveAccount(context.Background(), account) + require.NoError(t, err, "save account failed") + + // Local Dex users have a "local" connector encoded in their user ID. + localUserAuth := nbauth.UserAuth{ + AccountId: account.Id, + Domain: domain, + UserId: dex.EncodeDexUserID("local-owner", "local"), + } + + localUserAuth, err = manager.EnsureUserAccessByJWTGroups(context.Background(), localUserAuth, token) + require.NoError(t, err, "local user must not be locked out by JWT allow-groups (issue #5337)") + require.Len(t, localUserAuth.Groups, 0, "JWT groups must not be evaluated for local users") + }) + + t.Run("Federated embedded-Dex user is still subject to JWT allow-groups", func(t *testing.T) { + account.Settings.JWTGroupsEnabled = true + account.Settings.JWTGroupsClaimName = "idp-groups" + account.Settings.JWTAllowGroups = []string{"not-a-group"} + err := store.SaveAccount(context.Background(), account) + require.NoError(t, err, "save account failed") + + // A federated user (non-"local" connector) must remain restricted. + fedUserAuth := nbauth.UserAuth{ + AccountId: account.Id, + Domain: domain, + UserId: dex.EncodeDexUserID("entra-user", "entra"), + } + + _, err = manager.EnsureUserAccessByJWTGroups(context.Background(), fedUserAuth, token) + require.Error(t, err, "federated user must still be restricted by JWT allow-groups") + }) } func TestAuthManager_ValidateAndParseToken(t *testing.T) { @@ -225,7 +263,7 @@ func TestAuthManager_ValidateAndParseToken(t *testing.T) { keyId := "test-key" // note, we can use a nil store because ValidateAndParseToken does not use it in it's flow - manager := auth.NewManager(nil, issuer, audience, server.URL, userIdClaim, []string{audience}, false) + manager := auth.NewManager(nil, issuer, audience, server.URL, userIdClaim, []string{audience}, false, nil) customClaim := func(name string) string { return fmt.Sprintf("%s/%s", audience, name) @@ -236,7 +274,7 @@ func TestAuthManager_ValidateAndParseToken(t *testing.T) { tests := []struct { name string tokenFunc func() string - expected *nbcontext.UserAuth // nil indicates expected error + expected *nbauth.UserAuth // nil indicates expected error }{ { name: "Valid with custom claims", @@ -258,7 +296,7 @@ func TestAuthManager_ValidateAndParseToken(t *testing.T) { tokenString, _ := token.SignedString(key) return tokenString }, - expected: &nbcontext.UserAuth{ + expected: &nbauth.UserAuth{ UserId: "user-id|123", AccountId: "account-id|567", Domain: "http://localhost", @@ -282,7 +320,7 @@ func TestAuthManager_ValidateAndParseToken(t *testing.T) { tokenString, _ := token.SignedString(key) return tokenString }, - expected: &nbcontext.UserAuth{ + expected: &nbauth.UserAuth{ UserId: "user-id|123", }, }, diff --git a/management/server/auth/session.go b/management/server/auth/session.go new file mode 100644 index 000000000..7621a1c10 --- /dev/null +++ b/management/server/auth/session.go @@ -0,0 +1,61 @@ +package auth + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "time" + + "github.com/eko/gocache/lib/v4/cache" + "github.com/eko/gocache/lib/v4/store" +) + +const ( + usedTokenKeyPrefix = "jwt-used:" + usedTokenMarker = "1" +) + +var ( + ErrTokenAlreadyUsed = errors.New("JWT already used") + ErrTokenExpired = errors.New("JWT expired") +) + +type SessionStore struct { + cache *cache.Cache[string] +} + +func NewSessionStore(cacheStore store.StoreInterface) *SessionStore { + return &SessionStore{cache: cache.New[string](cacheStore)} +} + +// RegisterToken records a JWT until its exp time and rejects reuse. +func (s *SessionStore) RegisterToken(ctx context.Context, token string, expiresAt time.Time) error { + ttl := time.Until(expiresAt) + if ttl <= 0 { + return ErrTokenExpired + } + + key := usedTokenKeyPrefix + hashToken(token) + _, err := s.cache.Get(ctx, key) + if err == nil { + return ErrTokenAlreadyUsed + } + + var notFound *store.NotFound + if !errors.As(err, ¬Found) { + return fmt.Errorf("failed to lookup used token entry: %w", err) + } + + if err := s.cache.Set(ctx, key, usedTokenMarker, store.WithExpiration(ttl)); err != nil { + return fmt.Errorf("failed to store used token entry: %w", err) + } + + return nil +} + +func hashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} diff --git a/management/server/auth/session_test.go b/management/server/auth/session_test.go new file mode 100644 index 000000000..3a7d85f4c --- /dev/null +++ b/management/server/auth/session_test.go @@ -0,0 +1,82 @@ +package auth + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbcache "github.com/netbirdio/netbird/management/server/cache" +) + +func newTestSessionStore(t *testing.T) *SessionStore { + t.Helper() + cacheStore, err := nbcache.NewStore(context.Background(), time.Hour, time.Hour, 100) + require.NoError(t, err) + return NewSessionStore(cacheStore) +} + +func TestSessionStore_FirstRegisterSucceeds(t *testing.T) { + s := newTestSessionStore(t) + ctx := context.Background() + + require.NoError(t, s.RegisterToken(ctx, "token", time.Now().Add(time.Hour))) +} + +func TestSessionStore_RegisterSameTokenTwiceIsRejected(t *testing.T) { + s := newTestSessionStore(t) + ctx := context.Background() + token := "token" + exp := time.Now().Add(time.Hour) + + require.NoError(t, s.RegisterToken(ctx, token, exp)) + + err := s.RegisterToken(ctx, token, exp) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenAlreadyUsed) +} + +func TestSessionStore_RegisterDifferentTokensAreIndependent(t *testing.T) { + s := newTestSessionStore(t) + ctx := context.Background() + exp := time.Now().Add(time.Hour) + + require.NoError(t, s.RegisterToken(ctx, "tokenA", exp)) + require.NoError(t, s.RegisterToken(ctx, "tokenB", exp)) +} + +func TestSessionStore_RegisterWithPastExpiryIsRejected(t *testing.T) { + s := newTestSessionStore(t) + ctx := context.Background() + token := "token" + + err := s.RegisterToken(ctx, token, time.Now().Add(-time.Second)) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenExpired) +} + +func TestSessionStore_EntryEvictsAtTTLAndAllowsReRegistration(t *testing.T) { + s := newTestSessionStore(t) + ctx := context.Background() + token := "token" + + require.NoError(t, s.RegisterToken(ctx, token, time.Now().Add(50*time.Millisecond))) + + err := s.RegisterToken(ctx, token, time.Now().Add(50*time.Millisecond)) + assert.ErrorIs(t, err, ErrTokenAlreadyUsed) + + time.Sleep(120 * time.Millisecond) + + require.NoError(t, s.RegisterToken(ctx, token, time.Now().Add(time.Hour))) +} + +func TestHashToken_StableAndDoesNotLeak(t *testing.T) { + a := hashToken("tokenA") + b := hashToken("tokenB") + assert.Equal(t, a, hashToken("tokenA"), "hash must be deterministic") + assert.NotEqual(t, a, b, "different tokens must hash differently") + assert.Len(t, a, 64, "sha256 hex must be 64 chars") + assert.NotContains(t, a, "tokenA", "raw token must not appear in hash") +} diff --git a/management/server/cache/idp.go b/management/server/cache/idp.go index 1b31ff82a..6ec42e217 100644 --- a/management/server/cache/idp.go +++ b/management/server/cache/idp.go @@ -18,6 +18,7 @@ const ( DefaultIDPCacheExpirationMax = 7 * 24 * time.Hour // 7 days DefaultIDPCacheExpirationMin = 3 * 24 * time.Hour // 3 days DefaultIDPCacheCleanupInterval = 30 * time.Minute + DefaultIDPCacheOpenConn = 100 ) // UserDataCache is an interface that wraps the basic Get, Set and Delete methods for idp.UserData objects. @@ -25,6 +26,8 @@ type UserDataCache interface { Get(ctx context.Context, key string) (*idp.UserData, error) Set(ctx context.Context, key string, value *idp.UserData, expiration time.Duration) error Delete(ctx context.Context, key string) error + GetUsers(ctx context.Context, key string) ([]*idp.UserData, error) + SetUsers(ctx context.Context, key string, users []*idp.UserData, expiration time.Duration) error } // UserDataCacheImpl is a struct that implements the UserDataCache interface. @@ -50,6 +53,29 @@ func (u *UserDataCacheImpl) Delete(ctx context.Context, key string) error { return u.cache.Delete(ctx, key) } +func (u *UserDataCacheImpl) GetUsers(ctx context.Context, key string) ([]*idp.UserData, error) { + var users []*idp.UserData + v, err := u.cache.Get(ctx, key, &users) + if err != nil { + return nil, err + } + + switch v := v.(type) { + case []*idp.UserData: + return v, nil + case *[]*idp.UserData: + return *v, nil + case []byte: + return unmarshalUserData(v) + } + + return nil, fmt.Errorf("unexpected type: %T", v) +} + +func (u *UserDataCacheImpl) SetUsers(ctx context.Context, key string, users []*idp.UserData, expiration time.Duration) error { + return u.cache.Set(ctx, key, users, store.WithExpiration(expiration)) +} + // NewUserDataCache creates a new UserDataCacheImpl object. func NewUserDataCache(store store.StoreInterface) *UserDataCacheImpl { simpleCache := cache.New[any](store) diff --git a/management/server/cache/idp_test.go b/management/server/cache/idp_test.go index 3fcfbb11a..0e8061e94 100644 --- a/management/server/cache/idp_test.go +++ b/management/server/cache/idp_test.go @@ -33,7 +33,7 @@ func TestNewIDPCacheManagers(t *testing.T) { t.Cleanup(cleanup) t.Setenv(cache.RedisStoreEnvVar, redisURL) } - cacheStore, err := cache.NewStore(context.Background(), cache.DefaultIDPCacheExpirationMax, cache.DefaultIDPCacheCleanupInterval) + cacheStore, err := cache.NewStore(context.Background(), cache.DefaultIDPCacheExpirationMax, cache.DefaultIDPCacheCleanupInterval, cache.DefaultIDPCacheOpenConn) if err != nil { t.Fatalf("couldn't create cache store: %s", err) } diff --git a/management/server/cache/store.go b/management/server/cache/store.go index 1c141a180..2ca8e8603 100644 --- a/management/server/cache/store.go +++ b/management/server/cache/store.go @@ -3,6 +3,7 @@ package cache import ( "context" "fmt" + "math" "os" "time" @@ -16,28 +17,52 @@ import ( // RedisStoreEnvVar is the environment variable that determines if a redis store should be used. // The value should follow redis URL format. https://github.com/redis/redis-specifications/blob/master/uri/redis.txt -const RedisStoreEnvVar = "NB_IDP_CACHE_REDIS_ADDRESS" +const RedisStoreEnvVar = "NB_CACHE_REDIS_ADDRESS" + +// legacyIdPCacheRedisEnvVar is the previous environment variable used for IDP cache. +const legacyIdPCacheRedisEnvVar = "NB_IDP_CACHE_REDIS_ADDRESS" + +const ( + // DefaultStoreMaxTimeout is the default max timeout for the shared cache store. + DefaultStoreMaxTimeout = 7 * 24 * time.Hour + // DefaultStoreCleanupInterval is the default cleanup interval for the shared cache store. + DefaultStoreCleanupInterval = 30 * time.Minute + // DefaultStoreMaxConn is the default max connections for the shared cache store. + DefaultStoreMaxConn = 1000 +) // NewStore creates a new cache store with the given max timeout and cleanup interval. It checks for the environment Variable RedisStoreEnvVar // to determine if a redis store should be used. If the environment variable is set, it will attempt to connect to the redis store. -func NewStore(ctx context.Context, maxTimeout, cleanupInterval time.Duration) (store.StoreInterface, error) { - redisAddr := os.Getenv(RedisStoreEnvVar) +func NewStore(ctx context.Context, maxTimeout, cleanupInterval time.Duration, maxConn int) (store.StoreInterface, error) { + redisAddr := GetAddrFromEnv() if redisAddr != "" { - return getRedisStore(ctx, redisAddr) + return getRedisStore(ctx, redisAddr, maxConn) } goc := gocache.New(maxTimeout, cleanupInterval) return gocache_store.NewGoCache(goc), nil } -func getRedisStore(ctx context.Context, redisEnvAddr string) (store.StoreInterface, error) { +// GetAddrFromEnv returns the redis address from the environment variable RedisStoreEnvVar or its legacy counterpart. +func GetAddrFromEnv() string { + addr := os.Getenv(RedisStoreEnvVar) + if addr == "" { + addr = os.Getenv(legacyIdPCacheRedisEnvVar) + } + return addr +} + +func getRedisStore(ctx context.Context, redisEnvAddr string, maxConn int) (store.StoreInterface, error) { options, err := redis.ParseURL(redisEnvAddr) if err != nil { return nil, fmt.Errorf("parsing redis cache url: %s", err) } - options.MaxIdleConns = 6 - options.MinIdleConns = 3 - options.MaxActiveConns = 100 + options.MaxIdleConns = int(math.Ceil(float64(maxConn) * 0.5)) // 50% of max conns + options.MinIdleConns = int(math.Ceil(float64(maxConn) * 0.1)) // 10% of max conns + options.MaxActiveConns = maxConn + options.ConnMaxIdleTime = 30 * time.Minute + options.ConnMaxLifetime = 0 + options.PoolTimeout = 10 * time.Second redisClient := redis.NewClient(options) subCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() diff --git a/management/server/cache/store_test.go b/management/server/cache/store_test.go index f49dd6bbd..b869170f0 100644 --- a/management/server/cache/store_test.go +++ b/management/server/cache/store_test.go @@ -7,15 +7,13 @@ import ( "github.com/eko/gocache/lib/v4/store" "github.com/redis/go-redis/v9" - "github.com/testcontainers/testcontainers-go" - testcontainersredis "github.com/testcontainers/testcontainers-go/modules/redis" "github.com/netbirdio/netbird/management/server/cache" ) func TestMemoryStore(t *testing.T) { - memStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond) + memStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100) if err != nil { t.Fatalf("couldn't create memory store: %s", err) } @@ -42,7 +40,7 @@ func TestMemoryStore(t *testing.T) { func TestRedisStoreConnectionFailure(t *testing.T) { t.Setenv(cache.RedisStoreEnvVar, "redis://127.0.0.1:6379") - _, err := cache.NewStore(context.Background(), 10*time.Millisecond, 30*time.Millisecond) + _, err := cache.NewStore(context.Background(), 10*time.Millisecond, 30*time.Millisecond, 100) if err == nil { t.Fatal("getting redis cache store should return error") } @@ -50,7 +48,7 @@ func TestRedisStoreConnectionFailure(t *testing.T) { func TestRedisStoreConnectionSuccess(t *testing.T) { ctx := context.Background() - redisContainer, err := testcontainersredis.RunContainer(ctx, testcontainers.WithImage("redis:7")) + redisContainer, err := testcontainersredis.Run(ctx, "redis:7") if err != nil { t.Fatalf("couldn't start redis container: %s", err) } @@ -65,7 +63,7 @@ func TestRedisStoreConnectionSuccess(t *testing.T) { } t.Setenv(cache.RedisStoreEnvVar, redisURL) - redisStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond) + redisStore, err := cache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100) if err != nil { t.Fatalf("couldn't create redis store: %s", err) } diff --git a/management/server/context/auth.go b/management/server/context/auth.go index 5cb28ddb7..cc59b8a63 100644 --- a/management/server/context/auth.go +++ b/management/server/context/auth.go @@ -4,7 +4,8 @@ import ( "context" "fmt" "net/http" - "time" + + "github.com/netbirdio/netbird/shared/auth" ) type key int @@ -13,45 +14,22 @@ const ( UserAuthContextKey key = iota ) -type UserAuth struct { - // The account id the user is accessing - AccountId string - // The account domain - Domain string - // The account domain category, TBC values - DomainCategory string - // Indicates whether this user was invited, TBC logic - Invited bool - // Indicates whether this is a child account - IsChild bool - - // The user id - UserId string - // Last login time for this user - LastLogin time.Time - // The Groups the user belongs to on this account - Groups []string - - // Indicates whether this user has authenticated with a Personal Access Token - IsPAT bool -} - -func GetUserAuthFromRequest(r *http.Request) (UserAuth, error) { +func GetUserAuthFromRequest(r *http.Request) (auth.UserAuth, error) { return GetUserAuthFromContext(r.Context()) } -func SetUserAuthInRequest(r *http.Request, userAuth UserAuth) *http.Request { +func SetUserAuthInRequest(r *http.Request, userAuth auth.UserAuth) *http.Request { return r.WithContext(SetUserAuthInContext(r.Context(), userAuth)) } -func GetUserAuthFromContext(ctx context.Context) (UserAuth, error) { - if userAuth, ok := ctx.Value(UserAuthContextKey).(UserAuth); ok { +func GetUserAuthFromContext(ctx context.Context) (auth.UserAuth, error) { + if userAuth, ok := ctx.Value(UserAuthContextKey).(auth.UserAuth); ok { return userAuth, nil } - return UserAuth{}, fmt.Errorf("user auth not in context") + return auth.UserAuth{}, fmt.Errorf("user auth not in context") } -func SetUserAuthInContext(ctx context.Context, userAuth UserAuth) context.Context { +func SetUserAuthInContext(ctx context.Context, userAuth auth.UserAuth) context.Context { //nolint ctx = context.WithValue(ctx, UserIDKey, userAuth.UserId) //nolint diff --git a/management/server/context/keys.go b/management/server/context/keys.go index 9697997a8..aa534c5d9 100644 --- a/management/server/context/keys.go +++ b/management/server/context/keys.go @@ -1,10 +1,28 @@ package context -import "github.com/netbirdio/netbird/shared/context" +import ( + "context" + + nbcontext "github.com/netbirdio/netbird/shared/context" +) const ( - RequestIDKey = context.RequestIDKey - AccountIDKey = context.AccountIDKey - UserIDKey = context.UserIDKey - PeerIDKey = context.PeerIDKey + RequestIDKey = nbcontext.RequestIDKey + AccountIDKey = nbcontext.AccountIDKey + RoleKey = nbcontext.RoleKey + UserIDKey = nbcontext.UserIDKey + PeerIDKey = nbcontext.PeerIDKey + UserAgentKey = nbcontext.UserAgentKey ) + +// RoleFromContext returns the role stored in ctx, or empty string and false if absent. +func RoleFromContext(ctx context.Context) (string, bool) { + role, ok := ctx.Value(RoleKey).(string) + return role, ok +} + +// WithRole returns a new context carrying the given role. +func WithRole(ctx context.Context, role string) context.Context { + //nolint + return context.WithValue(ctx, RoleKey, role) +} diff --git a/management/server/dns.go b/management/server/dns.go index baf6debc3..612c8ecba 100644 --- a/management/server/dns.go +++ b/management/server/dns.go @@ -8,6 +8,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/store" @@ -22,7 +23,7 @@ const ( // GetDNSSettings validates a user role and returns the DNS settings for the provided account ID func (am *DefaultAccountManager) GetDNSSettings(ctx context.Context, accountID string, userID string) (*types.DNSSettings, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -39,7 +40,7 @@ func (am *DefaultAccountManager) SaveDNSSettings(ctx context.Context, accountID return status.Errorf(status.InvalidArgument, "the dns settings provided are nil") } - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Update) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Dns, operations.Update) if err != nil { return status.NewPermissionValidationError(err) } @@ -47,8 +48,9 @@ func (am *DefaultAccountManager) SaveDNSSettings(ctx context.Context, accountID return status.NewPermissionDeniedError() } - var updateAccountPeers bool var eventsToStore []func() + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err = validateDNSSettings(ctx, transaction, accountID, dnsSettingsToSave); err != nil { @@ -63,11 +65,6 @@ func (am *DefaultAccountManager) SaveDNSSettings(ctx context.Context, accountID addedGroups := util.Difference(dnsSettingsToSave.DisabledManagementGroups, oldSettings.DisabledManagementGroups) removedGroups := util.Difference(oldSettings.DisabledManagementGroups, dnsSettingsToSave.DisabledManagementGroups) - updateAccountPeers, err = areDNSSettingChangesAffectPeers(ctx, transaction, accountID, addedGroups, removedGroups) - if err != nil { - return err - } - events := am.prepareDNSSettingsEvents(ctx, transaction, accountID, userID, addedGroups, removedGroups) eventsToStore = append(eventsToStore, events...) @@ -75,6 +72,11 @@ func (am *DefaultAccountManager) SaveDNSSettings(ctx context.Context, accountID return err } + change = affectedpeers.Change{DistributionGroupIDs: slices.Concat(addedGroups, removedGroups)} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return err + } + return transaction.IncrementNetworkSerial(ctx, accountID) }) if err != nil { @@ -85,9 +87,7 @@ func (am *DefaultAccountManager) SaveDNSSettings(ctx context.Context, accountID storeEvent() } - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } @@ -133,20 +133,6 @@ func (am *DefaultAccountManager) prepareDNSSettingsEvents(ctx context.Context, t return eventsToStore } -// areDNSSettingChangesAffectPeers checks if the DNS settings changes affect any peers. -func areDNSSettingChangesAffectPeers(ctx context.Context, transaction store.Store, accountID string, addedGroups, removedGroups []string) (bool, error) { - hasPeers, err := anyGroupHasPeersOrResources(ctx, transaction, accountID, addedGroups) - if err != nil { - return false, err - } - - if hasPeers { - return true, nil - } - - return anyGroupHasPeersOrResources(ctx, transaction, accountID, removedGroups) -} - // validateDNSSettings validates the DNS settings. func validateDNSSettings(ctx context.Context, transaction store.Store, accountID string, settings *types.DNSSettings) error { if len(settings.DisabledManagementGroups) == 0 { diff --git a/management/server/dns_test.go b/management/server/dns_test.go index 356a2f640..8917902d9 100644 --- a/management/server/dns_test.go +++ b/management/server/dns_test.go @@ -12,7 +12,12 @@ import ( 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" + ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" + "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" + "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" "github.com/netbirdio/netbird/management/server/store" @@ -218,13 +223,20 @@ func createDNSManager(t *testing.T) (*DefaultAccountManager, error) { // return empty extra settings for expected calls to UpdateAccountPeers settingsMockManager.EXPECT().GetExtraSettings(gomock.Any(), gomock.Any()).Return(&types.ExtraSettings{}, nil).AnyTimes() permissionsManager := permissions.NewManager(store) + peersManager := peers.NewManager(store, permissionsManager) ctx := context.Background() + + cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100) + if err != nil { + return nil, err + } + updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.test", port_forwarding.NewControllerMock()) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.test", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}) - return BuildManager(context.Background(), store, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false) + return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) } func createDNSStore(t *testing.T) (store.Store, error) { @@ -274,7 +286,7 @@ func initTestDNSAccount(t *testing.T, am *DefaultAccountManager) (*types.Account domain := "example.com" - account := newAccountWithId(context.Background(), dnsAccountID, dnsAdminUserID, domain, false) + account := newAccountWithId(context.Background(), dnsAccountID, dnsAdminUserID, domain, "", "", false) account.Users[dnsRegularUserID] = &types.User{ Id: dnsRegularUserID, @@ -286,11 +298,11 @@ func initTestDNSAccount(t *testing.T, am *DefaultAccountManager) (*types.Account return nil, err } - savedPeer1, _, _, err := am.AddPeer(context.Background(), "", "", dnsAdminUserID, peer1, false) + savedPeer1, _, _, _, err := am.AddPeer(context.Background(), "", "", dnsAdminUserID, peer1, false) if err != nil { return nil, err } - _, _, _, err = am.AddPeer(context.Background(), "", "", dnsAdminUserID, peer2, false) + _, _, _, _, err = am.AddPeer(context.Background(), "", "", dnsAdminUserID, peer2, false) if err != nil { return nil, err } @@ -446,7 +458,7 @@ func TestDNSAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -466,7 +478,7 @@ func TestDNSAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -506,7 +518,7 @@ func TestDNSAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) diff --git a/management/server/event.go b/management/server/event.go index d26c569ae..4211f2dda 100644 --- a/management/server/event.go +++ b/management/server/event.go @@ -23,7 +23,7 @@ func isEnabled() bool { // GetEvents returns a list of activity events of an account func (am *DefaultAccountManager) GetEvents(ctx context.Context, accountID, userID string) ([]*activity.Event, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Events, operations.Read) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Events, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } diff --git a/management/server/geolocation/geolocation.go b/management/server/geolocation/geolocation.go index c0179a1c4..0af3ce2f6 100644 --- a/management/server/geolocation/geolocation.go +++ b/management/server/geolocation/geolocation.go @@ -44,6 +44,12 @@ type Record struct { GeonameID uint `maxminddb:"geoname_id"` ISOCode string `maxminddb:"iso_code"` } `maxminddb:"country"` + Subdivisions []struct { + ISOCode string `maxminddb:"iso_code"` + Names struct { + En string `maxminddb:"en"` + } `maxminddb:"names"` + } `maxminddb:"subdivisions"` } type City struct { @@ -124,6 +130,10 @@ func (gl *geolocationImpl) Lookup(ip net.IP) (*Record, error) { gl.mux.RLock() defer gl.mux.RUnlock() + if gl.db == nil { + return nil, fmt.Errorf("geolocation database is not available") + } + var record Record err := gl.db.Lookup(ip, &record) if err != nil { @@ -167,8 +177,14 @@ func (gl *geolocationImpl) GetCitiesByCountry(countryISOCode string) ([]City, er func (gl *geolocationImpl) Stop() error { close(gl.stopCh) - if gl.db != nil { - if err := gl.db.Close(); err != nil { + + gl.mux.Lock() + db := gl.db + gl.db = nil + gl.mux.Unlock() + + if db != nil { + if err := db.Close(); err != nil { return err } } diff --git a/management/server/group.go b/management/server/group.go index 84e641f26..dab891f2a 100644 --- a/management/server/group.go +++ b/management/server/group.go @@ -11,6 +11,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" @@ -32,7 +33,7 @@ func (e *GroupLinkError) Error() string { // CheckGroupPermissions validates if a user has the necessary permissions to view groups func (am *DefaultAccountManager) CheckGroupPermissions(ctx context.Context, accountID, userID string) error { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Read) + allowed, _, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Read) if err != nil { return err } @@ -61,13 +62,16 @@ func (am *DefaultAccountManager) GetAllGroups(ctx context.Context, accountID, us } // GetGroupByName filters all groups in an account by name and returns the one with the most peers -func (am *DefaultAccountManager) GetGroupByName(ctx context.Context, groupName, accountID string) (*types.Group, error) { +func (am *DefaultAccountManager) GetGroupByName(ctx context.Context, groupName, accountID, userID string) (*types.Group, error) { + if err := am.CheckGroupPermissions(ctx, accountID, userID); err != nil { + return nil, err + } return am.Store.GetGroupByName(ctx, store.LockingStrengthNone, accountID, groupName) } // CreateGroup object of the peers func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, userID string, newGroup *types.Group) error { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Create) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Create) if err != nil { return status.NewPermissionValidationError(err) } @@ -76,7 +80,8 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use } var eventsToStore []func() - var updateAccountPeers bool + var snap *affectedpeers.Snapshot + change := affectedpeers.Change{ChangedGroupIDs: []string{newGroup.ID}} err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err = validateNewGroup(ctx, transaction, accountID, newGroup); err != nil { @@ -88,10 +93,7 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use events := am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup) eventsToStore = append(eventsToStore, events...) - updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{newGroup.ID}) - if err != nil { - return err - } + newGroup.PublicID = xid.New().String() if err := transaction.CreateGroup(ctx, newGroup); err != nil { return status.Errorf(status.Internal, "failed to create group: %v", err) @@ -103,6 +105,11 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use } } + snap, err = affectedpeers.Load(ctx, transaction, accountID, change) + if err != nil { + return err + } + return transaction.IncrementNetworkSerial(ctx, accountID) }) if err != nil { @@ -113,16 +120,14 @@ func (am *DefaultAccountManager) CreateGroup(ctx context.Context, accountID, use storeEvent() } - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } // UpdateGroup object of the peers func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, userID string, newGroup *types.Group) error { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update) if err != nil { return status.NewPermissionValidationError(err) } @@ -131,7 +136,8 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use } var eventsToStore []func() - var updateAccountPeers bool + var snap *affectedpeers.Snapshot + change := affectedpeers.Change{ChangedGroupIDs: []string{newGroup.ID}} err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err = validateNewGroup(ctx, transaction, accountID, newGroup); err != nil { @@ -150,27 +156,31 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use peersToAdd := util.Difference(newGroup.Peers, oldGroup.Peers) peersToRemove := util.Difference(oldGroup.Peers, newGroup.Peers) - - for _, peerID := range peersToAdd { - if err := transaction.AddPeerToGroup(ctx, accountID, peerID, newGroup.ID); err != nil { - return status.Errorf(status.Internal, "failed to add peer %s to group %s: %v", peerID, newGroup.ID, err) - } - } - for _, peerID := range peersToRemove { - if err := transaction.RemovePeerFromGroup(ctx, peerID, newGroup.ID); err != nil { - return status.Errorf(status.Internal, "failed to remove peer %s from group %s: %v", peerID, newGroup.ID, err) - } - } - - updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{newGroup.ID}) - if err != nil { + if err = syncGroupMembership(ctx, transaction, accountID, newGroup.ID, peersToAdd, peersToRemove); err != nil { return err } + newGroup.PublicID = oldGroup.PublicID + if err = transaction.UpdateGroup(ctx, newGroup); err != nil { return err } + if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{newGroup.ID}); err != nil { + return err + } + + // A membership change does not alter which entities reference the group, so + // the dependency walk runs once against the post-change snapshot. The new + // members are already in the snapshot's index; the removed members are + // carried separately and folded in only when the group is linked. + if len(peersToRemove) > 0 { + change.RemovedPeersByGroup = map[string][]string{newGroup.ID: peersToRemove} + } + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return err + } + return transaction.IncrementNetworkSerial(ctx, accountID) }) if err != nil { @@ -181,19 +191,32 @@ func (am *DefaultAccountManager) UpdateGroup(ctx context.Context, accountID, use storeEvent() } - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } +// syncGroupMembership applies the peer membership delta for a group within a transaction. +func syncGroupMembership(ctx context.Context, transaction store.Store, accountID, groupID string, peersToAdd, peersToRemove []string) error { + for _, peerID := range peersToAdd { + if err := transaction.AddPeerToGroup(ctx, accountID, peerID, groupID); err != nil { + return status.Errorf(status.Internal, "failed to add peer %s to group %s: %v", peerID, groupID, err) + } + } + for _, peerID := range peersToRemove { + if err := transaction.RemovePeerFromGroup(ctx, peerID, groupID); err != nil { + return status.Errorf(status.Internal, "failed to remove peer %s from group %s: %v", peerID, groupID, err) + } + } + return nil +} + // CreateGroups adds new groups to the account. // Note: This function does not acquire the global lock. // It is the caller's responsibility to ensure proper locking is in place before invoking this method. // This method will not create group peer membership relations. Use AddPeerToGroup or RemovePeerFromGroup methods for that. func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, userID string, groups []*types.Group) error { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Create) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Create) if err != nil { return status.NewPermissionValidationError(err) } @@ -202,17 +225,21 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us } var eventsToStore []func() - var updateAccountPeers bool + var snaps []*affectedpeers.Snapshot + var changes []affectedpeers.Change var globalErr error - groupIDs := make([]string, 0, len(groups)) + createdCount := 0 for _, newGroup := range groups { + change := affectedpeers.Change{ChangedGroupIDs: []string{newGroup.ID}} + var snap *affectedpeers.Snapshot err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err = validateNewGroup(ctx, transaction, accountID, newGroup); err != nil { return err } newGroup.AccountID = accountID + newGroup.PublicID = xid.New().String() if err = transaction.CreateGroup(ctx, newGroup); err != nil { return err @@ -223,35 +250,31 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us return err } - groupIDs = append(groupIDs, newGroup.ID) - events := am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup) eventsToStore = append(eventsToStore, events...) - return nil + snap, err = affectedpeers.Load(ctx, transaction, accountID, change) + return err }) if err != nil { log.WithContext(ctx).Errorf("failed to update group %s: %v", newGroup.ID, err) - if len(groupIDs) == 1 { + if createdCount == 0 { return err } globalErr = errors.Join(globalErr, err) // continue updating other groups + continue } - } - - updateAccountPeers, err = areGroupChangesAffectPeers(ctx, am.Store, accountID, groupIDs) - if err != nil { - return err + createdCount++ + snaps = append(snaps, snap) + changes = append(changes, change) } for _, storeEvent := range eventsToStore { storeEvent() } - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) - } + go am.dispatchAffected(ctx, accountID, snaps, changes) return globalErr } @@ -261,7 +284,7 @@ func (am *DefaultAccountManager) CreateGroups(ctx context.Context, accountID, us // It is the caller's responsibility to ensure proper locking is in place before invoking this method. // This method will not create group peer membership relations. Use AddPeerToGroup or RemovePeerFromGroup methods for that. func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, userID string, groups []*types.Group) error { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update) if err != nil { return status.NewPermissionValidationError(err) } @@ -270,60 +293,71 @@ func (am *DefaultAccountManager) UpdateGroups(ctx context.Context, accountID, us } var eventsToStore []func() - var updateAccountPeers bool + var snaps []*affectedpeers.Snapshot + var changes []affectedpeers.Change var globalErr error - groupIDs := make([]string, 0, len(groups)) for _, newGroup := range groups { - err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - if err = validateNewGroup(ctx, transaction, accountID, newGroup); err != nil { - return err - } - - newGroup.AccountID = accountID - - if err = transaction.UpdateGroup(ctx, newGroup); err != nil { - return err - } - - err = transaction.IncrementNetworkSerial(ctx, accountID) - if err != nil { - return err - } - - events := am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup) - eventsToStore = append(eventsToStore, events...) - - groupIDs = append(groupIDs, newGroup.ID) - - return nil - }) + change := affectedpeers.Change{ChangedGroupIDs: []string{newGroup.ID}} + events, snap, err := am.updateSingleGroup(ctx, accountID, userID, newGroup, change) if err != nil { log.WithContext(ctx).Errorf("failed to update group %s: %v", newGroup.ID, err) if len(groups) == 1 { return err } globalErr = errors.Join(globalErr, err) - // continue updating other groups + continue } - } - - updateAccountPeers, err = areGroupChangesAffectPeers(ctx, am.Store, accountID, groupIDs) - if err != nil { - return err + eventsToStore = append(eventsToStore, events...) + snaps = append(snaps, snap) + changes = append(changes, change) } for _, storeEvent := range eventsToStore { storeEvent() } - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) - } + go am.dispatchAffected(ctx, accountID, snaps, changes) return globalErr } +func (am *DefaultAccountManager) updateSingleGroup(ctx context.Context, accountID, userID string, newGroup *types.Group, change affectedpeers.Change) ([]func(), *affectedpeers.Snapshot, error) { + var events []func() + var snap *affectedpeers.Snapshot + err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + if err := validateNewGroup(ctx, transaction, accountID, newGroup); err != nil { + return err + } + + newGroup.AccountID = accountID + + oldGroup, err := transaction.GetGroupByID(ctx, store.LockingStrengthNone, accountID, newGroup.ID) + if err != nil { + return err + } + newGroup.PublicID = oldGroup.PublicID + + if err := transaction.UpdateGroup(ctx, newGroup); err != nil { + return err + } + + if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{newGroup.ID}); err != nil { + return err + } + + if err := transaction.IncrementNetworkSerial(ctx, accountID); err != nil { + return err + } + + events = am.prepareGroupEvents(ctx, transaction, accountID, userID, newGroup) + + snap, err = affectedpeers.Load(ctx, transaction, accountID, change) + return err + }) + return events, snap, err +} + // prepareGroupEvents prepares a list of event functions to be stored. func (am *DefaultAccountManager) prepareGroupEvents(ctx context.Context, transaction store.Store, accountID, userID string, newGroup *types.Group) []func() { var eventsToStore []func() @@ -413,7 +447,7 @@ func (am *DefaultAccountManager) DeleteGroup(ctx context.Context, accountID, use // If an error occurs while deleting a group, the function skips it and continues deleting other groups. // Errors are collected and returned at the end. func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, userID string, groupIDs []string) error { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Delete) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Delete) if err != nil { return status.NewPermissionValidationError(err) } @@ -424,28 +458,40 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us var allErrors error var groupIDsToDelete []string var deletedGroups []*types.Group + var snap *affectedpeers.Snapshot + var change affectedpeers.Change + + extraSettings, err := am.settingsManager.GetExtraSettings(ctx, accountID) + if err != nil { + return err + } err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - for _, groupID := range groupIDs { - group, err := transaction.GetGroupByID(ctx, store.LockingStrengthUpdate, accountID, groupID) - if err != nil { - allErrors = errors.Join(allErrors, err) - continue - } + deletedGroups, allErrors = collectDeletableGroups(ctx, transaction, accountID, userID, groupIDs, extraSettings.FlowGroups) + for _, group := range deletedGroups { + groupIDsToDelete = append(groupIDsToDelete, group.ID) + } - if err := validateDeleteGroup(ctx, transaction, group, userID); err != nil { - allErrors = errors.Join(allErrors, err) - continue - } + if len(groupIDsToDelete) == 0 { + return allErrors + } - groupIDsToDelete = append(groupIDsToDelete, groupID) - deletedGroups = append(deletedGroups, group) + // Delete: compute affected peers from the PRE-delete state. The groups, + // their members and the entities referencing them still exist, so a plain + // Load+Expand captures everyone — no removed-peer folding needed. + change = affectedpeers.Change{ChangedGroupIDs: groupIDsToDelete} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return err } if err = transaction.DeleteGroups(ctx, accountID, groupIDsToDelete); err != nil { return err } + if err = am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, groupIDsToDelete); err != nil { + return err + } + return transaction.IncrementNetworkSerial(ctx, accountID) }) if err != nil { @@ -456,21 +502,52 @@ func (am *DefaultAccountManager) DeleteGroups(ctx context.Context, accountID, us am.StoreEvent(ctx, userID, group.ID, accountID, activity.GroupDeleted, group.EventMeta()) } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) + return allErrors } +// collectDeletableGroups loads and validates each group for deletion, returning +// the groups that may be deleted and the joined validation errors for the rest. +func collectDeletableGroups(ctx context.Context, transaction store.Store, accountID, userID string, groupIDs, flowGroups []string) ([]*types.Group, error) { + var deletable []*types.Group + var allErrors error + for _, groupID := range groupIDs { + group, err := transaction.GetGroupByID(ctx, store.LockingStrengthNone, accountID, groupID) + if err != nil { + allErrors = errors.Join(allErrors, err) + continue + } + if err = validateDeleteGroup(ctx, transaction, group, userID, flowGroups); err != nil { + allErrors = errors.Join(allErrors, err) + continue + } + deletable = append(deletable, group) + } + return deletable, allErrors +} + // GroupAddPeer appends peer to the group func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, groupID, peerID string) error { - var updateAccountPeers bool - var err error + var snap *affectedpeers.Snapshot + // A membership change affects only the peer itself and the opposite side of THIS + // group's policies — not the group's other members, and not the peer's other + // groups. LinkGroups walks only this group (matched, not expanded); OutputPeerIDs + // refreshes the peer without seeding its other group memberships. For an + // intra-group policy the opposite side is the group, so its members still refresh. + change := affectedpeers.Change{OutputPeerIDs: []string{peerID}, LinkGroups: []string{groupID}} - err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{groupID}) - if err != nil { + err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + if err := transaction.AddPeerToGroup(ctx, accountID, peerID, groupID); err != nil { return err } - if err = transaction.AddPeerToGroup(ctx, accountID, peerID, groupID); err != nil { + if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil { + return err + } + + var err error + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } @@ -480,9 +557,7 @@ func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, gr return err } - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } @@ -490,8 +565,9 @@ func (am *DefaultAccountManager) GroupAddPeer(ctx context.Context, accountID, gr // GroupAddResource appends resource to the group func (am *DefaultAccountManager) GroupAddResource(ctx context.Context, accountID, groupID string, resource types.Resource) error { var group *types.Group - var updateAccountPeers bool + var snap *affectedpeers.Snapshot var err error + change := affectedpeers.Change{ChangedGroupIDs: []string{groupID}} err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { group, err = transaction.GetGroupByID(context.Background(), store.LockingStrengthUpdate, accountID, groupID) @@ -503,12 +579,11 @@ func (am *DefaultAccountManager) GroupAddResource(ctx context.Context, accountID return nil } - updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{groupID}) - if err != nil { + if err = transaction.UpdateGroup(ctx, group); err != nil { return err } - if err = transaction.UpdateGroup(ctx, group); err != nil { + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } @@ -518,25 +593,31 @@ func (am *DefaultAccountManager) GroupAddResource(ctx context.Context, accountID return err } - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } // GroupDeletePeer removes peer from the group func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID, groupID, peerID string) error { - var updateAccountPeers bool - var err error + var snap *affectedpeers.Snapshot + // Same as GroupAddPeer: the removed peer and the opposite side of THIS group's + // policies refresh, not the group's other members or the peer's other groups. The + // peer is no longer in the group's index, but LinkGroups still drives the + // opposite-side walk, and OutputPeerIDs refreshes the removed peer itself. + change := affectedpeers.Change{OutputPeerIDs: []string{peerID}, LinkGroups: []string{groupID}} - err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{groupID}) - if err != nil { + err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + if err := transaction.RemovePeerFromGroup(ctx, peerID, groupID); err != nil { return err } - if err = transaction.RemovePeerFromGroup(ctx, peerID, groupID); err != nil { + if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, []string{groupID}); err != nil { + return err + } + + var err error + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } @@ -546,9 +627,7 @@ func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID, return err } - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } @@ -556,8 +635,9 @@ func (am *DefaultAccountManager) GroupDeletePeer(ctx context.Context, accountID, // GroupDeleteResource removes resource from the group func (am *DefaultAccountManager) GroupDeleteResource(ctx context.Context, accountID, groupID string, resource types.Resource) error { var group *types.Group - var updateAccountPeers bool + var snap *affectedpeers.Snapshot var err error + change := affectedpeers.Change{ChangedGroupIDs: []string{groupID}} err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { group, err = transaction.GetGroupByID(context.Background(), store.LockingStrengthUpdate, accountID, groupID) @@ -569,8 +649,9 @@ func (am *DefaultAccountManager) GroupDeleteResource(ctx context.Context, accoun return nil } - updateAccountPeers, err = areGroupChangesAffectPeers(ctx, transaction, accountID, []string{groupID}) - if err != nil { + // Load before persisting the removal, so the snapshot still maps the group + // to the resource and the bridge can reach its routing peers. + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } @@ -584,9 +665,7 @@ func (am *DefaultAccountManager) GroupDeleteResource(ctx context.Context, accoun return err } - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } @@ -617,7 +696,7 @@ func validateNewGroup(ctx context.Context, transaction store.Store, accountID st return nil } -func validateDeleteGroup(ctx context.Context, transaction store.Store, group *types.Group, userID string) error { +func validateDeleteGroup(ctx context.Context, transaction store.Store, group *types.Group, userID string, flowGroups []string) error { // disable a deleting integration group if the initiator is not an admin service user if group.Issued == types.GroupIssuedIntegration { executingUser, err := transaction.GetUserByUserID(ctx, store.LockingStrengthNone, userID) @@ -637,6 +716,10 @@ func validateDeleteGroup(ctx context.Context, transaction store.Store, group *ty return &GroupLinkError{"network resource", group.Resources[0].ID} } + if slices.Contains(flowGroups, group.ID) { + return &GroupLinkError{"settings", "traffic event logging"} + } + if isLinked, linkedRoute := isGroupLinkedToRoute(ctx, transaction, group.AccountID, group.ID); isLinked { return &GroupLinkError{"route", string(linkedRoute.NetID)} } @@ -793,49 +876,103 @@ func isGroupLinkedToNetworkRouter(ctx context.Context, transaction store.Store, } // areGroupChangesAffectPeers checks if any changes to the specified groups will affect peers. +// It fetches each collection once and checks all groupIDs against them in memory. func areGroupChangesAffectPeers(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) (bool, error) { if len(groupIDs) == 0 { return false, nil } + groupSet := make(map[string]struct{}, len(groupIDs)) + for _, id := range groupIDs { + groupSet[id] = struct{}{} + } + + if affected, err := dnsSettingsReferenceGroups(ctx, transaction, accountID, groupSet); affected || err != nil { + return affected, err + } + if affected, err := nameServersReferenceGroups(ctx, transaction, accountID, groupSet); affected || err != nil { + return affected, err + } + if affected, err := policiesReferenceGroups(ctx, transaction, accountID, groupSet); affected || err != nil { + return affected, err + } + if affected, err := routesReferenceGroups(ctx, transaction, accountID, groupSet); affected || err != nil { + return affected, err + } + if affected, err := networkRoutersReferenceGroups(ctx, transaction, accountID, groupSet); affected || err != nil { + return affected, err + } + + return false, nil +} + +func dnsSettingsReferenceGroups(ctx context.Context, transaction store.Store, accountID string, groupSet map[string]struct{}) (bool, error) { dnsSettings, err := transaction.GetAccountDNSSettings(ctx, store.LockingStrengthNone, accountID) if err != nil { return false, err } - - for _, groupID := range groupIDs { - if slices.Contains(dnsSettings.DisabledManagementGroups, groupID) { - return true, nil - } - if linked, _ := isGroupLinkedToDns(ctx, transaction, accountID, groupID); linked { - return true, nil - } - if linked, _ := isGroupLinkedToPolicy(ctx, transaction, accountID, groupID); linked { - return true, nil - } - if linked, _ := isGroupLinkedToRoute(ctx, transaction, accountID, groupID); linked { - return true, nil - } - if linked, _ := isGroupLinkedToNetworkRouter(ctx, transaction, accountID, groupID); linked { - return true, nil - } - } - - return false, nil + return anyInSet(dnsSettings.DisabledManagementGroups, groupSet), nil } -// anyGroupHasPeersOrResources checks if any of the given groups in the account have peers or resources. -func anyGroupHasPeersOrResources(ctx context.Context, transaction store.Store, accountID string, groupIDs []string) (bool, error) { - groups, err := transaction.GetGroupsByIDs(ctx, store.LockingStrengthNone, accountID, groupIDs) +func nameServersReferenceGroups(ctx context.Context, transaction store.Store, accountID string, groupSet map[string]struct{}) (bool, error) { + nameServerGroups, err := transaction.GetAccountNameServerGroups(ctx, store.LockingStrengthNone, accountID) if err != nil { return false, err } - - for _, group := range groups { - if group.HasPeers() || group.HasResources() { + for _, ns := range nameServerGroups { + if anyInSet(ns.Groups, groupSet) { return true, nil } } - return false, nil } + +func policiesReferenceGroups(ctx context.Context, transaction store.Store, accountID string, groupSet map[string]struct{}) (bool, error) { + policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return false, err + } + for _, policy := range policies { + for _, rule := range policy.Rules { + if anyInSet(rule.Sources, groupSet) || anyInSet(rule.Destinations, groupSet) { + return true, nil + } + } + } + return false, nil +} + +func routesReferenceGroups(ctx context.Context, transaction store.Store, accountID string, groupSet map[string]struct{}) (bool, error) { + routes, err := transaction.GetAccountRoutes(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return false, err + } + for _, r := range routes { + if anyInSet(r.Groups, groupSet) || anyInSet(r.PeerGroups, groupSet) || anyInSet(r.AccessControlGroups, groupSet) { + return true, nil + } + } + return false, nil +} + +func networkRoutersReferenceGroups(ctx context.Context, transaction store.Store, accountID string, groupSet map[string]struct{}) (bool, error) { + routers, err := transaction.GetNetworkRoutersByAccountID(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return false, err + } + for _, router := range routers { + if anyInSet(router.PeerGroups, groupSet) { + return true, nil + } + } + return false, nil +} + +func anyInSet(ids []string, set map[string]struct{}) bool { + for _, id := range ids { + if _, ok := set[id]; ok { + return true + } + } + return false +} diff --git a/management/server/group_ipv6_test.go b/management/server/group_ipv6_test.go new file mode 100644 index 000000000..dfb436060 --- /dev/null +++ b/management/server/group_ipv6_test.go @@ -0,0 +1,125 @@ +package server + +import ( + "context" + "math/rand" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" +) + +// TestGroupIPv6Assignment verifies that peers gain or lose IPv6 addresses +// when they are added to or removed from an IPv6-enabled group. +func TestGroupIPv6Assignment(t *testing.T) { + am, _, err := createManager(t) + require.NoError(t, err) + + ctx := context.Background() + userID := groupAdminUserID + + account, err := createAccount(am, "ipv6-grp-test", userID, "ipv6test.example.com") + require.NoError(t, err) + + // Allocate IPv6 subnet for the account + account.Network.NetV6 = types.AllocateIPv6Subnet(rand.New(rand.NewSource(time.Now().UnixNano()))) + require.NoError(t, am.Store.SaveAccount(ctx, account)) + + // Create setup key + setupKey, err := am.CreateSetupKey(ctx, account.Id, "ipv6-key", types.SetupKeyReusable, time.Hour, nil, 999, userID, false, false) + require.NoError(t, err) + + // Create an IPv6-enabled group + ipv6GroupID := "ipv6-enabled-grp" + err = am.CreateGroup(ctx, account.Id, userID, &types.Group{ + ID: ipv6GroupID, + Name: "IPv6 Enabled", + Issued: types.GroupIssuedAPI, + Peers: []string{}, + }) + require.NoError(t, err) + + // Enable IPv6 on that group + settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, account.Id) + require.NoError(t, err) + settings.IPv6EnabledGroups = []string{ipv6GroupID} + require.NoError(t, am.Store.SaveAccountSettings(ctx, account.Id, settings)) + + // Register a peer (will be in "All" group, not the IPv6 group) + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err) + + peer, _, _, _, err := am.AddPeer(ctx, "", setupKey.Key, "", &nbpeer.Peer{ + Key: key.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: "ipv6-test-host"}, + }, false) + require.NoError(t, err) + assert.False(t, peer.IPv6.IsValid(), "peer should not have IPv6 before joining an IPv6-enabled group") + + t.Run("GroupAddPeer assigns IPv6", func(t *testing.T) { + err := am.GroupAddPeer(ctx, account.Id, ipv6GroupID, peer.ID) + require.NoError(t, err) + + p, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, account.Id, peer.ID) + require.NoError(t, err) + assert.True(t, p.IPv6.IsValid(), "peer should have an IPv6 address after joining the group") + }) + + t.Run("GroupDeletePeer clears IPv6", func(t *testing.T) { + err := am.GroupDeletePeer(ctx, account.Id, ipv6GroupID, peer.ID) + require.NoError(t, err) + + p, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, account.Id, peer.ID) + require.NoError(t, err) + assert.False(t, p.IPv6.IsValid(), "peer should not have IPv6 after removal from the group") + }) + + t.Run("UpdateGroup with peer addition assigns IPv6", func(t *testing.T) { + grp, err := am.Store.GetGroupByID(ctx, store.LockingStrengthNone, account.Id, ipv6GroupID) + require.NoError(t, err) + + grp.Peers = append(grp.Peers, peer.ID) + err = am.UpdateGroup(ctx, account.Id, userID, grp) + require.NoError(t, err) + + p, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, account.Id, peer.ID) + require.NoError(t, err) + assert.True(t, p.IPv6.IsValid(), "peer should have IPv6 after UpdateGroup adds it") + }) + + t.Run("UpdateGroup with peer removal clears IPv6", func(t *testing.T) { + grp, err := am.Store.GetGroupByID(ctx, store.LockingStrengthNone, account.Id, ipv6GroupID) + require.NoError(t, err) + + grp.Peers = []string{} + err = am.UpdateGroup(ctx, account.Id, userID, grp) + require.NoError(t, err) + + p, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, account.Id, peer.ID) + require.NoError(t, err) + assert.False(t, p.IPv6.IsValid(), "peer should lose IPv6 after UpdateGroup removes it") + }) + + t.Run("non-IPv6 group changes do not affect IPv6", func(t *testing.T) { + err := am.CreateGroup(ctx, account.Id, userID, &types.Group{ + ID: "regular-grp", + Name: "Regular Group", + Issued: types.GroupIssuedAPI, + Peers: []string{}, + }) + require.NoError(t, err) + + err = am.GroupAddPeer(ctx, account.Id, "regular-grp", peer.ID) + require.NoError(t, err) + + p, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, account.Id, peer.ID) + require.NoError(t, err) + assert.False(t, p.IPv6.IsValid(), "peer should not get IPv6 from a non-IPv6 group") + }) +} diff --git a/management/server/group_test.go b/management/server/group_test.go index 4935dac5d..22fda2671 100644 --- a/management/server/group_test.go +++ b/management/server/group_test.go @@ -5,13 +5,13 @@ import ( "encoding/binary" "errors" "fmt" - "net" "net/netip" "strconv" "sync" "testing" "time" + "github.com/golang/mock/gomock" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -26,6 +26,7 @@ import ( networkTypes "github.com/netbirdio/netbird/management/server/networks/types" peer2 "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/settings" "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/route" @@ -284,6 +285,67 @@ func TestDefaultAccountManager_DeleteGroups(t *testing.T) { } } +func TestDefaultAccountManager_DeleteGroupLinkedToFlowGroup(t *testing.T) { + am, _, err := createManager(t) + require.NoError(t, err) + + ctrl := gomock.NewController(t) + settingsMock := settings.NewMockManager(ctrl) + settingsMock.EXPECT(). + GetExtraSettings(gomock.Any(), gomock.Any()). + Return(&types.ExtraSettings{FlowGroups: []string{"grp-for-flow"}}, nil). + AnyTimes() + settingsMock.EXPECT(). + UpdateExtraSettings(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(false, nil). + AnyTimes() + am.settingsManager = settingsMock + + _, account, err := initTestGroupAccount(am) + require.NoError(t, err) + + grp := &types.Group{ + ID: "grp-for-flow", + AccountID: account.Id, + Name: "Group for flow", + Issued: types.GroupIssuedAPI, + Peers: make([]string, 0), + } + require.NoError(t, am.CreateGroup(context.Background(), account.Id, groupAdminUserID, grp)) + + err = am.DeleteGroup(context.Background(), account.Id, groupAdminUserID, "grp-for-flow") + require.Error(t, err) + + var gErr *GroupLinkError + require.ErrorAs(t, err, &gErr) + assert.Equal(t, "settings", gErr.Resource) + assert.Equal(t, "traffic event logging", gErr.Name) + + group, err := am.GetGroup(context.Background(), account.Id, "grp-for-flow", groupAdminUserID) + require.NoError(t, err) + assert.NotNil(t, group) + + regularGrp := &types.Group{ + ID: "grp-regular", + AccountID: account.Id, + Name: "Regular group", + Issued: types.GroupIssuedAPI, + Peers: make([]string, 0), + } + err = am.CreateGroup(context.Background(), account.Id, groupAdminUserID, regularGrp) + require.NoError(t, err) + + err = am.DeleteGroups(context.Background(), account.Id, groupAdminUserID, []string{"grp-for-flow", "grp-regular"}) + require.Error(t, err) + + group, err = am.GetGroup(context.Background(), account.Id, "grp-for-flow", groupAdminUserID) + require.NoError(t, err) + assert.NotNil(t, group) + + _, err = am.GetGroup(context.Background(), account.Id, "grp-regular", groupAdminUserID) + assert.Error(t, err) +} + func initTestGroupAccount(am *DefaultAccountManager) (*DefaultAccountManager, *types.Account, error) { accountID := "testingAcc" domain := "example.com" @@ -379,7 +441,7 @@ func initTestGroupAccount(am *DefaultAccountManager) (*DefaultAccountManager, *t Id: "example user", AutoGroups: []string{groupForUsers.ID}, } - account := newAccountWithId(context.Background(), accountID, groupAdminUserID, domain, false) + account := newAccountWithId(context.Background(), accountID, groupAdminUserID, domain, "", "", false) account.Routes[routeResource.ID] = routeResource account.Routes[routePeerGroupResource.ID] = routePeerGroupResource account.NameServerGroups[nameServerGroup.ID] = nameServerGroup @@ -557,7 +619,7 @@ func TestGroupAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -575,7 +637,7 @@ func TestGroupAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -593,7 +655,7 @@ func TestGroupAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -626,7 +688,7 @@ func TestGroupAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -667,7 +729,7 @@ func TestGroupAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -694,7 +756,7 @@ func TestGroupAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -703,7 +765,7 @@ func TestGroupAccountPeersUpdate(t *testing.T) { t.Run("saving group linked to network router", func(t *testing.T) { permissionsManager := permissions.NewManager(manager.Store) groupsManager := groups.NewManager(manager.Store, permissionsManager, manager) - resourcesManager := resources.NewManager(manager.Store, permissionsManager, groupsManager, manager) + resourcesManager := resources.NewManager(manager.Store, permissionsManager, groupsManager, manager, manager.serviceManager) routersManager := routers.NewManager(manager.Store, permissionsManager, manager) networksManager := networks.NewManager(manager.Store, permissionsManager, resourcesManager, routersManager, manager) @@ -741,7 +803,7 @@ func TestGroupAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -893,6 +955,7 @@ func Test_AddPeerAndAddToAll(t *testing.T) { peer := &peer2.Peer{ ID: strconv.Itoa(i), AccountID: accountID, + Key: "key" + strconv.Itoa(i), DNSLabel: "peer" + strconv.Itoa(i), IP: uint32ToIP(uint32(i)), } @@ -935,10 +998,10 @@ func Test_AddPeerAndAddToAll(t *testing.T) { assert.Equal(t, totalPeers, len(account.Peers), "Expected %d peers in account %s, got %d", totalPeers, accountID, len(account.Peers)) } -func uint32ToIP(n uint32) net.IP { - ip := make(net.IP, 4) - binary.BigEndian.PutUint32(ip, n) - return ip +func uint32ToIP(n uint32) netip.Addr { + var b [4]byte + binary.BigEndian.PutUint32(b[:], n) + return netip.AddrFrom4(b) } func Test_IncrementNetworkSerial(t *testing.T) { diff --git a/management/server/groups/manager.go b/management/server/groups/manager.go index d110ab564..6d19b1c35 100644 --- a/management/server/groups/manager.go +++ b/management/server/groups/manager.go @@ -6,6 +6,7 @@ import ( "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" @@ -30,6 +31,10 @@ type managerImpl struct { accountManager account.Manager } +func eventMetaResource(group *types.Group, resource *resourceTypes.NetworkResource) map[string]any { + return map[string]any{"name": group.Name, "id": group.ID, "resource_name": resource.Name, "resource_id": resource.ID, "resource_type": resource.Type} +} + type mockManager struct { } @@ -42,7 +47,7 @@ func NewManager(store store.Store, permissionsManager permissions.Manager, accou } func (m *managerImpl) GetAllGroups(ctx context.Context, accountID, userID string) ([]*types.Group, error) { - ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Read) + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Read) if err != nil { return nil, err } @@ -73,7 +78,7 @@ func (m *managerImpl) GetAllGroupsMap(ctx context.Context, accountID, userID str } func (m *managerImpl) AddResourceToGroup(ctx context.Context, accountID, userID, groupID string, resource *types.Resource) error { - ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update) + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Groups, operations.Update) if err != nil { return err } @@ -109,7 +114,7 @@ func (m *managerImpl) AddResourceToGroupInTransaction(ctx context.Context, trans } event := func() { - m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, group.EventMetaResource(networkResource)) + m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, eventMetaResource(group, networkResource)) } return event, nil @@ -133,7 +138,7 @@ func (m *managerImpl) RemoveResourceFromGroupInTransaction(ctx context.Context, } event := func() { - m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, group.EventMetaResource(networkResource)) + m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, eventMetaResource(group, networkResource)) } return event, nil diff --git a/management/server/http/handler.go b/management/server/http/handler.go index 794d29ac8..a57f44b3c 100644 --- a/management/server/http/handler.go +++ b/management/server/http/handler.go @@ -4,23 +4,38 @@ import ( "context" "fmt" "net/http" - "os" - "strconv" - "time" + "net/netip" "github.com/gorilla/mux" "github.com/rs/cors" log "github.com/sirupsen/logrus" - "github.com/netbirdio/management-integrations/integrations" - "github.com/netbirdio/netbird/management/internals/controllers/network_map" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager" + "github.com/netbirdio/netbird/management/server/types" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxytoken" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + reverseproxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service/manager" + + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + idpmanager "github.com/netbirdio/netbird/management/server/idp" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map" + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + agentnetworkhandlers "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/handlers" + "github.com/netbirdio/netbird/management/internals/modules/zones" + zonesManager "github.com/netbirdio/netbird/management/internals/modules/zones/manager" + "github.com/netbirdio/netbird/management/internals/modules/zones/records" + recordsManager "github.com/netbirdio/netbird/management/internals/modules/zones/records/manager" "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/settings" - "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/http/handlers/proxy" + "github.com/netbirdio/netbird/management/server/auth" "github.com/netbirdio/netbird/management/server/geolocation" nbgroups "github.com/netbirdio/netbird/management/server/groups" @@ -28,6 +43,8 @@ import ( "github.com/netbirdio/netbird/management/server/http/handlers/dns" "github.com/netbirdio/netbird/management/server/http/handlers/events" "github.com/netbirdio/netbird/management/server/http/handlers/groups" + "github.com/netbirdio/netbird/management/server/http/handlers/idp" + "github.com/netbirdio/netbird/management/server/http/handlers/instance" "github.com/netbirdio/netbird/management/server/http/handlers/networks" "github.com/netbirdio/netbird/management/server/http/handlers/peers" "github.com/netbirdio/netbird/management/server/http/handlers/policies" @@ -35,54 +52,40 @@ import ( "github.com/netbirdio/netbird/management/server/http/handlers/setup_keys" "github.com/netbirdio/netbird/management/server/http/handlers/users" "github.com/netbirdio/netbird/management/server/http/middleware" - "github.com/netbirdio/netbird/management/server/integrations/integrated_validator" + "github.com/netbirdio/netbird/management/server/http/middleware/bypass" + nbinstance "github.com/netbirdio/netbird/management/server/instance" nbnetworks "github.com/netbirdio/netbird/management/server/networks" "github.com/netbirdio/netbird/management/server/networks/resources" "github.com/netbirdio/netbird/management/server/networks/routers" - nbpeers "github.com/netbirdio/netbird/management/server/peers" "github.com/netbirdio/netbird/management/server/telemetry" ) -const ( - apiPrefix = "/api" - rateLimitingEnabledKey = "NB_API_RATE_LIMITING_ENABLED" - rateLimitingBurstKey = "NB_API_RATE_LIMITING_BURST" - rateLimitingRPMKey = "NB_API_RATE_LIMITING_RPM" -) +// NewAPIHandler creates the Management service HTTP API handler registering all the available endpoints. +func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, permissionsManager permissions.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, trustedHTTPProxies []netip.Prefix, rateLimiter *middleware.APIRateLimiter, isValidChildAccount middleware.IsValidChildAccountFunc, agentNetworkManager agentnetwork.Manager) (http.Handler, error) { -func NewRouter( - authManager auth.Manager, - appMetrics telemetry.AppMetrics, - accountManager account.Manager, -) *mux.Router { - var rateLimitingConfig *middleware.RateLimiterConfig - if os.Getenv(rateLimitingEnabledKey) == "true" { - rpm := 6 - if v := os.Getenv(rateLimitingRPMKey); v != "" { - value, err := strconv.Atoi(v) - if err != nil { - log.Warnf("parsing %s env var: %v, using default %d", rateLimitingRPMKey, err, rpm) - } else { - rpm = value - } - } + // Register bypass paths for unauthenticated endpoints + if err := bypass.AddBypassPath("/api/instance"); err != nil { + return nil, fmt.Errorf("failed to add bypass path: %w", err) + } + if err := bypass.AddBypassPath("/api/setup"); err != nil { + return nil, fmt.Errorf("failed to add bypass path: %w", err) + } + // Public invite endpoints (tokens start with nbi_) + if err := bypass.AddBypassPath("/api/users/invites/nbi_*"); err != nil { + return nil, fmt.Errorf("failed to add bypass path: %w", err) + } + if err := bypass.AddBypassPath("/api/users/invites/nbi_*/accept"); err != nil { + return nil, fmt.Errorf("failed to add bypass path: %w", err) + } + // OAuth callback for proxy authentication + if err := bypass.AddBypassPath(types.ProxyCallbackEndpointFull); err != nil { + return nil, fmt.Errorf("failed to add bypass path: %w", err) + } - burst := 500 - if v := os.Getenv(rateLimitingBurstKey); v != "" { - value, err := strconv.Atoi(v) - if err != nil { - log.Warnf("parsing %s env var: %v, using default %d", rateLimitingBurstKey, err, burst) - } else { - burst = value - } - } - - rateLimitingConfig = &middleware.RateLimiterConfig{ - RequestsPerMinute: float64(rpm), - Burst: burst, - CleanupInterval: 6 * time.Hour, - LimiterTTL: 24 * time.Hour, - } + if rateLimiter == nil { + log.Warn("NewAPIHandler: nil rate limiter, rate limiting disabled") + rateLimiter = middleware.NewAPIRateLimiter(nil) + rateLimiter.SetEnabled(false) } authMiddleware := middleware.NewAuthMiddleware( @@ -90,47 +93,27 @@ func NewRouter( accountManager.GetAccountIDFromUserAuth, accountManager.SyncUserJWTGroups, accountManager.GetUserFromUserAuth, - rateLimitingConfig, + rateLimiter, + appMetrics.GetMeter(), + isValidChildAccount, ) corsMiddleware := cors.AllowAll() - rootRouter := mux.NewRouter() metricsMiddleware := appMetrics.HTTPMiddleware() - router := rootRouter.PathPrefix(apiPrefix).Subrouter() router.Use(metricsMiddleware.Handler, corsMiddleware.Handler, authMiddleware.Handler) - return router -} -// NewAPIHandler creates the Management service HTTP API handler registering all the available endpoints. -func NewAPIHandler( - ctx context.Context, - accountManager account.Manager, - networksManager nbnetworks.Manager, - resourceManager resources.Manager, - routerManager routers.Manager, - groupsManager nbgroups.Manager, - LocationManager geolocation.Geolocation, - router *mux.Router, - appMetrics telemetry.AppMetrics, - integratedValidator integrated_validator.IntegratedValidator, - proxyController port_forwarding.Controller, - permissionsManager permissions.Manager, - peersManager nbpeers.Manager, - settingsManager settings.Manager, - networkMapController network_map.Controller, -) (http.Handler, error) { - - prefix := apiPrefix - - if _, err := integrations.RegisterHandlers(ctx, prefix, router, accountManager, integratedValidator, appMetrics.GetMeter(), permissionsManager, peersManager, proxyController, settingsManager); err != nil { - return nil, fmt.Errorf("register integrations endpoints: %w", err) + instanceManager, err := nbinstance.NewManager(ctx, accountManager.GetStore(), idpManager) + if err != nil { + return nil, fmt.Errorf("failed to create instance manager: %w", err) } accounts.AddEndpoints(accountManager, settingsManager, router) - peers.AddEndpoints(accountManager, router, networkMapController) + peers.AddEndpoints(accountManager, router, networkMapController, permissionsManager) users.AddEndpoints(accountManager, router) + users.AddInvitesEndpoints(accountManager, router) + users.AddPublicInvitesEndpoints(accountManager, router) setup_keys.AddEndpoints(accountManager, router) policies.AddEndpoints(accountManager, LocationManager, router) policies.AddPostureCheckEndpoints(accountManager, LocationManager, router) @@ -140,6 +123,25 @@ func NewAPIHandler( dns.AddEndpoints(accountManager, router) events.AddEndpoints(accountManager, router) networks.AddEndpoints(networksManager, resourceManager, routerManager, groupsManager, accountManager, router) + zonesManager.RegisterEndpoints(router, zManager) + recordsManager.RegisterEndpoints(router, rManager) + idp.AddEndpoints(accountManager, router) + if agentNetworkManager != nil { + agentnetworkhandlers.RegisterEndpoints(agentNetworkManager, router) + } + instance.AddEndpoints(instanceManager, accountManager, router) + instance.AddVersionEndpoint(instanceManager, router) + if serviceManager != nil && reverseProxyDomainManager != nil { + reverseproxymanager.RegisterEndpoints(serviceManager, *reverseProxyDomainManager, reverseProxyAccessLogsManager, permissionsManager, router) + } + + proxytoken.RegisterEndpoints(accountManager.GetStore(), permissionsManager, router) + + // Register OAuth callback handler for proxy authentication + if proxyGRPCServer != nil { + oauthHandler := proxy.NewAuthCallbackHandler(proxyGRPCServer, trustedHTTPProxies) + oauthHandler.RegisterEndpoints(router) + } return router, nil } diff --git a/management/server/http/handlers/accounts/accounts_handler.go b/management/server/http/handlers/accounts/accounts_handler.go index f1552d0ea..c4cba5962 100644 --- a/management/server/http/handlers/accounts/accounts_handler.go +++ b/management/server/http/handlers/accounts/accounts_handler.go @@ -3,12 +3,18 @@ package accounts import ( "context" "encoding/json" + "fmt" + "math" "net/http" "net/netip" "time" + log "github.com/sirupsen/logrus" + "github.com/gorilla/mux" + goversion "github.com/hashicorp/go-version" + "github.com/netbirdio/netbird/management/server/account" nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/server/settings" @@ -27,6 +33,10 @@ const ( MinNetworkBitsIPv4 = 28 // MinNetworkBitsIPv6 is the minimum prefix length for IPv6 network ranges MinNetworkBitsIPv6 = 120 + // MaxNetworkSizeIPv6 is the largest allowed IPv6 prefix (smallest number) + MaxNetworkSizeIPv6 = 48 + disableAutoUpdate = "disabled" + autoUpdateLatestVersion = "latest" ) // handler is a handler that handles the server.Account HTTP endpoints @@ -71,12 +81,35 @@ func validateMinimumSize(prefix netip.Prefix) error { if addr.Is4() && prefix.Bits() > MinNetworkBitsIPv4 { return status.Errorf(status.InvalidArgument, "network range too small: minimum size is /%d for IPv4", MinNetworkBitsIPv4) } - if addr.Is6() && prefix.Bits() > MinNetworkBitsIPv6 { - return status.Errorf(status.InvalidArgument, "network range too small: minimum size is /%d for IPv6", MinNetworkBitsIPv6) + if addr.Is6() { + if prefix.Bits() > MinNetworkBitsIPv6 { + return status.Errorf(status.InvalidArgument, "network range too small: minimum size is /%d for IPv6", MinNetworkBitsIPv6) + } + if prefix.Bits() < MaxNetworkSizeIPv6 { + return status.Errorf(status.InvalidArgument, "network range too large: maximum size is /%d for IPv6", MaxNetworkSizeIPv6) + } } return nil } +func (h *handler) parseAndValidateNetworkRange(ctx context.Context, accountID, userID, rangeStr string, requireV6 bool) (netip.Prefix, error) { + prefix, err := netip.ParsePrefix(rangeStr) + if err != nil { + return netip.Prefix{}, status.Errorf(status.InvalidArgument, "invalid CIDR format: %v", err) + } + prefix = prefix.Masked() + if requireV6 && !prefix.Addr().Is6() { + return netip.Prefix{}, status.Errorf(status.InvalidArgument, "network range must be an IPv6 address") + } + if !requireV6 && prefix.Addr().Is6() { + return netip.Prefix{}, status.Errorf(status.InvalidArgument, "network range must be an IPv4 address") + } + if err := h.validateNetworkRange(ctx, accountID, userID, prefix); err != nil { + return netip.Prefix{}, err + } + return prefix, nil +} + func (h *handler) validateNetworkRange(ctx context.Context, accountID, userID string, networkRange netip.Prefix) error { if !networkRange.IsValid() { return nil @@ -112,9 +145,12 @@ func (h *handler) validateCapacity(ctx context.Context, accountID, userID string } func calculateMaxHosts(prefix netip.Prefix) int64 { - availableAddresses := prefix.Addr().BitLen() - prefix.Bits() - maxHosts := int64(1) << availableAddresses + hostBits := prefix.Addr().BitLen() - prefix.Bits() + if hostBits >= 63 { + return math.MaxInt64 + } + maxHosts := int64(1) << hostBits if prefix.Addr().Is4() { maxHosts -= 2 // network and broadcast addresses } @@ -159,9 +195,116 @@ func (h *handler) getAllAccounts(w http.ResponseWriter, r *http.Request) { } resp := toAccountResponse(accountID, settings, meta, onboarding) + + // Populate effective network ranges when settings don't have explicit overrides. + if resp.Settings.NetworkRange == nil || resp.Settings.NetworkRangeV6 == nil { + v4, v6, err := h.settingsManager.GetEffectiveNetworkRanges(r.Context(), accountID) + if err != nil { + log.WithContext(r.Context()).Warnf("get effective network ranges: %v", err) + } else { + if resp.Settings.NetworkRange == nil && v4.IsValid() { + s := v4.String() + resp.Settings.NetworkRange = &s + } + if resp.Settings.NetworkRangeV6 == nil && v6.IsValid() { + s := v6.String() + resp.Settings.NetworkRangeV6 = &s + } + } + } + util.WriteJSONObject(r.Context(), w, []*api.Account{resp}) } +func (h *handler) updateAccountRequestSettings(req api.PutApiAccountsAccountIdJSONRequestBody) (*types.Settings, error) { + if req.Settings.PeerExposeEnabled && len(req.Settings.PeerExposeGroups) == 0 { + return nil, status.Errorf(status.InvalidArgument, "peer expose requires at least one group") + } + + returnSettings := &types.Settings{ + PeerLoginExpirationEnabled: req.Settings.PeerLoginExpirationEnabled, + PeerLoginExpiration: time.Duration(float64(time.Second.Nanoseconds()) * float64(req.Settings.PeerLoginExpiration)), + RegularUsersViewBlocked: req.Settings.RegularUsersViewBlocked, + + PeerInactivityExpirationEnabled: req.Settings.PeerInactivityExpirationEnabled, + PeerInactivityExpiration: time.Duration(float64(time.Second.Nanoseconds()) * float64(req.Settings.PeerInactivityExpiration)), + + PeerExposeEnabled: req.Settings.PeerExposeEnabled, + PeerExposeGroups: req.Settings.PeerExposeGroups, + } + + if req.Settings.Extra != nil { + returnSettings.Extra = &types.ExtraSettings{ + PeerApprovalEnabled: req.Settings.Extra.PeerApprovalEnabled, + UserApprovalRequired: req.Settings.Extra.UserApprovalRequired, + FlowEnabled: req.Settings.Extra.NetworkTrafficLogsEnabled, + FlowGroups: req.Settings.Extra.NetworkTrafficLogsGroups, + FlowPacketCounterEnabled: req.Settings.Extra.NetworkTrafficPacketCounterEnabled, + } + } + + if req.Settings.JwtGroupsEnabled != nil { + returnSettings.JWTGroupsEnabled = *req.Settings.JwtGroupsEnabled + } + if req.Settings.GroupsPropagationEnabled != nil { + returnSettings.GroupsPropagationEnabled = *req.Settings.GroupsPropagationEnabled + } + if req.Settings.JwtGroupsClaimName != nil { + returnSettings.JWTGroupsClaimName = *req.Settings.JwtGroupsClaimName + } + if req.Settings.JwtAllowGroups != nil { + returnSettings.JWTAllowGroups = *req.Settings.JwtAllowGroups + } + if req.Settings.RoutingPeerDnsResolutionEnabled != nil { + returnSettings.RoutingPeerDNSResolutionEnabled = *req.Settings.RoutingPeerDnsResolutionEnabled + } + if req.Settings.DnsDomain != nil { + returnSettings.DNSDomain = *req.Settings.DnsDomain + } + if req.Settings.LazyConnectionEnabled != nil { + returnSettings.LazyConnectionEnabled = *req.Settings.LazyConnectionEnabled + } + if req.Settings.AutoUpdateVersion != nil { + _, err := goversion.NewSemver(*req.Settings.AutoUpdateVersion) + if *req.Settings.AutoUpdateVersion == autoUpdateLatestVersion || + *req.Settings.AutoUpdateVersion == disableAutoUpdate || + err == nil { + returnSettings.AutoUpdateVersion = *req.Settings.AutoUpdateVersion + } else if *req.Settings.AutoUpdateVersion != "" { + return nil, fmt.Errorf("invalid AutoUpdateVersion") + } + } + if req.Settings.AutoUpdateAlways != nil { + returnSettings.AutoUpdateAlways = *req.Settings.AutoUpdateAlways + } + if req.Settings.LocalMfaEnabled != nil { + returnSettings.LocalMfaEnabled = *req.Settings.LocalMfaEnabled + } + if req.Settings.Ipv6EnabledGroups != nil { + returnSettings.IPv6EnabledGroups = *req.Settings.Ipv6EnabledGroups + } + if req.Settings.MetricsPushEnabled != nil { + returnSettings.MetricsPushEnabled = *req.Settings.MetricsPushEnabled + } + if req.Settings.AgentNetworkOnly != nil { + returnSettings.AgentNetworkOnly = *req.Settings.AgentNetworkOnly + } + if req.Settings.DashboardFeatures != nil { + returnSettings.DashboardFeatures = &types.DashboardFeatures{ + AgentNetwork: req.Settings.DashboardFeatures.AgentNetwork, + } + } + + if returnSettings.AgentNetworkOnly && + (returnSettings.DashboardFeatures == nil || + returnSettings.DashboardFeatures.AgentNetwork == nil || + !*returnSettings.DashboardFeatures.AgentNetwork) { + return nil, status.Errorf(status.InvalidArgument, "agent network only mode requires dashboard_features.agent_network to be enabled") + } + + return returnSettings, nil +} + // updateAccount is HTTP PUT handler that updates the provided account. Updates only account settings (server.Settings) func (h *handler) updateAccount(w http.ResponseWriter, r *http.Request) { userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) @@ -186,59 +329,29 @@ func (h *handler) updateAccount(w http.ResponseWriter, r *http.Request) { return } - settings := &types.Settings{ - PeerLoginExpirationEnabled: req.Settings.PeerLoginExpirationEnabled, - PeerLoginExpiration: time.Duration(float64(time.Second.Nanoseconds()) * float64(req.Settings.PeerLoginExpiration)), - RegularUsersViewBlocked: req.Settings.RegularUsersViewBlocked, - - PeerInactivityExpirationEnabled: req.Settings.PeerInactivityExpirationEnabled, - PeerInactivityExpiration: time.Duration(float64(time.Second.Nanoseconds()) * float64(req.Settings.PeerInactivityExpiration)), - } - - if req.Settings.Extra != nil { - settings.Extra = &types.ExtraSettings{ - PeerApprovalEnabled: req.Settings.Extra.PeerApprovalEnabled, - UserApprovalRequired: req.Settings.Extra.UserApprovalRequired, - FlowEnabled: req.Settings.Extra.NetworkTrafficLogsEnabled, - FlowGroups: req.Settings.Extra.NetworkTrafficLogsGroups, - FlowPacketCounterEnabled: req.Settings.Extra.NetworkTrafficPacketCounterEnabled, - } - } - - if req.Settings.JwtGroupsEnabled != nil { - settings.JWTGroupsEnabled = *req.Settings.JwtGroupsEnabled - } - if req.Settings.GroupsPropagationEnabled != nil { - settings.GroupsPropagationEnabled = *req.Settings.GroupsPropagationEnabled - } - if req.Settings.JwtGroupsClaimName != nil { - settings.JWTGroupsClaimName = *req.Settings.JwtGroupsClaimName - } - if req.Settings.JwtAllowGroups != nil { - settings.JWTAllowGroups = *req.Settings.JwtAllowGroups - } - if req.Settings.RoutingPeerDnsResolutionEnabled != nil { - settings.RoutingPeerDNSResolutionEnabled = *req.Settings.RoutingPeerDnsResolutionEnabled - } - if req.Settings.DnsDomain != nil { - settings.DNSDomain = *req.Settings.DnsDomain - } - if req.Settings.LazyConnectionEnabled != nil { - settings.LazyConnectionEnabled = *req.Settings.LazyConnectionEnabled + settings, err := h.updateAccountRequestSettings(req) + if err != nil { + util.WriteError(r.Context(), err, w) + return } if req.Settings.NetworkRange != nil && *req.Settings.NetworkRange != "" { - prefix, err := netip.ParsePrefix(*req.Settings.NetworkRange) + prefix, err := h.parseAndValidateNetworkRange(r.Context(), accountID, userID, *req.Settings.NetworkRange, false) if err != nil { - util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invalid CIDR format: %v", err), w) - return - } - if err := h.validateNetworkRange(r.Context(), accountID, userID, prefix); err != nil { util.WriteError(r.Context(), err, w) return } settings.NetworkRange = prefix } + if req.Settings.NetworkRangeV6 != nil && *req.Settings.NetworkRangeV6 != "" { + prefix, err := h.parseAndValidateNetworkRange(r.Context(), accountID, userID, *req.Settings.NetworkRangeV6, true) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + settings.NetworkRangeV6 = prefix + } + var onboarding *types.AccountOnboarding if req.Onboarding != nil { onboarding = &types.AccountOnboarding{ @@ -311,14 +424,33 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A JwtAllowGroups: &jwtAllowGroups, RegularUsersViewBlocked: settings.RegularUsersViewBlocked, RoutingPeerDnsResolutionEnabled: &settings.RoutingPeerDNSResolutionEnabled, + PeerExposeEnabled: settings.PeerExposeEnabled, + PeerExposeGroups: settings.PeerExposeGroups, LazyConnectionEnabled: &settings.LazyConnectionEnabled, DnsDomain: &settings.DNSDomain, + AutoUpdateVersion: &settings.AutoUpdateVersion, + AutoUpdateAlways: &settings.AutoUpdateAlways, + Ipv6EnabledGroups: &settings.IPv6EnabledGroups, + MetricsPushEnabled: &settings.MetricsPushEnabled, + AgentNetworkOnly: &settings.AgentNetworkOnly, + EmbeddedIdpEnabled: &settings.EmbeddedIdpEnabled, + LocalAuthDisabled: &settings.LocalAuthDisabled, + LocalMfaEnabled: &settings.LocalMfaEnabled, } if settings.NetworkRange.IsValid() { networkRangeStr := settings.NetworkRange.String() apiSettings.NetworkRange = &networkRangeStr } + if settings.NetworkRangeV6.IsValid() { + networkRangeV6Str := settings.NetworkRangeV6.String() + apiSettings.NetworkRangeV6 = &networkRangeV6Str + } + if settings.DashboardFeatures != nil { + apiSettings.DashboardFeatures = &api.AccountDashboardFeatures{ + AgentNetwork: settings.DashboardFeatures.AgentNetwork, + } + } apiOnboarding := api.AccountOnboarding{ OnboardingFlowPending: onboarding.OnboardingFlowPending, diff --git a/management/server/http/handlers/accounts/accounts_handler_test.go b/management/server/http/handlers/accounts/accounts_handler_test.go index 4b9b79fdc..0069efcb7 100644 --- a/management/server/http/handlers/accounts/accounts_handler_test.go +++ b/management/server/http/handlers/accounts/accounts_handler_test.go @@ -5,8 +5,10 @@ import ( "context" "encoding/json" "io" + "math" "net/http" "net/http/httptest" + "net/netip" "testing" "time" @@ -18,6 +20,7 @@ import ( "github.com/netbirdio/netbird/management/server/mock_server" "github.com/netbirdio/netbird/management/server/settings" "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/auth" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/status" ) @@ -30,6 +33,10 @@ func initAccountsTestData(t *testing.T, account *types.Account) *handler { GetSettings(gomock.Any(), account.Id, "test_user"). Return(account.Settings, nil). AnyTimes() + settingsMockManager.EXPECT(). + GetEffectiveNetworkRanges(gomock.Any(), account.Id). + Return(netip.Prefix{}, netip.Prefix{}, nil). + AnyTimes() return &handler{ accountManager: &mock_server.MockAccountManager{ @@ -120,6 +127,13 @@ func TestAccounts_AccountsHandler(t *testing.T) { RoutingPeerDnsResolutionEnabled: br(false), LazyConnectionEnabled: br(false), DnsDomain: sr(""), + AutoUpdateAlways: br(false), + AutoUpdateVersion: sr(""), + MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), + EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), }, expectedArray: true, expectedID: accountID, @@ -142,6 +156,42 @@ func TestAccounts_AccountsHandler(t *testing.T) { RoutingPeerDnsResolutionEnabled: br(false), LazyConnectionEnabled: br(false), DnsDomain: sr(""), + AutoUpdateAlways: br(false), + AutoUpdateVersion: sr(""), + MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), + EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), + }, + expectedArray: false, + expectedID: accountID, + }, + { + name: "PutAccount OK with autoUpdateVersion", + expectedBody: true, + requestType: http.MethodPut, + requestPath: "/api/accounts/" + accountID, + requestBody: bytes.NewBufferString("{\"settings\": {\"auto_update_version\": \"latest\", \"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"), + expectedStatus: http.StatusOK, + expectedSettings: api.AccountSettings{ + PeerLoginExpiration: 15552000, + PeerLoginExpirationEnabled: true, + GroupsPropagationEnabled: br(false), + JwtGroupsClaimName: sr(""), + JwtGroupsEnabled: br(false), + JwtAllowGroups: &[]string{}, + RegularUsersViewBlocked: false, + RoutingPeerDnsResolutionEnabled: br(false), + LazyConnectionEnabled: br(false), + DnsDomain: sr(""), + AutoUpdateAlways: br(false), + AutoUpdateVersion: sr("latest"), + MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), + EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), }, expectedArray: false, expectedID: accountID, @@ -164,6 +214,13 @@ func TestAccounts_AccountsHandler(t *testing.T) { RoutingPeerDnsResolutionEnabled: br(false), LazyConnectionEnabled: br(false), DnsDomain: sr(""), + AutoUpdateAlways: br(false), + AutoUpdateVersion: sr(""), + MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), + EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), }, expectedArray: false, expectedID: accountID, @@ -186,6 +243,13 @@ func TestAccounts_AccountsHandler(t *testing.T) { RoutingPeerDnsResolutionEnabled: br(false), LazyConnectionEnabled: br(false), DnsDomain: sr(""), + AutoUpdateAlways: br(false), + AutoUpdateVersion: sr(""), + MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), + EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), }, expectedArray: false, expectedID: accountID, @@ -208,6 +272,115 @@ func TestAccounts_AccountsHandler(t *testing.T) { RoutingPeerDnsResolutionEnabled: br(false), LazyConnectionEnabled: br(false), DnsDomain: sr(""), + AutoUpdateAlways: br(false), + AutoUpdateVersion: sr(""), + MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), + EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), + }, + expectedArray: false, + expectedID: accountID, + }, + { + name: "PutAccount OK enabling agent_network_only", + expectedBody: true, + requestType: http.MethodPut, + requestPath: "/api/accounts/" + accountID, + requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"agent_network_only\": true,\"dashboard_features\": {\"agent_network\": true}},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"), + expectedStatus: http.StatusOK, + expectedSettings: api.AccountSettings{ + PeerLoginExpiration: 15552000, + PeerLoginExpirationEnabled: true, + GroupsPropagationEnabled: br(false), + JwtGroupsClaimName: sr(""), + JwtGroupsEnabled: br(false), + JwtAllowGroups: &[]string{}, + RegularUsersViewBlocked: false, + RoutingPeerDnsResolutionEnabled: br(false), + LazyConnectionEnabled: br(false), + DnsDomain: sr(""), + AutoUpdateAlways: br(false), + AutoUpdateVersion: sr(""), + MetricsPushEnabled: br(false), + AgentNetworkOnly: br(true), + DashboardFeatures: &api.AccountDashboardFeatures{ + AgentNetwork: br(true), + }, + EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), + }, + expectedArray: false, + expectedID: accountID, + }, + { + name: "PutAccount fails enabling agent_network_only without dashboard_features", + expectedBody: true, + requestType: http.MethodPut, + requestPath: "/api/accounts/" + accountID, + requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"agent_network_only\": true},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"), + expectedStatus: http.StatusUnprocessableEntity, + expectedArray: false, + }, + { + name: "PutAccount OK setting dashboard_features agent_network", + expectedBody: true, + requestType: http.MethodPut, + requestPath: "/api/accounts/" + accountID, + requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"dashboard_features\": {\"agent_network\": true}},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"), + expectedStatus: http.StatusOK, + expectedSettings: api.AccountSettings{ + PeerLoginExpiration: 15552000, + PeerLoginExpirationEnabled: true, + GroupsPropagationEnabled: br(false), + JwtGroupsClaimName: sr(""), + JwtGroupsEnabled: br(false), + JwtAllowGroups: &[]string{}, + RegularUsersViewBlocked: false, + RoutingPeerDnsResolutionEnabled: br(false), + LazyConnectionEnabled: br(false), + DnsDomain: sr(""), + AutoUpdateAlways: br(false), + AutoUpdateVersion: sr(""), + MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), + DashboardFeatures: &api.AccountDashboardFeatures{ + AgentNetwork: br(true), + }, + EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), + }, + expectedArray: false, + expectedID: accountID, + }, + { + name: "PutAccount OK disabling agent_network_only again", + expectedBody: true, + requestType: http.MethodPut, + requestPath: "/api/accounts/" + accountID, + requestBody: bytes.NewBufferString("{\"settings\": {\"peer_login_expiration\": 15552000,\"peer_login_expiration_enabled\": true,\"agent_network_only\": false},\"onboarding\": {\"onboarding_flow_pending\": true,\"signup_form_pending\": true}}"), + expectedStatus: http.StatusOK, + expectedSettings: api.AccountSettings{ + PeerLoginExpiration: 15552000, + PeerLoginExpirationEnabled: true, + GroupsPropagationEnabled: br(false), + JwtGroupsClaimName: sr(""), + JwtGroupsEnabled: br(false), + JwtAllowGroups: &[]string{}, + RegularUsersViewBlocked: false, + RoutingPeerDnsResolutionEnabled: br(false), + LazyConnectionEnabled: br(false), + DnsDomain: sr(""), + AutoUpdateAlways: br(false), + AutoUpdateVersion: sr(""), + MetricsPushEnabled: br(false), + AgentNetworkOnly: br(false), + EmbeddedIdpEnabled: br(false), + LocalAuthDisabled: br(false), + LocalMfaEnabled: br(false), }, expectedArray: false, expectedID: accountID, @@ -236,7 +409,7 @@ func TestAccounts_AccountsHandler(t *testing.T) { t.Run(tc.name, func(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody) - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: adminUser.Id, AccountId: accountID, Domain: "hotmail.com", @@ -289,3 +462,27 @@ func TestAccounts_AccountsHandler(t *testing.T) { }) } } + +func TestCalculateMaxHosts(t *testing.T) { + tests := []struct { + name string + prefix string + min int64 + }{ + {"v4 /24", "100.64.0.0/24", 254}, + {"v4 /16", "100.64.0.0/16", 65534}, + {"v4 /28", "100.64.0.0/28", 14}, + {"v6 /64", "fd00::/64", math.MaxInt64}, + {"v6 /120", "fd00::/120", 256}, + {"v6 /112", "fd00::/112", 65536}, + {"v6 /48", "fd00::/48", math.MaxInt64}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prefix := netip.MustParsePrefix(tt.prefix) + got := calculateMaxHosts(prefix) + assert.Equal(t, tt.min, got) + }) + } +} diff --git a/management/server/http/handlers/dns/dns_settings_handler.go b/management/server/http/handlers/dns/dns_settings_handler.go index 08a0b2afd..67638aea5 100644 --- a/management/server/http/handlers/dns/dns_settings_handler.go +++ b/management/server/http/handlers/dns/dns_settings_handler.go @@ -9,9 +9,9 @@ import ( "github.com/netbirdio/netbird/management/server/account" nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/http/util" - "github.com/netbirdio/netbird/management/server/types" ) // dnsSettingsHandler is a handler that returns the DNS settings of the account diff --git a/management/server/http/handlers/dns/dns_settings_handler_test.go b/management/server/http/handlers/dns/dns_settings_handler_test.go index 42b519c29..a027c067e 100644 --- a/management/server/http/handlers/dns/dns_settings_handler_test.go +++ b/management/server/http/handlers/dns/dns_settings_handler_test.go @@ -11,13 +11,14 @@ import ( "github.com/stretchr/testify/assert" + "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/status" - "github.com/netbirdio/netbird/management/server/types" "github.com/gorilla/mux" nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/auth" "github.com/netbirdio/netbird/management/server/mock_server" ) @@ -107,7 +108,7 @@ func TestDNSSettingsHandlers(t *testing.T) { t.Run(tc.name, func(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody) - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: testingDNSSettingsAccount.Users[testDNSSettingsUserID].Id, AccountId: testingDNSSettingsAccount.Id, Domain: testingDNSSettingsAccount.Domain, diff --git a/management/server/http/handlers/dns/nameservers_handler.go b/management/server/http/handlers/dns/nameservers_handler.go index bce1c4b78..dbbdf3ed9 100644 --- a/management/server/http/handlers/dns/nameservers_handler.go +++ b/management/server/http/handlers/dns/nameservers_handler.go @@ -3,7 +3,10 @@ package dns import ( "encoding/json" "fmt" + "net" "net/http" + "strconv" + "strings" "github.com/gorilla/mux" log "github.com/sirupsen/logrus" @@ -201,7 +204,11 @@ func (h *nameserversHandler) getNameserverGroup(w http.ResponseWriter, r *http.R func toServerNSList(apiNSList []api.Nameserver) ([]nbdns.NameServer, error) { var nsList []nbdns.NameServer for _, apiNS := range apiNSList { - parsed, err := nbdns.ParseNameServerURL(fmt.Sprintf("%s://%s:%d", apiNS.NsType, apiNS.Ip, apiNS.Port)) + host, err := unwrapBracketedHost(apiNS.Ip) + if err != nil { + return nil, err + } + parsed, err := nbdns.ParseNameServerURL(fmt.Sprintf("%s://%s", apiNS.NsType, net.JoinHostPort(host, strconv.Itoa(apiNS.Port)))) if err != nil { return nil, err } @@ -211,6 +218,18 @@ func toServerNSList(apiNSList []api.Nameserver) ([]nbdns.NameServer, error) { return nsList, nil } +// unwrapBracketedHost returns ip with surrounding brackets stripped, rejecting +// inputs with mismatched brackets. +func unwrapBracketedHost(ip string) (string, error) { + if !strings.ContainsAny(ip, "[]") { + return ip, nil + } + if !strings.HasPrefix(ip, "[") || !strings.HasSuffix(ip, "]") { + return "", fmt.Errorf("malformed bracketed address: %s", ip) + } + return ip[1 : len(ip)-1], nil +} + func toNameserverGroupResponse(serverNSGroup *nbdns.NameServerGroup) *api.NameserverGroup { var nsList []api.Nameserver for _, ns := range serverNSGroup.NameServers { diff --git a/management/server/http/handlers/dns/nameservers_handler_test.go b/management/server/http/handlers/dns/nameservers_handler_test.go index d49b6c7e0..a165f009b 100644 --- a/management/server/http/handlers/dns/nameservers_handler_test.go +++ b/management/server/http/handlers/dns/nameservers_handler_test.go @@ -19,6 +19,7 @@ import ( "github.com/gorilla/mux" nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/auth" "github.com/netbirdio/netbird/management/server/mock_server" ) @@ -193,7 +194,7 @@ func TestNameserversHandlers(t *testing.T) { t.Run(tc.name, func(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody) - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: "test_user", AccountId: testNSGroupAccountID, Domain: "hotmail.com", @@ -232,3 +233,37 @@ func TestNameserversHandlers(t *testing.T) { }) } } + +func TestToServerNSList_IPv6(t *testing.T) { + tests := []struct { + name string + input []api.Nameserver + expectIP netip.Addr + }{ + { + name: "IPv4", + input: []api.Nameserver{ + {Ip: "1.1.1.1", NsType: "udp", Port: 53}, + }, + expectIP: netip.MustParseAddr("1.1.1.1"), + }, + { + name: "IPv6", + input: []api.Nameserver{ + {Ip: "2001:4860:4860::8888", NsType: "udp", Port: 53}, + }, + expectIP: netip.MustParseAddr("2001:4860:4860::8888"), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := toServerNSList(tc.input) + assert.NoError(t, err) + if assert.Len(t, result, 1) { + assert.Equal(t, tc.expectIP, result[0].IP) + assert.Equal(t, 53, result[0].Port) + } + }) + } +} diff --git a/management/server/http/handlers/events/events_handler_test.go b/management/server/http/handlers/events/events_handler_test.go index a0695fa3f..923a24e31 100644 --- a/management/server/http/handlers/events/events_handler_test.go +++ b/management/server/http/handlers/events/events_handler_test.go @@ -14,11 +14,12 @@ import ( "github.com/stretchr/testify/assert" nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/auth" "github.com/netbirdio/netbird/management/server/activity" - "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/management/server/mock_server" "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/http/api" ) func initEventsTestData(account string, events ...*activity.Event) *handler { @@ -188,7 +189,7 @@ func TestEvents_GetEvents(t *testing.T) { t.Run(tc.name, func(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody) - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: "test_user", Domain: "hotmail.com", AccountId: "test_account", diff --git a/management/server/http/handlers/groups/groups_handler.go b/management/server/http/handlers/groups/groups_handler.go index e861e873c..f8d161a87 100644 --- a/management/server/http/handlers/groups/groups_handler.go +++ b/management/server/http/handlers/groups/groups_handler.go @@ -11,10 +11,10 @@ import ( nbcontext "github.com/netbirdio/netbird/management/server/context" nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/http/util" "github.com/netbirdio/netbird/shared/management/status" - "github.com/netbirdio/netbird/management/server/types" ) // handler is a handler that returns groups of the account @@ -48,6 +48,29 @@ func (h *handler) getAllGroups(w http.ResponseWriter, r *http.Request) { } accountID, userID := userAuth.AccountId, userAuth.UserId + // Check if filtering by name + groupName := r.URL.Query().Get("name") + if groupName != "" { + // Get single group by name + group, err := h.accountManager.GetGroupByName(r.Context(), groupName, accountID, userID) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + accountPeers, err := h.accountManager.GetPeers(r.Context(), accountID, userID, "", "") + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + // Return as array with single element to maintain API consistency + groupsResponse := []*api.Group{toGroupResponse(accountPeers, group)} + util.WriteJSONObject(r.Context(), w, groupsResponse) + return + } + + // Get all groups groups, err := h.accountManager.GetAllGroups(r.Context(), accountID, userID) if err != nil { util.WriteError(r.Context(), err, w) @@ -95,7 +118,7 @@ func (h *handler) updateGroup(w http.ResponseWriter, r *http.Request) { return } - allGroup, err := h.accountManager.GetGroupByName(r.Context(), "All", accountID) + allGroup, err := h.accountManager.GetGroupByName(r.Context(), "All", accountID, userID) if err != nil { util.WriteError(r.Context(), err, w) return diff --git a/management/server/http/handlers/groups/groups_handler_test.go b/management/server/http/handlers/groups/groups_handler_test.go index 34694ec8c..800cce1e5 100644 --- a/management/server/http/handlers/groups/groups_handler_test.go +++ b/management/server/http/handlers/groups/groups_handler_test.go @@ -7,9 +7,9 @@ import ( "errors" "fmt" "io" - "net" "net/http" "net/http/httptest" + "net/netip" "strings" "testing" @@ -19,17 +19,18 @@ import ( "github.com/netbirdio/netbird/management/server" 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/management/server/mock_server" nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/shared/management/status" "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/shared/management/status" ) var TestPeers = map[string]*nbpeer.Peer{ - "A": {Key: "A", ID: "peer-A-ID", IP: net.ParseIP("100.100.100.100")}, - "B": {Key: "B", ID: "peer-B-ID", IP: net.ParseIP("200.200.200.200")}, + "A": {Key: "A", ID: "peer-A-ID", IP: netip.MustParseAddr("100.100.100.100")}, + "B": {Key: "B", ID: "peer-B-ID", IP: netip.MustParseAddr("200.200.200.200")}, } func initGroupTestData(initGroups ...*types.Group) *handler { @@ -59,12 +60,23 @@ func initGroupTestData(initGroups ...*types.Group) *handler { return group, nil }, - GetGroupByNameFunc: func(ctx context.Context, groupName, _ string) (*types.Group, error) { + GetAllGroupsFunc: func(ctx context.Context, accountID, userID string) ([]*types.Group, error) { + groups := []*types.Group{ + {ID: "id-jwt-group", Name: "From JWT", Issued: types.GroupIssuedJWT}, + {ID: "id-existed", Name: "Existed", Peers: []string{"A", "B"}, Issued: types.GroupIssuedAPI}, + {ID: "id-all", Name: "All", Issued: types.GroupIssuedAPI}, + } + + groups = append(groups, initGroups...) + + return groups, nil + }, + GetGroupByNameFunc: func(ctx context.Context, groupName, _, _ string) (*types.Group, error) { if groupName == "All" { return &types.Group{ID: "id-all", Name: "All", Issued: types.GroupIssuedAPI}, nil } - return nil, fmt.Errorf("unknown group name") + return nil, status.Errorf(status.NotFound, "unknown group name") }, GetPeersFunc: func(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) { return maps.Values(TestPeers), nil @@ -122,7 +134,7 @@ func TestGetGroup(t *testing.T) { t.Run(tc.name, func(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody) - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: "test_user", Domain: "hotmail.com", AccountId: "test_id", @@ -248,7 +260,7 @@ func TestWriteGroup(t *testing.T) { t.Run(tc.name, func(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody) - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: "test_user", Domain: "hotmail.com", AccountId: "test_id", @@ -286,6 +298,84 @@ func TestWriteGroup(t *testing.T) { } } +func TestGetAllGroups(t *testing.T) { + tt := []struct { + name string + expectedStatus int + expectedBody bool + requestType string + requestPath string + expectedCount int + }{ + { + name: "Get All Groups", + expectedBody: true, + requestType: http.MethodGet, + requestPath: "/api/groups", + expectedStatus: http.StatusOK, + expectedCount: 3, // id-jwt-group, id-existed, id-all + }, + { + name: "Get Group By Name - Existing", + expectedBody: true, + requestType: http.MethodGet, + requestPath: "/api/groups?name=All", + expectedStatus: http.StatusOK, + expectedCount: 1, + }, + { + name: "Get Group By Name - Not Found", + expectedBody: false, + requestType: http.MethodGet, + requestPath: "/api/groups?name=NonExistent", + expectedStatus: http.StatusNotFound, + }, + } + + p := initGroupTestData() + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + req := httptest.NewRequest(tc.requestType, tc.requestPath, nil) + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ + UserId: "test_user", + Domain: "hotmail.com", + AccountId: "test_id", + }) + + router := mux.NewRouter() + router.HandleFunc("/api/groups", p.getAllGroups).Methods("GET") + router.ServeHTTP(recorder, req) + + res := recorder.Result() + defer res.Body.Close() + + if status := recorder.Code; status != tc.expectedStatus { + t.Errorf("handler returned wrong status code: got %v want %v", + status, tc.expectedStatus) + return + } + + if !tc.expectedBody { + return + } + + content, err := io.ReadAll(res.Body) + if err != nil { + t.Fatalf("Failed to read response body: %v", err) + } + + var groups []api.Group + if err = json.Unmarshal(content, &groups); err != nil { + t.Fatalf("Response is not in correct json format; %v", err) + } + + assert.Equal(t, tc.expectedCount, len(groups)) + }) + } +} + func TestDeleteGroup(t *testing.T) { tt := []struct { name string @@ -330,7 +420,7 @@ func TestDeleteGroup(t *testing.T) { t.Run(tc.name, func(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest(tc.requestType, tc.requestPath, nil) - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: "test_user", Domain: "hotmail.com", AccountId: "test_id", diff --git a/management/server/http/handlers/idp/idp_handler.go b/management/server/http/handlers/idp/idp_handler.go new file mode 100644 index 000000000..077507b89 --- /dev/null +++ b/management/server/http/handlers/idp/idp_handler.go @@ -0,0 +1,196 @@ +package idp + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/server/account" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/shared/management/status" +) + +// handler handles identity provider HTTP endpoints +type handler struct { + accountManager account.Manager +} + +// AddEndpoints registers identity provider endpoints +func AddEndpoints(accountManager account.Manager, router *mux.Router) { + h := newHandler(accountManager) + router.HandleFunc("/identity-providers", h.getAllIdentityProviders).Methods("GET", "OPTIONS") + router.HandleFunc("/identity-providers", h.createIdentityProvider).Methods("POST", "OPTIONS") + router.HandleFunc("/identity-providers/{idpId}", h.getIdentityProvider).Methods("GET", "OPTIONS") + router.HandleFunc("/identity-providers/{idpId}", h.updateIdentityProvider).Methods("PUT", "OPTIONS") + router.HandleFunc("/identity-providers/{idpId}", h.deleteIdentityProvider).Methods("DELETE", "OPTIONS") +} + +func newHandler(accountManager account.Manager) *handler { + return &handler{ + accountManager: accountManager, + } +} + +// getAllIdentityProviders returns all identity providers for the account +func (h *handler) getAllIdentityProviders(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + accountID, userID := userAuth.AccountId, userAuth.UserId + + providers, err := h.accountManager.GetIdentityProviders(r.Context(), accountID, userID) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + response := make([]api.IdentityProvider, 0, len(providers)) + for _, p := range providers { + response = append(response, toAPIResponse(p)) + } + + util.WriteJSONObject(r.Context(), w, response) +} + +// getIdentityProvider returns a specific identity provider +func (h *handler) getIdentityProvider(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + accountID, userID := userAuth.AccountId, userAuth.UserId + + vars := mux.Vars(r) + idpID := vars["idpId"] + if idpID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "identity provider ID is required"), w) + return + } + + provider, err := h.accountManager.GetIdentityProvider(r.Context(), accountID, idpID, userID) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, toAPIResponse(provider)) +} + +// createIdentityProvider creates a new identity provider +func (h *handler) createIdentityProvider(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + accountID, userID := userAuth.AccountId, userAuth.UserId + + var req api.IdentityProviderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + idp := fromAPIRequest(&req) + + created, err := h.accountManager.CreateIdentityProvider(r.Context(), accountID, userID, idp) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, toAPIResponse(created)) +} + +// updateIdentityProvider updates an existing identity provider +func (h *handler) updateIdentityProvider(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + accountID, userID := userAuth.AccountId, userAuth.UserId + + vars := mux.Vars(r) + idpID := vars["idpId"] + if idpID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "identity provider ID is required"), w) + return + } + + var req api.IdentityProviderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + idp := fromAPIRequest(&req) + + updated, err := h.accountManager.UpdateIdentityProvider(r.Context(), accountID, idpID, userID, idp) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, toAPIResponse(updated)) +} + +// deleteIdentityProvider deletes an identity provider +func (h *handler) deleteIdentityProvider(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + accountID, userID := userAuth.AccountId, userAuth.UserId + + vars := mux.Vars(r) + idpID := vars["idpId"] + if idpID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "identity provider ID is required"), w) + return + } + + if err := h.accountManager.DeleteIdentityProvider(r.Context(), accountID, idpID, userID); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} + +func toAPIResponse(idp *types.IdentityProvider) api.IdentityProvider { + resp := api.IdentityProvider{ + Type: api.IdentityProviderType(idp.Type), + Name: idp.Name, + Issuer: idp.Issuer, + ClientId: idp.ClientID, + } + if idp.ID != "" { + resp.Id = &idp.ID + } + // Note: ClientSecret is never returned in responses for security + return resp +} + +func fromAPIRequest(req *api.IdentityProviderRequest) *types.IdentityProvider { + return &types.IdentityProvider{ + Type: types.IdentityProviderType(req.Type), + Name: req.Name, + Issuer: req.Issuer, + ClientID: req.ClientId, + ClientSecret: req.ClientSecret, + } +} diff --git a/management/server/http/handlers/idp/idp_handler_test.go b/management/server/http/handlers/idp/idp_handler_test.go new file mode 100644 index 000000000..74b204048 --- /dev/null +++ b/management/server/http/handlers/idp/idp_handler_test.go @@ -0,0 +1,438 @@ +package idp + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/management/server/mock_server" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/status" +) + +const ( + testAccountID = "test-account-id" + testUserID = "test-user-id" + existingIDPID = "existing-idp-id" + newIDPID = "new-idp-id" +) + +func initIDPTestData(existingIDP *types.IdentityProvider) *handler { + return &handler{ + accountManager: &mock_server.MockAccountManager{ + GetIdentityProvidersFunc: func(_ context.Context, accountID, userID string) ([]*types.IdentityProvider, error) { + if accountID != testAccountID { + return nil, status.Errorf(status.NotFound, "account not found") + } + if existingIDP != nil { + return []*types.IdentityProvider{existingIDP}, nil + } + return []*types.IdentityProvider{}, nil + }, + GetIdentityProviderFunc: func(_ context.Context, accountID, idpID, userID string) (*types.IdentityProvider, error) { + if accountID != testAccountID { + return nil, status.Errorf(status.NotFound, "account not found") + } + if existingIDP != nil && idpID == existingIDP.ID { + return existingIDP, nil + } + return nil, status.Errorf(status.NotFound, "identity provider not found") + }, + CreateIdentityProviderFunc: func(_ context.Context, accountID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error) { + if accountID != testAccountID { + return nil, status.Errorf(status.NotFound, "account not found") + } + if idp.Name == "" { + return nil, status.Errorf(status.InvalidArgument, "name is required") + } + created := idp.Copy() + created.ID = newIDPID + created.AccountID = accountID + return created, nil + }, + UpdateIdentityProviderFunc: func(_ context.Context, accountID, idpID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error) { + if accountID != testAccountID { + return nil, status.Errorf(status.NotFound, "account not found") + } + if existingIDP == nil || idpID != existingIDP.ID { + return nil, status.Errorf(status.NotFound, "identity provider not found") + } + updated := idp.Copy() + updated.ID = idpID + updated.AccountID = accountID + return updated, nil + }, + DeleteIdentityProviderFunc: func(_ context.Context, accountID, idpID, userID string) error { + if accountID != testAccountID { + return status.Errorf(status.NotFound, "account not found") + } + if existingIDP == nil || idpID != existingIDP.ID { + return status.Errorf(status.NotFound, "identity provider not found") + } + return nil + }, + }, + } +} + +func TestGetAllIdentityProviders(t *testing.T) { + existingIDP := &types.IdentityProvider{ + ID: existingIDPID, + Name: "Test IDP", + Type: types.IdentityProviderTypeOIDC, + Issuer: "https://issuer.example.com", + ClientID: "client-id", + } + + tt := []struct { + name string + expectedStatus int + expectedCount int + }{ + { + name: "Get All Identity Providers", + expectedStatus: http.StatusOK, + expectedCount: 1, + }, + } + + h := initIDPTestData(existingIDP) + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/identity-providers", nil) + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ + UserId: testUserID, + AccountId: testAccountID, + }) + + router := mux.NewRouter() + router.HandleFunc("/api/identity-providers", h.getAllIdentityProviders).Methods("GET") + router.ServeHTTP(recorder, req) + + res := recorder.Result() + defer res.Body.Close() + + assert.Equal(t, tc.expectedStatus, recorder.Code) + + content, err := io.ReadAll(res.Body) + require.NoError(t, err) + + var idps []api.IdentityProvider + err = json.Unmarshal(content, &idps) + require.NoError(t, err) + assert.Len(t, idps, tc.expectedCount) + }) + } +} + +func TestGetIdentityProvider(t *testing.T) { + existingIDP := &types.IdentityProvider{ + ID: existingIDPID, + Name: "Test IDP", + Type: types.IdentityProviderTypeOIDC, + Issuer: "https://issuer.example.com", + ClientID: "client-id", + } + + tt := []struct { + name string + idpID string + expectedStatus int + expectedBody bool + }{ + { + name: "Get Existing Identity Provider", + idpID: existingIDPID, + expectedStatus: http.StatusOK, + expectedBody: true, + }, + { + name: "Get Non-Existing Identity Provider", + idpID: "non-existing-id", + expectedStatus: http.StatusNotFound, + expectedBody: false, + }, + } + + h := initIDPTestData(existingIDP) + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/identity-providers/%s", tc.idpID), nil) + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ + UserId: testUserID, + AccountId: testAccountID, + }) + + router := mux.NewRouter() + router.HandleFunc("/api/identity-providers/{idpId}", h.getIdentityProvider).Methods("GET") + router.ServeHTTP(recorder, req) + + res := recorder.Result() + defer res.Body.Close() + + assert.Equal(t, tc.expectedStatus, recorder.Code) + + if tc.expectedBody { + content, err := io.ReadAll(res.Body) + require.NoError(t, err) + + var idp api.IdentityProvider + err = json.Unmarshal(content, &idp) + require.NoError(t, err) + assert.Equal(t, existingIDPID, *idp.Id) + assert.Equal(t, existingIDP.Name, idp.Name) + } + }) + } +} + +func TestCreateIdentityProvider(t *testing.T) { + tt := []struct { + name string + requestBody string + expectedStatus int + expectedBody bool + }{ + { + name: "Create Identity Provider", + requestBody: `{ + "name": "New IDP", + "type": "oidc", + "issuer": "https://new-issuer.example.com", + "client_id": "new-client-id", + "client_secret": "new-client-secret" + }`, + expectedStatus: http.StatusOK, + expectedBody: true, + }, + { + name: "Create Identity Provider with Invalid JSON", + requestBody: `{invalid json`, + expectedStatus: http.StatusBadRequest, + expectedBody: false, + }, + } + + h := initIDPTestData(nil) + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/identity-providers", bytes.NewBufferString(tc.requestBody)) + req.Header.Set("Content-Type", "application/json") + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ + UserId: testUserID, + AccountId: testAccountID, + }) + + router := mux.NewRouter() + router.HandleFunc("/api/identity-providers", h.createIdentityProvider).Methods("POST") + router.ServeHTTP(recorder, req) + + res := recorder.Result() + defer res.Body.Close() + + assert.Equal(t, tc.expectedStatus, recorder.Code) + + if tc.expectedBody { + content, err := io.ReadAll(res.Body) + require.NoError(t, err) + + var idp api.IdentityProvider + err = json.Unmarshal(content, &idp) + require.NoError(t, err) + assert.Equal(t, newIDPID, *idp.Id) + assert.Equal(t, "New IDP", idp.Name) + assert.Equal(t, api.IdentityProviderTypeOidc, idp.Type) + } + }) + } +} + +func TestUpdateIdentityProvider(t *testing.T) { + existingIDP := &types.IdentityProvider{ + ID: existingIDPID, + Name: "Test IDP", + Type: types.IdentityProviderTypeOIDC, + Issuer: "https://issuer.example.com", + ClientID: "client-id", + ClientSecret: "client-secret", + } + + tt := []struct { + name string + idpID string + requestBody string + expectedStatus int + expectedBody bool + }{ + { + name: "Update Existing Identity Provider", + idpID: existingIDPID, + requestBody: `{ + "name": "Updated IDP", + "type": "oidc", + "issuer": "https://updated-issuer.example.com", + "client_id": "updated-client-id" + }`, + expectedStatus: http.StatusOK, + expectedBody: true, + }, + { + name: "Update Non-Existing Identity Provider", + idpID: "non-existing-id", + requestBody: `{ + "name": "Updated IDP", + "type": "oidc", + "issuer": "https://updated-issuer.example.com", + "client_id": "updated-client-id" + }`, + expectedStatus: http.StatusNotFound, + expectedBody: false, + }, + { + name: "Update Identity Provider with Invalid JSON", + idpID: existingIDPID, + requestBody: `{invalid json`, + expectedStatus: http.StatusBadRequest, + expectedBody: false, + }, + } + + h := initIDPTestData(existingIDP) + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/identity-providers/%s", tc.idpID), bytes.NewBufferString(tc.requestBody)) + req.Header.Set("Content-Type", "application/json") + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ + UserId: testUserID, + AccountId: testAccountID, + }) + + router := mux.NewRouter() + router.HandleFunc("/api/identity-providers/{idpId}", h.updateIdentityProvider).Methods("PUT") + router.ServeHTTP(recorder, req) + + res := recorder.Result() + defer res.Body.Close() + + assert.Equal(t, tc.expectedStatus, recorder.Code) + + if tc.expectedBody { + content, err := io.ReadAll(res.Body) + require.NoError(t, err) + + var idp api.IdentityProvider + err = json.Unmarshal(content, &idp) + require.NoError(t, err) + assert.Equal(t, existingIDPID, *idp.Id) + assert.Equal(t, "Updated IDP", idp.Name) + } + }) + } +} + +func TestDeleteIdentityProvider(t *testing.T) { + existingIDP := &types.IdentityProvider{ + ID: existingIDPID, + Name: "Test IDP", + Type: types.IdentityProviderTypeOIDC, + Issuer: "https://issuer.example.com", + ClientID: "client-id", + } + + tt := []struct { + name string + idpID string + expectedStatus int + }{ + { + name: "Delete Existing Identity Provider", + idpID: existingIDPID, + expectedStatus: http.StatusOK, + }, + { + name: "Delete Non-Existing Identity Provider", + idpID: "non-existing-id", + expectedStatus: http.StatusNotFound, + }, + } + + h := initIDPTestData(existingIDP) + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/identity-providers/%s", tc.idpID), nil) + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ + UserId: testUserID, + AccountId: testAccountID, + }) + + router := mux.NewRouter() + router.HandleFunc("/api/identity-providers/{idpId}", h.deleteIdentityProvider).Methods("DELETE") + router.ServeHTTP(recorder, req) + + res := recorder.Result() + defer res.Body.Close() + + assert.Equal(t, tc.expectedStatus, recorder.Code) + }) + } +} + +func TestToAPIResponse(t *testing.T) { + idp := &types.IdentityProvider{ + ID: "test-id", + Name: "Test IDP", + Type: types.IdentityProviderTypeGoogle, + Issuer: "https://accounts.google.com", + ClientID: "client-id", + ClientSecret: "should-not-be-returned", + } + + response := toAPIResponse(idp) + + assert.Equal(t, "test-id", *response.Id) + assert.Equal(t, "Test IDP", response.Name) + assert.Equal(t, api.IdentityProviderTypeGoogle, response.Type) + assert.Equal(t, "https://accounts.google.com", response.Issuer) + assert.Equal(t, "client-id", response.ClientId) + // Note: ClientSecret is not included in response type by design +} + +func TestFromAPIRequest(t *testing.T) { + req := &api.IdentityProviderRequest{ + Name: "New IDP", + Type: api.IdentityProviderTypeOkta, + Issuer: "https://dev-123456.okta.com", + ClientId: "okta-client-id", + ClientSecret: "okta-client-secret", + } + + idp := fromAPIRequest(req) + + assert.Equal(t, "New IDP", idp.Name) + assert.Equal(t, types.IdentityProviderTypeOkta, idp.Type) + assert.Equal(t, "https://dev-123456.okta.com", idp.Issuer) + assert.Equal(t, "okta-client-id", idp.ClientID) + assert.Equal(t, "okta-client-secret", idp.ClientSecret) +} diff --git a/management/server/http/handlers/instance/instance_handler.go b/management/server/http/handlers/instance/instance_handler.go new file mode 100644 index 000000000..e98ce4d7c --- /dev/null +++ b/management/server/http/handlers/instance/instance_handler.go @@ -0,0 +1,117 @@ +package instance + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/management/server/account" + nbinstance "github.com/netbirdio/netbird/management/server/instance" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" +) + +// handler handles the instance setup HTTP endpoints +type handler struct { + instanceManager nbinstance.Manager + setupManager *nbinstance.SetupService +} + +// AddEndpoints registers the instance setup endpoints. +// These endpoints bypass authentication for initial setup. +func AddEndpoints(instanceManager nbinstance.Manager, accountManager account.Manager, router *mux.Router) { + h := &handler{ + instanceManager: instanceManager, + setupManager: nbinstance.NewSetupService(instanceManager, accountManager), + } + + router.HandleFunc("/instance", h.getInstanceStatus).Methods("GET", "OPTIONS") + router.HandleFunc("/setup", h.setup).Methods("POST", "OPTIONS") +} + +// AddVersionEndpoint registers the authenticated version endpoint. +func AddVersionEndpoint(instanceManager nbinstance.Manager, router *mux.Router) { + h := &handler{ + instanceManager: instanceManager, + } + + router.HandleFunc("/instance/version", h.getVersionInfo).Methods("GET", "OPTIONS") +} + +// getInstanceStatus returns the instance status including whether setup is required. +// This endpoint is unauthenticated. +func (h *handler) getInstanceStatus(w http.ResponseWriter, r *http.Request) { + setupRequired, err := h.instanceManager.IsSetupRequired(r.Context()) + if err != nil { + log.WithContext(r.Context()).Errorf("failed to check setup status: %v", err) + util.WriteErrorResponse("failed to check instance status", http.StatusInternalServerError, w) + return + } + log.WithContext(r.Context()).Infof("instance setup status: %v", setupRequired) + util.WriteJSONObject(r.Context(), w, api.InstanceStatus{ + SetupRequired: setupRequired, + }) +} + +// setup creates the initial admin user for the instance. +// This endpoint is unauthenticated but only works when setup is required. +func (h *handler) setup(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var req api.SetupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("invalid request body", http.StatusBadRequest, w) + return + } + + result, err := h.setupManager.SetupOwner(ctx, req.Email, req.Password, req.Name, nbinstance.SetupOptions{ + CreatePAT: req.CreatePat != nil && *req.CreatePat, + PATExpireInDays: req.PatExpireIn, + }) + if err != nil { + util.WriteError(ctx, err, w) + return + } + + log.WithContext(ctx).Infof("instance setup completed: created user %s", req.Email) + + resp := api.SetupResponse{ + UserId: result.User.ID, + Email: result.User.Email, + } + + if result.PATPlainToken != "" { + resp.PersonalAccessToken = &result.PATPlainToken + } + + w.Header().Set("Cache-Control", "no-store") + util.WriteJSONObject(ctx, w, resp) +} + +// getVersionInfo returns version information for NetBird components. +// This endpoint requires authentication. +func (h *handler) getVersionInfo(w http.ResponseWriter, r *http.Request) { + versionInfo, err := h.instanceManager.GetVersionInfo(r.Context()) + if err != nil { + log.WithContext(r.Context()).Errorf("failed to get version info: %v", err) + util.WriteErrorResponse("failed to get version info", http.StatusInternalServerError, w) + return + } + + resp := api.InstanceVersionInfo{ + ManagementCurrentVersion: versionInfo.CurrentVersion, + ManagementUpdateAvailable: versionInfo.ManagementUpdateAvailable, + } + + if versionInfo.DashboardVersion != "" { + resp.DashboardAvailableVersion = &versionInfo.DashboardVersion + } + + if versionInfo.ManagementVersion != "" { + resp.ManagementAvailableVersion = &versionInfo.ManagementVersion + } + + util.WriteJSONObject(r.Context(), w, resp) +} diff --git a/management/server/http/handlers/instance/instance_handler_test.go b/management/server/http/handlers/instance/instance_handler_test.go new file mode 100644 index 000000000..711e01964 --- /dev/null +++ b/management/server/http/handlers/instance/instance_handler_test.go @@ -0,0 +1,587 @@ +package instance + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/mail" + "testing" + + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/account" + "github.com/netbirdio/netbird/management/server/idp" + nbinstance "github.com/netbirdio/netbird/management/server/instance" + "github.com/netbirdio/netbird/management/server/mock_server" + nbstore "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/status" +) + +// mockInstanceManager implements instance.Manager for testing +type mockInstanceManager struct { + isSetupRequired bool + isSetupRequiredFn func(ctx context.Context) (bool, error) + createOwnerUserFn func(ctx context.Context, email, password, name string) (*idp.UserData, error) + rollbackSetupFn func(ctx context.Context, userID string) error + getVersionInfoFn func(ctx context.Context) (*nbinstance.VersionInfo, error) +} + +func (m *mockInstanceManager) IsSetupRequired(ctx context.Context) (bool, error) { + if m.isSetupRequiredFn != nil { + return m.isSetupRequiredFn(ctx) + } + return m.isSetupRequired, nil +} + +func (m *mockInstanceManager) CreateOwnerUser(ctx context.Context, email, password, name string) (*idp.UserData, error) { + if m.createOwnerUserFn != nil { + return m.createOwnerUserFn(ctx, email, password, name) + } + + // Default mock includes validation like the real manager + if !m.isSetupRequired { + return nil, status.Errorf(status.PreconditionFailed, "setup already completed") + } + if email == "" { + return nil, status.Errorf(status.InvalidArgument, "email is required") + } + if _, err := mail.ParseAddress(email); err != nil { + return nil, status.Errorf(status.InvalidArgument, "invalid email format") + } + if name == "" { + return nil, status.Errorf(status.InvalidArgument, "name is required") + } + if password == "" { + return nil, status.Errorf(status.InvalidArgument, "password is required") + } + if len(password) < 8 { + return nil, status.Errorf(status.InvalidArgument, "password must be at least 8 characters") + } + + return &idp.UserData{ + ID: "test-user-id", + Email: email, + Name: name, + }, nil +} + +func (m *mockInstanceManager) RollbackSetup(ctx context.Context, userID string) error { + if m.rollbackSetupFn != nil { + return m.rollbackSetupFn(ctx, userID) + } + return nil +} + +func (m *mockInstanceManager) GetVersionInfo(ctx context.Context) (*nbinstance.VersionInfo, error) { + if m.getVersionInfoFn != nil { + return m.getVersionInfoFn(ctx) + } + return &nbinstance.VersionInfo{ + CurrentVersion: "0.34.0", + DashboardVersion: "2.0.0", + ManagementVersion: "0.35.0", + ManagementUpdateAvailable: true, + }, nil +} + +var _ nbinstance.Manager = (*mockInstanceManager)(nil) + +func setupTestRouter(manager nbinstance.Manager) *mux.Router { + return setupTestRouterWithPAT(manager, nil) +} + +func setupTestRouterWithPAT(manager nbinstance.Manager, accountManager account.Manager) *mux.Router { + router := mux.NewRouter() + AddEndpoints(manager, accountManager, router) + return router +} + +func TestGetInstanceStatus_SetupRequired(t *testing.T) { + manager := &mockInstanceManager{isSetupRequired: true} + router := setupTestRouter(manager) + + req := httptest.NewRequest(http.MethodGet, "/instance", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var response api.InstanceStatus + err := json.NewDecoder(rec.Body).Decode(&response) + require.NoError(t, err) + assert.True(t, response.SetupRequired) +} + +func TestGetInstanceStatus_SetupNotRequired(t *testing.T) { + manager := &mockInstanceManager{isSetupRequired: false} + router := setupTestRouter(manager) + + req := httptest.NewRequest(http.MethodGet, "/instance", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var response api.InstanceStatus + err := json.NewDecoder(rec.Body).Decode(&response) + require.NoError(t, err) + assert.False(t, response.SetupRequired) +} + +func TestGetInstanceStatus_Error(t *testing.T) { + manager := &mockInstanceManager{ + isSetupRequiredFn: func(ctx context.Context) (bool, error) { + return false, errors.New("database error") + }, + } + router := setupTestRouter(manager) + + req := httptest.NewRequest(http.MethodGet, "/instance", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) +} + +func TestSetup_Success(t *testing.T) { + manager := &mockInstanceManager{ + isSetupRequired: true, + createOwnerUserFn: func(ctx context.Context, email, password, name string) (*idp.UserData, error) { + assert.Equal(t, "admin@example.com", email) + assert.Equal(t, "securepassword123", password) + assert.Equal(t, "Admin User", name) + return &idp.UserData{ + ID: "created-user-id", + Email: email, + Name: name, + }, nil + }, + } + router := setupTestRouter(manager) + + body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin User"}` + req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + + var response api.SetupResponse + err := json.NewDecoder(rec.Body).Decode(&response) + require.NoError(t, err) + assert.Equal(t, "created-user-id", response.UserId) + assert.Equal(t, "admin@example.com", response.Email) +} + +func TestSetup_AlreadyCompleted(t *testing.T) { + manager := &mockInstanceManager{isSetupRequired: false} + router := setupTestRouter(manager) + + body := `{"email": "admin@example.com", "password": "securepassword123"}` + req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusPreconditionFailed, rec.Code) +} + +func TestSetup_MissingEmail(t *testing.T) { + manager := &mockInstanceManager{isSetupRequired: true} + router := setupTestRouter(manager) + + body := `{"password": "securepassword123"}` + req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code) +} + +func TestSetup_InvalidEmail(t *testing.T) { + manager := &mockInstanceManager{isSetupRequired: true} + router := setupTestRouter(manager) + + body := `{"email": "not-an-email", "password": "securepassword123", "name": "User"}` + req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + // Note: Invalid email format uses mail.ParseAddress which is treated differently + // and returns 400 Bad Request instead of 422 Unprocessable Entity + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code) +} + +func TestSetup_MissingPassword(t *testing.T) { + manager := &mockInstanceManager{isSetupRequired: true} + router := setupTestRouter(manager) + + body := `{"email": "admin@example.com", "name": "User"}` + req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code) +} + +func TestSetup_PasswordTooShort(t *testing.T) { + manager := &mockInstanceManager{isSetupRequired: true} + router := setupTestRouter(manager) + + body := `{"email": "admin@example.com", "password": "short", "name": "User"}` + req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code) +} + +func TestSetup_InvalidJSON(t *testing.T) { + manager := &mockInstanceManager{isSetupRequired: true} + router := setupTestRouter(manager) + + body := `{invalid json}` + req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestSetup_CreateUserError(t *testing.T) { + manager := &mockInstanceManager{ + isSetupRequired: true, + createOwnerUserFn: func(ctx context.Context, email, password, name string) (*idp.UserData, error) { + return nil, errors.New("user creation failed") + }, + } + router := setupTestRouter(manager) + + body := `{"email": "admin@example.com", "password": "securepassword123", "name": "User"}` + req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) +} + +func TestSetup_ManagerError(t *testing.T) { + manager := &mockInstanceManager{ + isSetupRequired: true, + createOwnerUserFn: func(ctx context.Context, email, password, name string) (*idp.UserData, error) { + return nil, status.Errorf(status.Internal, "database error") + }, + } + router := setupTestRouter(manager) + + body := `{"email": "admin@example.com", "password": "securepassword123", "name": "User"}` + req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) +} + +func TestSetup_PAT_FeatureDisabled_IgnoresCreatePAT(t *testing.T) { + t.Setenv(nbinstance.SetupPATEnabledEnvKey, "false") + + manager := &mockInstanceManager{isSetupRequired: true} + // NB_SETUP_PAT_ENABLED=false: request fields must be silently ignored + router := setupTestRouterWithPAT(manager, &mock_server.MockAccountManager{}) + + body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin", "create_pat": true}` + req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + var response api.SetupResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) + assert.Nil(t, response.PersonalAccessToken) +} + +func TestSetup_PAT_FlagOmitted_NoPAT(t *testing.T) { + t.Setenv(nbinstance.SetupPATEnabledEnvKey, "true") + + manager := &mockInstanceManager{isSetupRequired: true} + router := setupTestRouterWithPAT(manager, &mock_server.MockAccountManager{}) + + body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin"}` + req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + var response api.SetupResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) + assert.Nil(t, response.PersonalAccessToken) +} + +func TestSetup_PAT_MissingExpireIn_DefaultsToOneDay(t *testing.T) { + t.Setenv(nbinstance.SetupPATEnabledEnvKey, "true") + + createCalled := false + manager := &mockInstanceManager{ + isSetupRequired: true, + createOwnerUserFn: func(ctx context.Context, email, password, name string) (*idp.UserData, error) { + createCalled = true + return &idp.UserData{ID: "u1", Email: email, Name: name}, nil + }, + } + accountMgr := &mock_server.MockAccountManager{ + GetAccountIDByUserIdFunc: func(_ context.Context, userAuth auth.UserAuth) (string, error) { + assert.Equal(t, "u1", userAuth.UserId) + return "acc-1", nil + }, + CreatePATFunc: func(_ context.Context, accountID, initiator, target, name string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) { + assert.Equal(t, "acc-1", accountID) + assert.Equal(t, "u1", initiator) + assert.Equal(t, "u1", target) + assert.Equal(t, "setup-token", name) + assert.Equal(t, 1, expiresIn) + return &types.PersonalAccessTokenGenerated{PlainToken: "nbp_plain"}, nil + }, + } + router := setupTestRouterWithPAT(manager, accountMgr) + + body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin", "create_pat": true}` + req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + assert.True(t, createCalled) + var response api.SetupResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) + require.NotNil(t, response.PersonalAccessToken) + assert.Equal(t, "nbp_plain", *response.PersonalAccessToken) +} + +func TestSetup_PAT_ExpireOutOfRange(t *testing.T) { + t.Setenv(nbinstance.SetupPATEnabledEnvKey, "true") + + manager := &mockInstanceManager{isSetupRequired: true} + router := setupTestRouterWithPAT(manager, &mock_server.MockAccountManager{}) + + body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin", "create_pat": true, "pat_expire_in": 0}` + req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code) +} + +func TestSetup_PAT_Success(t *testing.T) { + t.Setenv(nbinstance.SetupPATEnabledEnvKey, "true") + + manager := &mockInstanceManager{ + isSetupRequired: true, + createOwnerUserFn: func(ctx context.Context, email, password, name string) (*idp.UserData, error) { + return &idp.UserData{ID: "owner-id", Email: email, Name: name}, nil + }, + } + + gotAccountArgs := struct { + userID string + email string + }{} + accountMgr := &mock_server.MockAccountManager{ + GetAccountIDByUserIdFunc: func(_ context.Context, userAuth auth.UserAuth) (string, error) { + gotAccountArgs.userID = userAuth.UserId + gotAccountArgs.email = userAuth.Email + return "acc-1", nil + }, + CreatePATFunc: func(_ context.Context, accountID, initiator, target, name string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) { + assert.Equal(t, "acc-1", accountID) + assert.Equal(t, "owner-id", initiator) + assert.Equal(t, "owner-id", target) + assert.Equal(t, "setup-token", name) + assert.Equal(t, 30, expiresIn) + return &types.PersonalAccessTokenGenerated{PlainToken: "nbp_plain"}, nil + }, + } + + router := setupTestRouterWithPAT(manager, accountMgr) + + body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin", "create_pat": true, "pat_expire_in": 30}` + req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + var response api.SetupResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) + assert.Equal(t, "owner-id", response.UserId) + require.NotNil(t, response.PersonalAccessToken) + assert.Equal(t, "nbp_plain", *response.PersonalAccessToken) + assert.Equal(t, "owner-id", gotAccountArgs.userID) + assert.Equal(t, "admin@example.com", gotAccountArgs.email) +} + +func TestSetup_PAT_AccountCreationFails_Rollback(t *testing.T) { + t.Setenv(nbinstance.SetupPATEnabledEnvKey, "true") + + ctrl := gomock.NewController(t) + accountStore := nbstore.NewMockStore(ctrl) + accountStore.EXPECT().GetAccountIDByUserID(gomock.Any(), nbstore.LockingStrengthNone, "owner-id").Return("", status.NewAccountNotFoundError("owner-id")) + + rolledBackFor := "" + manager := &mockInstanceManager{ + isSetupRequired: true, + createOwnerUserFn: func(ctx context.Context, email, password, name string) (*idp.UserData, error) { + return &idp.UserData{ID: "owner-id", Email: email, Name: name}, nil + }, + rollbackSetupFn: func(_ context.Context, userID string) error { + rolledBackFor = userID + return nil + }, + } + accountMgr := &mock_server.MockAccountManager{ + GetAccountIDByUserIdFunc: func(_ context.Context, _ auth.UserAuth) (string, error) { + return "", errors.New("db down") + }, + GetStoreFunc: func() nbstore.Store { + return accountStore + }, + } + + router := setupTestRouterWithPAT(manager, accountMgr) + + body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin", "create_pat": true, "pat_expire_in": 30}` + req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Equal(t, "owner-id", rolledBackFor, "RollbackSetup must be called with the created user id") +} + +func TestSetup_PAT_CreatePATFails_Rollback(t *testing.T) { + t.Setenv(nbinstance.SetupPATEnabledEnvKey, "true") + + ctrl := gomock.NewController(t) + accountStore := nbstore.NewMockStore(ctrl) + account := &types.Account{Id: "acc-1"} + accountStore.EXPECT().GetAccount(gomock.Any(), "acc-1").Return(account, nil) + accountStore.EXPECT().DeleteAccount(gomock.Any(), account).Return(nil) + + rolledBackFor := "" + manager := &mockInstanceManager{ + isSetupRequired: true, + createOwnerUserFn: func(ctx context.Context, email, password, name string) (*idp.UserData, error) { + return &idp.UserData{ID: "owner-id", Email: email, Name: name}, nil + }, + rollbackSetupFn: func(_ context.Context, userID string) error { + rolledBackFor = userID + return nil + }, + } + accountMgr := &mock_server.MockAccountManager{ + GetAccountIDByUserIdFunc: func(_ context.Context, _ auth.UserAuth) (string, error) { + return "acc-1", nil + }, + CreatePATFunc: func(_ context.Context, _, _, _, _ string, _ int) (*types.PersonalAccessTokenGenerated, error) { + return nil, status.Errorf(status.Internal, "token store unavailable") + }, + GetStoreFunc: func() nbstore.Store { + return accountStore + }, + } + + router := setupTestRouterWithPAT(manager, accountMgr) + + body := `{"email": "admin@example.com", "password": "securepassword123", "name": "Admin", "create_pat": true, "pat_expire_in": 30}` + req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.Equal(t, "owner-id", rolledBackFor, "RollbackSetup must be called when CreatePAT fails") +} + +func TestGetVersionInfo_Success(t *testing.T) { + manager := &mockInstanceManager{} + router := mux.NewRouter() + AddVersionEndpoint(manager, router) + + req := httptest.NewRequest(http.MethodGet, "/instance/version", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var response api.InstanceVersionInfo + err := json.NewDecoder(rec.Body).Decode(&response) + require.NoError(t, err) + + assert.Equal(t, "0.34.0", response.ManagementCurrentVersion) + assert.NotNil(t, response.DashboardAvailableVersion) + assert.Equal(t, "2.0.0", *response.DashboardAvailableVersion) + assert.NotNil(t, response.ManagementAvailableVersion) + assert.Equal(t, "0.35.0", *response.ManagementAvailableVersion) + assert.True(t, response.ManagementUpdateAvailable) +} + +func TestGetVersionInfo_Error(t *testing.T) { + manager := &mockInstanceManager{ + getVersionInfoFn: func(ctx context.Context) (*nbinstance.VersionInfo, error) { + return nil, errors.New("failed to fetch versions") + }, + } + router := mux.NewRouter() + AddVersionEndpoint(manager, router) + + req := httptest.NewRequest(http.MethodGet, "/instance/version", nil) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusInternalServerError, rec.Code) +} diff --git a/management/server/http/handlers/networks/handler.go b/management/server/http/handlers/networks/handler.go index d7b598a5d..f99eca794 100644 --- a/management/server/http/handlers/networks/handler.go +++ b/management/server/http/handlers/networks/handler.go @@ -12,15 +12,15 @@ import ( "github.com/netbirdio/netbird/management/server/account" nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/server/groups" - "github.com/netbirdio/netbird/shared/management/http/api" - "github.com/netbirdio/netbird/shared/management/http/util" "github.com/netbirdio/netbird/management/server/networks" "github.com/netbirdio/netbird/management/server/networks/resources" "github.com/netbirdio/netbird/management/server/networks/routers" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" "github.com/netbirdio/netbird/management/server/networks/types" - "github.com/netbirdio/netbird/shared/management/status" nbtypes "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/shared/management/status" ) // handler is a handler that returns networks of the account diff --git a/management/server/http/handlers/networks/resources_handler.go b/management/server/http/handlers/networks/resources_handler.go index 59396dceb..c31729a39 100644 --- a/management/server/http/handlers/networks/resources_handler.go +++ b/management/server/http/handlers/networks/resources_handler.go @@ -8,10 +8,10 @@ import ( nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/server/groups" - "github.com/netbirdio/netbird/shared/management/http/api" - "github.com/netbirdio/netbird/shared/management/http/util" "github.com/netbirdio/netbird/management/server/networks/resources" "github.com/netbirdio/netbird/management/server/networks/resources/types" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" ) type resourceHandler struct { diff --git a/management/server/http/handlers/networks/routers_handler.go b/management/server/http/handlers/networks/routers_handler.go index 2e64c637f..ce9efb78d 100644 --- a/management/server/http/handlers/networks/routers_handler.go +++ b/management/server/http/handlers/networks/routers_handler.go @@ -7,10 +7,10 @@ import ( "github.com/gorilla/mux" 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/management/server/networks/routers" "github.com/netbirdio/netbird/management/server/networks/routers/types" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" ) type routersHandler struct { @@ -105,6 +105,12 @@ func (h *routersHandler) createRouter(w http.ResponseWriter, r *http.Request) { router.NetworkID = networkID router.AccountID = accountID router.Enabled = true + + if err := router.Validate(); err != nil { + util.WriteErrorResponse(err.Error(), http.StatusBadRequest, w) + return + } + router, err = h.routersManager.CreateRouter(r.Context(), userID, router) if err != nil { util.WriteError(r.Context(), err, w) @@ -157,6 +163,11 @@ func (h *routersHandler) updateRouter(w http.ResponseWriter, r *http.Request) { router.ID = mux.Vars(r)["routerId"] router.AccountID = accountID + if err := router.Validate(); err != nil { + util.WriteErrorResponse(err.Error(), http.StatusBadRequest, w) + return + } + router, err = h.routersManager.UpdateRouter(r.Context(), userID, router) if err != nil { util.WriteError(r.Context(), err, w) diff --git a/management/server/http/handlers/peers/peers_handler.go b/management/server/http/handlers/peers/peers_handler.go index c4c5ae165..03a37c3ec 100644 --- a/management/server/http/handlers/peers/peers_handler.go +++ b/management/server/http/handlers/peers/peers_handler.go @@ -10,12 +10,16 @@ import ( "github.com/gorilla/mux" log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/internals/controllers/network_map" "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/server/groups" nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/http/util" @@ -25,37 +29,122 @@ import ( // Handler is a handler that returns peers of the account type Handler struct { accountManager account.Manager + permissionsManager permissions.Manager networkMapController network_map.Controller } -func AddEndpoints(accountManager account.Manager, router *mux.Router, networkMapController network_map.Controller) { - peersHandler := NewHandler(accountManager, networkMapController) +func AddEndpoints(accountManager account.Manager, router *mux.Router, networkMapController network_map.Controller, permissionsManager permissions.Manager) { + peersHandler := NewHandler(accountManager, networkMapController, permissionsManager) router.HandleFunc("/peers", peersHandler.GetAllPeers).Methods("GET", "OPTIONS") router.HandleFunc("/peers/{peerId}", peersHandler.HandlePeer). Methods("GET", "PUT", "DELETE", "OPTIONS") router.HandleFunc("/peers/{peerId}/accessible-peers", peersHandler.GetAccessiblePeers).Methods("GET", "OPTIONS") router.HandleFunc("/peers/{peerId}/temporary-access", peersHandler.CreateTemporaryAccess).Methods("POST", "OPTIONS") + router.HandleFunc("/peers/{peerId}/jobs", peersHandler.ListJobs).Methods("GET", "OPTIONS") + router.HandleFunc("/peers/{peerId}/jobs", peersHandler.CreateJob).Methods("POST", "OPTIONS") + router.HandleFunc("/peers/{peerId}/jobs/{jobId}", peersHandler.GetJob).Methods("GET", "OPTIONS") } // NewHandler creates a new peers Handler -func NewHandler(accountManager account.Manager, networkMapController network_map.Controller) *Handler { +func NewHandler(accountManager account.Manager, networkMapController network_map.Controller, permissionsManager permissions.Manager) *Handler { return &Handler{ accountManager: accountManager, networkMapController: networkMapController, + permissionsManager: permissionsManager, } } -func (h *Handler) checkPeerStatus(peer *nbpeer.Peer) (*nbpeer.Peer, error) { - peerToReturn := peer.Copy() - if peer.Status.Connected { - // Although we have online status in store we do not yet have an updated channel so have to show it as disconnected - // This may happen after server restart when not all peers are yet connected - if !h.networkMapController.IsConnected(peer.ID) { - peerToReturn.Status.Connected = false - } +func (h *Handler) CreateJob(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + userAuth, err := nbcontext.GetUserAuthFromContext(ctx) + if err != nil { + util.WriteError(ctx, err, w) + return } - return peerToReturn, nil + vars := mux.Vars(r) + peerID := vars["peerId"] + + req := &api.JobRequest{} + if err := json.NewDecoder(r.Body).Decode(req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + job, err := types.NewJob(userAuth.UserId, userAuth.AccountId, peerID, req) + if err != nil { + util.WriteError(ctx, err, w) + return + } + if err := h.accountManager.CreatePeerJob(ctx, userAuth.AccountId, peerID, userAuth.UserId, job); err != nil { + util.WriteError(ctx, err, w) + return + } + + resp, err := toSingleJobResponse(job) + if err != nil { + util.WriteError(ctx, err, w) + return + } + + util.WriteJSONObject(ctx, w, resp) +} + +func (h *Handler) ListJobs(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + userAuth, err := nbcontext.GetUserAuthFromContext(ctx) + if err != nil { + util.WriteError(ctx, err, w) + return + } + + vars := mux.Vars(r) + peerID := vars["peerId"] + + jobs, err := h.accountManager.GetAllPeerJobs(ctx, userAuth.AccountId, userAuth.UserId, peerID) + if err != nil { + util.WriteError(ctx, err, w) + return + } + + respBody := make([]*api.JobResponse, 0, len(jobs)) + for _, job := range jobs { + resp, err := toSingleJobResponse(job) + if err != nil { + util.WriteError(ctx, err, w) + return + } + respBody = append(respBody, resp) + } + + util.WriteJSONObject(ctx, w, respBody) +} + +func (h *Handler) GetJob(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + userAuth, err := nbcontext.GetUserAuthFromContext(ctx) + if err != nil { + util.WriteError(ctx, err, w) + return + } + + vars := mux.Vars(r) + peerID := vars["peerId"] + jobID := vars["jobId"] + + job, err := h.accountManager.GetPeerJobByID(ctx, userAuth.AccountId, userAuth.UserId, peerID, jobID) + if err != nil { + util.WriteError(ctx, err, w) + return + } + + resp, err := toSingleJobResponse(job) + if err != nil { + util.WriteError(ctx, err, w) + return + } + + util.WriteJSONObject(ctx, w, resp) } func (h *Handler) getPeer(ctx context.Context, accountID, peerID, userID string, w http.ResponseWriter) { @@ -65,11 +154,11 @@ func (h *Handler) getPeer(ctx context.Context, accountID, peerID, userID string, return } - peerToReturn, err := h.checkPeerStatus(peer) - if err != nil { - util.WriteError(ctx, err, w) + if peer.ProxyMeta.Embedded { + util.WriteError(ctx, status.Errorf(status.InvalidArgument, "not allowed to read peer"), w) return } + settings, err := h.accountManager.GetAccountSettings(ctx, accountID, activity.SystemInitiator) if err != nil { util.WriteError(ctx, err, w) @@ -91,7 +180,7 @@ func (h *Handler) getPeer(ctx context.Context, accountID, peerID, userID string, _, valid := validPeers[peer.ID] reason := invalidPeers[peer.ID] - util.WriteJSONObject(ctx, w, toSinglePeerResponse(peerToReturn, grpsInfoMap[peerID], dnsDomain, valid, reason)) + util.WriteJSONObject(ctx, w, toSinglePeerResponse(peer, grpsInfoMap[peerID], dnsDomain, valid, reason)) } func (h *Handler) updatePeer(ctx context.Context, accountID, userID, peerID string, w http.ResponseWriter, r *http.Request) { @@ -131,6 +220,18 @@ func (h *Handler) updatePeer(ctx context.Context, accountID, userID, peerID stri } } + if req.Ipv6 != nil { + v6Addr, err := parseIPv6(req.Ipv6) + if err != nil { + util.WriteError(ctx, status.Errorf(status.InvalidArgument, "%v", err), w) + return + } + if err = h.accountManager.UpdatePeerIPv6(ctx, accountID, userID, peerID, v6Addr); err != nil { + util.WriteError(ctx, err, w) + return + } + } + peer, err := h.accountManager.UpdatePeer(ctx, accountID, userID, update) if err != nil { util.WriteError(ctx, err, w) @@ -237,13 +338,10 @@ func (h *Handler) GetAllPeers(w http.ResponseWriter, r *http.Request) { grpsInfoMap := groups.ToGroupsInfoMap(grps, len(peers)) respBody := make([]*api.PeerBatch, 0, len(peers)) for _, peer := range peers { - peerToReturn, err := h.checkPeerStatus(peer) - if err != nil { - util.WriteError(r.Context(), err, w) - return + if peer.ProxyMeta.Embedded { + continue } - - respBody = append(respBody, toPeerListItemResponse(peerToReturn, grpsInfoMap[peer.ID], dnsDomain, 0)) + respBody = append(respBody, toPeerListItemResponse(peer, grpsInfoMap[peer.ID], dnsDomain, 0)) } validPeersMap, invalidPeersMap, err := h.accountManager.GetValidatedPeers(r.Context(), accountID) @@ -269,6 +367,21 @@ func (h *Handler) setApprovalRequiredFlag(respBody []*api.PeerBatch, validPeersM } } +func parseIPv6(s *string) (netip.Addr, error) { + if s == nil { + return netip.Addr{}, fmt.Errorf("IPv6 address is nil") + } + addr, err := netip.ParseAddr(*s) + if err != nil { + return netip.Addr{}, fmt.Errorf("invalid IPv6 address %s: %w", *s, err) + } + addr = addr.Unmap() + if !addr.Is6() { + return netip.Addr{}, fmt.Errorf("address %s is not IPv6", *s) + } + return addr, nil +} + // GetAccessiblePeers returns a list of all peers that the specified peer can connect to within the network. func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) { userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) @@ -286,46 +399,54 @@ func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) { return } - account, err := h.accountManager.GetAccountByID(r.Context(), accountID, activity.SystemInitiator) - if err != nil { - util.WriteError(r.Context(), err, w) - return - } - user, err := h.accountManager.GetUserByID(r.Context(), userID) if err != nil { util.WriteError(r.Context(), err, w) return } - // If the user is regular user and does not own the peer - // with the given peerID return an empty list - if !user.HasAdminPower() && !user.IsServiceUser && !userAuth.IsChild { + allowed, ctx, err := h.permissionsManager.ValidateUserPermissions(r.Context(), accountID, userID, modules.Peers, operations.Read) + if err != nil { + util.WriteError(ctx, status.NewPermissionValidationError(err), w) + return + } + + account, err := h.accountManager.GetAccountByID(ctx, accountID, activity.SystemInitiator) + if err != nil { + util.WriteError(ctx, err, w) + return + } + + if !allowed && !userAuth.IsChild { + if account.Settings.RegularUsersViewBlocked { + util.WriteJSONObject(ctx, w, []api.AccessiblePeer{}) + return + } + peer, ok := account.Peers[peerID] if !ok { - util.WriteError(r.Context(), status.Errorf(status.NotFound, "peer not found"), w) + util.WriteError(ctx, status.Errorf(status.NotFound, "peer not found"), w) return } if peer.UserID != user.Id { - util.WriteJSONObject(r.Context(), w, []api.AccessiblePeer{}) + util.WriteJSONObject(ctx, w, []api.AccessiblePeer{}) return } } - validPeers, _, err := h.accountManager.GetValidatedPeers(r.Context(), accountID) + validPeers, _, err := h.accountManager.GetValidatedPeers(ctx, accountID) if err != nil { - log.WithContext(r.Context()).Errorf("failed to list approved peers: %v", err) - util.WriteError(r.Context(), fmt.Errorf("internal error"), w) + log.WithContext(ctx).Errorf("failed to list approved peers: %v", err) + util.WriteError(ctx, fmt.Errorf("internal error"), w) return } dnsDomain := h.networkMapController.GetDNSDomain(account.Settings) - customZone := account.GetPeersCustomZone(r.Context(), dnsDomain) - netMap := account.GetPeerNetworkMap(r.Context(), peerID, customZone, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil) + netMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers()) - util.WriteJSONObject(r.Context(), w, toAccessiblePeers(netMap, dnsDomain)) + util.WriteJSONObject(ctx, w, toAccessiblePeers(netMap, account.Peers, dnsDomain)) } func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) { @@ -358,7 +479,7 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) return } - peer, _, _, err := h.accountManager.AddPeer(r.Context(), userAuth.AccountId, "", userAuth.UserId, newPeer, true) + peer, _, _, _, err := h.accountManager.AddPeer(r.Context(), userAuth.AccountId, "", userAuth.UserId, newPeer, true) if err != nil { util.WriteError(r.Context(), err, w) return @@ -393,6 +514,9 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) PortRanges: []types.RulePortRange{portRange}, }}, } + if protocol == types.PolicyRuleProtocolNetbirdSSH { + policy.Rules[0].AuthorizedUser = userAuth.UserId + } _, err = h.accountManager.SavePolicy(r.Context(), userAuth.AccountId, userAuth.UserId, policy, true) if err != nil { @@ -410,15 +534,20 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) util.WriteJSONObject(r.Context(), w, resp) } -func toAccessiblePeers(netMap *types.NetworkMap, dnsDomain string) []api.AccessiblePeer { +// toAccessiblePeers rehydrates the calculated map's component peers into the +// account's full peer objects, which carry the location/status/meta fields +// the API response needs. +func toAccessiblePeers(netMap *types.NetworkMap, accountPeers map[string]*nbpeer.Peer, dnsDomain string) []api.AccessiblePeer { accessiblePeers := make([]api.AccessiblePeer, 0, len(netMap.Peers)+len(netMap.OfflinePeers)) - for _, p := range netMap.Peers { - accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(p, dnsDomain)) - } - - for _, p := range netMap.OfflinePeers { - accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(p, dnsDomain)) + add := func(peers []*types.ComponentPeer) { + for _, p := range peers { + if peer := accountPeers[p.ID]; peer != nil { + accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(peer, dnsDomain)) + } + } } + add(netMap.Peers) + add(netMap.OfflinePeers) return accessiblePeers } @@ -432,6 +561,7 @@ func peerToAccessiblePeer(peer *nbpeer.Peer, dnsDomain string) api.AccessiblePee GeonameId: int(peer.Location.GeoNameID), Id: peer.ID, Ip: peer.IP.String(), + Ipv6: peerIPv6String(peer), LastSeen: peer.Status.LastSeen, Name: peer.Name, Os: peer.Meta.OS, @@ -450,6 +580,7 @@ func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dnsD Id: peer.ID, Name: peer.Name, Ip: peer.IP.String(), + Ipv6: peerIPv6String(peer), ConnectionIp: peer.Location.ConnectionIP.String(), Connected: peer.Status.Connected, LastSeen: peer.Status.LastSeen, @@ -473,6 +604,18 @@ func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dnsD SerialNumber: peer.Meta.SystemSerialNumber, InactivityExpirationEnabled: peer.InactivityExpirationEnabled, Ephemeral: peer.Ephemeral, + LocalFlags: &api.PeerLocalFlags{ + BlockInbound: &peer.Meta.Flags.BlockInbound, + BlockLanAccess: &peer.Meta.Flags.BlockLANAccess, + DisableClientRoutes: &peer.Meta.Flags.DisableClientRoutes, + DisableDns: &peer.Meta.Flags.DisableDNS, + DisableFirewall: &peer.Meta.Flags.DisableFirewall, + DisableServerRoutes: &peer.Meta.Flags.DisableServerRoutes, + LazyConnectionEnabled: &peer.Meta.Flags.LazyConnectionEnabled, + RosenpassEnabled: &peer.Meta.Flags.RosenpassEnabled, + RosenpassPermissive: &peer.Meta.Flags.RosenpassPermissive, + ServerSshAllowed: &peer.Meta.Flags.ServerSSHAllowed, + }, } if !approved { @@ -487,12 +630,12 @@ func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dn if osVersion == "" { osVersion = peer.Meta.Core } - return &api.PeerBatch{ CreatedAt: peer.CreatedAt, Id: peer.ID, Name: peer.Name, Ip: peer.IP.String(), + Ipv6: peerIPv6String(peer), ConnectionIp: peer.Location.ConnectionIP.String(), Connected: peer.Status.Connected, LastSeen: peer.Status.LastSeen, @@ -516,9 +659,43 @@ func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dn SerialNumber: peer.Meta.SystemSerialNumber, InactivityExpirationEnabled: peer.InactivityExpirationEnabled, Ephemeral: peer.Ephemeral, + LocalFlags: &api.PeerLocalFlags{ + BlockInbound: &peer.Meta.Flags.BlockInbound, + BlockLanAccess: &peer.Meta.Flags.BlockLANAccess, + DisableClientRoutes: &peer.Meta.Flags.DisableClientRoutes, + DisableDns: &peer.Meta.Flags.DisableDNS, + DisableFirewall: &peer.Meta.Flags.DisableFirewall, + DisableServerRoutes: &peer.Meta.Flags.DisableServerRoutes, + LazyConnectionEnabled: &peer.Meta.Flags.LazyConnectionEnabled, + RosenpassEnabled: &peer.Meta.Flags.RosenpassEnabled, + RosenpassPermissive: &peer.Meta.Flags.RosenpassPermissive, + ServerSshAllowed: &peer.Meta.Flags.ServerSSHAllowed, + }, } } +func toSingleJobResponse(job *types.Job) (*api.JobResponse, error) { + workload, err := job.BuildWorkloadResponse() + if err != nil { + return nil, err + } + + var failed *string + if job.FailedReason != "" { + failed = &job.FailedReason + } + + return &api.JobResponse{ + Id: job.ID, + CreatedAt: job.CreatedAt, + CompletedAt: job.CompletedAt, + TriggeredBy: job.TriggeredBy, + Status: api.JobResponseStatus(job.Status), + FailedReason: failed, + Workload: *workload, + }, nil +} + func fqdn(peer *nbpeer.Peer, dnsDomain string) string { fqdn := peer.FQDN(dnsDomain) if fqdn == "" { @@ -535,3 +712,11 @@ func fqdnList(extraLabels []string, dnsDomain string) []string { } return fqdnList } + +func peerIPv6String(peer *nbpeer.Peer) *string { + if !peer.IPv6.IsValid() { + return nil + } + s := peer.IPv6.String() + return &s +} diff --git a/management/server/http/handlers/peers/peers_handler_test.go b/management/server/http/handlers/peers/peers_handler_test.go index 7a5a6d911..047213879 100644 --- a/management/server/http/handlers/peers/peers_handler_test.go +++ b/management/server/http/handlers/peers/peers_handler_test.go @@ -13,14 +13,19 @@ import ( "testing" "time" + "github.com/golang/mock/gomock" "github.com/gorilla/mux" - "go.uber.org/mock/gomock" + ugomock "go.uber.org/mock/gomock" "golang.org/x/exp/maps" "github.com/netbirdio/netbird/management/internals/controllers/network_map" nbcontext "github.com/netbirdio/netbird/management/server/context" nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/auth" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/stretchr/testify/assert" @@ -65,7 +70,7 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler { }, } - srvUser := types.NewRegularUser(serviceUser) + srvUser := types.NewRegularUser(serviceUser, "", "") srvUser.IsServiceUser = true account := &types.Account{ @@ -74,7 +79,7 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler { Peers: peersMap, Users: map[string]*types.User{ adminUser: types.NewAdminUser(adminUser), - regularUser: types.NewRegularUser(regularUser), + regularUser: types.NewRegularUser(regularUser, "", ""), serviceUser: srvUser, }, Groups: map[string]*types.Group{ @@ -101,20 +106,26 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler { }, } - ctrl := gomock.NewController(t) + ctrl := ugomock.NewController(t) networkMapController := network_map.NewMockController(ctrl) networkMapController.EXPECT(). GetDNSDomain(gomock.Any()). Return("domain"). AnyTimes() - networkMapController.EXPECT(). - IsConnected(noUpdateChannelTestPeerID). - Return(false). - AnyTimes() - networkMapController.EXPECT(). - IsConnected(gomock.Any()). - Return(true). + + ctrl2 := gomock.NewController(t) + permissionsManager := permissions.NewMockManager(ctrl2) + permissionsManager.EXPECT().ValidateAccountAccess(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(context.Background(), nil).AnyTimes() + permissionsManager.EXPECT(). + ValidateUserPermissions(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Eq(modules.Peers), gomock.Eq(operations.Read)). + DoAndReturn(func(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, context.Context, error) { + user, ok := account.Users[userID] + if !ok { + return false, ctx, fmt.Errorf("user not found") + } + return user.HasAdminPower() || user.IsServiceUser, ctx, nil + }). AnyTimes() return &Handler{ @@ -135,7 +146,7 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler { UpdatePeerIPFunc: func(_ context.Context, accountID, userID, peerID string, newIP netip.Addr) error { for _, peer := range peers { if peer.ID == peerID { - peer.IP = net.IP(newIP.AsSlice()) + peer.IP = newIP return nil } } @@ -206,6 +217,7 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler { }, }, networkMapController: networkMapController, + permissionsManager: permissionsManager, } } @@ -216,7 +228,8 @@ func TestGetPeers(t *testing.T) { peer := &nbpeer.Peer{ ID: testPeerID, Key: "key", - IP: net.ParseIP("100.64.0.1"), + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), Status: &nbpeer.PeerStatus{Connected: true}, Name: "PeerName", LoginExpirationEnabled: false, @@ -268,14 +281,6 @@ func TestGetPeers(t *testing.T) { expectedArray: false, expectedPeer: peer, }, - { - name: "GetPeer with no update channel", - requestType: http.MethodGet, - requestPath: "/api/peers/" + peer1.ID, - expectedStatus: http.StatusOK, - expectedArray: false, - expectedPeer: expectedPeer1, - }, { name: "PutPeer", requestType: http.MethodPut, @@ -296,7 +301,7 @@ func TestGetPeers(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody) - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: "admin_user", Domain: "hotmail.com", AccountId: "test_id", @@ -335,8 +340,6 @@ func TestGetPeers(t *testing.T) { for _, peer := range respBody { if peer.Id == testPeerID { got = peer - } else { - assert.Equal(t, peer.Connected, false) } } @@ -350,14 +353,14 @@ func TestGetPeers(t *testing.T) { t.Log(got) - assert.Equal(t, got.Name, tc.expectedPeer.Name) - assert.Equal(t, got.Version, tc.expectedPeer.Meta.WtVersion) - assert.Equal(t, got.Ip, tc.expectedPeer.IP.String()) - assert.Equal(t, got.Os, "OS core") - assert.Equal(t, got.LoginExpirationEnabled, tc.expectedPeer.LoginExpirationEnabled) - assert.Equal(t, got.SshEnabled, tc.expectedPeer.SSHEnabled) - assert.Equal(t, got.Connected, tc.expectedPeer.Status.Connected) - assert.Equal(t, got.SerialNumber, tc.expectedPeer.Meta.SystemSerialNumber) + assert.Equal(t, tc.expectedPeer.Name, got.Name) + assert.Equal(t, tc.expectedPeer.Meta.WtVersion, got.Version) + assert.Equal(t, tc.expectedPeer.IP.String(), got.Ip) + assert.Equal(t, "OS core", got.Os) + assert.Equal(t, tc.expectedPeer.LoginExpirationEnabled, got.LoginExpirationEnabled) + assert.Equal(t, tc.expectedPeer.SSHEnabled, got.SshEnabled) + assert.Equal(t, tc.expectedPeer.Status.Connected, got.Connected) + assert.Equal(t, tc.expectedPeer.Meta.SystemSerialNumber, got.SerialNumber) }) } } @@ -366,7 +369,8 @@ func TestGetAccessiblePeers(t *testing.T) { peer1 := &nbpeer.Peer{ ID: "peer1", Key: "key1", - IP: net.ParseIP("100.64.0.1"), + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00:1234::1"), Status: &nbpeer.PeerStatus{Connected: true}, Name: "peer1", LoginExpirationEnabled: false, @@ -376,7 +380,8 @@ func TestGetAccessiblePeers(t *testing.T) { peer2 := &nbpeer.Peer{ ID: "peer2", Key: "key2", - IP: net.ParseIP("100.64.0.2"), + IP: netip.MustParseAddr("100.64.0.2"), + IPv6: netip.MustParseAddr("fd00:1234::2"), Status: &nbpeer.PeerStatus{Connected: true}, Name: "peer2", LoginExpirationEnabled: false, @@ -386,19 +391,19 @@ func TestGetAccessiblePeers(t *testing.T) { peer3 := &nbpeer.Peer{ ID: "peer3", Key: "key3", - IP: net.ParseIP("100.64.0.3"), + IP: netip.MustParseAddr("100.64.0.3"), + IPv6: netip.MustParseAddr("fd00:1234::3"), Status: &nbpeer.PeerStatus{Connected: true}, Name: "peer3", LoginExpirationEnabled: false, UserID: regularUser, } - p := initTestMetaData(t, peer1, peer2, peer3) - tt := []struct { name string peerID string callerUserID string + viewBlocked bool expectedStatus int expectedPeers []string }{ @@ -437,14 +442,60 @@ func TestGetAccessiblePeers(t *testing.T) { expectedStatus: http.StatusOK, expectedPeers: []string{"peer1", "peer2"}, }, + { + name: "regular user gets empty for owned peer list when view blocked", + peerID: "peer1", + callerUserID: regularUser, + viewBlocked: true, + expectedStatus: http.StatusOK, + expectedPeers: []string{}, + }, + { + name: "regular user gets empty list for unowned peer when view blocked", + peerID: "peer2", + callerUserID: regularUser, + viewBlocked: true, + expectedStatus: http.StatusOK, + expectedPeers: []string{}, + }, + { + name: "admin user still sees accessible peers when view blocked", + peerID: "peer2", + callerUserID: adminUser, + viewBlocked: true, + expectedStatus: http.StatusOK, + expectedPeers: []string{"peer1", "peer3"}, + }, + { + name: "service user still sees accessible peers when view blocked", + peerID: "peer3", + callerUserID: serviceUser, + viewBlocked: true, + expectedStatus: http.StatusOK, + expectedPeers: []string{"peer1", "peer2"}, + }, } for _, tc := range tt { t.Run(tc.name, func(t *testing.T) { + p := initTestMetaData(t, peer1, peer2, peer3) + + if tc.viewBlocked { + mockAM := p.accountManager.(*mock_server.MockAccountManager) + originalGetAccountByIDFunc := mockAM.GetAccountByIDFunc + mockAM.GetAccountByIDFunc = func(ctx context.Context, accountID string, userID string) (*types.Account, error) { + account, err := originalGetAccountByIDFunc(ctx, accountID, userID) + if err != nil { + return nil, err + } + account.Settings.RegularUsersViewBlocked = true + return account, nil + } + } recorder := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/peers/%s/accessible-peers", tc.peerID), nil) - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: tc.callerUserID, Domain: "hotmail.com", AccountId: "test_id", @@ -485,7 +536,8 @@ func TestPeersHandlerUpdatePeerIP(t *testing.T) { testPeer := &nbpeer.Peer{ ID: testPeerID, Key: "key", - IP: net.ParseIP("100.64.0.1"), + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), Status: &nbpeer.PeerStatus{Connected: false, LastSeen: time.Now()}, Name: "test-host@netbird.io", LoginExpirationEnabled: false, @@ -527,7 +579,7 @@ func TestPeersHandlerUpdatePeerIP(t *testing.T) { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/peers/%s", tc.peerID), bytes.NewBuffer([]byte(tc.requestBody))) req.Header.Set("Content-Type", "application/json") - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: tc.callerUserID, Domain: "hotmail.com", AccountId: "test_id", diff --git a/management/server/http/handlers/policies/geolocation_handler_test.go b/management/server/http/handlers/policies/geolocation_handler_test.go index cedd5ac88..f5723b8fc 100644 --- a/management/server/http/handlers/policies/geolocation_handler_test.go +++ b/management/server/http/handlers/policies/geolocation_handler_test.go @@ -16,12 +16,13 @@ import ( nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/server/geolocation" - "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/management/server/mock_server" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/util" ) @@ -50,7 +51,7 @@ func initGeolocationTestData(t *testing.T) *geolocationsHandler { permissionsManagerMock. EXPECT(). ValidateUserPermissions(gomock.Any(), gomock.Any(), gomock.Any(), modules.Policies, operations.Read). - Return(true, nil). + Return(true, context.Background(), nil). AnyTimes() return &geolocationsHandler{ @@ -113,7 +114,7 @@ func TestGetCitiesByCountry(t *testing.T) { t.Run(tc.name, func(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest(tc.requestType, tc.requestPath, nil) - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: "test_user", Domain: "hotmail.com", AccountId: "test_id", @@ -206,7 +207,7 @@ func TestGetAllCountries(t *testing.T) { t.Run(tc.name, func(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest(tc.requestType, tc.requestPath, nil) - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: "test_user", Domain: "hotmail.com", AccountId: "test_id", diff --git a/management/server/http/handlers/policies/geolocations_handler.go b/management/server/http/handlers/policies/geolocations_handler.go index cb6995793..eea31ebc6 100644 --- a/management/server/http/handlers/policies/geolocations_handler.go +++ b/management/server/http/handlers/policies/geolocations_handler.go @@ -7,13 +7,10 @@ import ( "github.com/gorilla/mux" "github.com/netbirdio/netbird/management/server/account" - nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/server/geolocation" + "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/http/util" - "github.com/netbirdio/netbird/management/server/permissions" - "github.com/netbirdio/netbird/management/server/permissions/modules" - "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/shared/management/status" ) @@ -45,11 +42,6 @@ func newGeolocationsHandlerHandler(accountManager account.Manager, geolocationMa // getAllCountries retrieves a list of all countries func (l *geolocationsHandler) getAllCountries(w http.ResponseWriter, r *http.Request) { - if err := l.authenticateUser(r); err != nil { - util.WriteError(r.Context(), err, w) - return - } - if l.geolocationManager == nil { // TODO: update error message to include geo db self hosted doc link when ready util.WriteError(r.Context(), status.Errorf(status.PreconditionFailed, "Geo location database is not initialized"), w) @@ -71,11 +63,6 @@ func (l *geolocationsHandler) getAllCountries(w http.ResponseWriter, r *http.Req // getCitiesByCountry retrieves a list of cities based on the given country code func (l *geolocationsHandler) getCitiesByCountry(w http.ResponseWriter, r *http.Request) { - if err := l.authenticateUser(r); err != nil { - util.WriteError(r.Context(), err, w) - return - } - vars := mux.Vars(r) countryCode := vars["country"] if !countryCodeRegex.MatchString(countryCode) { @@ -102,27 +89,6 @@ func (l *geolocationsHandler) getCitiesByCountry(w http.ResponseWriter, r *http. util.WriteJSONObject(r.Context(), w, cities) } -func (l *geolocationsHandler) authenticateUser(r *http.Request) error { - ctx := r.Context() - - userAuth, err := nbcontext.GetUserAuthFromContext(ctx) - if err != nil { - return err - } - - accountID, userID := userAuth.AccountId, userAuth.UserId - - allowed, err := l.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read) - if err != nil { - return status.NewPermissionValidationError(err) - } - - if !allowed { - return status.NewPermissionDeniedError() - } - return nil -} - func toCountryResponse(country geolocation.Country) api.Country { return api.Country{ CountryName: country.CountryName, diff --git a/management/server/http/handlers/policies/policies_handler.go b/management/server/http/handlers/policies/policies_handler.go index 4d6bad5e3..e4d1d73df 100644 --- a/management/server/http/handlers/policies/policies_handler.go +++ b/management/server/http/handlers/policies/policies_handler.go @@ -10,10 +10,10 @@ import ( "github.com/netbirdio/netbird/management/server/account" nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/server/geolocation" + "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/http/util" "github.com/netbirdio/netbird/shared/management/status" - "github.com/netbirdio/netbird/management/server/types" ) // handler is a handler that returns policy of the account @@ -221,6 +221,8 @@ func (h *handler) savePolicy(w http.ResponseWriter, r *http.Request, accountID s pr.Protocol = types.PolicyRuleProtocolUDP case api.PolicyRuleUpdateProtocolIcmp: pr.Protocol = types.PolicyRuleProtocolICMP + case api.PolicyRuleUpdateProtocolNetbirdSsh: + pr.Protocol = types.PolicyRuleProtocolNetbirdSSH default: util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "unknown protocol type: %v", rule.Protocol), w) return @@ -254,6 +256,17 @@ func (h *handler) savePolicy(w http.ResponseWriter, r *http.Request, accountID s } } + if pr.Protocol == types.PolicyRuleProtocolNetbirdSSH && rule.AuthorizedGroups != nil && len(*rule.AuthorizedGroups) != 0 { + for _, sourceGroupID := range pr.Sources { + _, ok := (*rule.AuthorizedGroups)[sourceGroupID] + if !ok { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "authorized group for netbird-ssh protocol should be specified for each source group"), w) + return + } + } + pr.AuthorizedGroups = *rule.AuthorizedGroups + } + // validate policy object if pr.Protocol == types.PolicyRuleProtocolALL || pr.Protocol == types.PolicyRuleProtocolICMP { if len(pr.Ports) != 0 || len(pr.PortRanges) != 0 { @@ -380,6 +393,11 @@ func toPolicyResponse(groups []*types.Group, policy *types.Policy) *api.Policy { DestinationResource: r.DestinationResource.ToAPIResponse(), } + if len(r.AuthorizedGroups) != 0 { + authorizedGroupsCopy := r.AuthorizedGroups + rule.AuthorizedGroups = &authorizedGroupsCopy + } + if len(r.Ports) != 0 { portsCopy := r.Ports rule.Ports = &portsCopy diff --git a/management/server/http/handlers/policies/policies_handler_test.go b/management/server/http/handlers/policies/policies_handler_test.go index fd39ae2a3..ca5a0a6ab 100644 --- a/management/server/http/handlers/policies/policies_handler_test.go +++ b/management/server/http/handlers/policies/policies_handler_test.go @@ -14,10 +14,11 @@ import ( "github.com/stretchr/testify/assert" nbcontext "github.com/netbirdio/netbird/management/server/context" - "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/management/server/mock_server" - "github.com/netbirdio/netbird/shared/management/status" "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/status" ) func initPoliciesTestData(policies ...*types.Policy) *handler { @@ -103,7 +104,7 @@ func TestPoliciesGetPolicy(t *testing.T) { t.Run(tc.name, func(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody) - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: "test_user", Domain: "hotmail.com", AccountId: "test_id", @@ -267,7 +268,7 @@ func TestPoliciesWritePolicy(t *testing.T) { t.Run(tc.name, func(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody) - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: "test_user", Domain: "hotmail.com", AccountId: "test_id", diff --git a/management/server/http/handlers/policies/posture_checks_handler.go b/management/server/http/handlers/policies/posture_checks_handler.go index 3ebc4d1e1..744cde10b 100644 --- a/management/server/http/handlers/policies/posture_checks_handler.go +++ b/management/server/http/handlers/policies/posture_checks_handler.go @@ -9,9 +9,9 @@ import ( "github.com/netbirdio/netbird/management/server/account" nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/server/geolocation" + "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/http/util" - "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/shared/management/status" ) diff --git a/management/server/http/handlers/policies/posture_checks_handler_test.go b/management/server/http/handlers/policies/posture_checks_handler_test.go index c644b533a..a5999f6c7 100644 --- a/management/server/http/handlers/policies/posture_checks_handler_test.go +++ b/management/server/http/handlers/policies/posture_checks_handler_test.go @@ -16,9 +16,10 @@ import ( nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/server/geolocation" - "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/management/server/mock_server" "github.com/netbirdio/netbird/management/server/posture" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/status" ) @@ -45,7 +46,7 @@ func initPostureChecksTestData(postureChecks ...*posture.Checks) *postureChecksH testPostureChecks[postureChecks.ID] = postureChecks if err := postureChecks.Validate(); err != nil { - return nil, status.Errorf(status.InvalidArgument, err.Error()) //nolint + return nil, status.Errorf(status.InvalidArgument, "%v", err) //nolint } return postureChecks, nil @@ -175,7 +176,7 @@ func TestGetPostureCheck(t *testing.T) { t.Run(tc.name, func(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/posture-checks/"+tc.id, tc.requestBody) - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: "test_user", Domain: "hotmail.com", AccountId: "test_id", @@ -828,7 +829,7 @@ func TestPostureCheckUpdate(t *testing.T) { t.Run(tc.name, func(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody) - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: "test_user", Domain: "hotmail.com", AccountId: "test_id", diff --git a/management/server/http/handlers/proxy/auth.go b/management/server/http/handlers/proxy/auth.go new file mode 100644 index 000000000..0120fad0e --- /dev/null +++ b/management/server/http/handlers/proxy/auth.go @@ -0,0 +1,208 @@ +package proxy + +import ( + "context" + "net" + "net/http" + "net/netip" + "net/url" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gorilla/mux" + log "github.com/sirupsen/logrus" + "golang.org/x/oauth2" + + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/server/http/middleware" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/proxy/auth" +) + +// AuthCallbackHandler handles OAuth callbacks for proxy authentication. +type AuthCallbackHandler struct { + proxyService *nbgrpc.ProxyServiceServer + rateLimiter *middleware.APIRateLimiter + trustedProxies []netip.Prefix +} + +// NewAuthCallbackHandler creates a new OAuth callback handler. +func NewAuthCallbackHandler(proxyService *nbgrpc.ProxyServiceServer, trustedProxies []netip.Prefix) *AuthCallbackHandler { + rateLimiterConfig := &middleware.RateLimiterConfig{ + RequestsPerMinute: 10, + Burst: 15, + CleanupInterval: 5 * time.Minute, + LimiterTTL: 10 * time.Minute, + } + + return &AuthCallbackHandler{ + proxyService: proxyService, + rateLimiter: middleware.NewAPIRateLimiter(rateLimiterConfig), + trustedProxies: trustedProxies, + } +} + +// RegisterEndpoints registers the OAuth callback endpoint. +func (h *AuthCallbackHandler) RegisterEndpoints(router *mux.Router) { + router.HandleFunc(types.ProxyCallbackEndpoint, h.handleCallback).Methods(http.MethodGet) +} + +func (h *AuthCallbackHandler) handleCallback(w http.ResponseWriter, r *http.Request) { + clientIP := h.resolveClientIP(r) + if !h.rateLimiter.Allow(clientIP) { + log.WithField("client_ip", clientIP).Warn("OAuth callback rate limit exceeded") + http.Error(w, "Too many requests. Please try again later.", http.StatusTooManyRequests) + return + } + + state := r.URL.Query().Get("state") + + codeVerifier, originalURL, err := h.proxyService.ValidateState(state) + if err != nil { + log.WithError(err).Error("OAuth callback state validation failed") + http.Error(w, "Invalid state parameter", http.StatusBadRequest) + return + } + + redirectURL, err := url.Parse(originalURL) + if err != nil { + log.WithError(err).Error("Failed to parse redirect URL") + http.Error(w, "Invalid redirect URL", http.StatusBadRequest) + return + } + + oidcConfig := h.proxyService.GetOIDCConfig() + + provider, err := oidc.NewProvider(r.Context(), oidcConfig.Issuer) + if err != nil { + log.WithError(err).Error("Failed to create OIDC provider") + http.Error(w, "Failed to create OIDC provider", http.StatusInternalServerError) + return + } + + token, err := (&oauth2.Config{ + ClientID: oidcConfig.ClientID, + Endpoint: provider.Endpoint(), + RedirectURL: oidcConfig.CallbackURL, + }).Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(codeVerifier)) + if err != nil { + log.WithError(err).Error("Failed to exchange code for token") + http.Error(w, "Failed to exchange code for token", http.StatusInternalServerError) + return + } + + userID := extractUserIDFromToken(r.Context(), provider, oidcConfig, token) + if userID == "" { + log.Error("Failed to extract user ID from OIDC token") + http.Error(w, "Failed to validate token", http.StatusUnauthorized) + return + } + + // Group validation is performed by the proxy via ValidateSession gRPC call. + // This allows the proxy to show 403 pages directly without redirect dance. + + sessionToken, err := h.proxyService.GenerateSessionToken(r.Context(), redirectURL.Hostname(), userID, auth.MethodOIDC) + if err != nil { + log.WithError(err).Error("Failed to create session token") + redirectURL.Scheme = "https" + query := redirectURL.Query() + query.Set("error", "access_denied") + query.Set("error_description", "Service configuration error") + redirectURL.RawQuery = query.Encode() + http.Redirect(w, r, redirectURL.String(), http.StatusFound) + return + } + + redirectURL.Scheme = "https" + + query := redirectURL.Query() + query.Set("session_token", sessionToken) + redirectURL.RawQuery = query.Encode() + + log.WithField("redirect", redirectURL.Host).Debug("OAuth callback: redirecting user with session token") + http.Redirect(w, r, redirectURL.String(), http.StatusFound) +} + +func extractUserIDFromToken(ctx context.Context, provider *oidc.Provider, config nbgrpc.ProxyOIDCConfig, token *oauth2.Token) string { + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + log.Warn("No id_token in OIDC response") + return "" + } + + verifier := provider.Verifier(&oidc.Config{ + ClientID: config.ClientID, + }) + + idToken, err := verifier.Verify(ctx, rawIDToken) + if err != nil { + log.WithError(err).Warn("Failed to verify ID token") + return "" + } + + var claims struct { + Subject string `json:"sub"` + } + if err := idToken.Claims(&claims); err != nil { + log.WithError(err).Warn("Failed to extract claims from ID token") + return "" + } + + return claims.Subject +} + +// resolveClientIP extracts the real client IP from the request. +// When trustedProxies is non-empty and the direct peer is trusted, +// it walks X-Forwarded-For right-to-left skipping trusted IPs. +// Otherwise it returns RemoteAddr directly. +func (h *AuthCallbackHandler) resolveClientIP(r *http.Request) string { + remoteIP := extractHost(r.RemoteAddr) + + if len(h.trustedProxies) == 0 || !isTrustedProxy(remoteIP, h.trustedProxies) { + return remoteIP + } + + xff := r.Header.Get("X-Forwarded-For") + if xff == "" { + return remoteIP + } + + parts := strings.Split(xff, ",") + for i := len(parts) - 1; i >= 0; i-- { + ip := strings.TrimSpace(parts[i]) + if ip == "" { + continue + } + if !isTrustedProxy(ip, h.trustedProxies) { + return ip + } + } + + // All IPs in XFF are trusted; return the leftmost as best guess. + if first := strings.TrimSpace(parts[0]); first != "" { + return first + } + return remoteIP +} + +func extractHost(remoteAddr string) string { + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + return remoteAddr + } + return host +} + +func isTrustedProxy(ipStr string, trusted []netip.Prefix) bool { + addr, err := netip.ParseAddr(ipStr) + if err != nil { + return false + } + for _, prefix := range trusted { + if prefix.Contains(addr) { + return true + } + } + return false +} diff --git a/management/server/http/handlers/proxy/auth_callback_integration_test.go b/management/server/http/handlers/proxy/auth_callback_integration_test.go new file mode 100644 index 000000000..a24857066 --- /dev/null +++ b/management/server/http/handlers/proxy/auth_callback_integration_test.go @@ -0,0 +1,575 @@ +//go:build integration + +package proxy + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/gorilla/mux" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + nbproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + nbcache "github.com/netbirdio/netbird/management/server/cache" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/management/server/users" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// fakeOIDCServer creates a minimal OIDC provider for testing. +type fakeOIDCServer struct { + server *httptest.Server + issuer string + signingKey ed25519.PrivateKey + publicKey ed25519.PublicKey + keyID string + tokenSubject string + tokenExpiry time.Duration + failExchange bool +} + +func newFakeOIDCServer() *fakeOIDCServer { + pub, priv, _ := ed25519.GenerateKey(rand.Reader) + f := &fakeOIDCServer{ + signingKey: priv, + publicKey: pub, + keyID: "test-key-1", + tokenExpiry: time.Hour, + } + f.server = httptest.NewServer(f) + f.issuer = f.server.URL + return f +} + +func (f *fakeOIDCServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/openid-configuration": + f.handleDiscovery(w, r) + case "/token": + f.handleToken(w, r) + case "/keys": + f.handleJWKS(w, r) + default: + http.NotFound(w, r) + } +} + +func (f *fakeOIDCServer) handleDiscovery(w http.ResponseWriter, _ *http.Request) { + discovery := map[string]interface{}{ + "issuer": f.issuer, + "authorization_endpoint": f.issuer + "/auth", + "token_endpoint": f.issuer + "/token", + "jwks_uri": f.issuer + "/keys", + "response_types_supported": []string{ + "code", + "id_token", + "token id_token", + }, + "subject_types_supported": []string{"public"}, + "id_token_signing_alg_values_supported": []string{"EdDSA"}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(discovery) +} + +func (f *fakeOIDCServer) handleToken(w http.ResponseWriter, r *http.Request) { + if f.failExchange { + http.Error(w, "invalid_grant", http.StatusBadRequest) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + idToken := f.createIDToken() + + response := map[string]interface{}{ + "access_token": "test-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "id_token": idToken, + "refresh_token": "test-refresh-token", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func (f *fakeOIDCServer) createIDToken() string { + now := time.Now() + claims := jwt.MapClaims{ + "iss": f.issuer, + "sub": f.tokenSubject, + "aud": "test-client-id", + "exp": now.Add(f.tokenExpiry).Unix(), + "iat": now.Unix(), + "nbf": now.Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + token.Header["kid"] = f.keyID + signed, _ := token.SignedString(f.signingKey) + return signed +} + +func (f *fakeOIDCServer) handleJWKS(w http.ResponseWriter, _ *http.Request) { + jwks := map[string]interface{}{ + "keys": []map[string]interface{}{ + { + "kty": "OKP", + "crv": "Ed25519", + "kid": f.keyID, + "x": base64.RawURLEncoding.EncodeToString(f.publicKey), + "use": "sig", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(jwks) +} + +func (f *fakeOIDCServer) Close() { + f.server.Close() +} + +// testSetup contains all test dependencies. +type testSetup struct { + store store.Store + oidcServer *fakeOIDCServer + proxyService *nbgrpc.ProxyServiceServer + handler *AuthCallbackHandler + router *mux.Router + cleanup func() +} + +// testAccessLogManager is a minimal mock for accesslogs.Manager. +type testAccessLogManager struct{} + +func (m *testAccessLogManager) CleanupOldAccessLogs(ctx context.Context, retentionDays int) (int64, error) { + return 0, nil +} + +func (m *testAccessLogManager) StartPeriodicCleanup(ctx context.Context, retentionDays, cleanupIntervalHours int) { + return +} + +func (m *testAccessLogManager) StopPeriodicCleanup() { + return +} + +func (m *testAccessLogManager) SaveAccessLog(_ context.Context, _ *accesslogs.AccessLogEntry) error { + return nil +} + +func (m *testAccessLogManager) GetAllAccessLogs(_ context.Context, _, _ string, _ *accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { + return nil, 0, nil +} + +func setupAuthCallbackTest(t *testing.T) *testSetup { + t.Helper() + + ctx := context.Background() + + testStore, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + + createTestAccountsAndUsers(t, ctx, testStore) + createTestReverseProxies(t, ctx, testStore) + + oidcServer := newFakeOIDCServer() + + cacheStore, err := nbcache.NewStore(ctx, 30*time.Minute, 10*time.Minute, 100) + require.NoError(t, err) + + tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore) + pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, cacheStore) + + usersManager := users.NewManager(testStore) + + oidcConfig := nbgrpc.ProxyOIDCConfig{ + Issuer: oidcServer.issuer, + ClientID: "test-client-id", + Scopes: []string{"openid", "profile", "email"}, + CallbackURL: "https://management.example.com/reverse-proxy/callback", + HMACKey: []byte("test-hmac-key-for-state-signing"), + } + + proxyService := nbgrpc.NewProxyServiceServer( + &testAccessLogManager{}, + tokenStore, + pkceStore, + oidcConfig, + nil, + usersManager, + nil, + nil, + nil, + ) + + proxyService.SetServiceManager(&testServiceManager{store: testStore}) + + handler := NewAuthCallbackHandler(proxyService, nil) + + router := mux.NewRouter() + handler.RegisterEndpoints(router) + + return &testSetup{ + store: testStore, + oidcServer: oidcServer, + proxyService: proxyService, + handler: handler, + router: router, + cleanup: func() { + cleanup() + oidcServer.Close() + }, + } +} + +func createTestReverseProxies(t *testing.T, ctx context.Context, testStore store.Store) { + t.Helper() + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + pubKey := base64.StdEncoding.EncodeToString(pub) + privKey := base64.StdEncoding.EncodeToString(priv) + + testProxy := &service.Service{ + ID: "testProxyId", + AccountID: "testAccountId", + Name: "Test Proxy", + Domain: "test-proxy.example.com", + Targets: []*service.Target{{ + Path: strPtr("/"), + Host: "localhost", + Port: 8080, + Protocol: "http", + TargetId: "peer1", + TargetType: "peer", + Enabled: true, + }}, + Enabled: true, + Auth: service.AuthConfig{ + BearerAuth: &service.BearerAuthConfig{ + Enabled: true, + DistributionGroups: []string{"allowedGroupId"}, + }, + }, + SessionPrivateKey: privKey, + SessionPublicKey: pubKey, + } + require.NoError(t, testStore.CreateService(ctx, testProxy)) + + restrictedProxy := &service.Service{ + ID: "restrictedProxyId", + AccountID: "testAccountId", + Name: "Restricted Proxy", + Domain: "restricted-proxy.example.com", + Targets: []*service.Target{{ + Path: strPtr("/"), + Host: "localhost", + Port: 8080, + Protocol: "http", + TargetId: "peer1", + TargetType: "peer", + Enabled: true, + }}, + Enabled: true, + Auth: service.AuthConfig{ + BearerAuth: &service.BearerAuthConfig{ + Enabled: true, + DistributionGroups: []string{"restrictedGroupId"}, + }, + }, + SessionPrivateKey: privKey, + SessionPublicKey: pubKey, + } + require.NoError(t, testStore.CreateService(ctx, restrictedProxy)) + + noAuthProxy := &service.Service{ + ID: "noAuthProxyId", + AccountID: "testAccountId", + Name: "No Auth Proxy", + Domain: "no-auth-proxy.example.com", + Targets: []*service.Target{{ + Path: strPtr("/"), + Host: "localhost", + Port: 8080, + Protocol: "http", + TargetId: "peer1", + TargetType: "peer", + Enabled: true, + }}, + Enabled: true, + Auth: service.AuthConfig{ + BearerAuth: &service.BearerAuthConfig{ + Enabled: false, + }, + }, + SessionPrivateKey: privKey, + SessionPublicKey: pubKey, + } + require.NoError(t, testStore.CreateService(ctx, noAuthProxy)) +} + +func strPtr(s string) *string { + return &s +} + +func createTestAccountsAndUsers(t *testing.T, ctx context.Context, testStore store.Store) { + t.Helper() + + testAccount := &types.Account{ + Id: "testAccountId", + Domain: "test.com", + DomainCategory: "private", + IsDomainPrimaryAccount: true, + CreatedAt: time.Now(), + } + require.NoError(t, testStore.SaveAccount(ctx, testAccount)) + + allowedGroup := &types.Group{ + ID: "allowedGroupId", + AccountID: "testAccountId", + Name: "Allowed Group", + Issued: "api", + } + require.NoError(t, testStore.CreateGroup(ctx, allowedGroup)) + + allowedUser := &types.User{ + Id: "allowedUserId", + AccountID: "testAccountId", + Role: types.UserRoleUser, + AutoGroups: []string{"allowedGroupId"}, + CreatedAt: time.Now(), + Issued: "api", + } + require.NoError(t, testStore.SaveUser(ctx, allowedUser)) +} + +// testServiceManager is a minimal implementation for testing. +type testServiceManager struct { + store store.Store +} + +func (m *testServiceManager) DeleteAllServices(ctx context.Context, accountID, userID string) error { + return nil +} + +func (m *testServiceManager) GetAllServices(_ context.Context, _, _ string) ([]*service.Service, error) { + return nil, nil +} + +func (m *testServiceManager) GetService(_ context.Context, _, _, _ string) (*service.Service, error) { + return nil, nil +} + +func (m *testServiceManager) CreateService(_ context.Context, _, _ string, _ *service.Service) (*service.Service, error) { + return nil, nil +} + +func (m *testServiceManager) UpdateService(_ context.Context, _, _ string, _ *service.Service) (*service.Service, error) { + return nil, nil +} + +func (m *testServiceManager) DeleteService(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *testServiceManager) DeleteAccountCluster(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *testServiceManager) SetCertificateIssuedAt(_ context.Context, _, _ string) error { + return nil +} + +func (m *testServiceManager) SetStatus(_ context.Context, _, _ string, _ service.Status) error { + return nil +} + +func (m *testServiceManager) ReloadAllServicesForAccount(_ context.Context, _ string) error { + return nil +} + +func (m *testServiceManager) ReloadService(_ context.Context, _, _ string) error { + return nil +} + +func (m *testServiceManager) GetGlobalServices(ctx context.Context) ([]*service.Service, error) { + return m.store.GetServices(ctx, store.LockingStrengthNone) +} + +func (m *testServiceManager) GetServiceByID(ctx context.Context, accountID, proxyID string) (*service.Service, error) { + return m.store.GetServiceByID(ctx, store.LockingStrengthNone, accountID, proxyID) +} + +func (m *testServiceManager) GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error) { + return m.store.GetAccountServices(ctx, store.LockingStrengthNone, accountID) +} + +func (m *testServiceManager) GetServiceIDByTargetID(_ context.Context, _, _ string) (string, error) { + return "", nil +} + +func (m *testServiceManager) CreateServiceFromPeer(_ context.Context, _, _ string, _ *service.ExposeServiceRequest) (*service.ExposeServiceResponse, error) { + return nil, nil +} + +func (m *testServiceManager) RenewServiceFromPeer(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *testServiceManager) StopServiceFromPeer(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *testServiceManager) StartExposeReaper(_ context.Context) {} + +func (m *testServiceManager) GetServiceByDomain(ctx context.Context, domain string) (*service.Service, error) { + return m.store.GetServiceByDomain(ctx, domain) +} + +func (m *testServiceManager) GetClusters(_ context.Context, _, _ string) ([]nbproxy.Cluster, error) { + return nil, nil +} + +func createTestState(t *testing.T, ps *nbgrpc.ProxyServiceServer, redirectURL string) string { + t.Helper() + + resp, err := ps.GetOIDCURL(context.Background(), &proto.GetOIDCURLRequest{ + RedirectUrl: redirectURL, + AccountId: "testAccountId", + }) + require.NoError(t, err) + + parsedURL, err := url.Parse(resp.Url) + require.NoError(t, err) + + return parsedURL.Query().Get("state") +} + +func TestAuthCallback_UserAllowedToLogin(t *testing.T) { + setup := setupAuthCallbackTest(t) + defer setup.cleanup() + + setup.oidcServer.tokenSubject = "allowedUserId" + + state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/dashboard") + + req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state="+url.QueryEscape(state), nil) + rec := httptest.NewRecorder() + + setup.router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusFound, rec.Code) + + location := rec.Header().Get("Location") + require.NotEmpty(t, location) + + parsedLocation, err := url.Parse(location) + require.NoError(t, err) + + require.Equal(t, "test-proxy.example.com", parsedLocation.Host) + require.NotEmpty(t, parsedLocation.Query().Get("session_token"), "Should include session token") + require.Empty(t, parsedLocation.Query().Get("error"), "Should not have error parameter") +} + +func TestAuthCallback_ProxyNotFound(t *testing.T) { + setup := setupAuthCallbackTest(t) + defer setup.cleanup() + + setup.oidcServer.tokenSubject = "allowedUserId" + + state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/") + + require.NoError(t, setup.store.DeleteService(context.Background(), "testAccountId", "testProxyId")) + + req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state="+url.QueryEscape(state), nil) + rec := httptest.NewRecorder() + + setup.router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusFound, rec.Code) + + location := rec.Header().Get("Location") + parsedLocation, err := url.Parse(location) + require.NoError(t, err) + + require.Equal(t, "access_denied", parsedLocation.Query().Get("error")) +} + +func TestAuthCallback_InvalidToken(t *testing.T) { + setup := setupAuthCallbackTest(t) + defer setup.cleanup() + + setup.oidcServer.failExchange = true + + state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/") + + req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=invalid-code&state="+url.QueryEscape(state), nil) + rec := httptest.NewRecorder() + + setup.router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusInternalServerError, rec.Code) + require.Contains(t, rec.Body.String(), "Failed to exchange code") +} + +func TestAuthCallback_ExpiredToken(t *testing.T) { + setup := setupAuthCallbackTest(t) + defer setup.cleanup() + + setup.oidcServer.tokenSubject = "allowedUserId" + setup.oidcServer.tokenExpiry = -time.Hour + + state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/") + + req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state="+url.QueryEscape(state), nil) + rec := httptest.NewRecorder() + + setup.router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusUnauthorized, rec.Code) + require.Contains(t, rec.Body.String(), "Failed to validate token") +} + +func TestAuthCallback_InvalidState(t *testing.T) { + setup := setupAuthCallbackTest(t) + defer setup.cleanup() + + req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state=invalid-state", nil) + rec := httptest.NewRecorder() + + setup.router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "Invalid state") +} + +func TestAuthCallback_MissingState(t *testing.T) { + setup := setupAuthCallbackTest(t) + defer setup.cleanup() + + req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code", nil) + rec := httptest.NewRecorder() + + setup.router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code) +} diff --git a/management/server/http/handlers/proxy/auth_test.go b/management/server/http/handlers/proxy/auth_test.go new file mode 100644 index 000000000..360405474 --- /dev/null +++ b/management/server/http/handlers/proxy/auth_test.go @@ -0,0 +1,185 @@ +package proxy + +import ( + "net/http" + "net/http/httptest" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" +) + +func TestAuthCallbackHandler_RateLimiting(t *testing.T) { + handler := NewAuthCallbackHandler(&nbgrpc.ProxyServiceServer{}, nil) + require.NotNil(t, handler.rateLimiter, "Rate limiter should be initialized") + + req := httptest.NewRequest(http.MethodGet, "/callback?state=test&code=test", nil) + req.RemoteAddr = "192.168.1.100:12345" + + t.Run("allows requests under limit", func(t *testing.T) { + for i := 0; i < 15; i++ { + allowed := handler.rateLimiter.Allow("192.168.1.100") + assert.True(t, allowed, "Request %d should be allowed", i+1) + } + }) + + t.Run("blocks requests over limit", func(t *testing.T) { + handler.rateLimiter.Reset("192.168.1.200") + + for i := 0; i < 15; i++ { + handler.rateLimiter.Allow("192.168.1.200") + } + + allowed := handler.rateLimiter.Allow("192.168.1.200") + assert.False(t, allowed, "Request over limit should be blocked") + }) + + t.Run("different IPs have separate limits", func(t *testing.T) { + ip1 := "192.168.1.201" + ip2 := "192.168.1.202" + + handler.rateLimiter.Reset(ip1) + handler.rateLimiter.Reset(ip2) + + for i := 0; i < 15; i++ { + handler.rateLimiter.Allow(ip1) + } + + assert.False(t, handler.rateLimiter.Allow(ip1), "IP1 should be blocked") + + assert.True(t, handler.rateLimiter.Allow(ip2), "IP2 should be allowed") + }) +} + +func TestAuthCallbackHandler_RateLimitInHandleCallback(t *testing.T) { + handler := NewAuthCallbackHandler(&nbgrpc.ProxyServiceServer{}, nil) + testIP := "10.0.0.50" + + handler.rateLimiter.Reset(testIP) + + t.Run("returns 429 when rate limited", func(t *testing.T) { + for i := 0; i < 15; i++ { + handler.rateLimiter.Allow(testIP) + } + + req := httptest.NewRequest(http.MethodGet, "/callback?state=test&code=test", nil) + req.RemoteAddr = testIP + ":12345" + + rr := httptest.NewRecorder() + handler.handleCallback(rr, req) + + assert.Equal(t, http.StatusTooManyRequests, rr.Code, "Should return 429 status code") + assert.Contains(t, rr.Body.String(), "Too many requests", "Should contain rate limit message") + }) +} + +func TestResolveClientIP(t *testing.T) { + trusted := []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("172.16.0.0/12"), + } + + tests := []struct { + name string + remoteAddr string + xForwardedFor string + trustedProxy []netip.Prefix + expectedIP string + }{ + { + name: "no trusted proxies returns RemoteAddr", + remoteAddr: "203.0.113.50:9999", + xForwardedFor: "1.2.3.4", + trustedProxy: nil, + expectedIP: "203.0.113.50", + }, + { + name: "untrusted RemoteAddr ignores XFF", + remoteAddr: "203.0.113.50:9999", + xForwardedFor: "1.2.3.4, 10.0.0.1", + trustedProxy: trusted, + expectedIP: "203.0.113.50", + }, + { + name: "trusted RemoteAddr with single client in XFF", + remoteAddr: "10.0.0.1:5000", + xForwardedFor: "203.0.113.50", + trustedProxy: trusted, + expectedIP: "203.0.113.50", + }, + { + name: "trusted RemoteAddr walks past trusted entries in XFF", + remoteAddr: "10.0.0.1:5000", + xForwardedFor: "203.0.113.50, 10.0.0.2, 172.16.0.5", + trustedProxy: trusted, + expectedIP: "203.0.113.50", + }, + { + name: "trusted RemoteAddr with empty XFF falls back to RemoteAddr", + remoteAddr: "10.0.0.1:5000", + trustedProxy: trusted, + expectedIP: "10.0.0.1", + }, + { + name: "all XFF IPs trusted returns leftmost", + remoteAddr: "10.0.0.1:5000", + xForwardedFor: "10.0.0.2, 172.16.0.1, 10.0.0.3", + trustedProxy: trusted, + expectedIP: "10.0.0.2", + }, + { + name: "XFF with whitespace", + remoteAddr: "10.0.0.1:5000", + xForwardedFor: " 203.0.113.50 , 10.0.0.2 ", + trustedProxy: trusted, + expectedIP: "203.0.113.50", + }, + { + name: "multi-hop with mixed trust", + remoteAddr: "10.0.0.1:5000", + xForwardedFor: "8.8.8.8, 203.0.113.50, 172.16.0.1", + trustedProxy: trusted, + expectedIP: "203.0.113.50", + }, + { + name: "RemoteAddr without port", + remoteAddr: "192.168.1.100", + expectedIP: "192.168.1.100", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := NewAuthCallbackHandler(&nbgrpc.ProxyServiceServer{}, tt.trustedProxy) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.RemoteAddr = tt.remoteAddr + if tt.xForwardedFor != "" { + req.Header.Set("X-Forwarded-For", tt.xForwardedFor) + } + + ip := handler.resolveClientIP(req) + assert.Equal(t, tt.expectedIP, ip) + }) + } +} + +func TestAuthCallbackHandler_RateLimiterConfiguration(t *testing.T) { + handler := NewAuthCallbackHandler(&nbgrpc.ProxyServiceServer{}, nil) + + require.NotNil(t, handler.rateLimiter, "Rate limiter should be initialized") + + testIP := "192.168.1.250" + handler.rateLimiter.Reset(testIP) + + for i := 0; i < 15; i++ { + allowed := handler.rateLimiter.Allow(testIP) + assert.True(t, allowed, "Should allow request %d within burst limit", i+1) + } + + allowed := handler.rateLimiter.Allow(testIP) + assert.False(t, allowed, "Should block request that exceeds burst limit") +} diff --git a/management/server/http/handlers/routes/routes_handler_test.go b/management/server/http/handlers/routes/routes_handler_test.go index 466a7987f..a44d81e3e 100644 --- a/management/server/http/handlers/routes/routes_handler_test.go +++ b/management/server/http/handlers/routes/routes_handler_test.go @@ -19,6 +19,7 @@ import ( "github.com/netbirdio/netbird/management/server/mock_server" "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/auth" "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/status" @@ -493,7 +494,7 @@ func TestRoutesHandlers(t *testing.T) { t.Run(tc.name, func(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody) - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: "test_user", Domain: "hotmail.com", AccountId: testAccountID, diff --git a/management/server/http/handlers/setup_keys/setupkeys_handler.go b/management/server/http/handlers/setup_keys/setupkeys_handler.go index 2287dadfe..d267b6eea 100644 --- a/management/server/http/handlers/setup_keys/setupkeys_handler.go +++ b/management/server/http/handlers/setup_keys/setupkeys_handler.go @@ -10,10 +10,10 @@ import ( "github.com/netbirdio/netbird/management/server/account" nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/http/util" "github.com/netbirdio/netbird/shared/management/status" - "github.com/netbirdio/netbird/management/server/types" ) // handler is a handler that returns a list of setup keys of the account diff --git a/management/server/http/handlers/setup_keys/setupkeys_handler_test.go b/management/server/http/handlers/setup_keys/setupkeys_handler_test.go index 7b46b486b..b137b6dd1 100644 --- a/management/server/http/handlers/setup_keys/setupkeys_handler_test.go +++ b/management/server/http/handlers/setup_keys/setupkeys_handler_test.go @@ -15,10 +15,11 @@ import ( "github.com/stretchr/testify/assert" nbcontext "github.com/netbirdio/netbird/management/server/context" - "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/management/server/mock_server" - "github.com/netbirdio/netbird/shared/management/status" "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/status" ) const ( @@ -163,7 +164,7 @@ func TestSetupKeysHandlers(t *testing.T) { t.Run(tc.name, func(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody) - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: adminUser.Id, Domain: "hotmail.com", AccountId: "testAccountId", diff --git a/management/server/http/handlers/users/invites_handler.go b/management/server/http/handlers/users/invites_handler.go new file mode 100644 index 000000000..0f0f57c29 --- /dev/null +++ b/management/server/http/handlers/users/invites_handler.go @@ -0,0 +1,263 @@ +package users + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "time" + + "github.com/gorilla/mux" + + "github.com/netbirdio/netbird/management/server/account" + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/management/server/http/middleware" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/shared/management/status" +) + +// publicInviteRateLimiter limits public invite requests by IP address to prevent brute-force attacks +var publicInviteRateLimiter = middleware.NewAPIRateLimiter(&middleware.RateLimiterConfig{ + RequestsPerMinute: 10, // 10 attempts per minute per IP + Burst: 5, // Allow burst of 5 requests + CleanupInterval: 10 * time.Minute, + LimiterTTL: 30 * time.Minute, +}) + +// toUserInviteResponse converts a UserInvite to an API response. +func toUserInviteResponse(invite *types.UserInvite) api.UserInvite { + autoGroups := invite.UserInfo.AutoGroups + if autoGroups == nil { + autoGroups = []string{} + } + var inviteLink *string + if invite.InviteToken != "" { + inviteLink = &invite.InviteToken + } + return api.UserInvite{ + Id: invite.UserInfo.ID, + Email: invite.UserInfo.Email, + Name: invite.UserInfo.Name, + Role: invite.UserInfo.Role, + AutoGroups: autoGroups, + ExpiresAt: invite.InviteExpiresAt.UTC(), + CreatedAt: invite.InviteCreatedAt.UTC(), + Expired: time.Now().After(invite.InviteExpiresAt), + InviteToken: inviteLink, + } +} + +// invitesHandler handles user invite operations +type invitesHandler struct { + accountManager account.Manager +} + +// AddInvitesEndpoints registers invite-related endpoints +func AddInvitesEndpoints(accountManager account.Manager, router *mux.Router) { + h := &invitesHandler{accountManager: accountManager} + + // Authenticated endpoints (require admin) + router.HandleFunc("/users/invites", h.listInvites).Methods("GET", "OPTIONS") + router.HandleFunc("/users/invites", h.createInvite).Methods("POST", "OPTIONS") + router.HandleFunc("/users/invites/{inviteId}", h.deleteInvite).Methods("DELETE", "OPTIONS") + router.HandleFunc("/users/invites/{inviteId}/regenerate", h.regenerateInvite).Methods("POST", "OPTIONS") +} + +// AddPublicInvitesEndpoints registers public (unauthenticated) invite endpoints with rate limiting +func AddPublicInvitesEndpoints(accountManager account.Manager, router *mux.Router) { + h := &invitesHandler{accountManager: accountManager} + + // Create a subrouter for public invite endpoints with rate limiting middleware + publicRouter := router.PathPrefix("/users/invites").Subrouter() + publicRouter.Use(publicInviteRateLimiter.Middleware) + + // Public endpoints (no auth required, protected by token and rate limited) + publicRouter.HandleFunc("/{token}", h.getInviteInfo).Methods("GET", "OPTIONS") + publicRouter.HandleFunc("/{token}/accept", h.acceptInvite).Methods("POST", "OPTIONS") +} + +// listInvites handles GET /api/users/invites +func (h *invitesHandler) listInvites(w http.ResponseWriter, r *http.Request) { + + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + invites, err := h.accountManager.ListUserInvites(r.Context(), userAuth.AccountId, userAuth.UserId) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + resp := make([]api.UserInvite, 0, len(invites)) + for _, invite := range invites { + resp = append(resp, toUserInviteResponse(invite)) + } + + util.WriteJSONObject(r.Context(), w, resp) +} + +// createInvite handles POST /api/users/invites +func (h *invitesHandler) createInvite(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.UserInviteCreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + invite := &types.UserInfo{ + Email: req.Email, + Name: req.Name, + Role: req.Role, + AutoGroups: req.AutoGroups, + } + + expiresIn := 0 + if req.ExpiresIn != nil { + expiresIn = *req.ExpiresIn + } + + result, err := h.accountManager.CreateUserInvite(r.Context(), userAuth.AccountId, userAuth.UserId, invite, expiresIn) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + result.InviteCreatedAt = time.Now().UTC() + resp := toUserInviteResponse(result) + util.WriteJSONObject(r.Context(), w, &resp) +} + +// getInviteInfo handles GET /api/users/invites/{token} +func (h *invitesHandler) getInviteInfo(w http.ResponseWriter, r *http.Request) { + + vars := mux.Vars(r) + token := vars["token"] + if token == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "token is required"), w) + return + } + + info, err := h.accountManager.GetUserInviteInfo(r.Context(), token) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + expiresAt := info.ExpiresAt.UTC() + util.WriteJSONObject(r.Context(), w, &api.UserInviteInfo{ + Email: info.Email, + Name: info.Name, + ExpiresAt: expiresAt, + Valid: info.Valid, + InvitedBy: info.InvitedBy, + }) +} + +// acceptInvite handles POST /api/users/invites/{token}/accept +func (h *invitesHandler) acceptInvite(w http.ResponseWriter, r *http.Request) { + + vars := mux.Vars(r) + token := vars["token"] + if token == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "token is required"), w) + return + } + + var req api.UserInviteAcceptRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + err := h.accountManager.AcceptUserInvite(r.Context(), token, req.Password) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, &api.UserInviteAcceptResponse{Success: true}) +} + +// regenerateInvite handles POST /api/users/invites/{inviteId}/regenerate +func (h *invitesHandler) regenerateInvite(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) + return + } + + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + vars := mux.Vars(r) + inviteID := vars["inviteId"] + if inviteID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invite ID is required"), w) + return + } + + var req api.UserInviteRegenerateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + // Allow empty body (io.EOF) - expiresIn is optional + if !errors.Is(err, io.EOF) { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + } + + expiresIn := 0 + if req.ExpiresIn != nil { + expiresIn = *req.ExpiresIn + } + + result, err := h.accountManager.RegenerateUserInvite(r.Context(), userAuth.AccountId, userAuth.UserId, inviteID, expiresIn) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + expiresAt := result.InviteExpiresAt.UTC() + util.WriteJSONObject(r.Context(), w, &api.UserInviteRegenerateResponse{ + InviteToken: result.InviteToken, + InviteExpiresAt: expiresAt, + }) +} + +// deleteInvite handles DELETE /api/users/invites/{inviteId} +func (h *invitesHandler) deleteInvite(w http.ResponseWriter, r *http.Request) { + + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + vars := mux.Vars(r) + inviteID := vars["inviteId"] + if inviteID == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invite ID is required"), w) + return + } + + err = h.accountManager.DeleteUserInvite(r.Context(), userAuth.AccountId, userAuth.UserId, inviteID) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} diff --git a/management/server/http/handlers/users/invites_handler_test.go b/management/server/http/handlers/users/invites_handler_test.go new file mode 100644 index 000000000..529ea24d6 --- /dev/null +++ b/management/server/http/handlers/users/invites_handler_test.go @@ -0,0 +1,659 @@ +package users + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/management/server/mock_server" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/status" +) + +const ( + testAccountID = "test-account-id" + testUserID = "test-user-id" + testInviteID = "test-invite-id" + testInviteToken = "nbi_testtoken123456789012345678" + testEmail = "invite@example.com" + testName = "Test User" +) + +func setupInvitesTestHandler(am *mock_server.MockAccountManager) *invitesHandler { + return &invitesHandler{ + accountManager: am, + } +} + +func TestListInvites(t *testing.T) { + now := time.Now().UTC() + testInvites := []*types.UserInvite{ + { + UserInfo: &types.UserInfo{ + ID: "invite-1", + Email: "user1@example.com", + Name: "User One", + Role: "user", + AutoGroups: []string{"group-1"}, + }, + InviteExpiresAt: now.Add(24 * time.Hour), + InviteCreatedAt: now, + }, + { + UserInfo: &types.UserInfo{ + ID: "invite-2", + Email: "user2@example.com", + Name: "User Two", + Role: "admin", + AutoGroups: nil, + }, + InviteExpiresAt: now.Add(-1 * time.Hour), // Expired + InviteCreatedAt: now.Add(-48 * time.Hour), + }, + } + + tt := []struct { + name string + expectedStatus int + mockFunc func(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error) + expectedCount int + }{ + { + name: "successful list", + expectedStatus: http.StatusOK, + mockFunc: func(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error) { + return testInvites, nil + }, + expectedCount: 2, + }, + { + name: "empty list", + expectedStatus: http.StatusOK, + mockFunc: func(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error) { + return []*types.UserInvite{}, nil + }, + expectedCount: 0, + }, + { + name: "permission denied", + expectedStatus: http.StatusForbidden, + mockFunc: func(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error) { + return nil, status.NewPermissionDeniedError() + }, + expectedCount: 0, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + am := &mock_server.MockAccountManager{ + ListUserInvitesFunc: tc.mockFunc, + } + handler := setupInvitesTestHandler(am) + + req := httptest.NewRequest(http.MethodGet, "/api/users/invites", nil) + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ + UserId: testUserID, + AccountId: testAccountID, + }) + + rr := httptest.NewRecorder() + handler.listInvites(rr, req) + + assert.Equal(t, tc.expectedStatus, rr.Code) + + if tc.expectedStatus == http.StatusOK { + var resp []api.UserInvite + err := json.NewDecoder(rr.Body).Decode(&resp) + require.NoError(t, err) + assert.Len(t, resp, tc.expectedCount) + } + }) + } +} + +func TestCreateInvite(t *testing.T) { + now := time.Now().UTC() + expiresAt := now.Add(72 * time.Hour) + + tt := []struct { + name string + requestBody string + expectedStatus int + mockFunc func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) + }{ + { + name: "successful create", + requestBody: `{"email":"test@example.com","name":"Test User","role":"user","auto_groups":["group-1"]}`, + expectedStatus: http.StatusOK, + mockFunc: func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) { + return &types.UserInvite{ + UserInfo: &types.UserInfo{ + ID: testInviteID, + Email: invite.Email, + Name: invite.Name, + Role: invite.Role, + AutoGroups: invite.AutoGroups, + Status: string(types.UserStatusInvited), + }, + InviteToken: testInviteToken, + InviteExpiresAt: expiresAt, + }, nil + }, + }, + { + name: "successful create with custom expiration", + requestBody: `{"email":"test@example.com","name":"Test User","role":"admin","auto_groups":[],"expires_in":3600}`, + expectedStatus: http.StatusOK, + mockFunc: func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) { + assert.Equal(t, 3600, expiresIn) + return &types.UserInvite{ + UserInfo: &types.UserInfo{ + ID: testInviteID, + Email: invite.Email, + Name: invite.Name, + Role: invite.Role, + AutoGroups: []string{}, + Status: string(types.UserStatusInvited), + }, + InviteToken: testInviteToken, + InviteExpiresAt: expiresAt, + }, nil + }, + }, + { + name: "user already exists", + requestBody: `{"email":"existing@example.com","name":"Existing User","role":"user","auto_groups":[]}`, + expectedStatus: http.StatusConflict, + mockFunc: func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) { + return nil, status.Errorf(status.UserAlreadyExists, "user with this email already exists") + }, + }, + { + name: "invite already exists", + requestBody: `{"email":"invited@example.com","name":"Invited User","role":"user","auto_groups":[]}`, + expectedStatus: http.StatusConflict, + mockFunc: func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) { + return nil, status.Errorf(status.AlreadyExists, "invite already exists for this email") + }, + }, + { + name: "permission denied", + requestBody: `{"email":"test@example.com","name":"Test User","role":"user","auto_groups":[]}`, + expectedStatus: http.StatusForbidden, + mockFunc: func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) { + return nil, status.NewPermissionDeniedError() + }, + }, + { + name: "embedded IDP not enabled", + requestBody: `{"email":"test@example.com","name":"Test User","role":"user","auto_groups":[]}`, + expectedStatus: http.StatusPreconditionFailed, + mockFunc: func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) { + return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider") + }, + }, + { + name: "local auth disabled", + requestBody: `{"email":"test@example.com","name":"Test User","role":"user","auto_groups":[]}`, + expectedStatus: http.StatusPreconditionFailed, + mockFunc: func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) { + return nil, status.Errorf(status.PreconditionFailed, "local user creation is disabled - use an external identity provider") + }, + }, + { + name: "invalid JSON", + requestBody: `{invalid json}`, + expectedStatus: http.StatusBadRequest, + mockFunc: nil, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + am := &mock_server.MockAccountManager{ + CreateUserInviteFunc: tc.mockFunc, + } + handler := setupInvitesTestHandler(am) + + req := httptest.NewRequest(http.MethodPost, "/api/users/invites", bytes.NewBufferString(tc.requestBody)) + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ + UserId: testUserID, + AccountId: testAccountID, + }) + + rr := httptest.NewRecorder() + handler.createInvite(rr, req) + + assert.Equal(t, tc.expectedStatus, rr.Code) + + if tc.expectedStatus == http.StatusOK { + var resp api.UserInvite + err := json.NewDecoder(rr.Body).Decode(&resp) + require.NoError(t, err) + assert.Equal(t, testInviteID, resp.Id) + assert.NotNil(t, resp.InviteToken) + assert.NotEmpty(t, *resp.InviteToken) + } + }) + } +} + +func TestGetInviteInfo(t *testing.T) { + now := time.Now().UTC() + + tt := []struct { + name string + token string + expectedStatus int + mockFunc func(ctx context.Context, token string) (*types.UserInviteInfo, error) + }{ + { + name: "successful get valid invite", + token: testInviteToken, + expectedStatus: http.StatusOK, + mockFunc: func(ctx context.Context, token string) (*types.UserInviteInfo, error) { + return &types.UserInviteInfo{ + Email: testEmail, + Name: testName, + ExpiresAt: now.Add(24 * time.Hour), + Valid: true, + InvitedBy: "Admin User", + }, nil + }, + }, + { + name: "successful get expired invite", + token: testInviteToken, + expectedStatus: http.StatusOK, + mockFunc: func(ctx context.Context, token string) (*types.UserInviteInfo, error) { + return &types.UserInviteInfo{ + Email: testEmail, + Name: testName, + ExpiresAt: now.Add(-24 * time.Hour), + Valid: false, + InvitedBy: "Admin User", + }, nil + }, + }, + { + name: "invite not found", + token: "nbi_invalidtoken1234567890123456", + expectedStatus: http.StatusNotFound, + mockFunc: func(ctx context.Context, token string) (*types.UserInviteInfo, error) { + return nil, status.Errorf(status.NotFound, "invite not found") + }, + }, + { + name: "invalid token format", + token: "invalid", + expectedStatus: http.StatusUnprocessableEntity, + mockFunc: func(ctx context.Context, token string) (*types.UserInviteInfo, error) { + return nil, status.Errorf(status.InvalidArgument, "invalid invite token") + }, + }, + { + name: "missing token", + token: "", + expectedStatus: http.StatusUnprocessableEntity, + mockFunc: nil, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + am := &mock_server.MockAccountManager{ + GetUserInviteInfoFunc: tc.mockFunc, + } + handler := setupInvitesTestHandler(am) + + req := httptest.NewRequest(http.MethodGet, "/api/users/invites/"+tc.token, nil) + if tc.token != "" { + req = mux.SetURLVars(req, map[string]string{"token": tc.token}) + } + + rr := httptest.NewRecorder() + handler.getInviteInfo(rr, req) + + assert.Equal(t, tc.expectedStatus, rr.Code) + + if tc.expectedStatus == http.StatusOK { + var resp api.UserInviteInfo + err := json.NewDecoder(rr.Body).Decode(&resp) + require.NoError(t, err) + assert.Equal(t, testEmail, resp.Email) + assert.Equal(t, testName, resp.Name) + } + }) + } +} + +func TestAcceptInvite(t *testing.T) { + tt := []struct { + name string + token string + requestBody string + expectedStatus int + mockFunc func(ctx context.Context, token, password string) error + }{ + { + name: "successful accept", + token: testInviteToken, + requestBody: `{"password":"SecurePass123!"}`, + expectedStatus: http.StatusOK, + mockFunc: func(ctx context.Context, token, password string) error { + return nil + }, + }, + { + name: "invite not found", + token: "nbi_invalidtoken1234567890123456", + requestBody: `{"password":"SecurePass123!"}`, + expectedStatus: http.StatusNotFound, + mockFunc: func(ctx context.Context, token, password string) error { + return status.Errorf(status.NotFound, "invite not found") + }, + }, + { + name: "invite expired", + token: testInviteToken, + requestBody: `{"password":"SecurePass123!"}`, + expectedStatus: http.StatusUnprocessableEntity, + mockFunc: func(ctx context.Context, token, password string) error { + return status.Errorf(status.InvalidArgument, "invite has expired") + }, + }, + { + name: "embedded IDP not enabled", + token: testInviteToken, + requestBody: `{"password":"SecurePass123!"}`, + expectedStatus: http.StatusPreconditionFailed, + mockFunc: func(ctx context.Context, token, password string) error { + return status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider") + }, + }, + { + name: "local auth disabled", + token: testInviteToken, + requestBody: `{"password":"SecurePass123!"}`, + expectedStatus: http.StatusPreconditionFailed, + mockFunc: func(ctx context.Context, token, password string) error { + return status.Errorf(status.PreconditionFailed, "local user creation is disabled - use an external identity provider") + }, + }, + { + name: "missing token", + token: "", + requestBody: `{"password":"SecurePass123!"}`, + expectedStatus: http.StatusUnprocessableEntity, + mockFunc: nil, + }, + { + name: "invalid JSON", + token: testInviteToken, + requestBody: `{invalid}`, + expectedStatus: http.StatusBadRequest, + mockFunc: nil, + }, + { + name: "password too short", + token: testInviteToken, + requestBody: `{"password":"Short1!"}`, + expectedStatus: http.StatusUnprocessableEntity, + mockFunc: func(ctx context.Context, token, password string) error { + return status.Errorf(status.InvalidArgument, "password must be at least 8 characters long") + }, + }, + { + name: "password missing digit", + token: testInviteToken, + requestBody: `{"password":"NoDigitPass!"}`, + expectedStatus: http.StatusUnprocessableEntity, + mockFunc: func(ctx context.Context, token, password string) error { + return status.Errorf(status.InvalidArgument, "password must contain at least one digit") + }, + }, + { + name: "password missing uppercase", + token: testInviteToken, + requestBody: `{"password":"nouppercase1!"}`, + expectedStatus: http.StatusUnprocessableEntity, + mockFunc: func(ctx context.Context, token, password string) error { + return status.Errorf(status.InvalidArgument, "password must contain at least one uppercase letter") + }, + }, + { + name: "password missing special character", + token: testInviteToken, + requestBody: `{"password":"NoSpecial123"}`, + expectedStatus: http.StatusUnprocessableEntity, + mockFunc: func(ctx context.Context, token, password string) error { + return status.Errorf(status.InvalidArgument, "password must contain at least one special character") + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + am := &mock_server.MockAccountManager{ + AcceptUserInviteFunc: tc.mockFunc, + } + handler := setupInvitesTestHandler(am) + + req := httptest.NewRequest(http.MethodPost, "/api/users/invites/"+tc.token+"/accept", bytes.NewBufferString(tc.requestBody)) + if tc.token != "" { + req = mux.SetURLVars(req, map[string]string{"token": tc.token}) + } + + rr := httptest.NewRecorder() + handler.acceptInvite(rr, req) + + assert.Equal(t, tc.expectedStatus, rr.Code) + + if tc.expectedStatus == http.StatusOK { + var resp api.UserInviteAcceptResponse + err := json.NewDecoder(rr.Body).Decode(&resp) + require.NoError(t, err) + assert.True(t, resp.Success) + } + }) + } +} + +func TestRegenerateInvite(t *testing.T) { + now := time.Now().UTC() + expiresAt := now.Add(72 * time.Hour) + + tt := []struct { + name string + inviteID string + requestBody string + expectedStatus int + mockFunc func(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error) + }{ + { + name: "successful regenerate with empty body", + inviteID: testInviteID, + requestBody: "", + expectedStatus: http.StatusOK, + mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error) { + assert.Equal(t, 0, expiresIn) + return &types.UserInvite{ + UserInfo: &types.UserInfo{ + ID: inviteID, + Email: testEmail, + }, + InviteToken: "nbi_newtoken12345678901234567890", + InviteExpiresAt: expiresAt, + }, nil + }, + }, + { + name: "successful regenerate with custom expiration", + inviteID: testInviteID, + requestBody: `{"expires_in":7200}`, + expectedStatus: http.StatusOK, + mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error) { + assert.Equal(t, 7200, expiresIn) + return &types.UserInvite{ + UserInfo: &types.UserInfo{ + ID: inviteID, + Email: testEmail, + }, + InviteToken: "nbi_newtoken12345678901234567890", + InviteExpiresAt: expiresAt, + }, nil + }, + }, + { + name: "invite not found", + inviteID: "non-existent-invite", + requestBody: "", + expectedStatus: http.StatusNotFound, + mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error) { + return nil, status.Errorf(status.NotFound, "invite not found") + }, + }, + { + name: "permission denied", + inviteID: testInviteID, + requestBody: "", + expectedStatus: http.StatusForbidden, + mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error) { + return nil, status.NewPermissionDeniedError() + }, + }, + { + name: "missing invite ID", + inviteID: "", + requestBody: "", + expectedStatus: http.StatusUnprocessableEntity, + mockFunc: nil, + }, + { + name: "invalid JSON should return error", + inviteID: testInviteID, + requestBody: `{invalid json}`, + expectedStatus: http.StatusBadRequest, + mockFunc: nil, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + am := &mock_server.MockAccountManager{ + RegenerateUserInviteFunc: tc.mockFunc, + } + handler := setupInvitesTestHandler(am) + + var body io.Reader + if tc.requestBody != "" { + body = bytes.NewBufferString(tc.requestBody) + } + + req := httptest.NewRequest(http.MethodPost, "/api/users/invites/"+tc.inviteID+"/regenerate", body) + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ + UserId: testUserID, + AccountId: testAccountID, + }) + if tc.inviteID != "" { + req = mux.SetURLVars(req, map[string]string{"inviteId": tc.inviteID}) + } + + rr := httptest.NewRecorder() + handler.regenerateInvite(rr, req) + + assert.Equal(t, tc.expectedStatus, rr.Code) + + if tc.expectedStatus == http.StatusOK { + var resp api.UserInviteRegenerateResponse + err := json.NewDecoder(rr.Body).Decode(&resp) + require.NoError(t, err) + assert.NotEmpty(t, resp.InviteToken) + } + }) + } +} + +func TestDeleteInvite(t *testing.T) { + tt := []struct { + name string + inviteID string + expectedStatus int + mockFunc func(ctx context.Context, accountID, initiatorUserID, inviteID string) error + }{ + { + name: "successful delete", + inviteID: testInviteID, + expectedStatus: http.StatusOK, + mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string) error { + return nil + }, + }, + { + name: "invite not found", + inviteID: "non-existent-invite", + expectedStatus: http.StatusNotFound, + mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string) error { + return status.Errorf(status.NotFound, "invite not found") + }, + }, + { + name: "permission denied", + inviteID: testInviteID, + expectedStatus: http.StatusForbidden, + mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string) error { + return status.NewPermissionDeniedError() + }, + }, + { + name: "embedded IDP not enabled", + inviteID: testInviteID, + expectedStatus: http.StatusPreconditionFailed, + mockFunc: func(ctx context.Context, accountID, initiatorUserID, inviteID string) error { + return status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider") + }, + }, + { + name: "missing invite ID", + inviteID: "", + expectedStatus: http.StatusUnprocessableEntity, + mockFunc: nil, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + am := &mock_server.MockAccountManager{ + DeleteUserInviteFunc: tc.mockFunc, + } + handler := setupInvitesTestHandler(am) + + req := httptest.NewRequest(http.MethodDelete, "/api/users/invites/"+tc.inviteID, nil) + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ + UserId: testUserID, + AccountId: testAccountID, + }) + if tc.inviteID != "" { + req = mux.SetURLVars(req, map[string]string{"inviteId": tc.inviteID}) + } + + rr := httptest.NewRecorder() + handler.deleteInvite(rr, req) + + assert.Equal(t, tc.expectedStatus, rr.Code) + }) + } +} diff --git a/management/server/http/handlers/users/pat_handler.go b/management/server/http/handlers/users/pat_handler.go index bae07af4a..867db3ca9 100644 --- a/management/server/http/handlers/users/pat_handler.go +++ b/management/server/http/handlers/users/pat_handler.go @@ -8,10 +8,10 @@ import ( "github.com/netbirdio/netbird/management/server/account" nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/http/util" "github.com/netbirdio/netbird/shared/management/status" - "github.com/netbirdio/netbird/management/server/types" ) // patHandler is the nameserver group handler of the account diff --git a/management/server/http/handlers/users/pat_handler_test.go b/management/server/http/handlers/users/pat_handler_test.go index 92544c56d..7cda14468 100644 --- a/management/server/http/handlers/users/pat_handler_test.go +++ b/management/server/http/handlers/users/pat_handler_test.go @@ -17,10 +17,11 @@ import ( "github.com/netbirdio/netbird/management/server/util" nbcontext "github.com/netbirdio/netbird/management/server/context" - "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/management/server/mock_server" - "github.com/netbirdio/netbird/shared/management/status" "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/status" ) const ( @@ -173,7 +174,7 @@ func TestTokenHandlers(t *testing.T) { t.Run(tc.name, func(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody) - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: existingUserID, Domain: testDomain, AccountId: existingAccountID, diff --git a/management/server/http/handlers/users/users_handler.go b/management/server/http/handlers/users/users_handler.go index 4e03e5e9b..179d433f1 100644 --- a/management/server/http/handlers/users/users_handler.go +++ b/management/server/http/handlers/users/users_handler.go @@ -33,6 +33,7 @@ func AddEndpoints(accountManager account.Manager, router *mux.Router) { router.HandleFunc("/users/{userId}/invite", userHandler.inviteUser).Methods("POST", "OPTIONS") router.HandleFunc("/users/{userId}/approve", userHandler.approveUser).Methods("POST", "OPTIONS") router.HandleFunc("/users/{userId}/reject", userHandler.rejectUser).Methods("DELETE", "OPTIONS") + router.HandleFunc("/users/{userId}/password", userHandler.changePassword).Methods("PUT", "OPTIONS") addUsersTokensEndpoint(accountManager, router) } @@ -219,7 +220,7 @@ func (h *handler) getAllUsers(w http.ResponseWriter, r *http.Request) { } includeServiceUser, err := strconv.ParseBool(serviceUser) - log.WithContext(r.Context()).Debugf("Should include service user: %v", includeServiceUser) + log.WithContext(r.Context()).Tracef("Should include service user: %v", includeServiceUser) if err != nil { util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invalid service_user query parameter"), w) return @@ -326,6 +327,16 @@ func toUserResponse(user *types.UserInfo, currenUserID string) *api.User { isCurrent := user.ID == currenUserID + var password *string + if user.Password != "" { + password = &user.Password + } + + var idpID *string + if user.IdPID != "" { + idpID = &user.IdPID + } + return &api.User{ Id: user.ID, Name: user.Name, @@ -339,6 +350,8 @@ func toUserResponse(user *types.UserInfo, currenUserID string) *api.User { LastLogin: &user.LastLogin, Issued: &user.Issued, PendingApproval: user.PendingApproval, + Password: password, + IdpId: idpID, } } @@ -398,3 +411,46 @@ func (h *handler) rejectUser(w http.ResponseWriter, r *http.Request) { util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) } + +// passwordChangeRequest represents the request body for password change +type passwordChangeRequest struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` +} + +// changePassword is a PUT request to change user's password. +// Only available when embedded IDP is enabled. +// Users can only change their own password. +func (h *handler) changePassword(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w) + return + } + + vars := mux.Vars(r) + targetUserID := vars["userId"] + if len(targetUserID) == 0 { + util.WriteErrorResponse("invalid user ID", http.StatusBadRequest, w) + return + } + + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var req passwordChangeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w) + return + } + + err = h.accountManager.UpdateUserPassword(r.Context(), userAuth.AccountId, userAuth.UserId, targetUserID, req.OldPassword, req.NewPassword) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} diff --git a/management/server/http/handlers/users/users_handler_test.go b/management/server/http/handlers/users/users_handler_test.go index e08004218..aa77dd843 100644 --- a/management/server/http/handlers/users/users_handler_test.go +++ b/management/server/http/handlers/users/users_handler_test.go @@ -21,6 +21,7 @@ import ( "github.com/netbirdio/netbird/management/server/permissions/roles" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/management/server/users" + "github.com/netbirdio/netbird/shared/auth" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/status" ) @@ -128,7 +129,7 @@ func initUsersTestData() *handler { return nil }, - GetCurrentUserInfoFunc: func(ctx context.Context, userAuth nbcontext.UserAuth) (*users.UserInfoWithPermissions, error) { + GetCurrentUserInfoFunc: func(ctx context.Context, userAuth auth.UserAuth) (*users.UserInfoWithPermissions, error) { switch userAuth.UserId { case "not-found": return nil, status.NewUserNotFoundError("not-found") @@ -225,7 +226,7 @@ func TestGetUsers(t *testing.T) { t.Run(tc.name, func(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest(tc.requestType, tc.requestPath, nil) - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: existingUserID, Domain: testDomain, AccountId: existingAccountID, @@ -335,7 +336,7 @@ func TestUpdateUser(t *testing.T) { t.Run(tc.name, func(t *testing.T) { recorder := httptest.NewRecorder() req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody) - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: existingUserID, Domain: testDomain, AccountId: existingAccountID, @@ -432,7 +433,7 @@ func TestCreateUser(t *testing.T) { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody) rr := httptest.NewRecorder() - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: existingUserID, Domain: testDomain, AccountId: existingAccountID, @@ -481,7 +482,7 @@ func TestInviteUser(t *testing.T) { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(tc.requestType, tc.requestPath, nil) req = mux.SetURLVars(req, tc.requestVars) - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: existingUserID, Domain: testDomain, AccountId: existingAccountID, @@ -540,7 +541,7 @@ func TestDeleteUser(t *testing.T) { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(tc.requestType, tc.requestPath, nil) req = mux.SetURLVars(req, tc.requestVars) - req = nbcontext.SetUserAuthInRequest(req, nbcontext.UserAuth{ + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{ UserId: existingUserID, Domain: testDomain, AccountId: existingAccountID, @@ -565,7 +566,7 @@ func TestCurrentUser(t *testing.T) { tt := []struct { name string expectedStatus int - requestAuth nbcontext.UserAuth + requestAuth auth.UserAuth expectedResult *api.User }{ { @@ -574,27 +575,27 @@ func TestCurrentUser(t *testing.T) { }, { name: "user not found", - requestAuth: nbcontext.UserAuth{UserId: "not-found"}, + requestAuth: auth.UserAuth{UserId: "not-found"}, expectedStatus: http.StatusNotFound, }, { name: "not of account", - requestAuth: nbcontext.UserAuth{UserId: "not-of-account"}, + requestAuth: auth.UserAuth{UserId: "not-of-account"}, expectedStatus: http.StatusForbidden, }, { name: "blocked user", - requestAuth: nbcontext.UserAuth{UserId: "blocked-user"}, + requestAuth: auth.UserAuth{UserId: "blocked-user"}, expectedStatus: http.StatusForbidden, }, { name: "service user", - requestAuth: nbcontext.UserAuth{UserId: "service-user"}, + requestAuth: auth.UserAuth{UserId: "service-user"}, expectedStatus: http.StatusForbidden, }, { name: "owner", - requestAuth: nbcontext.UserAuth{UserId: "owner"}, + requestAuth: auth.UserAuth{UserId: "owner"}, expectedStatus: http.StatusOK, expectedResult: &api.User{ Id: "owner", @@ -613,7 +614,7 @@ func TestCurrentUser(t *testing.T) { }, { name: "regular user", - requestAuth: nbcontext.UserAuth{UserId: "regular-user"}, + requestAuth: auth.UserAuth{UserId: "regular-user"}, expectedStatus: http.StatusOK, expectedResult: &api.User{ Id: "regular-user", @@ -632,7 +633,7 @@ func TestCurrentUser(t *testing.T) { }, { name: "admin user", - requestAuth: nbcontext.UserAuth{UserId: "admin-user"}, + requestAuth: auth.UserAuth{UserId: "admin-user"}, expectedStatus: http.StatusOK, expectedResult: &api.User{ Id: "admin-user", @@ -651,7 +652,7 @@ func TestCurrentUser(t *testing.T) { }, { name: "restricted user", - requestAuth: nbcontext.UserAuth{UserId: "restricted-user"}, + requestAuth: auth.UserAuth{UserId: "restricted-user"}, expectedStatus: http.StatusOK, expectedResult: &api.User{ Id: "restricted-user", @@ -783,7 +784,7 @@ func TestApproveUserEndpoint(t *testing.T) { req, err := http.NewRequest("POST", "/users/pending-user/approve", nil) require.NoError(t, err) - userAuth := nbcontext.UserAuth{ + userAuth := auth.UserAuth{ AccountId: existingAccountID, UserId: tc.requestingUser.Id, } @@ -841,7 +842,7 @@ func TestRejectUserEndpoint(t *testing.T) { req, err := http.NewRequest("DELETE", "/users/pending-user/reject", nil) require.NoError(t, err) - userAuth := nbcontext.UserAuth{ + userAuth := auth.UserAuth{ AccountId: existingAccountID, UserId: tc.requestingUser.Id, } @@ -855,3 +856,118 @@ func TestRejectUserEndpoint(t *testing.T) { }) } } + +func TestChangePasswordEndpoint(t *testing.T) { + tt := []struct { + name string + expectedStatus int + requestBody string + targetUserID string + currentUserID string + mockError error + expectMockNotCalled bool + }{ + { + name: "successful password change", + expectedStatus: http.StatusOK, + requestBody: `{"old_password": "OldPass123!", "new_password": "NewPass456!"}`, + targetUserID: existingUserID, + currentUserID: existingUserID, + mockError: nil, + }, + { + name: "missing old password", + expectedStatus: http.StatusUnprocessableEntity, + requestBody: `{"new_password": "NewPass456!"}`, + targetUserID: existingUserID, + currentUserID: existingUserID, + mockError: status.Errorf(status.InvalidArgument, "old password is required"), + }, + { + name: "missing new password", + expectedStatus: http.StatusUnprocessableEntity, + requestBody: `{"old_password": "OldPass123!"}`, + targetUserID: existingUserID, + currentUserID: existingUserID, + mockError: status.Errorf(status.InvalidArgument, "new password is required"), + }, + { + name: "wrong old password", + expectedStatus: http.StatusUnprocessableEntity, + requestBody: `{"old_password": "WrongPass!", "new_password": "NewPass456!"}`, + targetUserID: existingUserID, + currentUserID: existingUserID, + mockError: status.Errorf(status.InvalidArgument, "invalid password"), + }, + { + name: "embedded IDP not enabled", + expectedStatus: http.StatusPreconditionFailed, + requestBody: `{"old_password": "OldPass123!", "new_password": "NewPass456!"}`, + targetUserID: existingUserID, + currentUserID: existingUserID, + mockError: status.Errorf(status.PreconditionFailed, "password change is only available with embedded identity provider"), + }, + { + name: "invalid JSON request", + expectedStatus: http.StatusBadRequest, + requestBody: `{invalid json}`, + targetUserID: existingUserID, + currentUserID: existingUserID, + expectMockNotCalled: true, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + mockCalled := false + am := &mock_server.MockAccountManager{} + am.UpdateUserPasswordFunc = func(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error { + mockCalled = true + return tc.mockError + } + + handler := newHandler(am) + router := mux.NewRouter() + router.HandleFunc("/users/{userId}/password", handler.changePassword).Methods("PUT") + + reqPath := "/users/" + tc.targetUserID + "/password" + req, err := http.NewRequest("PUT", reqPath, bytes.NewBufferString(tc.requestBody)) + require.NoError(t, err) + + userAuth := auth.UserAuth{ + AccountId: existingAccountID, + UserId: tc.currentUserID, + } + ctx := nbcontext.SetUserAuthInContext(req.Context(), userAuth) + req = req.WithContext(ctx) + + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + assert.Equal(t, tc.expectedStatus, rr.Code) + + if tc.expectMockNotCalled { + assert.False(t, mockCalled, "mock should not have been called") + } + }) + } +} + +func TestChangePasswordEndpoint_WrongMethod(t *testing.T) { + am := &mock_server.MockAccountManager{} + handler := newHandler(am) + + req, err := http.NewRequest("POST", "/users/test-user/password", bytes.NewBufferString(`{}`)) + require.NoError(t, err) + + userAuth := auth.UserAuth{ + AccountId: existingAccountID, + UserId: existingUserID, + } + req = nbcontext.SetUserAuthInRequest(req, userAuth) + + rr := httptest.NewRecorder() + handler.changePassword(rr, req) + + assert.Equal(t, http.StatusMethodNotAllowed, rr.Code) +} diff --git a/management/server/http/middleware/auth_middleware.go b/management/server/http/middleware/auth_middleware.go index bce917a25..ba8c66241 100644 --- a/management/server/http/middleware/auth_middleware.go +++ b/management/server/http/middleware/auth_middleware.go @@ -9,40 +9,52 @@ import ( "time" log "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel/metric" - "github.com/netbirdio/netbird/management/server/auth" + serverauth "github.com/netbirdio/netbird/management/server/auth" nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/server/http/middleware/bypass" "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/auth" "github.com/netbirdio/netbird/shared/management/http/util" "github.com/netbirdio/netbird/shared/management/status" ) -type EnsureAccountFunc func(ctx context.Context, userAuth nbcontext.UserAuth) (string, string, error) -type SyncUserJWTGroupsFunc func(ctx context.Context, userAuth nbcontext.UserAuth) error +type EnsureAccountFunc func(ctx context.Context, userAuth auth.UserAuth) (string, string, error) +type SyncUserJWTGroupsFunc func(ctx context.Context, userAuth auth.UserAuth) error -type GetUserFromUserAuthFunc func(ctx context.Context, userAuth nbcontext.UserAuth) (*types.User, error) +type GetUserFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) + +type IsValidChildAccountFunc func(ctx context.Context, userID, accountID, childAccountID string) bool // AuthMiddleware middleware to verify personal access tokens (PAT) and JWT tokens type AuthMiddleware struct { - authManager auth.Manager + authManager serverauth.Manager ensureAccount EnsureAccountFunc getUserFromUserAuth GetUserFromUserAuthFunc syncUserJWTGroups SyncUserJWTGroupsFunc rateLimiter *APIRateLimiter + patUsageTracker *PATUsageTracker + isValidChildAccount IsValidChildAccountFunc } // NewAuthMiddleware instance constructor func NewAuthMiddleware( - authManager auth.Manager, + authManager serverauth.Manager, ensureAccount EnsureAccountFunc, syncUserJWTGroups SyncUserJWTGroupsFunc, getUserFromUserAuth GetUserFromUserAuthFunc, - rateLimiterConfig *RateLimiterConfig, + rateLimiter *APIRateLimiter, + meter metric.Meter, + isValidChildAccount IsValidChildAccountFunc, ) *AuthMiddleware { - var rateLimiter *APIRateLimiter - if rateLimiterConfig != nil { - rateLimiter = NewAPIRateLimiter(rateLimiterConfig) + var patUsageTracker *PATUsageTracker + if meter != nil { + var err error + patUsageTracker, err = NewPATUsageTracker(context.Background(), meter) + if err != nil { + log.Errorf("Failed to create PAT usage tracker: %s", err) + } } return &AuthMiddleware{ @@ -51,6 +63,8 @@ func NewAuthMiddleware( syncUserJWTGroups: syncUserJWTGroups, getUserFromUserAuth: getUserFromUserAuth, rateLimiter: rateLimiter, + patUsageTracker: patUsageTracker, + isValidChildAccount: isValidChildAccount, } } @@ -61,28 +75,25 @@ func (m *AuthMiddleware) Handler(h http.Handler) http.Handler { return } - auth := strings.Split(r.Header.Get("Authorization"), " ") - authType := strings.ToLower(auth[0]) + authHeader := strings.Split(r.Header.Get("Authorization"), " ") + authType := strings.ToLower(authHeader[0]) // fallback to token when receive pat as bearer - if len(auth) >= 2 && authType == "bearer" && strings.HasPrefix(auth[1], "nbp_") { + if len(authHeader) >= 2 && authType == "bearer" && strings.HasPrefix(authHeader[1], "nbp_") { authType = "token" - auth[0] = authType + authHeader[0] = authType } switch authType { case "bearer": - request, err := m.checkJWTFromRequest(r, auth) - if err != nil { + if err := m.checkJWTFromRequest(r, authHeader); err != nil { log.WithContext(r.Context()).Errorf("Error when validating JWT: %s", err.Error()) util.WriteError(r.Context(), status.Errorf(status.Unauthorized, "token invalid"), w) return } - - h.ServeHTTP(w, request) + h.ServeHTTP(w, r) case "token": - request, err := m.checkPATFromRequest(r, auth) - if err != nil { + if err := m.checkPATFromRequest(r, authHeader); err != nil { log.WithContext(r.Context()).Debugf("Error when validating PAT: %s", err.Error()) // Check if it's a status error, otherwise default to Unauthorized if _, ok := status.FromError(err); !ok { @@ -91,7 +102,7 @@ func (m *AuthMiddleware) Handler(h http.Handler) http.Handler { util.WriteError(r.Context(), err, w) return } - h.ServeHTTP(w, request) + h.ServeHTTP(w, r) default: util.WriteError(r.Context(), status.Errorf(status.Unauthorized, "no valid authentication provided"), w) return @@ -100,43 +111,52 @@ func (m *AuthMiddleware) Handler(h http.Handler) http.Handler { } // CheckJWTFromRequest checks if the JWT is valid -func (m *AuthMiddleware) checkJWTFromRequest(r *http.Request, auth []string) (*http.Request, error) { - token, err := getTokenFromJWTRequest(auth) +func (m *AuthMiddleware) checkJWTFromRequest(r *http.Request, authHeaderParts []string) error { + token, err := getTokenFromJWTRequest(authHeaderParts) // If an error occurs, call the error handler and return an error if err != nil { - return r, fmt.Errorf("error extracting token: %w", err) + return fmt.Errorf("error extracting token: %w", err) } ctx := r.Context() userAuth, validatedToken, err := m.authManager.ValidateAndParseToken(ctx, token) if err != nil { - return r, err + return err } if impersonate, ok := r.URL.Query()["account"]; ok && len(impersonate) == 1 { - userAuth.AccountId = impersonate[0] - userAuth.IsChild = ok + if m.isValidChildAccount(ctx, userAuth.UserId, userAuth.AccountId, impersonate[0]) { + userAuth.AccountId = impersonate[0] + userAuth.IsChild = true + } } + // Email is now extracted in ToUserAuth (from claims or userinfo endpoint) + // Available as userAuth.Email + // we need to call this method because if user is new, we will automatically add it to existing or create a new account accountId, _, err := m.ensureAccount(ctx, userAuth) if err != nil { - return r, err + return err } if userAuth.AccountId != accountId { - log.WithContext(ctx).Debugf("Auth middleware sets accountId from ensure, before %s, now %s", userAuth.AccountId, accountId) + log.WithContext(ctx).Tracef("Auth middleware sets accountId from ensure, before %s, now %s", userAuth.AccountId, accountId) userAuth.AccountId = accountId } userAuth, err = m.authManager.EnsureUserAccessByJWTGroups(ctx, userAuth, validatedToken) if err != nil { - return r, err + return err } - err = m.syncUserJWTGroups(ctx, userAuth) + // Detach the group-sync write from the request's cancellation: the dashboard + // SPA aborts in-flight requests on re-render, which would otherwise cancel the + // DB transaction mid-write and silently drop the synced groups. Context values + // (request id, logger) are preserved; the store bounds the tx with its own timeout. + err = m.syncUserJWTGroups(context.WithoutCancel(ctx), userAuth) if err != nil { log.WithContext(ctx).Errorf("HTTP server failed to sync user JWT groups: %s", err) } @@ -144,40 +164,44 @@ func (m *AuthMiddleware) checkJWTFromRequest(r *http.Request, auth []string) (*h _, err = m.getUserFromUserAuth(ctx, userAuth) if err != nil { log.WithContext(ctx).Errorf("HTTP server failed to update user from user auth: %s", err) - return r, err + return err } - return nbcontext.SetUserAuthInRequest(r, userAuth), nil + // propagates ctx change to upstream middleware + *r = *nbcontext.SetUserAuthInRequest(r, userAuth) + return nil } // CheckPATFromRequest checks if the PAT is valid -func (m *AuthMiddleware) checkPATFromRequest(r *http.Request, auth []string) (*http.Request, error) { - token, err := getTokenFromPATRequest(auth) +func (m *AuthMiddleware) checkPATFromRequest(r *http.Request, authHeaderParts []string) error { + token, err := getTokenFromPATRequest(authHeaderParts) if err != nil { - return r, fmt.Errorf("error extracting token: %w", err) + return fmt.Errorf("error extracting token: %w", err) } - if m.rateLimiter != nil { - if !m.rateLimiter.Allow(token) { - return r, status.Errorf(status.TooManyRequests, "too many requests") - } + if m.patUsageTracker != nil { + m.patUsageTracker.IncrementUsage(token) + } + + if !isTerraformRequest(r) && !m.rateLimiter.Allow(token) { + return status.Errorf(status.TooManyRequests, "too many requests") } ctx := r.Context() user, pat, accDomain, accCategory, err := m.authManager.GetPATInfo(ctx, token) if err != nil { - return r, fmt.Errorf("invalid Token: %w", err) + return fmt.Errorf("invalid Token: %w", err) } if time.Now().After(pat.GetExpirationDate()) { - return r, fmt.Errorf("token expired") + return fmt.Errorf("token expired") } err = m.authManager.MarkPATUsed(ctx, pat.ID) if err != nil { - return r, err + return err } - userAuth := nbcontext.UserAuth{ + userAuth := auth.UserAuth{ UserId: user.Id, AccountId: user.AccountID, Domain: accDomain, @@ -186,11 +210,20 @@ func (m *AuthMiddleware) checkPATFromRequest(r *http.Request, auth []string) (*h } if impersonate, ok := r.URL.Query()["account"]; ok && len(impersonate) == 1 { - userAuth.AccountId = impersonate[0] - userAuth.IsChild = ok + if m.isValidChildAccount(r.Context(), userAuth.UserId, userAuth.AccountId, impersonate[0]) { + userAuth.AccountId = impersonate[0] + userAuth.IsChild = true + } } - return nbcontext.SetUserAuthInRequest(r, userAuth), nil + // propagates ctx change to upstream middleware + *r = *nbcontext.SetUserAuthInRequest(r, userAuth) + return nil +} + +func isTerraformRequest(r *http.Request) bool { + ua := strings.ToLower(r.Header.Get("User-Agent")) + return strings.Contains(ua, "terraform") } // getTokenFromJWTRequest is a "TokenExtractor" that takes auth header parts and extracts diff --git a/management/server/http/middleware/auth_middleware_test.go b/management/server/http/middleware/auth_middleware_test.go index d1bd9959f..a34554660 100644 --- a/management/server/http/middleware/auth_middleware_test.go +++ b/management/server/http/middleware/auth_middleware_test.go @@ -12,11 +12,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/netbirdio/netbird/management/server/auth" - nbjwt "github.com/netbirdio/netbird/management/server/auth/jwt" nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/server/http/middleware/bypass" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/management/server/util" + nbauth "github.com/netbirdio/netbird/shared/auth" + nbjwt "github.com/netbirdio/netbird/shared/auth/jwt" ) const ( @@ -75,9 +76,9 @@ func mockGetAccountInfoFromPAT(_ context.Context, token string) (user *types.Use return nil, nil, "", "", fmt.Errorf("PAT invalid") } -func mockValidateAndParseToken(_ context.Context, token string) (nbcontext.UserAuth, *jwt.Token, error) { +func mockValidateAndParseToken(_ context.Context, token string) (nbauth.UserAuth, *jwt.Token, error) { if token == JWT { - return nbcontext.UserAuth{ + return nbauth.UserAuth{ UserId: userID, AccountId: accountID, Domain: testAccount.Domain, @@ -91,7 +92,7 @@ func mockValidateAndParseToken(_ context.Context, token string) (nbcontext.UserA Valid: true, }, nil } - return nbcontext.UserAuth{}, nil, fmt.Errorf("JWT invalid") + return nbauth.UserAuth{}, nil, fmt.Errorf("JWT invalid") } func mockMarkPATUsed(_ context.Context, token string) error { @@ -101,7 +102,7 @@ func mockMarkPATUsed(_ context.Context, token string) error { return fmt.Errorf("Should never get reached") } -func mockEnsureUserAccessByJWTGroups(_ context.Context, userAuth nbcontext.UserAuth, token *jwt.Token) (nbcontext.UserAuth, error) { +func mockEnsureUserAccessByJWTGroups(_ context.Context, userAuth nbauth.UserAuth, token *jwt.Token) (nbauth.UserAuth, error) { if userAuth.IsChild || userAuth.IsPAT { return userAuth, nil } @@ -195,18 +196,22 @@ func TestAuthMiddleware_Handler(t *testing.T) { GetPATInfoFunc: mockGetAccountInfoFromPAT, } + disabledLimiter := NewAPIRateLimiter(nil) + disabledLimiter.SetEnabled(false) authMiddleware := NewAuthMiddleware( mockAuth, - func(ctx context.Context, userAuth nbcontext.UserAuth) (string, string, error) { + func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) { return userAuth.AccountId, userAuth.UserId, nil }, - func(ctx context.Context, userAuth nbcontext.UserAuth) error { + func(ctx context.Context, userAuth nbauth.UserAuth) error { return nil }, - func(ctx context.Context, userAuth nbcontext.UserAuth) (*types.User, error) { + func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { return &types.User{}, nil }, + disabledLimiter, nil, + func(_ context.Context, _, _, _ string) bool { return false }, ) handlerToTest := authMiddleware.Handler(nextHandler) @@ -236,6 +241,66 @@ func TestAuthMiddleware_Handler(t *testing.T) { } } +// TestAuthMiddleware_SyncUserJWTGroupsDetachedFromRequestCancellation ensures the +// JWT group sync write is not bound to the request context. The dashboard SPA +// routinely aborts in-flight requests on re-render/navigation; if the sync ran in +// the request context, the cancellation would roll back the DB transaction and the +// synced groups would silently never persist. The sync must receive a context that +// is not cancelled even when the originating request is. +func TestAuthMiddleware_SyncUserJWTGroupsDetachedFromRequestCancellation(t *testing.T) { + var ( + syncCalled bool + syncCtxErr error + ) + + mockAuth := &auth.MockManager{ + ValidateAndParseTokenFunc: mockValidateAndParseToken, + EnsureUserAccessByJWTGroupsFunc: mockEnsureUserAccessByJWTGroups, + MarkPATUsedFunc: mockMarkPATUsed, + GetPATInfoFunc: mockGetAccountInfoFromPAT, + } + + disabledLimiter := NewAPIRateLimiter(nil) + disabledLimiter.SetEnabled(false) + + authMiddleware := NewAuthMiddleware( + mockAuth, + func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) { + return userAuth.AccountId, userAuth.UserId, nil + }, + func(ctx context.Context, userAuth nbauth.UserAuth) error { + syncCalled = true + syncCtxErr = ctx.Err() + return nil + }, + func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { + return &types.User{}, nil + }, + disabledLimiter, + nil, + func(_ context.Context, _, _, _ string) bool { return false }, + ) + + handlerToTest := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + + // Simulate the dashboard aborting the request: it arrives already cancelled. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + req := httptest.NewRequest("GET", "http://testing/test", nil).WithContext(ctx) + req.Header.Set("Authorization", "Bearer "+JWT) + rec := httptest.NewRecorder() + + handlerToTest.ServeHTTP(rec, req) + + if !syncCalled { + t.Fatal("syncUserJWTGroups was not called") + } + if syncCtxErr != nil { + t.Fatalf("syncUserJWTGroups received a cancelled context (%v); the group-sync write must be detached from request cancellation", syncCtxErr) + } +} + func TestAuthMiddleware_RateLimiting(t *testing.T) { mockAuth := &auth.MockManager{ ValidateAndParseTokenFunc: mockValidateAndParseToken, @@ -255,16 +320,18 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { authMiddleware := NewAuthMiddleware( mockAuth, - func(ctx context.Context, userAuth nbcontext.UserAuth) (string, string, error) { + func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) { return userAuth.AccountId, userAuth.UserId, nil }, - func(ctx context.Context, userAuth nbcontext.UserAuth) error { + func(ctx context.Context, userAuth nbauth.UserAuth) error { return nil }, - func(ctx context.Context, userAuth nbcontext.UserAuth) (*types.User, error) { + func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { return &types.User{}, nil }, - rateLimitConfig, + NewAPIRateLimiter(rateLimitConfig), + nil, + func(_ context.Context, _, _, _ string) bool { return false }, ) handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -306,16 +373,18 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { authMiddleware := NewAuthMiddleware( mockAuth, - func(ctx context.Context, userAuth nbcontext.UserAuth) (string, string, error) { + func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) { return userAuth.AccountId, userAuth.UserId, nil }, - func(ctx context.Context, userAuth nbcontext.UserAuth) error { + func(ctx context.Context, userAuth nbauth.UserAuth) error { return nil }, - func(ctx context.Context, userAuth nbcontext.UserAuth) (*types.User, error) { + func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { return &types.User{}, nil }, - rateLimitConfig, + NewAPIRateLimiter(rateLimitConfig), + nil, + func(_ context.Context, _, _, _ string) bool { return false }, ) handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -348,16 +417,18 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { authMiddleware := NewAuthMiddleware( mockAuth, - func(ctx context.Context, userAuth nbcontext.UserAuth) (string, string, error) { + func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) { return userAuth.AccountId, userAuth.UserId, nil }, - func(ctx context.Context, userAuth nbcontext.UserAuth) error { + func(ctx context.Context, userAuth nbauth.UserAuth) error { return nil }, - func(ctx context.Context, userAuth nbcontext.UserAuth) (*types.User, error) { + func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { return &types.User{}, nil }, - rateLimitConfig, + NewAPIRateLimiter(rateLimitConfig), + nil, + func(_ context.Context, _, _, _ string) bool { return false }, ) handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -391,16 +462,18 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { authMiddleware := NewAuthMiddleware( mockAuth, - func(ctx context.Context, userAuth nbcontext.UserAuth) (string, string, error) { + func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) { return userAuth.AccountId, userAuth.UserId, nil }, - func(ctx context.Context, userAuth nbcontext.UserAuth) error { + func(ctx context.Context, userAuth nbauth.UserAuth) error { return nil }, - func(ctx context.Context, userAuth nbcontext.UserAuth) (*types.User, error) { + func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { return &types.User{}, nil }, - rateLimitConfig, + NewAPIRateLimiter(rateLimitConfig), + nil, + func(_ context.Context, _, _, _ string) bool { return false }, ) handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -454,16 +527,18 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { authMiddleware := NewAuthMiddleware( mockAuth, - func(ctx context.Context, userAuth nbcontext.UserAuth) (string, string, error) { + func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) { return userAuth.AccountId, userAuth.UserId, nil }, - func(ctx context.Context, userAuth nbcontext.UserAuth) error { + func(ctx context.Context, userAuth nbauth.UserAuth) error { return nil }, - func(ctx context.Context, userAuth nbcontext.UserAuth) (*types.User, error) { + func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { return &types.User{}, nil }, - rateLimitConfig, + NewAPIRateLimiter(rateLimitConfig), + nil, + func(_ context.Context, _, _, _ string) bool { return false }, ) handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -501,6 +576,105 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusTooManyRequests, rec.Code, "Second request after cleanup should be rate limited again") }) + + t.Run("Terraform User Agent Not Rate Limited", func(t *testing.T) { + rateLimitConfig := &RateLimiterConfig{ + RequestsPerMinute: 1, + Burst: 1, + CleanupInterval: 5 * time.Minute, + LimiterTTL: 10 * time.Minute, + } + + authMiddleware := NewAuthMiddleware( + mockAuth, + func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) { + return userAuth.AccountId, userAuth.UserId, nil + }, + func(ctx context.Context, userAuth nbauth.UserAuth) error { + return nil + }, + func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { + return &types.User{}, nil + }, + NewAPIRateLimiter(rateLimitConfig), + nil, + func(_ context.Context, _, _, _ string) bool { return false }, + ) + + handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + // Test various Terraform user agent formats + terraformUserAgents := []string{ + "Terraform/1.5.0", + "terraform/1.0.0", + "Terraform-Provider/2.0.0", + "Mozilla/5.0 (compatible; Terraform/1.3.0)", + } + + for _, userAgent := range terraformUserAgents { + t.Run("UserAgent: "+userAgent, func(t *testing.T) { + successCount := 0 + for i := 0; i < 10; i++ { + req := httptest.NewRequest("GET", "http://testing/test", nil) + req.Header.Set("Authorization", "Token "+PAT) + req.Header.Set("User-Agent", userAgent) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + if rec.Code == http.StatusOK { + successCount++ + } + } + + assert.Equal(t, 10, successCount, "All Terraform user agent requests should succeed (not rate limited)") + }) + } + }) + + t.Run("Non-Terraform User Agent With PAT Is Rate Limited", func(t *testing.T) { + rateLimitConfig := &RateLimiterConfig{ + RequestsPerMinute: 1, + Burst: 1, + CleanupInterval: 5 * time.Minute, + LimiterTTL: 10 * time.Minute, + } + + authMiddleware := NewAuthMiddleware( + mockAuth, + func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) { + return userAuth.AccountId, userAuth.UserId, nil + }, + func(ctx context.Context, userAuth nbauth.UserAuth) error { + return nil + }, + func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { + return &types.User{}, nil + }, + NewAPIRateLimiter(rateLimitConfig), + nil, + func(_ context.Context, _, _, _ string) bool { return false }, + ) + + handler := authMiddleware.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("GET", "http://testing/test", nil) + req.Header.Set("Authorization", "Token "+PAT) + req.Header.Set("User-Agent", "curl/7.68.0") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code, "First request should succeed") + + req = httptest.NewRequest("GET", "http://testing/test", nil) + req.Header.Set("Authorization", "Token "+PAT) + req.Header.Set("User-Agent", "curl/7.68.0") + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusTooManyRequests, rec.Code, "Second request should be rate limited") + }) } func TestAuthMiddleware_Handler_Child(t *testing.T) { @@ -508,13 +682,13 @@ func TestAuthMiddleware_Handler_Child(t *testing.T) { name string path string authHeader string - expectedUserAuth *nbcontext.UserAuth // nil expects 401 response status + expectedUserAuth *nbauth.UserAuth // nil expects 401 response status }{ { name: "Valid PAT Token", path: "/test", authHeader: "Token " + PAT, - expectedUserAuth: &nbcontext.UserAuth{ + expectedUserAuth: &nbauth.UserAuth{ AccountId: accountID, UserId: userID, Domain: testAccount.Domain, @@ -523,15 +697,14 @@ func TestAuthMiddleware_Handler_Child(t *testing.T) { }, }, { - name: "Valid PAT Token accesses child", + name: "PAT Token with account param ignored in public version", path: "/test?account=xyz", authHeader: "Token " + PAT, - expectedUserAuth: &nbcontext.UserAuth{ - AccountId: "xyz", + expectedUserAuth: &nbauth.UserAuth{ + AccountId: accountID, UserId: userID, Domain: testAccount.Domain, DomainCategory: testAccount.DomainCategory, - IsChild: true, IsPAT: true, }, }, @@ -539,7 +712,7 @@ func TestAuthMiddleware_Handler_Child(t *testing.T) { name: "Valid JWT Token", path: "/test", authHeader: "Bearer " + JWT, - expectedUserAuth: &nbcontext.UserAuth{ + expectedUserAuth: &nbauth.UserAuth{ AccountId: accountID, UserId: userID, Domain: testAccount.Domain, @@ -548,15 +721,14 @@ func TestAuthMiddleware_Handler_Child(t *testing.T) { }, { - name: "Valid JWT Token with child", + name: "JWT Token with account param ignored in public version", path: "/test?account=xyz", authHeader: "Bearer " + JWT, - expectedUserAuth: &nbcontext.UserAuth{ - AccountId: "xyz", + expectedUserAuth: &nbauth.UserAuth{ + AccountId: accountID, UserId: userID, Domain: testAccount.Domain, DomainCategory: testAccount.DomainCategory, - IsChild: true, }, }, } @@ -568,18 +740,22 @@ func TestAuthMiddleware_Handler_Child(t *testing.T) { GetPATInfoFunc: mockGetAccountInfoFromPAT, } + disabledLimiter := NewAPIRateLimiter(nil) + disabledLimiter.SetEnabled(false) authMiddleware := NewAuthMiddleware( mockAuth, - func(ctx context.Context, userAuth nbcontext.UserAuth) (string, string, error) { + func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) { return userAuth.AccountId, userAuth.UserId, nil }, - func(ctx context.Context, userAuth nbcontext.UserAuth) error { + func(ctx context.Context, userAuth nbauth.UserAuth) error { return nil }, - func(ctx context.Context, userAuth nbcontext.UserAuth) (*types.User, error) { + func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { return &types.User{}, nil }, + disabledLimiter, nil, + func(_ context.Context, _, _, _ string) bool { return false }, ) for _, tc := range tt { diff --git a/management/server/http/middleware/bypass/bypass.go b/management/server/http/middleware/bypass/bypass.go index 9447704cb..ddece7152 100644 --- a/management/server/http/middleware/bypass/bypass.go +++ b/management/server/http/middleware/bypass/bypass.go @@ -51,19 +51,28 @@ func GetList() []string { // This can be used to bypass authz/authn middlewares for certain paths, such as webhooks that implement their own authentication. func ShouldBypass(requestPath string, h http.Handler, w http.ResponseWriter, r *http.Request) bool { byPassMutex.RLock() - defer byPassMutex.RUnlock() - + var matched bool for bypassPath := range bypassPaths { - matched, err := path.Match(bypassPath, requestPath) + m, err := path.Match(bypassPath, requestPath) if err != nil { - log.WithContext(r.Context()).Errorf("Error matching path %s with %s from %s: %v", bypassPath, requestPath, GetList(), err) + list := make([]string, 0, len(bypassPaths)) + for k := range bypassPaths { + list = append(list, k) + } + log.WithContext(r.Context()).Errorf("Error matching path %s with %s from %v: %v", bypassPath, requestPath, list, err) continue } - if matched { - h.ServeHTTP(w, r) - return true + if m { + matched = true + break } } + byPassMutex.RUnlock() + + if matched { + h.ServeHTTP(w, r) + return true + } return false } diff --git a/management/server/http/middleware/pat_usage_tracker.go b/management/server/http/middleware/pat_usage_tracker.go new file mode 100644 index 000000000..331c288e7 --- /dev/null +++ b/management/server/http/middleware/pat_usage_tracker.go @@ -0,0 +1,87 @@ +package middleware + +import ( + "context" + "maps" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel/metric" +) + +// PATUsageTracker tracks PAT usage metrics +type PATUsageTracker struct { + usageCounters map[string]int64 + mu sync.Mutex + stopChan chan struct{} + ctx context.Context + histogram metric.Int64Histogram +} + +// NewPATUsageTracker creates a new PAT usage tracker with metrics +func NewPATUsageTracker(ctx context.Context, meter metric.Meter) (*PATUsageTracker, error) { + histogram, err := meter.Int64Histogram( + "management.pat.usage_distribution", + metric.WithUnit("1"), + metric.WithDescription("Distribution of PAT token usage counts per minute"), + ) + if err != nil { + return nil, err + } + + tracker := &PATUsageTracker{ + usageCounters: make(map[string]int64), + stopChan: make(chan struct{}), + ctx: ctx, + histogram: histogram, + } + + go tracker.reportLoop() + + return tracker, nil +} + +// IncrementUsage increments the usage counter for a given token +func (t *PATUsageTracker) IncrementUsage(token string) { + t.mu.Lock() + defer t.mu.Unlock() + t.usageCounters[token]++ +} + +// reportLoop reports the usage buckets every minute +func (t *PATUsageTracker) reportLoop() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + t.reportUsageBuckets() + case <-t.stopChan: + return + } + } +} + +// reportUsageBuckets reports all token usage counts and resets counters +func (t *PATUsageTracker) reportUsageBuckets() { + t.mu.Lock() + snapshot := maps.Clone(t.usageCounters) + + clear(t.usageCounters) + t.mu.Unlock() + + totalTokens := len(snapshot) + if totalTokens > 0 { + for _, count := range snapshot { + t.histogram.Record(t.ctx, count) + } + log.Debugf("PAT usage in last minute: %d unique tokens used", totalTokens) + } +} + +// Stop stops the reporting goroutine +func (t *PATUsageTracker) Stop() { + close(t.stopChan) +} diff --git a/management/server/http/middleware/rate_limiter.go b/management/server/http/middleware/rate_limiter.go index a6266d4f3..bfd44afee 100644 --- a/management/server/http/middleware/rate_limiter.go +++ b/management/server/http/middleware/rate_limiter.go @@ -2,10 +2,27 @@ package middleware import ( "context" + "net" + "net/http" + "os" + "strconv" "sync" + "sync/atomic" "time" + log "github.com/sirupsen/logrus" "golang.org/x/time/rate" + + "github.com/netbirdio/netbird/shared/management/http/util" +) + +const ( + RateLimitingEnabledEnv = "NB_API_RATE_LIMITING_ENABLED" + RateLimitingBurstEnv = "NB_API_RATE_LIMITING_BURST" + RateLimitingRPMEnv = "NB_API_RATE_LIMITING_RPM" + + defaultAPIRPM = 6 + defaultAPIBurst = 500 ) // RateLimiterConfig holds configuration for the API rate limiter @@ -30,6 +47,43 @@ func DefaultRateLimiterConfig() *RateLimiterConfig { } } +func RateLimiterConfigFromEnv() (cfg *RateLimiterConfig, enabled bool) { + rpm := defaultAPIRPM + if v := os.Getenv(RateLimitingRPMEnv); v != "" { + value, err := strconv.Atoi(v) + if err != nil { + log.Warnf("parsing %s env var: %v, using default %d", RateLimitingRPMEnv, err, rpm) + } else { + rpm = value + } + } + if rpm <= 0 { + log.Warnf("%s=%d is non-positive, using default %d", RateLimitingRPMEnv, rpm, defaultAPIRPM) + rpm = defaultAPIRPM + } + + burst := defaultAPIBurst + if v := os.Getenv(RateLimitingBurstEnv); v != "" { + value, err := strconv.Atoi(v) + if err != nil { + log.Warnf("parsing %s env var: %v, using default %d", RateLimitingBurstEnv, err, burst) + } else { + burst = value + } + } + if burst <= 0 { + log.Warnf("%s=%d is non-positive, using default %d", RateLimitingBurstEnv, burst, defaultAPIBurst) + burst = defaultAPIBurst + } + + return &RateLimiterConfig{ + RequestsPerMinute: float64(rpm), + Burst: burst, + CleanupInterval: 6 * time.Hour, + LimiterTTL: 24 * time.Hour, + }, os.Getenv(RateLimitingEnabledEnv) == "true" +} + // limiterEntry holds a rate limiter and its last access time type limiterEntry struct { limiter *rate.Limiter @@ -42,6 +96,7 @@ type APIRateLimiter struct { limiters map[string]*limiterEntry mu sync.RWMutex stopChan chan struct{} + enabled atomic.Bool } // NewAPIRateLimiter creates a new API rate limiter with the given configuration @@ -55,14 +110,53 @@ func NewAPIRateLimiter(config *RateLimiterConfig) *APIRateLimiter { limiters: make(map[string]*limiterEntry), stopChan: make(chan struct{}), } + rl.enabled.Store(true) go rl.cleanupLoop() return rl } +func (rl *APIRateLimiter) SetEnabled(enabled bool) { + rl.enabled.Store(enabled) +} + +func (rl *APIRateLimiter) Enabled() bool { + return rl.enabled.Load() +} + +func (rl *APIRateLimiter) UpdateConfig(config *RateLimiterConfig) { + if config == nil { + return + } + if config.RequestsPerMinute <= 0 || config.Burst <= 0 { + log.Warnf("UpdateConfig: ignoring invalid rpm=%v burst=%d", config.RequestsPerMinute, config.Burst) + return + } + + newRPS := rate.Limit(config.RequestsPerMinute / 60.0) + newBurst := config.Burst + + rl.mu.Lock() + rl.config.RequestsPerMinute = config.RequestsPerMinute + rl.config.Burst = newBurst + snapshot := make([]*rate.Limiter, 0, len(rl.limiters)) + for _, entry := range rl.limiters { + snapshot = append(snapshot, entry.limiter) + } + rl.mu.Unlock() + + for _, l := range snapshot { + l.SetLimit(newRPS) + l.SetBurst(newBurst) + } +} + // Allow checks if a request for the given key (token) is allowed func (rl *APIRateLimiter) Allow(key string) bool { + if !rl.enabled.Load() { + return true + } limiter := rl.getLimiter(key) return limiter.Allow() } @@ -70,6 +164,9 @@ func (rl *APIRateLimiter) Allow(key string) bool { // Wait blocks until the rate limiter allows another request for the given key // Returns an error if the context is canceled func (rl *APIRateLimiter) Wait(ctx context.Context, key string) error { + if !rl.enabled.Load() { + return nil + } limiter := rl.getLimiter(key) return limiter.Wait(ctx) } @@ -144,3 +241,29 @@ func (rl *APIRateLimiter) Reset(key string) { defer rl.mu.Unlock() delete(rl.limiters, key) } + +// Middleware returns an HTTP middleware that rate limits requests by client IP. +// Returns 429 Too Many Requests if the rate limit is exceeded. +func (rl *APIRateLimiter) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !rl.enabled.Load() { + next.ServeHTTP(w, r) + return + } + clientIP := getClientIP(r) + if !rl.Allow(clientIP) { + util.WriteErrorResponse("rate limit exceeded, please try again later", http.StatusTooManyRequests, w) + return + } + next.ServeHTTP(w, r) + }) +} + +// getClientIP extracts the client IP address from the request. +func getClientIP(r *http.Request) string { + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return ip +} diff --git a/management/server/http/middleware/rate_limiter_test.go b/management/server/http/middleware/rate_limiter_test.go new file mode 100644 index 000000000..4b97d1874 --- /dev/null +++ b/management/server/http/middleware/rate_limiter_test.go @@ -0,0 +1,329 @@ +package middleware + +import ( + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestAPIRateLimiter_Allow(t *testing.T) { + rl := NewAPIRateLimiter(&RateLimiterConfig{ + RequestsPerMinute: 60, // 1 per second + Burst: 2, + CleanupInterval: time.Minute, + LimiterTTL: time.Minute, + }) + defer rl.Stop() + + // First two requests should be allowed (burst) + assert.True(t, rl.Allow("test-key")) + assert.True(t, rl.Allow("test-key")) + + // Third request should be denied (exceeded burst) + assert.False(t, rl.Allow("test-key")) + + // Different key should be allowed + assert.True(t, rl.Allow("different-key")) +} + +func TestAPIRateLimiter_Middleware(t *testing.T) { + rl := NewAPIRateLimiter(&RateLimiterConfig{ + RequestsPerMinute: 60, // 1 per second + Burst: 2, + CleanupInterval: time.Minute, + LimiterTTL: time.Minute, + }) + defer rl.Stop() + + // Create a simple handler that returns 200 OK + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // Wrap with rate limiter middleware + handler := rl.Middleware(nextHandler) + + // First two requests should pass (burst) + for i := 0; i < 2; i++ { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.RemoteAddr = "192.168.1.1:12345" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code, "request %d should be allowed", i+1) + } + + // Third request should be rate limited + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.RemoteAddr = "192.168.1.1:12345" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusTooManyRequests, rr.Code) +} + +func TestAPIRateLimiter_Middleware_DifferentIPs(t *testing.T) { + rl := NewAPIRateLimiter(&RateLimiterConfig{ + RequestsPerMinute: 60, + Burst: 1, + CleanupInterval: time.Minute, + LimiterTTL: time.Minute, + }) + defer rl.Stop() + + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + handler := rl.Middleware(nextHandler) + + // Request from first IP + req1 := httptest.NewRequest(http.MethodGet, "/test", nil) + req1.RemoteAddr = "192.168.1.1:12345" + rr1 := httptest.NewRecorder() + handler.ServeHTTP(rr1, req1) + assert.Equal(t, http.StatusOK, rr1.Code) + + // Second request from first IP should be rate limited + req2 := httptest.NewRequest(http.MethodGet, "/test", nil) + req2.RemoteAddr = "192.168.1.1:12345" + rr2 := httptest.NewRecorder() + handler.ServeHTTP(rr2, req2) + assert.Equal(t, http.StatusTooManyRequests, rr2.Code) + + // Request from different IP should be allowed + req3 := httptest.NewRequest(http.MethodGet, "/test", nil) + req3.RemoteAddr = "192.168.1.2:12345" + rr3 := httptest.NewRecorder() + handler.ServeHTTP(rr3, req3) + assert.Equal(t, http.StatusOK, rr3.Code) +} + +func TestGetClientIP(t *testing.T) { + tests := []struct { + name string + remoteAddr string + expected string + }{ + { + name: "remote addr with port", + remoteAddr: "192.168.1.1:12345", + expected: "192.168.1.1", + }, + { + name: "remote addr without port", + remoteAddr: "192.168.1.1", + expected: "192.168.1.1", + }, + { + name: "IPv6 with port", + remoteAddr: "[::1]:12345", + expected: "::1", + }, + { + name: "IPv6 without port", + remoteAddr: "::1", + expected: "::1", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.RemoteAddr = tc.remoteAddr + assert.Equal(t, tc.expected, getClientIP(req)) + }) + } +} + +func TestAPIRateLimiter_Reset(t *testing.T) { + rl := NewAPIRateLimiter(&RateLimiterConfig{ + RequestsPerMinute: 60, + Burst: 1, + CleanupInterval: time.Minute, + LimiterTTL: time.Minute, + }) + defer rl.Stop() + + // Use up the burst + assert.True(t, rl.Allow("test-key")) + assert.False(t, rl.Allow("test-key")) + + // Reset the limiter + rl.Reset("test-key") + + // Should be allowed again + assert.True(t, rl.Allow("test-key")) +} + +func TestAPIRateLimiter_SetEnabled(t *testing.T) { + rl := NewAPIRateLimiter(&RateLimiterConfig{ + RequestsPerMinute: 60, + Burst: 1, + CleanupInterval: time.Minute, + LimiterTTL: time.Minute, + }) + defer rl.Stop() + + assert.True(t, rl.Allow("key")) + assert.False(t, rl.Allow("key"), "burst exhausted while enabled") + + rl.SetEnabled(false) + assert.False(t, rl.Enabled()) + for i := 0; i < 5; i++ { + assert.True(t, rl.Allow("key"), "disabled limiter must always allow") + } + + rl.SetEnabled(true) + assert.True(t, rl.Enabled()) + assert.False(t, rl.Allow("key"), "re-enabled limiter retains prior bucket state") +} + +func TestAPIRateLimiter_UpdateConfig(t *testing.T) { + rl := NewAPIRateLimiter(&RateLimiterConfig{ + RequestsPerMinute: 60, + Burst: 2, + CleanupInterval: time.Minute, + LimiterTTL: time.Minute, + }) + defer rl.Stop() + + assert.True(t, rl.Allow("k1")) + assert.True(t, rl.Allow("k1")) + assert.False(t, rl.Allow("k1"), "burst=2 exhausted") + + rl.UpdateConfig(&RateLimiterConfig{ + RequestsPerMinute: 60, + Burst: 10, + CleanupInterval: time.Minute, + LimiterTTL: time.Minute, + }) + + // New burst applies to existing keys in place; bucket refills up to new burst over time, + // but importantly newly-added keys use the updated config immediately. + assert.True(t, rl.Allow("k2")) + for i := 0; i < 9; i++ { + assert.True(t, rl.Allow("k2")) + } + assert.False(t, rl.Allow("k2"), "new burst=10 exhausted") +} + +func TestAPIRateLimiter_UpdateConfig_NilIgnored(t *testing.T) { + rl := NewAPIRateLimiter(&RateLimiterConfig{ + RequestsPerMinute: 60, + Burst: 1, + CleanupInterval: time.Minute, + LimiterTTL: time.Minute, + }) + defer rl.Stop() + + rl.UpdateConfig(nil) // must not panic or zero the config + + assert.True(t, rl.Allow("k")) + assert.False(t, rl.Allow("k")) +} + +func TestAPIRateLimiter_UpdateConfig_NonPositiveIgnored(t *testing.T) { + rl := NewAPIRateLimiter(&RateLimiterConfig{ + RequestsPerMinute: 60, + Burst: 1, + CleanupInterval: time.Minute, + LimiterTTL: time.Minute, + }) + defer rl.Stop() + + assert.True(t, rl.Allow("k")) + assert.False(t, rl.Allow("k")) + + rl.UpdateConfig(&RateLimiterConfig{RequestsPerMinute: 0, Burst: 0, CleanupInterval: time.Minute, LimiterTTL: time.Minute}) + rl.UpdateConfig(&RateLimiterConfig{RequestsPerMinute: -1, Burst: 5, CleanupInterval: time.Minute, LimiterTTL: time.Minute}) + rl.UpdateConfig(&RateLimiterConfig{RequestsPerMinute: 60, Burst: -1, CleanupInterval: time.Minute, LimiterTTL: time.Minute}) + + rl.Reset("k") + assert.True(t, rl.Allow("k")) + assert.False(t, rl.Allow("k"), "burst should still be 1 — invalid UpdateConfig calls were ignored") +} + +func TestAPIRateLimiter_ConcurrentAllowAndUpdate(t *testing.T) { + rl := NewAPIRateLimiter(&RateLimiterConfig{ + RequestsPerMinute: 600, + Burst: 10, + CleanupInterval: time.Minute, + LimiterTTL: time.Minute, + }) + defer rl.Stop() + + var wg sync.WaitGroup + stop := make(chan struct{}) + + for i := 0; i < 8; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + key := fmt.Sprintf("k%d", id) + for { + select { + case <-stop: + return + default: + rl.Allow(key) + } + } + }(i) + } + + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 200; i++ { + select { + case <-stop: + return + default: + rl.UpdateConfig(&RateLimiterConfig{ + RequestsPerMinute: float64(30 + (i % 90)), + Burst: 1 + (i % 20), + CleanupInterval: time.Minute, + LimiterTTL: time.Minute, + }) + rl.SetEnabled(i%2 == 0) + } + } + }() + + time.Sleep(100 * time.Millisecond) + close(stop) + wg.Wait() +} + +func TestRateLimiterConfigFromEnv(t *testing.T) { + t.Setenv(RateLimitingEnabledEnv, "true") + t.Setenv(RateLimitingRPMEnv, "42") + t.Setenv(RateLimitingBurstEnv, "7") + + cfg, enabled := RateLimiterConfigFromEnv() + assert.True(t, enabled) + assert.Equal(t, float64(42), cfg.RequestsPerMinute) + assert.Equal(t, 7, cfg.Burst) + + t.Setenv(RateLimitingEnabledEnv, "false") + _, enabled = RateLimiterConfigFromEnv() + assert.False(t, enabled) + + t.Setenv(RateLimitingEnabledEnv, "") + t.Setenv(RateLimitingRPMEnv, "") + t.Setenv(RateLimitingBurstEnv, "") + cfg, enabled = RateLimiterConfigFromEnv() + assert.False(t, enabled) + assert.Equal(t, float64(defaultAPIRPM), cfg.RequestsPerMinute) + assert.Equal(t, defaultAPIBurst, cfg.Burst) + + t.Setenv(RateLimitingRPMEnv, "0") + t.Setenv(RateLimitingBurstEnv, "-5") + cfg, _ = RateLimiterConfigFromEnv() + assert.Equal(t, float64(defaultAPIRPM), cfg.RequestsPerMinute, "non-positive rpm must fall back to default") + assert.Equal(t, defaultAPIBurst, cfg.Burst, "non-positive burst must fall back to default") +} diff --git a/management/server/http/testing/benchmarks/peers_handler_benchmark_test.go b/management/server/http/testing/benchmarks/peers_handler_benchmark_test.go index 3fe3fe809..3345a034b 100644 --- a/management/server/http/testing/benchmarks/peers_handler_benchmark_test.go +++ b/management/server/http/testing/benchmarks/peers_handler_benchmark_test.go @@ -1,5 +1,4 @@ //go:build benchmark -// +build benchmark package benchmarks diff --git a/management/server/http/testing/benchmarks/setupkeys_handler_benchmark_test.go b/management/server/http/testing/benchmarks/setupkeys_handler_benchmark_test.go index 36b226db0..ca25861dd 100644 --- a/management/server/http/testing/benchmarks/setupkeys_handler_benchmark_test.go +++ b/management/server/http/testing/benchmarks/setupkeys_handler_benchmark_test.go @@ -1,5 +1,4 @@ //go:build benchmark -// +build benchmark package benchmarks diff --git a/management/server/http/testing/benchmarks/users_handler_benchmark_test.go b/management/server/http/testing/benchmarks/users_handler_benchmark_test.go index 2868a20bd..b13773268 100644 --- a/management/server/http/testing/benchmarks/users_handler_benchmark_test.go +++ b/management/server/http/testing/benchmarks/users_handler_benchmark_test.go @@ -1,5 +1,4 @@ //go:build benchmark -// +build benchmark package benchmarks diff --git a/management/server/http/testing/integration/accounts_handler_integration_test.go b/management/server/http/testing/integration/accounts_handler_integration_test.go new file mode 100644 index 000000000..511730ee5 --- /dev/null +++ b/management/server/http/testing/integration/accounts_handler_integration_test.go @@ -0,0 +1,238 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools/channel" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +func Test_Accounts_GetAll(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, true}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all accounts", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/accounts.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/accounts", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []api.Account{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.Equal(t, 1, len(got)) + account := got[0] + assert.Equal(t, "test.com", account.Domain) + assert.Equal(t, "private", account.DomainCategory) + assert.Equal(t, true, account.Settings.PeerLoginExpirationEnabled) + assert.Equal(t, 86400, account.Settings.PeerLoginExpiration) + assert.Equal(t, false, account.Settings.RegularUsersViewBlocked) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_Accounts_Update(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + trueVal := true + falseVal := false + + tt := []struct { + name string + expectedStatus int + requestBody *api.AccountRequest + verifyResponse func(t *testing.T, account *api.Account) + verifyDB func(t *testing.T, account *types.Account) + }{ + { + name: "Disable peer login expiration", + requestBody: &api.AccountRequest{ + Settings: api.AccountSettings{ + PeerLoginExpirationEnabled: false, + PeerLoginExpiration: 86400, + }, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, account *api.Account) { + t.Helper() + assert.Equal(t, false, account.Settings.PeerLoginExpirationEnabled) + }, + verifyDB: func(t *testing.T, dbAccount *types.Account) { + t.Helper() + assert.Equal(t, false, dbAccount.Settings.PeerLoginExpirationEnabled) + }, + }, + { + name: "Update peer login expiration to 48h", + requestBody: &api.AccountRequest{ + Settings: api.AccountSettings{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: 172800, + }, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, account *api.Account) { + t.Helper() + assert.Equal(t, 172800, account.Settings.PeerLoginExpiration) + }, + verifyDB: func(t *testing.T, dbAccount *types.Account) { + t.Helper() + assert.Equal(t, 172800*time.Second, dbAccount.Settings.PeerLoginExpiration) + }, + }, + { + name: "Enable regular users view blocked", + requestBody: &api.AccountRequest{ + Settings: api.AccountSettings{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: 86400, + RegularUsersViewBlocked: true, + }, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, account *api.Account) { + t.Helper() + assert.Equal(t, true, account.Settings.RegularUsersViewBlocked) + }, + verifyDB: func(t *testing.T, dbAccount *types.Account) { + t.Helper() + assert.Equal(t, true, dbAccount.Settings.RegularUsersViewBlocked) + }, + }, + { + name: "Enable groups propagation", + requestBody: &api.AccountRequest{ + Settings: api.AccountSettings{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: 86400, + GroupsPropagationEnabled: &trueVal, + }, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, account *api.Account) { + t.Helper() + assert.NotNil(t, account.Settings.GroupsPropagationEnabled) + assert.Equal(t, true, *account.Settings.GroupsPropagationEnabled) + }, + verifyDB: func(t *testing.T, dbAccount *types.Account) { + t.Helper() + assert.Equal(t, true, dbAccount.Settings.GroupsPropagationEnabled) + }, + }, + { + name: "Enable JWT groups", + requestBody: &api.AccountRequest{ + Settings: api.AccountSettings{ + PeerLoginExpirationEnabled: true, + PeerLoginExpiration: 86400, + GroupsPropagationEnabled: &falseVal, + JwtGroupsEnabled: &trueVal, + JwtGroupsClaimName: stringPointer("groups"), + }, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, account *api.Account) { + t.Helper() + assert.NotNil(t, account.Settings.JwtGroupsEnabled) + assert.Equal(t, true, *account.Settings.JwtGroupsEnabled) + assert.NotNil(t, account.Settings.JwtGroupsClaimName) + assert.Equal(t, "groups", *account.Settings.JwtGroupsClaimName) + }, + verifyDB: func(t *testing.T, dbAccount *types.Account) { + t.Helper() + assert.Equal(t, true, dbAccount.Settings.JWTGroupsEnabled) + assert.Equal(t, "groups", dbAccount.Settings.JWTGroupsClaimName) + }, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/accounts.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPut, strings.Replace("/api/accounts/{accountId}", "{accountId}", testing_tools.TestAccountId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + got := &api.Account{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.Equal(t, testing_tools.TestAccountId, got.Id) + assert.Equal(t, "test.com", got.Domain) + tc.verifyResponse(t, got) + + db := testing_tools.GetDB(t, am.GetStore()) + dbAccount := testing_tools.VerifyAccountSettings(t, db) + tc.verifyDB(t, dbAccount) + }) + } + } +} + +func stringPointer(s string) *string { + return &s +} diff --git a/management/server/http/testing/integration/dns_handler_integration_test.go b/management/server/http/testing/integration/dns_handler_integration_test.go new file mode 100644 index 000000000..7ada5e462 --- /dev/null +++ b/management/server/http/testing/integration/dns_handler_integration_test.go @@ -0,0 +1,554 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools/channel" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +func Test_Nameservers_GetAll(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all nameservers", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/dns.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/dns/nameservers", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []api.NameserverGroup{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.Equal(t, 1, len(got)) + assert.Equal(t, "testNSGroup", got[0].Name) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_Nameservers_GetById(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + nsGroupId string + expectedStatus int + expectGroup bool + }{ + { + name: "Get existing nameserver group", + nsGroupId: "testNSGroupId", + expectedStatus: http.StatusOK, + expectGroup: true, + }, + { + name: "Get non-existing nameserver group", + nsGroupId: "nonExistingNSGroupId", + expectedStatus: http.StatusNotFound, + expectGroup: false, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/dns.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, strings.Replace("/api/dns/nameservers/{nsgroupId}", "{nsgroupId}", tc.nsGroupId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.expectGroup { + got := &api.NameserverGroup{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + assert.Equal(t, "testNSGroupId", got.Id) + assert.Equal(t, "testNSGroup", got.Name) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_Nameservers_Create(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + requestBody *api.PostApiDnsNameserversJSONRequestBody + expectedStatus int + verifyResponse func(t *testing.T, nsGroup *api.NameserverGroup) + }{ + { + name: "Create nameserver group with single NS", + requestBody: &api.PostApiDnsNameserversJSONRequestBody{ + Name: "newNSGroup", + Description: "a new nameserver group", + Nameservers: []api.Nameserver{ + {Ip: "8.8.8.8", NsType: "udp", Port: 53}, + }, + Groups: []string{testing_tools.TestGroupId}, + Primary: false, + Domains: []string{"test.com"}, + Enabled: true, + SearchDomainsEnabled: false, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, nsGroup *api.NameserverGroup) { + t.Helper() + assert.NotEmpty(t, nsGroup.Id) + assert.Equal(t, "newNSGroup", nsGroup.Name) + assert.Equal(t, 1, len(nsGroup.Nameservers)) + assert.Equal(t, false, nsGroup.Primary) + }, + }, + { + name: "Create primary nameserver group", + requestBody: &api.PostApiDnsNameserversJSONRequestBody{ + Name: "primaryNS", + Description: "primary nameserver", + Nameservers: []api.Nameserver{ + {Ip: "1.1.1.1", NsType: "udp", Port: 53}, + }, + Groups: []string{testing_tools.TestGroupId}, + Primary: true, + Domains: []string{}, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, nsGroup *api.NameserverGroup) { + t.Helper() + assert.Equal(t, true, nsGroup.Primary) + }, + }, + { + name: "Create nameserver group with empty groups", + requestBody: &api.PostApiDnsNameserversJSONRequestBody{ + Name: "emptyGroupsNS", + Description: "no groups", + Nameservers: []api.Nameserver{ + {Ip: "8.8.8.8", NsType: "udp", Port: 53}, + }, + Groups: []string{}, + Primary: true, + Domains: []string{}, + Enabled: true, + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/dns.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPost, "/api/dns/nameservers", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.NameserverGroup{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify the created NS group directly in the DB + db := testing_tools.GetDB(t, am.GetStore()) + dbNS := testing_tools.VerifyNSGroupInDB(t, db, got.Id) + assert.Equal(t, got.Name, dbNS.Name) + assert.Equal(t, got.Primary, dbNS.Primary) + assert.Equal(t, len(got.Nameservers), len(dbNS.NameServers)) + assert.Equal(t, got.Enabled, dbNS.Enabled) + assert.Equal(t, got.SearchDomainsEnabled, dbNS.SearchDomainsEnabled) + } + }) + } + } +} + +func Test_Nameservers_Update(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + nsGroupId string + requestBody *api.PutApiDnsNameserversNsgroupIdJSONRequestBody + expectedStatus int + verifyResponse func(t *testing.T, nsGroup *api.NameserverGroup) + }{ + { + name: "Update nameserver group name", + nsGroupId: "testNSGroupId", + requestBody: &api.PutApiDnsNameserversNsgroupIdJSONRequestBody{ + Name: "updatedNSGroup", + Description: "updated description", + Nameservers: []api.Nameserver{ + {Ip: "1.1.1.1", NsType: "udp", Port: 53}, + }, + Groups: []string{testing_tools.TestGroupId}, + Primary: false, + Domains: []string{"example.com"}, + Enabled: true, + SearchDomainsEnabled: false, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, nsGroup *api.NameserverGroup) { + t.Helper() + assert.Equal(t, "updatedNSGroup", nsGroup.Name) + assert.Equal(t, "updated description", nsGroup.Description) + }, + }, + { + name: "Update non-existing nameserver group", + nsGroupId: "nonExistingNSGroupId", + requestBody: &api.PutApiDnsNameserversNsgroupIdJSONRequestBody{ + Name: "whatever", + Nameservers: []api.Nameserver{ + {Ip: "1.1.1.1", NsType: "udp", Port: 53}, + }, + Groups: []string{testing_tools.TestGroupId}, + Primary: true, + Domains: []string{}, + Enabled: true, + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/dns.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPut, strings.Replace("/api/dns/nameservers/{nsgroupId}", "{nsgroupId}", tc.nsGroupId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.NameserverGroup{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify the updated NS group directly in the DB + db := testing_tools.GetDB(t, am.GetStore()) + dbNS := testing_tools.VerifyNSGroupInDB(t, db, tc.nsGroupId) + assert.Equal(t, "updatedNSGroup", dbNS.Name) + assert.Equal(t, "updated description", dbNS.Description) + assert.Equal(t, false, dbNS.Primary) + assert.Equal(t, true, dbNS.Enabled) + assert.Equal(t, 1, len(dbNS.NameServers)) + assert.Equal(t, false, dbNS.SearchDomainsEnabled) + } + }) + } + } +} + +func Test_Nameservers_Delete(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + nsGroupId string + expectedStatus int + }{ + { + name: "Delete existing nameserver group", + nsGroupId: "testNSGroupId", + expectedStatus: http.StatusOK, + }, + { + name: "Delete non-existing nameserver group", + nsGroupId: "nonExistingNSGroupId", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/dns.sql", nil, false) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodDelete, strings.Replace("/api/dns/nameservers/{nsgroupId}", "{nsgroupId}", tc.nsGroupId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + + // Verify deletion in DB for successful deletes by privileged users + if tc.expectedStatus == http.StatusOK && user.expectResponse { + db := testing_tools.GetDB(t, am.GetStore()) + testing_tools.VerifyNSGroupNotInDB(t, db, tc.nsGroupId) + } + }) + } + } +} + +func Test_DnsSettings_Get(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get DNS settings", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/dns.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/dns/settings", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := &api.DNSSettings{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.NotNil(t, got.DisabledManagementGroups) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_DnsSettings_Update(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + requestBody *api.PutApiDnsSettingsJSONRequestBody + expectedStatus int + verifyResponse func(t *testing.T, settings *api.DNSSettings) + expectedDBDisabledMgmtLen int + expectedDBDisabledMgmtItem string + }{ + { + name: "Update disabled management groups", + requestBody: &api.PutApiDnsSettingsJSONRequestBody{ + DisabledManagementGroups: []string{testing_tools.TestGroupId}, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, settings *api.DNSSettings) { + t.Helper() + assert.Equal(t, 1, len(settings.DisabledManagementGroups)) + assert.Equal(t, testing_tools.TestGroupId, settings.DisabledManagementGroups[0]) + }, + expectedDBDisabledMgmtLen: 1, + expectedDBDisabledMgmtItem: testing_tools.TestGroupId, + }, + { + name: "Update with empty disabled management groups", + requestBody: &api.PutApiDnsSettingsJSONRequestBody{ + DisabledManagementGroups: []string{}, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, settings *api.DNSSettings) { + t.Helper() + assert.Equal(t, 0, len(settings.DisabledManagementGroups)) + }, + expectedDBDisabledMgmtLen: 0, + }, + { + name: "Update with non-existing group", + requestBody: &api.PutApiDnsSettingsJSONRequestBody{ + DisabledManagementGroups: []string{"nonExistingGroupId"}, + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/dns.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPut, "/api/dns/settings", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.DNSSettings{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify DNS settings directly in the DB + db := testing_tools.GetDB(t, am.GetStore()) + dbAccount := testing_tools.VerifyAccountSettings(t, db) + assert.Equal(t, tc.expectedDBDisabledMgmtLen, len(dbAccount.DNSSettings.DisabledManagementGroups)) + if tc.expectedDBDisabledMgmtItem != "" { + assert.Contains(t, dbAccount.DNSSettings.DisabledManagementGroups, tc.expectedDBDisabledMgmtItem) + } + } + }) + } + } +} diff --git a/management/server/http/testing/integration/events_handler_integration_test.go b/management/server/http/testing/integration/events_handler_integration_test.go new file mode 100644 index 000000000..6611b60ee --- /dev/null +++ b/management/server/http/testing/integration/events_handler_integration_test.go @@ -0,0 +1,105 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools/channel" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +func Test_Events_GetAll(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all events", func(t *testing.T) { + apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/events.sql", nil, false) + + // First, perform a mutation to generate an event (create a group as admin) + groupBody, err := json.Marshal(&api.GroupRequest{Name: "eventTestGroup"}) + if err != nil { + t.Fatalf("Failed to marshal group request: %v", err) + } + createReq := testing_tools.BuildRequest(t, groupBody, http.MethodPost, "/api/groups", testing_tools.TestAdminId) + createRecorder := httptest.NewRecorder() + apiHandler.ServeHTTP(createRecorder, createReq) + assert.Equal(t, http.StatusOK, createRecorder.Code, "Failed to create group to generate event") + + // Now query events + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/events", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []api.Event{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.GreaterOrEqual(t, len(got), 1, "Expected at least one event after creating a group") + + // Verify the group creation event exists + found := false + for _, event := range got { + if event.ActivityCode == "group.add" { + found = true + assert.Equal(t, testing_tools.TestAdminId, event.InitiatorId) + assert.Equal(t, "Group created", event.Activity) + break + } + } + assert.True(t, found, "Expected to find a group.add event") + }) + } +} + +func Test_Events_GetAll_Empty(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/events.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/events", testing_tools.TestAdminId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, true) + if !expectResponse { + return + } + + got := []api.Event{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.Equal(t, 0, len(got), "Expected empty events list when no mutations have been performed") + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } +} diff --git a/management/server/http/testing/integration/groups_handler_integration_test.go b/management/server/http/testing/integration/groups_handler_integration_test.go new file mode 100644 index 000000000..edb43f3f3 --- /dev/null +++ b/management/server/http/testing/integration/groups_handler_integration_test.go @@ -0,0 +1,382 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools/channel" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +func Test_Groups_GetAll(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, true}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all groups", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/groups.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/groups", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []api.Group{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.GreaterOrEqual(t, len(got), 2) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_Groups_GetById(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, true}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + groupId string + expectedStatus int + expectGroup bool + }{ + { + name: "Get existing group", + groupId: testing_tools.TestGroupId, + expectedStatus: http.StatusOK, + expectGroup: true, + }, + { + name: "Get non-existing group", + groupId: "nonExistingGroupId", + expectedStatus: http.StatusNotFound, + expectGroup: false, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/groups.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, strings.Replace("/api/groups/{groupId}", "{groupId}", tc.groupId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.expectGroup { + got := &api.Group{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + assert.Equal(t, tc.groupId, got.Id) + assert.Equal(t, "testGroupName", got.Name) + assert.Equal(t, 1, got.PeersCount) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_Groups_Create(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + requestBody *api.GroupRequest + expectedStatus int + verifyResponse func(t *testing.T, group *api.Group) + }{ + { + name: "Create group with valid name", + requestBody: &api.GroupRequest{ + Name: "brandNewGroup", + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, group *api.Group) { + t.Helper() + assert.NotEmpty(t, group.Id) + assert.Equal(t, "brandNewGroup", group.Name) + assert.Equal(t, 0, group.PeersCount) + }, + }, + { + name: "Create group with peers", + requestBody: &api.GroupRequest{ + Name: "groupWithPeers", + Peers: &[]string{testing_tools.TestPeerId}, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, group *api.Group) { + t.Helper() + assert.NotEmpty(t, group.Id) + assert.Equal(t, "groupWithPeers", group.Name) + assert.Equal(t, 1, group.PeersCount) + }, + }, + { + name: "Create group with empty name", + requestBody: &api.GroupRequest{ + Name: "", + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/groups.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPost, "/api/groups", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.Group{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify group exists in DB + db := testing_tools.GetDB(t, am.GetStore()) + dbGroup := testing_tools.VerifyGroupInDB(t, db, got.Id) + assert.Equal(t, tc.requestBody.Name, dbGroup.Name) + } + }) + } + } +} + +func Test_Groups_Update(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + groupId string + requestBody *api.GroupRequest + expectedStatus int + verifyResponse func(t *testing.T, group *api.Group) + }{ + { + name: "Update group name", + groupId: testing_tools.TestGroupId, + requestBody: &api.GroupRequest{ + Name: "updatedGroupName", + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, group *api.Group) { + t.Helper() + assert.Equal(t, testing_tools.TestGroupId, group.Id) + assert.Equal(t, "updatedGroupName", group.Name) + }, + }, + { + name: "Update group peers", + groupId: testing_tools.TestGroupId, + requestBody: &api.GroupRequest{ + Name: "testGroupName", + Peers: &[]string{}, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, group *api.Group) { + t.Helper() + assert.Equal(t, 0, group.PeersCount) + }, + }, + { + name: "Update with empty name", + groupId: testing_tools.TestGroupId, + requestBody: &api.GroupRequest{ + Name: "", + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + { + name: "Update non-existing group", + groupId: "nonExistingGroupId", + requestBody: &api.GroupRequest{ + Name: "someName", + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/groups.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPut, strings.Replace("/api/groups/{groupId}", "{groupId}", tc.groupId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.Group{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify updated group in DB + db := testing_tools.GetDB(t, am.GetStore()) + dbGroup := testing_tools.VerifyGroupInDB(t, db, tc.groupId) + assert.Equal(t, tc.requestBody.Name, dbGroup.Name) + } + }) + } + } +} + +func Test_Groups_Delete(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + groupId string + expectedStatus int + }{ + { + name: "Delete existing group not in use", + groupId: testing_tools.NewGroupId, + expectedStatus: http.StatusOK, + }, + { + name: "Delete non-existing group", + groupId: "nonExistingGroupId", + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/groups.sql", nil, false) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodDelete, strings.Replace("/api/groups/{groupId}", "{groupId}", tc.groupId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + _, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if expectResponse && tc.expectedStatus == http.StatusOK { + db := testing_tools.GetDB(t, am.GetStore()) + testing_tools.VerifyGroupNotInDB(t, db, tc.groupId) + } + }) + } + } +} diff --git a/management/server/http/testing/integration/networks_handler_integration_test.go b/management/server/http/testing/integration/networks_handler_integration_test.go new file mode 100644 index 000000000..a0a49a9ec --- /dev/null +++ b/management/server/http/testing/integration/networks_handler_integration_test.go @@ -0,0 +1,1439 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools/channel" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +func Test_Networks_GetAll(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all networks", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/networks", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []*api.Network{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.Equal(t, 1, len(got)) + assert.Equal(t, "testNetworkId", got[0].Id) + assert.Equal(t, "testNetwork", got[0].Name) + assert.Equal(t, "test network description", *got[0].Description) + assert.GreaterOrEqual(t, len(got[0].Routers), 1) + assert.GreaterOrEqual(t, len(got[0].Resources), 1) + assert.GreaterOrEqual(t, got[0].RoutingPeersCount, 1) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_Networks_GetById(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + networkId string + expectedStatus int + expectNetwork bool + }{ + { + name: "Get existing network", + networkId: "testNetworkId", + expectedStatus: http.StatusOK, + expectNetwork: true, + }, + { + name: "Get non-existing network", + networkId: "nonExistingNetworkId", + expectedStatus: http.StatusNotFound, + expectNetwork: false, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, strings.Replace("/api/networks/{networkId}", "{networkId}", tc.networkId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.expectNetwork { + got := &api.Network{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + assert.Equal(t, tc.networkId, got.Id) + assert.Equal(t, "testNetwork", got.Name) + assert.Equal(t, "test network description", *got.Description) + assert.GreaterOrEqual(t, len(got.Routers), 1) + assert.GreaterOrEqual(t, len(got.Resources), 1) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_Networks_Create(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + desc := "new network description" + + tt := []struct { + name string + requestBody *api.NetworkRequest + expectedStatus int + verifyResponse func(t *testing.T, network *api.Network) + }{ + { + name: "Create network with name and description", + requestBody: &api.NetworkRequest{ + Name: "newNetwork", + Description: &desc, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, network *api.Network) { + t.Helper() + assert.NotEmpty(t, network.Id) + assert.Equal(t, "newNetwork", network.Name) + assert.Equal(t, "new network description", *network.Description) + assert.Empty(t, network.Routers) + assert.Empty(t, network.Resources) + assert.Equal(t, 0, network.RoutingPeersCount) + }, + }, + { + name: "Create network with name only", + requestBody: &api.NetworkRequest{ + Name: "simpleNetwork", + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, network *api.Network) { + t.Helper() + assert.NotEmpty(t, network.Id) + assert.Equal(t, "simpleNetwork", network.Name) + }, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + require.NoError(t, err) + + req := testing_tools.BuildRequest(t, body, http.MethodPost, "/api/networks", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.Network{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + } + }) + } + } +} + +func Test_Networks_Update(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + updatedDesc := "updated description" + + tt := []struct { + name string + networkId string + requestBody *api.NetworkRequest + expectedStatus int + verifyResponse func(t *testing.T, network *api.Network) + }{ + { + name: "Update network name", + networkId: "testNetworkId", + requestBody: &api.NetworkRequest{ + Name: "updatedNetwork", + Description: &updatedDesc, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, network *api.Network) { + t.Helper() + assert.Equal(t, "testNetworkId", network.Id) + assert.Equal(t, "updatedNetwork", network.Name) + assert.Equal(t, "updated description", *network.Description) + }, + }, + { + name: "Update non-existing network", + networkId: "nonExistingNetworkId", + requestBody: &api.NetworkRequest{ + Name: "whatever", + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + require.NoError(t, err) + + req := testing_tools.BuildRequest(t, body, http.MethodPut, strings.Replace("/api/networks/{networkId}", "{networkId}", tc.networkId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.Network{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + } + }) + } + } +} + +func Test_Networks_Delete(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + networkId string + expectedStatus int + }{ + { + name: "Delete existing network", + networkId: "testNetworkId", + expectedStatus: http.StatusOK, + }, + { + name: "Delete non-existing network", + networkId: "nonExistingNetworkId", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, false) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodDelete, strings.Replace("/api/networks/{networkId}", "{networkId}", tc.networkId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + }) + } + } +} + +func Test_Networks_Delete_Cascades(t *testing.T) { + apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, false) + + // Delete the network + req := testing_tools.BuildRequest(t, []byte{}, http.MethodDelete, "/api/networks/testNetworkId", testing_tools.TestAdminId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + testing_tools.ReadResponse(t, recorder, http.StatusOK, true) + + // Verify network is gone + req = testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/networks/testNetworkId", testing_tools.TestAdminId) + recorder = httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + testing_tools.ReadResponse(t, recorder, http.StatusNotFound, true) + + // Verify routers in that network are gone + req = testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/networks/testNetworkId/routers", testing_tools.TestAdminId) + recorder = httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + content, _ := testing_tools.ReadResponse(t, recorder, http.StatusOK, true) + var routers []*api.NetworkRouter + require.NoError(t, json.Unmarshal(content, &routers)) + assert.Empty(t, routers) + + // Verify resources in that network are gone + req = testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/networks/testNetworkId/resources", testing_tools.TestAdminId) + recorder = httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + content, _ = testing_tools.ReadResponse(t, recorder, http.StatusOK, true) + var resources []*api.NetworkResource + require.NoError(t, json.Unmarshal(content, &resources)) + assert.Empty(t, resources) +} + +func Test_NetworkResources_GetAllInNetwork(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all resources in network", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/networks/testNetworkId/resources", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []*api.NetworkResource{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.Equal(t, 1, len(got)) + assert.Equal(t, "testResourceId", got[0].Id) + assert.Equal(t, "testResource", got[0].Name) + assert.Equal(t, api.NetworkResourceType("host"), got[0].Type) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_NetworkResources_GetAllInAccount(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all resources in account", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/networks/resources", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []*api.NetworkResource{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.GreaterOrEqual(t, len(got), 1) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_NetworkResources_GetById(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + networkId string + resourceId string + expectedStatus int + expectResource bool + }{ + { + name: "Get existing resource", + networkId: "testNetworkId", + resourceId: "testResourceId", + expectedStatus: http.StatusOK, + expectResource: true, + }, + { + name: "Get non-existing resource", + networkId: "testNetworkId", + resourceId: "nonExistingResourceId", + expectedStatus: http.StatusNotFound, + expectResource: false, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, true) + + path := fmt.Sprintf("/api/networks/%s/resources/%s", tc.networkId, tc.resourceId) + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, path, user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.expectResource { + got := &api.NetworkResource{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + assert.Equal(t, tc.resourceId, got.Id) + assert.Equal(t, "testResource", got.Name) + assert.Equal(t, api.NetworkResourceType("host"), got.Type) + assert.Equal(t, "3.3.3.3/32", got.Address) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_NetworkResources_Create(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + desc := "new resource" + + tt := []struct { + name string + networkId string + requestBody *api.NetworkResourceRequest + expectedStatus int + verifyResponse func(t *testing.T, resource *api.NetworkResource) + }{ + { + name: "Create host resource with IP", + networkId: "testNetworkId", + requestBody: &api.NetworkResourceRequest{ + Name: "hostResource", + Description: &desc, + Address: "1.1.1.1", + Groups: []string{testing_tools.TestGroupId}, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, resource *api.NetworkResource) { + t.Helper() + assert.NotEmpty(t, resource.Id) + assert.Equal(t, "hostResource", resource.Name) + assert.Equal(t, api.NetworkResourceType("host"), resource.Type) + assert.Equal(t, "1.1.1.1/32", resource.Address) + assert.True(t, resource.Enabled) + }, + }, + { + name: "Create host resource with CIDR /32", + networkId: "testNetworkId", + requestBody: &api.NetworkResourceRequest{ + Name: "hostCIDR", + Address: "10.0.0.1/32", + Groups: []string{testing_tools.TestGroupId}, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, resource *api.NetworkResource) { + t.Helper() + assert.Equal(t, api.NetworkResourceType("host"), resource.Type) + assert.Equal(t, "10.0.0.1/32", resource.Address) + }, + }, + { + name: "Create subnet resource", + networkId: "testNetworkId", + requestBody: &api.NetworkResourceRequest{ + Name: "subnetResource", + Address: "192.168.0.0/24", + Groups: []string{testing_tools.TestGroupId}, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, resource *api.NetworkResource) { + t.Helper() + assert.Equal(t, api.NetworkResourceType("subnet"), resource.Type) + assert.Equal(t, "192.168.0.0/24", resource.Address) + }, + }, + { + name: "Create domain resource", + networkId: "testNetworkId", + requestBody: &api.NetworkResourceRequest{ + Name: "domainResource", + Address: "example.com", + Groups: []string{testing_tools.TestGroupId}, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, resource *api.NetworkResource) { + t.Helper() + assert.Equal(t, api.NetworkResourceType("domain"), resource.Type) + assert.Equal(t, "example.com", resource.Address) + }, + }, + { + name: "Create wildcard domain resource", + networkId: "testNetworkId", + requestBody: &api.NetworkResourceRequest{ + Name: "wildcardDomain", + Address: "*.example.com", + Groups: []string{testing_tools.TestGroupId}, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, resource *api.NetworkResource) { + t.Helper() + assert.Equal(t, api.NetworkResourceType("domain"), resource.Type) + assert.Equal(t, "*.example.com", resource.Address) + }, + }, + { + name: "Create disabled resource", + networkId: "testNetworkId", + requestBody: &api.NetworkResourceRequest{ + Name: "disabledResource", + Address: "5.5.5.5", + Groups: []string{testing_tools.TestGroupId}, + Enabled: false, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, resource *api.NetworkResource) { + t.Helper() + assert.False(t, resource.Enabled) + }, + }, + { + name: "Create resource with invalid address", + networkId: "testNetworkId", + requestBody: &api.NetworkResourceRequest{ + Name: "invalidResource", + Address: "not-a-valid-address!!!", + Groups: []string{testing_tools.TestGroupId}, + Enabled: true, + }, + expectedStatus: http.StatusInternalServerError, + }, + { + name: "Create resource with empty groups", + networkId: "testNetworkId", + requestBody: &api.NetworkResourceRequest{ + Name: "noGroupsResource", + Address: "7.7.7.7", + Groups: []string{}, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, resource *api.NetworkResource) { + t.Helper() + assert.NotEmpty(t, resource.Id) + }, + }, + { + name: "Create resource with duplicate name", + networkId: "testNetworkId", + requestBody: &api.NetworkResourceRequest{ + Name: "testResource", + Address: "8.8.8.8", + Groups: []string{testing_tools.TestGroupId}, + Enabled: true, + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + require.NoError(t, err) + + path := fmt.Sprintf("/api/networks/%s/resources", tc.networkId) + req := testing_tools.BuildRequest(t, body, http.MethodPost, path, user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.NetworkResource{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + } + }) + } + } +} + +func Test_NetworkResources_Update(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + updatedDesc := "updated resource" + + tt := []struct { + name string + networkId string + resourceId string + requestBody *api.NetworkResourceRequest + expectedStatus int + verifyResponse func(t *testing.T, resource *api.NetworkResource) + }{ + { + name: "Update resource name and address", + networkId: "testNetworkId", + resourceId: "testResourceId", + requestBody: &api.NetworkResourceRequest{ + Name: "updatedResource", + Description: &updatedDesc, + Address: "4.4.4.4", + Groups: []string{testing_tools.TestGroupId}, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, resource *api.NetworkResource) { + t.Helper() + assert.Equal(t, "testResourceId", resource.Id) + assert.Equal(t, "updatedResource", resource.Name) + assert.Equal(t, "updated resource", *resource.Description) + assert.Equal(t, "4.4.4.4/32", resource.Address) + }, + }, + { + name: "Update resource to subnet type", + networkId: "testNetworkId", + resourceId: "testResourceId", + requestBody: &api.NetworkResourceRequest{ + Name: "testResource", + Address: "10.0.0.0/16", + Groups: []string{testing_tools.TestGroupId}, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, resource *api.NetworkResource) { + t.Helper() + assert.Equal(t, api.NetworkResourceType("subnet"), resource.Type) + assert.Equal(t, "10.0.0.0/16", resource.Address) + }, + }, + { + name: "Update resource to domain type", + networkId: "testNetworkId", + resourceId: "testResourceId", + requestBody: &api.NetworkResourceRequest{ + Name: "testResource", + Address: "myservice.example.com", + Groups: []string{testing_tools.TestGroupId}, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, resource *api.NetworkResource) { + t.Helper() + assert.Equal(t, api.NetworkResourceType("domain"), resource.Type) + assert.Equal(t, "myservice.example.com", resource.Address) + }, + }, + { + name: "Update non-existing resource", + networkId: "testNetworkId", + resourceId: "nonExistingResourceId", + requestBody: &api.NetworkResourceRequest{ + Name: "whatever", + Address: "1.2.3.4", + Groups: []string{testing_tools.TestGroupId}, + Enabled: true, + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + require.NoError(t, err) + + path := fmt.Sprintf("/api/networks/%s/resources/%s", tc.networkId, tc.resourceId) + req := testing_tools.BuildRequest(t, body, http.MethodPut, path, user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.NetworkResource{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + } + }) + } + } +} + +func Test_NetworkResources_Delete(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + networkId string + resourceId string + expectedStatus int + }{ + { + name: "Delete existing resource", + networkId: "testNetworkId", + resourceId: "testResourceId", + expectedStatus: http.StatusOK, + }, + { + name: "Delete non-existing resource", + networkId: "testNetworkId", + resourceId: "nonExistingResourceId", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, false) + + path := fmt.Sprintf("/api/networks/%s/resources/%s", tc.networkId, tc.resourceId) + req := testing_tools.BuildRequest(t, []byte{}, http.MethodDelete, path, user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + }) + } + } +} + +func Test_NetworkRouters_GetAllInNetwork(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all routers in network", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/networks/testNetworkId/routers", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []*api.NetworkRouter{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.Equal(t, 1, len(got)) + assert.Equal(t, "testRouterId", got[0].Id) + assert.Equal(t, "testPeerId", *got[0].Peer) + assert.True(t, got[0].Masquerade) + assert.Equal(t, 100, got[0].Metric) + assert.True(t, got[0].Enabled) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_NetworkRouters_GetAllInAccount(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all routers in account", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/networks/routers", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []*api.NetworkRouter{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.GreaterOrEqual(t, len(got), 1) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_NetworkRouters_GetById(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + networkId string + routerId string + expectedStatus int + expectRouter bool + }{ + { + name: "Get existing router", + networkId: "testNetworkId", + routerId: "testRouterId", + expectedStatus: http.StatusOK, + expectRouter: true, + }, + { + name: "Get non-existing router", + networkId: "testNetworkId", + routerId: "nonExistingRouterId", + expectedStatus: http.StatusNotFound, + expectRouter: false, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, true) + + path := fmt.Sprintf("/api/networks/%s/routers/%s", tc.networkId, tc.routerId) + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, path, user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.expectRouter { + got := &api.NetworkRouter{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + assert.Equal(t, tc.routerId, got.Id) + assert.Equal(t, "testPeerId", *got.Peer) + assert.True(t, got.Masquerade) + assert.Equal(t, 100, got.Metric) + assert.True(t, got.Enabled) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_NetworkRouters_Create(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + peerID := "testPeerId" + peerGroups := []string{testing_tools.TestGroupId} + + tt := []struct { + name string + networkId string + requestBody *api.NetworkRouterRequest + expectedStatus int + verifyResponse func(t *testing.T, router *api.NetworkRouter) + }{ + { + name: "Create router with peer", + networkId: "testNetworkId", + requestBody: &api.NetworkRouterRequest{ + Peer: &peerID, + Masquerade: true, + Metric: 200, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, router *api.NetworkRouter) { + t.Helper() + assert.NotEmpty(t, router.Id) + assert.Equal(t, peerID, *router.Peer) + assert.True(t, router.Masquerade) + assert.Equal(t, 200, router.Metric) + assert.True(t, router.Enabled) + }, + }, + { + name: "Create router with peer groups", + networkId: "testNetworkId", + requestBody: &api.NetworkRouterRequest{ + PeerGroups: &peerGroups, + Masquerade: false, + Metric: 300, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, router *api.NetworkRouter) { + t.Helper() + assert.NotEmpty(t, router.Id) + assert.NotNil(t, router.PeerGroups) + assert.Equal(t, 1, len(*router.PeerGroups)) + assert.False(t, router.Masquerade) + assert.Equal(t, 300, router.Metric) + assert.True(t, router.Enabled) // always true on creation + }, + }, + { + name: "Create router with both peer and peer_groups", + networkId: "testNetworkId", + requestBody: &api.NetworkRouterRequest{ + Peer: &peerID, + PeerGroups: &peerGroups, + Masquerade: true, + Metric: 100, + Enabled: true, + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "Create router without peer and peer_groups", + networkId: "testNetworkId", + requestBody: &api.NetworkRouterRequest{ + Masquerade: true, + Metric: 100, + Enabled: true, + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "Create router in non-existing network", + networkId: "nonExistingNetworkId", + requestBody: &api.NetworkRouterRequest{ + Peer: &peerID, + Masquerade: true, + Metric: 100, + Enabled: true, + }, + expectedStatus: http.StatusNotFound, + }, + { + name: "Create router enabled is always true", + networkId: "testNetworkId", + requestBody: &api.NetworkRouterRequest{ + Peer: &peerID, + Masquerade: false, + Metric: 50, + Enabled: false, // handler sets to true + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, router *api.NetworkRouter) { + t.Helper() + assert.True(t, router.Enabled) // always true on creation + }, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + require.NoError(t, err) + + path := fmt.Sprintf("/api/networks/%s/routers", tc.networkId) + req := testing_tools.BuildRequest(t, body, http.MethodPost, path, user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.NetworkRouter{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + } + }) + } + } +} + +func Test_NetworkRouters_Update(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + peerID := "testPeerId" + peerGroups := []string{testing_tools.TestGroupId} + + tt := []struct { + name string + networkId string + routerId string + requestBody *api.NetworkRouterRequest + expectedStatus int + verifyResponse func(t *testing.T, router *api.NetworkRouter) + }{ + { + name: "Update router metric and masquerade", + networkId: "testNetworkId", + routerId: "testRouterId", + requestBody: &api.NetworkRouterRequest{ + Peer: &peerID, + Masquerade: false, + Metric: 500, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, router *api.NetworkRouter) { + t.Helper() + assert.Equal(t, "testRouterId", router.Id) + assert.False(t, router.Masquerade) + assert.Equal(t, 500, router.Metric) + }, + }, + { + name: "Update router to use peer groups", + networkId: "testNetworkId", + routerId: "testRouterId", + requestBody: &api.NetworkRouterRequest{ + PeerGroups: &peerGroups, + Masquerade: true, + Metric: 100, + Enabled: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, router *api.NetworkRouter) { + t.Helper() + assert.NotNil(t, router.PeerGroups) + assert.Equal(t, 1, len(*router.PeerGroups)) + }, + }, + { + name: "Update router disabled", + networkId: "testNetworkId", + routerId: "testRouterId", + requestBody: &api.NetworkRouterRequest{ + Peer: &peerID, + Masquerade: true, + Metric: 100, + Enabled: false, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, router *api.NetworkRouter) { + t.Helper() + assert.False(t, router.Enabled) + }, + }, + { + name: "Update non-existing router returns not found", + networkId: "testNetworkId", + routerId: "nonExistingRouterId", + requestBody: &api.NetworkRouterRequest{ + Peer: &peerID, + Masquerade: true, + Metric: 100, + Enabled: true, + }, + expectedStatus: http.StatusNotFound, + }, + { + name: "Update router with both peer and peer_groups", + networkId: "testNetworkId", + routerId: "testRouterId", + requestBody: &api.NetworkRouterRequest{ + Peer: &peerID, + PeerGroups: &peerGroups, + Masquerade: true, + Metric: 100, + Enabled: true, + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "Update router without peer and peer_groups", + networkId: "testNetworkId", + routerId: "testRouterId", + requestBody: &api.NetworkRouterRequest{ + Masquerade: true, + Metric: 100, + Enabled: true, + }, + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + require.NoError(t, err) + + path := fmt.Sprintf("/api/networks/%s/routers/%s", tc.networkId, tc.routerId) + req := testing_tools.BuildRequest(t, body, http.MethodPut, path, user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.NetworkRouter{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + } + }) + } + } +} + +func Test_NetworkRouters_Delete(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + networkId string + routerId string + expectedStatus int + }{ + { + name: "Delete existing router", + networkId: "testNetworkId", + routerId: "testRouterId", + expectedStatus: http.StatusOK, + }, + { + name: "Delete non-existing router", + networkId: "testNetworkId", + routerId: "nonExistingRouterId", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/networks.sql", nil, false) + + path := fmt.Sprintf("/api/networks/%s/routers/%s", tc.networkId, tc.routerId) + req := testing_tools.BuildRequest(t, []byte{}, http.MethodDelete, path, user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + }) + } + } +} diff --git a/management/server/http/testing/integration/peers_handler_integration_test.go b/management/server/http/testing/integration/peers_handler_integration_test.go new file mode 100644 index 000000000..17a9e94a6 --- /dev/null +++ b/management/server/http/testing/integration/peers_handler_integration_test.go @@ -0,0 +1,605 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools/channel" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +const ( + testPeerId2 = "testPeerId2" +) + +func Test_Peers_GetAll(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + { + name: "Regular user", + userId: testing_tools.TestUserId, + expectResponse: false, + }, + { + name: "Admin user", + userId: testing_tools.TestAdminId, + expectResponse: true, + }, + { + name: "Owner user", + userId: testing_tools.TestOwnerId, + expectResponse: true, + }, + { + name: "Regular service user", + userId: testing_tools.TestServiceUserId, + expectResponse: true, + }, + { + name: "Admin service user", + userId: testing_tools.TestServiceAdminId, + expectResponse: true, + }, + { + name: "Blocked user", + userId: testing_tools.BlockedUserId, + expectResponse: false, + }, + { + name: "Other user", + userId: testing_tools.OtherUserId, + expectResponse: false, + }, + { + name: "Invalid token", + userId: testing_tools.InvalidToken, + expectResponse: false, + }, + } + + for _, user := range users { + t.Run(user.name+" - Get all peers", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/peers_integration.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/peers", user.userId) + recorder := httptest.NewRecorder() + + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + var got []api.PeerBatch + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.GreaterOrEqual(t, len(got), 2, "Expected at least 2 peers") + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_Peers_GetById(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + { + name: "Regular user", + userId: testing_tools.TestUserId, + expectResponse: false, + }, + { + name: "Admin user", + userId: testing_tools.TestAdminId, + expectResponse: true, + }, + { + name: "Owner user", + userId: testing_tools.TestOwnerId, + expectResponse: true, + }, + { + name: "Regular service user", + userId: testing_tools.TestServiceUserId, + expectResponse: true, + }, + { + name: "Admin service user", + userId: testing_tools.TestServiceAdminId, + expectResponse: true, + }, + { + name: "Blocked user", + userId: testing_tools.BlockedUserId, + expectResponse: false, + }, + { + name: "Other user", + userId: testing_tools.OtherUserId, + expectResponse: false, + }, + { + name: "Invalid token", + userId: testing_tools.InvalidToken, + expectResponse: false, + }, + } + + tt := []struct { + name string + expectedStatus int + requestType string + requestPath string + requestId string + verifyResponse func(t *testing.T, peer *api.Peer) + }{ + { + name: "Get existing peer", + requestType: http.MethodGet, + requestPath: "/api/peers/{peerId}", + requestId: testing_tools.TestPeerId, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, peer *api.Peer) { + t.Helper() + assert.Equal(t, testing_tools.TestPeerId, peer.Id) + assert.Equal(t, "test-peer-1", peer.Name) + assert.Equal(t, "test-host-1", peer.Hostname) + assert.Equal(t, "Debian GNU/Linux ", peer.Os) + assert.Equal(t, "0.12.0", peer.Version) + assert.Equal(t, false, peer.SshEnabled) + assert.Equal(t, true, peer.LoginExpirationEnabled) + }, + }, + { + name: "Get second existing peer", + requestType: http.MethodGet, + requestPath: "/api/peers/{peerId}", + requestId: testPeerId2, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, peer *api.Peer) { + t.Helper() + assert.Equal(t, testPeerId2, peer.Id) + assert.Equal(t, "test-peer-2", peer.Name) + assert.Equal(t, "test-host-2", peer.Hostname) + assert.Equal(t, "Ubuntu ", peer.Os) + assert.Equal(t, true, peer.SshEnabled) + assert.Equal(t, false, peer.LoginExpirationEnabled) + assert.Equal(t, true, peer.Connected) + }, + }, + { + name: "Get non-existing peer", + requestType: http.MethodGet, + requestPath: "/api/peers/{peerId}", + requestId: "nonExistingPeerId", + expectedStatus: http.StatusNotFound, + verifyResponse: nil, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/peers_integration.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, tc.requestType, strings.Replace(tc.requestPath, "{peerId}", tc.requestId, 1), user.userId) + recorder := httptest.NewRecorder() + + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.Peer{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_Peers_Update(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + { + name: "Regular user", + userId: testing_tools.TestUserId, + expectResponse: false, + }, + { + name: "Admin user", + userId: testing_tools.TestAdminId, + expectResponse: true, + }, + { + name: "Owner user", + userId: testing_tools.TestOwnerId, + expectResponse: true, + }, + { + name: "Regular service user", + userId: testing_tools.TestServiceUserId, + expectResponse: false, + }, + { + name: "Admin service user", + userId: testing_tools.TestServiceAdminId, + expectResponse: true, + }, + { + name: "Blocked user", + userId: testing_tools.BlockedUserId, + expectResponse: false, + }, + { + name: "Other user", + userId: testing_tools.OtherUserId, + expectResponse: false, + }, + { + name: "Invalid token", + userId: testing_tools.InvalidToken, + expectResponse: false, + }, + } + + tt := []struct { + name string + expectedStatus int + requestBody *api.PeerRequest + requestType string + requestPath string + requestId string + verifyResponse func(t *testing.T, peer *api.Peer) + }{ + { + name: "Update peer name", + requestType: http.MethodPut, + requestPath: "/api/peers/{peerId}", + requestId: testing_tools.TestPeerId, + requestBody: &api.PeerRequest{ + Name: "updated-peer-name", + SshEnabled: false, + LoginExpirationEnabled: true, + InactivityExpirationEnabled: false, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, peer *api.Peer) { + t.Helper() + assert.Equal(t, testing_tools.TestPeerId, peer.Id) + assert.Equal(t, "updated-peer-name", peer.Name) + assert.Equal(t, false, peer.SshEnabled) + assert.Equal(t, true, peer.LoginExpirationEnabled) + }, + }, + { + name: "Enable SSH on peer", + requestType: http.MethodPut, + requestPath: "/api/peers/{peerId}", + requestId: testing_tools.TestPeerId, + requestBody: &api.PeerRequest{ + Name: "test-peer-1", + SshEnabled: true, + LoginExpirationEnabled: true, + InactivityExpirationEnabled: false, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, peer *api.Peer) { + t.Helper() + assert.Equal(t, testing_tools.TestPeerId, peer.Id) + assert.Equal(t, "test-peer-1", peer.Name) + assert.Equal(t, true, peer.SshEnabled) + assert.Equal(t, true, peer.LoginExpirationEnabled) + }, + }, + { + name: "Disable login expiration on peer", + requestType: http.MethodPut, + requestPath: "/api/peers/{peerId}", + requestId: testing_tools.TestPeerId, + requestBody: &api.PeerRequest{ + Name: "test-peer-1", + SshEnabled: false, + LoginExpirationEnabled: false, + InactivityExpirationEnabled: false, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, peer *api.Peer) { + t.Helper() + assert.Equal(t, testing_tools.TestPeerId, peer.Id) + assert.Equal(t, false, peer.LoginExpirationEnabled) + }, + }, + { + name: "Update non-existing peer", + requestType: http.MethodPut, + requestPath: "/api/peers/{peerId}", + requestId: "nonExistingPeerId", + requestBody: &api.PeerRequest{ + Name: "updated-name", + SshEnabled: false, + LoginExpirationEnabled: false, + InactivityExpirationEnabled: false, + }, + expectedStatus: http.StatusNotFound, + verifyResponse: nil, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/peers_integration.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, tc.requestType, strings.Replace(tc.requestPath, "{peerId}", tc.requestId, 1), user.userId) + recorder := httptest.NewRecorder() + + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.Peer{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify updated peer in DB + db := testing_tools.GetDB(t, am.GetStore()) + dbPeer := testing_tools.VerifyPeerInDB(t, db, tc.requestId) + assert.Equal(t, tc.requestBody.Name, dbPeer.Name) + assert.Equal(t, tc.requestBody.SshEnabled, dbPeer.SSHEnabled) + assert.Equal(t, tc.requestBody.LoginExpirationEnabled, dbPeer.LoginExpirationEnabled) + assert.Equal(t, tc.requestBody.InactivityExpirationEnabled, dbPeer.InactivityExpirationEnabled) + } + }) + } + } +} + +func Test_Peers_Delete(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + { + name: "Regular user", + userId: testing_tools.TestUserId, + expectResponse: false, + }, + { + name: "Admin user", + userId: testing_tools.TestAdminId, + expectResponse: true, + }, + { + name: "Owner user", + userId: testing_tools.TestOwnerId, + expectResponse: true, + }, + { + name: "Regular service user", + userId: testing_tools.TestServiceUserId, + expectResponse: false, + }, + { + name: "Admin service user", + userId: testing_tools.TestServiceAdminId, + expectResponse: true, + }, + { + name: "Blocked user", + userId: testing_tools.BlockedUserId, + expectResponse: false, + }, + { + name: "Other user", + userId: testing_tools.OtherUserId, + expectResponse: false, + }, + { + name: "Invalid token", + userId: testing_tools.InvalidToken, + expectResponse: false, + }, + } + + tt := []struct { + name string + expectedStatus int + requestType string + requestPath string + requestId string + }{ + { + name: "Delete existing peer", + requestType: http.MethodDelete, + requestPath: "/api/peers/{peerId}", + requestId: testPeerId2, + expectedStatus: http.StatusOK, + }, + { + name: "Delete non-existing peer", + requestType: http.MethodDelete, + requestPath: "/api/peers/{peerId}", + requestId: "nonExistingPeerId", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/peers_integration.sql", nil, false) + + req := testing_tools.BuildRequest(t, []byte{}, tc.requestType, strings.Replace(tc.requestPath, "{peerId}", tc.requestId, 1), user.userId) + recorder := httptest.NewRecorder() + + apiHandler.ServeHTTP(recorder, req) + + _, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + // Verify peer is actually deleted in DB + if tc.expectedStatus == http.StatusOK { + db := testing_tools.GetDB(t, am.GetStore()) + testing_tools.VerifyPeerNotInDB(t, db, tc.requestId) + } + }) + } + } +} + +func Test_Peers_GetAccessiblePeers(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + { + name: "Regular user", + userId: testing_tools.TestUserId, + expectResponse: false, + }, + { + name: "Admin user", + userId: testing_tools.TestAdminId, + expectResponse: true, + }, + { + name: "Owner user", + userId: testing_tools.TestOwnerId, + expectResponse: true, + }, + { + name: "Regular service user", + userId: testing_tools.TestServiceUserId, + expectResponse: false, + }, + { + name: "Admin service user", + userId: testing_tools.TestServiceAdminId, + expectResponse: true, + }, + { + name: "Blocked user", + userId: testing_tools.BlockedUserId, + expectResponse: false, + }, + { + name: "Other user", + userId: testing_tools.OtherUserId, + expectResponse: false, + }, + { + name: "Invalid token", + userId: testing_tools.InvalidToken, + expectResponse: false, + }, + } + + tt := []struct { + name string + expectedStatus int + requestType string + requestPath string + requestId string + }{ + { + name: "Get accessible peers for existing peer", + requestType: http.MethodGet, + requestPath: "/api/peers/{peerId}/accessible-peers", + requestId: testing_tools.TestPeerId, + expectedStatus: http.StatusOK, + }, + { + name: "Get accessible peers for non-existing peer", + requestType: http.MethodGet, + requestPath: "/api/peers/{peerId}/accessible-peers", + requestId: "nonExistingPeerId", + expectedStatus: http.StatusOK, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/peers_integration.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, tc.requestType, strings.Replace(tc.requestPath, "{peerId}", tc.requestId, 1), user.userId) + recorder := httptest.NewRecorder() + + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.expectedStatus == http.StatusOK { + var got []api.AccessiblePeer + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + // The accessible peers list should be a valid array (may be empty if no policies connect peers) + assert.NotNil(t, got, "Expected accessible peers to be a valid array") + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} diff --git a/management/server/http/testing/integration/policies_handler_integration_test.go b/management/server/http/testing/integration/policies_handler_integration_test.go new file mode 100644 index 000000000..6f3624fb5 --- /dev/null +++ b/management/server/http/testing/integration/policies_handler_integration_test.go @@ -0,0 +1,488 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools/channel" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +func Test_Policies_GetAll(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all policies", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/policies.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/policies", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []api.Policy{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.Equal(t, 1, len(got)) + assert.Equal(t, "testPolicy", got[0].Name) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_Policies_GetById(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + policyId string + expectedStatus int + expectPolicy bool + }{ + { + name: "Get existing policy", + policyId: "testPolicyId", + expectedStatus: http.StatusOK, + expectPolicy: true, + }, + { + name: "Get non-existing policy", + policyId: "nonExistingPolicyId", + expectedStatus: http.StatusNotFound, + expectPolicy: false, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/policies.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, strings.Replace("/api/policies/{policyId}", "{policyId}", tc.policyId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.expectPolicy { + got := &api.Policy{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + assert.NotNil(t, got.Id) + assert.Equal(t, tc.policyId, *got.Id) + assert.Equal(t, "testPolicy", got.Name) + assert.Equal(t, true, got.Enabled) + assert.GreaterOrEqual(t, len(got.Rules), 1) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_Policies_Create(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + srcGroups := []string{testing_tools.TestGroupId} + dstGroups := []string{testing_tools.TestGroupId} + + tt := []struct { + name string + requestBody *api.PolicyCreate + expectedStatus int + verifyResponse func(t *testing.T, policy *api.Policy) + }{ + { + name: "Create policy with accept rule", + requestBody: &api.PolicyCreate{ + Name: "newPolicy", + Enabled: true, + Rules: []api.PolicyRuleUpdate{ + { + Name: "allowAll", + Enabled: true, + Action: "accept", + Protocol: "all", + Bidirectional: true, + Sources: &srcGroups, + Destinations: &dstGroups, + }, + }, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, policy *api.Policy) { + t.Helper() + assert.NotNil(t, policy.Id) + assert.Equal(t, "newPolicy", policy.Name) + assert.Equal(t, true, policy.Enabled) + assert.Equal(t, 1, len(policy.Rules)) + assert.Equal(t, "allowAll", policy.Rules[0].Name) + }, + }, + { + name: "Create policy with drop rule", + requestBody: &api.PolicyCreate{ + Name: "dropPolicy", + Enabled: true, + Rules: []api.PolicyRuleUpdate{ + { + Name: "dropAll", + Enabled: true, + Action: "drop", + Protocol: "all", + Bidirectional: true, + Sources: &srcGroups, + Destinations: &dstGroups, + }, + }, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, policy *api.Policy) { + t.Helper() + assert.Equal(t, "dropPolicy", policy.Name) + }, + }, + { + name: "Create policy with TCP rule and ports", + requestBody: &api.PolicyCreate{ + Name: "tcpPolicy", + Enabled: true, + Rules: []api.PolicyRuleUpdate{ + { + Name: "tcpRule", + Enabled: true, + Action: "accept", + Protocol: "tcp", + Bidirectional: true, + Sources: &srcGroups, + Destinations: &dstGroups, + Ports: &[]string{"80", "443"}, + }, + }, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, policy *api.Policy) { + t.Helper() + assert.Equal(t, "tcpPolicy", policy.Name) + assert.NotNil(t, policy.Rules[0].Ports) + assert.Equal(t, 2, len(*policy.Rules[0].Ports)) + }, + }, + { + name: "Create policy with empty name", + requestBody: &api.PolicyCreate{ + Name: "", + Enabled: true, + Rules: []api.PolicyRuleUpdate{ + { + Name: "rule", + Enabled: true, + Action: "accept", + Protocol: "all", + Sources: &srcGroups, + Destinations: &dstGroups, + }, + }, + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + { + name: "Create policy with no rules", + requestBody: &api.PolicyCreate{ + Name: "noRulesPolicy", + Enabled: true, + Rules: []api.PolicyRuleUpdate{}, + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/policies.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPost, "/api/policies", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.Policy{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify policy exists in DB with correct fields + db := testing_tools.GetDB(t, am.GetStore()) + dbPolicy := testing_tools.VerifyPolicyInDB(t, db, *got.Id) + assert.Equal(t, tc.requestBody.Name, dbPolicy.Name) + assert.Equal(t, tc.requestBody.Enabled, dbPolicy.Enabled) + assert.Equal(t, len(tc.requestBody.Rules), len(dbPolicy.Rules)) + } + }) + } + } +} + +func Test_Policies_Update(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + srcGroups := []string{testing_tools.TestGroupId} + dstGroups := []string{testing_tools.TestGroupId} + + tt := []struct { + name string + policyId string + requestBody *api.PolicyCreate + expectedStatus int + verifyResponse func(t *testing.T, policy *api.Policy) + }{ + { + name: "Update policy name", + policyId: "testPolicyId", + requestBody: &api.PolicyCreate{ + Name: "updatedPolicy", + Enabled: true, + Rules: []api.PolicyRuleUpdate{ + { + Name: "testRule", + Enabled: true, + Action: "accept", + Protocol: "all", + Bidirectional: true, + Sources: &srcGroups, + Destinations: &dstGroups, + }, + }, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, policy *api.Policy) { + t.Helper() + assert.Equal(t, "updatedPolicy", policy.Name) + }, + }, + { + name: "Update policy enabled state", + policyId: "testPolicyId", + requestBody: &api.PolicyCreate{ + Name: "testPolicy", + Enabled: false, + Rules: []api.PolicyRuleUpdate{ + { + Name: "testRule", + Enabled: true, + Action: "accept", + Protocol: "all", + Bidirectional: true, + Sources: &srcGroups, + Destinations: &dstGroups, + }, + }, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, policy *api.Policy) { + t.Helper() + assert.Equal(t, false, policy.Enabled) + }, + }, + { + name: "Update non-existing policy", + policyId: "nonExistingPolicyId", + requestBody: &api.PolicyCreate{ + Name: "whatever", + Enabled: true, + Rules: []api.PolicyRuleUpdate{ + { + Name: "rule", + Enabled: true, + Action: "accept", + Protocol: "all", + Sources: &srcGroups, + Destinations: &dstGroups, + }, + }, + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/policies.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPut, strings.Replace("/api/policies/{policyId}", "{policyId}", tc.policyId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.Policy{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify updated policy in DB + db := testing_tools.GetDB(t, am.GetStore()) + dbPolicy := testing_tools.VerifyPolicyInDB(t, db, tc.policyId) + assert.Equal(t, tc.requestBody.Name, dbPolicy.Name) + assert.Equal(t, tc.requestBody.Enabled, dbPolicy.Enabled) + } + }) + } + } +} + +func Test_Policies_Delete(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + policyId string + expectedStatus int + }{ + { + name: "Delete existing policy", + policyId: "testPolicyId", + expectedStatus: http.StatusOK, + }, + { + name: "Delete non-existing policy", + policyId: "nonExistingPolicyId", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/policies.sql", nil, false) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodDelete, strings.Replace("/api/policies/{policyId}", "{policyId}", tc.policyId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + _, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if expectResponse && tc.expectedStatus == http.StatusOK { + db := testing_tools.GetDB(t, am.GetStore()) + testing_tools.VerifyPolicyNotInDB(t, db, tc.policyId) + } + }) + } + } +} diff --git a/management/server/http/testing/integration/routes_handler_integration_test.go b/management/server/http/testing/integration/routes_handler_integration_test.go new file mode 100644 index 000000000..eeb0c3025 --- /dev/null +++ b/management/server/http/testing/integration/routes_handler_integration_test.go @@ -0,0 +1,455 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools/channel" + "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +func Test_Routes_GetAll(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all routes", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/routes.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/routes", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []api.Route{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.Equal(t, 2, len(got)) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_Routes_GetById(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + routeId string + expectedStatus int + expectRoute bool + }{ + { + name: "Get existing route", + routeId: "testRouteId", + expectedStatus: http.StatusOK, + expectRoute: true, + }, + { + name: "Get non-existing route", + routeId: "nonExistingRouteId", + expectedStatus: http.StatusNotFound, + expectRoute: false, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/routes.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, strings.Replace("/api/routes/{routeId}", "{routeId}", tc.routeId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.expectRoute { + got := &api.Route{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + assert.Equal(t, tc.routeId, got.Id) + assert.Equal(t, "Test Network Route", got.Description) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_Routes_Create(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + networkCIDR := "10.10.0.0/24" + peerID := testing_tools.TestPeerId + peerGroups := []string{"peerGroupId"} + + tt := []struct { + name string + requestBody *api.RouteRequest + expectedStatus int + verifyResponse func(t *testing.T, route *api.Route) + }{ + { + name: "Create network route with peer", + requestBody: &api.RouteRequest{ + Description: "New network route", + Network: &networkCIDR, + Peer: &peerID, + NetworkId: "newNet", + Metric: 100, + Masquerade: true, + Enabled: true, + Groups: []string{testing_tools.TestGroupId}, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, route *api.Route) { + t.Helper() + assert.NotEmpty(t, route.Id) + assert.Equal(t, "New network route", route.Description) + assert.Equal(t, 100, route.Metric) + assert.Equal(t, true, route.Masquerade) + assert.Equal(t, true, route.Enabled) + }, + }, + { + name: "Create network route with peer groups", + requestBody: &api.RouteRequest{ + Description: "Route with peer groups", + Network: &networkCIDR, + PeerGroups: &peerGroups, + NetworkId: "peerGroupNet", + Metric: 150, + Masquerade: false, + Enabled: true, + Groups: []string{testing_tools.TestGroupId}, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, route *api.Route) { + t.Helper() + assert.NotEmpty(t, route.Id) + assert.Equal(t, "Route with peer groups", route.Description) + }, + }, + { + name: "Create route with empty network_id", + requestBody: &api.RouteRequest{ + Description: "Empty net id", + Network: &networkCIDR, + Peer: &peerID, + NetworkId: "", + Metric: 100, + Enabled: true, + Groups: []string{testing_tools.TestGroupId}, + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + { + name: "Create route with metric 0", + requestBody: &api.RouteRequest{ + Description: "Zero metric", + Network: &networkCIDR, + Peer: &peerID, + NetworkId: "zeroMetric", + Metric: 0, + Enabled: true, + Groups: []string{testing_tools.TestGroupId}, + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + { + name: "Create route with metric 10000", + requestBody: &api.RouteRequest{ + Description: "High metric", + Network: &networkCIDR, + Peer: &peerID, + NetworkId: "highMetric", + Metric: 10000, + Enabled: true, + Groups: []string{testing_tools.TestGroupId}, + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/routes.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPost, "/api/routes", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.Route{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify route exists in DB with correct fields + db := testing_tools.GetDB(t, am.GetStore()) + dbRoute := testing_tools.VerifyRouteInDB(t, db, route.ID(got.Id)) + assert.Equal(t, tc.requestBody.Description, dbRoute.Description) + assert.Equal(t, tc.requestBody.Metric, dbRoute.Metric) + assert.Equal(t, tc.requestBody.Masquerade, dbRoute.Masquerade) + assert.Equal(t, tc.requestBody.Enabled, dbRoute.Enabled) + assert.Equal(t, route.NetID(tc.requestBody.NetworkId), dbRoute.NetID) + } + }) + } + } +} + +func Test_Routes_Update(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + networkCIDR := "10.0.0.0/24" + peerID := testing_tools.TestPeerId + + tt := []struct { + name string + routeId string + requestBody *api.RouteRequest + expectedStatus int + verifyResponse func(t *testing.T, route *api.Route) + }{ + { + name: "Update route description", + routeId: "testRouteId", + requestBody: &api.RouteRequest{ + Description: "Updated description", + Network: &networkCIDR, + Peer: &peerID, + NetworkId: "testNet", + Metric: 100, + Masquerade: true, + Enabled: true, + Groups: []string{testing_tools.TestGroupId}, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, route *api.Route) { + t.Helper() + assert.Equal(t, "testRouteId", route.Id) + assert.Equal(t, "Updated description", route.Description) + }, + }, + { + name: "Update route metric", + routeId: "testRouteId", + requestBody: &api.RouteRequest{ + Description: "Test Network Route", + Network: &networkCIDR, + Peer: &peerID, + NetworkId: "testNet", + Metric: 500, + Masquerade: true, + Enabled: true, + Groups: []string{testing_tools.TestGroupId}, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, route *api.Route) { + t.Helper() + assert.Equal(t, 500, route.Metric) + }, + }, + { + name: "Update non-existing route", + routeId: "nonExistingRouteId", + requestBody: &api.RouteRequest{ + Description: "whatever", + Network: &networkCIDR, + Peer: &peerID, + NetworkId: "testNet", + Metric: 100, + Enabled: true, + Groups: []string{testing_tools.TestGroupId}, + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/routes.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPut, strings.Replace("/api/routes/{routeId}", "{routeId}", tc.routeId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.Route{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify updated route in DB + db := testing_tools.GetDB(t, am.GetStore()) + dbRoute := testing_tools.VerifyRouteInDB(t, db, route.ID(got.Id)) + assert.Equal(t, tc.requestBody.Description, dbRoute.Description) + assert.Equal(t, tc.requestBody.Metric, dbRoute.Metric) + assert.Equal(t, tc.requestBody.Masquerade, dbRoute.Masquerade) + assert.Equal(t, tc.requestBody.Enabled, dbRoute.Enabled) + } + }) + } + } +} + +func Test_Routes_Delete(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + routeId string + expectedStatus int + }{ + { + name: "Delete existing route", + routeId: "testRouteId", + expectedStatus: http.StatusOK, + }, + { + name: "Delete non-existing route", + routeId: "nonExistingRouteId", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/routes.sql", nil, false) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodDelete, strings.Replace("/api/routes/{routeId}", "{routeId}", tc.routeId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + + // Verify route was deleted from DB for successful deletes + if tc.expectedStatus == http.StatusOK && user.expectResponse { + db := testing_tools.GetDB(t, am.GetStore()) + testing_tools.VerifyRouteNotInDB(t, db, route.ID(tc.routeId)) + } + }) + } + } +} diff --git a/management/server/http/testing/integration/setupkeys_handler_integration_test.go b/management/server/http/testing/integration/setupkeys_handler_integration_test.go index 1079de4aa..0d3aaac82 100644 --- a/management/server/http/testing/integration/setupkeys_handler_integration_test.go +++ b/management/server/http/testing/integration/setupkeys_handler_integration_test.go @@ -1,10 +1,8 @@ //go:build integration -// +build integration package integration import ( - "context" "encoding/json" "net/http" "net/http/httptest" @@ -15,7 +13,6 @@ import ( "github.com/stretchr/testify/assert" - "github.com/netbirdio/netbird/management/server/http/handlers/setup_keys" "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" "github.com/netbirdio/netbird/management/server/http/testing/testing_tools/channel" "github.com/netbirdio/netbird/shared/management/http/api" @@ -255,7 +252,7 @@ func Test_SetupKeys_Create(t *testing.T) { expectedResponse: nil, }, { - name: "Create Setup Key", + name: "Create Setup Key with nil AutoGroups", requestType: http.MethodPost, requestPath: "/api/setup-keys", requestBody: &api.CreateSetupKeyRequest{ @@ -309,14 +306,15 @@ func Test_SetupKeys_Create(t *testing.T) { t.Fatalf("Sent content is not in correct json format; %v", err) } + gotID := got.Id validateCreatedKey(t, tc.expectedResponse, got) - key, err := am.GetSetupKey(context.Background(), testing_tools.TestAccountId, testing_tools.TestUserId, got.Id) - if err != nil { - return - } - - validateCreatedKey(t, tc.expectedResponse, setup_keys.ToResponseBody(key)) + // Verify setup key exists in DB via gorm + db := testing_tools.GetDB(t, am.GetStore()) + dbKey := testing_tools.VerifySetupKeyInDB(t, db, gotID) + assert.Equal(t, tc.expectedResponse.Name, dbKey.Name) + assert.Equal(t, tc.expectedResponse.Revoked, dbKey.Revoked) + assert.Equal(t, tc.expectedResponse.UsageLimit, dbKey.UsageLimit) select { case <-done: @@ -572,7 +570,7 @@ func Test_SetupKeys_Update(t *testing.T) { for _, tc := range tt { for _, user := range users { - t.Run(tc.name, func(t *testing.T) { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { apiHandler, am, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/setup_keys.sql", nil, true) body, err := json.Marshal(tc.requestBody) @@ -595,14 +593,16 @@ func Test_SetupKeys_Update(t *testing.T) { t.Fatalf("Sent content is not in correct json format; %v", err) } + gotID := got.Id + gotRevoked := got.Revoked + gotUsageLimit := got.UsageLimit validateCreatedKey(t, tc.expectedResponse, got) - key, err := am.GetSetupKey(context.Background(), testing_tools.TestAccountId, testing_tools.TestUserId, got.Id) - if err != nil { - return - } - - validateCreatedKey(t, tc.expectedResponse, setup_keys.ToResponseBody(key)) + // Verify updated setup key in DB via gorm + db := testing_tools.GetDB(t, am.GetStore()) + dbKey := testing_tools.VerifySetupKeyInDB(t, db, gotID) + assert.Equal(t, gotRevoked, dbKey.Revoked) + assert.Equal(t, gotUsageLimit, dbKey.UsageLimit) select { case <-done: @@ -760,8 +760,8 @@ func Test_SetupKeys_Get(t *testing.T) { apiHandler.ServeHTTP(recorder, req) - content, expectRespnose := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) - if !expectRespnose { + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { return } got := &api.SetupKey{} @@ -769,14 +769,16 @@ func Test_SetupKeys_Get(t *testing.T) { t.Fatalf("Sent content is not in correct json format; %v", err) } + gotID := got.Id + gotName := got.Name + gotRevoked := got.Revoked validateCreatedKey(t, tc.expectedResponse, got) - key, err := am.GetSetupKey(context.Background(), testing_tools.TestAccountId, testing_tools.TestUserId, got.Id) - if err != nil { - return - } - - validateCreatedKey(t, tc.expectedResponse, setup_keys.ToResponseBody(key)) + // Verify setup key in DB via gorm + db := testing_tools.GetDB(t, am.GetStore()) + dbKey := testing_tools.VerifySetupKeyInDB(t, db, gotID) + assert.Equal(t, gotName, dbKey.Name) + assert.Equal(t, gotRevoked, dbKey.Revoked) select { case <-done: @@ -929,15 +931,17 @@ func Test_SetupKeys_GetAll(t *testing.T) { return tc.expectedResponse[i].UsageLimit < tc.expectedResponse[j].UsageLimit }) + db := testing_tools.GetDB(t, am.GetStore()) for i := range tc.expectedResponse { + gotID := got[i].Id + gotName := got[i].Name + gotRevoked := got[i].Revoked validateCreatedKey(t, tc.expectedResponse[i], &got[i]) - key, err := am.GetSetupKey(context.Background(), testing_tools.TestAccountId, testing_tools.TestUserId, got[i].Id) - if err != nil { - return - } - - validateCreatedKey(t, tc.expectedResponse[i], setup_keys.ToResponseBody(key)) + // Verify each setup key in DB via gorm + dbKey := testing_tools.VerifySetupKeyInDB(t, db, gotID) + assert.Equal(t, gotName, dbKey.Name) + assert.Equal(t, gotRevoked, dbKey.Revoked) } select { @@ -1105,8 +1109,9 @@ func Test_SetupKeys_Delete(t *testing.T) { t.Fatalf("Sent content is not in correct json format; %v", err) } - _, err := am.GetSetupKey(context.Background(), testing_tools.TestAccountId, testing_tools.TestUserId, got.Id) - assert.Errorf(t, err, "Expected error when trying to get deleted key") + // Verify setup key deleted from DB via gorm + db := testing_tools.GetDB(t, am.GetStore()) + testing_tools.VerifySetupKeyNotInDB(t, db, got.Id) select { case <-done: @@ -1121,7 +1126,7 @@ func Test_SetupKeys_Delete(t *testing.T) { func validateCreatedKey(t *testing.T, expectedKey *api.SetupKey, got *api.SetupKey) { t.Helper() - if got.Expires.After(time.Now().Add(-1*time.Minute)) && got.Expires.Before(time.Now().Add(testing_tools.ExpiresIn*time.Second)) || + if (got.Expires.After(time.Now().Add(-1*time.Minute)) && got.Expires.Before(time.Now().Add(testing_tools.ExpiresIn*time.Second))) || got.Expires.After(time.Date(2300, 01, 01, 0, 0, 0, 0, time.Local)) || got.Expires.Before(time.Date(1950, 01, 01, 0, 0, 0, 0, time.Local)) { got.Expires = time.Time{} diff --git a/management/server/http/testing/integration/users_handler_integration_test.go b/management/server/http/testing/integration/users_handler_integration_test.go new file mode 100644 index 000000000..eae3b4ad5 --- /dev/null +++ b/management/server/http/testing/integration/users_handler_integration_test.go @@ -0,0 +1,701 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" + "github.com/netbirdio/netbird/management/server/http/testing/testing_tools/channel" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +func Test_Users_GetAll(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, true}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, true}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all users", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/users_integration.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/users", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []api.User{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.GreaterOrEqual(t, len(got), 1) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_Users_GetAll_ServiceUsers(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all service users", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/users_integration.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, "/api/users?service_user=true", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []api.User{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + for _, u := range got { + assert.NotNil(t, u.IsServiceUser) + assert.Equal(t, true, *u.IsServiceUser) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_Users_Create_ServiceUser(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + requestBody *api.UserCreateRequest + expectedStatus int + verifyResponse func(t *testing.T, user *api.User) + }{ + { + name: "Create service user with admin role", + requestBody: &api.UserCreateRequest{ + Role: "admin", + IsServiceUser: true, + AutoGroups: []string{testing_tools.TestGroupId}, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, user *api.User) { + t.Helper() + assert.NotEmpty(t, user.Id) + assert.Equal(t, "admin", user.Role) + assert.NotNil(t, user.IsServiceUser) + assert.Equal(t, true, *user.IsServiceUser) + }, + }, + { + name: "Create service user with user role", + requestBody: &api.UserCreateRequest{ + Role: "user", + IsServiceUser: true, + AutoGroups: []string{testing_tools.TestGroupId}, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, user *api.User) { + t.Helper() + assert.NotEmpty(t, user.Id) + assert.Equal(t, "user", user.Role) + }, + }, + { + name: "Create service user with empty auto_groups", + requestBody: &api.UserCreateRequest{ + Role: "admin", + IsServiceUser: true, + AutoGroups: []string{}, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, user *api.User) { + t.Helper() + assert.NotEmpty(t, user.Id) + }, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/users_integration.sql", nil, true) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPost, "/api/users", user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.User{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify user in DB + db := testing_tools.GetDB(t, am.GetStore()) + dbUser := testing_tools.VerifyUserInDB(t, db, got.Id) + assert.True(t, dbUser.IsServiceUser) + assert.Equal(t, string(dbUser.Role), string(tc.requestBody.Role)) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_Users_Update(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + targetUserId string + requestBody *api.UserRequest + expectedStatus int + verifyResponse func(t *testing.T, user *api.User) + }{ + { + name: "Update user role to admin", + targetUserId: testing_tools.TestUserId, + requestBody: &api.UserRequest{ + Role: "admin", + AutoGroups: []string{}, + IsBlocked: false, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, user *api.User) { + t.Helper() + assert.Equal(t, "admin", user.Role) + }, + }, + { + name: "Update user auto_groups", + targetUserId: testing_tools.TestUserId, + requestBody: &api.UserRequest{ + Role: "user", + AutoGroups: []string{testing_tools.TestGroupId}, + IsBlocked: false, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, user *api.User) { + t.Helper() + assert.Equal(t, 1, len(user.AutoGroups)) + }, + }, + { + name: "Block user", + targetUserId: testing_tools.TestUserId, + requestBody: &api.UserRequest{ + Role: "user", + AutoGroups: []string{}, + IsBlocked: true, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, user *api.User) { + t.Helper() + assert.Equal(t, true, user.IsBlocked) + }, + }, + { + name: "Update non-existing user", + targetUserId: "nonExistingUserId", + requestBody: &api.UserRequest{ + Role: "user", + AutoGroups: []string{}, + IsBlocked: false, + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, _ := channel.BuildApiBlackBoxWithDBState(t, "../testdata/users_integration.sql", nil, false) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPut, strings.Replace("/api/users/{userId}", "{userId}", tc.targetUserId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.User{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify updated fields in DB + if tc.expectedStatus == http.StatusOK { + db := testing_tools.GetDB(t, am.GetStore()) + dbUser := testing_tools.VerifyUserInDB(t, db, tc.targetUserId) + assert.Equal(t, string(dbUser.Role), string(tc.requestBody.Role)) + assert.Equal(t, dbUser.Blocked, tc.requestBody.IsBlocked) + assert.ElementsMatch(t, dbUser.AutoGroups, tc.requestBody.AutoGroups) + } + } + }) + } + } +} + +func Test_Users_Delete(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + targetUserId string + expectedStatus int + }{ + { + name: "Delete existing service user", + targetUserId: "deletableServiceUserId", + expectedStatus: http.StatusOK, + }, + { + name: "Delete non-existing user", + targetUserId: "nonExistingUserId", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/users_integration.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodDelete, strings.Replace("/api/users/{userId}", "{userId}", tc.targetUserId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + _, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + + // Verify user deleted from DB for successful deletes + if expectResponse && tc.expectedStatus == http.StatusOK { + db := testing_tools.GetDB(t, am.GetStore()) + testing_tools.VerifyUserNotInDB(t, db, tc.targetUserId) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_PATs_GetAll(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + for _, user := range users { + t.Run(user.name+" - Get all PATs for service user", func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/users_integration.sql", nil, true) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, strings.Replace("/api/users/{userId}/tokens", "{userId}", testing_tools.TestServiceUserId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, http.StatusOK, user.expectResponse) + if !expectResponse { + return + } + + got := []api.PersonalAccessToken{} + if err := json.Unmarshal(content, &got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + + assert.Equal(t, 1, len(got)) + assert.Equal(t, "serviceToken", got[0].Name) + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } +} + +func Test_PATs_GetById(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + tokenId string + expectedStatus int + expectToken bool + }{ + { + name: "Get existing PAT", + tokenId: "serviceTokenId", + expectedStatus: http.StatusOK, + expectToken: true, + }, + { + name: "Get non-existing PAT", + tokenId: "nonExistingTokenId", + expectedStatus: http.StatusNotFound, + expectToken: false, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, _, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/users_integration.sql", nil, true) + + path := strings.Replace("/api/users/{userId}/tokens/{tokenId}", "{userId}", testing_tools.TestServiceUserId, 1) + path = strings.Replace(path, "{tokenId}", tc.tokenId, 1) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodGet, path, user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.expectToken { + got := &api.PersonalAccessToken{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + assert.Equal(t, "serviceTokenId", got.Id) + assert.Equal(t, "serviceToken", got.Name) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_PATs_Create(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + targetUserId string + requestBody *api.PersonalAccessTokenRequest + expectedStatus int + verifyResponse func(t *testing.T, pat *api.PersonalAccessTokenGenerated) + }{ + { + name: "Create PAT with 30 day expiry", + targetUserId: testing_tools.TestServiceUserId, + requestBody: &api.PersonalAccessTokenRequest{ + Name: "newPAT", + ExpiresIn: 30, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, pat *api.PersonalAccessTokenGenerated) { + t.Helper() + assert.NotEmpty(t, pat.PlainToken) + assert.Equal(t, "newPAT", pat.PersonalAccessToken.Name) + }, + }, + { + name: "Create PAT with 365 day expiry", + targetUserId: testing_tools.TestServiceUserId, + requestBody: &api.PersonalAccessTokenRequest{ + Name: "longPAT", + ExpiresIn: 365, + }, + expectedStatus: http.StatusOK, + verifyResponse: func(t *testing.T, pat *api.PersonalAccessTokenGenerated) { + t.Helper() + assert.NotEmpty(t, pat.PlainToken) + assert.Equal(t, "longPAT", pat.PersonalAccessToken.Name) + }, + }, + { + name: "Create PAT with empty name", + targetUserId: testing_tools.TestServiceUserId, + requestBody: &api.PersonalAccessTokenRequest{ + Name: "", + ExpiresIn: 30, + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + { + name: "Create PAT with 0 day expiry", + targetUserId: testing_tools.TestServiceUserId, + requestBody: &api.PersonalAccessTokenRequest{ + Name: "zeroPAT", + ExpiresIn: 0, + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + { + name: "Create PAT with expiry over 365 days", + targetUserId: testing_tools.TestServiceUserId, + requestBody: &api.PersonalAccessTokenRequest{ + Name: "tooLongPAT", + ExpiresIn: 400, + }, + expectedStatus: http.StatusUnprocessableEntity, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/users_integration.sql", nil, true) + + body, err := json.Marshal(tc.requestBody) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req := testing_tools.BuildRequest(t, body, http.MethodPost, strings.Replace("/api/users/{userId}/tokens", "{userId}", tc.targetUserId, 1), user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + content, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + if !expectResponse { + return + } + + if tc.verifyResponse != nil { + got := &api.PersonalAccessTokenGenerated{} + if err := json.Unmarshal(content, got); err != nil { + t.Fatalf("Sent content is not in correct json format; %v", err) + } + tc.verifyResponse(t, got) + + // Verify PAT in DB + db := testing_tools.GetDB(t, am.GetStore()) + dbPAT := testing_tools.VerifyPATInDB(t, db, got.PersonalAccessToken.Id) + assert.Equal(t, tc.requestBody.Name, dbPAT.Name) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} + +func Test_PATs_Delete(t *testing.T) { + users := []struct { + name string + userId string + expectResponse bool + }{ + {"Regular user", testing_tools.TestUserId, false}, + {"Admin user", testing_tools.TestAdminId, true}, + {"Owner user", testing_tools.TestOwnerId, true}, + {"Regular service user", testing_tools.TestServiceUserId, false}, + {"Admin service user", testing_tools.TestServiceAdminId, true}, + {"Blocked user", testing_tools.BlockedUserId, false}, + {"Other user", testing_tools.OtherUserId, false}, + {"Invalid token", testing_tools.InvalidToken, false}, + } + + tt := []struct { + name string + tokenId string + expectedStatus int + }{ + { + name: "Delete existing PAT", + tokenId: "serviceTokenId", + expectedStatus: http.StatusOK, + }, + { + name: "Delete non-existing PAT", + tokenId: "nonExistingTokenId", + expectedStatus: http.StatusNotFound, + }, + } + + for _, tc := range tt { + for _, user := range users { + t.Run(user.name+" - "+tc.name, func(t *testing.T) { + apiHandler, am, done := channel.BuildApiBlackBoxWithDBState(t, "../testdata/users_integration.sql", nil, true) + + path := strings.Replace("/api/users/{userId}/tokens/{tokenId}", "{userId}", testing_tools.TestServiceUserId, 1) + path = strings.Replace(path, "{tokenId}", tc.tokenId, 1) + + req := testing_tools.BuildRequest(t, []byte{}, http.MethodDelete, path, user.userId) + recorder := httptest.NewRecorder() + apiHandler.ServeHTTP(recorder, req) + + _, expectResponse := testing_tools.ReadResponse(t, recorder, tc.expectedStatus, user.expectResponse) + + // Verify PAT deleted from DB for successful deletes + if expectResponse && tc.expectedStatus == http.StatusOK { + db := testing_tools.GetDB(t, am.GetStore()) + testing_tools.VerifyPATNotInDB(t, db, tc.tokenId) + } + + select { + case <-done: + case <-time.After(time.Second): + t.Error("timeout waiting for peerShouldNotReceiveUpdate") + } + }) + } + } +} diff --git a/management/server/http/testing/testdata/accounts.sql b/management/server/http/testing/testdata/accounts.sql new file mode 100644 index 000000000..35f00d419 --- /dev/null +++ b/management/server/http/testing/testdata/accounts.sql @@ -0,0 +1,18 @@ +CREATE TABLE `accounts` (`id` text,`created_by` text,`created_at` datetime,`domain` text,`domain_category` text,`is_domain_primary_account` numeric,`network_identifier` text,`network_net` text,`network_dns` text,`network_serial` integer,`dns_settings_disabled_management_groups` text,`settings_peer_login_expiration_enabled` numeric,`settings_peer_login_expiration` integer,`settings_regular_users_view_blocked` numeric,`settings_groups_propagation_enabled` numeric,`settings_jwt_groups_enabled` numeric,`settings_jwt_groups_claim_name` text,`settings_jwt_allow_groups` text,`settings_extra_peer_approval_enabled` numeric,`settings_extra_integrated_validator_groups` text,PRIMARY KEY (`id`)); +CREATE TABLE `users` (`id` text,`account_id` text,`role` text,`is_service_user` numeric,`non_deletable` numeric,`service_user_name` text,`auto_groups` text,`blocked` numeric,`last_login` datetime DEFAULT NULL,`created_at` datetime,`issued` text DEFAULT "api",`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_users_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `groups` (`id` text,`account_id` text,`name` text,`issued` text,`peers` text,`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_groups_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `peers` (`id` text,`account_id` text,`key` text,`setup_key` text,`ip` text,`meta_hostname` text,`meta_go_os` text,`meta_kernel` text,`meta_core` text,`meta_platform` text,`meta_os` text,`meta_os_version` text,`meta_wt_version` text,`meta_ui_version` text,`meta_kernel_version` text,`meta_network_addresses` text,`meta_system_serial_number` text,`meta_system_product_name` text,`meta_system_manufacturer` text,`meta_environment` text,`meta_files` text,`name` text,`dns_label` text,`peer_status_last_seen` datetime,`peer_status_connected` numeric,`peer_status_login_expired` numeric,`peer_status_requires_approval` numeric,`user_id` text,`ssh_key` text,`ssh_enabled` numeric,`login_expiration_enabled` numeric,`last_login` datetime,`created_at` datetime,`ephemeral` numeric,`location_connection_ip` text,`location_country_code` text,`location_city_name` text,`location_geo_name_id` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_peers_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `setup_keys` (`id` text,`account_id` text,`key` text,`key_secret` text,`name` text,`type` text,`created_at` datetime,`expires_at` datetime,`updated_at` datetime,`revoked` numeric,`used_times` integer,`last_used` datetime DEFAULT NULL,`auto_groups` text,`usage_limit` integer,`ephemeral` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_setup_keys_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); + +INSERT INTO accounts VALUES('testAccountId','','2024-10-02 16:01:38.000000000+00:00','test.com','private',1,'testNetworkIdentifier','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',1,86400000000000,0,0,0,'',NULL,NULL,NULL); +INSERT INTO users VALUES('testUserId','testAccountId','user',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testAdminId','testAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testOwnerId','testAccountId','owner',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceUserId','testAccountId','user',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceAdminId','testAccountId','admin',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('blockedUserId','testAccountId','admin',0,0,'','[]',1,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('otherUserId','otherAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO "groups" VALUES('testGroupId','testAccountId','testGroupName','api','[]',0,''); +INSERT INTO "groups" VALUES('newGroupId','testAccountId','newGroupName','api','[]',0,''); +INSERT INTO peers VALUES('testPeerId','testAccountId','5rvhvriKJZ3S9oxYToVj5TzDM9u9y8cxg7htIMWlYAg=','72546A29-6BC8-4311-BCFC-9CDBF33F1A48','"100.64.114.31"','f2a34f6a4731','linux','Linux','11','unknown','Debian GNU/Linux','','0.12.0','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'f2a34f6a4731','f2a34f6a4731','2023-03-02 09:21:02.189035775+01:00',0,0,0,'','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILzUUSYG/LGnV8zarb2SGN+tib/PZ+M7cL4WtTzUrTpk',0,1,'2023-03-01 19:48:19.817799698+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); +INSERT INTO setup_keys VALUES('testKeyId','testAccountId','testKey','testK****','existingKey','one-off','2021-08-19 20:46:20.000000000+00:00','2321-09-18 20:46:20.000000000+00:00','2021-08-19 20:46:20.000000000+00:000',0,0,NULL,'["testGroupId"]',1,0); diff --git a/management/server/http/testing/testdata/dns.sql b/management/server/http/testing/testdata/dns.sql new file mode 100644 index 000000000..9ed4daf7e --- /dev/null +++ b/management/server/http/testing/testdata/dns.sql @@ -0,0 +1,21 @@ +CREATE TABLE `accounts` (`id` text,`created_by` text,`created_at` datetime,`domain` text,`domain_category` text,`is_domain_primary_account` numeric,`network_identifier` text,`network_net` text,`network_dns` text,`network_serial` integer,`dns_settings_disabled_management_groups` text,`settings_peer_login_expiration_enabled` numeric,`settings_peer_login_expiration` integer,`settings_regular_users_view_blocked` numeric,`settings_groups_propagation_enabled` numeric,`settings_jwt_groups_enabled` numeric,`settings_jwt_groups_claim_name` text,`settings_jwt_allow_groups` text,`settings_extra_peer_approval_enabled` numeric,`settings_extra_integrated_validator_groups` text,PRIMARY KEY (`id`)); +CREATE TABLE `users` (`id` text,`account_id` text,`role` text,`is_service_user` numeric,`non_deletable` numeric,`service_user_name` text,`auto_groups` text,`blocked` numeric,`last_login` datetime DEFAULT NULL,`created_at` datetime,`issued` text DEFAULT "api",`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_users_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `groups` (`id` text,`account_id` text,`name` text,`issued` text,`peers` text,`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_groups_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `setup_keys` (`id` text,`account_id` text,`key` text,`key_secret` text,`name` text,`type` text,`created_at` datetime,`expires_at` datetime,`updated_at` datetime,`revoked` numeric,`used_times` integer,`last_used` datetime DEFAULT NULL,`auto_groups` text,`usage_limit` integer,`ephemeral` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_setup_keys_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `peers` (`id` text,`account_id` text,`key` text,`setup_key` text,`ip` text,`meta_hostname` text,`meta_go_os` text,`meta_kernel` text,`meta_core` text,`meta_platform` text,`meta_os` text,`meta_os_version` text,`meta_wt_version` text,`meta_ui_version` text,`meta_kernel_version` text,`meta_network_addresses` text,`meta_system_serial_number` text,`meta_system_product_name` text,`meta_system_manufacturer` text,`meta_environment` text,`meta_files` text,`name` text,`dns_label` text,`peer_status_last_seen` datetime,`peer_status_connected` numeric,`peer_status_login_expired` numeric,`peer_status_requires_approval` numeric,`user_id` text,`ssh_key` text,`ssh_enabled` numeric,`login_expiration_enabled` numeric,`last_login` datetime,`created_at` datetime,`ephemeral` numeric,`location_connection_ip` text,`location_country_code` text,`location_city_name` text,`location_geo_name_id` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_peers_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `name_server_groups` (`id` text,`account_id` text,`name` text,`description` text,`name_servers` text,`groups` text,`primary` numeric,`domains` text,`enabled` numeric,`search_domains_enabled` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_name_server_groups_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); + +INSERT INTO accounts VALUES('testAccountId','','2024-10-02 16:01:38.000000000+00:00','test.com','private',1,'testNetworkIdentifier','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',1,86400000000000,0,0,0,'',NULL,NULL,NULL); +INSERT INTO users VALUES('testUserId','testAccountId','user',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testAdminId','testAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testOwnerId','testAccountId','owner',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceUserId','testAccountId','user',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceAdminId','testAccountId','admin',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('blockedUserId','testAccountId','admin',0,0,'','[]',1,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('otherUserId','otherAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO "groups" VALUES('testGroupId','testAccountId','testGroupName','api','["testPeerId"]',0,''); +INSERT INTO "groups" VALUES('newGroupId','testAccountId','newGroupName','api','[]',0,''); +INSERT INTO setup_keys VALUES('testKeyId','testAccountId','testKey','testK****','existingKey','one-off','2021-08-19 20:46:20.000000000+00:00','2321-09-18 20:46:20.000000000+00:00','2021-08-19 20:46:20.000000000+00:000',0,0,NULL,'["testGroupId"]',1,0); +INSERT INTO peers VALUES('testPeerId','testAccountId','5rvhvriKJZ3S9oxYToVj5TzDM9u9y8cxg7htIMWlYAg=','72546A29-6BC8-4311-BCFC-9CDBF33F1A48','"100.64.114.31"','f2a34f6a4731','linux','Linux','11','unknown','Debian GNU/Linux','','0.12.0','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'f2a34f6a4731','f2a34f6a4731','2023-03-02 09:21:02.189035775+01:00',0,0,0,'','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILzUUSYG/LGnV8zarb2SGN+tib/PZ+M7cL4WtTzUrTpk',0,1,'2023-03-01 19:48:19.817799698+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); + +INSERT INTO name_server_groups VALUES('testNSGroupId','testAccountId','testNSGroup','test nameserver group','[{"IP":"1.1.1.1","NSType":1,"Port":53}]','["testGroupId"]',0,'["example.com"]',1,0); \ No newline at end of file diff --git a/management/server/http/testing/testdata/events.sql b/management/server/http/testing/testdata/events.sql new file mode 100644 index 000000000..27fd01aea --- /dev/null +++ b/management/server/http/testing/testdata/events.sql @@ -0,0 +1,18 @@ +CREATE TABLE `accounts` (`id` text,`created_by` text,`created_at` datetime,`domain` text,`domain_category` text,`is_domain_primary_account` numeric,`network_identifier` text,`network_net` text,`network_dns` text,`network_serial` integer,`dns_settings_disabled_management_groups` text,`settings_peer_login_expiration_enabled` numeric,`settings_peer_login_expiration` integer,`settings_regular_users_view_blocked` numeric,`settings_groups_propagation_enabled` numeric,`settings_jwt_groups_enabled` numeric,`settings_jwt_groups_claim_name` text,`settings_jwt_allow_groups` text,`settings_extra_peer_approval_enabled` numeric,`settings_extra_integrated_validator_groups` text,PRIMARY KEY (`id`)); +CREATE TABLE `users` (`id` text,`account_id` text,`role` text,`is_service_user` numeric,`non_deletable` numeric,`service_user_name` text,`auto_groups` text,`blocked` numeric,`last_login` datetime DEFAULT NULL,`created_at` datetime,`issued` text DEFAULT "api",`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_users_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `groups` (`id` text,`account_id` text,`name` text,`issued` text,`peers` text,`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_groups_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `setup_keys` (`id` text,`account_id` text,`key` text,`key_secret` text,`name` text,`type` text,`created_at` datetime,`expires_at` datetime,`updated_at` datetime,`revoked` numeric,`used_times` integer,`last_used` datetime DEFAULT NULL,`auto_groups` text,`usage_limit` integer,`ephemeral` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_setup_keys_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `peers` (`id` text,`account_id` text,`key` text,`setup_key` text,`ip` text,`meta_hostname` text,`meta_go_os` text,`meta_kernel` text,`meta_core` text,`meta_platform` text,`meta_os` text,`meta_os_version` text,`meta_wt_version` text,`meta_ui_version` text,`meta_kernel_version` text,`meta_network_addresses` text,`meta_system_serial_number` text,`meta_system_product_name` text,`meta_system_manufacturer` text,`meta_environment` text,`meta_files` text,`name` text,`dns_label` text,`peer_status_last_seen` datetime,`peer_status_connected` numeric,`peer_status_login_expired` numeric,`peer_status_requires_approval` numeric,`user_id` text,`ssh_key` text,`ssh_enabled` numeric,`login_expiration_enabled` numeric,`last_login` datetime,`created_at` datetime,`ephemeral` numeric,`location_connection_ip` text,`location_country_code` text,`location_city_name` text,`location_geo_name_id` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_peers_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); + +INSERT INTO accounts VALUES('testAccountId','','2024-10-02 16:01:38.000000000+00:00','test.com','private',1,'testNetworkIdentifier','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',1,86400000000000,0,0,0,'',NULL,NULL,NULL); +INSERT INTO users VALUES('testUserId','testAccountId','user',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testAdminId','testAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testOwnerId','testAccountId','owner',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceUserId','testAccountId','user',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceAdminId','testAccountId','admin',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('blockedUserId','testAccountId','admin',0,0,'','[]',1,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('otherUserId','otherAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO "groups" VALUES('testGroupId','testAccountId','testGroupName','api','[]',0,''); +INSERT INTO "groups" VALUES('newGroupId','testAccountId','newGroupName','api','[]',0,''); +INSERT INTO setup_keys VALUES('testKeyId','testAccountId','testKey','testK****','existingKey','one-off','2021-08-19 20:46:20.000000000+00:00','2321-09-18 20:46:20.000000000+00:00','2021-08-19 20:46:20.000000000+00:000',0,0,NULL,'["testGroupId"]',1,0); +INSERT INTO peers VALUES('testPeerId','testAccountId','5rvhvriKJZ3S9oxYToVj5TzDM9u9y8cxg7htIMWlYAg=','72546A29-6BC8-4311-BCFC-9CDBF33F1A48','"100.64.114.31"','f2a34f6a4731','linux','Linux','11','unknown','Debian GNU/Linux','','0.12.0','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'f2a34f6a4731','f2a34f6a4731','2023-03-02 09:21:02.189035775+01:00',0,0,0,'','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILzUUSYG/LGnV8zarb2SGN+tib/PZ+M7cL4WtTzUrTpk',0,1,'2023-03-01 19:48:19.817799698+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); \ No newline at end of file diff --git a/management/server/http/testing/testdata/groups.sql b/management/server/http/testing/testdata/groups.sql new file mode 100644 index 000000000..eb874f036 --- /dev/null +++ b/management/server/http/testing/testdata/groups.sql @@ -0,0 +1,19 @@ +CREATE TABLE `accounts` (`id` text,`created_by` text,`created_at` datetime,`domain` text,`domain_category` text,`is_domain_primary_account` numeric,`network_identifier` text,`network_net` text,`network_dns` text,`network_serial` integer,`dns_settings_disabled_management_groups` text,`settings_peer_login_expiration_enabled` numeric,`settings_peer_login_expiration` integer,`settings_regular_users_view_blocked` numeric,`settings_groups_propagation_enabled` numeric,`settings_jwt_groups_enabled` numeric,`settings_jwt_groups_claim_name` text,`settings_jwt_allow_groups` text,`settings_extra_peer_approval_enabled` numeric,`settings_extra_integrated_validator_groups` text,PRIMARY KEY (`id`)); +CREATE TABLE `users` (`id` text,`account_id` text,`role` text,`is_service_user` numeric,`non_deletable` numeric,`service_user_name` text,`auto_groups` text,`blocked` numeric,`last_login` datetime DEFAULT NULL,`created_at` datetime,`issued` text DEFAULT "api",`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_users_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `groups` (`id` text,`account_id` text,`name` text,`issued` text,`peers` text,`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_groups_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `setup_keys` (`id` text,`account_id` text,`key` text,`key_secret` text,`name` text,`type` text,`created_at` datetime,`expires_at` datetime,`updated_at` datetime,`revoked` numeric,`used_times` integer,`last_used` datetime DEFAULT NULL,`auto_groups` text,`usage_limit` integer,`ephemeral` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_setup_keys_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `peers` (`id` text,`account_id` text,`key` text,`setup_key` text,`ip` text,`meta_hostname` text,`meta_go_os` text,`meta_kernel` text,`meta_core` text,`meta_platform` text,`meta_os` text,`meta_os_version` text,`meta_wt_version` text,`meta_ui_version` text,`meta_kernel_version` text,`meta_network_addresses` text,`meta_system_serial_number` text,`meta_system_product_name` text,`meta_system_manufacturer` text,`meta_environment` text,`meta_files` text,`name` text,`dns_label` text,`peer_status_last_seen` datetime,`peer_status_connected` numeric,`peer_status_login_expired` numeric,`peer_status_requires_approval` numeric,`user_id` text,`ssh_key` text,`ssh_enabled` numeric,`login_expiration_enabled` numeric,`last_login` datetime,`created_at` datetime,`ephemeral` numeric,`location_connection_ip` text,`location_country_code` text,`location_city_name` text,`location_geo_name_id` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_peers_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); + +INSERT INTO accounts VALUES('testAccountId','','2024-10-02 16:01:38.000000000+00:00','test.com','private',1,'testNetworkIdentifier','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',1,86400000000000,0,0,0,'',NULL,NULL,NULL); +INSERT INTO users VALUES('testUserId','testAccountId','user',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testAdminId','testAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testOwnerId','testAccountId','owner',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceUserId','testAccountId','user',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceAdminId','testAccountId','admin',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('blockedUserId','testAccountId','admin',0,0,'','[]',1,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('otherUserId','otherAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO "groups" VALUES('testGroupId','testAccountId','testGroupName','api','["testPeerId"]',0,''); +INSERT INTO "groups" VALUES('newGroupId','testAccountId','newGroupName','api','[]',0,''); +INSERT INTO "groups" VALUES('allGroupId','testAccountId','All','api','[]',0,''); +INSERT INTO setup_keys VALUES('testKeyId','testAccountId','testKey','testK****','existingKey','one-off','2021-08-19 20:46:20.000000000+00:00','2321-09-18 20:46:20.000000000+00:00','2021-08-19 20:46:20.000000000+00:000',0,0,NULL,'["testGroupId"]',1,0); +INSERT INTO peers VALUES('testPeerId','testAccountId','5rvhvriKJZ3S9oxYToVj5TzDM9u9y8cxg7htIMWlYAg=','72546A29-6BC8-4311-BCFC-9CDBF33F1A48','"100.64.114.31"','f2a34f6a4731','linux','Linux','11','unknown','Debian GNU/Linux','','0.12.0','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'f2a34f6a4731','f2a34f6a4731','2023-03-02 09:21:02.189035775+01:00',0,0,0,'','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILzUUSYG/LGnV8zarb2SGN+tib/PZ+M7cL4WtTzUrTpk',0,1,'2023-03-01 19:48:19.817799698+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); \ No newline at end of file diff --git a/management/server/http/testing/testdata/networks.sql b/management/server/http/testing/testdata/networks.sql new file mode 100644 index 000000000..39ec8e646 --- /dev/null +++ b/management/server/http/testing/testdata/networks.sql @@ -0,0 +1,25 @@ +CREATE TABLE `accounts` (`id` text,`created_by` text,`created_at` datetime,`domain` text,`domain_category` text,`is_domain_primary_account` numeric,`network_identifier` text,`network_net` text,`network_dns` text,`network_serial` integer,`dns_settings_disabled_management_groups` text,`settings_peer_login_expiration_enabled` numeric,`settings_peer_login_expiration` integer,`settings_regular_users_view_blocked` numeric,`settings_groups_propagation_enabled` numeric,`settings_jwt_groups_enabled` numeric,`settings_jwt_groups_claim_name` text,`settings_jwt_allow_groups` text,`settings_extra_peer_approval_enabled` numeric,`settings_extra_integrated_validator_groups` text,PRIMARY KEY (`id`)); +CREATE TABLE `users` (`id` text,`account_id` text,`role` text,`is_service_user` numeric,`non_deletable` numeric,`service_user_name` text,`auto_groups` text,`blocked` numeric,`last_login` datetime DEFAULT NULL,`created_at` datetime,`issued` text DEFAULT "api",`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_users_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `groups` (`id` text,`account_id` text,`name` text,`issued` text,`peers` text,`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_groups_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `setup_keys` (`id` text,`account_id` text,`key` text,`key_secret` text,`name` text,`type` text,`created_at` datetime,`expires_at` datetime,`updated_at` datetime,`revoked` numeric,`used_times` integer,`last_used` datetime DEFAULT NULL,`auto_groups` text,`usage_limit` integer,`ephemeral` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_setup_keys_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `peers` (`id` text,`account_id` text,`key` text,`setup_key` text,`ip` text,`meta_hostname` text,`meta_go_os` text,`meta_kernel` text,`meta_core` text,`meta_platform` text,`meta_os` text,`meta_os_version` text,`meta_wt_version` text,`meta_ui_version` text,`meta_kernel_version` text,`meta_network_addresses` text,`meta_system_serial_number` text,`meta_system_product_name` text,`meta_system_manufacturer` text,`meta_environment` text,`meta_files` text,`name` text,`dns_label` text,`peer_status_last_seen` datetime,`peer_status_connected` numeric,`peer_status_login_expired` numeric,`peer_status_requires_approval` numeric,`user_id` text,`ssh_key` text,`ssh_enabled` numeric,`login_expiration_enabled` numeric,`last_login` datetime,`created_at` datetime,`ephemeral` numeric,`location_connection_ip` text,`location_country_code` text,`location_city_name` text,`location_geo_name_id` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_peers_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `networks` (`id` text,`account_id` text,`name` text,`description` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_networks` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `network_routers` (`id` text,`network_id` text,`account_id` text,`peer` text,`peer_groups` text,`masquerade` numeric,`metric` integer,`enabled` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_network_routers` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `network_resources` (`id` text,`network_id` text,`account_id` text,`name` text,`description` text,`type` text,`domain` text,`prefix` text,`enabled` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_network_resources` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); + +INSERT INTO accounts VALUES('testAccountId','','2024-10-02 16:01:38.000000000+00:00','test.com','private',1,'testNetworkIdentifier','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',1,86400000000000,0,0,0,'',NULL,NULL,NULL); +INSERT INTO users VALUES('testUserId','testAccountId','user',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testAdminId','testAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testOwnerId','testAccountId','owner',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceUserId','testAccountId','user',1,0,'testServiceUser','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceAdminId','testAccountId','admin',1,0,'testServiceAdmin','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('blockedUserId','testAccountId','admin',0,0,'','[]',1,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('otherUserId','otherAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO "groups" VALUES('testGroupId','testAccountId','testGroupName','api','["testPeerId"]',0,''); +INSERT INTO "groups" VALUES('newGroupId','testAccountId','newGroupName','api','[]',0,''); +INSERT INTO setup_keys VALUES('testKeyId','testAccountId','testKey','testK****','existingKey','one-off','2021-08-19 20:46:20.000000000+00:00','2321-09-18 20:46:20.000000000+00:00','2021-08-19 20:46:20.000000000+00:00',0,0,NULL,'["testGroupId"]',1,0); +INSERT INTO peers VALUES('testPeerId','testAccountId','5rvhvriKJZ3S9oxYToVj5TzDM9u9y8cxg7htIMWlYAg=','72546A29-6BC8-4311-BCFC-9CDBF33F1A48','"100.64.114.31"','f2a34f6a4731','linux','Linux','11','unknown','Debian GNU/Linux','','0.12.0','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'f2a34f6a4731','f2a34f6a4731','2023-03-02 09:21:02.189035775+01:00',0,0,0,'','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILzUUSYG/LGnV8zarb2SGN+tib/PZ+M7cL4WtTzUrTpk',0,1,'2023-03-01 19:48:19.817799698+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); + +INSERT INTO networks VALUES('testNetworkId','testAccountId','testNetwork','test network description'); +INSERT INTO network_routers VALUES('testRouterId','testNetworkId','testAccountId','testPeerId','[]',1,100,1); +INSERT INTO network_resources VALUES('testResourceId','testNetworkId','testAccountId','testResource','test resource description','host','','"3.3.3.3/32"',1); \ No newline at end of file diff --git a/management/server/http/testing/testdata/peers_integration.sql b/management/server/http/testing/testdata/peers_integration.sql new file mode 100644 index 000000000..62a7760e7 --- /dev/null +++ b/management/server/http/testing/testdata/peers_integration.sql @@ -0,0 +1,20 @@ +CREATE TABLE `accounts` (`id` text,`created_by` text,`created_at` datetime,`domain` text,`domain_category` text,`is_domain_primary_account` numeric,`network_identifier` text,`network_net` text,`network_dns` text,`network_serial` integer,`dns_settings_disabled_management_groups` text,`settings_peer_login_expiration_enabled` numeric,`settings_peer_login_expiration` integer,`settings_regular_users_view_blocked` numeric,`settings_groups_propagation_enabled` numeric,`settings_jwt_groups_enabled` numeric,`settings_jwt_groups_claim_name` text,`settings_jwt_allow_groups` text,`settings_extra_peer_approval_enabled` numeric,`settings_extra_integrated_validator_groups` text,PRIMARY KEY (`id`)); +CREATE TABLE `users` (`id` text,`account_id` text,`role` text,`is_service_user` numeric,`non_deletable` numeric,`service_user_name` text,`auto_groups` text,`blocked` numeric,`last_login` datetime DEFAULT NULL,`created_at` datetime,`issued` text DEFAULT "api",`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_users_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `groups` (`id` text,`account_id` text,`name` text,`issued` text,`peers` text,`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_groups_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `setup_keys` (`id` text,`account_id` text,`key` text,`key_secret` text,`name` text,`type` text,`created_at` datetime,`expires_at` datetime,`updated_at` datetime,`revoked` numeric,`used_times` integer,`last_used` datetime DEFAULT NULL,`auto_groups` text,`usage_limit` integer,`ephemeral` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_setup_keys_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `peers` (`id` text,`account_id` text,`key` text,`setup_key` text,`ip` text,`meta_hostname` text,`meta_go_os` text,`meta_kernel` text,`meta_core` text,`meta_platform` text,`meta_os` text,`meta_os_version` text,`meta_wt_version` text,`meta_ui_version` text,`meta_kernel_version` text,`meta_network_addresses` text,`meta_system_serial_number` text,`meta_system_product_name` text,`meta_system_manufacturer` text,`meta_environment` text,`meta_files` text,`name` text,`dns_label` text,`peer_status_last_seen` datetime,`peer_status_connected` numeric,`peer_status_login_expired` numeric,`peer_status_requires_approval` numeric,`user_id` text,`ssh_key` text,`ssh_enabled` numeric,`login_expiration_enabled` numeric,`last_login` datetime,`created_at` datetime,`ephemeral` numeric,`location_connection_ip` text,`location_country_code` text,`location_city_name` text,`location_geo_name_id` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_peers_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); + +INSERT INTO accounts VALUES('testAccountId','','2024-10-02 16:01:38.000000000+00:00','test.com','private',1,'testNetworkIdentifier','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',0,86400000000000,0,0,0,'',NULL,NULL,NULL); +INSERT INTO users VALUES('testUserId','testAccountId','user',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testAdminId','testAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testOwnerId','testAccountId','owner',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceUserId','testAccountId','user',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceAdminId','testAccountId','admin',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('blockedUserId','testAccountId','admin',0,0,'','[]',1,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('otherUserId','otherAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO "groups" VALUES('testGroupId','testAccountId','testGroupName','api','["testPeerId","testPeerId2"]',0,''); +INSERT INTO "groups" VALUES('newGroupId','testAccountId','newGroupName','api','[]',0,''); +INSERT INTO setup_keys VALUES('testKeyId','testAccountId','testKey','testK****','existingKey','one-off','2021-08-19 20:46:20.000000000+00:00','2321-09-18 20:46:20.000000000+00:00','2021-08-19 20:46:20.000000000+00:000',0,0,NULL,'["testGroupId"]',1,0); + +INSERT INTO peers VALUES('testPeerId','testAccountId','5rvhvriKJZ3S9oxYToVj5TzDM9u9y8cxg7htIMWlYAg=','72546A29-6BC8-4311-BCFC-9CDBF33F1A48','"100.64.114.31"','test-host-1','linux','Linux','','unknown','Debian GNU/Linux','','0.12.0','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'test-peer-1','test-peer-1','2023-03-02 09:21:02.189035775+01:00',0,0,0,'testUserId','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILzUUSYG/LGnV8zarb2SGN+tib/PZ+M7cL4WtTzUrTpk',0,1,'2023-03-01 19:48:19.817799698+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); +INSERT INTO peers VALUES('testPeerId2','testAccountId','6rvhvriKJZ3S9oxYToVj5TzDM9u9y8cxg7htIMWlYBg=','82546A29-6BC8-4311-BCFC-9CDBF33F1A49','"100.64.114.32"','test-host-2','linux','Linux','','unknown','Ubuntu','','0.12.0','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'test-peer-2','test-peer-2','2023-03-02 09:21:02.189035775+01:00',1,0,0,'testAdminId','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILzUUSYG/LGnV8zarb2SGN+tib/PZ+M7cL4WtTzUrTpk',1,0,'2023-03-01 19:48:19.817799698+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); \ No newline at end of file diff --git a/management/server/http/testing/testdata/policies.sql b/management/server/http/testing/testdata/policies.sql new file mode 100644 index 000000000..7e6cc883b --- /dev/null +++ b/management/server/http/testing/testdata/policies.sql @@ -0,0 +1,23 @@ +CREATE TABLE `accounts` (`id` text,`created_by` text,`created_at` datetime,`domain` text,`domain_category` text,`is_domain_primary_account` numeric,`network_identifier` text,`network_net` text,`network_dns` text,`network_serial` integer,`dns_settings_disabled_management_groups` text,`settings_peer_login_expiration_enabled` numeric,`settings_peer_login_expiration` integer,`settings_regular_users_view_blocked` numeric,`settings_groups_propagation_enabled` numeric,`settings_jwt_groups_enabled` numeric,`settings_jwt_groups_claim_name` text,`settings_jwt_allow_groups` text,`settings_extra_peer_approval_enabled` numeric,`settings_extra_integrated_validator_groups` text,PRIMARY KEY (`id`)); +CREATE TABLE `users` (`id` text,`account_id` text,`role` text,`is_service_user` numeric,`non_deletable` numeric,`service_user_name` text,`auto_groups` text,`blocked` numeric,`last_login` datetime DEFAULT NULL,`created_at` datetime,`issued` text DEFAULT "api",`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_users_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `groups` (`id` text,`account_id` text,`name` text,`issued` text,`peers` text,`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_groups_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `setup_keys` (`id` text,`account_id` text,`key` text,`key_secret` text,`name` text,`type` text,`created_at` datetime,`expires_at` datetime,`updated_at` datetime,`revoked` numeric,`used_times` integer,`last_used` datetime DEFAULT NULL,`auto_groups` text,`usage_limit` integer,`ephemeral` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_setup_keys_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `peers` (`id` text,`account_id` text,`key` text,`setup_key` text,`ip` text,`meta_hostname` text,`meta_go_os` text,`meta_kernel` text,`meta_core` text,`meta_platform` text,`meta_os` text,`meta_os_version` text,`meta_wt_version` text,`meta_ui_version` text,`meta_kernel_version` text,`meta_network_addresses` text,`meta_system_serial_number` text,`meta_system_product_name` text,`meta_system_manufacturer` text,`meta_environment` text,`meta_files` text,`name` text,`dns_label` text,`peer_status_last_seen` datetime,`peer_status_connected` numeric,`peer_status_login_expired` numeric,`peer_status_requires_approval` numeric,`user_id` text,`ssh_key` text,`ssh_enabled` numeric,`login_expiration_enabled` numeric,`last_login` datetime,`created_at` datetime,`ephemeral` numeric,`location_connection_ip` text,`location_country_code` text,`location_city_name` text,`location_geo_name_id` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_peers_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `policies` (`id` text,`account_id` text,`name` text,`description` text,`enabled` numeric,`source_posture_checks` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_policies_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `policy_rules` (`id` text,`policy_id` text,`name` text,`description` text,`enabled` numeric,`action` text,`protocol` text,`bidirectional` numeric,`sources` text,`destinations` text,`source_resource` text,`destination_resource` text,`ports` text,`port_ranges` text,`authorized_groups` text,`authorized_user` text,PRIMARY KEY (`id`),CONSTRAINT `fk_policies_rules_g` FOREIGN KEY (`policy_id`) REFERENCES `policies`(`id`)); + +INSERT INTO accounts VALUES('testAccountId','','2024-10-02 16:01:38.000000000+00:00','test.com','private',1,'testNetworkIdentifier','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',1,86400000000000,0,0,0,'',NULL,NULL,NULL); +INSERT INTO users VALUES('testUserId','testAccountId','user',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testAdminId','testAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testOwnerId','testAccountId','owner',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceUserId','testAccountId','user',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceAdminId','testAccountId','admin',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('blockedUserId','testAccountId','admin',0,0,'','[]',1,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('otherUserId','otherAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO "groups" VALUES('testGroupId','testAccountId','testGroupName','api','["testPeerId"]',0,''); +INSERT INTO "groups" VALUES('newGroupId','testAccountId','newGroupName','api','[]',0,''); +INSERT INTO setup_keys VALUES('testKeyId','testAccountId','testKey','testK****','existingKey','one-off','2021-08-19 20:46:20.000000000+00:00','2321-09-18 20:46:20.000000000+00:00','2021-08-19 20:46:20.000000000+00:000',0,0,NULL,'["testGroupId"]',1,0); +INSERT INTO peers VALUES('testPeerId','testAccountId','5rvhvriKJZ3S9oxYToVj5TzDM9u9y8cxg7htIMWlYAg=','72546A29-6BC8-4311-BCFC-9CDBF33F1A48','"100.64.114.31"','f2a34f6a4731','linux','Linux','11','unknown','Debian GNU/Linux','','0.12.0','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'f2a34f6a4731','f2a34f6a4731','2023-03-02 09:21:02.189035775+01:00',0,0,0,'','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILzUUSYG/LGnV8zarb2SGN+tib/PZ+M7cL4WtTzUrTpk',0,1,'2023-03-01 19:48:19.817799698+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); + +INSERT INTO policies VALUES('testPolicyId','testAccountId','testPolicy','test policy description',1,NULL); +INSERT INTO policy_rules VALUES('testRuleId','testPolicyId','testRule','test rule',1,'accept','all',1,'["testGroupId"]','["testGroupId"]',NULL,NULL,NULL,NULL,NULL,''); \ No newline at end of file diff --git a/management/server/http/testing/testdata/routes.sql b/management/server/http/testing/testdata/routes.sql new file mode 100644 index 000000000..48aa02052 --- /dev/null +++ b/management/server/http/testing/testdata/routes.sql @@ -0,0 +1,23 @@ +CREATE TABLE `accounts` (`id` text,`created_by` text,`created_at` datetime,`domain` text,`domain_category` text,`is_domain_primary_account` numeric,`network_identifier` text,`network_net` text,`network_dns` text,`network_serial` integer,`dns_settings_disabled_management_groups` text,`settings_peer_login_expiration_enabled` numeric,`settings_peer_login_expiration` integer,`settings_regular_users_view_blocked` numeric,`settings_groups_propagation_enabled` numeric,`settings_jwt_groups_enabled` numeric,`settings_jwt_groups_claim_name` text,`settings_jwt_allow_groups` text,`settings_extra_peer_approval_enabled` numeric,`settings_extra_integrated_validator_groups` text,PRIMARY KEY (`id`)); +CREATE TABLE `users` (`id` text,`account_id` text,`role` text,`is_service_user` numeric,`non_deletable` numeric,`service_user_name` text,`auto_groups` text,`blocked` numeric,`last_login` datetime DEFAULT NULL,`created_at` datetime,`issued` text DEFAULT "api",`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_users_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `groups` (`id` text,`account_id` text,`name` text,`issued` text,`peers` text,`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_groups_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `setup_keys` (`id` text,`account_id` text,`key` text,`key_secret` text,`name` text,`type` text,`created_at` datetime,`expires_at` datetime,`updated_at` datetime,`revoked` numeric,`used_times` integer,`last_used` datetime DEFAULT NULL,`auto_groups` text,`usage_limit` integer,`ephemeral` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_setup_keys_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `peers` (`id` text,`account_id` text,`key` text,`setup_key` text,`ip` text,`meta_hostname` text,`meta_go_os` text,`meta_kernel` text,`meta_core` text,`meta_platform` text,`meta_os` text,`meta_os_version` text,`meta_wt_version` text,`meta_ui_version` text,`meta_kernel_version` text,`meta_network_addresses` text,`meta_system_serial_number` text,`meta_system_product_name` text,`meta_system_manufacturer` text,`meta_environment` text,`meta_files` text,`name` text,`dns_label` text,`peer_status_last_seen` datetime,`peer_status_connected` numeric,`peer_status_login_expired` numeric,`peer_status_requires_approval` numeric,`user_id` text,`ssh_key` text,`ssh_enabled` numeric,`login_expiration_enabled` numeric,`last_login` datetime,`created_at` datetime,`ephemeral` numeric,`location_connection_ip` text,`location_country_code` text,`location_city_name` text,`location_geo_name_id` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_peers_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `routes` (`id` text,`account_id` text,`network` text,`domains` text,`keep_route` numeric,`net_id` text,`description` text,`peer` text,`peer_groups` text,`network_type` integer,`masquerade` numeric,`metric` integer,`enabled` numeric,`groups` text,`access_control_groups` text,`skip_auto_apply` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_routes_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); + +INSERT INTO accounts VALUES('testAccountId','','2024-10-02 16:01:38.000000000+00:00','test.com','private',1,'testNetworkIdentifier','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',1,86400000000000,0,0,0,'',NULL,NULL,NULL); +INSERT INTO users VALUES('testUserId','testAccountId','user',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testAdminId','testAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testOwnerId','testAccountId','owner',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceUserId','testAccountId','user',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceAdminId','testAccountId','admin',1,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('blockedUserId','testAccountId','admin',0,0,'','[]',1,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('otherUserId','otherAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO "groups" VALUES('testGroupId','testAccountId','testGroupName','api','["testPeerId"]',0,''); +INSERT INTO "groups" VALUES('newGroupId','testAccountId','newGroupName','api','[]',0,''); +INSERT INTO "groups" VALUES('peerGroupId','testAccountId','peerGroupName','api','["testPeerId"]',0,''); +INSERT INTO setup_keys VALUES('testKeyId','testAccountId','testKey','testK****','existingKey','one-off','2021-08-19 20:46:20.000000000+00:00','2321-09-18 20:46:20.000000000+00:00','2021-08-19 20:46:20.000000000+00:000',0,0,NULL,'["testGroupId"]',1,0); +INSERT INTO peers VALUES('testPeerId','testAccountId','5rvhvriKJZ3S9oxYToVj5TzDM9u9y8cxg7htIMWlYAg=','72546A29-6BC8-4311-BCFC-9CDBF33F1A48','"100.64.114.31"','f2a34f6a4731','linux','Linux','11','unknown','Debian GNU/Linux','','0.12.0','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'f2a34f6a4731','f2a34f6a4731','2023-03-02 09:21:02.189035775+01:00',0,0,0,'','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILzUUSYG/LGnV8zarb2SGN+tib/PZ+M7cL4WtTzUrTpk',0,1,'2023-03-01 19:48:19.817799698+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); + +INSERT INTO routes VALUES('testRouteId','testAccountId','"10.0.0.0/24"',NULL,0,'testNet','Test Network Route','testPeerId',NULL,1,1,100,1,'["testGroupId"]',NULL,0); +INSERT INTO routes VALUES('testDomainRouteId','testAccountId','"0.0.0.0/0"','["example.com"]',0,'testDomainNet','Test Domain Route','','["peerGroupId"]',3,1,200,1,'["testGroupId"]',NULL,0); diff --git a/management/server/http/testing/testdata/users_integration.sql b/management/server/http/testing/testdata/users_integration.sql new file mode 100644 index 000000000..57df73e8c --- /dev/null +++ b/management/server/http/testing/testdata/users_integration.sql @@ -0,0 +1,24 @@ +CREATE TABLE `accounts` (`id` text,`created_by` text,`created_at` datetime,`domain` text,`domain_category` text,`is_domain_primary_account` numeric,`network_identifier` text,`network_net` text,`network_dns` text,`network_serial` integer,`dns_settings_disabled_management_groups` text,`settings_peer_login_expiration_enabled` numeric,`settings_peer_login_expiration` integer,`settings_regular_users_view_blocked` numeric,`settings_groups_propagation_enabled` numeric,`settings_jwt_groups_enabled` numeric,`settings_jwt_groups_claim_name` text,`settings_jwt_allow_groups` text,`settings_extra_peer_approval_enabled` numeric,`settings_extra_integrated_validator_groups` text,PRIMARY KEY (`id`)); +CREATE TABLE `users` (`id` text,`account_id` text,`role` text,`is_service_user` numeric,`non_deletable` numeric,`service_user_name` text,`auto_groups` text,`blocked` numeric,`last_login` datetime DEFAULT NULL,`created_at` datetime,`issued` text DEFAULT "api",`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_users_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `groups` (`id` text,`account_id` text,`name` text,`issued` text,`peers` text,`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_groups_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `setup_keys` (`id` text,`account_id` text,`key` text,`key_secret` text,`name` text,`type` text,`created_at` datetime,`expires_at` datetime,`updated_at` datetime,`revoked` numeric,`used_times` integer,`last_used` datetime DEFAULT NULL,`auto_groups` text,`usage_limit` integer,`ephemeral` numeric,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_setup_keys_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `peers` (`id` text,`account_id` text,`key` text,`setup_key` text,`ip` text,`meta_hostname` text,`meta_go_os` text,`meta_kernel` text,`meta_core` text,`meta_platform` text,`meta_os` text,`meta_os_version` text,`meta_wt_version` text,`meta_ui_version` text,`meta_kernel_version` text,`meta_network_addresses` text,`meta_system_serial_number` text,`meta_system_product_name` text,`meta_system_manufacturer` text,`meta_environment` text,`meta_files` text,`name` text,`dns_label` text,`peer_status_last_seen` datetime,`peer_status_connected` numeric,`peer_status_login_expired` numeric,`peer_status_requires_approval` numeric,`user_id` text,`ssh_key` text,`ssh_enabled` numeric,`login_expiration_enabled` numeric,`last_login` datetime,`created_at` datetime,`ephemeral` numeric,`location_connection_ip` text,`location_country_code` text,`location_city_name` text,`location_geo_name_id` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_peers_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `personal_access_tokens` (`id` text,`user_id` text,`name` text,`hashed_token` text,`expiration_date` datetime,`created_by` text,`created_at` datetime,`last_used` datetime DEFAULT NULL,PRIMARY KEY (`id`),CONSTRAINT `fk_users_pa_ts_g` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`)); +CREATE INDEX `idx_personal_access_tokens_user_id` ON `personal_access_tokens`(`user_id`); + +INSERT INTO accounts VALUES('testAccountId','','2024-10-02 16:01:38.000000000+00:00','test.com','private',1,'testNetworkIdentifier','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',1,86400000000000,0,0,0,'',NULL,NULL,NULL); +INSERT INTO users VALUES('testUserId','testAccountId','user',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testAdminId','testAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testOwnerId','testAccountId','owner',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceUserId','testAccountId','user',1,0,'testServiceUser','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('testServiceAdminId','testAccountId','admin',1,0,'testServiceAdmin','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('blockedUserId','testAccountId','admin',0,0,'','[]',1,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('otherUserId','otherAccountId','admin',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('deletableServiceUserId','testAccountId','user',1,0,'deletableServiceUser','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO "groups" VALUES('testGroupId','testAccountId','testGroupName','api','["testPeerId"]',0,''); +INSERT INTO "groups" VALUES('newGroupId','testAccountId','newGroupName','api','[]',0,''); +INSERT INTO setup_keys VALUES('testKeyId','testAccountId','testKey','testK****','existingKey','one-off','2021-08-19 20:46:20.000000000+00:00','2321-09-18 20:46:20.000000000+00:00','2021-08-19 20:46:20.000000000+00:000',0,0,NULL,'["testGroupId"]',1,0); +INSERT INTO peers VALUES('testPeerId','testAccountId','5rvhvriKJZ3S9oxYToVj5TzDM9u9y8cxg7htIMWlYAg=','72546A29-6BC8-4311-BCFC-9CDBF33F1A48','"100.64.114.31"','f2a34f6a4731','linux','Linux','11','unknown','Debian GNU/Linux','','0.12.0','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'f2a34f6a4731','f2a34f6a4731','2023-03-02 09:21:02.189035775+01:00',0,0,0,'','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILzUUSYG/LGnV8zarb2SGN+tib/PZ+M7cL4WtTzUrTpk',0,1,'2023-03-01 19:48:19.817799698+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); + +INSERT INTO personal_access_tokens VALUES('testTokenId','testUserId','testToken','hashedTokenValue123','2325-10-02 16:01:38.000000000+00:00','testUserId','2024-10-02 16:01:38.000000000+00:00',NULL); +INSERT INTO personal_access_tokens VALUES('serviceTokenId','testServiceUserId','serviceToken','hashedServiceTokenValue123','2325-10-02 16:01:38.000000000+00:00','testAdminId','2024-10-02 16:01:38.000000000+00:00',NULL); \ No newline at end of file diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go index ab3f5437a..8b05b2ddf 100644 --- a/management/server/http/testing/testing_tools/channel/channel.go +++ b/management/server/http/testing/testing_tools/channel/channel.go @@ -7,19 +7,36 @@ import ( "time" "github.com/golang-jwt/jwt/v5" + "github.com/gorilla/mux" "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel/metric/noop" + "github.com/netbirdio/management-integrations/integrations" + + accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager" + proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager" + reverseproxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service/manager" + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + + zonesManager "github.com/netbirdio/netbird/management/internals/modules/zones/manager" + recordsManager "github.com/netbirdio/netbird/management/internals/modules/zones/records/manager" + "github.com/netbirdio/netbird/management/internals/server/config" + "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/update_channel" + "github.com/netbirdio/netbird/management/internals/modules/peers" + ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" + "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server" "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" - "github.com/netbirdio/netbird/management/server/auth" - nbcontext "github.com/netbirdio/netbird/management/server/context" + serverauth "github.com/netbirdio/netbird/management/server/auth" + nbcache "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/geolocation" "github.com/netbirdio/netbird/management/server/groups" http2 "github.com/netbirdio/netbird/management/server/http" @@ -27,12 +44,12 @@ import ( "github.com/netbirdio/netbird/management/server/networks" "github.com/netbirdio/netbird/management/server/networks/resources" "github.com/netbirdio/netbird/management/server/networks/routers" - "github.com/netbirdio/netbird/management/server/peers" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/telemetry" "github.com/netbirdio/netbird/management/server/users" + "github.com/netbirdio/netbird/shared/auth" ) func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPeerUpdate *network_map.UpdateMessage, validateUpdate bool) (http.Handler, account.Manager, chan struct{}) { @@ -66,32 +83,61 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee proxyController := integrations.NewController(store) userManager := users.NewManager(store) permissionsManager := permissions.NewManager(store) - settingsManager := settings.NewManager(store, userManager, integrations.NewManager(&activity.InMemoryEventStore{}), permissionsManager) + settingsManager := settings.NewManager(store, userManager, integrations.NewManager(&activity.InMemoryEventStore{}), permissionsManager, settings.IdpConfig{}) + peersManager := peers.NewManager(store, permissionsManager) + + jobManager := job.NewJobManager(nil, store, peersManager) ctx := context.Background() + + cacheStore, err := nbcache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100) + if err != nil { + t.Fatalf("Failed to create cache store: %v", err) + } + requestBuffer := server.NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock()) - am, err := server.BuildManager(ctx, store, networkMapController, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, proxyController, settingsManager, permissionsManager, false) + networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}) + am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, proxyController, settingsManager, permissionsManager, false, cacheStore) if err != nil { t.Fatalf("Failed to create manager: %v", err) } + accessLogsManager := accesslogsmanager.NewManager(store, permissionsManager, nil) + proxyTokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore) + pkceverifierStore := nbgrpc.NewPKCEVerifierStore(ctx, cacheStore) + noopMeter := noop.NewMeterProvider().Meter("") + proxyMgr, err := proxymanager.NewManager(store, noopMeter) + if err != nil { + t.Fatalf("Failed to create proxy manager: %v", err) + } + proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil) + domainManager := manager.NewManager(store, proxyMgr, permissionsManager, am) + serviceProxyController, err := proxymanager.NewGRPCController(proxyServiceServer, noopMeter) + if err != nil { + t.Fatalf("Failed to create proxy controller: %v", err) + } + serviceManager := reverseproxymanager.NewManager(store, am, permissionsManager, serviceProxyController, proxyMgr, domainManager) + proxyServiceServer.SetServiceManager(serviceManager) + am.SetServiceManager(serviceManager) + // @note this is required so that PAT's validate from store, but JWT's are mocked - authManager := auth.NewManager(store, "", "", "", "", []string{}, false) - authManagerMock := &auth.MockManager{ + authManager := serverauth.NewManager(store, "", "", "", "", []string{}, false, nil) + authManagerMock := &serverauth.MockManager{ ValidateAndParseTokenFunc: mockValidateAndParseToken, EnsureUserAccessByJWTGroupsFunc: authManager.EnsureUserAccessByJWTGroups, MarkPATUsedFunc: authManager.MarkPATUsed, GetPATInfoFunc: authManager.GetPATInfo, } - networksManagerMock := networks.NewManagerMock() - resourcesManagerMock := resources.NewManagerMock() - routersManagerMock := routers.NewManagerMock() - groupsManagerMock := groups.NewManagerMock() - peersManager := peers.NewManager(store, permissionsManager) + groupsManager := groups.NewManager(store, permissionsManager, am) + routersManager := routers.NewManager(store, permissionsManager, am) + resourcesManager := resources.NewManager(store, permissionsManager, groupsManager, am, serviceManager) + networksManager := networks.NewManager(store, permissionsManager, resourcesManager, routersManager, am) + customZonesManager := zonesManager.NewManager(store, am, permissionsManager, "") + zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager) - apiHandler, err := http2.NewAPIHandler(context.Background(), am, networksManagerMock, resourcesManagerMock, routersManagerMock, groupsManagerMock, geoMock, authManagerMock, metrics, validatorMock, proxyController, permissionsManager, peersManager, settingsManager, networkMapController) + apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter() + apiHandler, err := http2.NewAPIHandler(context.Background(), apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil) if err != nil { t.Fatalf("Failed to create API handler: %v", err) } @@ -123,8 +169,114 @@ func peerShouldReceiveUpdate(t testing_tools.TB, updateMessage <-chan *network_m } } -func mockValidateAndParseToken(_ context.Context, token string) (nbcontext.UserAuth, *jwt.Token, error) { - userAuth := nbcontext.UserAuth{} +// PeerShouldReceiveAnyUpdate waits for a peer update message and returns it. +// Fails the test if no update is received within timeout. +func PeerShouldReceiveAnyUpdate(t testing_tools.TB, updateMessage <-chan *network_map.UpdateMessage) *network_map.UpdateMessage { + t.Helper() + select { + case msg := <-updateMessage: + if msg == nil { + t.Errorf("Received nil update message, expected valid message") + } + return msg + case <-time.After(500 * time.Millisecond): + t.Errorf("Timed out waiting for update message") + return nil + } +} + +// PeerShouldNotReceiveAnyUpdate verifies no peer update message is received. +func PeerShouldNotReceiveAnyUpdate(t testing_tools.TB, updateMessage <-chan *network_map.UpdateMessage) { + t.Helper() + peerShouldNotReceiveUpdate(t, updateMessage) +} + +// BuildApiBlackBoxWithDBStateAndPeerChannel creates the API handler and returns +// the peer update channel directly so tests can verify updates inline. +func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile string) (http.Handler, account.Manager, <-chan *network_map.UpdateMessage) { + store, cleanup, err := store.NewTestStoreFromSQL(context.Background(), sqlFile, t.TempDir()) + if err != nil { + t.Fatalf("Failed to create test store: %v", err) + } + t.Cleanup(cleanup) + + metrics, err := telemetry.NewDefaultAppMetrics(context.Background()) + if err != nil { + t.Fatalf("Failed to create metrics: %v", err) + } + + peersUpdateManager := update_channel.NewPeersUpdateManager(nil) + updMsg := peersUpdateManager.CreateChannel(context.Background(), testing_tools.TestPeerId) + + geoMock := &geolocation.Mock{} + validatorMock := server.MockIntegratedValidator{} + proxyController := integrations.NewController(store) + userManager := users.NewManager(store) + permissionsManager := permissions.NewManager(store) + settingsManager := settings.NewManager(store, userManager, integrations.NewManager(&activity.InMemoryEventStore{}), permissionsManager, settings.IdpConfig{}) + peersManager := peers.NewManager(store, permissionsManager) + + jobManager := job.NewJobManager(nil, store, peersManager) + + ctx := context.Background() + + cacheStore, err := nbcache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100) + if err != nil { + t.Fatalf("Failed to create cache store: %v", err) + } + + requestBuffer := server.NewAccountRequestBuffer(ctx, store) + networkMapController := controller.NewController(ctx, store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsManager, "", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peersManager), &config.Config{}) + am, err := server.BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", &activity.InMemoryEventStore{}, geoMock, false, validatorMock, metrics, proxyController, settingsManager, permissionsManager, false, cacheStore) + if err != nil { + t.Fatalf("Failed to create manager: %v", err) + } + + accessLogsManager := accesslogsmanager.NewManager(store, permissionsManager, nil) + proxyTokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore) + pkceverifierStore := nbgrpc.NewPKCEVerifierStore(ctx, cacheStore) + noopMeter := noop.NewMeterProvider().Meter("") + proxyMgr, err := proxymanager.NewManager(store, noopMeter) + if err != nil { + t.Fatalf("Failed to create proxy manager: %v", err) + } + proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil) + domainManager := manager.NewManager(store, proxyMgr, permissionsManager, am) + serviceProxyController, err := proxymanager.NewGRPCController(proxyServiceServer, noopMeter) + if err != nil { + t.Fatalf("Failed to create proxy controller: %v", err) + } + serviceManager := reverseproxymanager.NewManager(store, am, permissionsManager, serviceProxyController, proxyMgr, domainManager) + proxyServiceServer.SetServiceManager(serviceManager) + am.SetServiceManager(serviceManager) + + // @note this is required so that PAT's validate from store, but JWT's are mocked + authManager := serverauth.NewManager(store, "", "", "", "", []string{}, false, nil) + authManagerMock := &serverauth.MockManager{ + ValidateAndParseTokenFunc: mockValidateAndParseToken, + EnsureUserAccessByJWTGroupsFunc: authManager.EnsureUserAccessByJWTGroups, + MarkPATUsedFunc: authManager.MarkPATUsed, + GetPATInfoFunc: authManager.GetPATInfo, + } + + groupsManager := groups.NewManager(store, permissionsManager, am) + routersManager := routers.NewManager(store, permissionsManager, am) + resourcesManager := resources.NewManager(store, permissionsManager, groupsManager, am, serviceManager) + networksManager := networks.NewManager(store, permissionsManager, resourcesManager, routersManager, am) + customZonesManager := zonesManager.NewManager(store, am, permissionsManager, "") + zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager) + + apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter() + apiHandler, err := http2.NewAPIHandler(context.Background(), apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil) + if err != nil { + t.Fatalf("Failed to create API handler: %v", err) + } + + return apiHandler, am, updMsg +} + +func mockValidateAndParseToken(_ context.Context, token string) (auth.UserAuth, *jwt.Token, error) { + userAuth := auth.UserAuth{} switch token { case "testUserId", "testAdminId", "testOwnerId", "testServiceUserId", "testServiceAdminId", "blockedUserId": diff --git a/management/server/http/testing/testing_tools/db_verify.go b/management/server/http/testing/testing_tools/db_verify.go new file mode 100644 index 000000000..f8af6a41f --- /dev/null +++ b/management/server/http/testing/testing_tools/db_verify.go @@ -0,0 +1,222 @@ +package testing_tools + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + nbdns "github.com/netbirdio/netbird/dns" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + networkTypes "github.com/netbirdio/netbird/management/server/networks/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/route" +) + +// GetDB extracts the *gorm.DB from a store.Store (must be *SqlStore). +func GetDB(t *testing.T, s store.Store) *gorm.DB { + t.Helper() + sqlStore, ok := s.(*store.SqlStore) + require.True(t, ok, "Store is not a *SqlStore, cannot get gorm.DB") + return sqlStore.GetDB() +} + +// VerifyGroupInDB reads a group directly from the DB and returns it. +func VerifyGroupInDB(t *testing.T, db *gorm.DB, groupID string) *types.Group { + t.Helper() + var group types.Group + err := db.Where("id = ? AND account_id = ?", groupID, TestAccountId).First(&group).Error + require.NoError(t, err, "Expected group %s to exist in DB", groupID) + return &group +} + +// VerifyGroupNotInDB verifies that a group does not exist in the DB. +func VerifyGroupNotInDB(t *testing.T, db *gorm.DB, groupID string) { + t.Helper() + var count int64 + db.Model(&types.Group{}).Where("id = ? AND account_id = ?", groupID, TestAccountId).Count(&count) + assert.Equal(t, int64(0), count, "Expected group %s to NOT exist in DB", groupID) +} + +// VerifyPolicyInDB reads a policy directly from the DB and returns it. +func VerifyPolicyInDB(t *testing.T, db *gorm.DB, policyID string) *types.Policy { + t.Helper() + var policy types.Policy + err := db.Preload("Rules").Where("id = ? AND account_id = ?", policyID, TestAccountId).First(&policy).Error + require.NoError(t, err, "Expected policy %s to exist in DB", policyID) + return &policy +} + +// VerifyPolicyNotInDB verifies that a policy does not exist in the DB. +func VerifyPolicyNotInDB(t *testing.T, db *gorm.DB, policyID string) { + t.Helper() + var count int64 + db.Model(&types.Policy{}).Where("id = ? AND account_id = ?", policyID, TestAccountId).Count(&count) + assert.Equal(t, int64(0), count, "Expected policy %s to NOT exist in DB", policyID) +} + +// VerifyRouteInDB reads a route directly from the DB and returns it. +func VerifyRouteInDB(t *testing.T, db *gorm.DB, routeID route.ID) *route.Route { + t.Helper() + var r route.Route + err := db.Where("id = ? AND account_id = ?", routeID, TestAccountId).First(&r).Error + require.NoError(t, err, "Expected route %s to exist in DB", routeID) + return &r +} + +// VerifyRouteNotInDB verifies that a route does not exist in the DB. +func VerifyRouteNotInDB(t *testing.T, db *gorm.DB, routeID route.ID) { + t.Helper() + var count int64 + db.Model(&route.Route{}).Where("id = ? AND account_id = ?", routeID, TestAccountId).Count(&count) + assert.Equal(t, int64(0), count, "Expected route %s to NOT exist in DB", routeID) +} + +// VerifyNSGroupInDB reads a nameserver group directly from the DB and returns it. +func VerifyNSGroupInDB(t *testing.T, db *gorm.DB, nsGroupID string) *nbdns.NameServerGroup { + t.Helper() + var nsGroup nbdns.NameServerGroup + err := db.Where("id = ? AND account_id = ?", nsGroupID, TestAccountId).First(&nsGroup).Error + require.NoError(t, err, "Expected NS group %s to exist in DB", nsGroupID) + return &nsGroup +} + +// VerifyNSGroupNotInDB verifies that a nameserver group does not exist in the DB. +func VerifyNSGroupNotInDB(t *testing.T, db *gorm.DB, nsGroupID string) { + t.Helper() + var count int64 + db.Model(&nbdns.NameServerGroup{}).Where("id = ? AND account_id = ?", nsGroupID, TestAccountId).Count(&count) + assert.Equal(t, int64(0), count, "Expected NS group %s to NOT exist in DB", nsGroupID) +} + +// VerifyPeerInDB reads a peer directly from the DB and returns it. +func VerifyPeerInDB(t *testing.T, db *gorm.DB, peerID string) *nbpeer.Peer { + t.Helper() + var peer nbpeer.Peer + err := db.Where("id = ? AND account_id = ?", peerID, TestAccountId).First(&peer).Error + require.NoError(t, err, "Expected peer %s to exist in DB", peerID) + return &peer +} + +// VerifyPeerNotInDB verifies that a peer does not exist in the DB. +func VerifyPeerNotInDB(t *testing.T, db *gorm.DB, peerID string) { + t.Helper() + var count int64 + db.Model(&nbpeer.Peer{}).Where("id = ? AND account_id = ?", peerID, TestAccountId).Count(&count) + assert.Equal(t, int64(0), count, "Expected peer %s to NOT exist in DB", peerID) +} + +// VerifySetupKeyInDB reads a setup key directly from the DB and returns it. +func VerifySetupKeyInDB(t *testing.T, db *gorm.DB, keyID string) *types.SetupKey { + t.Helper() + var key types.SetupKey + err := db.Where("id = ? AND account_id = ?", keyID, TestAccountId).First(&key).Error + require.NoError(t, err, "Expected setup key %s to exist in DB", keyID) + return &key +} + +// VerifySetupKeyNotInDB verifies that a setup key does not exist in the DB. +func VerifySetupKeyNotInDB(t *testing.T, db *gorm.DB, keyID string) { + t.Helper() + var count int64 + db.Model(&types.SetupKey{}).Where("id = ? AND account_id = ?", keyID, TestAccountId).Count(&count) + assert.Equal(t, int64(0), count, "Expected setup key %s to NOT exist in DB", keyID) +} + +// VerifyUserInDB reads a user directly from the DB and returns it. +func VerifyUserInDB(t *testing.T, db *gorm.DB, userID string) *types.User { + t.Helper() + var user types.User + err := db.Where("id = ? AND account_id = ?", userID, TestAccountId).First(&user).Error + require.NoError(t, err, "Expected user %s to exist in DB", userID) + return &user +} + +// VerifyUserNotInDB verifies that a user does not exist in the DB. +func VerifyUserNotInDB(t *testing.T, db *gorm.DB, userID string) { + t.Helper() + var count int64 + db.Model(&types.User{}).Where("id = ? AND account_id = ?", userID, TestAccountId).Count(&count) + assert.Equal(t, int64(0), count, "Expected user %s to NOT exist in DB", userID) +} + +// VerifyPATInDB reads a PAT directly from the DB and returns it. +func VerifyPATInDB(t *testing.T, db *gorm.DB, tokenID string) *types.PersonalAccessToken { + t.Helper() + var pat types.PersonalAccessToken + err := db.Where("id = ?", tokenID).First(&pat).Error + require.NoError(t, err, "Expected PAT %s to exist in DB", tokenID) + return &pat +} + +// VerifyPATNotInDB verifies that a PAT does not exist in the DB. +func VerifyPATNotInDB(t *testing.T, db *gorm.DB, tokenID string) { + t.Helper() + var count int64 + db.Model(&types.PersonalAccessToken{}).Where("id = ?", tokenID).Count(&count) + assert.Equal(t, int64(0), count, "Expected PAT %s to NOT exist in DB", tokenID) +} + +// VerifyAccountSettings reads the account and returns its settings from the DB. +func VerifyAccountSettings(t *testing.T, db *gorm.DB) *types.Account { + t.Helper() + var account types.Account + err := db.Where("id = ?", TestAccountId).First(&account).Error + require.NoError(t, err, "Expected account %s to exist in DB", TestAccountId) + return &account +} + +// VerifyNetworkInDB reads a network directly from the store and returns it. +func VerifyNetworkInDB(t *testing.T, db *gorm.DB, networkID string) *networkTypes.Network { + t.Helper() + var network networkTypes.Network + err := db.Where("id = ? AND account_id = ?", networkID, TestAccountId).First(&network).Error + require.NoError(t, err, "Expected network %s to exist in DB", networkID) + return &network +} + +// VerifyNetworkNotInDB verifies that a network does not exist in the DB. +func VerifyNetworkNotInDB(t *testing.T, db *gorm.DB, networkID string) { + t.Helper() + var count int64 + db.Model(&networkTypes.Network{}).Where("id = ? AND account_id = ?", networkID, TestAccountId).Count(&count) + assert.Equal(t, int64(0), count, "Expected network %s to NOT exist in DB", networkID) +} + +// VerifyNetworkResourceInDB reads a network resource directly from the DB and returns it. +func VerifyNetworkResourceInDB(t *testing.T, db *gorm.DB, resourceID string) *resourceTypes.NetworkResource { + t.Helper() + var resource resourceTypes.NetworkResource + err := db.Where("id = ? AND account_id = ?", resourceID, TestAccountId).First(&resource).Error + require.NoError(t, err, "Expected network resource %s to exist in DB", resourceID) + return &resource +} + +// VerifyNetworkResourceNotInDB verifies that a network resource does not exist in the DB. +func VerifyNetworkResourceNotInDB(t *testing.T, db *gorm.DB, resourceID string) { + t.Helper() + var count int64 + db.Model(&resourceTypes.NetworkResource{}).Where("id = ? AND account_id = ?", resourceID, TestAccountId).Count(&count) + assert.Equal(t, int64(0), count, "Expected network resource %s to NOT exist in DB", resourceID) +} + +// VerifyNetworkRouterInDB reads a network router directly from the DB and returns it. +func VerifyNetworkRouterInDB(t *testing.T, db *gorm.DB, routerID string) *routerTypes.NetworkRouter { + t.Helper() + var router routerTypes.NetworkRouter + err := db.Where("id = ? AND account_id = ?", routerID, TestAccountId).First(&router).Error + require.NoError(t, err, "Expected network router %s to exist in DB", routerID) + return &router +} + +// VerifyNetworkRouterNotInDB verifies that a network router does not exist in the DB. +func VerifyNetworkRouterNotInDB(t *testing.T, db *gorm.DB, routerID string) { + t.Helper() + var count int64 + db.Model(&routerTypes.NetworkRouter{}).Where("id = ? AND account_id = ?", routerID, TestAccountId).Count(&count) + assert.Equal(t, int64(0), count, "Expected network router %s to NOT exist in DB", routerID) +} diff --git a/management/server/http/testing/testing_tools/tools.go b/management/server/http/testing/testing_tools/tools.go index b7a63b104..9a78620c9 100644 --- a/management/server/http/testing/testing_tools/tools.go +++ b/management/server/http/testing/testing_tools/tools.go @@ -5,9 +5,9 @@ import ( "context" "fmt" "io" - "net" "net/http" "net/http/httptest" + "net/netip" "os" "strconv" "testing" @@ -133,7 +133,7 @@ func PopulateTestData(b *testing.B, am account.Manager, peers, groups, users, se ID: fmt.Sprintf("oldpeer-%d", i), DNSLabel: fmt.Sprintf("oldpeer-%d", i), Key: peerKey.PublicKey().String(), - IP: net.ParseIP(fmt.Sprintf("100.64.%d.%d", i/256, i%256)), + IP: netip.MustParseAddr(fmt.Sprintf("100.64.%d.%d", i/256, i%256)), Status: &nbpeer.PeerStatus{LastSeen: time.Now().UTC(), Connected: true}, UserID: TestUserId, } diff --git a/management/server/identity_provider.go b/management/server/identity_provider.go new file mode 100644 index 000000000..86bbcd893 --- /dev/null +++ b/management/server/identity_provider.go @@ -0,0 +1,305 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/dexidp/dex/storage" + "github.com/rs/xid" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/idp/dex" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/permissions/modules" + "github.com/netbirdio/netbird/management/server/permissions/operations" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +// oidcProviderJSON represents the OpenID Connect discovery document +type oidcProviderJSON struct { + Issuer string `json:"issuer"` +} + +// validateOIDCIssuer validates the OIDC issuer by fetching the OpenID configuration +// and verifying that the returned issuer matches the configured one. +func validateOIDCIssuer(ctx context.Context, issuer string) error { + wellKnown := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration" + + httpClient := &http.Client{ + Timeout: 10 * time.Second, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnown, nil) + if err != nil { + return fmt.Errorf("%w: %v", types.ErrIdentityProviderIssuerUnreachable, err) + } + + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("%w: %v", types.ErrIdentityProviderIssuerUnreachable, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("%w: unable to read response body: %v", types.ErrIdentityProviderIssuerUnreachable, err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%w: %s: %s", types.ErrIdentityProviderIssuerUnreachable, resp.Status, body) + } + + var p oidcProviderJSON + if err := json.Unmarshal(body, &p); err != nil { + return fmt.Errorf("%w: failed to decode provider discovery object: %v", types.ErrIdentityProviderIssuerUnreachable, err) + } + + if p.Issuer != issuer { + return fmt.Errorf("%w: expected %q got %q", types.ErrIdentityProviderIssuerMismatch, issuer, p.Issuer) + } + + return nil +} + +// validateIdentityProviderConfig validates the identity provider configuration including +// basic validation and OIDC issuer verification. +func validateIdentityProviderConfig(ctx context.Context, idpConfig *types.IdentityProvider) error { + if err := idpConfig.Validate(); err != nil { + return status.Errorf(status.InvalidArgument, "%s", err.Error()) + } + + // Validate the issuer by calling the OIDC discovery endpoint + if idpConfig.Issuer != "" { + if err := validateOIDCIssuer(ctx, idpConfig.Issuer); err != nil { + return status.Errorf(status.InvalidArgument, "%s", err.Error()) + } + } + + return nil +} + +// GetIdentityProviders returns all identity providers for an account +func (am *DefaultAccountManager) GetIdentityProviders(ctx context.Context, accountID, userID string) ([]*types.IdentityProvider, error) { + ok, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + embeddedManager, ok := am.idpManager.(*idp.EmbeddedIdPManager) + if !ok { + log.Warn("identity provider management requires embedded IdP") + return []*types.IdentityProvider{}, nil + } + + connectors, err := embeddedManager.ListConnectors(ctx) + if err != nil { + return nil, status.Errorf(status.Internal, "failed to list identity providers: %v", err) + } + + result := make([]*types.IdentityProvider, 0, len(connectors)) + for _, conn := range connectors { + result = append(result, connectorConfigToIdentityProvider(conn, accountID)) + } + + return result, nil +} + +// GetIdentityProvider returns a specific identity provider by ID +func (am *DefaultAccountManager) GetIdentityProvider(ctx context.Context, accountID, idpID, userID string) (*types.IdentityProvider, error) { + ok, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + embeddedManager, ok := am.idpManager.(*idp.EmbeddedIdPManager) + if !ok { + return nil, status.Errorf(status.Internal, "identity provider management requires embedded IdP") + } + + conn, err := embeddedManager.GetConnector(ctx, idpID) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return nil, status.Errorf(status.NotFound, "identity provider not found") + } + return nil, status.Errorf(status.Internal, "failed to get identity provider: %v", err) + } + + return connectorConfigToIdentityProvider(conn, accountID), nil +} + +// CreateIdentityProvider creates a new identity provider +func (am *DefaultAccountManager) CreateIdentityProvider(ctx context.Context, accountID, userID string, idpConfig *types.IdentityProvider) (*types.IdentityProvider, error) { + ok, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Create) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + if err := validateIdentityProviderConfig(ctx, idpConfig); err != nil { + return nil, err + } + + embeddedManager, ok := am.idpManager.(*idp.EmbeddedIdPManager) + if !ok { + return nil, status.Errorf(status.Internal, "identity provider management requires embedded IdP") + } + + // Generate ID if not provided + if idpConfig.ID == "" { + idpConfig.ID = generateIdentityProviderID(idpConfig.Type) + } + idpConfig.AccountID = accountID + + connCfg := identityProviderToConnectorConfig(idpConfig) + + _, err = embeddedManager.CreateConnector(ctx, connCfg) + if err != nil { + return nil, status.Errorf(status.Internal, "failed to create identity provider: %v", err) + } + + am.StoreEvent(ctx, userID, idpConfig.ID, accountID, activity.IdentityProviderCreated, idpConfig.EventMeta()) + + return idpConfig, nil +} + +// UpdateIdentityProvider updates an existing identity provider +func (am *DefaultAccountManager) UpdateIdentityProvider(ctx context.Context, accountID, idpID, userID string, idpConfig *types.IdentityProvider) (*types.IdentityProvider, error) { + ok, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Update) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !ok { + return nil, status.NewPermissionDeniedError() + } + + if err := validateIdentityProviderConfig(ctx, idpConfig); err != nil { + return nil, err + } + + embeddedManager, ok := am.idpManager.(*idp.EmbeddedIdPManager) + if !ok { + return nil, status.Errorf(status.Internal, "identity provider management requires embedded IdP") + } + + idpConfig.ID = idpID + idpConfig.AccountID = accountID + + connCfg := identityProviderToConnectorConfig(idpConfig) + + if err := embeddedManager.UpdateConnector(ctx, connCfg); err != nil { + return nil, status.Errorf(status.Internal, "failed to update identity provider: %v", err) + } + + am.StoreEvent(ctx, userID, idpConfig.ID, accountID, activity.IdentityProviderUpdated, idpConfig.EventMeta()) + + return idpConfig, nil +} + +// DeleteIdentityProvider deletes an identity provider +func (am *DefaultAccountManager) DeleteIdentityProvider(ctx context.Context, accountID, idpID, userID string) error { + ok, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.IdentityProviders, operations.Delete) + if err != nil { + return status.NewPermissionValidationError(err) + } + if !ok { + return status.NewPermissionDeniedError() + } + + embeddedManager, ok := am.idpManager.(*idp.EmbeddedIdPManager) + if !ok { + return status.Errorf(status.Internal, "identity provider management requires embedded IdP") + } + + // Get the IDP info before deleting for the activity event + conn, err := embeddedManager.GetConnector(ctx, idpID) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return status.Errorf(status.NotFound, "identity provider not found") + } + return status.Errorf(status.Internal, "failed to get identity provider: %v", err) + } + idpConfig := connectorConfigToIdentityProvider(conn, accountID) + + if err := embeddedManager.DeleteConnector(ctx, idpID); err != nil { + if errors.Is(err, storage.ErrNotFound) { + return status.Errorf(status.NotFound, "identity provider not found") + } + return status.Errorf(status.Internal, "failed to delete identity provider: %v", err) + } + + am.StoreEvent(ctx, userID, idpID, accountID, activity.IdentityProviderDeleted, idpConfig.EventMeta()) + + return nil +} + +// connectorConfigToIdentityProvider converts a dex.ConnectorConfig to types.IdentityProvider +func connectorConfigToIdentityProvider(conn *dex.ConnectorConfig, accountID string) *types.IdentityProvider { + return &types.IdentityProvider{ + ID: conn.ID, + AccountID: accountID, + Type: types.IdentityProviderType(conn.Type), + Name: conn.Name, + Issuer: conn.Issuer, + ClientID: conn.ClientID, + ClientSecret: conn.ClientSecret, + } +} + +// identityProviderToConnectorConfig converts a types.IdentityProvider to dex.ConnectorConfig +func identityProviderToConnectorConfig(idpConfig *types.IdentityProvider) *dex.ConnectorConfig { + return &dex.ConnectorConfig{ + ID: idpConfig.ID, + Name: idpConfig.Name, + Type: string(idpConfig.Type), + Issuer: idpConfig.Issuer, + ClientID: idpConfig.ClientID, + ClientSecret: idpConfig.ClientSecret, + } +} + +// generateIdentityProviderID generates a unique ID for an identity provider. +// For specific provider types (okta, zitadel, entra, google, pocketid, microsoft, adfs), +// the ID is prefixed with the type name. Generic OIDC providers get no prefix. +func generateIdentityProviderID(idpType types.IdentityProviderType) string { + id := xid.New().String() + + switch idpType { + case types.IdentityProviderTypeOkta: + return "okta-" + id + case types.IdentityProviderTypeZitadel: + return "zitadel-" + id + case types.IdentityProviderTypeEntra: + return "entra-" + id + case types.IdentityProviderTypeGoogle: + return "google-" + id + case types.IdentityProviderTypePocketID: + return "pocketid-" + id + case types.IdentityProviderTypeMicrosoft: + return "microsoft-" + id + case types.IdentityProviderTypeAuthentik: + return "authentik-" + id + case types.IdentityProviderTypeKeycloak: + return "keycloak-" + id + case types.IdentityProviderTypeADFS: + return "adfs-" + id + default: + // Generic OIDC - no prefix + return id + } +} diff --git a/management/server/identity_provider_test.go b/management/server/identity_provider_test.go new file mode 100644 index 000000000..d51254c55 --- /dev/null +++ b/management/server/identity_provider_test.go @@ -0,0 +1,321 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "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" + ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" + "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/cache" + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" + "github.com/netbirdio/netbird/management/server/job" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/settings" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/auth" +) + +func createManagerWithEmbeddedIdP(t testing.TB) (*DefaultAccountManager, *update_channel.PeersUpdateManager, error) { + t.Helper() + + ctx := context.Background() + + dataDir := t.TempDir() + testStore, cleanUp, err := store.NewTestStoreFromSQL(ctx, "", dataDir) + if err != nil { + return nil, nil, err + } + t.Cleanup(cleanUp) + + // Create embedded IdP manager + embeddedConfig := &idp.EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + Storage: idp.EmbeddedStorageConfig{ + Type: "sqlite3", + Config: idp.EmbeddedStorageTypeConfig{ + File: filepath.Join(dataDir, "dex.db"), + }, + }, + } + + idpManager, err := idp.NewEmbeddedIdPManager(ctx, embeddedConfig, nil) + if err != nil { + return nil, nil, err + } + t.Cleanup(func() { _ = idpManager.Stop(ctx) }) + + eventStore := &activity.InMemoryEventStore{} + + metrics, err := telemetry.NewDefaultAppMetrics(ctx) + if err != nil { + return nil, nil, err + } + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + settingsMockManager := settings.NewMockManager(ctrl) + settingsMockManager.EXPECT(). + GetExtraSettings(gomock.Any(), gomock.Any()). + Return(&types.ExtraSettings{}, nil). + AnyTimes() + settingsMockManager.EXPECT(). + UpdateExtraSettings(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(false, nil). + AnyTimes() + + permissionsManager := permissions.NewManager(testStore) + peersManager := peers.NewManager(testStore, permissionsManager) + + cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100) + if err != nil { + return nil, nil, err + } + + updateManager := update_channel.NewPeersUpdateManager(metrics) + requestBuffer := NewAccountRequestBuffer(ctx, testStore) + networkMapController := controller.NewController(ctx, testStore, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(testStore, peersManager), &config.Config{}) + manager, err := BuildManager(ctx, &config.Config{}, testStore, networkMapController, job.NewJobManager(nil, testStore, peersManager), idpManager, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + if err != nil { + return nil, nil, err + } + + return manager, updateManager, nil +} + +func TestDefaultAccountManager_CreateIdentityProvider_Validation(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + userID := "testingUser" + account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err) + + testCases := []struct { + name string + idp *types.IdentityProvider + expectError bool + errorMsg string + }{ + { + name: "Missing Name", + idp: &types.IdentityProvider{ + Type: types.IdentityProviderTypeOIDC, + Issuer: "https://issuer.example.com", + ClientID: "client-id", + }, + expectError: true, + errorMsg: "name is required", + }, + { + name: "Missing Type", + idp: &types.IdentityProvider{ + Name: "Test IDP", + Issuer: "https://issuer.example.com", + ClientID: "client-id", + }, + expectError: true, + errorMsg: "type is required", + }, + { + name: "Missing Issuer", + idp: &types.IdentityProvider{ + Name: "Test IDP", + Type: types.IdentityProviderTypeOIDC, + ClientID: "client-id", + }, + expectError: true, + errorMsg: "issuer is required", + }, + { + name: "Missing ClientID", + idp: &types.IdentityProvider{ + Name: "Test IDP", + Type: types.IdentityProviderTypeOIDC, + Issuer: "https://issuer.example.com", + }, + expectError: true, + errorMsg: "client ID is required", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := manager.CreateIdentityProvider(context.Background(), account.Id, userID, tc.idp) + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.errorMsg) + } + }) + } +} + +func TestDefaultAccountManager_GetIdentityProviders(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + userID := "testingUser" + account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err) + + // Should return empty list (stub implementation) + providers, err := manager.GetIdentityProviders(context.Background(), account.Id, userID) + require.NoError(t, err) + assert.Empty(t, providers) +} + +func TestDefaultAccountManager_GetIdentityProvider_NotFound(t *testing.T) { + manager, _, err := createManagerWithEmbeddedIdP(t) + require.NoError(t, err) + + userID := "testingUser" + account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err) + + // Should return not found error when identity provider doesn't exist + _, err = manager.GetIdentityProvider(context.Background(), account.Id, "any-id", userID) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestDefaultAccountManager_UpdateIdentityProvider_Validation(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + userID := "testingUser" + account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err) + + // Should fail validation before reaching "not implemented" error + invalidIDP := &types.IdentityProvider{ + Name: "", // Empty name should fail validation + } + + _, err = manager.UpdateIdentityProvider(context.Background(), account.Id, "some-id", userID, invalidIDP) + require.Error(t, err) + assert.Contains(t, err.Error(), "name is required") +} + +func TestValidateOIDCIssuer(t *testing.T) { + tests := []struct { + name string + setupServer func() *httptest.Server + expectedErr error + expectedErrMsg string + }{ + { + name: "issuer mismatch", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := oidcProviderJSON{Issuer: "https://different-issuer.com"} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + }, + expectedErr: types.ErrIdentityProviderIssuerMismatch, + expectedErrMsg: "does not match", + }, + { + name: "server returns non-200 status", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("not found")) + })) + }, + expectedErr: types.ErrIdentityProviderIssuerUnreachable, + expectedErrMsg: "404", + }, + { + name: "server returns invalid JSON", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("invalid json")) + })) + }, + expectedErr: types.ErrIdentityProviderIssuerUnreachable, + expectedErrMsg: "failed to decode", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := tt.setupServer() + defer server.Close() + + err := validateOIDCIssuer(context.Background(), server.URL) + + require.Error(t, err) + assert.True(t, errors.Is(err, tt.expectedErr), "expected error %v, got %v", tt.expectedErr, err) + if tt.expectedErrMsg != "" { + assert.Contains(t, err.Error(), tt.expectedErrMsg) + } + }) + } +} + +func TestValidateOIDCIssuer_Success(t *testing.T) { + // Create a server that returns its own URL as the issuer + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/openid-configuration" { + http.NotFound(w, r) + return + } + resp := oidcProviderJSON{Issuer: server.URL} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + err := validateOIDCIssuer(context.Background(), server.URL) + require.NoError(t, err) +} + +func TestValidateOIDCIssuer_UnreachableServer(t *testing.T) { + // Use a URL that will definitely fail to connect + err := validateOIDCIssuer(context.Background(), "http://localhost:59999") + require.Error(t, err) + assert.True(t, errors.Is(err, types.ErrIdentityProviderIssuerUnreachable)) +} + +func TestValidateOIDCIssuer_TrailingSlash(t *testing.T) { + // Test that trailing slashes are handled correctly + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/openid-configuration" { + http.NotFound(w, r) + return + } + // Return issuer without trailing slash + resp := oidcProviderJSON{Issuer: server.URL} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // Pass issuer with trailing slash + err := validateOIDCIssuer(context.Background(), server.URL+"/") + // This should fail because the issuer returned doesn't have trailing slash + require.Error(t, err) + assert.True(t, errors.Is(err, types.ErrIdentityProviderIssuerMismatch)) +} diff --git a/management/server/idp/auth0.go b/management/server/idp/auth0.go index 1eb8434d3..7d3837190 100644 --- a/management/server/idp/auth0.go +++ b/management/server/idp/auth0.go @@ -136,9 +136,10 @@ func NewAuth0Manager(config Auth0ClientConfig, appMetrics telemetry.AppMetrics) httpTransport.MaxIdleConns = 5 httpClient := &http.Client{ - Timeout: 10 * time.Second, + Timeout: idpTimeout(), Transport: httpTransport, } + helper := JsonParser{} if config.AuthIssuer == "" { diff --git a/management/server/idp/authentik.go b/management/server/idp/authentik.go index 2f87a9bba..ebd79b715 100644 --- a/management/server/idp/authentik.go +++ b/management/server/idp/authentik.go @@ -48,13 +48,12 @@ type AuthentikCredentials struct { } // NewAuthentikManager creates a new instance of the AuthentikManager. -func NewAuthentikManager(config AuthentikClientConfig, - appMetrics telemetry.AppMetrics) (*AuthentikManager, error) { +func NewAuthentikManager(config AuthentikClientConfig, appMetrics telemetry.AppMetrics) (*AuthentikManager, error) { httpTransport := http.DefaultTransport.(*http.Transport).Clone() httpTransport.MaxIdleConns = 5 httpClient := &http.Client{ - Timeout: 10 * time.Second, + Timeout: idpTimeout(), Transport: httpTransport, } diff --git a/management/server/idp/azure.go b/management/server/idp/azure.go index 393a39e3e..320ca7a83 100644 --- a/management/server/idp/azure.go +++ b/management/server/idp/azure.go @@ -58,9 +58,10 @@ func NewAzureManager(config AzureClientConfig, appMetrics telemetry.AppMetrics) httpTransport.MaxIdleConns = 5 httpClient := &http.Client{ - Timeout: 10 * time.Second, + Timeout: idpTimeout(), Transport: httpTransport, } + helper := JsonParser{} if config.ClientID == "" { diff --git a/management/server/idp/dex.go b/management/server/idp/dex.go new file mode 100644 index 000000000..0cac246e1 --- /dev/null +++ b/management/server/idp/dex.go @@ -0,0 +1,445 @@ +package idp + +import ( + "context" + "encoding/base64" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/dexidp/dex/api/v2" + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/bcrypt" + "google.golang.org/grpc" + "google.golang.org/grpc/connectivity" + "google.golang.org/grpc/credentials/insecure" + + "github.com/netbirdio/netbird/management/server/telemetry" +) + +// DexManager implements the Manager interface for Dex IDP. +// It uses Dex's gRPC API to manage users in the password database. +type DexManager struct { + grpcAddr string + httpClient ManagerHTTPClient + helper ManagerHelper + appMetrics telemetry.AppMetrics + mux sync.Mutex + conn *grpc.ClientConn +} + +// DexClientConfig Dex manager client configuration. +type DexClientConfig struct { + // GRPCAddr is the address of Dex's gRPC API (e.g., "localhost:5557") + GRPCAddr string + // Issuer is the Dex issuer URL (e.g., "https://dex.example.com/dex") + Issuer string +} + +// NewDexManager creates a new instance of DexManager. +func NewDexManager(config DexClientConfig, appMetrics telemetry.AppMetrics) (*DexManager, error) { + if config.GRPCAddr == "" { + return nil, fmt.Errorf("dex IdP configuration is incomplete, GRPCAddr is missing") + } + + httpTransport := http.DefaultTransport.(*http.Transport).Clone() + httpTransport.MaxIdleConns = 5 + + httpClient := &http.Client{ + Timeout: 10 * time.Second, + Transport: httpTransport, + } + helper := JsonParser{} + + return &DexManager{ + grpcAddr: config.GRPCAddr, + httpClient: httpClient, + helper: helper, + appMetrics: appMetrics, + }, nil +} + +// getConnection returns a gRPC connection to Dex, creating one if necessary. +// It also checks if an existing connection is still healthy and reconnects if needed. +func (dm *DexManager) getConnection(ctx context.Context) (*grpc.ClientConn, error) { + dm.mux.Lock() + defer dm.mux.Unlock() + + if dm.conn != nil { + state := dm.conn.GetState() + // If connection is shutdown or in a transient failure, close and reconnect + if state == connectivity.Shutdown || state == connectivity.TransientFailure { + log.WithContext(ctx).Debugf("Dex gRPC connection in state %s, reconnecting", state) + _ = dm.conn.Close() + dm.conn = nil + } else { + return dm.conn, nil + } + } + + log.WithContext(ctx).Debugf("connecting to Dex gRPC API at %s", dm.grpcAddr) + + conn, err := grpc.NewClient(dm.grpcAddr, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + return nil, fmt.Errorf("failed to connect to Dex gRPC API: %w", err) + } + + dm.conn = conn + return conn, nil +} + +// getDexClient returns a Dex API client. +func (dm *DexManager) getDexClient(ctx context.Context) (api.DexClient, error) { + conn, err := dm.getConnection(ctx) + if err != nil { + return nil, err + } + return api.NewDexClient(conn), nil +} + +// encodeDexUserID encodes a user ID and connector ID into Dex's composite format. +// This is the reverse of parseDexUserID - it creates the base64-encoded protobuf +// format that Dex uses in JWT tokens. +func encodeDexUserID(userID, connectorID string) string { + // Build simple protobuf structure: + // Field 1 (tag 0x0a): user ID string + // Field 2 (tag 0x12): connector ID string + buf := make([]byte, 0, 2+len(userID)+2+len(connectorID)) + + // Field 1: user ID + buf = append(buf, 0x0a) // tag for field 1, wire type 2 (length-delimited) + buf = append(buf, byte(len(userID))) // length + buf = append(buf, []byte(userID)...) // value + + // Field 2: connector ID + buf = append(buf, 0x12) // tag for field 2, wire type 2 (length-delimited) + buf = append(buf, byte(len(connectorID))) // length + buf = append(buf, []byte(connectorID)...) // value + + return base64.StdEncoding.EncodeToString(buf) +} + +// parseDexUserID extracts the actual user ID from Dex's composite user ID. +// Dex encodes user IDs in JWT tokens as base64-encoded protobuf with format: +// - Field 1 (string): actual user ID +// - Field 2 (string): connector ID (e.g., "local") +// If the ID is not in this format, it returns the original ID. +func parseDexUserID(compositeID string) string { + // Try to decode as standard base64 + decoded, err := base64.StdEncoding.DecodeString(compositeID) + if err != nil { + // Try URL-safe base64 + decoded, err = base64.RawURLEncoding.DecodeString(compositeID) + if err != nil { + // Not base64 encoded, return as-is + return compositeID + } + } + + // Parse the simple protobuf structure + // Field 1 (tag 0x0a): user ID string + // Field 2 (tag 0x12): connector ID string + if len(decoded) < 2 { + return compositeID + } + + // Check for field 1 tag (0x0a = field 1, wire type 2/length-delimited) + if decoded[0] != 0x0a { + return compositeID + } + + // Read the length of the user ID string + length := int(decoded[1]) + if len(decoded) < 2+length { + return compositeID + } + + // Extract the user ID + userID := string(decoded[2 : 2+length]) + return userID +} + +// UpdateUserAppMetadata updates user app metadata based on userID and metadata map. +// Dex doesn't support app metadata, so this is a no-op. +func (dm *DexManager) UpdateUserAppMetadata(_ context.Context, _ string, _ AppMetadata) error { + return nil +} + +// GetUserDataByID requests user data from Dex via user ID. +func (dm *DexManager) GetUserDataByID(ctx context.Context, userID string, _ AppMetadata) (*UserData, error) { + if dm.appMetrics != nil { + dm.appMetrics.IDPMetrics().CountGetUserDataByID() + } + + client, err := dm.getDexClient(ctx) + if err != nil { + if dm.appMetrics != nil { + dm.appMetrics.IDPMetrics().CountRequestError() + } + return nil, err + } + + resp, err := client.ListPasswords(ctx, &api.ListPasswordReq{}) + if err != nil { + if dm.appMetrics != nil { + dm.appMetrics.IDPMetrics().CountRequestError() + } + return nil, fmt.Errorf("failed to list passwords from Dex: %w", err) + } + + // Try to parse the composite user ID from Dex JWT token + actualUserID := parseDexUserID(userID) + + for _, p := range resp.Passwords { + // Match against both the raw userID and the parsed actualUserID + if p.UserId == userID || p.UserId == actualUserID { + return &UserData{ + Email: p.Email, + Name: p.Username, + ID: userID, // Return the original ID for consistency + }, nil + } + } + + return nil, fmt.Errorf("user with ID %s not found", userID) +} + +// GetAccount returns all the users for a given account. +// Since Dex doesn't have account concepts, this returns all users. +func (dm *DexManager) GetAccount(ctx context.Context, accountID string) ([]*UserData, error) { + if dm.appMetrics != nil { + dm.appMetrics.IDPMetrics().CountGetAccount() + } + + users, err := dm.getAllUsers(ctx) + if err != nil { + return nil, err + } + + // Set the account ID for all users + for _, user := range users { + user.AppMetadata.WTAccountID = accountID + } + + return users, nil +} + +// GetAllAccounts gets all registered accounts with corresponding user data. +// Since Dex doesn't have account concepts, all users are returned under UnsetAccountID. +func (dm *DexManager) GetAllAccounts(ctx context.Context) (map[string][]*UserData, error) { + if dm.appMetrics != nil { + dm.appMetrics.IDPMetrics().CountGetAllAccounts() + } + + users, err := dm.getAllUsers(ctx) + if err != nil { + return nil, err + } + + indexedUsers := make(map[string][]*UserData) + indexedUsers[UnsetAccountID] = users + + return indexedUsers, nil +} + +// CreateUser creates a new user in Dex's password database. +func (dm *DexManager) CreateUser(ctx context.Context, email, name, accountID, invitedByEmail string) (*UserData, error) { + if dm.appMetrics != nil { + dm.appMetrics.IDPMetrics().CountCreateUser() + } + + client, err := dm.getDexClient(ctx) + if err != nil { + if dm.appMetrics != nil { + dm.appMetrics.IDPMetrics().CountRequestError() + } + return nil, err + } + + // Generate a random password for the new user + password := GeneratePassword(16, 2, 2, 2) + + // Hash the password using bcrypt + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("failed to hash password: %w", err) + } + + // Generate a user ID from email (Dex uses email as the key, but we need a stable ID) + userID := strings.ReplaceAll(email, "@", "-at-") + userID = strings.ReplaceAll(userID, ".", "-") + + req := &api.CreatePasswordReq{ + Password: &api.Password{ + Email: email, + Username: name, + UserId: userID, + Hash: hashedPassword, + }, + } + + resp, err := client.CreatePassword(ctx, req) + if err != nil { + if dm.appMetrics != nil { + dm.appMetrics.IDPMetrics().CountRequestError() + } + return nil, fmt.Errorf("failed to create user in Dex: %w", err) + } + + if resp.AlreadyExists { + return nil, fmt.Errorf("user with email %s already exists", email) + } + + log.WithContext(ctx).Debugf("created user %s in Dex", email) + + return &UserData{ + Email: email, + Name: name, + ID: userID, + AppMetadata: AppMetadata{ + WTAccountID: accountID, + WTInvitedBy: invitedByEmail, + }, + }, nil +} + +// GetUserByEmail searches users with a given email. +// If no users have been found, this function returns an empty list. +func (dm *DexManager) GetUserByEmail(ctx context.Context, email string) ([]*UserData, error) { + if dm.appMetrics != nil { + dm.appMetrics.IDPMetrics().CountGetUserByEmail() + } + + client, err := dm.getDexClient(ctx) + if err != nil { + if dm.appMetrics != nil { + dm.appMetrics.IDPMetrics().CountRequestError() + } + return nil, err + } + + resp, err := client.ListPasswords(ctx, &api.ListPasswordReq{}) + if err != nil { + if dm.appMetrics != nil { + dm.appMetrics.IDPMetrics().CountRequestError() + } + return nil, fmt.Errorf("failed to list passwords from Dex: %w", err) + } + + users := make([]*UserData, 0) + for _, p := range resp.Passwords { + if strings.EqualFold(p.Email, email) { + // Encode the user ID in Dex's composite format to match stored IDs + encodedID := encodeDexUserID(p.UserId, "local") + users = append(users, &UserData{ + Email: p.Email, + Name: p.Username, + ID: encodedID, + }) + } + } + + return users, nil +} + +// InviteUserByID resends an invitation to a user. +// Dex doesn't support invitations, so this returns an error. +func (dm *DexManager) InviteUserByID(_ context.Context, _ string) error { + return fmt.Errorf("method InviteUserByID not implemented for Dex") +} + +// DeleteUser deletes a user from Dex by user ID. +func (dm *DexManager) DeleteUser(ctx context.Context, userID string) error { + if dm.appMetrics != nil { + dm.appMetrics.IDPMetrics().CountDeleteUser() + } + + client, err := dm.getDexClient(ctx) + if err != nil { + if dm.appMetrics != nil { + dm.appMetrics.IDPMetrics().CountRequestError() + } + return err + } + + // First, find the user's email by ID + resp, err := client.ListPasswords(ctx, &api.ListPasswordReq{}) + if err != nil { + if dm.appMetrics != nil { + dm.appMetrics.IDPMetrics().CountRequestError() + } + return fmt.Errorf("failed to list passwords from Dex: %w", err) + } + + // Try to parse the composite user ID from Dex JWT token + actualUserID := parseDexUserID(userID) + + var email string + for _, p := range resp.Passwords { + if p.UserId == userID || p.UserId == actualUserID { + email = p.Email + break + } + } + + if email == "" { + return fmt.Errorf("user with ID %s not found", userID) + } + + // Delete the user by email + deleteResp, err := client.DeletePassword(ctx, &api.DeletePasswordReq{ + Email: email, + }) + if err != nil { + if dm.appMetrics != nil { + dm.appMetrics.IDPMetrics().CountRequestError() + } + return fmt.Errorf("failed to delete user from Dex: %w", err) + } + + if deleteResp.NotFound { + return fmt.Errorf("user with email %s not found", email) + } + + log.WithContext(ctx).Debugf("deleted user %s from Dex", email) + + return nil +} + +// getAllUsers retrieves all users from Dex's password database. +func (dm *DexManager) getAllUsers(ctx context.Context) ([]*UserData, error) { + client, err := dm.getDexClient(ctx) + if err != nil { + if dm.appMetrics != nil { + dm.appMetrics.IDPMetrics().CountRequestError() + } + return nil, err + } + + resp, err := client.ListPasswords(ctx, &api.ListPasswordReq{}) + if err != nil { + if dm.appMetrics != nil { + dm.appMetrics.IDPMetrics().CountRequestError() + } + return nil, fmt.Errorf("failed to list passwords from Dex: %w", err) + } + + users := make([]*UserData, 0, len(resp.Passwords)) + for _, p := range resp.Passwords { + // Encode the user ID in Dex's composite format (base64-encoded protobuf) + // to match how NetBird stores user IDs from Dex JWT tokens. + // The connector ID "local" is used for Dex's password database. + encodedID := encodeDexUserID(p.UserId, "local") + users = append(users, &UserData{ + Email: p.Email, + Name: p.Username, + ID: encodedID, + }) + } + + return users, nil +} diff --git a/management/server/idp/dex_test.go b/management/server/idp/dex_test.go new file mode 100644 index 000000000..b1991bd9f --- /dev/null +++ b/management/server/idp/dex_test.go @@ -0,0 +1,137 @@ +package idp + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/telemetry" +) + +func TestNewDexManager(t *testing.T) { + type test struct { + name string + inputConfig DexClientConfig + assertErrFunc require.ErrorAssertionFunc + assertErrFuncMessage string + } + + defaultTestConfig := DexClientConfig{ + GRPCAddr: "localhost:5557", + Issuer: "https://dex.example.com/dex", + } + + testCase1 := test{ + name: "Good Configuration", + inputConfig: defaultTestConfig, + assertErrFunc: require.NoError, + assertErrFuncMessage: "shouldn't return error", + } + + testCase2Config := defaultTestConfig + testCase2Config.GRPCAddr = "" + + testCase2 := test{ + name: "Missing GRPCAddr Configuration", + inputConfig: testCase2Config, + assertErrFunc: require.Error, + assertErrFuncMessage: "should return error when GRPCAddr is empty", + } + + // Test with empty issuer - should still work since issuer is optional for the manager + testCase3Config := defaultTestConfig + testCase3Config.Issuer = "" + + testCase3 := test{ + name: "Missing Issuer Configuration - OK", + inputConfig: testCase3Config, + assertErrFunc: require.NoError, + assertErrFuncMessage: "shouldn't return error when issuer is empty", + } + + for _, testCase := range []test{testCase1, testCase2, testCase3} { + t.Run(testCase.name, func(t *testing.T) { + manager, err := NewDexManager(testCase.inputConfig, &telemetry.MockAppMetrics{}) + testCase.assertErrFunc(t, err, testCase.assertErrFuncMessage) + + if err == nil { + require.NotNil(t, manager, "manager should not be nil") + require.Equal(t, testCase.inputConfig.GRPCAddr, manager.grpcAddr, "grpcAddr should match") + } + }) + } +} + +func TestDexManagerUpdateUserAppMetadata(t *testing.T) { + config := DexClientConfig{ + GRPCAddr: "localhost:5557", + Issuer: "https://dex.example.com/dex", + } + + manager, err := NewDexManager(config, &telemetry.MockAppMetrics{}) + require.NoError(t, err, "should create manager without error") + + // UpdateUserAppMetadata should be a no-op for Dex + err = manager.UpdateUserAppMetadata(context.Background(), "test-user-id", AppMetadata{ + WTAccountID: "test-account", + }) + require.NoError(t, err, "UpdateUserAppMetadata should not return error") +} + +func TestDexManagerInviteUserByID(t *testing.T) { + config := DexClientConfig{ + GRPCAddr: "localhost:5557", + Issuer: "https://dex.example.com/dex", + } + + manager, err := NewDexManager(config, &telemetry.MockAppMetrics{}) + require.NoError(t, err, "should create manager without error") + + // InviteUserByID should return an error for Dex + err = manager.InviteUserByID(context.Background(), "test-user-id") + require.Error(t, err, "InviteUserByID should return error") + require.Contains(t, err.Error(), "not implemented", "error should mention not implemented") +} + +func TestParseDexUserID(t *testing.T) { + tests := []struct { + name string + compositeID string + expectedID string + }{ + { + name: "Parse base64-encoded protobuf composite ID", + // This is a real Dex composite ID: contains user ID "cf5db180-d360-484d-9b78-c5db92146420" and connector "local" + compositeID: "CiRjZjVkYjE4MC1kMzYwLTQ4NGQtOWI3OC1jNWRiOTIxNDY0MjASBWxvY2Fs", + expectedID: "cf5db180-d360-484d-9b78-c5db92146420", + }, + { + name: "Return plain ID unchanged", + compositeID: "simple-user-id", + expectedID: "simple-user-id", + }, + { + name: "Return UUID unchanged", + compositeID: "cf5db180-d360-484d-9b78-c5db92146420", + expectedID: "cf5db180-d360-484d-9b78-c5db92146420", + }, + { + name: "Handle empty string", + compositeID: "", + expectedID: "", + }, + { + name: "Handle invalid base64", + compositeID: "not-valid-base64!!!", + expectedID: "not-valid-base64!!!", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseDexUserID(tt.compositeID) + require.Equal(t, tt.expectedID, result, "parsed user ID should match expected") + }) + } +} diff --git a/management/server/idp/embedded.go b/management/server/idp/embedded.go new file mode 100644 index 000000000..b045d6ad6 --- /dev/null +++ b/management/server/idp/embedded.go @@ -0,0 +1,822 @@ +package idp + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "path" + "strings" + + "github.com/dexidp/dex/storage" + "github.com/google/uuid" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/idp/dex" + "github.com/netbirdio/netbird/management/server/telemetry" + nbjwt "github.com/netbirdio/netbird/shared/auth/jwt" +) + +const ( + StaticClientDashboard = "netbird-dashboard" + StaticClientCLI = "netbird-cli" + DefaultTOTPAuthenticatorID = "default-totp" + LocalConnectorID = dex.LocalConnectorID + + defaultCLIRedirectURL1 = "http://localhost:53000/" + defaultCLIRedirectURL2 = "http://localhost:54000/" + defaultScopes = "openid profile email groups" + defaultUserIDClaim = "sub" + sessionCookieEncryptionKeyEnv = "NB_IDP_SESSION_COOKIE_ENCRYPTION_KEY" +) + +// EmbeddedIdPConfig contains configuration for the embedded Dex OIDC identity provider +type EmbeddedIdPConfig struct { + // Enabled indicates whether the embedded IDP is enabled + Enabled bool + // Issuer is the OIDC issuer URL (e.g., "https://management.netbird.io/oauth2") + Issuer string + // LocalAddress is the management server's local listen address (e.g., ":8080" or "localhost:8080") + // Used for internal JWT validation to avoid external network calls + LocalAddress string + // Storage configuration for the IdP database + Storage EmbeddedStorageConfig + // DashboardRedirectURIs are the OAuth2 redirect URIs for the dashboard client + DashboardRedirectURIs []string + // DashboardRedirectURIs are the OAuth2 redirect URIs for the dashboard client + CLIRedirectURIs []string + // Owner is the initial owner/admin user (optional, can be nil) + Owner *OwnerConfig + // SignKeyRefreshEnabled enables automatic key rotation for signing keys + SignKeyRefreshEnabled bool + // LocalAuthDisabled disables the local (email/password) authentication connector. + // When true, users cannot authenticate via email/password, only via external identity providers. + // Existing local users are preserved and will be able to login again if re-enabled. + // Cannot be enabled if no external identity provider connectors are configured. + LocalAuthDisabled bool + // MfaSessionMaxLifetime is the maximum MFA session duration from creation (e.g. "24h"). + // Defaults to "24h" if empty. + MfaSessionMaxLifetime string + // MfaSessionIdleTimeout is the idle timeout after which the MFA session expires (e.g. "1h"). + // Defaults to "1h" if empty. + MfaSessionIdleTimeout string + // MfaSessionRememberMe controls the default state of the "remember me" checkbox on the + // login screen. When true, the session cookie persists across browser tabs/restarts so + // MFA is not re-prompted until the session expires. Defaults to false. + MfaSessionRememberMe bool + // SessionCookieEncryptionKey is the optional AES key used to encrypt embedded IdP session cookies. + // It can also be set with NB_IDP_SESSION_COOKIE_ENCRYPTION_KEY. The value must be 16, 24, or 32 + // bytes when provided as a raw string, or base64-encoded to one of those lengths. + SessionCookieEncryptionKey string + // Dashboard Post logout redirect URIs, these are required to tell + // Dex what to allow when an RP-Initiated logout is started by the frontend + // at least one of these must match the dashboard base URL or the dashboard + // DASHBOARD_POST_LOGOUT_URL environment variable + // WARNING: Dex only uses exact match, not wildcards + DashboardPostLogoutRedirectURIs []string + // StaticConnectors are additional connectors to seed during initialization + StaticConnectors []dex.Connector + // GrantTypes restricts allowed OAuth2 grants; empty means all (Dex default). Omit the + // device_code grant to disable the device flow; keep authorization_code and refresh_token. + GrantTypes []string +} + +// EmbeddedStorageConfig holds storage configuration for the embedded IdP. +type EmbeddedStorageConfig struct { + // Type is the storage type: "sqlite3" (default) or "postgres" + Type string + // Config contains type-specific configuration + Config EmbeddedStorageTypeConfig +} + +// EmbeddedStorageTypeConfig contains type-specific storage configuration. +type EmbeddedStorageTypeConfig struct { + // File is the path to the SQLite database file (for sqlite3 type) + File string + // DSN is the connection string for postgres + DSN string +} + +// OwnerConfig represents the initial owner/admin user for the embedded IdP. +type OwnerConfig struct { + // Email is the user's email address (required) + Email string + // Hash is the bcrypt hash of the user's password (required) + Hash string + // Username is the display name for the user (optional, defaults to email) + Username string +} + +// buildIdpStorageConfig builds the Dex storage config map based on the storage type. +func buildIdpStorageConfig(storageType string, cfg EmbeddedStorageTypeConfig) (map[string]interface{}, error) { + switch storageType { + case "sqlite3": + return map[string]interface{}{ + "file": cfg.File, + }, nil + case "postgres": + return map[string]interface{}{ + "dsn": cfg.DSN, + }, nil + default: + return nil, fmt.Errorf("unsupported IdP storage type: %s", storageType) + } +} + +// ToYAMLConfig converts EmbeddedIdPConfig to dex.YAMLConfig. +func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) { + if c.Issuer == "" { + return nil, fmt.Errorf("issuer is required") + } + if c.Storage.Type == "" { + c.Storage.Type = "sqlite3" + } + if c.Storage.Type == "sqlite3" && c.Storage.Config.File == "" { + return nil, fmt.Errorf("storage file is required for sqlite3") + } + if c.Storage.Type == "postgres" && c.Storage.Config.DSN == "" { + return nil, fmt.Errorf("storage DSN is required for postgres") + } + + storageConfig, err := buildIdpStorageConfig(c.Storage.Type, c.Storage.Config) + if err != nil { + return nil, fmt.Errorf("invalid IdP storage config: %w", err) + } + + // Build CLI redirect URIs including the device callback. Dex uses the issuer-relative + // path (for example, /oauth2/device/callback) when completing the device flow, so + // include it explicitly in addition to the legacy bare path and absolute URL. + cliRedirectURIs := c.CLIRedirectURIs + cliRedirectURIs = append(cliRedirectURIs, "/device/callback") + cliRedirectURIs = append(cliRedirectURIs, issuerRelativeDeviceCallback(c.Issuer)) + cliRedirectURIs = append(cliRedirectURIs, strings.TrimSuffix(c.Issuer, "/")+"/device/callback") + + // Build dashboard redirect URIs including the OAuth callback for proxy authentication + dashboardRedirectURIs := c.DashboardRedirectURIs + baseURL := strings.TrimSuffix(c.Issuer, "/oauth2") + // todo: resolve import cycle + dashboardRedirectURIs = append(dashboardRedirectURIs, baseURL+"/api/reverse-proxy/callback") + + dashboardPostLogoutRedirectURIs := c.DashboardPostLogoutRedirectURIs + // It is safe to assume that most installations will share the location of the + // MGMT api and the dashboard, adding baseURL means less configuration for the instance admin + dashboardPostLogoutRedirectURIs = append(dashboardPostLogoutRedirectURIs, baseURL) + + redirectURIs := make([]string, 0) + redirectURIs = append(redirectURIs, cliRedirectURIs...) + redirectURIs = append(redirectURIs, dashboardRedirectURIs...) + + cfg := &dex.YAMLConfig{ + Issuer: c.Issuer, + Storage: dex.Storage{ + Type: c.Storage.Type, + Config: storageConfig, + }, + Web: dex.Web{ + AllowedOrigins: []string{"*"}, + AllowedHeaders: []string{"Authorization", "Content-Type"}, + }, + OAuth2: dex.OAuth2{ + SkipApprovalScreen: true, + GrantTypes: c.GrantTypes, + }, + Frontend: dex.Frontend{ + Issuer: "NetBird", + Theme: "light", + }, + // Always enable password DB initially - we disable the local connector after startup if needed. + // This ensures Dex has at least one connector during initialization. + EnablePasswordDB: true, + StaticClients: []storage.Client{ + { + ID: StaticClientDashboard, + Name: "NetBird Dashboard", + Public: true, + RedirectURIs: redirectURIs, + PostLogoutRedirectURIs: sanitizePostLogoutRedirectURIs(dashboardPostLogoutRedirectURIs), + }, + { + ID: StaticClientCLI, + Name: "NetBird CLI", + Public: true, + RedirectURIs: redirectURIs, + }, + }, + StaticConnectors: c.StaticConnectors, + } + + // Always initialize MFA providers and sessions so TOTP can be toggled at runtime. + // MFAChain on clients is NOT set here — it's synced from the DB setting on startup. + if err := configureMFA(cfg, c.MfaSessionMaxLifetime, c.MfaSessionIdleTimeout, c.MfaSessionRememberMe, c.SessionCookieEncryptionKey); err != nil { + return nil, err + } + + // Add owner user if provided + if c.Owner != nil && c.Owner.Email != "" && c.Owner.Hash != "" { + username := c.Owner.Username + if username == "" { + username = c.Owner.Email + } + cfg.StaticPasswords = []dex.Password{ + { + Email: c.Owner.Email, + Hash: []byte(c.Owner.Hash), + Username: username, + UserID: uuid.New().String(), + }, + } + } + + return cfg, nil +} + +func issuerRelativeDeviceCallback(issuer string) string { + u, err := url.Parse(issuer) + if err != nil || u.Path == "" { + return "/device/callback" + } + return path.Join(u.Path, "/device/callback") +} + +// Due to how the frontend generates the logout, sometimes it appends a trailing slash +// and because Dex only allows exact matches, we need to make sure we always have both +// versions of each provided uri +func sanitizePostLogoutRedirectURIs(uris []string) []string { + result := make([]string, 0) + for _, uri := range uris { + if strings.HasSuffix(uri, "/") { + result = append(result, uri) + result = append(result, strings.TrimSuffix(uri, "/")) + } else { + result = append(result, uri) + result = append(result, uri+"/") + } + } + + return result +} + +func configureMFA(cfg *dex.YAMLConfig, sessionMaxLifetime, sessionIdleTimeout string, rememberMe bool, sessionCookieEncryptionKey string) error { + cfg.MFA.Authenticators = []dex.MFAAuthenticator{{ + ID: DefaultTOTPAuthenticatorID, + // Has to be caps otherwise it will fail + Type: "TOTP", + Config: map[string]interface{}{ + "issuer": "NetBird", + }, + ConnectorTypes: []string{LocalConnectorID}, + }} + + if sessionMaxLifetime == "" { + sessionMaxLifetime = "24h" + } + if sessionIdleTimeout == "" { + sessionIdleTimeout = "1h" + } + + cookieEncryptionKey, err := resolveSessionCookieEncryptionKey(sessionCookieEncryptionKey) + if err != nil { + return err + } + + cfg.Sessions = &dex.Sessions{ + CookieName: "netbird-session", + AbsoluteLifetime: sessionMaxLifetime, + ValidIfNotUsedFor: sessionIdleTimeout, + RememberMeCheckedByDefault: &rememberMe, + SSOSharedWithDefault: "all", + CookieEncryptionKey: cookieEncryptionKey, + } + // Absolutely required, otherwise the dex server will omit the MFA configuration entirely + os.Setenv("DEX_SESSIONS_ENABLED", "true") + + // Note: MFAChain on clients is NOT set here. + // It is toggled at runtime via SetMFAEnabled() based on the account settings DB value. + return nil +} + +func resolveSessionCookieEncryptionKey(configuredKey string) (string, error) { + key := strings.TrimSpace(configuredKey) + if key == "" { + key = strings.TrimSpace(os.Getenv(sessionCookieEncryptionKeyEnv)) + } + if key == "" { + return "", nil + } + + if validSessionCookieEncryptionKeyLength(len([]byte(key))) { + return key, nil + } + + for _, encoding := range []*base64.Encoding{ + base64.StdEncoding, + base64.RawStdEncoding, + base64.URLEncoding, + base64.RawURLEncoding, + } { + decoded, err := encoding.DecodeString(key) + if err == nil && validSessionCookieEncryptionKeyLength(len(decoded)) { + return string(decoded), nil + } + } + + return "", fmt.Errorf("invalid embedded IdP session cookie encryption key:%s (or sessionCookieEncryptionKey) must be 16, 24, or 32 bytes as a raw string or base64-encoded to one of those lengths; got %d raw bytes", sessionCookieEncryptionKeyEnv, len([]byte(key))) +} + +func validSessionCookieEncryptionKeyLength(length int) bool { + switch length { + case 16, 24, 32: + return true + default: + return false + } +} + +// Compile-time check that EmbeddedIdPManager implements Manager interface +var _ Manager = (*EmbeddedIdPManager)(nil) + +// Compile-time check that EmbeddedIdPManager implements OAuthConfigProvider interface +var _ OAuthConfigProvider = (*EmbeddedIdPManager)(nil) + +// OAuthConfigProvider defines the interface for OAuth configuration needed by auth flows. +type OAuthConfigProvider interface { + GetIssuer() string + // GetKeysLocation returns the public JWKS endpoint URL (uses external issuer URL) + GetKeysLocation() string + // GetLocalKeysLocation returns the localhost JWKS endpoint URL for internal use. + // Management server has embedded Dex and can validate tokens via localhost, + // avoiding external network calls and DNS resolution issues during startup. + GetLocalKeysLocation() string + // GetKeyFetcher returns a KeyFetcher that reads keys directly from the IDP storage, + // or nil if direct key fetching is not supported (falls back to HTTP). + GetKeyFetcher() nbjwt.KeyFetcher + GetClientIDs() []string + GetUserIDClaim() string + GetTokenEndpoint() string + GetDeviceAuthEndpoint() string + GetAuthorizationEndpoint() string + GetDefaultScopes() string + GetCLIClientID() string + GetCLIRedirectURLs() []string +} + +// EmbeddedIdPManager implements the Manager interface using the embedded Dex IdP. +type EmbeddedIdPManager struct { + provider *dex.Provider + appMetrics telemetry.AppMetrics + config EmbeddedIdPConfig + mfaEnabled bool +} + +// NewEmbeddedIdPManager creates a new instance of EmbeddedIdPManager from a configuration. +// It instantiates the underlying Dex provider internally. +// Note: Storage defaults are applied in config loading (applyEmbeddedIdPConfig) based on Datadir. +func NewEmbeddedIdPManager(ctx context.Context, config *EmbeddedIdPConfig, appMetrics telemetry.AppMetrics) (*EmbeddedIdPManager, error) { + if config == nil { + return nil, fmt.Errorf("embedded IdP config is required") + } + + // Apply defaults for CLI redirect URIs + if len(config.CLIRedirectURIs) == 0 { + config.CLIRedirectURIs = []string{defaultCLIRedirectURL1, defaultCLIRedirectURL2} + } + + // there are some properties create when creating YAML config (e.g., auth clients) + yamlConfig, err := config.ToYAMLConfig() + if err != nil { + return nil, err + } + + log.WithContext(ctx).Debugf("initializing embedded Dex IDP with config: %+v", config) + + provider, err := dex.NewProviderFromYAML(ctx, yamlConfig) + if err != nil { + return nil, fmt.Errorf("failed to create embedded IdP provider: %w", err) + } + + // If local auth is disabled, validate that other connectors exist + if config.LocalAuthDisabled { + hasOthers, err := provider.HasNonLocalConnectors(ctx) + if err != nil { + _ = provider.Stop(ctx) + return nil, fmt.Errorf("failed to check connectors: %w", err) + } + if !hasOthers { + _ = provider.Stop(ctx) + return nil, fmt.Errorf("cannot disable local authentication: no other identity providers configured") + } + // Ensure local connector is removed (it might exist from a previous run) + if err := provider.DisableLocalAuth(ctx); err != nil { + _ = provider.Stop(ctx) + return nil, fmt.Errorf("failed to disable local auth: %w", err) + } + log.WithContext(ctx).Info("local authentication disabled - only external identity providers can be used") + } + + log.WithContext(ctx).Infof("embedded Dex IDP initialized with issuer: %s", yamlConfig.Issuer) + + return &EmbeddedIdPManager{ + provider: provider, + appMetrics: appMetrics, + config: *config, + }, nil +} + +// Handler returns the HTTP handler for serving OIDC requests. +func (m *EmbeddedIdPManager) Handler() http.Handler { + return m.provider.Handler() +} + +// Stop gracefully shuts down the embedded IdP provider. +func (m *EmbeddedIdPManager) Stop(ctx context.Context) error { + return m.provider.Stop(ctx) +} + +// UpdateUserAppMetadata updates user app metadata based on userID and metadata map. +func (m *EmbeddedIdPManager) UpdateUserAppMetadata(ctx context.Context, userID string, appMetadata AppMetadata) error { + // TODO: implement + return nil +} + +// GetUserDataByID requests user data from the embedded IdP via user ID. +func (m *EmbeddedIdPManager) GetUserDataByID(ctx context.Context, userID string, appMetadata AppMetadata) (*UserData, error) { + user, err := m.provider.GetUserByID(ctx, userID) + if err != nil { + if m.appMetrics != nil { + m.appMetrics.IDPMetrics().CountRequestError() + } + return nil, fmt.Errorf("failed to get user by ID: %w", err) + } + + return &UserData{ + Email: user.Email, + Name: user.Username, + ID: user.UserID, + AppMetadata: appMetadata, + }, nil +} + +// GetAccount returns all the users for a given account. +// Note: Embedded dex doesn't store account metadata, so this returns all users. +func (m *EmbeddedIdPManager) GetAccount(ctx context.Context, accountID string) ([]*UserData, error) { + users, err := m.provider.ListUsers(ctx) + if err != nil { + if m.appMetrics != nil { + m.appMetrics.IDPMetrics().CountRequestError() + } + return nil, fmt.Errorf("failed to list users: %w", err) + } + + result := make([]*UserData, 0, len(users)) + for _, user := range users { + result = append(result, &UserData{ + Email: user.Email, + Name: user.Username, + ID: user.UserID, + AppMetadata: AppMetadata{ + WTAccountID: accountID, + }, + }) + } + + return result, nil +} + +// GetAllAccounts gets all registered accounts with corresponding user data. +// Note: Embedded dex doesn't store account metadata, so all users are indexed under UnsetAccountID. +func (m *EmbeddedIdPManager) GetAllAccounts(ctx context.Context) (map[string][]*UserData, error) { + if m.appMetrics != nil { + m.appMetrics.IDPMetrics().CountGetAllAccounts() + } + + users, err := m.provider.ListUsers(ctx) + if err != nil { + if m.appMetrics != nil { + m.appMetrics.IDPMetrics().CountRequestError() + } + return nil, fmt.Errorf("failed to list users: %w", err) + } + + log.WithContext(ctx).Debugf("retrieved %d users from embedded IdP", len(users)) + + indexedUsers := make(map[string][]*UserData) + for _, user := range users { + indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], &UserData{ + Email: user.Email, + Name: user.Username, + ID: user.UserID, + }) + } + + log.WithContext(ctx).Debugf("retrieved %d users from embedded IdP", len(indexedUsers[UnsetAccountID])) + + return indexedUsers, nil +} + +// CreateUser creates a new user in the embedded IdP. +func (m *EmbeddedIdPManager) CreateUser(ctx context.Context, email, name, accountID, invitedByEmail string) (*UserData, error) { + if m.config.LocalAuthDisabled { + return nil, fmt.Errorf("local user creation is disabled") + } + + if m.appMetrics != nil { + m.appMetrics.IDPMetrics().CountCreateUser() + } + + // Check if user already exists + _, err := m.provider.GetUser(ctx, email) + if err == nil { + return nil, fmt.Errorf("user with email %s already exists", email) + } + if !errors.Is(err, storage.ErrNotFound) { + if m.appMetrics != nil { + m.appMetrics.IDPMetrics().CountRequestError() + } + return nil, fmt.Errorf("failed to check existing user: %w", err) + } + + // Generate a random password for the new user + password := GeneratePassword(16, 2, 2, 2) + + // Create the user via provider (handles hashing and ID generation) + // The provider returns an encoded user ID in Dex's format (base64 protobuf with connector ID) + userID, err := m.provider.CreateUser(ctx, email, name, password) + if err != nil { + if m.appMetrics != nil { + m.appMetrics.IDPMetrics().CountRequestError() + } + return nil, fmt.Errorf("failed to create user in embedded IdP: %w", err) + } + + log.WithContext(ctx).Debugf("created user %s in embedded IdP", email) + + return &UserData{ + Email: email, + Name: name, + ID: userID, + Password: password, + AppMetadata: AppMetadata{ + WTAccountID: accountID, + WTInvitedBy: invitedByEmail, + }, + }, nil +} + +// GetUserByEmail searches users with a given email. +func (m *EmbeddedIdPManager) GetUserByEmail(ctx context.Context, email string) ([]*UserData, error) { + user, err := m.provider.GetUser(ctx, email) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return nil, nil // Return empty slice for not found + } + if m.appMetrics != nil { + m.appMetrics.IDPMetrics().CountRequestError() + } + return nil, fmt.Errorf("failed to get user by email: %w", err) + } + + return []*UserData{ + { + Email: user.Email, + Name: user.Username, + ID: user.UserID, + }, + }, nil +} + +// CreateUserWithPassword creates a new user in the embedded IdP with a provided password. +// Unlike CreateUser which auto-generates a password, this method uses the provided password. +// This is useful for instance setup where the user provides their own password. +func (m *EmbeddedIdPManager) CreateUserWithPassword(ctx context.Context, email, password, name string) (*UserData, error) { + if m.config.LocalAuthDisabled { + return nil, fmt.Errorf("local user creation is disabled") + } + + if m.appMetrics != nil { + m.appMetrics.IDPMetrics().CountCreateUser() + } + + // Check if user already exists + _, err := m.provider.GetUser(ctx, email) + if err == nil { + return nil, fmt.Errorf("user with email %s already exists", email) + } + if !errors.Is(err, storage.ErrNotFound) { + if m.appMetrics != nil { + m.appMetrics.IDPMetrics().CountRequestError() + } + return nil, fmt.Errorf("failed to check existing user: %w", err) + } + + // Create the user via provider with the provided password + userID, err := m.provider.CreateUser(ctx, email, name, password) + if err != nil { + if m.appMetrics != nil { + m.appMetrics.IDPMetrics().CountRequestError() + } + return nil, fmt.Errorf("failed to create user in embedded IdP: %w", err) + } + + log.WithContext(ctx).Debugf("created user %s in embedded IdP with provided password", email) + + return &UserData{ + Email: email, + Name: name, + ID: userID, + }, nil +} + +// InviteUserByID resends an invitation to a user. +func (m *EmbeddedIdPManager) InviteUserByID(ctx context.Context, userID string) error { + return fmt.Errorf("not implemented") +} + +// DeleteUser deletes a user from the embedded IdP by user ID. +func (m *EmbeddedIdPManager) DeleteUser(ctx context.Context, userID string) error { + if m.appMetrics != nil { + m.appMetrics.IDPMetrics().CountDeleteUser() + } + + // Get user by ID to retrieve email (provider.DeleteUser requires email) + user, err := m.provider.GetUserByID(ctx, userID) + if err != nil { + if m.appMetrics != nil { + m.appMetrics.IDPMetrics().CountRequestError() + } + return fmt.Errorf("failed to get user for deletion: %w", err) + } + + err = m.provider.DeleteUser(ctx, user.Email) + if err != nil { + if m.appMetrics != nil { + m.appMetrics.IDPMetrics().CountRequestError() + } + return fmt.Errorf("failed to delete user from embedded IdP: %w", err) + } + + log.WithContext(ctx).Debugf("deleted user %s from embedded IdP", user.Email) + + return nil +} + +// UpdateUserPassword updates the password for a user in the embedded IdP. +// It verifies that the current user is changing their own password and +// validates the current password before updating to the new password. +func (m *EmbeddedIdPManager) UpdateUserPassword(ctx context.Context, currentUserID, targetUserID string, oldPassword, newPassword string) error { + // Verify the user is changing their own password + if currentUserID != targetUserID { + return fmt.Errorf("users can only change their own password") + } + + // Verify the new password is different from the old password + if oldPassword == newPassword { + return fmt.Errorf("new password must be different from current password") + } + + err := m.provider.UpdateUserPassword(ctx, targetUserID, oldPassword, newPassword) + if err != nil { + if m.appMetrics != nil { + m.appMetrics.IDPMetrics().CountRequestError() + } + return err + } + + log.WithContext(ctx).Debugf("updated password for user %s in embedded IdP", targetUserID) + + return nil +} + +// CreateConnector creates a new identity provider connector in Dex. +// Returns the created connector config with the redirect URL populated. +func (m *EmbeddedIdPManager) CreateConnector(ctx context.Context, cfg *dex.ConnectorConfig) (*dex.ConnectorConfig, error) { + return m.provider.CreateConnector(ctx, cfg) +} + +// GetConnector retrieves an identity provider connector by ID. +func (m *EmbeddedIdPManager) GetConnector(ctx context.Context, id string) (*dex.ConnectorConfig, error) { + return m.provider.GetConnector(ctx, id) +} + +// ListConnectors returns all identity provider connectors. +func (m *EmbeddedIdPManager) ListConnectors(ctx context.Context) ([]*dex.ConnectorConfig, error) { + return m.provider.ListConnectors(ctx) +} + +// UpdateConnector updates an existing identity provider connector. +// Field preservation for partial updates is handled by Provider.UpdateConnector. +func (m *EmbeddedIdPManager) UpdateConnector(ctx context.Context, cfg *dex.ConnectorConfig) error { + return m.provider.UpdateConnector(ctx, cfg) +} + +// DeleteConnector removes an identity provider connector. +func (m *EmbeddedIdPManager) DeleteConnector(ctx context.Context, id string) error { + return m.provider.DeleteConnector(ctx, id) +} + +// GetIssuer returns the OIDC issuer URL. +func (m *EmbeddedIdPManager) GetIssuer() string { + return m.provider.GetIssuer() +} + +// GetTokenEndpoint returns the OAuth2 token endpoint URL. +func (m *EmbeddedIdPManager) GetTokenEndpoint() string { + return m.provider.GetTokenEndpoint() +} + +// GetDeviceAuthEndpoint returns the OAuth2 device authorization endpoint URL. +func (m *EmbeddedIdPManager) GetDeviceAuthEndpoint() string { + return m.provider.GetDeviceAuthEndpoint() +} + +// GetAuthorizationEndpoint returns the OAuth2 authorization endpoint URL. +func (m *EmbeddedIdPManager) GetAuthorizationEndpoint() string { + return m.provider.GetAuthorizationEndpoint() +} + +// GetDefaultScopes returns the default OAuth2 scopes for authentication. +func (m *EmbeddedIdPManager) GetDefaultScopes() string { + return defaultScopes +} + +// GetCLIClientID returns the client ID for CLI authentication. +func (m *EmbeddedIdPManager) GetCLIClientID() string { + return StaticClientCLI +} + +// GetCLIRedirectURLs returns the redirect URLs configured for the CLI client. +func (m *EmbeddedIdPManager) GetCLIRedirectURLs() []string { + if len(m.config.CLIRedirectURIs) == 0 { + return []string{defaultCLIRedirectURL1, defaultCLIRedirectURL2} + } + return m.config.CLIRedirectURIs +} + +// GetKeyFetcher returns a KeyFetcher that reads keys directly from Dex storage. +func (m *EmbeddedIdPManager) GetKeyFetcher() nbjwt.KeyFetcher { + return m.provider.GetJWKS +} + +// GetKeysLocation returns the JWKS endpoint URL for token validation. +func (m *EmbeddedIdPManager) GetKeysLocation() string { + return m.provider.GetKeysLocation() +} + +// GetLocalKeysLocation returns the localhost JWKS endpoint URL for internal token validation. +// Uses the LocalAddress from config (management server's listen address) since embedded Dex +// is served by the management HTTP server, not a standalone Dex server. +func (m *EmbeddedIdPManager) GetLocalKeysLocation() string { + addr := m.config.LocalAddress + if addr == "" { + return "" + } + // Construct localhost URL from listen address + // addr is in format ":port" or "host:port" or "localhost:port" + if strings.HasPrefix(addr, ":") { + return fmt.Sprintf("http://localhost%s/oauth2/keys", addr) + } + return fmt.Sprintf("http://%s/oauth2/keys", addr) +} + +// GetClientIDs returns the OAuth2 client IDs configured for this provider. +func (m *EmbeddedIdPManager) GetClientIDs() []string { + return []string{StaticClientDashboard, StaticClientCLI} +} + +// GetUserIDClaim returns the JWT claim name used for user identification. +func (m *EmbeddedIdPManager) GetUserIDClaim() string { + return defaultUserIDClaim +} + +// IsLocalAuthDisabled returns whether local authentication is disabled based on configuration. +func (m *EmbeddedIdPManager) IsLocalAuthDisabled() bool { + return m.config.LocalAuthDisabled +} + +// SetMFAEnabled enables or disables TOTP MFA for local users by updating the MFAChain on OAuth2 clients. +func (m *EmbeddedIdPManager) SetMFAEnabled(ctx context.Context, enabled bool) error { + var mfaChain []string + if enabled { + mfaChain = []string{DefaultTOTPAuthenticatorID} + } + if err := m.provider.SetClientsMFAChain(ctx, []string{ + StaticClientCLI, + StaticClientDashboard, + }, mfaChain); err != nil { + return fmt.Errorf("failed to set MFA enabled=%v: %w", enabled, err) + } + m.mfaEnabled = enabled + return nil +} + +// IsMFAEnabled returns whether TOTP MFA is currently enabled for local users. +func (m *EmbeddedIdPManager) IsMFAEnabled() bool { + return m.mfaEnabled +} + +// HasNonLocalConnectors checks if there are any identity provider connectors other than local. +func (m *EmbeddedIdPManager) HasNonLocalConnectors(ctx context.Context) (bool, error) { + return m.provider.HasNonLocalConnectors(ctx) +} diff --git a/management/server/idp/embedded_test.go b/management/server/idp/embedded_test.go new file mode 100644 index 000000000..cf3fcf7ff --- /dev/null +++ b/management/server/idp/embedded_test.go @@ -0,0 +1,698 @@ +package idp + +import ( + "context" + "encoding/base64" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/idp/dex" +) + +func TestEmbeddedIdPManager_CreateUser_EndToEnd(t *testing.T) { + ctx := context.Background() + + // Create a temporary directory for the test + tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create the embedded IDP config + config := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: filepath.Join(tmpDir, "dex.db"), + }, + }, + } + + // Create the embedded IDP manager + manager, err := NewEmbeddedIdPManager(ctx, config, nil) + require.NoError(t, err) + defer func() { _ = manager.Stop(ctx) }() + + // Test data + email := "newuser@example.com" + name := "New User" + accountID := "test-account-id" + invitedByEmail := "admin@example.com" + + // Create the user + userData, err := manager.CreateUser(ctx, email, name, accountID, invitedByEmail) + require.NoError(t, err) + require.NotNil(t, userData) + + t.Logf("Created user: ID=%s, Email=%s, Name=%s, Password=%s", + userData.ID, userData.Email, userData.Name, userData.Password) + + // Verify user data + assert.Equal(t, email, userData.Email) + assert.Equal(t, name, userData.Name) + assert.NotEmpty(t, userData.ID) + assert.NotEmpty(t, userData.Password) + assert.Equal(t, accountID, userData.AppMetadata.WTAccountID) + assert.Equal(t, invitedByEmail, userData.AppMetadata.WTInvitedBy) + + // Verify the user ID is in Dex's encoded format (base64 protobuf) + rawUserID, connectorID, err := dex.DecodeDexUserID(userData.ID) + require.NoError(t, err) + assert.NotEmpty(t, rawUserID) + assert.Equal(t, "local", connectorID) + + t.Logf("Decoded user ID: rawUserID=%s, connectorID=%s", rawUserID, connectorID) + + // Verify we can look up the user by the encoded ID + lookedUpUser, err := manager.GetUserDataByID(ctx, userData.ID, AppMetadata{WTAccountID: accountID}) + require.NoError(t, err) + assert.Equal(t, email, lookedUpUser.Email) + + // Verify we can look up by email + users, err := manager.GetUserByEmail(ctx, email) + require.NoError(t, err) + require.Len(t, users, 1) + assert.Equal(t, email, users[0].Email) + + // Verify creating duplicate user fails + _, err = manager.CreateUser(ctx, email, name, accountID, invitedByEmail) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already exists") +} + +func TestEmbeddedIdPManager_GetUserDataByID_WithEncodedID(t *testing.T) { + ctx := context.Background() + + tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + config := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: filepath.Join(tmpDir, "dex.db"), + }, + }, + } + + manager, err := NewEmbeddedIdPManager(ctx, config, nil) + require.NoError(t, err) + defer func() { _ = manager.Stop(ctx) }() + + // Create a user first + userData, err := manager.CreateUser(ctx, "test@example.com", "Test User", "account1", "admin@example.com") + require.NoError(t, err) + + // The returned ID should be encoded + encodedID := userData.ID + + // Lookup should work with the encoded ID + lookedUp, err := manager.GetUserDataByID(ctx, encodedID, AppMetadata{WTAccountID: "account1"}) + require.NoError(t, err) + assert.Equal(t, "test@example.com", lookedUp.Email) + assert.Equal(t, "Test User", lookedUp.Name) +} + +func TestEmbeddedIdPManager_DeleteUser(t *testing.T) { + ctx := context.Background() + + tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + config := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: filepath.Join(tmpDir, "dex.db"), + }, + }, + } + + manager, err := NewEmbeddedIdPManager(ctx, config, nil) + require.NoError(t, err) + defer func() { _ = manager.Stop(ctx) }() + + // Create a user + userData, err := manager.CreateUser(ctx, "delete-me@example.com", "Delete Me", "account1", "admin@example.com") + require.NoError(t, err) + + // Delete the user using the encoded ID + err = manager.DeleteUser(ctx, userData.ID) + require.NoError(t, err) + + // Verify user no longer exists + _, err = manager.GetUserDataByID(ctx, userData.ID, AppMetadata{}) + assert.Error(t, err) +} + +func TestEmbeddedIdPManager_GetAccount(t *testing.T) { + ctx := context.Background() + + tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + config := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: filepath.Join(tmpDir, "dex.db"), + }, + }, + } + + manager, err := NewEmbeddedIdPManager(ctx, config, nil) + require.NoError(t, err) + defer func() { _ = manager.Stop(ctx) }() + + // Create multiple users + _, err = manager.CreateUser(ctx, "user1@example.com", "User 1", "account1", "admin@example.com") + require.NoError(t, err) + + _, err = manager.CreateUser(ctx, "user2@example.com", "User 2", "account1", "admin@example.com") + require.NoError(t, err) + + // Get all users for the account + users, err := manager.GetAccount(ctx, "account1") + require.NoError(t, err) + assert.Len(t, users, 2) + + emails := make([]string, len(users)) + for i, u := range users { + emails[i] = u.Email + } + assert.Contains(t, emails, "user1@example.com") + assert.Contains(t, emails, "user2@example.com") +} + +func TestEmbeddedIdPManager_UserIDFormat_MatchesJWT(t *testing.T) { + // This test verifies that the user ID returned by CreateUser + // matches the format that Dex uses in JWT tokens (the 'sub' claim) + ctx := context.Background() + + tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + config := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: filepath.Join(tmpDir, "dex.db"), + }, + }, + } + + manager, err := NewEmbeddedIdPManager(ctx, config, nil) + require.NoError(t, err) + defer func() { _ = manager.Stop(ctx) }() + + // Create a user + userData, err := manager.CreateUser(ctx, "jwt-test@example.com", "JWT Test", "account1", "admin@example.com") + require.NoError(t, err) + + // The ID should be in the format: base64(protobuf{user_id, connector_id}) + // Example: CiQ3YWFkOGMwNS0zMjg3LTQ3M2YtYjQyYS0zNjU1MDRiZjI1ZTcSBWxvY2Fs + + // Verify it can be decoded + rawUserID, connectorID, err := dex.DecodeDexUserID(userData.ID) + require.NoError(t, err) + + // Raw user ID should be a UUID + assert.Regexp(t, `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, rawUserID) + + // Connector ID should be "local" for password-based auth + assert.Equal(t, "local", connectorID) + + // Re-encoding should produce the same result + reEncoded := dex.EncodeDexUserID(rawUserID, connectorID) + assert.Equal(t, userData.ID, reEncoded) + + t.Logf("User ID format verified:") + t.Logf(" Encoded ID: %s", userData.ID) + t.Logf(" Raw UUID: %s", rawUserID) + t.Logf(" Connector: %s", connectorID) +} + +func TestEmbeddedIdPManager_UpdateUserPassword(t *testing.T) { + ctx := context.Background() + + tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + config := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: filepath.Join(tmpDir, "dex.db"), + }, + }, + } + + manager, err := NewEmbeddedIdPManager(ctx, config, nil) + require.NoError(t, err) + defer func() { _ = manager.Stop(ctx) }() + + // Create a user with a known password + email := "password-test@example.com" + name := "Password Test User" + initialPassword := "InitialPass123!" + + userData, err := manager.CreateUserWithPassword(ctx, email, initialPassword, name) + require.NoError(t, err) + require.NotNil(t, userData) + + userID := userData.ID + + t.Run("successful password change", func(t *testing.T) { + newPassword := "NewSecurePass456!" + err := manager.UpdateUserPassword(ctx, userID, userID, initialPassword, newPassword) + require.NoError(t, err) + + // Verify the new password works by changing it again + anotherPassword := "AnotherPass789!" + err = manager.UpdateUserPassword(ctx, userID, userID, newPassword, anotherPassword) + require.NoError(t, err) + }) + + t.Run("wrong old password", func(t *testing.T) { + err := manager.UpdateUserPassword(ctx, userID, userID, "wrongpassword", "NewPass123!") + require.Error(t, err) + assert.Contains(t, err.Error(), "current password is incorrect") + }) + + t.Run("cannot change other user password", func(t *testing.T) { + otherUserID := "other-user-id" + err := manager.UpdateUserPassword(ctx, userID, otherUserID, "oldpass", "newpass") + require.Error(t, err) + assert.Contains(t, err.Error(), "users can only change their own password") + }) + + t.Run("same password rejected", func(t *testing.T) { + samePassword := "SamePass123!" + err := manager.UpdateUserPassword(ctx, userID, userID, samePassword, samePassword) + require.Error(t, err) + assert.Contains(t, err.Error(), "new password must be different") + }) +} + +func TestEmbeddedIdPConfig_ToYAMLConfig_IncludesDeviceCallbackRedirectURI(t *testing.T) { + config := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "https://example.com/oauth2", + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: filepath.Join(t.TempDir(), "dex.db"), + }, + }, + } + + yamlConfig, err := config.ToYAMLConfig() + require.NoError(t, err) + + var cliRedirectURIs []string + for _, client := range yamlConfig.StaticClients { + if client.ID == StaticClientCLI { + cliRedirectURIs = client.RedirectURIs + break + } + } + require.NotEmpty(t, cliRedirectURIs) + assert.Contains(t, cliRedirectURIs, "/device/callback") + assert.Contains(t, cliRedirectURIs, "/oauth2/device/callback") + assert.Contains(t, cliRedirectURIs, "https://example.com/oauth2/device/callback") +} + +func TestEmbeddedIdPConfig_ToYAMLConfig_SessionCookieEncryptionKey(t *testing.T) { + t.Setenv(sessionCookieEncryptionKeyEnv, "") + + rawKey := "0123456789abcdef0123456789abcdef" + config := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + SessionCookieEncryptionKey: base64.StdEncoding.EncodeToString([]byte(rawKey)), + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: filepath.Join(t.TempDir(), "dex.db"), + }, + }, + } + + yamlConfig, err := config.ToYAMLConfig() + require.NoError(t, err) + require.NotNil(t, yamlConfig.Sessions) + assert.Equal(t, rawKey, yamlConfig.Sessions.CookieEncryptionKey) +} + +func TestResolveSessionCookieEncryptionKey(t *testing.T) { + rawKey := "0123456789abcdef0123456789abcdef" + + t.Run("uses raw configured key", func(t *testing.T) { + t.Setenv(sessionCookieEncryptionKeyEnv, "") + + key, err := resolveSessionCookieEncryptionKey(rawKey) + require.NoError(t, err) + assert.Equal(t, rawKey, key) + }) + + t.Run("uses base64 configured key", func(t *testing.T) { + t.Setenv(sessionCookieEncryptionKeyEnv, "") + + key, err := resolveSessionCookieEncryptionKey(base64.StdEncoding.EncodeToString([]byte(rawKey))) + require.NoError(t, err) + assert.Equal(t, rawKey, key) + }) + + t.Run("falls back to env var", func(t *testing.T) { + t.Setenv(sessionCookieEncryptionKeyEnv, rawKey) + + key, err := resolveSessionCookieEncryptionKey("") + require.NoError(t, err) + assert.Equal(t, rawKey, key) + }) + + t.Run("empty key disables encryption", func(t *testing.T) { + t.Setenv(sessionCookieEncryptionKeyEnv, "") + + key, err := resolveSessionCookieEncryptionKey("") + require.NoError(t, err) + assert.Empty(t, key) + }) + + t.Run("rejects invalid key length", func(t *testing.T) { + t.Setenv(sessionCookieEncryptionKeyEnv, "") + + _, err := resolveSessionCookieEncryptionKey("32") + require.Error(t, err) + assert.Contains(t, err.Error(), sessionCookieEncryptionKeyEnv) + }) +} + +func TestEmbeddedIdPManager_GetLocalKeysLocation(t *testing.T) { + ctx := context.Background() + + tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + tests := []struct { + name string + localAddress string + expected string + }{ + { + name: "localhost with port", + localAddress: "localhost:8080", + expected: "http://localhost:8080/oauth2/keys", + }, + { + name: "localhost with https port", + localAddress: "localhost:443", + expected: "http://localhost:443/oauth2/keys", + }, + { + name: "port only format", + localAddress: ":8080", + expected: "http://localhost:8080/oauth2/keys", + }, + { + name: "empty address", + localAddress: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + LocalAddress: tt.localAddress, + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: filepath.Join(tmpDir, "dex-"+tt.name+".db"), + }, + }, + } + + manager, err := NewEmbeddedIdPManager(ctx, config, nil) + require.NoError(t, err) + defer func() { _ = manager.Stop(ctx) }() + + result := manager.GetLocalKeysLocation() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestEmbeddedIdPManager_LocalAuthDisabled(t *testing.T) { + ctx := context.Background() + + t.Run("cannot start with local auth disabled without other connectors", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + config := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + LocalAuthDisabled: true, + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: filepath.Join(tmpDir, "dex.db"), + }, + }, + } + + _, err = NewEmbeddedIdPManager(ctx, config, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no other identity providers configured") + }) + + t.Run("local auth enabled by default", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + config := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: filepath.Join(tmpDir, "dex.db"), + }, + }, + } + + manager, err := NewEmbeddedIdPManager(ctx, config, nil) + require.NoError(t, err) + defer func() { _ = manager.Stop(ctx) }() + + // Verify local auth is enabled by default + assert.False(t, manager.IsLocalAuthDisabled()) + }) + + t.Run("start with local auth disabled when connector exists", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + dbFile := filepath.Join(tmpDir, "dex.db") + + // First, create a manager with local auth enabled and add a connector + config1 := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: dbFile, + }, + }, + } + + manager1, err := NewEmbeddedIdPManager(ctx, config1, nil) + require.NoError(t, err) + + // Create a user + userData, err := manager1.CreateUser(ctx, "preserved@example.com", "Preserved User", "account1", "admin@example.com") + require.NoError(t, err) + userID := userData.ID + + // Add an external connector (Google doesn't require OIDC discovery) + _, err = manager1.CreateConnector(ctx, &dex.ConnectorConfig{ + ID: "google-test", + Name: "Google Test", + Type: "google", + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + }) + require.NoError(t, err) + + // Stop the first manager + err = manager1.Stop(ctx) + require.NoError(t, err) + + // Now create a new manager with local auth disabled + config2 := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + LocalAuthDisabled: true, + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: dbFile, + }, + }, + } + + manager2, err := NewEmbeddedIdPManager(ctx, config2, nil) + require.NoError(t, err) + defer func() { _ = manager2.Stop(ctx) }() + + // Verify local auth is disabled via config + assert.True(t, manager2.IsLocalAuthDisabled()) + + // Verify the user still exists in storage (just can't login via local) + lookedUp, err := manager2.GetUserDataByID(ctx, userID, AppMetadata{}) + require.NoError(t, err) + assert.Equal(t, "preserved@example.com", lookedUp.Email) + }) + + t.Run("CreateUser fails when local auth is disabled", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + dbFile := filepath.Join(tmpDir, "dex.db") + + // First, create a manager and add an external connector + config1 := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: dbFile, + }, + }, + } + + manager1, err := NewEmbeddedIdPManager(ctx, config1, nil) + require.NoError(t, err) + + _, err = manager1.CreateConnector(ctx, &dex.ConnectorConfig{ + ID: "google-test", + Name: "Google Test", + Type: "google", + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + }) + require.NoError(t, err) + + err = manager1.Stop(ctx) + require.NoError(t, err) + + // Create manager with local auth disabled + config2 := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + LocalAuthDisabled: true, + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: dbFile, + }, + }, + } + + manager2, err := NewEmbeddedIdPManager(ctx, config2, nil) + require.NoError(t, err) + defer func() { _ = manager2.Stop(ctx) }() + + // Try to create a user - should fail + _, err = manager2.CreateUser(ctx, "newuser@example.com", "New User", "account1", "admin@example.com") + require.Error(t, err) + assert.Contains(t, err.Error(), "local user creation is disabled") + }) + + t.Run("CreateUserWithPassword fails when local auth is disabled", func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + dbFile := filepath.Join(tmpDir, "dex.db") + + // First, create a manager and add an external connector + config1 := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: dbFile, + }, + }, + } + + manager1, err := NewEmbeddedIdPManager(ctx, config1, nil) + require.NoError(t, err) + + _, err = manager1.CreateConnector(ctx, &dex.ConnectorConfig{ + ID: "google-test", + Name: "Google Test", + Type: "google", + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + }) + require.NoError(t, err) + + err = manager1.Stop(ctx) + require.NoError(t, err) + + // Create manager with local auth disabled + config2 := &EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + LocalAuthDisabled: true, + Storage: EmbeddedStorageConfig{ + Type: "sqlite3", + Config: EmbeddedStorageTypeConfig{ + File: dbFile, + }, + }, + } + + manager2, err := NewEmbeddedIdPManager(ctx, config2, nil) + require.NoError(t, err) + defer func() { _ = manager2.Stop(ctx) }() + + // Try to create a user with password - should fail + _, err = manager2.CreateUserWithPassword(ctx, "newuser@example.com", "SecurePass123!", "New User") + require.Error(t, err) + assert.Contains(t, err.Error(), "local user creation is disabled") + }) +} diff --git a/management/server/idp/google_workspace.go b/management/server/idp/google_workspace.go index 09ea8c430..dadbfd83e 100644 --- a/management/server/idp/google_workspace.go +++ b/management/server/idp/google_workspace.go @@ -5,7 +5,6 @@ import ( "encoding/base64" "fmt" "net/http" - "time" log "github.com/sirupsen/logrus" "golang.org/x/oauth2/google" @@ -49,9 +48,10 @@ func NewGoogleWorkspaceManager(ctx context.Context, config GoogleWorkspaceClient httpTransport.MaxIdleConns = 5 httpClient := &http.Client{ - Timeout: 10 * time.Second, + Timeout: idpTimeout(), Transport: httpTransport, } + helper := JsonParser{} if config.CustomerID == "" { @@ -66,14 +66,14 @@ func NewGoogleWorkspaceManager(ctx context.Context, config GoogleWorkspaceClient } // Create a new Admin SDK Directory service client - adminCredentials, err := getGoogleCredentials(ctx, config.ServiceAccountKey) + credentialsOption, err := getGoogleCredentialsOption(ctx, config.ServiceAccountKey) if err != nil { return nil, err } service, err := admin.NewService(context.Background(), option.WithScopes(admin.AdminDirectoryUserReadonlyScope), - option.WithCredentials(adminCredentials), + credentialsOption, ) if err != nil { return nil, err @@ -218,39 +218,32 @@ func (gm *GoogleWorkspaceManager) DeleteUser(_ context.Context, userID string) e return nil } -// getGoogleCredentials retrieves Google credentials based on the provided serviceAccountKey. -// It decodes the base64-encoded serviceAccountKey and attempts to obtain credentials using it. -// If that fails, it falls back to using the default Google credentials path. -// It returns the retrieved credentials or an error if unsuccessful. -func getGoogleCredentials(ctx context.Context, serviceAccountKey string) (*google.Credentials, error) { +// getGoogleCredentialsOption returns the google.golang.org/api option carrying +// Google credentials derived from the provided serviceAccountKey. +// It decodes the base64-encoded serviceAccountKey and uses it as the credentials JSON. +// If the key is empty, it falls back to the default Google credentials path. +func getGoogleCredentialsOption(ctx context.Context, serviceAccountKey string) (option.ClientOption, error) { log.WithContext(ctx).Debug("retrieving google credentials from the base64 encoded service account key") decodeKey, err := base64.StdEncoding.DecodeString(serviceAccountKey) if err != nil { return nil, fmt.Errorf("failed to decode service account key: %w", err) } - creds, err := google.CredentialsFromJSON( - context.Background(), - decodeKey, - admin.AdminDirectoryUserReadonlyScope, - ) - if err == nil { - // No need to fallback to the default Google credentials path - return creds, nil + if len(decodeKey) > 0 { + return option.WithAuthCredentialsJSON(option.ServiceAccount, decodeKey), nil } - log.WithContext(ctx).Debugf("failed to retrieve Google credentials from ServiceAccountKey: %v", err) - log.WithContext(ctx).Debug("falling back to default google credentials location") + log.WithContext(ctx).Debug("no service account key provided, falling back to default google credentials location") - creds, err = google.FindDefaultCredentials( - context.Background(), + creds, err := google.FindDefaultCredentials( + ctx, admin.AdminDirectoryUserReadonlyScope, ) if err != nil { return nil, err } - return creds, nil + return option.WithCredentials(creds), nil } // parseGoogleWorkspaceUser parse google user to UserData. diff --git a/management/server/idp/idp.go b/management/server/idp/idp.go index f06e57196..20d6cacd5 100644 --- a/management/server/idp/idp.go +++ b/management/server/idp/idp.go @@ -72,6 +72,7 @@ type UserData struct { Name string `json:"name"` ID string `json:"user_id"` AppMetadata AppMetadata `json:"app_metadata"` + Password string `json:"-"` // Plain password, only set on user creation, excluded from JSON } func (u *UserData) MarshalBinary() (data []byte, err error) { @@ -173,40 +174,41 @@ func NewManager(ctx context.Context, config Config, appMetrics telemetry.AppMetr return NewZitadelManager(*zitadelClientConfig, appMetrics) case "authentik": - authentikConfig := AuthentikClientConfig{ + return NewAuthentikManager(AuthentikClientConfig{ Issuer: config.ClientConfig.Issuer, ClientID: config.ClientConfig.ClientID, TokenEndpoint: config.ClientConfig.TokenEndpoint, GrantType: config.ClientConfig.GrantType, Username: config.ExtraConfig["Username"], Password: config.ExtraConfig["Password"], - } - return NewAuthentikManager(authentikConfig, appMetrics) + }, appMetrics) case "okta": - oktaClientConfig := OktaClientConfig{ + return NewOktaManager(OktaClientConfig{ Issuer: config.ClientConfig.Issuer, TokenEndpoint: config.ClientConfig.TokenEndpoint, GrantType: config.ClientConfig.GrantType, APIToken: config.ExtraConfig["ApiToken"], - } - return NewOktaManager(oktaClientConfig, appMetrics) + }, appMetrics) case "google": - googleClientConfig := GoogleWorkspaceClientConfig{ + return NewGoogleWorkspaceManager(ctx, GoogleWorkspaceClientConfig{ ServiceAccountKey: config.ExtraConfig["ServiceAccountKey"], CustomerID: config.ExtraConfig["CustomerId"], - } - return NewGoogleWorkspaceManager(ctx, googleClientConfig, appMetrics) + }, appMetrics) case "jumpcloud": - jumpcloudConfig := JumpCloudClientConfig{ + return NewJumpCloudManager(JumpCloudClientConfig{ APIToken: config.ExtraConfig["ApiToken"], - } - return NewJumpCloudManager(jumpcloudConfig, appMetrics) + ApiUrl: config.ExtraConfig["ApiUrl"], + }, appMetrics) case "pocketid": - pocketidConfig := PocketIdClientConfig{ + return NewPocketIdManager(PocketIdClientConfig{ APIToken: config.ExtraConfig["ApiToken"], ManagementEndpoint: config.ExtraConfig["ManagementEndpoint"], - } - return NewPocketIdManager(pocketidConfig, appMetrics) + }, appMetrics) + case "dex": + return NewDexManager(DexClientConfig{ + GRPCAddr: config.ExtraConfig["GRPCAddr"], + Issuer: config.ClientConfig.Issuer, + }, appMetrics) default: return nil, fmt.Errorf("invalid manager type: %s", config.ManagerType) } diff --git a/management/server/idp/jumpcloud.go b/management/server/idp/jumpcloud.go index 6345e424a..f0dec3a9b 100644 --- a/management/server/idp/jumpcloud.go +++ b/management/server/idp/jumpcloud.go @@ -1,25 +1,40 @@ package idp import ( + "bytes" "context" + "encoding/json" "fmt" + "io" "net/http" "strings" - "time" - - v1 "github.com/TheJumpCloud/jcapi-go/v1" "github.com/netbirdio/netbird/management/server/telemetry" ) const ( - contentType = "application/json" - accept = "application/json" + jumpCloudDefaultApiUrl = "https://console.jumpcloud.com" + jumpCloudSearchPageSize = 100 ) +// jumpCloudUser represents a JumpCloud V1 API system user. +type jumpCloudUser struct { + ID string `json:"_id"` + Email string `json:"email"` + Firstname string `json:"firstname"` + Middlename string `json:"middlename"` + Lastname string `json:"lastname"` +} + +// jumpCloudUserList represents the response from the JumpCloud search endpoint. +type jumpCloudUserList struct { + Results []jumpCloudUser `json:"results"` + TotalCount int `json:"totalCount"` +} + // JumpCloudManager JumpCloud manager client instance. type JumpCloudManager struct { - client *v1.APIClient + apiBase string apiToken string httpClient ManagerHTTPClient credentials ManagerCredentials @@ -30,6 +45,7 @@ type JumpCloudManager struct { // JumpCloudClientConfig JumpCloud manager client configurations. type JumpCloudClientConfig struct { APIToken string + ApiUrl string } // JumpCloudCredentials JumpCloud authentication information. @@ -46,16 +62,25 @@ func NewJumpCloudManager(config JumpCloudClientConfig, appMetrics telemetry.AppM httpTransport.MaxIdleConns = 5 httpClient := &http.Client{ - Timeout: 10 * time.Second, + Timeout: idpTimeout(), Transport: httpTransport, } + helper := JsonParser{} if config.APIToken == "" { return nil, fmt.Errorf("jumpCloud IdP configuration is incomplete, ApiToken is missing") } - client := v1.NewAPIClient(v1.NewConfiguration()) + apiBase := config.ApiUrl + if apiBase == "" { + apiBase = jumpCloudDefaultApiUrl + } + apiBase = strings.TrimSuffix(apiBase, "/") + if !strings.HasSuffix(apiBase, "/api") { + apiBase += "/api" + } + credentials := &JumpCloudCredentials{ clientConfig: config, httpClient: httpClient, @@ -64,7 +89,7 @@ func NewJumpCloudManager(config JumpCloudClientConfig, appMetrics telemetry.AppM } return &JumpCloudManager{ - client: client, + apiBase: apiBase, apiToken: config.APIToken, httpClient: httpClient, credentials: credentials, @@ -78,37 +103,58 @@ func (jc *JumpCloudCredentials) Authenticate(_ context.Context) (JWTToken, error return JWTToken{}, nil } -func (jm *JumpCloudManager) authenticationContext() context.Context { - return context.WithValue(context.Background(), v1.ContextAPIKey, v1.APIKey{ - Key: jm.apiToken, - }) -} - -// UpdateUserAppMetadata updates user app metadata based on userID and metadata map. -func (jm *JumpCloudManager) UpdateUserAppMetadata(_ context.Context, _ string, _ AppMetadata) error { - return nil -} - -// GetUserDataByID requests user data from JumpCloud via ID. -func (jm *JumpCloudManager) GetUserDataByID(_ context.Context, userID string, appMetadata AppMetadata) (*UserData, error) { - authCtx := jm.authenticationContext() - user, resp, err := jm.client.SystemusersApi.SystemusersGet(authCtx, userID, contentType, accept, nil) +// doRequest executes an HTTP request against the JumpCloud V1 API. +func (jm *JumpCloudManager) doRequest(ctx context.Context, method, path string, body io.Reader) ([]byte, error) { + reqURL := jm.apiBase + path + req, err := http.NewRequestWithContext(ctx, method, reqURL, body) if err != nil { return nil, err } + + req.Header.Set("x-api-key", jm.apiToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := jm.httpClient.Do(req) + if err != nil { + if jm.appMetrics != nil { + jm.appMetrics.IDPMetrics().CountRequestError() + } + return nil, err + } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { if jm.appMetrics != nil { jm.appMetrics.IDPMetrics().CountRequestStatusError() } - return nil, fmt.Errorf("unable to get user %s, statusCode %d", userID, resp.StatusCode) + return nil, fmt.Errorf("JumpCloud API request %s %s failed with status %d", method, path, resp.StatusCode) + } + + return io.ReadAll(resp.Body) +} + +// UpdateUserAppMetadata updates user app metadata based on userID and metadata map. +func (jm *JumpCloudManager) UpdateUserAppMetadata(_ context.Context, _ string, _ AppMetadata) error { + return nil +} + +// GetUserDataByID requests user data from JumpCloud via ID. +func (jm *JumpCloudManager) GetUserDataByID(ctx context.Context, userID string, appMetadata AppMetadata) (*UserData, error) { + body, err := jm.doRequest(ctx, http.MethodGet, "/systemusers/"+userID, nil) + if err != nil { + return nil, err } if jm.appMetrics != nil { jm.appMetrics.IDPMetrics().CountGetUserDataByID() } + var user jumpCloudUser + if err = jm.helper.Unmarshal(body, &user); err != nil { + return nil, err + } + userData := parseJumpCloudUser(user) userData.AppMetadata = appMetadata @@ -116,30 +162,20 @@ func (jm *JumpCloudManager) GetUserDataByID(_ context.Context, userID string, ap } // GetAccount returns all the users for a given profile. -func (jm *JumpCloudManager) GetAccount(_ context.Context, accountID string) ([]*UserData, error) { - authCtx := jm.authenticationContext() - userList, resp, err := jm.client.SearchApi.SearchSystemusersPost(authCtx, contentType, accept, nil) +func (jm *JumpCloudManager) GetAccount(ctx context.Context, accountID string) ([]*UserData, error) { + allUsers, err := jm.searchAllUsers(ctx) if err != nil { return nil, err } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - if jm.appMetrics != nil { - jm.appMetrics.IDPMetrics().CountRequestStatusError() - } - return nil, fmt.Errorf("unable to get account %s users, statusCode %d", accountID, resp.StatusCode) - } if jm.appMetrics != nil { jm.appMetrics.IDPMetrics().CountGetAccount() } - users := make([]*UserData, 0) - for _, user := range userList.Results { + users := make([]*UserData, 0, len(allUsers)) + for _, user := range allUsers { userData := parseJumpCloudUser(user) userData.AppMetadata.WTAccountID = accountID - users = append(users, userData) } @@ -148,27 +184,18 @@ func (jm *JumpCloudManager) GetAccount(_ context.Context, accountID string) ([]* // GetAllAccounts gets all registered accounts with corresponding user data. // It returns a list of users indexed by accountID. -func (jm *JumpCloudManager) GetAllAccounts(_ context.Context) (map[string][]*UserData, error) { - authCtx := jm.authenticationContext() - userList, resp, err := jm.client.SearchApi.SearchSystemusersPost(authCtx, contentType, accept, nil) +func (jm *JumpCloudManager) GetAllAccounts(ctx context.Context) (map[string][]*UserData, error) { + allUsers, err := jm.searchAllUsers(ctx) if err != nil { return nil, err } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - if jm.appMetrics != nil { - jm.appMetrics.IDPMetrics().CountRequestStatusError() - } - return nil, fmt.Errorf("unable to get all accounts, statusCode %d", resp.StatusCode) - } if jm.appMetrics != nil { jm.appMetrics.IDPMetrics().CountGetAllAccounts() } indexedUsers := make(map[string][]*UserData) - for _, user := range userList.Results { + for _, user := range allUsers { userData := parseJumpCloudUser(user) indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], userData) } @@ -176,6 +203,41 @@ func (jm *JumpCloudManager) GetAllAccounts(_ context.Context) (map[string][]*Use return indexedUsers, nil } +// searchAllUsers paginates through all system users using limit/skip. +func (jm *JumpCloudManager) searchAllUsers(ctx context.Context) ([]jumpCloudUser, error) { + var allUsers []jumpCloudUser + + for skip := 0; ; skip += jumpCloudSearchPageSize { + searchReq := map[string]int{ + "limit": jumpCloudSearchPageSize, + "skip": skip, + } + + payload, err := json.Marshal(searchReq) + if err != nil { + return nil, err + } + + body, err := jm.doRequest(ctx, http.MethodPost, "/search/systemusers", bytes.NewReader(payload)) + if err != nil { + return nil, err + } + + var userList jumpCloudUserList + if err = jm.helper.Unmarshal(body, &userList); err != nil { + return nil, err + } + + allUsers = append(allUsers, userList.Results...) + + if skip+len(userList.Results) >= userList.TotalCount { + break + } + } + + return allUsers, nil +} + // CreateUser creates a new user in JumpCloud Idp and sends an invitation. func (jm *JumpCloudManager) CreateUser(_ context.Context, _, _, _, _ string) (*UserData, error) { return nil, fmt.Errorf("method CreateUser not implemented") @@ -183,7 +245,7 @@ func (jm *JumpCloudManager) CreateUser(_ context.Context, _, _, _, _ string) (*U // GetUserByEmail searches users with a given email. // If no users have been found, this function returns an empty list. -func (jm *JumpCloudManager) GetUserByEmail(_ context.Context, email string) ([]*UserData, error) { +func (jm *JumpCloudManager) GetUserByEmail(ctx context.Context, email string) ([]*UserData, error) { searchFilter := map[string]interface{}{ "searchFilter": map[string]interface{}{ "filter": []string{email}, @@ -191,25 +253,26 @@ func (jm *JumpCloudManager) GetUserByEmail(_ context.Context, email string) ([]* }, } - authCtx := jm.authenticationContext() - userList, resp, err := jm.client.SearchApi.SearchSystemusersPost(authCtx, contentType, accept, searchFilter) + payload, err := json.Marshal(searchFilter) if err != nil { return nil, err } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - if jm.appMetrics != nil { - jm.appMetrics.IDPMetrics().CountRequestStatusError() - } - return nil, fmt.Errorf("unable to get user %s, statusCode %d", email, resp.StatusCode) + body, err := jm.doRequest(ctx, http.MethodPost, "/search/systemusers", bytes.NewReader(payload)) + if err != nil { + return nil, err } if jm.appMetrics != nil { jm.appMetrics.IDPMetrics().CountGetUserByEmail() } - usersData := make([]*UserData, 0) + var userList jumpCloudUserList + if err = jm.helper.Unmarshal(body, &userList); err != nil { + return nil, err + } + + usersData := make([]*UserData, 0, len(userList.Results)) for _, user := range userList.Results { usersData = append(usersData, parseJumpCloudUser(user)) } @@ -224,20 +287,11 @@ func (jm *JumpCloudManager) InviteUserByID(_ context.Context, _ string) error { } // DeleteUser from jumpCloud directory -func (jm *JumpCloudManager) DeleteUser(_ context.Context, userID string) error { - authCtx := jm.authenticationContext() - _, resp, err := jm.client.SystemusersApi.SystemusersDelete(authCtx, userID, contentType, accept, nil) +func (jm *JumpCloudManager) DeleteUser(ctx context.Context, userID string) error { + _, err := jm.doRequest(ctx, http.MethodDelete, "/systemusers/"+userID, nil) if err != nil { return err } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - if jm.appMetrics != nil { - jm.appMetrics.IDPMetrics().CountRequestStatusError() - } - return fmt.Errorf("unable to delete user, statusCode %d", resp.StatusCode) - } if jm.appMetrics != nil { jm.appMetrics.IDPMetrics().CountDeleteUser() @@ -247,11 +301,11 @@ func (jm *JumpCloudManager) DeleteUser(_ context.Context, userID string) error { } // parseJumpCloudUser parse JumpCloud system user returned from API V1 to UserData. -func parseJumpCloudUser(user v1.Systemuserreturn) *UserData { +func parseJumpCloudUser(user jumpCloudUser) *UserData { names := []string{user.Firstname, user.Middlename, user.Lastname} return &UserData{ Email: user.Email, Name: strings.Join(names, " "), - ID: user.Id, + ID: user.ID, } } diff --git a/management/server/idp/jumpcloud_test.go b/management/server/idp/jumpcloud_test.go index 1bfdcefcc..dc7a9cb6c 100644 --- a/management/server/idp/jumpcloud_test.go +++ b/management/server/idp/jumpcloud_test.go @@ -1,8 +1,15 @@ package idp import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/management/server/telemetry" @@ -44,3 +51,212 @@ func TestNewJumpCloudManager(t *testing.T) { }) } } + +func TestJumpCloudGetUserDataByID(t *testing.T) { + userResponse := jumpCloudUser{ + ID: "user123", + Email: "test@example.com", + Firstname: "John", + Middlename: "", + Lastname: "Doe", + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/systemusers/user123", r.URL.Path) + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "test-api-key", r.Header.Get("x-api-key")) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(userResponse) + })) + defer server.Close() + + manager := newTestJumpCloudManager(t, server.URL) + + userData, err := manager.GetUserDataByID(context.Background(), "user123", AppMetadata{WTAccountID: "acc1"}) + require.NoError(t, err) + + assert.Equal(t, "user123", userData.ID) + assert.Equal(t, "test@example.com", userData.Email) + assert.Equal(t, "John Doe", userData.Name) + assert.Equal(t, "acc1", userData.AppMetadata.WTAccountID) +} + +func TestJumpCloudGetAccount(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/search/systemusers", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + + var reqBody map[string]any + assert.NoError(t, json.NewDecoder(r.Body).Decode(&reqBody)) + assert.Contains(t, reqBody, "limit") + assert.Contains(t, reqBody, "skip") + + resp := jumpCloudUserList{ + Results: []jumpCloudUser{ + {ID: "u1", Email: "a@test.com", Firstname: "Alice", Lastname: "Smith"}, + {ID: "u2", Email: "b@test.com", Firstname: "Bob", Lastname: "Jones"}, + }, + TotalCount: 2, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + manager := newTestJumpCloudManager(t, server.URL) + + users, err := manager.GetAccount(context.Background(), "testAccount") + require.NoError(t, err) + assert.Len(t, users, 2) + assert.Equal(t, "testAccount", users[0].AppMetadata.WTAccountID) + assert.Equal(t, "testAccount", users[1].AppMetadata.WTAccountID) +} + +func TestJumpCloudGetAllAccounts(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := jumpCloudUserList{ + Results: []jumpCloudUser{ + {ID: "u1", Email: "a@test.com", Firstname: "Alice"}, + {ID: "u2", Email: "b@test.com", Firstname: "Bob"}, + }, + TotalCount: 2, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + manager := newTestJumpCloudManager(t, server.URL) + + indexedUsers, err := manager.GetAllAccounts(context.Background()) + require.NoError(t, err) + assert.Len(t, indexedUsers[UnsetAccountID], 2) +} + +func TestJumpCloudGetAllAccountsPagination(t *testing.T) { + totalUsers := 250 + allUsers := make([]jumpCloudUser, totalUsers) + for i := range allUsers { + allUsers[i] = jumpCloudUser{ + ID: fmt.Sprintf("u%d", i), + Email: fmt.Sprintf("user%d@test.com", i), + Firstname: fmt.Sprintf("User%d", i), + } + } + + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var reqBody map[string]int + assert.NoError(t, json.NewDecoder(r.Body).Decode(&reqBody)) + + limit := reqBody["limit"] + skip := reqBody["skip"] + requestCount++ + + end := skip + limit + if end > totalUsers { + end = totalUsers + } + + resp := jumpCloudUserList{ + Results: allUsers[skip:end], + TotalCount: totalUsers, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + manager := newTestJumpCloudManager(t, server.URL) + + indexedUsers, err := manager.GetAllAccounts(context.Background()) + require.NoError(t, err) + assert.Len(t, indexedUsers[UnsetAccountID], totalUsers) + assert.Equal(t, 3, requestCount, "should require 3 pages for 250 users at page size 100") +} + +func TestJumpCloudGetUserByEmail(t *testing.T) { + searchResponse := jumpCloudUserList{ + Results: []jumpCloudUser{ + {ID: "u1", Email: "alice@test.com", Firstname: "Alice", Lastname: "Smith"}, + }, + TotalCount: 1, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/search/systemusers", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + assert.Contains(t, string(body), "alice@test.com") + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(searchResponse) + })) + defer server.Close() + + manager := newTestJumpCloudManager(t, server.URL) + + users, err := manager.GetUserByEmail(context.Background(), "alice@test.com") + require.NoError(t, err) + assert.Len(t, users, 1) + assert.Equal(t, "alice@test.com", users[0].Email) +} + +func TestJumpCloudDeleteUser(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/systemusers/user123", r.URL.Path) + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "test-api-key", r.Header.Get("x-api-key")) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"_id": "user123"}) + })) + defer server.Close() + + manager := newTestJumpCloudManager(t, server.URL) + + err := manager.DeleteUser(context.Background(), "user123") + require.NoError(t, err) +} + +func TestJumpCloudAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + manager := newTestJumpCloudManager(t, server.URL) + + _, err := manager.GetUserDataByID(context.Background(), "user123", AppMetadata{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "401") +} + +func TestParseJumpCloudUser(t *testing.T) { + user := jumpCloudUser{ + ID: "abc123", + Email: "test@example.com", + Firstname: "John", + Middlename: "M", + Lastname: "Doe", + } + + userData := parseJumpCloudUser(user) + assert.Equal(t, "abc123", userData.ID) + assert.Equal(t, "test@example.com", userData.Email) + assert.Equal(t, "John M Doe", userData.Name) +} + +func newTestJumpCloudManager(t *testing.T, apiBase string) *JumpCloudManager { + t.Helper() + return &JumpCloudManager{ + apiBase: apiBase, + apiToken: "test-api-key", + httpClient: http.DefaultClient, + helper: JsonParser{}, + appMetrics: nil, + } +} diff --git a/management/server/idp/keycloak.go b/management/server/idp/keycloak.go index c611317ab..1cf26394f 100644 --- a/management/server/idp/keycloak.go +++ b/management/server/idp/keycloak.go @@ -63,9 +63,10 @@ func NewKeycloakManager(config KeycloakClientConfig, appMetrics telemetry.AppMet httpTransport.MaxIdleConns = 5 httpClient := &http.Client{ - Timeout: 10 * time.Second, + Timeout: idpTimeout(), Transport: httpTransport, } + helper := JsonParser{} if config.ClientID == "" { diff --git a/management/server/idp/migration/migration.go b/management/server/idp/migration/migration.go new file mode 100644 index 000000000..01cadb86d --- /dev/null +++ b/management/server/idp/migration/migration.go @@ -0,0 +1,235 @@ +// Package migration provides utility functions for migrating from the external IdP solution in pre v0.62.0 +// to the new embedded IdP manager (Dex based), which is the default in v0.62.0 and later. +// It includes functions to seed connectors and migrate existing users to use these connectors. +package migration + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/idp/dex" + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/types" +) + +// Server is the dependency interface that migration functions use to access +// the main data store and the activity event store. +type Server interface { + Store() Store + EventStore() EventStore // may return nil +} + +const idpSeedInfoKey = "IDP_SEED_INFO" +const dryRunEnvKey = "NB_IDP_MIGRATION_DRY_RUN" + +func isDryRun() bool { + return os.Getenv(dryRunEnvKey) == "true" +} + +var ErrNoSeedInfo = errors.New("no seed info found in environment") + +// SeedConnectorFromEnv reads the IDP_SEED_INFO env var, base64-decodes it, +// and JSON-unmarshals it into a dex.Connector. Returns nil if not set. +func SeedConnectorFromEnv() (*dex.Connector, error) { + val, ok := os.LookupEnv(idpSeedInfoKey) + if !ok || val == "" { + return nil, ErrNoSeedInfo + } + + decoded, err := base64.StdEncoding.DecodeString(val) + if err != nil { + return nil, fmt.Errorf("base64 decode: %w", err) + } + + var conn dex.Connector + if err := json.Unmarshal(decoded, &conn); err != nil { + return nil, fmt.Errorf("json unmarshal: %w", err) + } + + return &conn, nil +} + +// MigrateUsersToStaticConnectors re-keys every user ID in the main store (and +// the activity store, if present) so that it encodes the given connector ID, +// skipping users that have already been migrated. Set NB_IDP_MIGRATION_DRY_RUN=true +// to log what would happen without writing any changes. +func MigrateUsersToStaticConnectors(s Server, conn *dex.Connector) error { + ctx := context.Background() + + if isDryRun() { + log.Info("[DRY RUN] migration dry-run mode enabled, no changes will be written") + } + + users, err := s.Store().ListUsers(ctx) + if err != nil { + return fmt.Errorf("failed to list users: %w", err) + } + + // Reconciliation pass: fix activity store for users already migrated in main DB + // but whose activity references may still use old IDs (from a previous partial failure). + if s.EventStore() != nil && !isDryRun() { + if err := reconcileActivityStore(ctx, s.EventStore(), users); err != nil { + return err + } + } + + var migratedCount, skippedCount int + + for _, user := range users { + _, _, decErr := dex.DecodeDexUserID(user.Id) + if decErr == nil { + skippedCount++ + continue + } + + newUserID := dex.EncodeDexUserID(user.Id, conn.ID) + + if isDryRun() { + log.Infof("[DRY RUN] would migrate user %s -> %s (account: %s)", user.Id, newUserID, user.AccountID) + migratedCount++ + continue + } + + if err := migrateUser(ctx, s, user.Id, user.AccountID, newUserID); err != nil { + return err + } + + migratedCount++ + } + + if isDryRun() { + log.Infof("[DRY RUN] migration summary: %d users would be migrated, %d already migrated", migratedCount, skippedCount) + } else { + log.Infof("migration complete: %d users migrated, %d already migrated", migratedCount, skippedCount) + } + + return nil +} + +// reconcileActivityStore updates activity store references for users already migrated +// in the main DB whose activity entries may still use old IDs from a previous partial failure. +func reconcileActivityStore(ctx context.Context, eventStore EventStore, users []*types.User) error { + for _, user := range users { + originalID, _, err := dex.DecodeDexUserID(user.Id) + if err != nil { + // skip users that aren't migrated, they will be handled in the main migration loop + continue + } + if err := eventStore.UpdateUserID(ctx, originalID, user.Id); err != nil { + return fmt.Errorf("reconcile activity store for user %s: %w", user.Id, err) + } + } + return nil +} + +// migrateUser updates a single user's ID in both the main store and the activity store. +func migrateUser(ctx context.Context, s Server, oldID, accountID, newID string) error { + if err := s.Store().UpdateUserID(ctx, accountID, oldID, newID); err != nil { + return fmt.Errorf("failed to update user ID for user %s: %w", oldID, err) + } + + if s.EventStore() == nil { + return nil + } + + if err := s.EventStore().UpdateUserID(ctx, oldID, newID); err != nil { + return fmt.Errorf("failed to update activity store user ID for user %s: %w", oldID, err) + } + + return nil +} + +// PopulateUserInfo fetches user email and name from the external IDP and updates +// the store for users that are missing this information. +func PopulateUserInfo(s Server, idpManager idp.Manager, dryRun bool) error { + ctx := context.Background() + + users, err := s.Store().ListUsers(ctx) + if err != nil { + return fmt.Errorf("failed to list users: %w", err) + } + + // Build a map of IDP user ID -> UserData from the external IDP + allAccounts, err := idpManager.GetAllAccounts(ctx) + if err != nil { + return fmt.Errorf("failed to fetch accounts from IDP: %w", err) + } + + idpUsers := make(map[string]*idp.UserData) + for _, accountUsers := range allAccounts { + for _, userData := range accountUsers { + idpUsers[userData.ID] = userData + } + } + + log.Infof("fetched %d users from IDP", len(idpUsers)) + + var updatedCount, skippedCount, notFoundCount int + + for _, user := range users { + if user.IsServiceUser { + skippedCount++ + continue + } + + if user.Email != "" && user.Name != "" { + skippedCount++ + continue + } + + // The user ID in the store may be the original IDP ID or a Dex-encoded ID. + // Try to decode the Dex format first to get the original IDP ID. + lookupID := user.Id + if originalID, _, decErr := dex.DecodeDexUserID(user.Id); decErr == nil { + lookupID = originalID + } + + idpUser, found := idpUsers[lookupID] + if !found { + notFoundCount++ + log.Debugf("user %s (lookup: %s) not found in IDP, skipping", user.Id, lookupID) + continue + } + + email := user.Email + name := user.Name + if email == "" && idpUser.Email != "" { + email = idpUser.Email + } + if name == "" && idpUser.Name != "" { + name = idpUser.Name + } + + if email == user.Email && name == user.Name { + skippedCount++ + continue + } + + if dryRun { + log.Infof("[DRY RUN] would update user %s: email=%q, name=%q", user.Id, email, name) + updatedCount++ + continue + } + + if err := s.Store().UpdateUserInfo(ctx, user.Id, email, name); err != nil { + return fmt.Errorf("failed to update user info for %s: %w", user.Id, err) + } + + log.Infof("updated user %s: email=%q, name=%q", user.Id, email, name) + updatedCount++ + } + + if dryRun { + log.Infof("[DRY RUN] user info summary: %d would be updated, %d skipped, %d not found in IDP", updatedCount, skippedCount, notFoundCount) + } else { + log.Infof("user info population complete: %d updated, %d skipped, %d not found in IDP", updatedCount, skippedCount, notFoundCount) + } + + return nil +} diff --git a/management/server/idp/migration/migration_test.go b/management/server/idp/migration/migration_test.go new file mode 100644 index 000000000..2ff71347e --- /dev/null +++ b/management/server/idp/migration/migration_test.go @@ -0,0 +1,828 @@ +package migration + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/idp/dex" + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/types" +) + +// testStore is a hand-written mock for MigrationStore. +type testStore struct { + listUsersFunc func(ctx context.Context) ([]*types.User, error) + updateUserIDFunc func(ctx context.Context, accountID, oldUserID, newUserID string) error + updateUserInfoFunc func(ctx context.Context, userID, email, name string) error + checkSchemaFunc func(checks []SchemaCheck) []SchemaError + updateCalls []updateUserIDCall + updateInfoCalls []updateUserInfoCall +} + +type updateUserIDCall struct { + AccountID string + OldUserID string + NewUserID string +} + +type updateUserInfoCall struct { + UserID string + Email string + Name string +} + +func (s *testStore) ListUsers(ctx context.Context) ([]*types.User, error) { + return s.listUsersFunc(ctx) +} + +func (s *testStore) UpdateUserID(ctx context.Context, accountID, oldUserID, newUserID string) error { + s.updateCalls = append(s.updateCalls, updateUserIDCall{accountID, oldUserID, newUserID}) + return s.updateUserIDFunc(ctx, accountID, oldUserID, newUserID) +} + +func (s *testStore) UpdateUserInfo(ctx context.Context, userID, email, name string) error { + s.updateInfoCalls = append(s.updateInfoCalls, updateUserInfoCall{userID, email, name}) + if s.updateUserInfoFunc != nil { + return s.updateUserInfoFunc(ctx, userID, email, name) + } + return nil +} + +func (s *testStore) CheckSchema(checks []SchemaCheck) []SchemaError { + if s.checkSchemaFunc != nil { + return s.checkSchemaFunc(checks) + } + return nil +} + +type testServer struct { + store Store + eventStore EventStore +} + +func (s *testServer) Store() Store { return s.store } +func (s *testServer) EventStore() EventStore { return s.eventStore } + +func TestSeedConnectorFromEnv(t *testing.T) { + t.Run("returns ErrNoSeedInfo when env var is not set", func(t *testing.T) { + os.Unsetenv(idpSeedInfoKey) + + conn, err := SeedConnectorFromEnv() + assert.ErrorIs(t, err, ErrNoSeedInfo) + assert.Nil(t, conn) + }) + + t.Run("returns ErrNoSeedInfo when env var is empty", func(t *testing.T) { + t.Setenv(idpSeedInfoKey, "") + + conn, err := SeedConnectorFromEnv() + assert.ErrorIs(t, err, ErrNoSeedInfo) + assert.Nil(t, conn) + }) + + t.Run("returns error on invalid base64", func(t *testing.T) { + t.Setenv(idpSeedInfoKey, "not-valid-base64!!!") + + conn, err := SeedConnectorFromEnv() + assert.NotErrorIs(t, err, ErrNoSeedInfo) + assert.Error(t, err) + assert.Nil(t, conn) + assert.Contains(t, err.Error(), "base64 decode") + }) + + t.Run("returns error on invalid JSON", func(t *testing.T) { + encoded := base64.StdEncoding.EncodeToString([]byte("not json")) + t.Setenv(idpSeedInfoKey, encoded) + + conn, err := SeedConnectorFromEnv() + assert.NotErrorIs(t, err, ErrNoSeedInfo) + assert.Error(t, err) + assert.Nil(t, conn) + assert.Contains(t, err.Error(), "json unmarshal") + }) + + t.Run("successfully decodes valid connector", func(t *testing.T) { + expected := dex.Connector{ + Type: "oidc", + Name: "Test Provider", + ID: "test-provider", + Config: map[string]any{ + "issuer": "https://example.com", + "clientID": "my-client-id", + "clientSecret": "my-secret", + }, + } + + data, err := json.Marshal(expected) + require.NoError(t, err) + + encoded := base64.StdEncoding.EncodeToString(data) + t.Setenv(idpSeedInfoKey, encoded) + + conn, err := SeedConnectorFromEnv() + assert.NoError(t, err) + require.NotNil(t, conn) + assert.Equal(t, expected.Type, conn.Type) + assert.Equal(t, expected.Name, conn.Name) + assert.Equal(t, expected.ID, conn.ID) + assert.Equal(t, expected.Config["issuer"], conn.Config["issuer"]) + }) +} + +func TestMigrateUsersToStaticConnectors(t *testing.T) { + connector := &dex.Connector{ + Type: "oidc", + Name: "Test Provider", + ID: "test-connector", + } + + t.Run("succeeds with no users", func(t *testing.T) { + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { return nil, nil }, + updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error { return nil }, + } + + srv := &testServer{store: ms} + err := MigrateUsersToStaticConnectors(srv, connector) + assert.NoError(t, err) + }) + + t.Run("returns error when ListUsers fails", func(t *testing.T) { + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return nil, fmt.Errorf("db error") + }, + updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error { return nil }, + } + + srv := &testServer{store: ms} + err := MigrateUsersToStaticConnectors(srv, connector) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to list users") + }) + + t.Run("migrates single user with correct encoded ID", func(t *testing.T) { + user := &types.User{Id: "user-1", AccountID: "account-1"} + expectedNewID := dex.EncodeDexUserID("user-1", "test-connector") + + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return []*types.User{user}, nil + }, + updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error { + return nil + }, + } + + srv := &testServer{store: ms} + err := MigrateUsersToStaticConnectors(srv, connector) + assert.NoError(t, err) + require.Len(t, ms.updateCalls, 1) + assert.Equal(t, "account-1", ms.updateCalls[0].AccountID) + assert.Equal(t, "user-1", ms.updateCalls[0].OldUserID) + assert.Equal(t, expectedNewID, ms.updateCalls[0].NewUserID) + }) + + t.Run("migrates multiple users", func(t *testing.T) { + users := []*types.User{ + {Id: "user-1", AccountID: "account-1"}, + {Id: "user-2", AccountID: "account-1"}, + {Id: "user-3", AccountID: "account-2"}, + } + + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return users, nil + }, + updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error { + return nil + }, + } + + srv := &testServer{store: ms} + err := MigrateUsersToStaticConnectors(srv, connector) + assert.NoError(t, err) + assert.Len(t, ms.updateCalls, 3) + }) + + t.Run("returns error when UpdateUserID fails", func(t *testing.T) { + users := []*types.User{ + {Id: "user-1", AccountID: "account-1"}, + {Id: "user-2", AccountID: "account-1"}, + } + + callCount := 0 + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return users, nil + }, + updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error { + callCount++ + if callCount == 2 { + return fmt.Errorf("update failed") + } + return nil + }, + } + + srv := &testServer{store: ms} + err := MigrateUsersToStaticConnectors(srv, connector) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to update user ID for user user-2") + }) + + t.Run("stops on first UpdateUserID error", func(t *testing.T) { + users := []*types.User{ + {Id: "user-1", AccountID: "account-1"}, + {Id: "user-2", AccountID: "account-1"}, + } + + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return users, nil + }, + updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error { + return fmt.Errorf("update failed") + }, + } + + srv := &testServer{store: ms} + err := MigrateUsersToStaticConnectors(srv, connector) + assert.Error(t, err) + assert.Len(t, ms.updateCalls, 1) // stopped after first error + }) + + t.Run("skips already migrated users", func(t *testing.T) { + alreadyMigratedID := dex.EncodeDexUserID("user-1", "test-connector") + users := []*types.User{ + {Id: alreadyMigratedID, AccountID: "account-1"}, + } + + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return users, nil + }, + updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error { + return nil + }, + } + + srv := &testServer{store: ms} + err := MigrateUsersToStaticConnectors(srv, connector) + assert.NoError(t, err) + assert.Len(t, ms.updateCalls, 0) + }) + + t.Run("migrates only non-migrated users in mixed state", func(t *testing.T) { + alreadyMigratedID := dex.EncodeDexUserID("user-1", "test-connector") + users := []*types.User{ + {Id: alreadyMigratedID, AccountID: "account-1"}, + {Id: "user-2", AccountID: "account-1"}, + {Id: "user-3", AccountID: "account-2"}, + } + + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return users, nil + }, + updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error { + return nil + }, + } + + srv := &testServer{store: ms} + err := MigrateUsersToStaticConnectors(srv, connector) + assert.NoError(t, err) + // Only user-2 and user-3 should be migrated + assert.Len(t, ms.updateCalls, 2) + assert.Equal(t, "user-2", ms.updateCalls[0].OldUserID) + assert.Equal(t, "user-3", ms.updateCalls[1].OldUserID) + }) + + t.Run("dry run does not call UpdateUserID", func(t *testing.T) { + t.Setenv(dryRunEnvKey, "true") + + users := []*types.User{ + {Id: "user-1", AccountID: "account-1"}, + {Id: "user-2", AccountID: "account-1"}, + } + + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return users, nil + }, + updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error { + t.Fatal("UpdateUserID should not be called in dry-run mode") + return nil + }, + } + + srv := &testServer{store: ms} + err := MigrateUsersToStaticConnectors(srv, connector) + assert.NoError(t, err) + assert.Len(t, ms.updateCalls, 0) + }) + + t.Run("dry run skips already migrated users", func(t *testing.T) { + t.Setenv(dryRunEnvKey, "true") + + alreadyMigratedID := dex.EncodeDexUserID("user-1", "test-connector") + users := []*types.User{ + {Id: alreadyMigratedID, AccountID: "account-1"}, + {Id: "user-2", AccountID: "account-1"}, + } + + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return users, nil + }, + updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error { + t.Fatal("UpdateUserID should not be called in dry-run mode") + return nil + }, + } + + srv := &testServer{store: ms} + err := MigrateUsersToStaticConnectors(srv, connector) + assert.NoError(t, err) + }) + + t.Run("dry run disabled by default", func(t *testing.T) { + user := &types.User{Id: "user-1", AccountID: "account-1"} + + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return []*types.User{user}, nil + }, + updateUserIDFunc: func(ctx context.Context, accountID, oldUserID, newUserID string) error { + return nil + }, + } + + srv := &testServer{store: ms} + err := MigrateUsersToStaticConnectors(srv, connector) + assert.NoError(t, err) + assert.Len(t, ms.updateCalls, 1) // proves it's not in dry-run + }) +} + +func TestPopulateUserInfo(t *testing.T) { + noopUpdateID := func(ctx context.Context, accountID, oldUserID, newUserID string) error { return nil } + + t.Run("succeeds with no users", func(t *testing.T) { + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { return nil, nil }, + updateUserIDFunc: noopUpdateID, + } + mockIDP := &idp.MockIDP{ + GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) { + return map[string][]*idp.UserData{}, nil + }, + } + + srv := &testServer{store: ms} + err := PopulateUserInfo(srv, mockIDP, false) + assert.NoError(t, err) + assert.Empty(t, ms.updateInfoCalls) + }) + + t.Run("returns error when ListUsers fails", func(t *testing.T) { + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return nil, fmt.Errorf("db error") + }, + updateUserIDFunc: noopUpdateID, + } + mockIDP := &idp.MockIDP{} + + srv := &testServer{store: ms} + err := PopulateUserInfo(srv, mockIDP, false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to list users") + }) + + t.Run("returns error when GetAllAccounts fails", func(t *testing.T) { + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return []*types.User{{Id: "user-1", AccountID: "acc-1"}}, nil + }, + updateUserIDFunc: noopUpdateID, + } + mockIDP := &idp.MockIDP{ + GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) { + return nil, fmt.Errorf("idp error") + }, + } + + srv := &testServer{store: ms} + err := PopulateUserInfo(srv, mockIDP, false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to fetch accounts from IDP") + }) + + t.Run("updates user with missing email and name", func(t *testing.T) { + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return []*types.User{ + {Id: "user-1", AccountID: "acc-1", Email: "", Name: ""}, + }, nil + }, + updateUserIDFunc: noopUpdateID, + } + mockIDP := &idp.MockIDP{ + GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) { + return map[string][]*idp.UserData{ + "acc-1": { + {ID: "user-1", Email: "user1@example.com", Name: "User One"}, + }, + }, nil + }, + } + + srv := &testServer{store: ms} + err := PopulateUserInfo(srv, mockIDP, false) + assert.NoError(t, err) + require.Len(t, ms.updateInfoCalls, 1) + assert.Equal(t, "user-1", ms.updateInfoCalls[0].UserID) + assert.Equal(t, "user1@example.com", ms.updateInfoCalls[0].Email) + assert.Equal(t, "User One", ms.updateInfoCalls[0].Name) + }) + + t.Run("updates only missing email when name exists", func(t *testing.T) { + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return []*types.User{ + {Id: "user-1", AccountID: "acc-1", Email: "", Name: "Existing Name"}, + }, nil + }, + updateUserIDFunc: noopUpdateID, + } + mockIDP := &idp.MockIDP{ + GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) { + return map[string][]*idp.UserData{ + "acc-1": {{ID: "user-1", Email: "user1@example.com", Name: "IDP Name"}}, + }, nil + }, + } + + srv := &testServer{store: ms} + err := PopulateUserInfo(srv, mockIDP, false) + assert.NoError(t, err) + require.Len(t, ms.updateInfoCalls, 1) + assert.Equal(t, "user1@example.com", ms.updateInfoCalls[0].Email) + assert.Equal(t, "Existing Name", ms.updateInfoCalls[0].Name) + }) + + t.Run("updates only missing name when email exists", func(t *testing.T) { + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return []*types.User{ + {Id: "user-1", AccountID: "acc-1", Email: "existing@example.com", Name: ""}, + }, nil + }, + updateUserIDFunc: noopUpdateID, + } + mockIDP := &idp.MockIDP{ + GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) { + return map[string][]*idp.UserData{ + "acc-1": {{ID: "user-1", Email: "idp@example.com", Name: "IDP Name"}}, + }, nil + }, + } + + srv := &testServer{store: ms} + err := PopulateUserInfo(srv, mockIDP, false) + assert.NoError(t, err) + require.Len(t, ms.updateInfoCalls, 1) + assert.Equal(t, "existing@example.com", ms.updateInfoCalls[0].Email) + assert.Equal(t, "IDP Name", ms.updateInfoCalls[0].Name) + }) + + t.Run("skips users that already have both email and name", func(t *testing.T) { + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return []*types.User{ + {Id: "user-1", AccountID: "acc-1", Email: "user1@example.com", Name: "User One"}, + }, nil + }, + updateUserIDFunc: noopUpdateID, + } + mockIDP := &idp.MockIDP{ + GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) { + return map[string][]*idp.UserData{ + "acc-1": {{ID: "user-1", Email: "different@example.com", Name: "Different Name"}}, + }, nil + }, + } + + srv := &testServer{store: ms} + err := PopulateUserInfo(srv, mockIDP, false) + assert.NoError(t, err) + assert.Empty(t, ms.updateInfoCalls) + }) + + t.Run("skips service users", func(t *testing.T) { + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return []*types.User{ + {Id: "svc-1", AccountID: "acc-1", Email: "", Name: "", IsServiceUser: true}, + }, nil + }, + updateUserIDFunc: noopUpdateID, + } + mockIDP := &idp.MockIDP{ + GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) { + return map[string][]*idp.UserData{ + "acc-1": {{ID: "svc-1", Email: "svc@example.com", Name: "Service"}}, + }, nil + }, + } + + srv := &testServer{store: ms} + err := PopulateUserInfo(srv, mockIDP, false) + assert.NoError(t, err) + assert.Empty(t, ms.updateInfoCalls) + }) + + t.Run("skips users not found in IDP", func(t *testing.T) { + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return []*types.User{ + {Id: "user-1", AccountID: "acc-1", Email: "", Name: ""}, + }, nil + }, + updateUserIDFunc: noopUpdateID, + } + mockIDP := &idp.MockIDP{ + GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) { + return map[string][]*idp.UserData{ + "acc-1": {{ID: "different-user", Email: "other@example.com", Name: "Other"}}, + }, nil + }, + } + + srv := &testServer{store: ms} + err := PopulateUserInfo(srv, mockIDP, false) + assert.NoError(t, err) + assert.Empty(t, ms.updateInfoCalls) + }) + + t.Run("looks up dex-encoded user IDs by original ID", func(t *testing.T) { + dexEncodedID := dex.EncodeDexUserID("original-idp-id", "my-connector") + + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return []*types.User{ + {Id: dexEncodedID, AccountID: "acc-1", Email: "", Name: ""}, + }, nil + }, + updateUserIDFunc: noopUpdateID, + } + mockIDP := &idp.MockIDP{ + GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) { + return map[string][]*idp.UserData{ + "acc-1": {{ID: "original-idp-id", Email: "user@example.com", Name: "User"}}, + }, nil + }, + } + + srv := &testServer{store: ms} + err := PopulateUserInfo(srv, mockIDP, false) + assert.NoError(t, err) + require.Len(t, ms.updateInfoCalls, 1) + assert.Equal(t, dexEncodedID, ms.updateInfoCalls[0].UserID) + assert.Equal(t, "user@example.com", ms.updateInfoCalls[0].Email) + assert.Equal(t, "User", ms.updateInfoCalls[0].Name) + }) + + t.Run("handles multiple users across multiple accounts", func(t *testing.T) { + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return []*types.User{ + {Id: "user-1", AccountID: "acc-1", Email: "", Name: ""}, + {Id: "user-2", AccountID: "acc-1", Email: "already@set.com", Name: "Already Set"}, + {Id: "user-3", AccountID: "acc-2", Email: "", Name: ""}, + }, nil + }, + updateUserIDFunc: noopUpdateID, + } + mockIDP := &idp.MockIDP{ + GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) { + return map[string][]*idp.UserData{ + "acc-1": { + {ID: "user-1", Email: "u1@example.com", Name: "User 1"}, + {ID: "user-2", Email: "u2@example.com", Name: "User 2"}, + }, + "acc-2": { + {ID: "user-3", Email: "u3@example.com", Name: "User 3"}, + }, + }, nil + }, + } + + srv := &testServer{store: ms} + err := PopulateUserInfo(srv, mockIDP, false) + assert.NoError(t, err) + require.Len(t, ms.updateInfoCalls, 2) + assert.Equal(t, "user-1", ms.updateInfoCalls[0].UserID) + assert.Equal(t, "u1@example.com", ms.updateInfoCalls[0].Email) + assert.Equal(t, "user-3", ms.updateInfoCalls[1].UserID) + assert.Equal(t, "u3@example.com", ms.updateInfoCalls[1].Email) + }) + + t.Run("returns error when UpdateUserInfo fails", func(t *testing.T) { + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return []*types.User{ + {Id: "user-1", AccountID: "acc-1", Email: "", Name: ""}, + }, nil + }, + updateUserIDFunc: noopUpdateID, + updateUserInfoFunc: func(ctx context.Context, userID, email, name string) error { + return fmt.Errorf("db write error") + }, + } + mockIDP := &idp.MockIDP{ + GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) { + return map[string][]*idp.UserData{ + "acc-1": {{ID: "user-1", Email: "u1@example.com", Name: "User 1"}}, + }, nil + }, + } + + srv := &testServer{store: ms} + err := PopulateUserInfo(srv, mockIDP, false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to update user info for user-1") + }) + + t.Run("stops on first UpdateUserInfo error", func(t *testing.T) { + callCount := 0 + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return []*types.User{ + {Id: "user-1", AccountID: "acc-1", Email: "", Name: ""}, + {Id: "user-2", AccountID: "acc-1", Email: "", Name: ""}, + }, nil + }, + updateUserIDFunc: noopUpdateID, + updateUserInfoFunc: func(ctx context.Context, userID, email, name string) error { + callCount++ + return fmt.Errorf("db write error") + }, + } + mockIDP := &idp.MockIDP{ + GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) { + return map[string][]*idp.UserData{ + "acc-1": { + {ID: "user-1", Email: "u1@example.com", Name: "U1"}, + {ID: "user-2", Email: "u2@example.com", Name: "U2"}, + }, + }, nil + }, + } + + srv := &testServer{store: ms} + err := PopulateUserInfo(srv, mockIDP, false) + assert.Error(t, err) + assert.Equal(t, 1, callCount) + }) + + t.Run("dry run does not call UpdateUserInfo", func(t *testing.T) { + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return []*types.User{ + {Id: "user-1", AccountID: "acc-1", Email: "", Name: ""}, + {Id: "user-2", AccountID: "acc-1", Email: "", Name: ""}, + }, nil + }, + updateUserIDFunc: noopUpdateID, + updateUserInfoFunc: func(ctx context.Context, userID, email, name string) error { + t.Fatal("UpdateUserInfo should not be called in dry-run mode") + return nil + }, + } + mockIDP := &idp.MockIDP{ + GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) { + return map[string][]*idp.UserData{ + "acc-1": { + {ID: "user-1", Email: "u1@example.com", Name: "U1"}, + {ID: "user-2", Email: "u2@example.com", Name: "U2"}, + }, + }, nil + }, + } + + srv := &testServer{store: ms} + err := PopulateUserInfo(srv, mockIDP, true) + assert.NoError(t, err) + assert.Empty(t, ms.updateInfoCalls) + }) + + t.Run("skips user when IDP has empty email and name too", func(t *testing.T) { + ms := &testStore{ + listUsersFunc: func(ctx context.Context) ([]*types.User, error) { + return []*types.User{ + {Id: "user-1", AccountID: "acc-1", Email: "", Name: ""}, + }, nil + }, + updateUserIDFunc: noopUpdateID, + } + mockIDP := &idp.MockIDP{ + GetAllAccountsFunc: func(ctx context.Context) (map[string][]*idp.UserData, error) { + return map[string][]*idp.UserData{ + "acc-1": {{ID: "user-1", Email: "", Name: ""}}, + }, nil + }, + } + + srv := &testServer{store: ms} + err := PopulateUserInfo(srv, mockIDP, false) + assert.NoError(t, err) + assert.Empty(t, ms.updateInfoCalls) + }) +} + +func TestSchemaError_String(t *testing.T) { + t.Run("missing table", func(t *testing.T) { + e := SchemaError{Table: "jobs"} + assert.Equal(t, `table "jobs" is missing`, e.String()) + }) + + t.Run("missing column", func(t *testing.T) { + e := SchemaError{Table: "users", Column: "email"} + assert.Equal(t, `column "email" on table "users" is missing`, e.String()) + }) +} + +func TestRequiredSchema(t *testing.T) { + // Verify RequiredSchema covers all the tables touched by UpdateUserID and UpdateUserInfo. + expectedTables := []string{ + "users", + "personal_access_tokens", + "peers", + "accounts", + "user_invites", + "proxy_access_tokens", + "jobs", + } + + schemaTableNames := make([]string, len(RequiredSchema)) + for i, s := range RequiredSchema { + schemaTableNames[i] = s.Table + } + + for _, expected := range expectedTables { + assert.Contains(t, schemaTableNames, expected, "RequiredSchema should include table %q", expected) + } +} + +func TestCheckSchema_MockStore(t *testing.T) { + t.Run("returns nil when all schema exists", func(t *testing.T) { + ms := &testStore{ + checkSchemaFunc: func(checks []SchemaCheck) []SchemaError { + return nil + }, + } + errs := ms.CheckSchema(RequiredSchema) + assert.Empty(t, errs) + }) + + t.Run("returns errors for missing tables", func(t *testing.T) { + ms := &testStore{ + checkSchemaFunc: func(checks []SchemaCheck) []SchemaError { + return []SchemaError{ + {Table: "jobs"}, + {Table: "proxy_access_tokens"}, + } + }, + } + errs := ms.CheckSchema(RequiredSchema) + require.Len(t, errs, 2) + assert.Equal(t, "jobs", errs[0].Table) + assert.Equal(t, "", errs[0].Column) + assert.Equal(t, "proxy_access_tokens", errs[1].Table) + }) + + t.Run("returns errors for missing columns", func(t *testing.T) { + ms := &testStore{ + checkSchemaFunc: func(checks []SchemaCheck) []SchemaError { + return []SchemaError{ + {Table: "users", Column: "email"}, + {Table: "users", Column: "name"}, + } + }, + } + errs := ms.CheckSchema(RequiredSchema) + require.Len(t, errs, 2) + assert.Equal(t, "users", errs[0].Table) + assert.Equal(t, "email", errs[0].Column) + }) +} diff --git a/management/server/idp/migration/store.go b/management/server/idp/migration/store.go new file mode 100644 index 000000000..e7cc54a41 --- /dev/null +++ b/management/server/idp/migration/store.go @@ -0,0 +1,82 @@ +package migration + +import ( + "context" + "fmt" + + "github.com/netbirdio/netbird/management/server/types" +) + +// SchemaCheck represents a table and the columns required on it. +type SchemaCheck struct { + Table string + Columns []string +} + +// RequiredSchema lists all tables and columns that the migration tool needs. +// If any are missing, the user must upgrade their management server first so +// that the automatic GORM migrations create them. +var RequiredSchema = []SchemaCheck{ + {Table: "users", Columns: []string{"id", "email", "name", "account_id"}}, + {Table: "personal_access_tokens", Columns: []string{"user_id", "created_by"}}, + {Table: "peers", Columns: []string{"user_id"}}, + {Table: "accounts", Columns: []string{"created_by"}}, + {Table: "user_invites", Columns: []string{"created_by"}}, + {Table: "proxy_access_tokens", Columns: []string{"created_by"}}, + {Table: "jobs", Columns: []string{"triggered_by"}}, +} + +// SchemaError describes a single missing table or column. +type SchemaError struct { + Table string + Column string // empty when the whole table is missing +} + +func (e SchemaError) String() string { + if e.Column == "" { + return fmt.Sprintf("table %q is missing", e.Table) + } + return fmt.Sprintf("column %q on table %q is missing", e.Column, e.Table) +} + +// Store defines the data store operations required for IdP user migration. +// This interface is separate from the main store.Store interface because these methods +// are only used during one-time migration and should be removed once migration tooling +// is no longer needed. +// +// The SQL store implementations (SqlStore) already have these methods on their concrete +// types, so they satisfy this interface via Go's structural typing with zero code changes. +type Store interface { + // ListUsers returns all users across all accounts. + ListUsers(ctx context.Context) ([]*types.User, error) + + // UpdateUserID atomically updates a user's ID and all foreign key references + // across the database (peers, groups, policies, PATs, etc.). + UpdateUserID(ctx context.Context, accountID, oldUserID, newUserID string) error + + // UpdateUserInfo updates a user's email and name in the store. + UpdateUserInfo(ctx context.Context, userID, email, name string) error + + // CheckSchema verifies that all tables and columns required by the migration + // exist in the database. Returns a list of problems; an empty slice means OK. + CheckSchema(checks []SchemaCheck) []SchemaError +} + +// RequiredEventSchema lists all tables and columns that the migration tool needs +// in the activity/event store. +var RequiredEventSchema = []SchemaCheck{ + {Table: "events", Columns: []string{"initiator_id", "target_id"}}, + {Table: "deleted_users", Columns: []string{"id"}}, +} + +// EventStore defines the activity event store operations required for migration. +// Like Store, this is a temporary interface for migration tooling only. +type EventStore interface { + // CheckSchema verifies that all tables and columns required by the migration + // exist in the event database. Returns a list of problems; an empty slice means OK. + CheckSchema(checks []SchemaCheck) []SchemaError + + // UpdateUserID updates all event references (initiator_id, target_id) and + // deleted_users records to use the new user ID format. + UpdateUserID(ctx context.Context, oldUserID, newUserID string) error +} diff --git a/management/server/idp/okta.go b/management/server/idp/okta.go index b9cd006be..07f0d8008 100644 --- a/management/server/idp/okta.go +++ b/management/server/idp/okta.go @@ -6,7 +6,6 @@ import ( "net/http" "net/url" "strings" - "time" "github.com/okta/okta-sdk-golang/v2/okta" "github.com/okta/okta-sdk-golang/v2/okta/query" @@ -45,7 +44,7 @@ func NewOktaManager(config OktaClientConfig, appMetrics telemetry.AppMetrics) (* httpTransport.MaxIdleConns = 5 httpClient := &http.Client{ - Timeout: 10 * time.Second, + Timeout: idpTimeout(), Transport: httpTransport, } diff --git a/management/server/idp/pocketid.go b/management/server/idp/pocketid.go index 38a5cc67f..fc338b86b 100644 --- a/management/server/idp/pocketid.go +++ b/management/server/idp/pocketid.go @@ -8,7 +8,6 @@ import ( "net/url" "slices" "strings" - "time" "github.com/netbirdio/netbird/management/server/telemetry" ) @@ -88,9 +87,10 @@ func NewPocketIdManager(config PocketIdClientConfig, appMetrics telemetry.AppMet httpTransport.MaxIdleConns = 5 httpClient := &http.Client{ - Timeout: 10 * time.Second, + Timeout: idpTimeout(), Transport: httpTransport, } + helper := JsonParser{} if config.ManagementEndpoint == "" { @@ -121,7 +121,7 @@ func NewPocketIdManager(config PocketIdClientConfig, appMetrics telemetry.AppMet func (p *PocketIdManager) request(ctx context.Context, method, resource string, query *url.Values, body string) ([]byte, error) { var MethodsWithBody = []string{http.MethodPost, http.MethodPut} if !slices.Contains(MethodsWithBody, method) && body != "" { - return nil, fmt.Errorf("Body provided to unsupported method: %s", method) + return nil, fmt.Errorf("body provided to unsupported method: %s", method) } reqURL := fmt.Sprintf("%s/api/%s", p.managementEndpoint, resource) @@ -301,7 +301,7 @@ func (p *PocketIdManager) CreateUser(ctx context.Context, email, name, accountID if p.appMetrics != nil { p.appMetrics.IDPMetrics().CountCreateUser() } - var pending bool = true + pending := true ret := &UserData{ Email: email, Name: name, diff --git a/management/server/idp/pocketid_test.go b/management/server/idp/pocketid_test.go index 49075a0d3..126a76919 100644 --- a/management/server/idp/pocketid_test.go +++ b/management/server/idp/pocketid_test.go @@ -1,138 +1,137 @@ package idp import ( - "context" - "testing" + "context" + "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" - "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/netbirdio/netbird/management/server/telemetry" ) - func TestNewPocketIdManager(t *testing.T) { - type test struct { - name string - inputConfig PocketIdClientConfig - assertErrFunc require.ErrorAssertionFunc - assertErrFuncMessage string - } + type test struct { + name string + inputConfig PocketIdClientConfig + assertErrFunc require.ErrorAssertionFunc + assertErrFuncMessage string + } - defaultTestConfig := PocketIdClientConfig{ - APIToken: "api_token", - ManagementEndpoint: "http://localhost", - } + defaultTestConfig := PocketIdClientConfig{ + APIToken: "api_token", + ManagementEndpoint: "http://localhost", + } - tests := []test{ - { - name: "Good Configuration", - inputConfig: defaultTestConfig, - assertErrFunc: require.NoError, - assertErrFuncMessage: "shouldn't return error", - }, - { - name: "Missing ManagementEndpoint", - inputConfig: PocketIdClientConfig{ - APIToken: defaultTestConfig.APIToken, - ManagementEndpoint: "", - }, - assertErrFunc: require.Error, - assertErrFuncMessage: "should return error when field empty", - }, - { - name: "Missing APIToken", - inputConfig: PocketIdClientConfig{ - APIToken: "", - ManagementEndpoint: defaultTestConfig.ManagementEndpoint, - }, - assertErrFunc: require.Error, - assertErrFuncMessage: "should return error when field empty", - }, - } + tests := []test{ + { + name: "Good Configuration", + inputConfig: defaultTestConfig, + assertErrFunc: require.NoError, + assertErrFuncMessage: "shouldn't return error", + }, + { + name: "Missing ManagementEndpoint", + inputConfig: PocketIdClientConfig{ + APIToken: defaultTestConfig.APIToken, + ManagementEndpoint: "", + }, + assertErrFunc: require.Error, + assertErrFuncMessage: "should return error when field empty", + }, + { + name: "Missing APIToken", + inputConfig: PocketIdClientConfig{ + APIToken: "", + ManagementEndpoint: defaultTestConfig.ManagementEndpoint, + }, + assertErrFunc: require.Error, + assertErrFuncMessage: "should return error when field empty", + }, + } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - _, err := NewPocketIdManager(tc.inputConfig, &telemetry.MockAppMetrics{}) - tc.assertErrFunc(t, err, tc.assertErrFuncMessage) - }) - } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := NewPocketIdManager(tc.inputConfig, &telemetry.MockAppMetrics{}) + tc.assertErrFunc(t, err, tc.assertErrFuncMessage) + }) + } } func TestPocketID_GetUserDataByID(t *testing.T) { - client := &mockHTTPClient{code: 200, resBody: `{"id":"u1","email":"user1@example.com","displayName":"User One"}`} + client := &mockHTTPClient{code: 200, resBody: `{"id":"u1","email":"user1@example.com","displayName":"User One"}`} - mgr, err := NewPocketIdManager(PocketIdClientConfig{APIToken: "tok", ManagementEndpoint: "http://localhost"}, nil) - require.NoError(t, err) - mgr.httpClient = client + mgr, err := NewPocketIdManager(PocketIdClientConfig{APIToken: "tok", ManagementEndpoint: "http://localhost"}, nil) + require.NoError(t, err) + mgr.httpClient = client - md := AppMetadata{WTAccountID: "acc1"} - got, err := mgr.GetUserDataByID(context.Background(), "u1", md) - require.NoError(t, err) - assert.Equal(t, "u1", got.ID) - assert.Equal(t, "user1@example.com", got.Email) - assert.Equal(t, "User One", got.Name) - assert.Equal(t, "acc1", got.AppMetadata.WTAccountID) + md := AppMetadata{WTAccountID: "acc1"} + got, err := mgr.GetUserDataByID(context.Background(), "u1", md) + require.NoError(t, err) + assert.Equal(t, "u1", got.ID) + assert.Equal(t, "user1@example.com", got.Email) + assert.Equal(t, "User One", got.Name) + assert.Equal(t, "acc1", got.AppMetadata.WTAccountID) } func TestPocketID_GetAccount_WithPagination(t *testing.T) { - // Single page response with two users - client := &mockHTTPClient{code: 200, resBody: `{"data":[{"id":"u1","email":"e1","displayName":"n1"},{"id":"u2","email":"e2","displayName":"n2"}],"pagination":{"currentPage":1,"itemsPerPage":100,"totalItems":2,"totalPages":1}}`} + // Single page response with two users + client := &mockHTTPClient{code: 200, resBody: `{"data":[{"id":"u1","email":"e1","displayName":"n1"},{"id":"u2","email":"e2","displayName":"n2"}],"pagination":{"currentPage":1,"itemsPerPage":100,"totalItems":2,"totalPages":1}}`} - mgr, err := NewPocketIdManager(PocketIdClientConfig{APIToken: "tok", ManagementEndpoint: "http://localhost"}, nil) - require.NoError(t, err) - mgr.httpClient = client + mgr, err := NewPocketIdManager(PocketIdClientConfig{APIToken: "tok", ManagementEndpoint: "http://localhost"}, nil) + require.NoError(t, err) + mgr.httpClient = client - users, err := mgr.GetAccount(context.Background(), "accX") - require.NoError(t, err) - require.Len(t, users, 2) - assert.Equal(t, "u1", users[0].ID) - assert.Equal(t, "accX", users[0].AppMetadata.WTAccountID) - assert.Equal(t, "u2", users[1].ID) + users, err := mgr.GetAccount(context.Background(), "accX") + require.NoError(t, err) + require.Len(t, users, 2) + assert.Equal(t, "u1", users[0].ID) + assert.Equal(t, "accX", users[0].AppMetadata.WTAccountID) + assert.Equal(t, "u2", users[1].ID) } func TestPocketID_GetAllAccounts_WithPagination(t *testing.T) { - client := &mockHTTPClient{code: 200, resBody: `{"data":[{"id":"u1","email":"e1","displayName":"n1"},{"id":"u2","email":"e2","displayName":"n2"}],"pagination":{"currentPage":1,"itemsPerPage":100,"totalItems":2,"totalPages":1}}`} + client := &mockHTTPClient{code: 200, resBody: `{"data":[{"id":"u1","email":"e1","displayName":"n1"},{"id":"u2","email":"e2","displayName":"n2"}],"pagination":{"currentPage":1,"itemsPerPage":100,"totalItems":2,"totalPages":1}}`} - mgr, err := NewPocketIdManager(PocketIdClientConfig{APIToken: "tok", ManagementEndpoint: "http://localhost"}, nil) - require.NoError(t, err) - mgr.httpClient = client + mgr, err := NewPocketIdManager(PocketIdClientConfig{APIToken: "tok", ManagementEndpoint: "http://localhost"}, nil) + require.NoError(t, err) + mgr.httpClient = client - accounts, err := mgr.GetAllAccounts(context.Background()) - require.NoError(t, err) - require.Len(t, accounts[UnsetAccountID], 2) + accounts, err := mgr.GetAllAccounts(context.Background()) + require.NoError(t, err) + require.Len(t, accounts[UnsetAccountID], 2) } func TestPocketID_CreateUser(t *testing.T) { - client := &mockHTTPClient{code: 201, resBody: `{"id":"newid","email":"new@example.com","displayName":"New User"}`} + client := &mockHTTPClient{code: 201, resBody: `{"id":"newid","email":"new@example.com","displayName":"New User"}`} - mgr, err := NewPocketIdManager(PocketIdClientConfig{APIToken: "tok", ManagementEndpoint: "http://localhost"}, nil) - require.NoError(t, err) - mgr.httpClient = client + mgr, err := NewPocketIdManager(PocketIdClientConfig{APIToken: "tok", ManagementEndpoint: "http://localhost"}, nil) + require.NoError(t, err) + mgr.httpClient = client - ud, err := mgr.CreateUser(context.Background(), "new@example.com", "New User", "acc1", "inviter@example.com") - require.NoError(t, err) - assert.Equal(t, "newid", ud.ID) - assert.Equal(t, "new@example.com", ud.Email) - assert.Equal(t, "New User", ud.Name) - assert.Equal(t, "acc1", ud.AppMetadata.WTAccountID) - if assert.NotNil(t, ud.AppMetadata.WTPendingInvite) { - assert.True(t, *ud.AppMetadata.WTPendingInvite) - } - assert.Equal(t, "inviter@example.com", ud.AppMetadata.WTInvitedBy) + ud, err := mgr.CreateUser(context.Background(), "new@example.com", "New User", "acc1", "inviter@example.com") + require.NoError(t, err) + assert.Equal(t, "newid", ud.ID) + assert.Equal(t, "new@example.com", ud.Email) + assert.Equal(t, "New User", ud.Name) + assert.Equal(t, "acc1", ud.AppMetadata.WTAccountID) + if assert.NotNil(t, ud.AppMetadata.WTPendingInvite) { + assert.True(t, *ud.AppMetadata.WTPendingInvite) + } + assert.Equal(t, "inviter@example.com", ud.AppMetadata.WTInvitedBy) } func TestPocketID_InviteAndDeleteUser(t *testing.T) { - // Same mock for both calls; returns OK with empty JSON - client := &mockHTTPClient{code: 200, resBody: `{}`} + // Same mock for both calls; returns OK with empty JSON + client := &mockHTTPClient{code: 200, resBody: `{}`} - mgr, err := NewPocketIdManager(PocketIdClientConfig{APIToken: "tok", ManagementEndpoint: "http://localhost"}, nil) - require.NoError(t, err) - mgr.httpClient = client + mgr, err := NewPocketIdManager(PocketIdClientConfig{APIToken: "tok", ManagementEndpoint: "http://localhost"}, nil) + require.NoError(t, err) + mgr.httpClient = client - err = mgr.InviteUserByID(context.Background(), "u1") - require.NoError(t, err) + err = mgr.InviteUserByID(context.Background(), "u1") + require.NoError(t, err) - err = mgr.DeleteUser(context.Background(), "u1") - require.NoError(t, err) + err = mgr.DeleteUser(context.Background(), "u1") + require.NoError(t, err) } diff --git a/management/server/idp/util.go b/management/server/idp/util.go index df1497114..ed82fb9e3 100644 --- a/management/server/idp/util.go +++ b/management/server/idp/util.go @@ -4,7 +4,9 @@ import ( "encoding/json" "math/rand" "net/url" + "os" "strings" + "time" ) var ( @@ -69,3 +71,24 @@ func baseURL(rawURL string) string { return parsedURL.Scheme + "://" + parsedURL.Host } + +const ( + // Provides the env variable name for use with idpTimeout function + idpTimeoutEnv = "NB_IDP_TIMEOUT" + // Sets the defaultTimeout to 10s. + defaultTimeout = 10 * time.Second +) + +// idpTimeout returns a timeout value for the IDP +func idpTimeout() time.Duration { + timeoutStr, ok := os.LookupEnv(idpTimeoutEnv) + if !ok || timeoutStr == "" { + return defaultTimeout + } + + timeout, err := time.ParseDuration(timeoutStr) + if err != nil { + return defaultTimeout + } + return timeout +} diff --git a/management/server/idp/zitadel.go b/management/server/idp/zitadel.go index 24228346a..320f0c131 100644 --- a/management/server/idp/zitadel.go +++ b/management/server/idp/zitadel.go @@ -164,9 +164,10 @@ func NewZitadelManager(config ZitadelClientConfig, appMetrics telemetry.AppMetri httpTransport.MaxIdleConns = 5 httpClient := &http.Client{ - Timeout: 10 * time.Second, + Timeout: idpTimeout(), Transport: httpTransport, } + helper := JsonParser{} hasPAT := config.PAT != "" @@ -357,7 +358,7 @@ func (zm *ZitadelManager) CreateUser(ctx context.Context, email, name, accountID return nil, err } - var pending bool = true + pending := true ret := &UserData{ Email: email, Name: name, diff --git a/management/server/instance/manager.go b/management/server/instance/manager.go new file mode 100644 index 000000000..2c355bb3b --- /dev/null +++ b/management/server/instance/manager.go @@ -0,0 +1,411 @@ +package instance + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/mail" + "strings" + "sync" + "time" + + "github.com/dexidp/dex/storage" + goversion "github.com/hashicorp/go-version" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/status" + "github.com/netbirdio/netbird/version" +) + +const ( + // Version endpoints + managementVersionURL = "https://pkgs.netbird.io/releases/latest/version" + dashboardReleasesURL = "https://api.github.com/repos/netbirdio/dashboard/releases/latest" + + // Cache TTL for version information + versionCacheTTL = 60 * time.Minute + + // HTTP client timeout + httpTimeout = 5 * time.Second +) + +// VersionInfo contains version information for NetBird components +type VersionInfo struct { + // CurrentVersion is the running management server version + CurrentVersion string + // DashboardVersion is the latest available dashboard version from GitHub + DashboardVersion string + // ManagementVersion is the latest available management version from GitHub + ManagementVersion string + // ManagementUpdateAvailable indicates if a newer management version is available + ManagementUpdateAvailable bool +} + +// githubRelease represents a GitHub release response +type githubRelease struct { + TagName string `json:"tag_name"` +} + +// Manager handles instance-level operations like initial setup. +type Manager interface { + // IsSetupRequired checks if instance setup is required. + // Returns true if embedded IDP is enabled and no accounts exist. + IsSetupRequired(ctx context.Context) (bool, error) + + // CreateOwnerUser creates the initial owner user in the embedded IDP. + // This should only be called when IsSetupRequired returns true. + CreateOwnerUser(ctx context.Context, email, password, name string) (*idp.UserData, error) + + // RollbackSetup reverses a successful CreateOwnerUser by deleting the user + // from the embedded IDP and reloading setupRequired from persistent state, so + // /api/setup can be retried only when no accounts or local users remain. Used + // when post-user steps (account or PAT creation) fail and the caller wants a + // clean slate. + RollbackSetup(ctx context.Context, userID string) error + + // GetVersionInfo returns version information for NetBird components. + GetVersionInfo(ctx context.Context) (*VersionInfo, error) +} + +type instanceStore interface { + GetAccountsCounter(ctx context.Context) (int64, error) +} + +type embeddedIdP interface { + CreateUserWithPassword(ctx context.Context, email, password, name string) (*idp.UserData, error) + DeleteUser(ctx context.Context, userID string) error + GetAllAccounts(ctx context.Context) (map[string][]*idp.UserData, error) +} + +// DefaultManager is the default implementation of Manager. +type DefaultManager struct { + store instanceStore + embeddedIdpManager embeddedIdP + + setupRequired bool + setupMu sync.RWMutex + + // Version caching + httpClient *http.Client + versionMu sync.RWMutex + cachedVersions *VersionInfo + lastVersionFetch time.Time +} + +// NewManager creates a new instance manager. +// If idpManager is not an EmbeddedIdPManager, setup-related operations will return appropriate defaults. +func NewManager(ctx context.Context, store store.Store, idpManager idp.Manager) (Manager, error) { + embeddedIdp, ok := idpManager.(*idp.EmbeddedIdPManager) + + m := &DefaultManager{ + store: store, + setupRequired: false, + httpClient: &http.Client{ + Timeout: httpTimeout, + }, + } + + if ok && embeddedIdp != nil { + m.embeddedIdpManager = embeddedIdp + err := m.loadSetupRequired(ctx) + if err != nil { + return nil, err + } + } + + return m, nil +} + +func (m *DefaultManager) loadSetupRequired(ctx context.Context) error { + // Check if there are any accounts in the NetBird store + numAccounts, err := m.store.GetAccountsCounter(ctx) + if err != nil { + return err + } + hasAccounts := numAccounts > 0 + + // Check if there are any users in the embedded IdP (Dex) + users, err := m.embeddedIdpManager.GetAllAccounts(ctx) + if err != nil { + return err + } + hasLocalUsers := len(users) > 0 + + m.setupMu.Lock() + m.setupRequired = !(hasAccounts || hasLocalUsers) + m.setupMu.Unlock() + + return nil +} + +// IsSetupRequired checks if instance setup is required. +// Setup is required when: +// 1. Embedded IDP is enabled +// 2. No accounts exist in the store +func (m *DefaultManager) IsSetupRequired(_ context.Context) (bool, error) { + if m.embeddedIdpManager == nil { + return false, nil + } + + m.setupMu.RLock() + defer m.setupMu.RUnlock() + + return m.setupRequired, nil +} + +// CreateOwnerUser creates the initial owner user in the embedded IDP. +func (m *DefaultManager) CreateOwnerUser(ctx context.Context, email, password, name string) (*idp.UserData, error) { + + if m.embeddedIdpManager == nil { + return nil, errors.New("embedded IDP is not enabled") + } + + if err := m.validateSetupInfo(email, password, name); err != nil { + return nil, err + } + + m.setupMu.Lock() + defer m.setupMu.Unlock() + + if !m.setupRequired { + return nil, status.Errorf(status.PreconditionFailed, "setup already completed") + } + + if err := m.checkSetupRequiredFromDB(ctx); err != nil { + var sErr *status.Error + if errors.As(err, &sErr) && sErr.Type() == status.PreconditionFailed { + m.setupRequired = false + } + return nil, err + } + + userData, err := m.embeddedIdpManager.CreateUserWithPassword(ctx, email, password, name) + if err != nil { + return nil, fmt.Errorf("failed to create user in embedded IdP: %w", err) + } + + m.setupRequired = false + + log.WithContext(ctx).Infof("created owner user %s in embedded IdP", email) + + return userData, nil +} + +// RollbackSetup undoes a successful CreateOwnerUser: deletes the user from the +// embedded IDP and reloads setupRequired from persistent state. +func (m *DefaultManager) RollbackSetup(ctx context.Context, userID string) error { + if m.embeddedIdpManager == nil { + return errors.New("embedded IDP is not enabled") + } + + var deleteErr error + if err := m.embeddedIdpManager.DeleteUser(ctx, userID); err != nil { + if isNotFoundError(err) { + log.WithContext(ctx).Debugf("setup rollback user %s already deleted", userID) + } else { + deleteErr = fmt.Errorf("failed to delete user from embedded IdP: %w", err) + } + } + + if err := m.loadSetupRequired(ctx); err != nil { + reloadErr := fmt.Errorf("failed to reload setup state after rollback: %w", err) + if deleteErr != nil { + return errors.Join(deleteErr, reloadErr) + } + return reloadErr + } + + if deleteErr != nil { + return deleteErr + } + + log.WithContext(ctx).Infof("rolled back setup for user %s", userID) + return nil +} + +func isNotFoundError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, storage.ErrNotFound) { + return true + } + if s, ok := status.FromError(err); ok { + return s.Type() == status.NotFound + } + return false +} + +func (m *DefaultManager) checkSetupRequiredFromDB(ctx context.Context) error { + numAccounts, err := m.store.GetAccountsCounter(ctx) + if err != nil { + return fmt.Errorf("failed to check accounts: %w", err) + } + if numAccounts > 0 { + return status.Errorf(status.PreconditionFailed, "setup already completed") + } + + users, err := m.embeddedIdpManager.GetAllAccounts(ctx) + if err != nil { + return fmt.Errorf("failed to check IdP users: %w", err) + } + if len(users) > 0 { + return status.Errorf(status.PreconditionFailed, "setup already completed") + } + + return nil +} + +func (m *DefaultManager) validateSetupInfo(email, password, name string) error { + if email == "" { + return status.Errorf(status.InvalidArgument, "email is required") + } + if _, err := mail.ParseAddress(email); err != nil { + return status.Errorf(status.InvalidArgument, "invalid email format") + } + if name == "" { + return status.Errorf(status.InvalidArgument, "name is required") + } + if password == "" { + return status.Errorf(status.InvalidArgument, "password is required") + } + if len(password) < 8 { + return status.Errorf(status.InvalidArgument, "password must be at least 8 characters") + } + if len(password) > 72 { + return status.Errorf(status.InvalidArgument, "password must be at most 72 characters") + } + return nil +} + +// GetVersionInfo returns version information for NetBird components. +func (m *DefaultManager) GetVersionInfo(ctx context.Context) (*VersionInfo, error) { + m.versionMu.RLock() + if m.cachedVersions != nil && time.Since(m.lastVersionFetch) < versionCacheTTL { + cached := *m.cachedVersions + m.versionMu.RUnlock() + return &cached, nil + } + m.versionMu.RUnlock() + + return m.fetchVersionInfo(ctx) +} + +func (m *DefaultManager) fetchVersionInfo(ctx context.Context) (*VersionInfo, error) { + m.versionMu.Lock() + // Double-check after acquiring write lock + if m.cachedVersions != nil && time.Since(m.lastVersionFetch) < versionCacheTTL { + cached := *m.cachedVersions + m.versionMu.Unlock() + return &cached, nil + } + m.versionMu.Unlock() + + info := &VersionInfo{ + CurrentVersion: version.NetbirdVersion(), + } + + // Fetch management version from pkgs.netbird.io (plain text) + mgmtVersion, err := m.fetchPlainTextVersion(ctx, managementVersionURL) + if err != nil { + log.WithContext(ctx).Warnf("failed to fetch management version: %v", err) + } else { + info.ManagementVersion = mgmtVersion + info.ManagementUpdateAvailable = isNewerVersion(info.CurrentVersion, mgmtVersion) + } + + // Fetch dashboard version from GitHub + dashVersion, err := m.fetchGitHubRelease(ctx, dashboardReleasesURL) + if err != nil { + log.WithContext(ctx).Warnf("failed to fetch dashboard version from GitHub: %v", err) + } else { + info.DashboardVersion = dashVersion + } + + // Update cache + m.versionMu.Lock() + m.cachedVersions = info + m.lastVersionFetch = time.Now() + m.versionMu.Unlock() + + return info, nil +} + +// isNewerVersion returns true if latestVersion is greater than currentVersion +func isNewerVersion(currentVersion, latestVersion string) bool { + current, err := goversion.NewVersion(currentVersion) + if err != nil { + return false + } + + latest, err := goversion.NewVersion(latestVersion) + if err != nil { + return false + } + + return latest.GreaterThan(current) +} + +func (m *DefaultManager) fetchPlainTextVersion(ctx context.Context, url string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("create request: %w", err) + } + + req.Header.Set("User-Agent", "NetBird-Management/"+version.NetbirdVersion()) + + resp, err := m.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 100)) + if err != nil { + return "", fmt.Errorf("read response: %w", err) + } + + return strings.TrimSpace(string(body)), nil +} + +func (m *DefaultManager) fetchGitHubRelease(ctx context.Context, url string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("User-Agent", "NetBird-Management/"+version.NetbirdVersion()) + + resp, err := m.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var release githubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", fmt.Errorf("decode response: %w", err) + } + + // Remove 'v' prefix if present + tag := release.TagName + if len(tag) > 0 && tag[0] == 'v' { + tag = tag[1:] + } + + return tag, nil +} diff --git a/management/server/instance/manager_test.go b/management/server/instance/manager_test.go new file mode 100644 index 000000000..5ffb016de --- /dev/null +++ b/management/server/instance/manager_test.go @@ -0,0 +1,397 @@ +package instance + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/dexidp/dex/storage" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/shared/management/status" +) + +type mockIdP struct { + mu sync.Mutex + createUserFunc func(ctx context.Context, email, password, name string) (*idp.UserData, error) + deleteUserFunc func(ctx context.Context, userID string) error + users map[string][]*idp.UserData + getAllAccountsErr error +} + +func (m *mockIdP) CreateUserWithPassword(ctx context.Context, email, password, name string) (*idp.UserData, error) { + if m.createUserFunc != nil { + return m.createUserFunc(ctx, email, password, name) + } + return &idp.UserData{ID: "test-user-id", Email: email, Name: name}, nil +} + +func (m *mockIdP) DeleteUser(ctx context.Context, userID string) error { + if m.deleteUserFunc != nil { + return m.deleteUserFunc(ctx, userID) + } + return nil +} + +func (m *mockIdP) GetAllAccounts(_ context.Context) (map[string][]*idp.UserData, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.getAllAccountsErr != nil { + return nil, m.getAllAccountsErr + } + return m.users, nil +} + +type mockStore struct { + accountsCount int64 + err error +} + +func (m *mockStore) GetAccountsCounter(_ context.Context) (int64, error) { + if m.err != nil { + return 0, m.err + } + return m.accountsCount, nil +} + +func newTestManager(idpMock *mockIdP, storeMock *mockStore) *DefaultManager { + return &DefaultManager{ + store: storeMock, + embeddedIdpManager: idpMock, + setupRequired: true, + httpClient: &http.Client{Timeout: httpTimeout}, + } +} + +func TestCreateOwnerUser_Success(t *testing.T) { + idpMock := &mockIdP{} + mgr := newTestManager(idpMock, &mockStore{}) + + userData, err := mgr.CreateOwnerUser(context.Background(), "admin@example.com", "password123", "Admin") + require.NoError(t, err) + assert.Equal(t, "admin@example.com", userData.Email) + + _, err = mgr.CreateOwnerUser(context.Background(), "admin2@example.com", "password123", "Admin2") + require.Error(t, err) + assert.Contains(t, err.Error(), "setup already completed") +} + +func TestCreateOwnerUser_SetupAlreadyCompleted(t *testing.T) { + mgr := newTestManager(&mockIdP{}, &mockStore{}) + mgr.setupRequired = false + + _, err := mgr.CreateOwnerUser(context.Background(), "admin@example.com", "password123", "Admin") + require.Error(t, err) + assert.Contains(t, err.Error(), "setup already completed") +} + +func TestCreateOwnerUser_EmbeddedIdPDisabled(t *testing.T) { + mgr := &DefaultManager{setupRequired: true} + + _, err := mgr.CreateOwnerUser(context.Background(), "admin@example.com", "password123", "Admin") + require.Error(t, err) + assert.Contains(t, err.Error(), "embedded IDP is not enabled") +} + +func TestCreateOwnerUser_IdPError(t *testing.T) { + idpMock := &mockIdP{ + createUserFunc: func(_ context.Context, _, _, _ string) (*idp.UserData, error) { + return nil, errors.New("provider error") + }, + } + mgr := newTestManager(idpMock, &mockStore{}) + + _, err := mgr.CreateOwnerUser(context.Background(), "admin@example.com", "password123", "Admin") + require.Error(t, err) + assert.Contains(t, err.Error(), "provider error") + + required, _ := mgr.IsSetupRequired(context.Background()) + assert.True(t, required, "setup should still be required after IdP error") +} + +func TestCreateOwnerUser_TransientDBError_DoesNotBlockSetup(t *testing.T) { + mgr := newTestManager(&mockIdP{}, &mockStore{err: errors.New("connection refused")}) + + _, err := mgr.CreateOwnerUser(context.Background(), "admin@example.com", "password123", "Admin") + require.Error(t, err) + assert.Contains(t, err.Error(), "connection refused") + + required, _ := mgr.IsSetupRequired(context.Background()) + assert.True(t, required, "setup should still be required after transient DB error") + + mgr.store = &mockStore{} + userData, err := mgr.CreateOwnerUser(context.Background(), "admin@example.com", "password123", "Admin") + require.NoError(t, err) + assert.Equal(t, "admin@example.com", userData.Email) +} + +func TestCreateOwnerUser_TransientIdPError_DoesNotBlockSetup(t *testing.T) { + idpMock := &mockIdP{getAllAccountsErr: errors.New("connection reset")} + mgr := newTestManager(idpMock, &mockStore{}) + + _, err := mgr.CreateOwnerUser(context.Background(), "admin@example.com", "password123", "Admin") + require.Error(t, err) + assert.Contains(t, err.Error(), "connection reset") + + required, _ := mgr.IsSetupRequired(context.Background()) + assert.True(t, required, "setup should still be required after transient IdP error") + + idpMock.getAllAccountsErr = nil + userData, err := mgr.CreateOwnerUser(context.Background(), "admin@example.com", "password123", "Admin") + require.NoError(t, err) + assert.Equal(t, "admin@example.com", userData.Email) +} + +func TestCreateOwnerUser_DBCheckBlocksConcurrent(t *testing.T) { + idpMock := &mockIdP{ + users: map[string][]*idp.UserData{ + "acc1": {{ID: "existing-user"}}, + }, + } + mgr := newTestManager(idpMock, &mockStore{}) + + _, err := mgr.CreateOwnerUser(context.Background(), "admin@example.com", "password123", "Admin") + require.Error(t, err) + assert.Contains(t, err.Error(), "setup already completed") +} + +func TestCreateOwnerUser_DBCheckBlocksWhenAccountsExist(t *testing.T) { + mgr := newTestManager(&mockIdP{}, &mockStore{accountsCount: 1}) + + _, err := mgr.CreateOwnerUser(context.Background(), "admin@example.com", "password123", "Admin") + require.Error(t, err) + assert.Contains(t, err.Error(), "setup already completed") +} + +func TestCreateOwnerUser_ConcurrentRequests(t *testing.T) { + var idpCallCount atomic.Int32 + var successCount atomic.Int32 + var failCount atomic.Int32 + + idpMock := &mockIdP{ + createUserFunc: func(_ context.Context, email, _, _ string) (*idp.UserData, error) { + idpCallCount.Add(1) + time.Sleep(50 * time.Millisecond) + return &idp.UserData{ID: "user-1", Email: email, Name: "Owner"}, nil + }, + } + mgr := newTestManager(idpMock, &mockStore{}) + + var wg sync.WaitGroup + for i := range 10 { + wg.Add(1) + go func(idx int) { + defer wg.Done() + _, err := mgr.CreateOwnerUser( + context.Background(), + fmt.Sprintf("owner%d@example.com", idx), + "password1234", + fmt.Sprintf("Owner%d", idx), + ) + if err != nil { + failCount.Add(1) + } else { + successCount.Add(1) + } + }(i) + } + wg.Wait() + + assert.Equal(t, int32(1), successCount.Load(), "exactly one concurrent setup request should succeed") + assert.Equal(t, int32(9), failCount.Load(), "remaining concurrent requests should fail") + assert.Equal(t, int32(1), idpCallCount.Load(), "IdP CreateUser should be called exactly once") +} + +func TestIsSetupRequired_EmbeddedIdPDisabled(t *testing.T) { + mgr := &DefaultManager{} + + required, err := mgr.IsSetupRequired(context.Background()) + require.NoError(t, err) + assert.False(t, required) +} + +func TestIsSetupRequired_ReturnsFlag(t *testing.T) { + mgr := newTestManager(&mockIdP{}, &mockStore{}) + + required, err := mgr.IsSetupRequired(context.Background()) + require.NoError(t, err) + assert.True(t, required) + + mgr.setupMu.Lock() + mgr.setupRequired = false + mgr.setupMu.Unlock() + + required, err = mgr.IsSetupRequired(context.Background()) + require.NoError(t, err) + assert.False(t, required) +} + +func TestRollbackSetup_UserAlreadyDeletedIsSuccess(t *testing.T) { + tests := []struct { + name string + err error + }{ + { + name: "management status not found", + err: status.NewUserNotFoundError("owner-id"), + }, + { + name: "dex storage not found", + err: fmt.Errorf("failed to get user for deletion: %w", storage.ErrNotFound), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + idpMock := &mockIdP{ + deleteUserFunc: func(_ context.Context, userID string) error { + assert.Equal(t, "owner-id", userID) + return tt.err + }, + } + mgr := newTestManager(idpMock, &mockStore{}) + mgr.setupRequired = false + + err := mgr.RollbackSetup(context.Background(), "owner-id") + require.NoError(t, err) + + required, err := mgr.IsSetupRequired(context.Background()) + require.NoError(t, err) + assert.True(t, required, "setup should be required when no accounts or local users remain") + }) + } +} + +func TestRollbackSetup_RecomputesSetupStateWhenAccountStillExists(t *testing.T) { + idpMock := &mockIdP{ + deleteUserFunc: func(_ context.Context, _ string) error { + return status.NewUserNotFoundError("owner-id") + }, + } + mgr := newTestManager(idpMock, &mockStore{accountsCount: 1}) + mgr.setupRequired = true + + err := mgr.RollbackSetup(context.Background(), "owner-id") + require.NoError(t, err) + + required, err := mgr.IsSetupRequired(context.Background()) + require.NoError(t, err) + assert.False(t, required, "setup should not be required while an account still exists") +} + +func TestRollbackSetup_ReturnsDeleteErrorButReloadsSetupState(t *testing.T) { + idpMock := &mockIdP{ + deleteUserFunc: func(_ context.Context, _ string) error { + return errors.New("idp unavailable") + }, + } + mgr := newTestManager(idpMock, &mockStore{}) + mgr.setupRequired = false + + err := mgr.RollbackSetup(context.Background(), "owner-id") + require.Error(t, err) + assert.Contains(t, err.Error(), "idp unavailable") + + required, err := mgr.IsSetupRequired(context.Background()) + require.NoError(t, err) + assert.True(t, required, "setup state should be reloaded even when user deletion fails") +} + +func TestDefaultManager_ValidateSetupRequest(t *testing.T) { + manager := &DefaultManager{setupRequired: true} + + tests := []struct { + name string + email string + password string + userName string + expectError bool + errorMsg string + }{ + { + name: "valid request", + email: "admin@example.com", + password: "password123", + userName: "Admin User", + }, + { + name: "empty email", + email: "", + password: "password123", + userName: "Admin User", + expectError: true, + errorMsg: "email is required", + }, + { + name: "invalid email format", + email: "not-an-email", + password: "password123", + userName: "Admin User", + expectError: true, + errorMsg: "invalid email format", + }, + { + name: "empty name", + email: "admin@example.com", + password: "password123", + userName: "", + expectError: true, + errorMsg: "name is required", + }, + { + name: "empty password", + email: "admin@example.com", + password: "", + userName: "Admin User", + expectError: true, + errorMsg: "password is required", + }, + { + name: "password too short", + email: "admin@example.com", + password: "short", + userName: "Admin User", + expectError: true, + errorMsg: "password must be at least 8 characters", + }, + { + name: "password exactly 8 characters", + email: "admin@example.com", + password: "12345678", + userName: "Admin User", + }, + { + name: "password exactly 72 characters", + email: "admin@example.com", + password: "aaaaaaaabbbbbbbbccccccccddddddddeeeeeeeeffffffffgggggggghhhhhhhhiiiiiiii", + userName: "Admin User", + }, + { + name: "password too long", + email: "admin@example.com", + password: "aaaaaaaabbbbbbbbccccccccddddddddeeeeeeeeffffffffgggggggghhhhhhhhiiiiiiiij", + userName: "Admin User", + expectError: true, + errorMsg: "password must be at most 72 characters", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := manager.validateSetupInfo(tt.email, tt.password, tt.userName) + if tt.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/management/server/instance/setup_service.go b/management/server/instance/setup_service.go new file mode 100644 index 000000000..92a4923be --- /dev/null +++ b/management/server/instance/setup_service.go @@ -0,0 +1,216 @@ +package instance + +import ( + "context" + "fmt" + "os" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/management/server/account" + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/status" +) + +const ( + setupPATTokenName = "setup-token" + + // SetupPATEnabledEnvKey enables setup-time Personal Access Token creation. + SetupPATEnabledEnvKey = "NB_SETUP_PAT_ENABLED" + + setupPATDefaultExpireDays = 1 +) + +// SetupOptions controls optional work performed during initial instance setup. +type SetupOptions struct { + // CreatePAT requests creation of a setup Personal Access Token. It is honored + // only when SetupPATEnabledEnvKey is set to "true". + CreatePAT bool + // PATExpireInDays defaults to 1 day when CreatePAT is requested and setup PAT + // creation is enabled. + PATExpireInDays *int +} + +// SetupResult contains resources created during initial instance setup. +type SetupResult struct { + User *idp.UserData + PATPlainToken string +} + +// SetupService orchestrates the initial setup use case across the instance and +// account bounded contexts and owns the compensation logic when a later step +// fails. +type SetupService struct { + instanceManager Manager + accountManager account.Manager + setupPATEnabled bool +} + +// NewSetupService creates a setup use-case service. +func NewSetupService(instanceManager Manager, accountManager account.Manager) *SetupService { + return &SetupService{ + instanceManager: instanceManager, + accountManager: accountManager, + setupPATEnabled: os.Getenv(SetupPATEnabledEnvKey) == "true", + } +} + +func normalizeSetupOptions(opts SetupOptions, setupPATEnabled bool) (SetupOptions, error) { + if !opts.CreatePAT { + return opts, nil + } + + if !setupPATEnabled { + opts.CreatePAT = false + opts.PATExpireInDays = nil + return opts, nil + } + + if opts.PATExpireInDays == nil { + defaultExpireInDays := setupPATDefaultExpireDays + opts.PATExpireInDays = &defaultExpireInDays + } + + if *opts.PATExpireInDays < account.PATMinExpireDays || *opts.PATExpireInDays > account.PATMaxExpireDays { + return opts, status.Errorf(status.InvalidArgument, "pat_expire_in must be between %d and %d", account.PATMinExpireDays, account.PATMaxExpireDays) + } + + return opts, nil +} + +// SetupOwner creates the initial owner user and, when requested and enabled by +// SetupPATEnabledEnvKey, provisions the account and a setup Personal Access +// Token. If account or PAT provisioning fails, created resources are rolled +// back so setup can be retried. If account rollback fails, user rollback is +// skipped to avoid leaving an account without its owner user. +func (m *SetupService) SetupOwner(ctx context.Context, email, password, name string, opts SetupOptions) (*SetupResult, error) { + opts, err := normalizeSetupOptions(opts, m.setupPATEnabled) + if err != nil { + return nil, err + } + + if opts.CreatePAT && m.accountManager == nil { + return nil, fmt.Errorf("account manager is required to create setup PAT") + } + + userData, err := m.instanceManager.CreateOwnerUser(ctx, email, password, name) + if err != nil { + return nil, err + } + + result := &SetupResult{User: userData} + if !opts.CreatePAT { + return result, nil + } + + userAuth := auth.UserAuth{ + UserId: userData.ID, + Email: userData.Email, + Name: userData.Name, + } + + accountID, err := m.accountManager.GetAccountIDByUserID(ctx, userAuth) + if err != nil { + err = fmt.Errorf("create account for setup user: %w", err) + if rollbackErr := m.rollbackSetup(ctx, userData.ID, "account provisioning failed", err, ""); rollbackErr != nil { + return nil, fmt.Errorf("%w; failed to roll back setup resources: %v", err, rollbackErr) + } + return nil, err + } + + pat, err := m.accountManager.CreatePAT(ctx, accountID, userData.ID, userData.ID, setupPATTokenName, *opts.PATExpireInDays) + if err != nil { + err = fmt.Errorf("create setup PAT: %w", err) + if rollbackErr := m.rollbackSetup(ctx, userData.ID, "setup PAT provisioning failed", err, accountID); rollbackErr != nil { + return nil, fmt.Errorf("%w; failed to roll back setup resources: %v", err, rollbackErr) + } + return nil, err + } + + result.PATPlainToken = pat.PlainToken + return result, nil +} + +func (m *SetupService) rollbackSetup(ctx context.Context, userID, reason string, origErr error, accountID string) error { + if accountID == "" { + resolvedAccountID, err := m.lookupSetupAccountIDForRollback(ctx, userID) + if err != nil { + rollbackErr := fmt.Errorf("resolve setup account for rollback: %w", err) + log.WithContext(ctx).Errorf("failed to resolve setup account for user %s after %s: original error: %v, rollback error: %v", userID, reason, origErr, rollbackErr) + return rollbackErr + } + accountID = resolvedAccountID + } + + if accountID != "" { + if err := m.rollbackSetupAccount(ctx, accountID); err != nil { + rollbackErr := fmt.Errorf("roll back setup account %s: %w", accountID, err) + log.WithContext(ctx).Errorf("failed to roll back setup account %s for user %s after %s: original error: %v, rollback error: %v", accountID, userID, reason, origErr, rollbackErr) + return rollbackErr + } + log.WithContext(ctx).Warnf("rolled back setup account %s for user %s after %s: %v", accountID, userID, reason, origErr) + } + + if err := m.instanceManager.RollbackSetup(ctx, userID); err != nil { + rollbackErr := fmt.Errorf("roll back setup user %s: %w", userID, err) + log.WithContext(ctx).Errorf("failed to roll back setup user %s after %s: original error: %v, rollback error: %v", userID, reason, origErr, rollbackErr) + return rollbackErr + } + log.WithContext(ctx).Warnf("rolled back setup user %s after %s: %v", userID, reason, origErr) + return nil +} + +func (m *SetupService) lookupSetupAccountIDForRollback(ctx context.Context, userID string) (string, error) { + if m.accountManager == nil { + return "", fmt.Errorf("account manager is required to resolve setup account") + } + + accountStore := m.accountManager.GetStore() + if accountStore == nil { + return "", fmt.Errorf("account store is unavailable") + } + + accountID, err := accountStore.GetAccountIDByUserID(ctx, store.LockingStrengthNone, userID) + if err != nil { + if isNotFoundError(err) { + return "", nil + } + return "", fmt.Errorf("get setup account ID for rollback: %w", err) + } + + return accountID, nil +} + +// rollbackSetupAccount removes only the setup-created account data from the +// store. It intentionally avoids accountManager.DeleteAccount because the normal +// account deletion path also deletes users from the IdP; embedded IdP cleanup is +// owned by instanceManager.RollbackSetup. +func (m *SetupService) rollbackSetupAccount(ctx context.Context, accountID string) error { + if m.accountManager == nil { + return fmt.Errorf("account manager is required to roll back setup account") + } + + accountStore := m.accountManager.GetStore() + if accountStore == nil { + return fmt.Errorf("account store is unavailable") + } + + account, err := accountStore.GetAccount(ctx, accountID) + if err != nil { + if isNotFoundError(err) { + return nil + } + return fmt.Errorf("get setup account for rollback: %w", err) + } + + if err := accountStore.DeleteAccount(ctx, account); err != nil { + if isNotFoundError(err) { + return nil + } + return fmt.Errorf("delete setup account for rollback: %w", err) + } + + return nil +} diff --git a/management/server/instance/setup_service_test.go b/management/server/instance/setup_service_test.go new file mode 100644 index 000000000..12ec7d0fa --- /dev/null +++ b/management/server/instance/setup_service_test.go @@ -0,0 +1,318 @@ +package instance + +import ( + "context" + "errors" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/mock_server" + nbstore "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/auth" + "github.com/netbirdio/netbird/shared/management/status" +) + +type setupInstanceManagerMock struct { + createOwnerUserFn func(ctx context.Context, email, password, name string) (*idp.UserData, error) + rollbackSetupFn func(ctx context.Context, userID string) error +} + +func (m *setupInstanceManagerMock) IsSetupRequired(context.Context) (bool, error) { + return true, nil +} + +func (m *setupInstanceManagerMock) CreateOwnerUser(ctx context.Context, email, password, name string) (*idp.UserData, error) { + if m.createOwnerUserFn != nil { + return m.createOwnerUserFn(ctx, email, password, name) + } + return &idp.UserData{ID: "owner-id", Email: email, Name: name}, nil +} + +func (m *setupInstanceManagerMock) RollbackSetup(ctx context.Context, userID string) error { + if m.rollbackSetupFn != nil { + return m.rollbackSetupFn(ctx, userID) + } + return nil +} + +func (m *setupInstanceManagerMock) GetVersionInfo(context.Context) (*VersionInfo, error) { + return &VersionInfo{}, nil +} + +var _ Manager = (*setupInstanceManagerMock)(nil) + +func intPtr(v int) *int { + return &v +} + +func TestSetupOwner_PATFeatureDisabled_IgnoresCreatePAT(t *testing.T) { + t.Setenv(SetupPATEnabledEnvKey, "false") + + createCalls := 0 + setupManager := NewSetupService( + &setupInstanceManagerMock{ + createOwnerUserFn: func(_ context.Context, email, _, name string) (*idp.UserData, error) { + createCalls++ + return &idp.UserData{ID: "owner-id", Email: email, Name: name}, nil + }, + }, + &mock_server.MockAccountManager{}, + ) + + result, err := setupManager.SetupOwner(context.Background(), "admin@example.com", "securepassword123", "Admin", SetupOptions{ + CreatePAT: true, + }) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "owner-id", result.User.ID) + assert.Empty(t, result.PATPlainToken) + assert.Equal(t, 1, createCalls) +} + +func TestSetupOwner_PATFeatureEnabled_MissingExpireDefaultsToOneDay(t *testing.T) { + t.Setenv(SetupPATEnabledEnvKey, "true") + + createCalled := false + setupManager := NewSetupService( + &setupInstanceManagerMock{ + createOwnerUserFn: func(_ context.Context, email, _, name string) (*idp.UserData, error) { + createCalled = true + return &idp.UserData{ID: "owner-id", Email: email, Name: name}, nil + }, + }, + &mock_server.MockAccountManager{ + GetAccountIDByUserIdFunc: func(_ context.Context, userAuth auth.UserAuth) (string, error) { + assert.Equal(t, "owner-id", userAuth.UserId) + return "acc-1", nil + }, + CreatePATFunc: func(_ context.Context, accountID, initiatorUserID, targetUserID, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) { + assert.Equal(t, "acc-1", accountID) + assert.Equal(t, "owner-id", initiatorUserID) + assert.Equal(t, "owner-id", targetUserID) + assert.Equal(t, setupPATTokenName, tokenName) + assert.Equal(t, setupPATDefaultExpireDays, expiresIn) + return &types.PersonalAccessTokenGenerated{PlainToken: "nbp_plain"}, nil + }, + }, + ) + + result, err := setupManager.SetupOwner(context.Background(), "admin@example.com", "securepassword123", "Admin", SetupOptions{ + CreatePAT: true, + }) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, createCalled) + assert.Equal(t, "nbp_plain", result.PATPlainToken) +} + +func TestSetupOwner_PATFeatureEnabled_MissingAccountManagerFailsBeforeCreateUser(t *testing.T) { + t.Setenv(SetupPATEnabledEnvKey, "true") + + createCalled := false + rollbackCalled := false + setupManager := NewSetupService( + &setupInstanceManagerMock{ + createOwnerUserFn: func(_ context.Context, email, _, name string) (*idp.UserData, error) { + createCalled = true + return &idp.UserData{ID: "owner-id", Email: email, Name: name}, nil + }, + rollbackSetupFn: func(_ context.Context, _ string) error { + rollbackCalled = true + return nil + }, + }, + nil, + ) + + result, err := setupManager.SetupOwner(context.Background(), "admin@example.com", "securepassword123", "Admin", SetupOptions{ + CreatePAT: true, + }) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "account manager is required") + assert.False(t, createCalled) + assert.False(t, rollbackCalled) +} + +func TestSetupOwner_AccountProvisioningFails_RollsBackSideEffectAccountAndUser(t *testing.T) { + t.Setenv(SetupPATEnabledEnvKey, "true") + + ctrl := gomock.NewController(t) + accountStore := nbstore.NewMockStore(ctrl) + account := &types.Account{Id: "acc-1"} + accountStore.EXPECT().GetAccountIDByUserID(gomock.Any(), nbstore.LockingStrengthNone, "owner-id").Return("acc-1", nil) + accountStore.EXPECT().GetAccount(gomock.Any(), "acc-1").Return(account, nil) + accountStore.EXPECT().DeleteAccount(gomock.Any(), account).Return(nil) + + rolledBackFor := "" + rollbackCalls := 0 + setupManager := NewSetupService( + &setupInstanceManagerMock{ + rollbackSetupFn: func(_ context.Context, userID string) error { + rollbackCalls++ + rolledBackFor = userID + return nil + }, + }, + &mock_server.MockAccountManager{ + GetAccountIDByUserIdFunc: func(_ context.Context, userAuth auth.UserAuth) (string, error) { + assert.Equal(t, "owner-id", userAuth.UserId) + return "", errors.New("metadata update failed") + }, + GetStoreFunc: func() nbstore.Store { + return accountStore + }, + }, + ) + + result, err := setupManager.SetupOwner(context.Background(), "admin@example.com", "securepassword123", "Admin", SetupOptions{ + CreatePAT: true, + PATExpireInDays: intPtr(30), + }) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "create account for setup user") + assert.Equal(t, "owner-id", rolledBackFor) + assert.Equal(t, 1, rollbackCalls) +} + +func TestSetupOwner_CreatePATFails_RollsBackSetupAccountAndUser(t *testing.T) { + t.Setenv(SetupPATEnabledEnvKey, "true") + + ctrl := gomock.NewController(t) + accountStore := nbstore.NewMockStore(ctrl) + account := &types.Account{Id: "acc-1"} + accountStore.EXPECT().GetAccount(gomock.Any(), "acc-1").Return(account, nil) + accountStore.EXPECT().DeleteAccount(gomock.Any(), account).Return(nil) + + rollbackCalls := 0 + setupManager := NewSetupService( + &setupInstanceManagerMock{ + rollbackSetupFn: func(_ context.Context, userID string) error { + rollbackCalls++ + assert.Equal(t, "owner-id", userID) + return nil + }, + }, + &mock_server.MockAccountManager{ + GetAccountIDByUserIdFunc: func(_ context.Context, userAuth auth.UserAuth) (string, error) { + assert.Equal(t, "owner-id", userAuth.UserId) + return "acc-1", nil + }, + CreatePATFunc: func(_ context.Context, accountID, initiatorUserID, targetUserID, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) { + assert.Equal(t, "acc-1", accountID) + assert.Equal(t, "owner-id", initiatorUserID) + assert.Equal(t, "owner-id", targetUserID) + assert.Equal(t, setupPATTokenName, tokenName) + assert.Equal(t, 30, expiresIn) + return nil, status.Errorf(status.Internal, "token store unavailable") + }, + GetStoreFunc: func() nbstore.Store { + return accountStore + }, + }, + ) + + result, err := setupManager.SetupOwner(context.Background(), "admin@example.com", "securepassword123", "Admin", SetupOptions{ + CreatePAT: true, + PATExpireInDays: intPtr(30), + }) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "create setup PAT") + assert.Equal(t, 1, rollbackCalls) +} + +func TestSetupOwner_CreatePATFails_AccountAlreadyGoneStillRollsBackUser(t *testing.T) { + t.Setenv(SetupPATEnabledEnvKey, "true") + + ctrl := gomock.NewController(t) + accountStore := nbstore.NewMockStore(ctrl) + accountStore.EXPECT().GetAccount(gomock.Any(), "acc-1").Return(nil, status.NewAccountNotFoundError("acc-1")) + + rolledBackFor := "" + rollbackCalls := 0 + setupManager := NewSetupService( + &setupInstanceManagerMock{ + rollbackSetupFn: func(_ context.Context, userID string) error { + rollbackCalls++ + rolledBackFor = userID + return nil + }, + }, + &mock_server.MockAccountManager{ + GetAccountIDByUserIdFunc: func(_ context.Context, _ auth.UserAuth) (string, error) { + return "acc-1", nil + }, + CreatePATFunc: func(_ context.Context, _, _, _, _ string, _ int) (*types.PersonalAccessTokenGenerated, error) { + return nil, errors.New("token failure") + }, + GetStoreFunc: func() nbstore.Store { + return accountStore + }, + }, + ) + + result, err := setupManager.SetupOwner(context.Background(), "admin@example.com", "securepassword123", "Admin", SetupOptions{ + CreatePAT: true, + PATExpireInDays: intPtr(30), + }) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "create setup PAT") + assert.Equal(t, "owner-id", rolledBackFor) + assert.Equal(t, 1, rollbackCalls) +} + +func TestSetupOwner_CreatePATFails_AccountRollbackFailureStopsBeforeUserRollback(t *testing.T) { + t.Setenv(SetupPATEnabledEnvKey, "true") + + ctrl := gomock.NewController(t) + accountStore := nbstore.NewMockStore(ctrl) + account := &types.Account{Id: "acc-1"} + accountStore.EXPECT().GetAccount(gomock.Any(), "acc-1").Return(account, nil) + accountStore.EXPECT().DeleteAccount(gomock.Any(), account).Return(errors.New("delete failed")) + + rollbackCalls := 0 + setupManager := NewSetupService( + &setupInstanceManagerMock{ + rollbackSetupFn: func(_ context.Context, userID string) error { + rollbackCalls++ + return nil + }, + }, + &mock_server.MockAccountManager{ + GetAccountIDByUserIdFunc: func(_ context.Context, _ auth.UserAuth) (string, error) { + return "acc-1", nil + }, + CreatePATFunc: func(_ context.Context, _, _, _, _ string, _ int) (*types.PersonalAccessTokenGenerated, error) { + return nil, errors.New("token failure") + }, + GetStoreFunc: func() nbstore.Store { + return accountStore + }, + }, + ) + + result, err := setupManager.SetupOwner(context.Background(), "admin@example.com", "securepassword123", "Admin", SetupOptions{ + CreatePAT: true, + PATExpireInDays: intPtr(30), + }) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "create setup PAT") + assert.Contains(t, err.Error(), "failed to roll back setup resources") + assert.Equal(t, 0, rollbackCalls) +} diff --git a/management/server/instance/version_test.go b/management/server/instance/version_test.go new file mode 100644 index 000000000..35ba66db8 --- /dev/null +++ b/management/server/instance/version_test.go @@ -0,0 +1,285 @@ +package instance + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockRoundTripper implements http.RoundTripper for testing +type mockRoundTripper struct { + callCount atomic.Int32 + managementVersion string + dashboardVersion string +} + +func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + m.callCount.Add(1) + + var body string + if strings.Contains(req.URL.String(), "pkgs.netbird.io") { + // Plain text response for management version + body = m.managementVersion + } else if strings.Contains(req.URL.String(), "github.com") { + // JSON response for dashboard version + jsonResp, _ := json.Marshal(githubRelease{TagName: "v" + m.dashboardVersion}) + body = string(jsonResp) + } + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(body)), + Header: make(http.Header), + }, nil +} + +func TestDefaultManager_GetVersionInfo_ReturnsCurrentVersion(t *testing.T) { + mockTransport := &mockRoundTripper{ + managementVersion: "0.65.0", + dashboardVersion: "2.10.0", + } + + m := &DefaultManager{ + httpClient: &http.Client{Transport: mockTransport}, + } + + ctx := context.Background() + + info, err := m.GetVersionInfo(ctx) + require.NoError(t, err) + + // CurrentVersion should always be set + assert.NotEmpty(t, info.CurrentVersion) + assert.Equal(t, "0.65.0", info.ManagementVersion) + assert.Equal(t, "2.10.0", info.DashboardVersion) + assert.Equal(t, int32(2), mockTransport.callCount.Load()) // 2 calls: management + dashboard +} + +func TestDefaultManager_GetVersionInfo_CachesResults(t *testing.T) { + mockTransport := &mockRoundTripper{ + managementVersion: "0.65.0", + dashboardVersion: "2.10.0", + } + + m := &DefaultManager{ + httpClient: &http.Client{Transport: mockTransport}, + } + + ctx := context.Background() + + // First call + info1, err := m.GetVersionInfo(ctx) + require.NoError(t, err) + assert.NotEmpty(t, info1.CurrentVersion) + assert.Equal(t, "0.65.0", info1.ManagementVersion) + + initialCallCount := mockTransport.callCount.Load() + + // Second call should use cache (no additional HTTP calls) + info2, err := m.GetVersionInfo(ctx) + require.NoError(t, err) + assert.Equal(t, info1.CurrentVersion, info2.CurrentVersion) + assert.Equal(t, info1.ManagementVersion, info2.ManagementVersion) + assert.Equal(t, info1.DashboardVersion, info2.DashboardVersion) + + // Verify no additional HTTP calls were made (cache was used) + assert.Equal(t, initialCallCount, mockTransport.callCount.Load()) +} + +func TestDefaultManager_FetchGitHubRelease_ParsesTagName(t *testing.T) { + tests := []struct { + name string + tagName string + expected string + shouldError bool + }{ + { + name: "tag with v prefix", + tagName: "v1.2.3", + expected: "1.2.3", + }, + { + name: "tag without v prefix", + tagName: "1.2.3", + expected: "1.2.3", + }, + { + name: "tag with prerelease", + tagName: "v2.0.0-beta.1", + expected: "2.0.0-beta.1", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(githubRelease{TagName: tc.tagName}) + })) + defer server.Close() + + m := &DefaultManager{ + httpClient: &http.Client{Timeout: 5 * time.Second}, + } + + version, err := m.fetchGitHubRelease(context.Background(), server.URL) + + if tc.shouldError { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expected, version) + } + }) + } +} + +func TestDefaultManager_FetchGitHubRelease_HandlesErrors(t *testing.T) { + tests := []struct { + name string + statusCode int + body string + }{ + { + name: "not found", + statusCode: http.StatusNotFound, + body: `{"message": "Not Found"}`, + }, + { + name: "rate limited", + statusCode: http.StatusForbidden, + body: `{"message": "API rate limit exceeded"}`, + }, + { + name: "server error", + statusCode: http.StatusInternalServerError, + body: `{"message": "Internal Server Error"}`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tc.statusCode) + _, _ = w.Write([]byte(tc.body)) + })) + defer server.Close() + + m := &DefaultManager{ + httpClient: &http.Client{Timeout: 5 * time.Second}, + } + + _, err := m.fetchGitHubRelease(context.Background(), server.URL) + assert.Error(t, err) + }) + } +} + +func TestDefaultManager_FetchGitHubRelease_InvalidJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{invalid json}`)) + })) + defer server.Close() + + m := &DefaultManager{ + httpClient: &http.Client{Timeout: 5 * time.Second}, + } + + _, err := m.fetchGitHubRelease(context.Background(), server.URL) + assert.Error(t, err) +} + +func TestDefaultManager_FetchGitHubRelease_ContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(1 * time.Second) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(githubRelease{TagName: "v1.0.0"}) + })) + defer server.Close() + + m := &DefaultManager{ + httpClient: &http.Client{Timeout: 5 * time.Second}, + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + _, err := m.fetchGitHubRelease(ctx, server.URL) + assert.Error(t, err) +} + +func TestIsNewerVersion(t *testing.T) { + tests := []struct { + name string + currentVersion string + latestVersion string + expected bool + }{ + { + name: "latest is newer - minor version", + currentVersion: "0.64.1", + latestVersion: "0.65.0", + expected: true, + }, + { + name: "latest is newer - patch version", + currentVersion: "0.64.1", + latestVersion: "0.64.2", + expected: true, + }, + { + name: "latest is newer - major version", + currentVersion: "0.64.1", + latestVersion: "1.0.0", + expected: true, + }, + { + name: "versions are equal", + currentVersion: "0.64.1", + latestVersion: "0.64.1", + expected: false, + }, + { + name: "current is newer - minor version", + currentVersion: "0.65.0", + latestVersion: "0.64.1", + expected: false, + }, + { + name: "current is newer - patch version", + currentVersion: "0.64.2", + latestVersion: "0.64.1", + expected: false, + }, + { + name: "development version", + currentVersion: "development", + latestVersion: "0.65.0", + expected: false, + }, + { + name: "invalid latest version", + currentVersion: "0.64.1", + latestVersion: "invalid", + expected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := isNewerVersion(tc.currentVersion, tc.latestVersion) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/management/server/integrated_validator.go b/management/server/integrated_validator.go index e9a1c8701..69ea668ad 100644 --- a/management/server/integrated_validator.go +++ b/management/server/integrated_validator.go @@ -127,7 +127,7 @@ type MockIntegratedValidator struct { ValidatePeerFunc func(_ context.Context, update *nbpeer.Peer, peer *nbpeer.Peer, userID string, accountID string, dnsDomain string, peersGroup []string, extraSettings *types.ExtraSettings) (*nbpeer.Peer, bool, error) } -func (a MockIntegratedValidator) ValidateExtraSettings(_ context.Context, newExtraSettings *types.ExtraSettings, oldExtraSettings *types.ExtraSettings, peers map[string]*nbpeer.Peer, userID string, accountID string) error { +func (a MockIntegratedValidator) ValidateExtraSettings(_ context.Context, newExtraSettings *types.ExtraSettings, oldExtraSettings *types.ExtraSettings, userID string, accountID string) error { return nil } diff --git a/management/server/integrations/integrated_validator/interface.go b/management/server/integrations/integrated_validator/interface.go index 26c338cb6..326fbfaf0 100644 --- a/management/server/integrations/integrated_validator/interface.go +++ b/management/server/integrations/integrated_validator/interface.go @@ -10,7 +10,7 @@ import ( // IntegratedValidator interface exists to avoid the circle dependencies type IntegratedValidator interface { - ValidateExtraSettings(ctx context.Context, newExtraSettings *types.ExtraSettings, oldExtraSettings *types.ExtraSettings, peers map[string]*nbpeer.Peer, userID string, accountID string) error + ValidateExtraSettings(ctx context.Context, newExtraSettings *types.ExtraSettings, oldExtraSettings *types.ExtraSettings, userID string, accountID string) error ValidatePeer(ctx context.Context, update *nbpeer.Peer, peer *nbpeer.Peer, userID string, accountID string, dnsDomain string, peersGroup []string, extraSettings *types.ExtraSettings) (*nbpeer.Peer, bool, error) PreparePeer(ctx context.Context, accountID string, peer *nbpeer.Peer, peersGroup []string, extraSettings *types.ExtraSettings, temporary bool) *nbpeer.Peer IsNotValidPeer(ctx context.Context, accountID string, peer *nbpeer.Peer, peersGroup []string, extraSettings *types.ExtraSettings) (bool, bool, error) diff --git a/management/server/integrations/integrated_validator/validator/validator.go b/management/server/integrations/integrated_validator/validator/validator.go new file mode 100644 index 000000000..db1d34373 --- /dev/null +++ b/management/server/integrations/integrated_validator/validator/validator.go @@ -0,0 +1,62 @@ +package validator + +import ( + "context" + + cachestore "github.com/eko/gocache/lib/v4/store" + + "github.com/netbirdio/netbird/management/internals/modules/peers" + "github.com/netbirdio/netbird/management/server/activity" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/settings" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +type IntegratedValidatorImpl struct{} + +func NewIntegratedValidator(_ context.Context, _ peers.Manager, _ settings.Manager, _ activity.Store, _ cachestore.StoreInterface) (*IntegratedValidatorImpl, error) { + return &IntegratedValidatorImpl{}, nil +} + +func (v *IntegratedValidatorImpl) ValidateExtraSettings(context.Context, *types.ExtraSettings, *types.ExtraSettings, string, string) error { + return nil +} + +func (v *IntegratedValidatorImpl) ValidatePeer(_ context.Context, update *nbpeer.Peer, _ *nbpeer.Peer, _ string, _ string, _ string, _ []string, _ *types.ExtraSettings) (*nbpeer.Peer, bool, error) { + return update, false, nil +} + +func (v *IntegratedValidatorImpl) PreparePeer(_ context.Context, _ string, peer *nbpeer.Peer, _ []string, _ *types.ExtraSettings, _ bool) *nbpeer.Peer { + return peer.Copy() +} + +func (v *IntegratedValidatorImpl) IsNotValidPeer(_ context.Context, _ string, _ *nbpeer.Peer, _ []string, _ *types.ExtraSettings) (bool, bool, error) { + return false, false, nil +} + +func (v *IntegratedValidatorImpl) GetValidatedPeers(_ context.Context, _ string, _ []*types.Group, peers []*nbpeer.Peer, _ *types.ExtraSettings) (map[string]struct{}, error) { + validatedPeers := make(map[string]struct{}) + for _, p := range peers { + validatedPeers[p.ID] = struct{}{} + } + return validatedPeers, nil +} + +func (v *IntegratedValidatorImpl) GetInvalidPeers(_ context.Context, _ string, _ *types.ExtraSettings) (map[string]string, error) { + return make(map[string]string), nil +} + +func (v *IntegratedValidatorImpl) PeerDeleted(_ context.Context, _, _ string, _ *types.ExtraSettings) error { + return nil +} + +func (v *IntegratedValidatorImpl) SetPeerInvalidationListener(_ func(accountID string, peerIDs []string)) { +} + +func (v *IntegratedValidatorImpl) Stop(_ context.Context) { +} + +func (v *IntegratedValidatorImpl) ValidateFlowResponse(_ context.Context, _ string, flowResponse *proto.PKCEAuthorizationFlow) *proto.PKCEAuthorizationFlow { + return flowResponse +} diff --git a/management/server/job/channel.go b/management/server/job/channel.go new file mode 100644 index 000000000..c4454c4c9 --- /dev/null +++ b/management/server/job/channel.go @@ -0,0 +1,65 @@ +package job + +import ( + "context" + "errors" + "fmt" + "sync" + "time" +) + +// todo consider the channel buffer size when we allow to run multiple jobs +const jobChannelBuffer = 1 + +var ( + ErrJobChannelClosed = errors.New("job channel closed") +) + +type Channel struct { + events chan *Event + once sync.Once +} + +func NewChannel() *Channel { + jc := &Channel{ + events: make(chan *Event, jobChannelBuffer), + } + + return jc +} + +func (jc *Channel) AddEvent(ctx context.Context, responseWait time.Duration, event *Event) (err error) { + defer func() { + if r := recover(); r != nil { + err = ErrJobChannelClosed + } + }() + + select { + case <-ctx.Done(): + return ctx.Err() + // todo: timeout is handled in the wrong place. If the peer does not respond with the job response, the server does not clean it up from the pending jobs and cannot apply a new job + case <-time.After(responseWait): + return fmt.Errorf("failed to add the event to the channel") + case jc.events <- event: + } + return nil +} + +func (jc *Channel) Close() { + jc.once.Do(func() { + close(jc.events) + }) +} + +func (jc *Channel) Event(ctx context.Context) (*Event, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case job, open := <-jc.events: + if !open { + return nil, ErrJobChannelClosed + } + return job, nil + } +} diff --git a/management/server/job/manager.go b/management/server/job/manager.go new file mode 100644 index 000000000..0b183ac39 --- /dev/null +++ b/management/server/job/manager.go @@ -0,0 +1,182 @@ +package job + +import ( + "context" + "fmt" + "sync" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/management/internals/modules/peers" + "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/shared/management/proto" +) + +type Event struct { + PeerID string + Request *proto.JobRequest + Response *proto.JobResponse +} + +type Manager struct { + mu *sync.RWMutex + jobChannels map[string]*Channel // per-peer job streams + pending map[string]*Event // jobID → event + responseWait time.Duration + metrics telemetry.AppMetrics + Store store.Store + peersManager peers.Manager +} + +func NewJobManager(metrics telemetry.AppMetrics, store store.Store, peersManager peers.Manager) *Manager { + + return &Manager{ + jobChannels: make(map[string]*Channel), + pending: make(map[string]*Event), + responseWait: 5 * time.Minute, + metrics: metrics, + mu: &sync.RWMutex{}, + Store: store, + peersManager: peersManager, + } +} + +// CreateJobChannel creates or replaces a channel for a peer +func (jm *Manager) CreateJobChannel(ctx context.Context, accountID, peerID string) *Channel { + // all pending jobs stored in db for this peer should be failed + if err := jm.Store.MarkAllPendingJobsAsFailed(ctx, accountID, peerID, "Pending job cleanup: marked as failed automatically due to being stuck too long"); err != nil { + log.WithContext(ctx).Error(err.Error()) + } + + jm.mu.Lock() + defer jm.mu.Unlock() + + if ch, ok := jm.jobChannels[peerID]; ok { + ch.Close() + delete(jm.jobChannels, peerID) + } + + ch := NewChannel() + jm.jobChannels[peerID] = ch + return ch +} + +// SendJob sends a job to a peer and tracks it as pending +func (jm *Manager) SendJob(ctx context.Context, accountID, peerID string, req *proto.JobRequest) error { + jm.mu.RLock() + ch, ok := jm.jobChannels[peerID] + jm.mu.RUnlock() + if !ok { + return fmt.Errorf("peer %s has no channel", peerID) + } + + event := &Event{ + PeerID: peerID, + Request: req, + } + + jm.mu.Lock() + jm.pending[string(req.ID)] = event + jm.mu.Unlock() + + if err := ch.AddEvent(ctx, jm.responseWait, event); err != nil { + jm.cleanup(ctx, accountID, string(req.ID), err.Error()) + return err + } + + return nil +} + +// HandleResponse marks a job as finished and moves it to completed +func (jm *Manager) HandleResponse(ctx context.Context, resp *proto.JobResponse, peerKey string) error { + jm.mu.Lock() + defer jm.mu.Unlock() + + // todo: validate job ID and would be nice to use uuid text marshal instead of string + jobID := string(resp.ID) + + // todo: in this map has jobs for all peers in any account. Consider to validate the jobID association for the peer + event, ok := jm.pending[jobID] + if !ok { + return fmt.Errorf("job %s not found", jobID) + } + var job types.Job + // todo: ApplyResponse should be static. Any member value is unusable in this way + if err := job.ApplyResponse(resp); err != nil { + return fmt.Errorf("invalid job response: %v", err) + } + + peerID, err := jm.peersManager.GetPeerID(ctx, peerKey) + if err != nil { + return fmt.Errorf("failed to get peer ID: %v", err) + } + if peerID != event.PeerID { + return fmt.Errorf("peer ID mismatch: %s != %s", peerID, event.PeerID) + } + + // update or create the store for job response + err = jm.Store.CompletePeerJob(ctx, &job) + if err != nil { + return fmt.Errorf("failed to complete job %s: %v", jobID, err) + } + + delete(jm.pending, jobID) + return nil +} + +// CloseChannel closes a peer’s channel and cleans up its jobs +func (jm *Manager) CloseChannel(ctx context.Context, accountID, peerID string) { + jm.mu.Lock() + defer jm.mu.Unlock() + + if ch, ok := jm.jobChannels[peerID]; ok { + ch.Close() + delete(jm.jobChannels, peerID) + } + + for jobID, ev := range jm.pending { + if ev.PeerID == peerID { + // if the client disconnect and there is pending job then mark it as failed + if err := jm.Store.MarkPendingJobsAsFailed(ctx, accountID, peerID, jobID, "Time out peer disconnected"); err != nil { + log.WithContext(ctx).Errorf("failed to mark pending jobs as failed: %v", err) + } + delete(jm.pending, jobID) + } + } +} + +// cleanup removes a pending job safely +func (jm *Manager) cleanup(ctx context.Context, accountID, jobID string, reason string) { + jm.mu.Lock() + defer jm.mu.Unlock() + + if ev, ok := jm.pending[jobID]; ok { + if err := jm.Store.MarkPendingJobsAsFailed(ctx, accountID, ev.PeerID, jobID, reason); err != nil { + log.WithContext(ctx).Errorf("failed to mark pending jobs as failed: %v", err) + } + delete(jm.pending, jobID) + } +} + +func (jm *Manager) IsPeerConnected(peerID string) bool { + jm.mu.RLock() + defer jm.mu.RUnlock() + + _, ok := jm.jobChannels[peerID] + return ok +} + +func (jm *Manager) IsPeerHasPendingJobs(peerID string) bool { + jm.mu.RLock() + defer jm.mu.RUnlock() + + for _, ev := range jm.pending { + if ev.PeerID == peerID { + return true + } + } + return false +} diff --git a/management/server/management_proto_test.go b/management/server/management_proto_test.go index fc67e01af..45d4ab8c9 100644 --- a/management/server/management_proto_test.go +++ b/management/server/management_proto_test.go @@ -24,13 +24,16 @@ import ( "github.com/netbirdio/netbird/formatter/hook" "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" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/groups" "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" + "github.com/netbirdio/netbird/management/server/job" nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/management/server/peers/ephemeral/manager" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" "github.com/netbirdio/netbird/management/server/store" @@ -264,8 +267,8 @@ func Test_SyncProtocol(t *testing.T) { } // expired peers come separately. - if len(networkMap.GetOfflinePeers()) != 1 { - t.Fatal("expecting SyncResponse to have NetworkMap with 1 offline peer") + if len(networkMap.GetOfflinePeers()) != 2 { + t.Fatal("expecting SyncResponse to have NetworkMap with 2 offline peer") } expiredPeerPubKey := "RlSy2vzoG2HyMBTUImXOiVhCBiiBa5qD5xzMxkiFDW4=" @@ -360,22 +363,35 @@ func startManagementForTest(t *testing.T, testFile string, config *config.Config AnyTimes() permissionsManager := permissions.NewManager(store) groupsManager := groups.NewManagerMock() + peersManager := peers.NewManager(store, permissionsManager) + jobManager := job.NewJobManager(nil, store, peersManager) updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock()) - accountManager, err := BuildManager(ctx, store, networkMapController, nil, "", - eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false) + ephemeralMgr := manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)) + + cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100) + if err != nil { + cleanup() + return nil, nil, "", cleanup, err + } + + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeralMgr, config) + accountManager, err := BuildManager(ctx, nil, store, networkMapController, jobManager, nil, "", + eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) if err != nil { cleanup() return nil, nil, "", cleanup, err } - secretsManager := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager) + secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager) + if err != nil { + cleanup() + return nil, nil, "", cleanup, err + } - ephemeralMgr := manager.NewEphemeralManager(store, accountManager) - mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, updateManager, secretsManager, nil, ephemeralMgr, nil, MockIntegratedValidator{}, networkMapController) + mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, MockIntegratedValidator{}, networkMapController, nil, nil) if err != nil { return nil, nil, "", cleanup, err } @@ -712,7 +728,7 @@ func Test_LoginPerformance(t *testing.T) { } login := func() error { - _, _, _, err = am.LoginPeer(context.Background(), peerLogin) + _, _, _, _, err = am.LoginPeer(context.Background(), peerLogin) if err != nil { t.Logf("failed to login peer: %v", err) return err @@ -730,7 +746,7 @@ func Test_LoginPerformance(t *testing.T) { go func(peerLogin types.PeerLogin, counterStart *int32) { defer wgPeer.Done() - _, _, _, err = am.LoginPeer(context.Background(), peerLogin) + _, _, _, _, err = am.LoginPeer(context.Background(), peerLogin) if err != nil { t.Logf("failed to login peer: %v", err) return diff --git a/management/server/management_test.go b/management/server/management_test.go index 930ecfb5a..f1d49193c 100644 --- a/management/server/management_test.go +++ b/management/server/management_test.go @@ -22,13 +22,16 @@ import ( "github.com/netbirdio/netbird/encryption" "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" + ephemeral_manager "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" "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/port_forwarding" - "github.com/netbirdio/netbird/management/server/peers/ephemeral/manager" + "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" "github.com/netbirdio/netbird/management/server/store" @@ -201,16 +204,26 @@ func startServer( AnyTimes() permissionsManager := permissions.NewManager(str) + peersManager := peers.NewManager(str, permissionsManager) + jobManager := job.NewJobManager(nil, str, peersManager) ctx := context.Background() + + cacheStore, err := nbcache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100) + if err != nil { + t.Fatalf("failed creating cache store: %v", err) + } + updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := server.NewAccountRequestBuffer(ctx, str) - networkMapController := controller.NewController(ctx, str, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock()) + networkMapController := controller.NewController(ctx, str, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(str, peers.NewManager(str, permissionsManager)), config) accountManager, err := server.BuildManager( context.Background(), + nil, str, networkMapController, + jobManager, nil, "", eventStore, @@ -221,24 +234,29 @@ func startServer( port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, - false) + false, + cacheStore) if err != nil { t.Fatalf("failed creating an account manager: %v", err) } groupsManager := groups.NewManager(str, permissionsManager, accountManager) - secretsManager := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager) + secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager) + if err != nil { + t.Fatalf("failed creating secrets manager: %v", err) + } mgmtServer, err := nbgrpc.NewServer( config, accountManager, settingsMockManager, - updateManager, + jobManager, secretsManager, nil, - &manager.EphemeralManager{}, nil, server.MockIntegratedValidator{}, networkMapController, + nil, + nil, ) if err != nil { t.Fatalf("failed creating management server: %v", err) @@ -601,6 +619,7 @@ func TestSync10PeersGetUpdates(t *testing.T) { initialPeers := 10 additionalPeers := 10 + expectedPeerCount := initialPeers + additionalPeers - 1 // -1 because peer doesn't see itself var peers []wgtypes.Key for i := 0; i < initialPeers; i++ { @@ -609,8 +628,19 @@ func TestSync10PeersGetUpdates(t *testing.T) { peers = append(peers, key) } + // Track the maximum peer count each peer has seen + type peerState struct { + mu sync.Mutex + maxPeerCount int + done bool + } + peerStates := make(map[string]*peerState) + for _, pk := range peers { + peerStates[pk.PublicKey().String()] = &peerState{} + } + var wg sync.WaitGroup - wg.Add(initialPeers + initialPeers*additionalPeers) + wg.Add(initialPeers) // One completion per initial peer var syncClients []mgmtProto.ManagementService_SyncClient for _, pk := range peers { @@ -634,6 +664,9 @@ func TestSync10PeersGetUpdates(t *testing.T) { syncClients = append(syncClients, s) go func(pk wgtypes.Key, syncStream mgmtProto.ManagementService_SyncClient) { + pubKey := pk.PublicKey().String() + state := peerStates[pubKey] + for { encMsg := &mgmtProto.EncryptedMessage{} err := syncStream.RecvMsg(encMsg) @@ -642,19 +675,28 @@ func TestSync10PeersGetUpdates(t *testing.T) { } decryptedBytes, decErr := encryption.Decrypt(encMsg.Body, ts.serverPubKey, pk) if decErr != nil { - t.Errorf("failed to decrypt SyncResponse for peer %s: %v", pk.PublicKey().String(), decErr) + t.Errorf("failed to decrypt SyncResponse for peer %s: %v", pubKey, decErr) return } resp := &mgmtProto.SyncResponse{} umErr := pb.Unmarshal(decryptedBytes, resp) if umErr != nil { - t.Errorf("failed to unmarshal SyncResponse for peer %s: %v", pk.PublicKey().String(), umErr) + t.Errorf("failed to unmarshal SyncResponse for peer %s: %v", pubKey, umErr) return } - // We only count if there's a new peer update - if len(resp.GetRemotePeers()) > 0 { + + // Track the maximum peer count seen (due to debouncing, updates are coalesced) + peerCount := len(resp.GetRemotePeers()) + state.mu.Lock() + if peerCount > state.maxPeerCount { + state.maxPeerCount = peerCount + } + // Signal completion when this peer has seen all expected peers + if !state.done && state.maxPeerCount >= expectedPeerCount { + state.done = true wg.Done() } + state.mu.Unlock() } }(pk, s) } @@ -668,7 +710,30 @@ func TestSync10PeersGetUpdates(t *testing.T) { time.Sleep(time.Duration(n) * time.Millisecond) } - wg.Wait() + // Wait for debouncer to flush final updates (debounce interval is 1000ms) + time.Sleep(1500 * time.Millisecond) + + // Wait with timeout + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + // Success - all peers received expected peer count + case <-time.After(5 * time.Second): + // Timeout - report which peers didn't receive all updates + t.Error("Timeout waiting for all peers to receive updates") + for pubKey, state := range peerStates { + state.mu.Lock() + if state.maxPeerCount < expectedPeerCount { + t.Errorf("Peer %s only saw %d peers, expected %d", pubKey, state.maxPeerCount, expectedPeerCount) + } + state.mu.Unlock() + } + } for _, sc := range syncClients { err := sc.CloseSend() diff --git a/management/server/metrics/selfhosted.go b/management/server/metrics/selfhosted.go index 4ce57b1da..43fbec15d 100644 --- a/management/server/metrics/selfhosted.go +++ b/management/server/metrics/selfhosted.go @@ -13,8 +13,11 @@ import ( "time" "github.com/hashicorp/go-version" + "github.com/netbirdio/netbird/idp/dex" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/types" nbversion "github.com/netbirdio/netbird/version" ) @@ -28,6 +31,7 @@ const ( defaultPushInterval = 12 * time.Hour // requestTimeout http request timeout requestTimeout = 45 * time.Second + EmbeddedType = "embedded" ) type getTokenResponse struct { @@ -49,6 +53,9 @@ type properties map[string]interface{} type DataSource interface { GetAllAccounts(ctx context.Context) []*types.Account GetStoreEngine() types.Engine + GetCustomDomainsCounts(ctx context.Context) (total int64, validated int64, err error) + GetProxyMetrics(ctx context.Context) (store.ProxyMetrics, error) + GetAgentNetworkMetrics(ctx context.Context) (store.AgentNetworkMetrics, error) } // ConnManager peer connection manager that holds state for current active connections @@ -206,6 +213,25 @@ func (w *Worker) generateProperties(ctx context.Context) properties { peerActiveVersions []string osUIClients map[string]int rosenpassEnabled int + localUsers int + idpUsers int + embeddedIdpTypes map[string]int + services int + servicesEnabled int + servicesTargets int + servicesStatusActive int + servicesStatusPending int + servicesStatusError int + servicesTargetType map[rpservice.TargetType]int + servicesAuthPassword int + servicesAuthPin int + servicesAuthOIDC int + // Private-service signals — track adoption of NetBird-only mode + // (services backed by an embedded proxy peer + access groups). + servicesPrivate int + servicesPrivateWithGroups int + servicesPrivateAccessGroupsSum int + servicesWithDirectUpstream int ) start := time.Now() metricsProperties := make(properties) @@ -214,10 +240,14 @@ func (w *Worker) generateProperties(ctx context.Context) properties { rulesProtocol = make(map[string]int) rulesDirection = make(map[string]int) activeUsersLastDay = make(map[string]struct{}) + embeddedIdpTypes = make(map[string]int) + servicesTargetType = make(map[rpservice.TargetType]int) uptime = time.Since(w.startupTime).Seconds() connections := w.connManager.GetAllConnectedPeers() version = nbversion.NetbirdVersion() + customDomains, customDomainsValidated, _ := w.dataSource.GetCustomDomainsCounts(ctx) + for _, account := range w.dataSource.GetAllAccounts(ctx) { accounts++ @@ -266,6 +296,18 @@ func (w *Worker) generateProperties(ctx context.Context) properties { serviceUsers++ } else { users++ + if w.idpManager == EmbeddedType { + _, idpID, err := dex.DecodeDexUserID(user.Id) + if err == nil { + if idpID == "local" { + localUsers++ + } else { + idpUsers++ + } + idpType := extractIdpType(idpID) + embeddedIdpTypes[idpType]++ + } + } } pats += len(user.PATs) } @@ -317,6 +359,66 @@ func (w *Worker) generateProperties(ctx context.Context) properties { peerActiveVersions = append(peerActiveVersions, peer.Meta.WtVersion) } } + + for _, service := range account.Services { + services++ + if service.Enabled { + servicesEnabled++ + } + servicesTargets += len(service.Targets) + + switch rpservice.Status(service.Meta.Status) { + case rpservice.StatusActive: + servicesStatusActive++ + case rpservice.StatusPending: + servicesStatusPending++ + case rpservice.StatusError, rpservice.StatusCertificateFailed, rpservice.StatusTunnelNotCreated: + servicesStatusError++ + } + + for _, target := range service.Targets { + servicesTargetType[target.TargetType]++ + } + + if service.Auth.PasswordAuth != nil && service.Auth.PasswordAuth.Enabled { + servicesAuthPassword++ + } + if service.Auth.PinAuth != nil && service.Auth.PinAuth.Enabled { + servicesAuthPin++ + } + if service.Auth.BearerAuth != nil && service.Auth.BearerAuth.Enabled { + servicesAuthOIDC++ + } + + if service.Private { + servicesPrivate++ + if len(service.AccessGroups) > 0 { + servicesPrivateWithGroups++ + } + servicesPrivateAccessGroupsSum += len(service.AccessGroups) + } + + for _, target := range service.Targets { + if target.Options.DirectUpstream { + servicesWithDirectUpstream++ + break + } + } + } + } + + // Proxy / BYOP cluster signals come from the proxies table aggregated + // across all accounts in a single store query; nil on FileStore. + proxyMetrics, err := w.dataSource.GetProxyMetrics(ctx) + if err != nil { + log.WithContext(ctx).Debugf("collect proxy metrics: %v", err) + } + + // Agent-network adoption + usage, aggregated across all accounts in a few + // cheap queries; nil on FileStore. + agentNetworkMetrics, err := w.dataSource.GetAgentNetworkMetrics(ctx) + if err != nil { + log.WithContext(ctx).Debugf("collect agent network metrics: %v", err) } minActivePeerVersion, maxActivePeerVersion := getMinMaxVersion(peerActiveVersions) @@ -353,6 +455,46 @@ func (w *Worker) generateProperties(ctx context.Context) properties { metricsProperties["idp_manager"] = w.idpManager metricsProperties["store_engine"] = w.dataSource.GetStoreEngine() metricsProperties["rosenpass_enabled"] = rosenpassEnabled + metricsProperties["local_users_count"] = localUsers + metricsProperties["idp_users_count"] = idpUsers + metricsProperties["embedded_idp_count"] = len(embeddedIdpTypes) + + metricsProperties["services"] = services + metricsProperties["services_enabled"] = servicesEnabled + metricsProperties["services_targets"] = servicesTargets + metricsProperties["services_status_active"] = servicesStatusActive + metricsProperties["services_status_pending"] = servicesStatusPending + metricsProperties["services_status_error"] = servicesStatusError + metricsProperties["services_auth_password"] = servicesAuthPassword + metricsProperties["services_auth_pin"] = servicesAuthPin + metricsProperties["services_auth_oidc"] = servicesAuthOIDC + metricsProperties["services_private"] = servicesPrivate + metricsProperties["services_private_with_access_groups"] = servicesPrivateWithGroups + metricsProperties["services_private_access_groups_sum"] = servicesPrivateAccessGroupsSum + metricsProperties["services_with_direct_upstream"] = servicesWithDirectUpstream + metricsProperties["proxy_clusters"] = proxyMetrics.Clusters + metricsProperties["proxy_clusters_byop"] = proxyMetrics.ClustersBYOP + metricsProperties["proxy_clusters_private"] = proxyMetrics.ClustersPrivate + metricsProperties["proxies"] = proxyMetrics.Proxies + metricsProperties["proxies_connected"] = proxyMetrics.ProxiesConnected + metricsProperties["custom_domains"] = customDomains + metricsProperties["custom_domains_validated"] = customDomainsValidated + metricsProperties["agent_network_accounts"] = agentNetworkMetrics.Accounts + metricsProperties["agent_network_providers"] = agentNetworkMetrics.Providers + metricsProperties["agent_network_policies"] = agentNetworkMetrics.Policies + metricsProperties["agent_network_budget_rules"] = agentNetworkMetrics.BudgetRules + metricsProperties["agent_network_log_collection_enabled"] = agentNetworkMetrics.LogCollectionEnabled + metricsProperties["agent_network_input_tokens"] = agentNetworkMetrics.InputTokens + metricsProperties["agent_network_output_tokens"] = agentNetworkMetrics.OutputTokens + metricsProperties["agent_network_cost_usd"] = agentNetworkMetrics.CostUSD + + for targetType, count := range servicesTargetType { + metricsProperties["services_target_type_"+string(targetType)] = count + } + + for idpType, count := range embeddedIdpTypes { + metricsProperties["embedded_idp_users_"+idpType] = count + } for protocol, count := range rulesProtocol { metricsProperties["rules_protocol_"+protocol] = count @@ -440,6 +582,20 @@ func createPostRequest(ctx context.Context, endpoint string, payloadStr string) return req, cancel, nil } +// extractIdpType extracts the IdP type from a Dex connector ID. +// Connector IDs are formatted as "-" (e.g., "okta-abc123", "zitadel-xyz"). +// Returns the type prefix, or "oidc" if no known prefix is found. +func extractIdpType(connectorID string) string { + if connectorID == "local" { + return "local" + } + idx := strings.LastIndex(connectorID, "-") + if idx <= 0 { + return "oidc" + } + return strings.ToLower(connectorID[:idx]) +} + func getMinMaxVersion(inputList []string) (string, string) { versions := make([]*version.Version, 0) diff --git a/management/server/metrics/selfhosted_test.go b/management/server/metrics/selfhosted_test.go index db0d90e64..1fef89a2c 100644 --- a/management/server/metrics/selfhosted_test.go +++ b/management/server/metrics/selfhosted_test.go @@ -5,11 +5,14 @@ import ( "testing" nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/idp/dex" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" networkTypes "github.com/netbirdio/netbird/management/server/networks/types" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/posture" + "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/route" ) @@ -25,6 +28,9 @@ func (mockDatasource) GetAllConnectedPeers() map[string]struct{} { // GetAllAccounts returns a list of *server.Account for use in tests with predefined information func (mockDatasource) GetAllAccounts(_ context.Context) []*types.Account { + localUserID := dex.EncodeDexUserID("10", "local") + idpUserID := dex.EncodeDexUserID("20", "zitadel-d5uv82dra0haedlf6kv0") + oidcUserID := dex.EncodeDexUserID("30", "d6jvvp69kmnc73c9pl40") return []*types.Account{ { Id: "1", @@ -98,18 +104,55 @@ func (mockDatasource) GetAllAccounts(_ context.Context) []*types.Account { }, Users: map[string]*types.User{ "1": { + Id: "1", IsServiceUser: true, PATs: map[string]*types.PersonalAccessToken{ "1": {}, }, }, - "2": { + localUserID: { + Id: localUserID, IsServiceUser: false, PATs: map[string]*types.PersonalAccessToken{ "1": {}, }, }, }, + Services: []*rpservice.Service{ + { + ID: "svc1", + Enabled: true, + Targets: []*rpservice.Target{ + {TargetType: "peer"}, + {TargetType: "host", Options: rpservice.TargetOptions{DirectUpstream: true}}, + }, + Auth: rpservice.AuthConfig{ + PasswordAuth: &rpservice.PasswordAuthConfig{Enabled: true}, + }, + Meta: rpservice.Meta{Status: string(rpservice.StatusActive)}, + }, + { + ID: "svc2", + Enabled: false, + Targets: []*rpservice.Target{ + {TargetType: "domain"}, + }, + Auth: rpservice.AuthConfig{ + BearerAuth: &rpservice.BearerAuthConfig{Enabled: true}, + }, + Meta: rpservice.Meta{Status: string(rpservice.StatusPending)}, + }, + { + ID: "svc3-private", + Enabled: true, + Private: true, + AccessGroups: []string{"grp-eng", "grp-ops"}, + Targets: []*rpservice.Target{ + {TargetType: "cluster", Options: rpservice.TargetOptions{DirectUpstream: true}}, + }, + Meta: rpservice.Meta{Status: string(rpservice.StatusActive)}, + }, + }, }, { Id: "2", @@ -162,12 +205,21 @@ func (mockDatasource) GetAllAccounts(_ context.Context) []*types.Account { }, Users: map[string]*types.User{ "1": { + Id: "1", IsServiceUser: true, PATs: map[string]*types.PersonalAccessToken{ "1": {}, }, }, - "2": { + idpUserID: { + Id: idpUserID, + IsServiceUser: false, + PATs: map[string]*types.PersonalAccessToken{ + "1": {}, + }, + }, + oidcUserID: { + Id: oidcUserID, IsServiceUser: false, PATs: map[string]*types.PersonalAccessToken{ "1": {}, @@ -208,12 +260,45 @@ func (mockDatasource) GetStoreEngine() types.Engine { return types.FileStoreEngine } +// GetCustomDomainsCounts returns test custom domain counts. +func (mockDatasource) GetCustomDomainsCounts(_ context.Context) (int64, int64, error) { + return 3, 2, nil +} + +// GetProxyMetrics returns canned proxy/cluster counts so the +// generateProperties test can assert the BYOP signals end-to-end. +func (mockDatasource) GetProxyMetrics(_ context.Context) (store.ProxyMetrics, error) { + return store.ProxyMetrics{ + Clusters: 3, + ClustersBYOP: 1, + ClustersPrivate: 1, + Proxies: 4, + ProxiesConnected: 2, + }, nil +} + +// GetAgentNetworkMetrics returns canned agent-network counts so the +// generateProperties test can assert the adoption/usage signals end-to-end. +func (mockDatasource) GetAgentNetworkMetrics(_ context.Context) (store.AgentNetworkMetrics, error) { + return store.AgentNetworkMetrics{ + Accounts: 2, + Providers: 5, + Policies: 3, + BudgetRules: 1, + LogCollectionEnabled: 2, + InputTokens: 1000, + OutputTokens: 500, + CostUSD: 1.25, + }, nil +} + // TestGenerateProperties tests and validate the properties generation by using the mockDatasource for the Worker.generateProperties func TestGenerateProperties(t *testing.T) { ds := mockDatasource{} worker := Worker{ dataSource: ds, connManager: ds, + idpManager: EmbeddedType, } properties := worker.generateProperties(context.Background()) @@ -239,14 +324,14 @@ func TestGenerateProperties(t *testing.T) { if properties["rules"] != 4 { t.Errorf("expected 4 rules, got %d", properties["rules"]) } - if properties["users"] != 2 { - t.Errorf("expected 1 users, got %d", properties["users"]) + if properties["users"] != 3 { + t.Errorf("expected 3 users, got %d", properties["users"]) } if properties["setup_keys_usage"] != 2 { t.Errorf("expected 1 setup_keys_usage, got %d", properties["setup_keys_usage"]) } - if properties["pats"] != 4 { - t.Errorf("expected 4 personal_access_tokens, got %d", properties["pats"]) + if properties["pats"] != 5 { + t.Errorf("expected 5 personal_access_tokens, got %d", properties["pats"]) } if properties["peers_ssh_enabled"] != 2 { t.Errorf("expected 2 peers_ssh_enabled, got %d", properties["peers_ssh_enabled"]) @@ -327,4 +412,123 @@ func TestGenerateProperties(t *testing.T) { t.Errorf("expected 1 active_users_last_day, got %d", properties["active_users_last_day"]) } + if properties["local_users_count"] != 1 { + t.Errorf("expected 1 local_users_count, got %d", properties["local_users_count"]) + } + if properties["idp_users_count"] != 2 { + t.Errorf("expected 2 idp_users_count, got %d", properties["idp_users_count"]) + } + if properties["embedded_idp_users_local"] != 1 { + t.Errorf("expected 1 embedded_idp_users_local, got %v", properties["embedded_idp_users_local"]) + } + if properties["embedded_idp_users_zitadel"] != 1 { + t.Errorf("expected 1 embedded_idp_users_zitadel, got %v", properties["embedded_idp_users_zitadel"]) + } + if properties["embedded_idp_users_oidc"] != 1 { + t.Errorf("expected 1 embedded_idp_users_oidc, got %v", properties["embedded_idp_users_oidc"]) + } + if properties["embedded_idp_count"] != 3 { + t.Errorf("expected 3 embedded_idp_count, got %v", properties["embedded_idp_count"]) + } + + if properties["services"] != 3 { + t.Errorf("expected 3 services, got %v", properties["services"]) + } + if properties["services_enabled"] != 2 { + t.Errorf("expected 2 services_enabled, got %v", properties["services_enabled"]) + } + if properties["services_targets"] != 4 { + t.Errorf("expected 4 services_targets, got %v", properties["services_targets"]) + } + if properties["services_status_active"] != 2 { + t.Errorf("expected 2 services_status_active, got %v", properties["services_status_active"]) + } + if properties["services_status_pending"] != 1 { + t.Errorf("expected 1 services_status_pending, got %v", properties["services_status_pending"]) + } + if properties["services_status_error"] != 0 { + t.Errorf("expected 0 services_status_error, got %v", properties["services_status_error"]) + } + if properties["services_target_type_peer"] != 1 { + t.Errorf("expected 1 services_target_type_peer, got %v", properties["services_target_type_peer"]) + } + if properties["services_target_type_host"] != 1 { + t.Errorf("expected 1 services_target_type_host, got %v", properties["services_target_type_host"]) + } + if properties["services_target_type_domain"] != 1 { + t.Errorf("expected 1 services_target_type_domain, got %v", properties["services_target_type_domain"]) + } + if properties["services_target_type_cluster"] != 1 { + t.Errorf("expected 1 services_target_type_cluster, got %v", properties["services_target_type_cluster"]) + } + if properties["services_auth_password"] != 1 { + t.Errorf("expected 1 services_auth_password, got %v", properties["services_auth_password"]) + } + if properties["services_auth_oidc"] != 1 { + t.Errorf("expected 1 services_auth_oidc, got %v", properties["services_auth_oidc"]) + } + if properties["services_auth_pin"] != 0 { + t.Errorf("expected 0 services_auth_pin, got %v", properties["services_auth_pin"]) + } + if properties["services_private"] != 1 { + t.Errorf("expected 1 services_private, got %v", properties["services_private"]) + } + if properties["services_private_with_access_groups"] != 1 { + t.Errorf("expected 1 services_private_with_access_groups, got %v", properties["services_private_with_access_groups"]) + } + if properties["services_private_access_groups_sum"] != 2 { + t.Errorf("expected 2 services_private_access_groups_sum, got %v", properties["services_private_access_groups_sum"]) + } + if properties["services_with_direct_upstream"] != 2 { + t.Errorf("expected 2 services_with_direct_upstream, got %v", properties["services_with_direct_upstream"]) + } + if properties["proxy_clusters"] != int64(3) { + t.Errorf("expected 3 proxy_clusters, got %v", properties["proxy_clusters"]) + } + if properties["proxy_clusters_byop"] != int64(1) { + t.Errorf("expected 1 proxy_clusters_byop, got %v", properties["proxy_clusters_byop"]) + } + if properties["proxy_clusters_private"] != int64(1) { + t.Errorf("expected 1 proxy_clusters_private, got %v", properties["proxy_clusters_private"]) + } + if properties["proxies"] != int64(4) { + t.Errorf("expected 4 proxies, got %v", properties["proxies"]) + } + if properties["proxies_connected"] != int64(2) { + t.Errorf("expected 2 proxies_connected, got %v", properties["proxies_connected"]) + } + if properties["custom_domains"] != int64(3) { + t.Errorf("expected 3 custom_domains, got %v", properties["custom_domains"]) + } + if properties["custom_domains_validated"] != int64(2) { + t.Errorf("expected 2 custom_domains_validated, got %v", properties["custom_domains_validated"]) + } +} + +func TestExtractIdpType(t *testing.T) { + tests := []struct { + connectorID string + expected string + }{ + {"okta-abc123def", "okta"}, + {"zitadel-d5uv82dra0haedlf6kv0", "zitadel"}, + {"entra-xyz789", "entra"}, + {"google-abc123", "google"}, + {"pocketid-abc123", "pocketid"}, + {"microsoft-abc123", "microsoft"}, + {"authentik-abc123", "authentik"}, + {"keycloak-d5uv82dra0haedlf6kv0", "keycloak"}, + {"local", "local"}, + {"d6jvvp69kmnc73c9pl40", "oidc"}, + {"", "oidc"}, + } + + for _, tt := range tests { + t.Run(tt.connectorID, func(t *testing.T) { + result := extractIdpType(tt.connectorID) + if result != tt.expected { + t.Errorf("extractIdpType(%q) = %q, want %q", tt.connectorID, result, tt.expected) + } + }) + } } diff --git a/management/server/migration/migration.go b/management/server/migration/migration.go index 78f4afbd5..6d8ed90cc 100644 --- a/management/server/migration/migration.go +++ b/management/server/migration/migration.go @@ -13,6 +13,7 @@ import ( "strings" "unicode/utf8" + "github.com/rs/xid" log "github.com/sirupsen/logrus" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -393,7 +394,7 @@ func CreateIndexIfNotExists[T any](ctx context.Context, db *gorm.DB, indexName s return fmt.Errorf("failed to parse model schema: %w", err) } tableName := stmt.Schema.Table - dialect := db.Dialector.Name() + dialect := db.Name() if db.Migrator().HasIndex(&model, indexName) { log.WithContext(ctx).Infof("index %s already exists on table %s", indexName, tableName) @@ -404,10 +405,11 @@ func CreateIndexIfNotExists[T any](ctx context.Context, db *gorm.DB, indexName s if dialect == "mysql" { var withLength []string for _, col := range columns { - if col == "ip" || col == "dns_label" { - withLength = append(withLength, fmt.Sprintf("%s(64)", col)) + quotedCol := fmt.Sprintf("`%s`", col) + if col == "ip" || col == "dns_label" || col == "key" { + withLength = append(withLength, fmt.Sprintf("%s(64)", quotedCol)) } else { - withLength = append(withLength, col) + withLength = append(withLength, quotedCol) } } columnClause = strings.Join(withLength, ", ") @@ -487,3 +489,275 @@ func MigrateJsonToTable[T any](ctx context.Context, db *gorm.DB, columnName stri log.WithContext(ctx).Infof("Migration of JSON field %s from table %s into separate table completed", columnName, tableName) return nil } + +// hasForeignKey checks whether a foreign key constraint exists on the given table and column. +func hasForeignKey(db *gorm.DB, table, column string) bool { + var count int64 + + switch db.Name() { + case "postgres": + db.Raw(` + SELECT COUNT(*) FROM information_schema.key_column_usage kcu + JOIN information_schema.table_constraints tc + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND kcu.table_name = ? + AND kcu.column_name = ? + `, table, column).Scan(&count) + case "mysql": + db.Raw(` + SELECT COUNT(*) FROM information_schema.key_column_usage + WHERE table_schema = DATABASE() + AND table_name = ? + AND column_name = ? + AND referenced_table_name IS NOT NULL + `, table, column).Scan(&count) + default: // sqlite + type fkInfo struct { + From string + } + var fks []fkInfo + db.Raw(fmt.Sprintf("PRAGMA foreign_key_list(%s)", table)).Scan(&fks) + for _, fk := range fks { + if fk.From == column { + return true + } + } + return false + } + + return count > 0 +} + +// CleanupOrphanedResources deletes rows from the table of model T where the foreign +// key column (fkColumn) references a row in the table of model R that no longer exists. +func CleanupOrphanedResources[T any, R any](ctx context.Context, db *gorm.DB, fkColumn string) error { + var model T + var refModel R + + if !db.Migrator().HasTable(&model) { + log.WithContext(ctx).Debugf("table for %T does not exist, no cleanup needed", model) + return nil + } + + if !db.Migrator().HasTable(&refModel) { + log.WithContext(ctx).Debugf("referenced table for %T does not exist, no cleanup needed", refModel) + return nil + } + + stmtT := &gorm.Statement{DB: db} + if err := stmtT.Parse(&model); err != nil { + return fmt.Errorf("parse model %T: %w", model, err) + } + childTable := stmtT.Schema.Table + + stmtR := &gorm.Statement{DB: db} + if err := stmtR.Parse(&refModel); err != nil { + return fmt.Errorf("parse reference model %T: %w", refModel, err) + } + parentTable := stmtR.Schema.Table + + if !db.Migrator().HasColumn(&model, fkColumn) { + log.WithContext(ctx).Debugf("column %s does not exist in table %s, no cleanup needed", fkColumn, childTable) + return nil + } + + // If a foreign key constraint already exists on the column, the DB itself + // enforces referential integrity and orphaned rows cannot exist. + if hasForeignKey(db, childTable, fkColumn) { + log.WithContext(ctx).Debugf("foreign key constraint for %s already exists on %s, no cleanup needed", fkColumn, childTable) + return nil + } + + result := db.Exec( + fmt.Sprintf( + "DELETE FROM %s WHERE %s NOT IN (SELECT id FROM %s)", + childTable, fkColumn, parentTable, + ), + ) + if result.Error != nil { + return fmt.Errorf("cleanup orphaned rows in %s: %w", childTable, result.Error) + } + + log.WithContext(ctx).Infof("Cleaned up %d orphaned rows from %s where %s had no matching row in %s", + result.RowsAffected, childTable, fkColumn, parentTable) + + return nil +} + +func RemoveDuplicatePeerKeys(ctx context.Context, db *gorm.DB) error { + if !db.Migrator().HasTable("peers") { + log.WithContext(ctx).Debug("peers table does not exist, skipping duplicate key cleanup") + return nil + } + + keyColumn := GetColumnName(db, "key") + + var duplicates []struct { + Key string + Count int64 + } + + if err := db.Table("peers"). + Select(keyColumn + ", COUNT(*) as count"). + Group(keyColumn). + Having("COUNT(*) > 1"). + Find(&duplicates).Error; err != nil { + return fmt.Errorf("find duplicate keys: %w", err) + } + + if len(duplicates) == 0 { + return nil + } + + log.WithContext(ctx).Warnf("Found %d duplicate peer keys, cleaning up", len(duplicates)) + + for _, dup := range duplicates { + var peerIDs []string + if err := db.Table("peers"). + Select("id"). + Where(keyColumn+" = ?", dup.Key). + Order("peer_status_last_seen DESC"). + Pluck("id", &peerIDs).Error; err != nil { + return fmt.Errorf("get peers for key: %w", err) + } + + if len(peerIDs) <= 1 { + continue + } + + idsToDelete := peerIDs[1:] + + if err := db.Table("peers").Where("id IN ?", idsToDelete).Delete(nil).Error; err != nil { + return fmt.Errorf("delete duplicate peers: %w", err) + } + } + + return nil +} + +func BackfillPublicIDs[T any](ctx context.Context, db *gorm.DB) error { + var model T + + if !db.Migrator().HasTable(&model) { + log.WithContext(ctx).Debugf("Table for %T does not exist, no backfill needed", model) + return nil + } + + stmt := &gorm.Statement{DB: db} + err := stmt.Parse(&model) + if err != nil { + return fmt.Errorf("parse model: %w", err) + } + tableName := stmt.Schema.Table + + if err := db.Transaction(func(tx *gorm.DB) error { + if !tx.Migrator().HasColumn(&model, "public_id") { + log.WithContext(ctx).Infof("Column public_id does not exist in table %s, adding it", tableName) + if err := tx.Migrator().AddColumn(&model, "public_id"); err != nil { + return fmt.Errorf("add column public_id: %w", err) + } + } + + var rows []map[string]any + if err := tx.Table(tableName).Select("id", "public_id").Where("public_id IS NULL").Or("public_id = ''").Find(&rows).Error; err != nil { + return fmt.Errorf("failed to find rows with empty public_id: %w", err) + } + + if len(rows) == 0 { + log.WithContext(ctx).Infof("No rows with empty public_id found in table %s, no migration needed", tableName) + return nil + } + + for _, row := range rows { + if err := tx.Table(tableName).Where("id = ?", row["id"]).Update("public_id", xid.New().String()).Error; err != nil { + return fmt.Errorf("failed to update row with id %v: %w", row["id"], err) + } + } + return nil + }); err != nil { + return err + } + + log.WithContext(ctx).Infof("Backfill of empty public_id in table %s completed", tableName) + return nil +} + +// FoldCostAggregatesIntoBuckets migrates a per-request cost table from the old +// "stored aggregate" shape (cost_usd + cache_cost_usd columns) to the per-bucket +// breakdown, where the total and cache portion are derived on read instead. +// +// The fold preserves both aggregates exactly for historical rows: the cache +// total moves into cached_input_cost_usd and the remainder into +// input_cost_usd, so a row's derived total and cache cost still match what it +// reported before the upgrade. The finer split is genuinely unknown for those +// rows — the old schema never recorded a read/write or input/output division — +// so it is lumped rather than guessed; only rows written after the upgrade +// carry a true four-way split. +// +// Dropping the columns before folding would zero every historical row's cost, +// so the update runs first and the drop only happens once it succeeds. A table +// with no cost_usd column has already been migrated (or was created fresh) and +// is skipped. +func FoldCostAggregatesIntoBuckets[T any](ctx context.Context, db *gorm.DB) error { + var model T + + if !db.Migrator().HasTable(&model) { + log.WithContext(ctx).Debugf("table for %T does not exist, no cost-bucket migration needed", model) + return nil + } + if !db.Migrator().HasColumn(&model, "cost_usd") { + log.WithContext(ctx).Debugf("table for %T has no cost_usd column, cost buckets already migrated", model) + return nil + } + + stmt := &gorm.Statement{DB: db} + if err := stmt.Parse(&model); err != nil { + return fmt.Errorf("parse model schema: %w", err) + } + tableName := stmt.Schema.Table + + // COALESCE guards rows whose new columns were added as NULL by an earlier + // AutoMigrate run that predates the NOT NULL default. + hasCacheColumn := db.Migrator().HasColumn(&model, "cache_cost_usd") + cacheExpr := "0" + if hasCacheColumn { + cacheExpr = "COALESCE(cache_cost_usd, 0)" + } + + if err := db.Transaction(func(tx *gorm.DB) error { + // Only touch rows that carry a legacy total and no breakdown yet, so + // the migration is idempotent and never overwrites a true split. + update := fmt.Sprintf(`UPDATE %s + SET input_cost_usd = COALESCE(cost_usd, 0) - %s, + cached_input_cost_usd = %s, + cache_creation_cost_usd = 0, + output_cost_usd = 0 + WHERE COALESCE(cost_usd, 0) <> 0 + AND COALESCE(input_cost_usd, 0) = 0 + AND COALESCE(cached_input_cost_usd, 0) = 0 + AND COALESCE(cache_creation_cost_usd, 0) = 0 + AND COALESCE(output_cost_usd, 0) = 0`, tableName, cacheExpr, cacheExpr) + res := tx.Exec(update) + if res.Error != nil { + return fmt.Errorf("fold legacy cost aggregates in %s: %w", tableName, res.Error) + } + log.WithContext(ctx).Infof("folded legacy cost aggregates into per-bucket columns for %d rows in table %s", res.RowsAffected, tableName) + + if err := tx.Migrator().DropColumn(&model, "cost_usd"); err != nil { + return fmt.Errorf("drop cost_usd from %s: %w", tableName, err) + } + if hasCacheColumn { + if err := tx.Migrator().DropColumn(&model, "cache_cost_usd"); err != nil { + return fmt.Errorf("drop cache_cost_usd from %s: %w", tableName, err) + } + } + return nil + }); err != nil { + return err + } + + log.WithContext(ctx).Infof("migration of stored cost aggregates to per-bucket columns in table %s completed", tableName) + return nil +} diff --git a/management/server/migration/migration_test.go b/management/server/migration/migration_test.go index ce76bd668..b60a50ee1 100644 --- a/management/server/migration/migration_test.go +++ b/management/server/migration/migration_test.go @@ -16,6 +16,7 @@ import ( "gorm.io/driver/sqlite" "gorm.io/gorm" + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" "github.com/netbirdio/netbird/management/server/migration" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/testutil" @@ -198,7 +199,11 @@ func TestMigrateNetIPFieldFromBlobToJSON_WithJSONData(t *testing.T) { require.NoError(t, err, "Failed to insert account") account.PeersG = []nbpeer.Peer{ - {AccountID: "1234", Location: nbpeer.Location{ConnectionIP: net.IP{10, 0, 0, 1}}}, + { + AccountID: "1234", + Location: nbpeer.Location{ConnectionIP: net.IP{10, 0, 0, 1}}, + Status: &nbpeer.PeerStatus{LastSeen: time.Now()}, + }, } err = db.Save(account).Error @@ -340,3 +345,394 @@ func TestCreateIndexIfExists(t *testing.T) { exist = db.Migrator().HasIndex(&nbpeer.Peer{}, indexName) assert.True(t, exist, "Should have the index") } + +type testPeer struct { + ID string `gorm:"primaryKey"` + Key string `gorm:"index"` + PeerStatusLastSeen time.Time + PeerStatusConnected bool +} + +func (testPeer) TableName() string { + return "peers" +} + +func setupPeerTestDB(t *testing.T) *gorm.DB { + t.Helper() + db := setupDatabase(t) + _ = db.Migrator().DropTable(&testPeer{}) + err := db.AutoMigrate(&testPeer{}) + require.NoError(t, err, "Failed to auto-migrate tables") + return db +} + +func TestRemoveDuplicatePeerKeys_NoDuplicates(t *testing.T) { + db := setupPeerTestDB(t) + + now := time.Now() + peers := []testPeer{ + {ID: "peer1", Key: "key1", PeerStatusLastSeen: now}, + {ID: "peer2", Key: "key2", PeerStatusLastSeen: now}, + {ID: "peer3", Key: "key3", PeerStatusLastSeen: now}, + } + + for _, p := range peers { + err := db.Create(&p).Error + require.NoError(t, err) + } + + err := migration.RemoveDuplicatePeerKeys(context.Background(), db) + require.NoError(t, err) + + var count int64 + db.Model(&testPeer{}).Count(&count) + assert.Equal(t, int64(len(peers)), count, "All peers should remain when no duplicates") +} + +func TestRemoveDuplicatePeerKeys_WithDuplicates(t *testing.T) { + db := setupPeerTestDB(t) + + now := time.Now() + peers := []testPeer{ + {ID: "peer1", Key: "key1", PeerStatusLastSeen: now.Add(-2 * time.Hour)}, + {ID: "peer2", Key: "key1", PeerStatusLastSeen: now.Add(-1 * time.Hour)}, + {ID: "peer3", Key: "key1", PeerStatusLastSeen: now}, + {ID: "peer4", Key: "key2", PeerStatusLastSeen: now}, + {ID: "peer5", Key: "key3", PeerStatusLastSeen: now.Add(-1 * time.Hour)}, + {ID: "peer6", Key: "key3", PeerStatusLastSeen: now}, + } + + for _, p := range peers { + err := db.Create(&p).Error + require.NoError(t, err) + } + + err := migration.RemoveDuplicatePeerKeys(context.Background(), db) + require.NoError(t, err) + + var count int64 + db.Model(&testPeer{}).Count(&count) + assert.Equal(t, int64(3), count, "Should have 3 peers after removing duplicates") + + var remainingPeers []testPeer + err = db.Find(&remainingPeers).Error + require.NoError(t, err) + + remainingIDs := make(map[string]bool) + for _, p := range remainingPeers { + remainingIDs[p.ID] = true + } + + assert.True(t, remainingIDs["peer3"], "peer3 should remain (most recent for key1)") + assert.True(t, remainingIDs["peer4"], "peer4 should remain (only peer for key2)") + assert.True(t, remainingIDs["peer6"], "peer6 should remain (most recent for key3)") + + assert.False(t, remainingIDs["peer1"], "peer1 should be deleted (older duplicate)") + assert.False(t, remainingIDs["peer2"], "peer2 should be deleted (older duplicate)") + assert.False(t, remainingIDs["peer5"], "peer5 should be deleted (older duplicate)") +} + +func TestRemoveDuplicatePeerKeys_EmptyTable(t *testing.T) { + db := setupPeerTestDB(t) + + err := migration.RemoveDuplicatePeerKeys(context.Background(), db) + require.NoError(t, err, "Should not fail on empty table") +} + +func TestRemoveDuplicatePeerKeys_NoTable(t *testing.T) { + db := setupDatabase(t) + _ = db.Migrator().DropTable(&testPeer{}) + + err := migration.RemoveDuplicatePeerKeys(context.Background(), db) + require.NoError(t, err, "Should not fail when table does not exist") +} + +type testParent struct { + ID string `gorm:"primaryKey"` +} + +func (testParent) TableName() string { + return "test_parents" +} + +type testChild struct { + ID string `gorm:"primaryKey"` + ParentID string +} + +func (testChild) TableName() string { + return "test_children" +} + +type testChildWithFK struct { + ID string `gorm:"primaryKey"` + ParentID string `gorm:"index"` + Parent *testParent `gorm:"foreignKey:ParentID"` +} + +func (testChildWithFK) TableName() string { + return "test_children" +} + +func setupOrphanTestDB(t *testing.T, models ...any) *gorm.DB { + t.Helper() + db := setupDatabase(t) + for _, m := range models { + _ = db.Migrator().DropTable(m) + } + err := db.AutoMigrate(models...) + require.NoError(t, err, "Failed to auto-migrate tables") + return db +} + +func TestCleanupOrphanedResources_NoChildTable(t *testing.T) { + db := setupDatabase(t) + _ = db.Migrator().DropTable(&testChild{}) + _ = db.Migrator().DropTable(&testParent{}) + + err := migration.CleanupOrphanedResources[testChild, testParent](context.Background(), db, "parent_id") + require.NoError(t, err, "Should not fail when child table does not exist") +} + +func TestCleanupOrphanedResources_NoParentTable(t *testing.T) { + db := setupDatabase(t) + _ = db.Migrator().DropTable(&testParent{}) + _ = db.Migrator().DropTable(&testChild{}) + + err := db.AutoMigrate(&testChild{}) + require.NoError(t, err) + + err = migration.CleanupOrphanedResources[testChild, testParent](context.Background(), db, "parent_id") + require.NoError(t, err, "Should not fail when parent table does not exist") +} + +func TestCleanupOrphanedResources_EmptyTables(t *testing.T) { + db := setupOrphanTestDB(t, &testParent{}, &testChild{}) + + err := migration.CleanupOrphanedResources[testChild, testParent](context.Background(), db, "parent_id") + require.NoError(t, err, "Should not fail on empty tables") + + var count int64 + db.Model(&testChild{}).Count(&count) + assert.Equal(t, int64(0), count) +} + +func TestCleanupOrphanedResources_NoOrphans(t *testing.T) { + db := setupOrphanTestDB(t, &testParent{}, &testChild{}) + + require.NoError(t, db.Create(&testParent{ID: "p1"}).Error) + require.NoError(t, db.Create(&testParent{ID: "p2"}).Error) + require.NoError(t, db.Create(&testChild{ID: "c1", ParentID: "p1"}).Error) + require.NoError(t, db.Create(&testChild{ID: "c2", ParentID: "p2"}).Error) + + err := migration.CleanupOrphanedResources[testChild, testParent](context.Background(), db, "parent_id") + require.NoError(t, err) + + var count int64 + db.Model(&testChild{}).Count(&count) + assert.Equal(t, int64(2), count, "All children should remain when no orphans") +} + +func TestCleanupOrphanedResources_AllOrphans(t *testing.T) { + db := setupOrphanTestDB(t, &testParent{}, &testChild{}) + + require.NoError(t, db.Exec("INSERT INTO test_children (id, parent_id) VALUES (?, ?)", "c1", "gone1").Error) + require.NoError(t, db.Exec("INSERT INTO test_children (id, parent_id) VALUES (?, ?)", "c2", "gone2").Error) + require.NoError(t, db.Exec("INSERT INTO test_children (id, parent_id) VALUES (?, ?)", "c3", "gone3").Error) + + err := migration.CleanupOrphanedResources[testChild, testParent](context.Background(), db, "parent_id") + require.NoError(t, err) + + var count int64 + db.Model(&testChild{}).Count(&count) + assert.Equal(t, int64(0), count, "All orphaned children should be deleted") +} + +func TestCleanupOrphanedResources_MixedValidAndOrphaned(t *testing.T) { + db := setupOrphanTestDB(t, &testParent{}, &testChild{}) + + require.NoError(t, db.Create(&testParent{ID: "p1"}).Error) + require.NoError(t, db.Create(&testParent{ID: "p2"}).Error) + + require.NoError(t, db.Create(&testChild{ID: "c1", ParentID: "p1"}).Error) + require.NoError(t, db.Create(&testChild{ID: "c2", ParentID: "p2"}).Error) + require.NoError(t, db.Create(&testChild{ID: "c3", ParentID: "p1"}).Error) + + require.NoError(t, db.Exec("INSERT INTO test_children (id, parent_id) VALUES (?, ?)", "c4", "gone1").Error) + require.NoError(t, db.Exec("INSERT INTO test_children (id, parent_id) VALUES (?, ?)", "c5", "gone2").Error) + + err := migration.CleanupOrphanedResources[testChild, testParent](context.Background(), db, "parent_id") + require.NoError(t, err) + + var remaining []testChild + require.NoError(t, db.Order("id").Find(&remaining).Error) + + assert.Len(t, remaining, 3, "Only valid children should remain") + assert.Equal(t, "c1", remaining[0].ID) + assert.Equal(t, "c2", remaining[1].ID) + assert.Equal(t, "c3", remaining[2].ID) +} + +func TestCleanupOrphanedResources_Idempotent(t *testing.T) { + db := setupOrphanTestDB(t, &testParent{}, &testChild{}) + + require.NoError(t, db.Create(&testParent{ID: "p1"}).Error) + require.NoError(t, db.Create(&testChild{ID: "c1", ParentID: "p1"}).Error) + require.NoError(t, db.Exec("INSERT INTO test_children (id, parent_id) VALUES (?, ?)", "c2", "gone").Error) + + ctx := context.Background() + + err := migration.CleanupOrphanedResources[testChild, testParent](ctx, db, "parent_id") + require.NoError(t, err) + + var count int64 + db.Model(&testChild{}).Count(&count) + assert.Equal(t, int64(1), count) + + err = migration.CleanupOrphanedResources[testChild, testParent](ctx, db, "parent_id") + require.NoError(t, err) + + db.Model(&testChild{}).Count(&count) + assert.Equal(t, int64(1), count, "Count should remain the same after second run") +} + +func TestCleanupOrphanedResources_SkipsWhenForeignKeyExists(t *testing.T) { + engine := os.Getenv("NETBIRD_STORE_ENGINE") + if engine != "postgres" && engine != "mysql" { + t.Skip("FK constraint early-exit test requires postgres or mysql") + } + + db := setupDatabase(t) + _ = db.Migrator().DropTable(&testChildWithFK{}) + _ = db.Migrator().DropTable(&testParent{}) + + err := db.AutoMigrate(&testParent{}, &testChildWithFK{}) + require.NoError(t, err) + + require.NoError(t, db.Create(&testParent{ID: "p1"}).Error) + require.NoError(t, db.Create(&testParent{ID: "p2"}).Error) + require.NoError(t, db.Create(&testChildWithFK{ID: "c1", ParentID: "p1"}).Error) + require.NoError(t, db.Create(&testChildWithFK{ID: "c2", ParentID: "p2"}).Error) + + switch engine { + case "postgres": + require.NoError(t, db.Exec("ALTER TABLE test_children DROP CONSTRAINT fk_test_children_parent").Error) + require.NoError(t, db.Exec("DELETE FROM test_parents WHERE id = ?", "p2").Error) + require.NoError(t, db.Exec( + "ALTER TABLE test_children ADD CONSTRAINT fk_test_children_parent "+ + "FOREIGN KEY (parent_id) REFERENCES test_parents(id) NOT VALID", + ).Error) + case "mysql": + require.NoError(t, db.Exec("SET FOREIGN_KEY_CHECKS = 0").Error) + require.NoError(t, db.Exec("ALTER TABLE test_children DROP FOREIGN KEY fk_test_children_parent").Error) + require.NoError(t, db.Exec("DELETE FROM test_parents WHERE id = ?", "p2").Error) + require.NoError(t, db.Exec( + "ALTER TABLE test_children ADD CONSTRAINT fk_test_children_parent "+ + "FOREIGN KEY (parent_id) REFERENCES test_parents(id)", + ).Error) + require.NoError(t, db.Exec("SET FOREIGN_KEY_CHECKS = 1").Error) + } + + err = migration.CleanupOrphanedResources[testChildWithFK, testParent](context.Background(), db, "parent_id") + require.NoError(t, err) + + var count int64 + db.Model(&testChildWithFK{}).Count(&count) + assert.Equal(t, int64(2), count, "Both rows should survive — migration must skip when FK constraint exists") +} + +// legacyCostRow is the pre-breakdown shape of the usage table: cost was stored +// as a total plus a cache portion, with no per-bucket columns. Used to build a +// realistic pre-upgrade table for the fold migration to run against. +type legacyCostRow struct { + ID string `gorm:"primaryKey"` + AccountID string + Model string + CostUSD float64 + CacheCostUSD float64 +} + +func (legacyCostRow) TableName() string { return "agent_network_request_usage" } + +// TestFoldCostAggregatesIntoBuckets_PreservesHistoricalCost covers the upgrade +// path: a table written under the old schema must come out with its per-row +// total and cache cost unchanged, because dropping cost_usd without folding it +// forward would silently zero every historical row's spend. +func TestFoldCostAggregatesIntoBuckets_PreservesHistoricalCost(t *testing.T) { + ctx := context.Background() + db := setupDatabase(t) + // setupDatabase hands back a process-shared database, so start from a clean + // table rather than inheriting rows from another test. + require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.AgentNetworkUsage{})) + + require.NoError(t, db.AutoMigrate(&legacyCostRow{}), "legacy table must be created") + require.NoError(t, db.Create(&legacyCostRow{ + ID: "u1", AccountID: "acct-1", Model: "claude-sonnet-4-6", CostUSD: 0.0123, CacheCostUSD: 0.0029, + }).Error) + require.NoError(t, db.Create(&legacyCostRow{ + ID: "u2", AccountID: "acct-1", Model: "gpt-4o", CostUSD: 0.5, CacheCostUSD: 0, + }).Error) + // A zero-cost row (denied / unpriced request) must stay zero, not be touched. + require.NoError(t, db.Create(&legacyCostRow{ID: "u3", AccountID: "acct-1", Model: "gw/unpriced"}).Error) + + // AutoMigrate adds the per-bucket columns alongside the legacy ones, exactly + // as a real upgrade does before the post-auto migrations run. + require.NoError(t, db.AutoMigrate(&agentNetworkTypes.AgentNetworkUsage{}), "new columns must be added") + + require.NoError(t, migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db)) + + assert.False(t, db.Migrator().HasColumn(&agentNetworkTypes.AgentNetworkUsage{}, "cost_usd"), + "legacy cost_usd column must be dropped once folded") + assert.False(t, db.Migrator().HasColumn(&agentNetworkTypes.AgentNetworkUsage{}, "cache_cost_usd"), + "legacy cache_cost_usd column must be dropped once folded") + + var rows []*agentNetworkTypes.AgentNetworkUsage + require.NoError(t, db.Order("id").Find(&rows).Error) + require.Len(t, rows, 3) + + // u1: total and cache portion both preserved; the read/write and + // input/output splits are unknowable for a legacy row, so the cache total + // lands on cached_input and the remainder on input. + assert.InDelta(t, 0.0123, rows[0].TotalCostUSD(), 1e-9, "historical total must survive the fold") + assert.InDelta(t, 0.0029, rows[0].CacheCostUSD(), 1e-9, "historical cache cost must survive the fold") + assert.InDelta(t, 0.0094, rows[0].InputCostUSD, 1e-9, "non-cache remainder lands on input") + assert.InDelta(t, 0.0029, rows[0].CachedInputCostUSD, 1e-9, "legacy cache total lands on cached input") + assert.Zero(t, rows[0].CacheCreationCostUSD, "legacy rows carry no read/write split to recover") + assert.Zero(t, rows[0].OutputCostUSD, "legacy rows carry no input/output split to recover") + + // u2: no cache spend — the whole total is the non-cache remainder. + assert.InDelta(t, 0.5, rows[1].TotalCostUSD(), 1e-9, "cache-free historical total must survive") + assert.Zero(t, rows[1].CacheCostUSD(), "a cache-free row must stay cache-free") + + // u3: zero stays zero rather than being rewritten. + assert.Zero(t, rows[2].TotalCostUSD(), "an unpriced row must remain unpriced") +} + +// TestFoldCostAggregatesIntoBuckets_SkipsAlreadyMigrated proves the migration is +// safe to re-run: with no legacy column present it is a no-op that leaves a +// true four-way split untouched. +func TestFoldCostAggregatesIntoBuckets_SkipsAlreadyMigrated(t *testing.T) { + ctx := context.Background() + db := setupDatabase(t) + require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.AgentNetworkUsage{})) + + require.NoError(t, db.AutoMigrate(&agentNetworkTypes.AgentNetworkUsage{})) + // Timestamp must be set explicitly: a zero time.Time serialises as + // '0000-00-00 00:00:00', which MySQL rejects under strict mode. + require.NoError(t, db.Create(&agentNetworkTypes.AgentNetworkUsage{ + ID: "u1", AccountID: "acct-1", Model: "claude-sonnet-4-6", + Timestamp: time.Date(2026, 5, 5, 9, 0, 0, 0, time.UTC), + InputCostUSD: 0.001, CachedInputCostUSD: 0.002, CacheCreationCostUSD: 0.003, OutputCostUSD: 0.004, + }).Error) + + require.NoError(t, migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db), + "running against an already-migrated table must be a no-op, not an error") + + var row agentNetworkTypes.AgentNetworkUsage + require.NoError(t, db.First(&row, "id = ?", "u1").Error) + assert.InDelta(t, 0.001, row.InputCostUSD, 1e-9, "a true split must not be rewritten") + assert.InDelta(t, 0.002, row.CachedInputCostUSD, 1e-9) + assert.InDelta(t, 0.003, row.CacheCreationCostUSD, 1e-9) + assert.InDelta(t, 0.004, row.OutputCostUSD, 1e-9) + assert.InDelta(t, 0.01, row.TotalCostUSD(), 1e-9, "derived total sums the four buckets") +} diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go index 781d84f5f..071e3771b 100644 --- a/management/server/mock_server/account_mock.go +++ b/management/server/mock_server/account_mock.go @@ -6,16 +6,18 @@ import ( "net/netip" "time" + "github.com/netbirdio/netbird/shared/auth" + "google.golang.org/grpc/codes" "google.golang.org/grpc/status" nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" - nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/idp" nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/management/server/peers/ephemeral" "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/types" @@ -27,25 +29,26 @@ import ( var _ account.Manager = (*MockAccountManager)(nil) type MockAccountManager struct { - GetOrCreateAccountByUserFunc func(ctx context.Context, userId, domain string) (*types.Account, error) + GetOrCreateAccountByUserFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.Account, error) GetAccountFunc func(ctx context.Context, accountID string) (*types.Account, error) CreateSetupKeyFunc func(ctx context.Context, accountId string, keyName string, keyType types.SetupKeyType, expiresIn time.Duration, autoGroups []string, usageLimit int, userID string, ephemeral bool, allowExtraDNSLabels bool) (*types.SetupKey, error) GetSetupKeyFunc func(ctx context.Context, accountID, userID, keyID string) (*types.SetupKey, error) AccountExistsFunc func(ctx context.Context, accountID string) (bool, error) - GetAccountIDByUserIdFunc func(ctx context.Context, userId, domain string) (string, error) - GetUserFromUserAuthFunc func(ctx context.Context, userAuth nbcontext.UserAuth) (*types.User, error) + GetAccountIDByUserIdFunc func(ctx context.Context, userAuth auth.UserAuth) (string, error) + GetUserFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) ListUsersFunc func(ctx context.Context, accountID string) ([]*types.User, error) GetPeersFunc func(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) - MarkPeerConnectedFunc func(ctx context.Context, peerKey string, connected bool, realIP net.IP) error - SyncAndMarkPeerFunc func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) + MarkPeerConnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error + MarkPeerDisconnectedFunc func(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error + SyncAndMarkPeerFunc func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) DeletePeerFunc func(ctx context.Context, accountID, peerKey, userID string) error GetNetworkMapFunc func(ctx context.Context, peerKey string) (*types.NetworkMap, error) GetPeerNetworkFunc func(ctx context.Context, peerKey string) (*types.Network, error) - AddPeerFunc func(ctx context.Context, accountID string, setupKey string, userId string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) + AddPeerFunc func(ctx context.Context, accountID string, setupKey string, userId string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) GetGroupFunc func(ctx context.Context, accountID, groupID, userID string) (*types.Group, error) GetAllGroupsFunc func(ctx context.Context, accountID, userID string) ([]*types.Group, error) - GetGroupByNameFunc func(ctx context.Context, accountID, groupName string) (*types.Group, error) + GetGroupByNameFunc func(ctx context.Context, groupName, accountID, userID string) (*types.Group, error) SaveGroupFunc func(ctx context.Context, accountID, userID string, group *types.Group, create bool) error SaveGroupsFunc func(ctx context.Context, accountID, userID string, groups []*types.Group, create bool) error DeleteGroupFunc func(ctx context.Context, accountID, userId, groupID string) error @@ -62,6 +65,7 @@ type MockAccountManager struct { UpdatePeerMetaFunc func(ctx context.Context, peerID string, meta nbpeer.PeerSystemMeta) error UpdatePeerFunc func(ctx context.Context, accountID, userID string, peer *nbpeer.Peer) (*nbpeer.Peer, error) UpdatePeerIPFunc func(ctx context.Context, accountID, userID, peerID string, newIP netip.Addr) error + UpdatePeerIPv6Func func(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error CreateRouteFunc func(ctx context.Context, accountID string, prefix netip.Prefix, networkType route.NetworkType, domains domain.List, peer string, peerGroups []string, description string, netID route.NetID, masquerade bool, metric int, groups, accessControlGroupIDs []string, enabled bool, userID string, keepRoute bool, isSelected bool) (*route.Route, error) GetRouteFunc func(ctx context.Context, accountID string, routeID route.ID, userID string) (*route.Route, error) SaveRouteFunc func(ctx context.Context, accountID string, userID string, route *route.Route) error @@ -74,6 +78,7 @@ type MockAccountManager struct { SaveOrAddUsersFunc func(ctx context.Context, accountID, initiatorUserID string, update []*types.User, addIfNotExists bool) ([]*types.UserInfo, error) DeleteUserFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) error DeleteRegularUsersFunc func(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string, userInfos map[string]*types.UserInfo) error + UpdateUserPasswordFunc func(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error CreatePATFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserId string, tokenName string, expiresIn int) (*types.PersonalAccessTokenGenerated, error) DeletePATFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserId string, tokenID string) error GetPATFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserId string, tokenID string) (*types.PersonalAccessToken, error) @@ -84,7 +89,7 @@ type MockAccountManager struct { DeleteNameServerGroupFunc func(ctx context.Context, accountID, nsGroupID, userID string) error ListNameServerGroupsFunc func(ctx context.Context, accountID string, userID string) ([]*nbdns.NameServerGroup, error) CreateUserFunc func(ctx context.Context, accountID, userID string, key *types.UserInfo) (*types.UserInfo, error) - GetAccountIDFromUserAuthFunc func(ctx context.Context, userAuth nbcontext.UserAuth) (string, string, error) + GetAccountIDFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (string, string, error) DeleteAccountFunc func(ctx context.Context, accountID, userID string) error GetDNSDomainFunc func(settings *types.Settings) string StoreEventFunc func(ctx context.Context, initiatorID, targetID, accountID string, activityID activity.ActivityDescriber, meta map[string]any) @@ -93,7 +98,8 @@ type MockAccountManager struct { SaveDNSSettingsFunc func(ctx context.Context, accountID, userID string, dnsSettingsToSave *types.DNSSettings) error GetPeerFunc func(ctx context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error) UpdateAccountSettingsFunc func(ctx context.Context, accountID, userID string, newSettings *types.Settings) (*types.Settings, error) - LoginPeerFunc func(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) + LoginPeerFunc func(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) + ExtendPeerSessionFunc func(ctx context.Context, peerPubKey, userID string) (time.Time, error) SyncPeerFunc func(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) InviteUserFunc func(ctx context.Context, accountID string, initiatorUserID string, targetUserEmail string) error ApproveUserFunc func(ctx context.Context, accountID, initiatorUserID, targetUserID string) (*types.UserInfo, error) @@ -108,7 +114,7 @@ type MockAccountManager struct { GetIdpManagerFunc func() idp.Manager UpdateIntegratedValidatorFunc func(ctx context.Context, accountID, userID, validator string, groups []string) error GroupValidationFunc func(ctx context.Context, accountId string, groups []string) (bool, error) - SyncPeerMetaFunc func(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta) error + SyncPeerMetaFunc func(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) error FindExistingPostureCheckFunc func(accountID string, checks *posture.ChecksDefinition) (*posture.Checks, error) GetAccountIDForPeerKeyFunc func(ctx context.Context, peerKey string) (string, error) GetAccountByIDFunc func(ctx context.Context, accountID string, userID string) (*types.Account, error) @@ -119,16 +125,56 @@ type MockAccountManager struct { GetStoreFunc func() store.Store UpdateToPrimaryAccountFunc func(ctx context.Context, accountId string) error GetOwnerInfoFunc func(ctx context.Context, accountID string) (*types.UserInfo, error) - GetCurrentUserInfoFunc func(ctx context.Context, userAuth nbcontext.UserAuth) (*users.UserInfoWithPermissions, error) + GetCurrentUserInfoFunc func(ctx context.Context, userAuth auth.UserAuth) (*users.UserInfoWithPermissions, error) GetAccountMetaFunc func(ctx context.Context, accountID, userID string) (*types.AccountMeta, error) GetAccountOnboardingFunc func(ctx context.Context, accountID, userID string) (*types.AccountOnboarding, error) UpdateAccountOnboardingFunc func(ctx context.Context, accountID, userID string, onboarding *types.AccountOnboarding) (*types.AccountOnboarding, error) GetOrCreateAccountByPrivateDomainFunc func(ctx context.Context, initiatorId, domain string) (*types.Account, bool, error) AllowSyncFunc func(string, uint64) bool - UpdateAccountPeersFunc func(ctx context.Context, accountID string) - BufferUpdateAccountPeersFunc func(ctx context.Context, accountID string) + UpdateAccountPeersFunc func(ctx context.Context, accountID string, reason types.UpdateReason) + ExpandAndUpdateAffectedFunc func(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change) + BufferUpdateAccountPeersFunc func(ctx context.Context, accountID string, reason types.UpdateReason) RecalculateNetworkMapCacheFunc func(ctx context.Context, accountId string) error + + GetIdentityProviderFunc func(ctx context.Context, accountID, idpID, userID string) (*types.IdentityProvider, error) + GetIdentityProvidersFunc func(ctx context.Context, accountID, userID string) ([]*types.IdentityProvider, error) + CreateIdentityProviderFunc func(ctx context.Context, accountID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error) + UpdateIdentityProviderFunc func(ctx context.Context, accountID, idpID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error) + DeleteIdentityProviderFunc func(ctx context.Context, accountID, idpID, userID string) error + CreatePeerJobFunc func(ctx context.Context, accountID, peerID, userID string, job *types.Job) error + GetAllPeerJobsFunc func(ctx context.Context, accountID, userID, peerID string) ([]*types.Job, error) + GetPeerJobByIDFunc func(ctx context.Context, accountID, userID, peerID, jobID string) (*types.Job, error) + CreateUserInviteFunc func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) + AcceptUserInviteFunc func(ctx context.Context, token, password string) error + RegenerateUserInviteFunc func(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error) + GetUserInviteInfoFunc func(ctx context.Context, token string) (*types.UserInviteInfo, error) + ListUserInvitesFunc func(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error) + DeleteUserInviteFunc func(ctx context.Context, accountID, initiatorUserID, inviteID string) error +} + +func (am *MockAccountManager) SetServiceManager(serviceManager service.Manager) { + // Mock implementation - no-op +} + +func (am *MockAccountManager) CreatePeerJob(ctx context.Context, accountID, peerID, userID string, job *types.Job) error { + if am.CreatePeerJobFunc != nil { + return am.CreatePeerJobFunc(ctx, accountID, peerID, userID, job) + } + return status.Errorf(codes.Unimplemented, "method CreatePeerJob is not implemented") +} + +func (am *MockAccountManager) GetAllPeerJobs(ctx context.Context, accountID, userID, peerID string) ([]*types.Job, error) { + if am.GetAllPeerJobsFunc != nil { + return am.GetAllPeerJobsFunc(ctx, accountID, userID, peerID) + } + return nil, status.Errorf(codes.Unimplemented, "method GetAllPeerJobs is not implemented") +} +func (am *MockAccountManager) GetPeerJobByID(ctx context.Context, accountID, userID, peerID, jobID string) (*types.Job, error) { + if am.GetPeerJobByIDFunc != nil { + return am.GetPeerJobByIDFunc(ctx, accountID, userID, peerID, jobID) + } + return nil, status.Errorf(codes.Unimplemented, "method GetPeerJobByID is not implemented") } func (am *MockAccountManager) CreateGroup(ctx context.Context, accountID, userID string, group *types.Group) error { @@ -159,15 +205,21 @@ func (am *MockAccountManager) UpdateGroups(ctx context.Context, accountID, userI return status.Errorf(codes.Unimplemented, "method UpdateGroups is not implemented") } -func (am *MockAccountManager) UpdateAccountPeers(ctx context.Context, accountID string) { +func (am *MockAccountManager) UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) { if am.UpdateAccountPeersFunc != nil { - am.UpdateAccountPeersFunc(ctx, accountID) + am.UpdateAccountPeersFunc(ctx, accountID, reason) } } -func (am *MockAccountManager) BufferUpdateAccountPeers(ctx context.Context, accountID string) { +func (am *MockAccountManager) ExpandAndUpdateAffected(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change) { + if am.ExpandAndUpdateAffectedFunc != nil { + am.ExpandAndUpdateAffectedFunc(ctx, accountID, snap, change) + } +} + +func (am *MockAccountManager) BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) { if am.BufferUpdateAccountPeersFunc != nil { - am.BufferUpdateAccountPeersFunc(ctx, accountID) + am.BufferUpdateAccountPeersFunc(ctx, accountID, reason) } } @@ -178,16 +230,22 @@ func (am *MockAccountManager) DeleteSetupKey(ctx context.Context, accountID, use return status.Errorf(codes.Unimplemented, "method DeleteSetupKey is not implemented") } -func (am *MockAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { +func (am *MockAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { if am.SyncAndMarkPeerFunc != nil { - return am.SyncAndMarkPeerFunc(ctx, accountID, peerPubKey, meta, realIP) + return am.SyncAndMarkPeerFunc(ctx, accountID, peerPubKey, meta, realIP, syncTime) } return nil, nil, nil, 0, status.Errorf(codes.Unimplemented, "method MarkPeerConnected is not implemented") } -func (am *MockAccountManager) OnPeerDisconnected(_ context.Context, accountID string, peerPubKey string) error { - // TODO implement me - panic("implement me") +func (am *MockAccountManager) OnPeerDisconnected(ctx context.Context, accountID string, peerPubKey string, streamStartTime time.Time) error { + // Mirror DefaultAccountManager.OnPeerDisconnected: drive the fencing + // hook so tests that inject MarkPeerDisconnectedFunc actually observe + // disconnect events. Falls through to nil when no hook is set, which + // is the original behaviour. + if am.MarkPeerDisconnectedFunc != nil { + return am.MarkPeerDisconnectedFunc(ctx, peerPubKey, accountID, streamStartTime.UnixNano()) + } + return nil } func (am *MockAccountManager) GetValidatedPeers(ctx context.Context, accountID string) (map[string]struct{}, map[string]string, error) { @@ -237,10 +295,10 @@ func (am *MockAccountManager) DeletePeer(ctx context.Context, accountID, peerID, // GetOrCreateAccountByUser mock implementation of GetOrCreateAccountByUser from server.AccountManager interface func (am *MockAccountManager) GetOrCreateAccountByUser( - ctx context.Context, userId, domain string, + ctx context.Context, userAuth auth.UserAuth, ) (*types.Account, error) { if am.GetOrCreateAccountByUserFunc != nil { - return am.GetOrCreateAccountByUserFunc(ctx, userId, domain) + return am.GetOrCreateAccountByUserFunc(ctx, userAuth) } return nil, status.Errorf( codes.Unimplemented, @@ -276,9 +334,9 @@ func (am *MockAccountManager) AccountExists(ctx context.Context, accountID strin } // GetAccountIDByUserID mock implementation of GetAccountIDByUserID from server.AccountManager interface -func (am *MockAccountManager) GetAccountIDByUserID(ctx context.Context, userId, domain string) (string, error) { +func (am *MockAccountManager) GetAccountIDByUserID(ctx context.Context, userAuth auth.UserAuth) (string, error) { if am.GetAccountIDByUserIdFunc != nil { - return am.GetAccountIDByUserIdFunc(ctx, userId, domain) + return am.GetAccountIDByUserIdFunc(ctx, userAuth) } return "", status.Errorf( codes.Unimplemented, @@ -287,13 +345,21 @@ func (am *MockAccountManager) GetAccountIDByUserID(ctx context.Context, userId, } // MarkPeerConnected mock implementation of MarkPeerConnected from server.AccountManager interface -func (am *MockAccountManager) MarkPeerConnected(ctx context.Context, peerKey string, connected bool, realIP net.IP, accountID string) error { +func (am *MockAccountManager) MarkPeerConnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error { if am.MarkPeerConnectedFunc != nil { - return am.MarkPeerConnectedFunc(ctx, peerKey, connected, realIP) + return am.MarkPeerConnectedFunc(ctx, peerKey, accountID, sessionStartedAt, nmap) } return status.Errorf(codes.Unimplemented, "method MarkPeerConnected is not implemented") } +// MarkPeerDisconnected mock implementation of MarkPeerDisconnected from server.AccountManager interface +func (am *MockAccountManager) MarkPeerDisconnected(ctx context.Context, peerKey string, accountID string, sessionStartedAt int64) error { + if am.MarkPeerDisconnectedFunc != nil { + return am.MarkPeerDisconnectedFunc(ctx, peerKey, accountID, sessionStartedAt) + } + return status.Errorf(codes.Unimplemented, "method MarkPeerDisconnected is not implemented") +} + // DeleteAccount mock implementation of DeleteAccount from server.AccountManager interface func (am *MockAccountManager) DeleteAccount(ctx context.Context, accountID, userID string) error { if am.DeleteAccountFunc != nil { @@ -358,17 +424,17 @@ func (am *MockAccountManager) AddPeer( userId string, peer *nbpeer.Peer, temporary bool, -) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) { +) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) { if am.AddPeerFunc != nil { return am.AddPeerFunc(ctx, accountID, setupKey, userId, peer, temporary) } - return nil, nil, nil, status.Errorf(codes.Unimplemented, "method AddPeer is not implemented") + return nil, nil, nil, false, status.Errorf(codes.Unimplemented, "method AddPeer is not implemented") } // GetGroupByName mock implementation of GetGroupByName from server.AccountManager interface -func (am *MockAccountManager) GetGroupByName(ctx context.Context, accountID, groupName string) (*types.Group, error) { - if am.GetGroupFunc != nil { - return am.GetGroupByNameFunc(ctx, accountID, groupName) +func (am *MockAccountManager) GetGroupByName(ctx context.Context, groupName, accountID, userID string) (*types.Group, error) { + if am.GetGroupByNameFunc != nil { + return am.GetGroupByNameFunc(ctx, groupName, accountID, userID) } return nil, status.Errorf(codes.Unimplemented, "method GetGroupByName is not implemented") } @@ -470,7 +536,7 @@ func (am *MockAccountManager) UpdatePeerMeta(ctx context.Context, peerID string, } // GetUser mock implementation of GetUser from server.AccountManager interface -func (am *MockAccountManager) GetUserFromUserAuth(ctx context.Context, userAuth nbcontext.UserAuth) (*types.User, error) { +func (am *MockAccountManager) GetUserFromUserAuth(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) { if am.GetUserFromUserAuthFunc != nil { return am.GetUserFromUserAuthFunc(ctx, userAuth) } @@ -499,6 +565,13 @@ func (am *MockAccountManager) UpdatePeerIP(ctx context.Context, accountID, userI return status.Errorf(codes.Unimplemented, "method UpdatePeerIP is not implemented") } +func (am *MockAccountManager) UpdatePeerIPv6(ctx context.Context, accountID, userID, peerID string, newIPv6 netip.Addr) error { + if am.UpdatePeerIPv6Func != nil { + return am.UpdatePeerIPv6Func(ctx, accountID, userID, peerID, newIPv6) + } + return status.Errorf(codes.Unimplemented, "method UpdatePeerIPv6 is not implemented") +} + // CreateRoute mock implementation of CreateRoute from server.AccountManager interface func (am *MockAccountManager) CreateRoute(ctx context.Context, accountID string, prefix netip.Prefix, networkType route.NetworkType, domains domain.List, peerID string, peerGroupIDs []string, description string, netID route.NetID, masquerade bool, metric int, groups, accessControlGroupID []string, enabled bool, userID string, keepRoute bool, isSelected bool) (*route.Route, error) { if am.CreateRouteFunc != nil { @@ -606,6 +679,14 @@ func (am *MockAccountManager) DeleteRegularUsers(ctx context.Context, accountID, return status.Errorf(codes.Unimplemented, "method DeleteRegularUsers is not implemented") } +// UpdateUserPassword mocks UpdateUserPassword of the AccountManager interface +func (am *MockAccountManager) UpdateUserPassword(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error { + if am.UpdateUserPasswordFunc != nil { + return am.UpdateUserPasswordFunc(ctx, accountID, currentUserID, targetUserID, oldPassword, newPassword) + } + return status.Errorf(codes.Unimplemented, "method UpdateUserPassword is not implemented") +} + func (am *MockAccountManager) InviteUser(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) error { if am.InviteUserFunc != nil { return am.InviteUserFunc(ctx, accountID, initiatorUserID, targetUserID) @@ -675,7 +756,49 @@ func (am *MockAccountManager) CreateUser(ctx context.Context, accountID, userID return nil, status.Errorf(codes.Unimplemented, "method CreateUser is not implemented") } -func (am *MockAccountManager) GetAccountIDFromUserAuth(ctx context.Context, userAuth nbcontext.UserAuth) (string, string, error) { +func (am *MockAccountManager) CreateUserInvite(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) { + if am.CreateUserInviteFunc != nil { + return am.CreateUserInviteFunc(ctx, accountID, initiatorUserID, invite, expiresIn) + } + return nil, status.Errorf(codes.Unimplemented, "method CreateUserInvite is not implemented") +} + +func (am *MockAccountManager) AcceptUserInvite(ctx context.Context, token, password string) error { + if am.AcceptUserInviteFunc != nil { + return am.AcceptUserInviteFunc(ctx, token, password) + } + return status.Errorf(codes.Unimplemented, "method AcceptUserInvite is not implemented") +} + +func (am *MockAccountManager) RegenerateUserInvite(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error) { + if am.RegenerateUserInviteFunc != nil { + return am.RegenerateUserInviteFunc(ctx, accountID, initiatorUserID, inviteID, expiresIn) + } + return nil, status.Errorf(codes.Unimplemented, "method RegenerateUserInvite is not implemented") +} + +func (am *MockAccountManager) GetUserInviteInfo(ctx context.Context, token string) (*types.UserInviteInfo, error) { + if am.GetUserInviteInfoFunc != nil { + return am.GetUserInviteInfoFunc(ctx, token) + } + return nil, status.Errorf(codes.Unimplemented, "method GetUserInviteInfo is not implemented") +} + +func (am *MockAccountManager) ListUserInvites(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error) { + if am.ListUserInvitesFunc != nil { + return am.ListUserInvitesFunc(ctx, accountID, initiatorUserID) + } + return nil, status.Errorf(codes.Unimplemented, "method ListUserInvites is not implemented") +} + +func (am *MockAccountManager) DeleteUserInvite(ctx context.Context, accountID, initiatorUserID, inviteID string) error { + if am.DeleteUserInviteFunc != nil { + return am.DeleteUserInviteFunc(ctx, accountID, initiatorUserID, inviteID) + } + return status.Errorf(codes.Unimplemented, "method DeleteUserInvite is not implemented") +} + +func (am *MockAccountManager) GetAccountIDFromUserAuth(ctx context.Context, userAuth auth.UserAuth) (string, string, error) { if am.GetAccountIDFromUserAuthFunc != nil { return am.GetAccountIDFromUserAuthFunc(ctx, userAuth) } @@ -739,11 +862,19 @@ func (am *MockAccountManager) UpdateAccountSettings(ctx context.Context, account } // LoginPeer mocks LoginPeer of the AccountManager interface -func (am *MockAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) { +func (am *MockAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) { if am.LoginPeerFunc != nil { return am.LoginPeerFunc(ctx, login) } - return nil, nil, nil, status.Errorf(codes.Unimplemented, "method LoginPeer is not implemented") + return nil, nil, nil, false, status.Errorf(codes.Unimplemented, "method LoginPeer is not implemented") +} + +// ExtendPeerSession mocks ExtendPeerSession of the AccountManager interface +func (am *MockAccountManager) ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) { + if am.ExtendPeerSessionFunc != nil { + return am.ExtendPeerSessionFunc(ctx, peerPubKey, userID) + } + return time.Time{}, status.Errorf(codes.Unimplemented, "method ExtendPeerSession is not implemented") } // SyncPeer mocks SyncPeer of the AccountManager interface @@ -844,9 +975,9 @@ func (am *MockAccountManager) GroupValidation(ctx context.Context, accountId str } // SyncPeerMeta mocks SyncPeerMeta of the AccountManager interface -func (am *MockAccountManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta) error { +func (am *MockAccountManager) SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) error { if am.SyncPeerMetaFunc != nil { - return am.SyncPeerMetaFunc(ctx, peerPubKey, meta) + return am.SyncPeerMetaFunc(ctx, peerPubKey, meta, realIP) } return status.Errorf(codes.Unimplemented, "method SyncPeerMeta is not implemented") } @@ -937,7 +1068,7 @@ func (am *MockAccountManager) BuildUserInfosForAccount(ctx context.Context, acco return nil, status.Errorf(codes.Unimplemented, "method BuildUserInfosForAccount is not implemented") } -func (am *MockAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth nbcontext.UserAuth) error { +func (am *MockAccountManager) SyncUserJWTGroups(ctx context.Context, userAuth auth.UserAuth) error { return status.Errorf(codes.Unimplemented, "method SyncUserJWTGroups is not implemented") } @@ -969,18 +1100,13 @@ func (am *MockAccountManager) GetOwnerInfo(ctx context.Context, accountId string return nil, status.Errorf(codes.Unimplemented, "method GetOwnerInfo is not implemented") } -func (am *MockAccountManager) GetCurrentUserInfo(ctx context.Context, userAuth nbcontext.UserAuth) (*users.UserInfoWithPermissions, error) { +func (am *MockAccountManager) GetCurrentUserInfo(ctx context.Context, userAuth auth.UserAuth) (*users.UserInfoWithPermissions, error) { if am.GetCurrentUserInfoFunc != nil { return am.GetCurrentUserInfoFunc(ctx, userAuth) } return nil, status.Errorf(codes.Unimplemented, "method GetCurrentUserInfo is not implemented") } -// SetEphemeralManager mocks SetEphemeralManager of the AccountManager interface -func (am *MockAccountManager) SetEphemeralManager(em ephemeral.Manager) { - // Mock implementation - does nothing -} - func (am *MockAccountManager) AllowSync(key string, hash uint64) bool { if am.AllowSyncFunc != nil { return am.AllowSyncFunc(key, hash) @@ -994,3 +1120,47 @@ func (am *MockAccountManager) RecalculateNetworkMapCache(ctx context.Context, ac } return nil } + +func (am *MockAccountManager) GetUserIDByPeerKey(ctx context.Context, peerKey string) (string, error) { + return "something", nil +} + +// GetIdentityProvider mocks GetIdentityProvider of the AccountManager interface +func (am *MockAccountManager) GetIdentityProvider(ctx context.Context, accountID, idpID, userID string) (*types.IdentityProvider, error) { + if am.GetIdentityProviderFunc != nil { + return am.GetIdentityProviderFunc(ctx, accountID, idpID, userID) + } + return nil, status.Errorf(codes.Unimplemented, "method GetIdentityProvider is not implemented") +} + +// GetIdentityProviders mocks GetIdentityProviders of the AccountManager interface +func (am *MockAccountManager) GetIdentityProviders(ctx context.Context, accountID, userID string) ([]*types.IdentityProvider, error) { + if am.GetIdentityProvidersFunc != nil { + return am.GetIdentityProvidersFunc(ctx, accountID, userID) + } + return nil, status.Errorf(codes.Unimplemented, "method GetIdentityProviders is not implemented") +} + +// CreateIdentityProvider mocks CreateIdentityProvider of the AccountManager interface +func (am *MockAccountManager) CreateIdentityProvider(ctx context.Context, accountID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error) { + if am.CreateIdentityProviderFunc != nil { + return am.CreateIdentityProviderFunc(ctx, accountID, userID, idp) + } + return nil, status.Errorf(codes.Unimplemented, "method CreateIdentityProvider is not implemented") +} + +// UpdateIdentityProvider mocks UpdateIdentityProvider of the AccountManager interface +func (am *MockAccountManager) UpdateIdentityProvider(ctx context.Context, accountID, idpID, userID string, idp *types.IdentityProvider) (*types.IdentityProvider, error) { + if am.UpdateIdentityProviderFunc != nil { + return am.UpdateIdentityProviderFunc(ctx, accountID, idpID, userID, idp) + } + return nil, status.Errorf(codes.Unimplemented, "method UpdateIdentityProvider is not implemented") +} + +// DeleteIdentityProvider mocks DeleteIdentityProvider of the AccountManager interface +func (am *MockAccountManager) DeleteIdentityProvider(ctx context.Context, accountID, idpID, userID string) error { + if am.DeleteIdentityProviderFunc != nil { + return am.DeleteIdentityProviderFunc(ctx, accountID, idpID, userID) + } + return status.Errorf(codes.Unimplemented, "method DeleteIdentityProvider is not implemented") +} diff --git a/management/server/nameserver.go b/management/server/nameserver.go index f278e1761..0a4c2291e 100644 --- a/management/server/nameserver.go +++ b/management/server/nameserver.go @@ -3,28 +3,29 @@ package server import ( "context" "errors" - "regexp" + "fmt" + "slices" + "strings" "unicode/utf8" - "github.com/miekg/dns" "github.com/rs/xid" nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/types" + nbdomain "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/status" ) -const domainPattern = `^(?i)[a-z0-9]+([\-\.]{1}[a-z0-9]+)*[*.a-z]{1,}$` - -var invalidDomainName = errors.New("invalid domain name") +var errInvalidDomainName = errors.New("invalid domain name") // GetNameServerGroup gets a nameserver group object from account and nameserver group IDs func (am *DefaultAccountManager) GetNameServerGroup(ctx context.Context, accountID, userID, nsGroupID string) (*nbdns.NameServerGroup, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Read) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -37,7 +38,7 @@ func (am *DefaultAccountManager) GetNameServerGroup(ctx context.Context, account // CreateNameServerGroup creates and saves a new nameserver group func (am *DefaultAccountManager) CreateNameServerGroup(ctx context.Context, accountID string, name, description string, nameServerList []nbdns.NameServer, groups []string, primary bool, domains []string, enabled bool, userID string, searchDomainEnabled bool) (*nbdns.NameServerGroup, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Create) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Create) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -58,19 +59,21 @@ func (am *DefaultAccountManager) CreateNameServerGroup(ctx context.Context, acco SearchDomainsEnabled: searchDomainEnabled, } - var updateAccountPeers bool + var snap *affectedpeers.Snapshot + change := affectedpeers.Change{DistributionGroupIDs: newNSGroup.Groups} err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err = validateNameServerGroup(ctx, transaction, accountID, newNSGroup); err != nil { return err } - updateAccountPeers, err = anyGroupHasPeersOrResources(ctx, transaction, accountID, newNSGroup.Groups) - if err != nil { + newNSGroup.PublicID = xid.New().String() + + if err = transaction.SaveNameServerGroup(ctx, newNSGroup); err != nil { return err } - if err = transaction.SaveNameServerGroup(ctx, newNSGroup); err != nil { + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } @@ -82,9 +85,7 @@ func (am *DefaultAccountManager) CreateNameServerGroup(ctx context.Context, acco am.StoreEvent(ctx, userID, newNSGroup.ID, accountID, activity.NameserverGroupCreated, newNSGroup.EventMeta()) - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return newNSGroup.Copy(), nil } @@ -95,7 +96,7 @@ func (am *DefaultAccountManager) SaveNameServerGroup(ctx context.Context, accoun return status.Errorf(status.InvalidArgument, "nameserver group provided is nil") } - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Update) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Update) if err != nil { return status.NewPermissionValidationError(err) } @@ -103,7 +104,8 @@ func (am *DefaultAccountManager) SaveNameServerGroup(ctx context.Context, accoun return status.NewPermissionDeniedError() } - var updateAccountPeers bool + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { oldNSGroup, err := transaction.GetNameServerGroupByID(ctx, store.LockingStrengthNone, accountID, nsGroupToSave.ID) @@ -116,12 +118,14 @@ func (am *DefaultAccountManager) SaveNameServerGroup(ctx context.Context, accoun return err } - updateAccountPeers, err = areNameServerGroupChangesAffectPeers(ctx, transaction, nsGroupToSave, oldNSGroup) - if err != nil { + nsGroupToSave.PublicID = oldNSGroup.PublicID + + if err = transaction.SaveNameServerGroup(ctx, nsGroupToSave); err != nil { return err } - if err = transaction.SaveNameServerGroup(ctx, nsGroupToSave); err != nil { + change = affectedpeers.Change{DistributionGroupIDs: slices.Concat(nsGroupToSave.Groups, oldNSGroup.Groups)} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } @@ -133,16 +137,14 @@ func (am *DefaultAccountManager) SaveNameServerGroup(ctx context.Context, accoun am.StoreEvent(ctx, userID, nsGroupToSave.ID, accountID, activity.NameserverGroupUpdated, nsGroupToSave.EventMeta()) - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } // DeleteNameServerGroup deletes nameserver group with nsGroupID func (am *DefaultAccountManager) DeleteNameServerGroup(ctx context.Context, accountID, nsGroupID, userID string) error { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Delete) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Delete) if err != nil { return status.NewPermissionValidationError(err) } @@ -151,7 +153,8 @@ func (am *DefaultAccountManager) DeleteNameServerGroup(ctx context.Context, acco } var nsGroup *nbdns.NameServerGroup - var updateAccountPeers bool + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { nsGroup, err = transaction.GetNameServerGroupByID(ctx, store.LockingStrengthUpdate, accountID, nsGroupID) @@ -159,8 +162,9 @@ func (am *DefaultAccountManager) DeleteNameServerGroup(ctx context.Context, acco return err } - updateAccountPeers, err = anyGroupHasPeersOrResources(ctx, transaction, accountID, nsGroup.Groups) - if err != nil { + // Load before delete: the post-delete state no longer references the groups. + change = affectedpeers.Change{DistributionGroupIDs: nsGroup.Groups} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } @@ -176,16 +180,14 @@ func (am *DefaultAccountManager) DeleteNameServerGroup(ctx context.Context, acco am.StoreEvent(ctx, userID, nsGroup.ID, accountID, activity.NameserverGroupDeleted, nsGroup.EventMeta()) - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } // ListNameServerGroups returns a list of nameserver groups from account func (am *DefaultAccountManager) ListNameServerGroups(ctx context.Context, accountID string, userID string) ([]*nbdns.NameServerGroup, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Read) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Nameservers, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -225,24 +227,6 @@ func validateNameServerGroup(ctx context.Context, transaction store.Store, accou return validateGroups(nameserverGroup.Groups, groups) } -// areNameServerGroupChangesAffectPeers checks if the changes in the nameserver group affect the peers. -func areNameServerGroupChangesAffectPeers(ctx context.Context, transaction store.Store, newNSGroup, oldNSGroup *nbdns.NameServerGroup) (bool, error) { - if !newNSGroup.Enabled && !oldNSGroup.Enabled { - return false, nil - } - - hasPeers, err := anyGroupHasPeersOrResources(ctx, transaction, newNSGroup.AccountID, newNSGroup.Groups) - if err != nil { - return false, err - } - - if hasPeers { - return true, nil - } - - return anyGroupHasPeersOrResources(ctx, transaction, oldNSGroup.AccountID, oldNSGroup.Groups) -} - func validateDomainInput(primary bool, domains []string, searchDomainsEnabled bool) error { if !primary && len(domains) == 0 { return status.Errorf(status.InvalidArgument, "nameserver group primary status is false and domains are empty,"+ @@ -305,16 +289,18 @@ func validateGroups(list []string, groups map[string]*types.Group) error { return nil } -var domainMatcher = regexp.MustCompile(domainPattern) - -func validateDomain(domain string) error { - if !domainMatcher.MatchString(domain) { - return errors.New("domain should consists of only letters, numbers, and hyphens with no leading, trailing hyphens, or spaces") +// validateDomain validates a nameserver match domain. +// Converts unicode to punycode. Wildcards are not allowed for nameservers. +func validateDomain(d string) error { + if strings.HasPrefix(d, "*.") { + return errors.New("wildcards not allowed") } - _, valid := dns.IsDomainName(domain) - if !valid { - return invalidDomainName + // Nameservers allow trailing dot (FQDN format) + toValidate := strings.TrimSuffix(d, ".") + + if _, err := nbdomain.ValidateDomains([]string{toValidate}); err != nil { + return fmt.Errorf("%w: %w", errInvalidDomainName, err) } return nil diff --git a/management/server/nameserver_test.go b/management/server/nameserver_test.go index 35291b30c..e13b0bb19 100644 --- a/management/server/nameserver_test.go +++ b/management/server/nameserver_test.go @@ -13,8 +13,13 @@ import ( 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" + ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" + "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" + "github.com/netbirdio/netbird/management/server/job" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" @@ -787,13 +792,20 @@ func createNSManager(t *testing.T) (*DefaultAccountManager, error) { AnyTimes() permissionsManager := permissions.NewManager(store) + peersManager := peers.NewManager(store, permissionsManager) ctx := context.Background() + + cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100) + if err != nil { + return nil, err + } + updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock()) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}) - return BuildManager(context.Background(), store, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false) + return BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) } func createNSStore(t *testing.T) (store.Store, error) { @@ -862,7 +874,7 @@ func initTestNSAccount(t *testing.T, am *DefaultAccountManager) (*types.Account, userID := testUserID domain := "example.com" - account := newAccountWithId(context.Background(), accountID, userID, domain, false) + account := newAccountWithId(context.Background(), accountID, userID, domain, "", "", false) account.NameServerGroups[existingNSGroup.ID] = &existingNSGroup @@ -884,11 +896,11 @@ func initTestNSAccount(t *testing.T, am *DefaultAccountManager) (*types.Account, return nil, err } - _, _, _, err = am.AddPeer(context.Background(), "", "", userID, peer1, false) + _, _, _, _, err = am.AddPeer(context.Background(), "", "", userID, peer1, false) if err != nil { return nil, err } - _, _, _, err = am.AddPeer(context.Background(), "", "", userID, peer2, false) + _, _, _, _, err = am.AddPeer(context.Background(), "", "", userID, peer2, false) if err != nil { return nil, err } @@ -896,82 +908,53 @@ func initTestNSAccount(t *testing.T, am *DefaultAccountManager) (*types.Account, return account, nil } +// TestValidateDomain tests nameserver-specific domain validation. +// Core domain validation is tested in shared/management/domain/validate_test.go. +// This test only covers nameserver-specific behavior: wildcard rejection and unicode support. func TestValidateDomain(t *testing.T) { testCases := []struct { name string domain string errFunc require.ErrorAssertionFunc }{ + // Nameserver-specific: wildcards not allowed { - name: "Valid domain name with multiple labels", - domain: "123.example.com", + name: "Wildcard prefix rejected", + domain: "*.example.com", + errFunc: require.Error, + }, + { + name: "Wildcard in middle rejected", + domain: "a.*.example.com", + errFunc: require.Error, + }, + // Nameserver-specific: unicode converted to punycode + { + name: "Unicode domain converted to punycode", + domain: "münchen.de", errFunc: require.NoError, }, { - name: "Valid domain name with hyphen", - domain: "test-example.com", + name: "Unicode domain all labels", + domain: "中国.中国", + errFunc: require.NoError, + }, + // Basic validation still works (delegates to shared validation) + { + name: "Valid multi-label domain", + domain: "example.com", errFunc: require.NoError, }, { - name: "Valid domain name with only one label", - domain: "example", + name: "Valid single label", + domain: "internal", errFunc: require.NoError, }, { - name: "Valid domain name with trailing dot", - domain: "example.", - errFunc: require.NoError, - }, - { - name: "Invalid wildcard domain name", - domain: "*.example", - errFunc: require.Error, - }, - { - name: "Invalid domain name with leading dot", - domain: ".com", - errFunc: require.Error, - }, - { - name: "Invalid domain name with dot only", - domain: ".", - errFunc: require.Error, - }, - { - name: "Invalid domain name with double hyphen", - domain: "test--example.com", - errFunc: require.Error, - }, - { - name: "Invalid domain name with a label exceeding 63 characters", - domain: "dnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdnsdns.com", - errFunc: require.Error, - }, - { - name: "Invalid domain name starting with a hyphen", + name: "Invalid leading hyphen", domain: "-example.com", errFunc: require.Error, }, - { - name: "Invalid domain name ending with a hyphen", - domain: "example.com-", - errFunc: require.Error, - }, - { - name: "Invalid domain with unicode", - domain: "example?,.com", - errFunc: require.Error, - }, - { - name: "Invalid domain with space before top-level domain", - domain: "space .example.com", - errFunc: require.Error, - }, - { - name: "Invalid domain with trailing space", - domain: "example.com ", - errFunc: require.Error, - }, } for _, testCase := range testCases { @@ -1104,7 +1087,7 @@ func TestNameServerAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -1122,7 +1105,7 @@ func TestNameServerAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) diff --git a/management/server/networks/manager.go b/management/server/networks/manager.go index b6706ca45..fc03fff9f 100644 --- a/management/server/networks/manager.go +++ b/management/server/networks/manager.go @@ -8,6 +8,7 @@ import ( "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/networks/resources" "github.com/netbirdio/netbird/management/server/networks/routers" "github.com/netbirdio/netbird/management/server/networks/types" @@ -48,7 +49,7 @@ func NewManager(store store.Store, permissionsManager permissions.Manager, resou } func (m *managerImpl) GetAllNetworks(ctx context.Context, accountID, userID string) ([]*types.Network, error) { - ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read) + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -60,7 +61,7 @@ func (m *managerImpl) GetAllNetworks(ctx context.Context, accountID, userID stri } func (m *managerImpl) CreateNetwork(ctx context.Context, userID string, network *types.Network) (*types.Network, error) { - ok, err := m.permissionsManager.ValidateUserPermissions(ctx, network.AccountID, userID, modules.Networks, operations.Create) + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, network.AccountID, userID, modules.Networks, operations.Create) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -70,9 +71,16 @@ func (m *managerImpl) CreateNetwork(ctx context.Context, userID string, network network.ID = xid.New().String() - err = m.store.SaveNetwork(ctx, network) + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + network.PublicID = xid.New().String() + + if err := transaction.SaveNetwork(ctx, network); err != nil { + return fmt.Errorf("failed to save network: %w", err) + } + return nil + }) if err != nil { - return nil, fmt.Errorf("failed to save network: %w", err) + return nil, err } m.accountManager.StoreEvent(ctx, userID, network.ID, network.AccountID, activity.NetworkCreated, network.EventMeta()) @@ -81,7 +89,7 @@ func (m *managerImpl) CreateNetwork(ctx context.Context, userID string, network } func (m *managerImpl) GetNetwork(ctx context.Context, accountID, userID, networkID string) (*types.Network, error) { - ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read) + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -93,7 +101,7 @@ func (m *managerImpl) GetNetwork(ctx context.Context, accountID, userID, network } func (m *managerImpl) UpdateNetwork(ctx context.Context, userID string, network *types.Network) (*types.Network, error) { - ok, err := m.permissionsManager.ValidateUserPermissions(ctx, network.AccountID, userID, modules.Networks, operations.Update) + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, network.AccountID, userID, modules.Networks, operations.Update) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -101,18 +109,29 @@ func (m *managerImpl) UpdateNetwork(ctx context.Context, userID string, network return nil, status.NewPermissionDeniedError() } - _, err = m.store.GetNetworkByID(ctx, store.LockingStrengthUpdate, network.AccountID, network.ID) + err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + existing, err := transaction.GetNetworkByID(ctx, store.LockingStrengthUpdate, network.AccountID, network.ID) + if err != nil { + return fmt.Errorf("failed to get network: %w", err) + } + network.PublicID = existing.PublicID + + if err := transaction.SaveNetwork(ctx, network); err != nil { + return fmt.Errorf("failed to save network: %w", err) + } + return nil + }) if err != nil { - return nil, fmt.Errorf("failed to get network: %w", err) + return nil, err } m.accountManager.StoreEvent(ctx, userID, network.ID, network.AccountID, activity.NetworkUpdated, network.EventMeta()) - return network, m.store.SaveNetwork(ctx, network) + return network, nil } func (m *managerImpl) DeleteNetwork(ctx context.Context, accountID, userID, networkID string) error { - ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete) + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete) if err != nil { return status.NewPermissionValidationError(err) } @@ -126,30 +145,39 @@ func (m *managerImpl) DeleteNetwork(ctx context.Context, accountID, userID, netw } var eventsToStore []func() + var snap *affectedpeers.Snapshot + change := affectedpeers.Change{Networks: []*types.Network{network}} err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { resources, err := transaction.GetNetworkResourcesByNetID(ctx, store.LockingStrengthUpdate, accountID, networkID) if err != nil { return fmt.Errorf("failed to get resources in network: %w", err) } - for _, resource := range resources { - event, err := m.resourcesManager.DeleteResourceInTransaction(ctx, transaction, accountID, userID, networkID, resource.ID) - if err != nil { - return fmt.Errorf("failed to delete resource: %w", err) - } - eventsToStore = append(eventsToStore, event...) - } - - routers, err := transaction.GetNetworkRoutersByNetID(ctx, store.LockingStrengthUpdate, accountID, networkID) + netRouters, err := transaction.GetNetworkRoutersByNetID(ctx, store.LockingStrengthUpdate, accountID, networkID) if err != nil { return fmt.Errorf("failed to get routers in network: %w", err) } - for _, router := range routers { - event, err := m.routersManager.DeleteRouterInTransaction(ctx, transaction, accountID, userID, networkID, router.ID) + var lerr error + if snap, lerr = affectedpeers.Load(ctx, transaction, accountID, change); lerr != nil { + return lerr + } + + for _, resource := range resources { + deleted, event, err := m.resourcesManager.DeleteResourceInTransaction(ctx, transaction, accountID, userID, networkID, resource.ID) + if err != nil { + return fmt.Errorf("failed to delete resource: %w", err) + } + change.Resources = append(change.Resources, deleted) + eventsToStore = append(eventsToStore, event...) + } + + for _, router := range netRouters { + deleted, event, err := m.routersManager.DeleteRouterInTransaction(ctx, transaction, accountID, userID, networkID, router.ID) if err != nil { return fmt.Errorf("failed to delete router: %w", err) } + change.Routers = append(change.Routers, deleted) eventsToStore = append(eventsToStore, event) } @@ -177,7 +205,7 @@ func (m *managerImpl) DeleteNetwork(ctx context.Context, accountID, userID, netw event() } - go m.accountManager.UpdateAccountPeers(ctx, accountID) + m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } diff --git a/management/server/networks/manager_test.go b/management/server/networks/manager_test.go index bf196fcb3..b5fb1c72d 100644 --- a/management/server/networks/manager_test.go +++ b/management/server/networks/manager_test.go @@ -29,13 +29,16 @@ func Test_GetAllNetworksReturnsNetworks(t *testing.T) { permissionsManager := permissions.NewManager(s) groupsManager := groups.NewManagerMock() routerManager := routers.NewManagerMock() - resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am) + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) networks, err := manager.GetAllNetworks(ctx, accountID, userID) require.NoError(t, err) - require.Len(t, networks, 1) - require.Equal(t, "testNetworkId", networks[0].ID) + ids := make([]string, 0, len(networks)) + for _, n := range networks { + ids = append(ids, n.ID) + } + require.ElementsMatch(t, []string{"testNetworkId", "secondNetworkId"}, ids) } func Test_GetAllNetworksReturnsPermissionDenied(t *testing.T) { @@ -52,7 +55,7 @@ func Test_GetAllNetworksReturnsPermissionDenied(t *testing.T) { permissionsManager := permissions.NewManager(s) groupsManager := groups.NewManagerMock() routerManager := routers.NewManagerMock() - resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am) + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) networks, err := manager.GetAllNetworks(ctx, accountID, userID) @@ -75,7 +78,7 @@ func Test_GetNetworkReturnsNetwork(t *testing.T) { permissionsManager := permissions.NewManager(s) groupsManager := groups.NewManagerMock() routerManager := routers.NewManagerMock() - resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am) + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) networks, err := manager.GetNetwork(ctx, accountID, userID, networkID) @@ -98,7 +101,7 @@ func Test_GetNetworkReturnsPermissionDenied(t *testing.T) { permissionsManager := permissions.NewManager(s) groupsManager := groups.NewManagerMock() routerManager := routers.NewManagerMock() - resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am) + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) network, err := manager.GetNetwork(ctx, accountID, userID, networkID) @@ -123,7 +126,7 @@ func Test_CreateNetworkSuccessfully(t *testing.T) { permissionsManager := permissions.NewManager(s) groupsManager := groups.NewManagerMock() routerManager := routers.NewManagerMock() - resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am) + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) createdNetwork, err := manager.CreateNetwork(ctx, userID, network) @@ -148,7 +151,7 @@ func Test_CreateNetworkFailsWithPermissionDenied(t *testing.T) { permissionsManager := permissions.NewManager(s) groupsManager := groups.NewManagerMock() routerManager := routers.NewManagerMock() - resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am) + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) createdNetwork, err := manager.CreateNetwork(ctx, userID, network) @@ -171,7 +174,7 @@ func Test_DeleteNetworkSuccessfully(t *testing.T) { permissionsManager := permissions.NewManager(s) groupsManager := groups.NewManagerMock() routerManager := routers.NewManagerMock() - resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am) + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) err = manager.DeleteNetwork(ctx, accountID, userID, networkID) @@ -193,7 +196,7 @@ func Test_DeleteNetworkFailsWithPermissionDenied(t *testing.T) { permissionsManager := permissions.NewManager(s) groupsManager := groups.NewManagerMock() routerManager := routers.NewManagerMock() - resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am) + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) err = manager.DeleteNetwork(ctx, accountID, userID, networkID) @@ -218,7 +221,7 @@ func Test_UpdateNetworkSuccessfully(t *testing.T) { permissionsManager := permissions.NewManager(s) groupsManager := groups.NewManagerMock() routerManager := routers.NewManagerMock() - resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am) + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) updatedNetwork, err := manager.UpdateNetwork(ctx, userID, network) @@ -245,10 +248,80 @@ func Test_UpdateNetworkFailsWithPermissionDenied(t *testing.T) { permissionsManager := permissions.NewManager(s) groupsManager := groups.NewManagerMock() routerManager := routers.NewManagerMock() - resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am) + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) updatedNetwork, err := manager.UpdateNetwork(ctx, userID, network) require.Error(t, err) require.Nil(t, updatedNetwork) } + +// Test_CreateNetworkAllocatesSeqID verifies that CreateNetwork sets a +// non-zero AccountSeqID on the persisted network (allocated through the +// account_seq_counters table). +func Test_CreateNetworkSetsPublicId(t *testing.T) { + ctx := context.Background() + const accountID = "testAccountId" + const userID = "testAdminId" + + s, cleanUp, err := store.NewTestStoreFromSQL(ctx, "../testdata/networks.sql", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanUp) + + am := mock_server.MockAccountManager{} + permissionsManager := permissions.NewManager(s) + groupsManager := groups.NewManagerMock() + routerManager := routers.NewManagerMock() + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) + manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) + + created, err := manager.CreateNetwork(ctx, userID, &types.Network{ + AccountID: accountID, + Name: "seq-allocation-test", + }) + require.NoError(t, err) + require.NotEqual(t, "", created.PublicID, "CreateNetwork must allocate a non-zero AccountSeqID") +} + +// Test_UpdateNetworkPreservesSeqID verifies UpdateNetwork does not reset +// AccountSeqID even when the caller passes a zero value (the shape REST +// handlers produce because the field is `json:"-"`). +func Test_UpdateNetworkPreservesPublicId(t *testing.T) { + ctx := context.Background() + const accountID = "testAccountId" + const userID = "testAdminId" + + s, cleanUp, err := store.NewTestStoreFromSQL(ctx, "../testdata/networks.sql", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanUp) + + am := mock_server.MockAccountManager{} + permissionsManager := permissions.NewManager(s) + groupsManager := groups.NewManagerMock() + routerManager := routers.NewManagerMock() + resourcesManager := resources.NewManager(s, permissionsManager, groupsManager, &am, nil) + manager := NewManager(s, permissionsManager, resourcesManager, routerManager, &am) + + created, err := manager.CreateNetwork(ctx, userID, &types.Network{ + AccountID: accountID, + Name: "seq-preserve-original", + }) + require.NoError(t, err) + originalPublicId := created.PublicID + require.NotZero(t, originalPublicId) + + update := &types.Network{ + AccountID: accountID, + ID: created.ID, + Name: "seq-preserve-renamed", + } + require.Equal(t, "", update.PublicID, "incoming struct must mirror an HTTP handler shape") + + _, err = manager.UpdateNetwork(ctx, userID, update) + require.NoError(t, err) + + got, err := manager.GetNetwork(ctx, accountID, userID, created.ID) + require.NoError(t, err) + require.Equal(t, originalPublicId, got.PublicID, "PublicID must survive UpdateNetwork") + require.Equal(t, "seq-preserve-renamed", got.Name) +} diff --git a/management/server/networks/resources/manager.go b/management/server/networks/resources/manager.go index 66484d120..001af4d83 100644 --- a/management/server/networks/resources/manager.go +++ b/management/server/networks/resources/manager.go @@ -5,8 +5,13 @@ import ( "errors" "fmt" + "github.com/rs/xid" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/groups" "github.com/netbirdio/netbird/management/server/networks/resources/types" "github.com/netbirdio/netbird/management/server/permissions" @@ -26,7 +31,7 @@ type Manager interface { GetResource(ctx context.Context, accountID, userID, networkID, resourceID string) (*types.NetworkResource, error) UpdateResource(ctx context.Context, userID string, resource *types.NetworkResource) (*types.NetworkResource, error) DeleteResource(ctx context.Context, accountID, userID, networkID, resourceID string) error - DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) ([]func(), error) + DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) (*types.NetworkResource, []func(), error) } type managerImpl struct { @@ -34,22 +39,24 @@ type managerImpl struct { permissionsManager permissions.Manager groupsManager groups.Manager accountManager account.Manager + serviceManager service.Manager } type mockManager struct { } -func NewManager(store store.Store, permissionsManager permissions.Manager, groupsManager groups.Manager, accountManager account.Manager) Manager { +func NewManager(store store.Store, permissionsManager permissions.Manager, groupsManager groups.Manager, accountManager account.Manager, reverseproxyManager service.Manager) Manager { return &managerImpl{ store: store, permissionsManager: permissionsManager, groupsManager: groupsManager, accountManager: accountManager, + serviceManager: reverseproxyManager, } } func (m *managerImpl) GetAllResourcesInNetwork(ctx context.Context, accountID, userID, networkID string) ([]*types.NetworkResource, error) { - ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read) + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -61,7 +68,7 @@ func (m *managerImpl) GetAllResourcesInNetwork(ctx context.Context, accountID, u } func (m *managerImpl) GetAllResourcesInAccount(ctx context.Context, accountID, userID string) ([]*types.NetworkResource, error) { - ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read) + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -73,7 +80,7 @@ func (m *managerImpl) GetAllResourcesInAccount(ctx context.Context, accountID, u } func (m *managerImpl) GetAllResourceIDsInAccount(ctx context.Context, accountID, userID string) (map[string][]string, error) { - ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read) + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -95,7 +102,7 @@ func (m *managerImpl) GetAllResourceIDsInAccount(ctx context.Context, accountID, } func (m *managerImpl) CreateResource(ctx context.Context, userID string, resource *types.NetworkResource) (*types.NetworkResource, error) { - ok, err := m.permissionsManager.ValidateUserPermissions(ctx, resource.AccountID, userID, modules.Networks, operations.Create) + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, resource.AccountID, userID, modules.Networks, operations.Create) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -109,45 +116,12 @@ func (m *managerImpl) CreateResource(ctx context.Context, userID string, resourc } var eventsToStore []func() + var snap *affectedpeers.Snapshot + change := affectedpeers.Change{Resources: []*types.NetworkResource{resource}} err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - _, err = transaction.GetNetworkResourceByName(ctx, store.LockingStrengthNone, resource.AccountID, resource.Name) - if err == nil { - return status.Errorf(status.InvalidArgument, "resource with name %s already exists", resource.Name) - } - - network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthUpdate, resource.AccountID, resource.NetworkID) - if err != nil { - return fmt.Errorf("failed to get network: %w", err) - } - - err = transaction.SaveNetworkResource(ctx, resource) - if err != nil { - return fmt.Errorf("failed to save network resource: %w", err) - } - - event := func() { - m.accountManager.StoreEvent(ctx, userID, resource.ID, resource.AccountID, activity.NetworkResourceCreated, resource.EventMeta(network)) - } - eventsToStore = append(eventsToStore, event) - - res := nbtypes.Resource{ - ID: resource.ID, - Type: nbtypes.ResourceType(resource.Type.String()), - } - for _, groupID := range resource.GroupIDs { - event, err := m.groupsManager.AddResourceToGroupInTransaction(ctx, transaction, resource.AccountID, userID, groupID, &res) - if err != nil { - return fmt.Errorf("failed to add resource to group: %w", err) - } - eventsToStore = append(eventsToStore, event) - } - - err = transaction.IncrementNetworkSerial(ctx, resource.AccountID) - if err != nil { - return fmt.Errorf("failed to increment network serial: %w", err) - } - - return nil + var txErr error + eventsToStore, snap, txErr = m.createResourceInTransaction(ctx, transaction, userID, resource, change) + return txErr }) if err != nil { return nil, fmt.Errorf("failed to create network resource: %w", err) @@ -157,13 +131,59 @@ func (m *managerImpl) CreateResource(ctx context.Context, userID string, resourc event() } - go m.accountManager.UpdateAccountPeers(ctx, resource.AccountID) + m.accountManager.ExpandAndUpdateAffected(ctx, resource.AccountID, snap, change) return resource, nil } +func (m *managerImpl) createResourceInTransaction(ctx context.Context, transaction store.Store, userID string, resource *types.NetworkResource, change affectedpeers.Change) ([]func(), *affectedpeers.Snapshot, error) { + _, err := transaction.GetNetworkResourceByName(ctx, store.LockingStrengthNone, resource.AccountID, resource.Name) + if err == nil { + return nil, nil, status.Errorf(status.InvalidArgument, "resource with name %s already exists", resource.Name) + } + + network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthUpdate, resource.AccountID, resource.NetworkID) + if err != nil { + return nil, nil, fmt.Errorf("failed to get network: %w", err) + } + + resource.PublicID = xid.New().String() + + if err = transaction.SaveNetworkResource(ctx, resource); err != nil { + return nil, nil, fmt.Errorf("failed to save network resource: %w", err) + } + + var eventsToStore []func() + eventsToStore = append(eventsToStore, func() { + m.accountManager.StoreEvent(ctx, userID, resource.ID, resource.AccountID, activity.NetworkResourceCreated, resource.EventMeta(network)) + }) + + res := nbtypes.Resource{ + ID: resource.ID, + Type: nbtypes.ResourceType(resource.Type.String()), + } + for _, groupID := range resource.GroupIDs { + event, err := m.groupsManager.AddResourceToGroupInTransaction(ctx, transaction, resource.AccountID, userID, groupID, &res) + if err != nil { + return nil, nil, fmt.Errorf("failed to add resource to group: %w", err) + } + eventsToStore = append(eventsToStore, event) + } + + if err = transaction.IncrementNetworkSerial(ctx, resource.AccountID); err != nil { + return nil, nil, fmt.Errorf("failed to increment network serial: %w", err) + } + + snap, err := affectedpeers.Load(ctx, transaction, resource.AccountID, change) + if err != nil { + return nil, nil, err + } + + return eventsToStore, snap, nil +} + func (m *managerImpl) GetResource(ctx context.Context, accountID, userID, networkID, resourceID string) (*types.NetworkResource, error) { - ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read) + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -184,7 +204,7 @@ func (m *managerImpl) GetResource(ctx context.Context, accountID, userID, networ } func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resource *types.NetworkResource) (*types.NetworkResource, error) { - ok, err := m.permissionsManager.ValidateUserPermissions(ctx, resource.AccountID, userID, modules.Networks, operations.Update) + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, resource.AccountID, userID, modules.Networks, operations.Update) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -202,6 +222,8 @@ func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resourc resource.Prefix = prefix var eventsToStore []func() + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthUpdate, resource.AccountID, resource.NetworkID) if err != nil { @@ -226,6 +248,15 @@ func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resourc if err != nil { return fmt.Errorf("failed to get network resource: %w", err) } + resource.PublicID = oldResource.PublicID + + oldGroups, err := m.groupsManager.GetResourceGroupsInTransaction(ctx, transaction, store.LockingStrengthNone, resource.AccountID, resource.ID) + if err != nil { + return fmt.Errorf("failed to get old resource groups: %w", err) + } + for _, g := range oldGroups { + oldResource.GroupIDs = append(oldResource.GroupIDs, g.ID) + } err = transaction.SaveNetworkResource(ctx, resource) if err != nil { @@ -242,6 +273,11 @@ func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resourc m.accountManager.StoreEvent(ctx, userID, resource.ID, resource.AccountID, activity.NetworkResourceUpdated, resource.EventMeta(network)) }) + change = affectedpeers.Change{Resources: []*types.NetworkResource{oldResource, resource}} + if snap, err = affectedpeers.Load(ctx, transaction, resource.AccountID, change); err != nil { + return err + } + err = transaction.IncrementNetworkSerial(ctx, resource.AccountID) if err != nil { return fmt.Errorf("failed to increment network serial: %w", err) @@ -257,7 +293,15 @@ func (m *managerImpl) UpdateResource(ctx context.Context, userID string, resourc event() } - go m.accountManager.UpdateAccountPeers(ctx, resource.AccountID) + // TODO: optimize to only reload reverse proxies that are affected by the resource update instead of all of them + go func() { + err := m.serviceManager.ReloadAllServicesForAccount(ctx, resource.AccountID) + if err != nil { + log.WithContext(ctx).Warnf("failed to reload all proxies for account: %v", err) + } + }() + + m.accountManager.ExpandAndUpdateAffected(ctx, resource.AccountID, snap, change) return resource, nil } @@ -301,7 +345,7 @@ func (m *managerImpl) updateResourceGroups(ctx context.Context, transaction stor } func (m *managerImpl) DeleteResource(ctx context.Context, accountID, userID, networkID, resourceID string) error { - ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete) + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete) if err != nil { return status.NewPermissionValidationError(err) } @@ -309,9 +353,35 @@ func (m *managerImpl) DeleteResource(ctx context.Context, accountID, userID, net return status.NewPermissionDeniedError() } + serviceID, err := m.serviceManager.GetServiceIDByTargetID(ctx, accountID, resourceID) + if err != nil { + return fmt.Errorf("failed to check if resource is used by service: %w", err) + } + if serviceID != "" { + return status.NewResourceInUseError(resourceID, serviceID) + } + var events []func() + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - events, err = m.DeleteResourceInTransaction(ctx, transaction, accountID, userID, networkID, resourceID) + existing, err := transaction.GetNetworkResourceByID(ctx, store.LockingStrengthUpdate, accountID, resourceID) + if err != nil { + return fmt.Errorf("failed to get network resource: %w", err) + } + oldGroups, err := m.groupsManager.GetResourceGroupsInTransaction(ctx, transaction, store.LockingStrengthNone, accountID, resourceID) + if err != nil { + return fmt.Errorf("failed to get resource groups: %w", err) + } + for _, g := range oldGroups { + existing.GroupIDs = append(existing.GroupIDs, g.ID) + } + change = affectedpeers.Change{Resources: []*types.NetworkResource{existing}} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return err + } + + _, events, err = m.DeleteResourceInTransaction(ctx, transaction, accountID, userID, networkID, resourceID) if err != nil { return fmt.Errorf("failed to delete resource: %w", err) } @@ -331,51 +401,53 @@ func (m *managerImpl) DeleteResource(ctx context.Context, accountID, userID, net event() } - go m.accountManager.UpdateAccountPeers(ctx, accountID) + m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } -func (m *managerImpl) DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) ([]func(), error) { +func (m *managerImpl) DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) (*types.NetworkResource, []func(), error) { resource, err := transaction.GetNetworkResourceByID(ctx, store.LockingStrengthUpdate, accountID, resourceID) if err != nil { - return nil, fmt.Errorf("failed to get network resource: %w", err) + return nil, nil, fmt.Errorf("failed to get network resource: %w", err) } network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthUpdate, accountID, networkID) if err != nil { - return nil, fmt.Errorf("failed to get network: %w", err) + return nil, nil, fmt.Errorf("failed to get network: %w", err) } if resource.NetworkID != networkID { - return nil, errors.New("resource not part of network") + return nil, nil, errors.New("resource not part of network") } groups, err := m.groupsManager.GetResourceGroupsInTransaction(ctx, transaction, store.LockingStrengthUpdate, accountID, resourceID) if err != nil { - return nil, fmt.Errorf("failed to get resource groups: %w", err) + return nil, nil, fmt.Errorf("failed to get resource groups: %w", err) } var eventsToStore []func() for _, group := range groups { + resource.GroupIDs = append(resource.GroupIDs, group.ID) + event, err := m.groupsManager.RemoveResourceFromGroupInTransaction(ctx, transaction, accountID, userID, group.ID, resourceID) if err != nil { - return nil, fmt.Errorf("failed to remove resource from group: %w", err) + return nil, nil, fmt.Errorf("failed to remove resource from group: %w", err) } eventsToStore = append(eventsToStore, event) } err = transaction.DeleteNetworkResource(ctx, accountID, resourceID) if err != nil { - return nil, fmt.Errorf("failed to delete network resource: %w", err) + return nil, nil, fmt.Errorf("failed to delete network resource: %w", err) } eventsToStore = append(eventsToStore, func() { m.accountManager.StoreEvent(ctx, userID, resourceID, accountID, activity.NetworkResourceDeleted, resource.EventMeta(network)) }) - return eventsToStore, nil + return resource, eventsToStore, nil } func NewManagerMock() Manager { @@ -410,6 +482,6 @@ func (m *mockManager) DeleteResource(ctx context.Context, accountID, userID, net return nil } -func (m *mockManager) DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) ([]func(), error) { - return []func(){}, nil +func (m *mockManager) DeleteResourceInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, resourceID string) (*types.NetworkResource, []func(), error) { + return nil, []func(){}, nil } diff --git a/management/server/networks/resources/manager_test.go b/management/server/networks/resources/manager_test.go index c6cec6f7e..c6d8e7bcc 100644 --- a/management/server/networks/resources/manager_test.go +++ b/management/server/networks/resources/manager_test.go @@ -4,14 +4,16 @@ import ( "context" "testing" + "github.com/golang/mock/gomock" "github.com/stretchr/testify/require" + reverseproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/server/groups" "github.com/netbirdio/netbird/management/server/mock_server" "github.com/netbirdio/netbird/management/server/networks/resources/types" "github.com/netbirdio/netbird/management/server/permissions" - "github.com/netbirdio/netbird/shared/management/status" "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/status" ) func Test_GetAllResourcesInNetworkReturnsResources(t *testing.T) { @@ -28,7 +30,9 @@ func Test_GetAllResourcesInNetworkReturnsResources(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) resources, err := manager.GetAllResourcesInNetwork(ctx, accountID, userID, networkID) require.NoError(t, err) @@ -49,7 +53,9 @@ func Test_GetAllResourcesInNetworkReturnsPermissionDenied(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) resources, err := manager.GetAllResourcesInNetwork(ctx, accountID, userID, networkID) require.Error(t, err) @@ -69,7 +75,9 @@ func Test_GetAllResourcesInAccountReturnsResources(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) resources, err := manager.GetAllResourcesInAccount(ctx, accountID, userID) require.NoError(t, err) @@ -89,7 +97,9 @@ func Test_GetAllResourcesInAccountReturnsPermissionDenied(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) resources, err := manager.GetAllResourcesInAccount(ctx, accountID, userID) require.Error(t, err) @@ -112,7 +122,9 @@ func Test_GetResourceInNetworkReturnsResources(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) resource, err := manager.GetResource(ctx, accountID, userID, networkID, resourceID) require.NoError(t, err) @@ -134,7 +146,9 @@ func Test_GetResourceInNetworkReturnsPermissionDenied(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) resources, err := manager.GetResource(ctx, accountID, userID, networkID, resourceID) require.Error(t, err) @@ -161,7 +175,10 @@ func Test_CreateResourceSuccessfully(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + serviceManager.EXPECT().ReloadAllServicesForAccount(gomock.Any(), resource.AccountID).Return(nil).AnyTimes() + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) createdResource, err := manager.CreateResource(ctx, userID, resource) require.NoError(t, err) @@ -187,7 +204,9 @@ func Test_CreateResourceFailsWithPermissionDenied(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) createdResource, err := manager.CreateResource(ctx, userID, resource) require.Error(t, err) @@ -203,7 +222,7 @@ func Test_CreateResourceFailsWithInvalidAddress(t *testing.T) { NetworkID: "testNetworkId", Name: "testResourceId", Description: "description", - Address: "invalid-address", + Address: "-invalid", } store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "../../testdata/networks.sql", t.TempDir()) @@ -214,7 +233,9 @@ func Test_CreateResourceFailsWithInvalidAddress(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) createdResource, err := manager.CreateResource(ctx, userID, resource) require.Error(t, err) @@ -227,9 +248,9 @@ func Test_CreateResourceFailsWithUsedName(t *testing.T) { resource := &types.NetworkResource{ AccountID: "testAccountId", NetworkID: "testNetworkId", - Name: "testResourceId", + Name: "used-name", Description: "description", - Address: "invalid-address", + Address: "example.com", } store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "../../testdata/networks.sql", t.TempDir()) @@ -240,7 +261,9 @@ func Test_CreateResourceFailsWithUsedName(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) createdResource, err := manager.CreateResource(ctx, userID, resource) require.Error(t, err) @@ -270,7 +293,10 @@ func Test_UpdateResourceSuccessfully(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + serviceManager.EXPECT().ReloadAllServicesForAccount(gomock.Any(), accountID).Return(nil).AnyTimes() + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) updatedResource, err := manager.UpdateResource(ctx, userID, resource) require.NoError(t, err) @@ -302,7 +328,9 @@ func Test_UpdateResourceFailsWithResourceNotFound(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) updatedResource, err := manager.UpdateResource(ctx, userID, resource) require.Error(t, err) @@ -332,7 +360,9 @@ func Test_UpdateResourceFailsWithNameInUse(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) updatedResource, err := manager.UpdateResource(ctx, userID, resource) require.Error(t, err) @@ -361,7 +391,9 @@ func Test_UpdateResourceFailsWithPermissionDenied(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) updatedResource, err := manager.UpdateResource(ctx, userID, resource) require.Error(t, err) @@ -383,7 +415,10 @@ func Test_DeleteResourceSuccessfully(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + serviceManager.EXPECT().GetServiceIDByTargetID(gomock.Any(), accountID, resourceID).Return("", nil).AnyTimes() + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) err = manager.DeleteResource(ctx, accountID, userID, networkID, resourceID) require.NoError(t, err) @@ -404,7 +439,9 @@ func Test_DeleteResourceFailsWithPermissionDenied(t *testing.T) { permissionsManager := permissions.NewManager(store) am := mock_server.MockAccountManager{} groupsManager := groups.NewManagerMock() - manager := NewManager(store, permissionsManager, groupsManager, &am) + ctrl := gomock.NewController(t) + serviceManager := reverseproxy.NewMockManager(ctrl) + manager := NewManager(store, permissionsManager, groupsManager, &am, serviceManager) err = manager.DeleteResource(ctx, accountID, userID, networkID, resourceID) require.Error(t, err) diff --git a/management/server/networks/resources/types/resource.go b/management/server/networks/resources/types/resource.go index 7874be858..643f9cdd6 100644 --- a/management/server/networks/resources/types/resource.go +++ b/management/server/networks/resources/types/resource.go @@ -4,17 +4,17 @@ import ( "errors" "fmt" "net/netip" - "regexp" "github.com/rs/xid" - nbDomain "github.com/netbirdio/netbird/shared/management/domain" 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/route" + nbDomain "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/http/api" + sharedTypes "github.com/netbirdio/netbird/shared/management/types" ) type NetworkResourceType string @@ -33,6 +33,7 @@ type NetworkResource struct { ID string `gorm:"primaryKey"` NetworkID string `gorm:"index"` AccountID string `gorm:"index"` + PublicID string `json:"-"` Name string Description string Type NetworkResourceType @@ -64,6 +65,27 @@ func NewNetworkResource(accountID, networkID, name, description, address string, }, nil } +// ToComponent converts the resource to its self-contained components +// representation. Returns nil for a nil resource. +func (n *NetworkResource) ToComponent() *sharedTypes.ComponentResource { + if n == nil { + return nil + } + return &sharedTypes.ComponentResource{ + ID: n.ID, + PublicID: n.PublicID, + NetworkID: n.NetworkID, + AccountID: n.AccountID, + Name: n.Name, + Description: n.Description, + Type: sharedTypes.ComponentResourceType(n.Type), + Address: n.Address, + Domain: n.Domain, + Prefix: n.Prefix, + Enabled: n.Enabled, + } +} + func (n *NetworkResource) ToAPIResponse(groups []api.GroupMinimum) *api.NetworkResource { addr := n.Prefix.String() if n.Type == Domain { @@ -97,6 +119,7 @@ func (n *NetworkResource) Copy() *NetworkResource { ID: n.ID, AccountID: n.AccountID, NetworkID: n.NetworkID, + PublicID: n.PublicID, Name: n.Name, Description: n.Description, Type: n.Type, @@ -166,8 +189,7 @@ func GetResourceType(address string) (NetworkResourceType, string, netip.Prefix, return Host, "", netip.PrefixFrom(ip, ip.BitLen()), nil } - domainRegex := regexp.MustCompile(`^(\*\.)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$`) - if domainRegex.MatchString(address) { + if _, err := nbDomain.ValidateDomains([]string{address}); err == nil { return Domain, address, netip.Prefix{}, nil } diff --git a/management/server/networks/resources/types/resource_test.go b/management/server/networks/resources/types/resource_test.go index 02e802300..a842b0a28 100644 --- a/management/server/networks/resources/types/resource_test.go +++ b/management/server/networks/resources/types/resource_test.go @@ -23,10 +23,12 @@ func TestGetResourceType(t *testing.T) { {"example.com", Domain, false, "example.com", netip.Prefix{}}, {"*.example.com", Domain, false, "*.example.com", netip.Prefix{}}, {"sub.example.com", Domain, false, "sub.example.com", netip.Prefix{}}, + {"example.x", Domain, false, "example.x", netip.Prefix{}}, + {"internal", Domain, false, "internal", netip.Prefix{}}, // Invalid inputs - {"invalid", "", true, "", netip.Prefix{}}, {"1.1.1.1/abc", "", true, "", netip.Prefix{}}, - {"1234", "", true, "", netip.Prefix{}}, + {"-invalid.com", "", true, "", netip.Prefix{}}, + {"", "", true, "", netip.Prefix{}}, } for _, tt := range tests { diff --git a/management/server/networks/routers/manager.go b/management/server/networks/routers/manager.go index 82cac424a..f72716579 100644 --- a/management/server/networks/routers/manager.go +++ b/management/server/networks/routers/manager.go @@ -9,6 +9,7 @@ import ( "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/networks/routers/types" networkTypes "github.com/netbirdio/netbird/management/server/networks/types" "github.com/netbirdio/netbird/management/server/permissions" @@ -25,7 +26,7 @@ type Manager interface { GetRouter(ctx context.Context, accountID, userID, networkID, routerID string) (*types.NetworkRouter, error) UpdateRouter(ctx context.Context, userID string, router *types.NetworkRouter) (*types.NetworkRouter, error) DeleteRouter(ctx context.Context, accountID, userID, networkID, routerID string) error - DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (func(), error) + DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (*types.NetworkRouter, func(), error) } type managerImpl struct { @@ -46,7 +47,7 @@ func NewManager(store store.Store, permissionsManager permissions.Manager, accou } func (m *managerImpl) GetAllRoutersInNetwork(ctx context.Context, accountID, userID, networkID string) ([]*types.NetworkRouter, error) { - ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read) + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -58,7 +59,7 @@ func (m *managerImpl) GetAllRoutersInNetwork(ctx context.Context, accountID, use } func (m *managerImpl) GetAllRoutersInAccount(ctx context.Context, accountID, userID string) (map[string][]*types.NetworkRouter, error) { - ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read) + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -80,7 +81,7 @@ func (m *managerImpl) GetAllRoutersInAccount(ctx context.Context, accountID, use } func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *types.NetworkRouter) (*types.NetworkRouter, error) { - ok, err := m.permissionsManager.ValidateUserPermissions(ctx, router.AccountID, userID, modules.Networks, operations.Create) + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, router.AccountID, userID, modules.Networks, operations.Create) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -89,6 +90,8 @@ func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *t } var network *networkTypes.Network + var snap *affectedpeers.Snapshot + change := affectedpeers.Change{Routers: []*types.NetworkRouter{router}} err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { network, err = transaction.GetNetworkByID(ctx, store.LockingStrengthNone, router.AccountID, router.NetworkID) if err != nil { @@ -101,7 +104,9 @@ func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *t router.ID = xid.New().String() - err = transaction.SaveNetworkRouter(ctx, router) + router.PublicID = xid.New().String() + + err = transaction.CreateNetworkRouter(ctx, router) if err != nil { return fmt.Errorf("failed to create network router: %w", err) } @@ -111,6 +116,10 @@ func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *t return fmt.Errorf("failed to increment network serial: %w", err) } + if snap, err = affectedpeers.Load(ctx, transaction, router.AccountID, change); err != nil { + return err + } + return nil }) if err != nil { @@ -119,13 +128,13 @@ func (m *managerImpl) CreateRouter(ctx context.Context, userID string, router *t m.accountManager.StoreEvent(ctx, userID, router.ID, router.AccountID, activity.NetworkRouterCreated, router.EventMeta(network)) - go m.accountManager.UpdateAccountPeers(ctx, router.AccountID) + m.accountManager.ExpandAndUpdateAffected(ctx, router.AccountID, snap, change) return router, nil } func (m *managerImpl) GetRouter(ctx context.Context, accountID, userID, networkID, routerID string) (*types.NetworkRouter, error) { - ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read) + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -146,7 +155,7 @@ func (m *managerImpl) GetRouter(ctx context.Context, accountID, userID, networkI } func (m *managerImpl) UpdateRouter(ctx context.Context, userID string, router *types.NetworkRouter) (*types.NetworkRouter, error) { - ok, err := m.permissionsManager.ValidateUserPermissions(ctx, router.AccountID, userID, modules.Networks, operations.Update) + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, router.AccountID, userID, modules.Networks, operations.Update) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -155,27 +164,12 @@ func (m *managerImpl) UpdateRouter(ctx context.Context, userID string, router *t } var network *networkTypes.Network + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - network, err = transaction.GetNetworkByID(ctx, store.LockingStrengthNone, router.AccountID, router.NetworkID) - if err != nil { - return fmt.Errorf("failed to get network: %w", err) - } - - if network.ID != router.NetworkID { - return status.NewRouterNotPartOfNetworkError(router.ID, router.NetworkID) - } - - err = transaction.SaveNetworkRouter(ctx, router) - if err != nil { - return fmt.Errorf("failed to update network router: %w", err) - } - - err = transaction.IncrementNetworkSerial(ctx, router.AccountID) - if err != nil { - return fmt.Errorf("failed to increment network serial: %w", err) - } - - return nil + var txErr error + network, snap, change, txErr = m.updateRouterInTransaction(ctx, transaction, router) + return txErr }) if err != nil { return nil, err @@ -183,13 +177,54 @@ func (m *managerImpl) UpdateRouter(ctx context.Context, userID string, router *t m.accountManager.StoreEvent(ctx, userID, router.ID, router.AccountID, activity.NetworkRouterUpdated, router.EventMeta(network)) - go m.accountManager.UpdateAccountPeers(ctx, router.AccountID) + m.accountManager.ExpandAndUpdateAffected(ctx, router.AccountID, snap, change) return router, nil } +func (m *managerImpl) updateRouterInTransaction(ctx context.Context, transaction store.Store, router *types.NetworkRouter) (*networkTypes.Network, *affectedpeers.Snapshot, affectedpeers.Change, error) { + network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthNone, router.AccountID, router.NetworkID) + if err != nil { + return nil, nil, affectedpeers.Change{}, fmt.Errorf("failed to get network: %w", err) + } + + existing, err := transaction.GetNetworkRouterByID(ctx, store.LockingStrengthUpdate, router.AccountID, router.ID) + if err != nil { + return nil, nil, affectedpeers.Change{}, fmt.Errorf("failed to get network router: %w", err) + } + + if existing.AccountID != router.AccountID { + return nil, nil, affectedpeers.Change{}, status.NewNetworkRouterNotFoundError(router.ID) + } + + if existing.NetworkID != router.NetworkID { + return nil, nil, affectedpeers.Change{}, status.NewRouterNotPartOfNetworkError(router.ID, router.NetworkID) + } + + // Preserve PublicID from the existing router so the upstream + // UpdateNetworkRouter (which does Updates(router) with Select("*")) + // doesn't clobber it with the request's zero value. + router.PublicID = existing.PublicID + + if err = transaction.UpdateNetworkRouter(ctx, router); err != nil { + return nil, nil, affectedpeers.Change{}, fmt.Errorf("failed to update network router: %w", err) + } + + if err = transaction.IncrementNetworkSerial(ctx, router.AccountID); err != nil { + return nil, nil, affectedpeers.Change{}, fmt.Errorf("failed to increment network serial: %w", err) + } + + change := affectedpeers.Change{Routers: []*types.NetworkRouter{existing, router}} + snap, err := affectedpeers.Load(ctx, transaction, router.AccountID, change) + if err != nil { + return nil, nil, affectedpeers.Change{}, err + } + + return network, snap, change, nil +} + func (m *managerImpl) DeleteRouter(ctx context.Context, accountID, userID, networkID, routerID string) error { - ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete) + ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Networks, operations.Delete) if err != nil { return status.NewPermissionValidationError(err) } @@ -198,8 +233,19 @@ func (m *managerImpl) DeleteRouter(ctx context.Context, accountID, userID, netwo } var event func() + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - event, err = m.DeleteRouterInTransaction(ctx, transaction, accountID, userID, networkID, routerID) + existing, err := transaction.GetNetworkRouterByID(ctx, store.LockingStrengthUpdate, accountID, routerID) + if err != nil { + return fmt.Errorf("failed to get network router: %w", err) + } + change = affectedpeers.Change{Routers: []*types.NetworkRouter{existing}} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return err + } + + _, event, err = m.DeleteRouterInTransaction(ctx, transaction, accountID, userID, networkID, routerID) if err != nil { return fmt.Errorf("failed to delete network router: %w", err) } @@ -217,36 +263,36 @@ func (m *managerImpl) DeleteRouter(ctx context.Context, accountID, userID, netwo event() - go m.accountManager.UpdateAccountPeers(ctx, accountID) + m.accountManager.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } -func (m *managerImpl) DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (func(), error) { +func (m *managerImpl) DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (*types.NetworkRouter, func(), error) { network, err := transaction.GetNetworkByID(ctx, store.LockingStrengthNone, accountID, networkID) if err != nil { - return nil, fmt.Errorf("failed to get network: %w", err) + return nil, nil, fmt.Errorf("failed to get network: %w", err) } router, err := transaction.GetNetworkRouterByID(ctx, store.LockingStrengthUpdate, accountID, routerID) if err != nil { - return nil, fmt.Errorf("failed to get network router: %w", err) + return nil, nil, fmt.Errorf("failed to get network router: %w", err) } if router.NetworkID != networkID { - return nil, status.NewRouterNotPartOfNetworkError(routerID, networkID) + return nil, nil, status.NewRouterNotPartOfNetworkError(routerID, networkID) } err = transaction.DeleteNetworkRouter(ctx, accountID, routerID) if err != nil { - return nil, fmt.Errorf("failed to delete network router: %w", err) + return nil, nil, fmt.Errorf("failed to delete network router: %w", err) } event := func() { m.accountManager.StoreEvent(ctx, userID, routerID, accountID, activity.NetworkRouterDeleted, router.EventMeta(network)) } - return event, nil + return router, event, nil } func NewManagerMock() Manager { @@ -277,6 +323,9 @@ func (m *mockManager) DeleteRouter(ctx context.Context, accountID, userID, netwo return nil } -func (m *mockManager) DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (func(), error) { - return func() {}, nil +func (m *mockManager) DeleteRouterInTransaction(ctx context.Context, transaction store.Store, accountID, userID, networkID, routerID string) (*types.NetworkRouter, func(), error) { + return nil, func() { + // no-op mock: returns zero values so tests that don't exercise router deletion + // can satisfy the Manager interface without a real store. + }, nil } diff --git a/management/server/networks/routers/manager_test.go b/management/server/networks/routers/manager_test.go index 8054d05c6..7b6d5f14f 100644 --- a/management/server/networks/routers/manager_test.go +++ b/management/server/networks/routers/manager_test.go @@ -9,8 +9,8 @@ import ( "github.com/netbirdio/netbird/management/server/mock_server" "github.com/netbirdio/netbird/management/server/networks/routers/types" "github.com/netbirdio/netbird/management/server/permissions" - "github.com/netbirdio/netbird/shared/management/status" "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/management/status" ) func Test_GetAllRoutersInNetworkReturnsRouters(t *testing.T) { @@ -195,6 +195,7 @@ func Test_UpdateRouterSuccessfully(t *testing.T) { if err != nil { require.NoError(t, err) } + router.ID = "testRouterId" s, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "../../testdata/networks.sql", t.TempDir()) if err != nil { @@ -210,6 +211,102 @@ func Test_UpdateRouterSuccessfully(t *testing.T) { require.Equal(t, router.Metric, updatedRouter.Metric) } +func Test_UpdateRouterRejectsCrossAccountID(t *testing.T) { + ctx := context.Background() + userID := "testAdminId" + + // Admin of testAccountId tries to update a router that belongs to otherAccountId + // by passing the other account's router ID through the URL. + router, err := types.NewNetworkRouter("testAccountId", "testNetworkId", "testPeerId", []string{}, false, 1, true) + if err != nil { + require.NoError(t, err) + } + router.ID = "otherRouterId" + + s, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "../../testdata/networks.sql", t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(cleanUp) + permissionsManager := permissions.NewManager(s) + am := mock_server.MockAccountManager{} + manager := NewManager(s, permissionsManager, &am) + + updatedRouter, err := manager.UpdateRouter(ctx, userID, router) + require.Error(t, err) + require.Nil(t, updatedRouter) + + // The other account's router must be untouched. + stored, err := s.GetNetworkRouterByID(ctx, store.LockingStrengthNone, "otherAccountId", "otherRouterId") + require.NoError(t, err) + require.Equal(t, "otherAccountId", stored.AccountID) + require.Equal(t, "otherNetworkId", stored.NetworkID) + require.Equal(t, "otherPeer", stored.Peer) + require.Equal(t, 1, stored.Metric) +} + +func Test_CreateRouterRejectsCrossAccountID(t *testing.T) { + ctx := context.Background() + userID := "testAdminId" + + // Admin of testAccountId tries to create a router in otherAccountId's network. + // The permission check is on router.AccountID (their own), but the network + // lookup must fail because (testAccountId, otherNetworkId) does not exist. + router, err := types.NewNetworkRouter("testAccountId", "otherNetworkId", "testPeerId", []string{}, false, 1, true) + if err != nil { + require.NoError(t, err) + } + + s, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "../../testdata/networks.sql", t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(cleanUp) + permissionsManager := permissions.NewManager(s) + am := mock_server.MockAccountManager{} + manager := NewManager(s, permissionsManager, &am) + + createdRouter, err := manager.CreateRouter(ctx, userID, router) + require.Error(t, err) + require.Nil(t, createdRouter) + + // No router should have been created in either account's scope under otherNetworkId. + routersInOther, err := s.GetNetworkRoutersByNetID(ctx, store.LockingStrengthNone, "otherAccountId", "otherNetworkId") + require.NoError(t, err) + require.Len(t, routersInOther, 1) + require.Equal(t, "otherRouterId", routersInOther[0].ID) +} + +func Test_UpdateRouterRejectsNetworkMismatch(t *testing.T) { + ctx := context.Background() + userID := "testAdminId" + + // The router exists in testNetworkId, but the caller submits secondNetworkId + // (a different network in the same account). The update must be refused. + router, err := types.NewNetworkRouter("testAccountId", "secondNetworkId", "testPeerId", []string{}, false, 1, true) + if err != nil { + require.NoError(t, err) + } + router.ID = "testRouterId" + + s, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "../../testdata/networks.sql", t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(cleanUp) + permissionsManager := permissions.NewManager(s) + am := mock_server.MockAccountManager{} + manager := NewManager(s, permissionsManager, &am) + + updatedRouter, err := manager.UpdateRouter(ctx, userID, router) + require.Error(t, err) + require.Nil(t, updatedRouter) + + stored, err := s.GetNetworkRouterByID(ctx, store.LockingStrengthNone, "testAccountId", "testRouterId") + require.NoError(t, err) + require.Equal(t, "testNetworkId", stored.NetworkID) +} + func Test_UpdateRouterFailsWithPermissionDenied(t *testing.T) { ctx := context.Background() userID := "testUserId" diff --git a/management/server/networks/routers/types/router.go b/management/server/networks/routers/types/router.go index 72b15fd9a..b8097cdbb 100644 --- a/management/server/networks/routers/types/router.go +++ b/management/server/networks/routers/types/router.go @@ -5,14 +5,16 @@ import ( "github.com/rs/xid" - "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/management/server/networks/types" + "github.com/netbirdio/netbird/shared/management/http/api" + sharedTypes "github.com/netbirdio/netbird/shared/management/types" ) type NetworkRouter struct { ID string `gorm:"primaryKey"` NetworkID string `gorm:"index"` AccountID string `gorm:"index"` + PublicID string `json:"-"` Peer string PeerGroups []string `gorm:"serializer:json"` Masquerade bool @@ -20,12 +22,38 @@ type NetworkRouter struct { Enabled bool } -func NewNetworkRouter(accountID string, networkID string, peer string, peerGroups []string, masquerade bool, metric int, enabled bool) (*NetworkRouter, error) { - if peer != "" && len(peerGroups) > 0 { - return nil, errors.New("peer and peerGroups cannot be set at the same time") +// ToComponent converts the router to its self-contained components +// representation. Returns nil for a nil router. +func (n *NetworkRouter) ToComponent() *sharedTypes.ComponentRouter { + if n == nil { + return nil } + return &sharedTypes.ComponentRouter{ + NetworkID: n.NetworkID, + PublicID: n.PublicID, + Peer: n.Peer, + PeerGroups: n.PeerGroups, + Masquerade: n.Masquerade, + Metric: n.Metric, + Enabled: n.Enabled, + } +} - return &NetworkRouter{ +// ToComponentMap converts a peer-keyed router map to its components +// representation. +func ToComponentMap(routers map[string]*NetworkRouter) map[string]*sharedTypes.ComponentRouter { + if routers == nil { + return nil + } + out := make(map[string]*sharedTypes.ComponentRouter, len(routers)) + for id, r := range routers { + out[id] = r.ToComponent() + } + return out +} + +func NewNetworkRouter(accountID string, networkID string, peer string, peerGroups []string, masquerade bool, metric int, enabled bool) (*NetworkRouter, error) { + r := &NetworkRouter{ ID: xid.New().String(), AccountID: accountID, NetworkID: networkID, @@ -34,7 +62,25 @@ func NewNetworkRouter(accountID string, networkID string, peer string, peerGroup Masquerade: masquerade, Metric: metric, Enabled: enabled, - }, nil + } + + if err := r.Validate(); err != nil { + return nil, err + } + + return r, nil +} + +func (n *NetworkRouter) Validate() error { + if n.Peer != "" && len(n.PeerGroups) > 0 { + return errors.New("peer and peer_groups cannot be set at the same time") + } + + if n.Peer == "" && len(n.PeerGroups) == 0 { + return errors.New("either peer or peer_groups must be provided") + } + + return nil } func (n *NetworkRouter) ToAPIResponse() *api.NetworkRouter { @@ -67,6 +113,7 @@ func (n *NetworkRouter) Copy() *NetworkRouter { ID: n.ID, NetworkID: n.NetworkID, AccountID: n.AccountID, + PublicID: n.PublicID, Peer: n.Peer, PeerGroups: n.PeerGroups, Masquerade: n.Masquerade, diff --git a/management/server/networks/routers/types/router_test.go b/management/server/networks/routers/types/router_test.go index 5801e3bfa..a2f2fe6e3 100644 --- a/management/server/networks/routers/types/router_test.go +++ b/management/server/networks/routers/types/router_test.go @@ -38,7 +38,7 @@ func TestNewNetworkRouter(t *testing.T) { expectedError: false, }, { - name: "Valid with no peer or peerGroups", + name: "Invalid with no peer or peerGroups", networkID: "network-3", accountID: "account-3", peer: "", @@ -46,7 +46,18 @@ func TestNewNetworkRouter(t *testing.T) { masquerade: true, metric: 300, enabled: true, - expectedError: false, + expectedError: true, + }, + { + name: "Invalid with empty peerGroups slice", + networkID: "network-5", + accountID: "account-5", + peer: "", + peerGroups: []string{}, + masquerade: true, + metric: 500, + enabled: true, + expectedError: true, }, // Invalid cases diff --git a/management/server/networks/types/network.go b/management/server/networks/types/network.go index 69d596f8b..6f7381bff 100644 --- a/management/server/networks/types/network.go +++ b/management/server/networks/types/network.go @@ -7,8 +7,11 @@ import ( ) type Network struct { - ID string `gorm:"primaryKey"` - AccountID string `gorm:"index"` + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index"` + + PublicID string `json:"-"` + Name string Description string } @@ -41,11 +44,12 @@ func (n *Network) FromAPIRequest(req *api.NetworkRequest) { } } -// Copy returns a copy of a posture checks. +// Copy returns a copy of a network. func (n *Network) Copy() *Network { return &Network{ ID: n.ID, AccountID: n.AccountID, + PublicID: n.PublicID, Name: n.Name, Description: n.Description, } diff --git a/management/server/peer.go b/management/server/peer.go index cd9fbe4c8..589cf9abf 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -6,6 +6,7 @@ import ( b64 "encoding/base64" "fmt" "net" + "net/netip" "slices" "strings" "time" @@ -15,7 +16,6 @@ import ( "golang.org/x/exp/maps" nbdns "github.com/netbirdio/netbird/dns" - "github.com/netbirdio/netbird/management/server/geolocation" "github.com/netbirdio/netbird/management/server/idp" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" "github.com/netbirdio/netbird/management/server/permissions/modules" @@ -27,31 +27,39 @@ import ( "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/telemetry" "github.com/netbirdio/netbird/shared/management/status" + "github.com/netbirdio/netbird/version" ) -// GetPeers returns a list of peers under the given account filtering out peers that do not belong to a user if -// the current user is not an admin. +type peerExpirationReason string + +const ( + remoteJobsMinVer = "0.64.0" + + peerExpirationSessionExpired peerExpirationReason = "session expiration" + peerExpirationInactivity peerExpirationReason = "inactivity timeout" + peerExpirationValidationFailed peerExpirationReason = "failed integration validation" + peerExpirationUserBlocked peerExpirationReason = "blocked owner account" +) + +// GetPeers returns peers visible to the user within an account. +// Users with "peers:read" see all peers. Otherwise, users see only their own peers, or none if restricted by account settings. func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) { user, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) if err != nil { return nil, err } - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } - accountPeers, err := am.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, nameFilter, ipFilter) - if err != nil { - return nil, err - } - - // @note if the user has permission to read peers it shows all account peers if allowed { - return accountPeers, nil + return am.Store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, nameFilter, ipFilter) } settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) @@ -63,125 +71,176 @@ func (am *DefaultAccountManager) GetPeers(ctx context.Context, accountID, userID return []*nbpeer.Peer{}, nil } - // @note if it does not have permission read peers then only display it's own peers - peers := make([]*nbpeer.Peer, 0) - peersMap := make(map[string]*nbpeer.Peer) - - for _, peer := range accountPeers { - if user.Id != peer.UserID { - continue - } - peers = append(peers, peer) - peersMap[peer.ID] = peer - } - - return am.getUserAccessiblePeers(ctx, accountID, peersMap, peers) + return am.Store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID) } -func (am *DefaultAccountManager) getUserAccessiblePeers(ctx context.Context, accountID string, peersMap map[string]*nbpeer.Peer, peers []*nbpeer.Peer) ([]*nbpeer.Peer, error) { - account, err := am.requestBuffer.GetAccountWithBackpressure(ctx, accountID) - if err != nil { - return nil, err - } +// MarkPeerConnected marks a peer as connected with optimistic-locked +// fencing on PeerStatus.SessionStartedAt. The sessionStartedAt argument +// is the start time of the gRPC sync stream that owns this update, +// expressed as Unix nanoseconds — only the call whose token is greater +// than what's stored wins. LastSeen is written by the database itself; +// we never pass it down. +// +// Disconnects use MarkPeerDisconnected and require the session to match +// exactly; see PeerStatus.SessionStartedAt for the protocol. +func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubKey string, accountID string, sessionStartedAt int64, nmap *types.NetworkMap) error { + start := time.Now() + defer func() { + am.metrics.AccountManagerMetrics().RecordPeerStatusUpdateDuration(telemetry.PeerStatusConnect, time.Since(start)) + }() - approvedPeersMap, err := am.integratedPeerValidator.GetValidatedPeers(ctx, accountID, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra) + peer, err := am.Store.GetPeerByPeerPubKey(ctx, store.LockingStrengthNone, peerPubKey) if err != nil { - return nil, err - } - - // fetch all the peers that have access to the user's peers - for _, peer := range peers { - aclPeers, _ := account.GetPeerConnectionResources(ctx, peer, approvedPeersMap) - for _, p := range aclPeers { - peersMap[p.ID] = p + outcome := telemetry.PeerStatusError + if s, ok := status.FromError(err); ok && s.Type() == status.NotFound { + outcome = telemetry.PeerStatusPeerNotFound } - } - - return maps.Values(peersMap), nil -} - -// MarkPeerConnected marks peer as connected (true) or disconnected (false) -func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubKey string, connected bool, realIP net.IP, accountID string) error { - var peer *nbpeer.Peer - var settings *types.Settings - var expired bool - var err error - - err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - peer, err = transaction.GetPeerByPeerPubKey(ctx, store.LockingStrengthUpdate, peerPubKey) - if err != nil { - return err - } - - expired, err = updatePeerStatusAndLocation(ctx, am.geo, transaction, peer, connected, realIP, accountID) - return err - }) - if err != nil { + am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusConnect, outcome) return err } - if peer.AddedWithSSOLogin() { - settings, err = am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) - if err != nil { - return err - } + updated, err := am.Store.MarkPeerConnectedIfNewerSession(ctx, accountID, peer.ID, sessionStartedAt) + if err != nil { + am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusConnect, telemetry.PeerStatusError) + return err + } + if !updated { + am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusConnect, telemetry.PeerStatusStale) + log.WithContext(ctx).Debugf("peer %s already has a newer session in store, skipping connect", peer.ID) + return nil + } + am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusConnect, telemetry.PeerStatusApplied) - if peer.LoginExpirationEnabled && settings.PeerLoginExpirationEnabled { - am.schedulePeerLoginExpiration(ctx, accountID) - } + log.WithContext(ctx).Debugf("mark peer %s connected", peer.ID) - if peer.InactivityExpirationEnabled && settings.PeerInactivityExpirationEnabled { - am.checkAndSchedulePeerInactivityExpiration(ctx, accountID) - } + if err = am.schedulePeerExpirations(ctx, accountID, peer); err != nil { + return err } - if expired { - am.networkMapController.OnPeerUpdated(accountID, peer) + // A login-expired peer reconnecting, or an embedded proxy peer flipping to + // connected (which triggers SynthesizePrivateServiceZones), must refresh the + // peers reachable from it. The embedded-proxy fan-out tolerates a dispatch error. + if peer.Status != nil && peer.Status.LoginExpired { + affectedPeerIDs := am.markConnectedAffectedPeers(ctx, accountID, peer.ID, nmap) + if err = am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}, affectedPeerIDs); err != nil { + return fmt.Errorf("notify network map controller of peer update: %w", err) + } + } + if peer.ProxyMeta.Embedded { + affectedPeerIDs := am.markConnectedAffectedPeers(ctx, accountID, peer.ID, nmap) + if err := am.networkMapController.OnPeersUpdated(ctx, accountID, []string{peer.ID}, affectedPeerIDs); err != nil { + log.WithContext(ctx).Warnf("notify network map controller of embedded proxy %s connect: %v", peer.ID, err) + } } return nil } -func updatePeerStatusAndLocation(ctx context.Context, geo geolocation.Geolocation, transaction store.Store, peer *nbpeer.Peer, connected bool, realIP net.IP, accountID string) (bool, error) { - oldStatus := peer.Status.Copy() - newStatus := oldStatus - newStatus.LastSeen = time.Now().UTC() - newStatus.Connected = connected - // whenever peer got connected that means that it logged in successfully - if newStatus.Connected { - newStatus.LoginExpired = false +// schedulePeerExpirations reschedules the account's login/inactivity expiration +// timers for an SSO peer that just connected. +func (am *DefaultAccountManager) schedulePeerExpirations(ctx context.Context, accountID string, peer *nbpeer.Peer) error { + if !peer.AddedWithSSOLogin() { + return nil } - peer.Status = newStatus + settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return err + } + if peer.LoginExpirationEnabled && settings.PeerLoginExpirationEnabled { + am.schedulePeerLoginExpiration(ctx, accountID) + } + if peer.InactivityExpirationEnabled && settings.PeerInactivityExpirationEnabled { + am.checkAndSchedulePeerInactivityExpiration(ctx, accountID) + } + return nil +} - if geo != nil && realIP != nil { - location, err := geo.Lookup(realIP) - if err != nil { - log.WithContext(ctx).Warnf("failed to get location for peer %s realip: [%s]: %v", peer.ID, realIP.String(), err) - } else { - peer.Location.ConnectionIP = realIP - peer.Location.CountryCode = location.Country.ISOCode - peer.Location.CityName = location.City.Names.En - peer.Location.GeoNameID = location.City.GeonameID - err = transaction.SavePeerLocation(ctx, accountID, peer) - if err != nil { - log.WithContext(ctx).Warnf("could not store location for peer %s: %s", peer.ID, err) - } +// MarkPeerDisconnected marks a peer as disconnected, but only when the +// stored session token matches the one passed in. A mismatch means a +// newer stream has already taken ownership of the peer — disconnects from +// the older stream are ignored. LastSeen is written by the database. +func (am *DefaultAccountManager) MarkPeerDisconnected(ctx context.Context, peerPubKey string, accountID string, sessionStartedAt int64) error { + start := time.Now() + defer func() { + am.metrics.AccountManagerMetrics().RecordPeerStatusUpdateDuration(telemetry.PeerStatusDisconnect, time.Since(start)) + }() + + peer, err := am.Store.GetPeerByPeerPubKey(ctx, store.LockingStrengthNone, peerPubKey) + if err != nil { + outcome := telemetry.PeerStatusError + if s, ok := status.FromError(err); ok && s.Type() == status.NotFound { + outcome = telemetry.PeerStatusPeerNotFound + } + am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusDisconnect, outcome) + return err + } + + updated, err := am.Store.MarkPeerDisconnectedIfSameSession(ctx, accountID, peer.ID, sessionStartedAt) + if err != nil { + am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusDisconnect, telemetry.PeerStatusError) + return err + } + if !updated { + am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusDisconnect, telemetry.PeerStatusStale) + log.WithContext(ctx).Debugf("peer %s session token mismatch on disconnect (token=%d), skipping", + peer.ID, sessionStartedAt) + return nil + } + am.metrics.AccountManagerMetrics().CountPeerStatusUpdate(telemetry.PeerStatusDisconnect, telemetry.PeerStatusApplied) + + log.WithContext(ctx).Debugf("mark peer %s disconnected", peer.ID) + + // Symmetric with MarkPeerConnected: when an embedded proxy peer goes + // offline, refresh the peers that had synthesized records pointing at + // it so they pull the stale entries instead of waiting out TTL. + if peer.ProxyMeta.Embedded { + changedPeerIDs := []string{peer.ID} + affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs) + if err := am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil { + log.WithContext(ctx).Warnf("notify network map controller of embedded proxy %s disconnect: %v", peer.ID, err) } } - log.WithContext(ctx).Tracef("saving peer status for peer %s is connected: %t", peer.ID, connected) - - err := transaction.SavePeerStatus(ctx, accountID, peer.ID, *newStatus) - if err != nil { - return false, err + if peer.AddedWithSSOLogin() && peer.InactivityExpirationEnabled { + settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + if err != nil { + log.WithContext(ctx).Warnf("failed getting account settings to schedule inactivity expiration for peer %s: %v", peer.ID, err) + } else if settings.PeerInactivityExpirationEnabled { + am.checkAndSchedulePeerInactivityExpiration(ctx, accountID) + } } - return oldStatus.LoginExpired, nil + return nil +} + +// resolvePeerLocation looks up the geo location for realIP, returning nil when +// there is nothing to apply: geo disabled, no real IP, the IP is unchanged from +// what the peer already has, or the lookup failed. Geo lookups are skipped on +// same-IP reconnects since they are comparatively expensive. The returned value +// is applied by Peer.UpdateMetaIfNew so the change is persisted by its peer save. +func (am *DefaultAccountManager) resolvePeerLocation(ctx context.Context, peer *nbpeer.Peer, realIP net.IP) *nbpeer.Location { + if am.geo == nil || realIP == nil { + return nil + } + location, err := am.geo.Lookup(realIP) + if err != nil { + log.WithContext(ctx).Warnf("failed to get location for peer %s realip: [%s]: %v", peer.ID, realIP.String(), err) + return nil + } + if peer.Location.ConnectionIP != nil && peer.Location.ConnectionIP.Equal(realIP) && peer.Location.GeoNameID == location.City.GeonameID { + return nil + } + return &nbpeer.Location{ + ConnectionIP: realIP, + CountryCode: location.Country.ISOCode, + CityName: location.City.Names.En, + GeoNameID: location.City.GeonameID, + } } // UpdatePeer updates peer. Only Peer.Name, Peer.SSHEnabled, Peer.LoginExpirationEnabled and Peer.InactivityExpirationEnabled can be updated. func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, userID string, update *nbpeer.Peer) (*nbpeer.Peer, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Update) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -204,6 +263,10 @@ func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, user return err } + if peer.ProxyMeta.Embedded { + return fmt.Errorf("not allowed to update peer") + } + settings, err = transaction.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) if err != nil { return err @@ -228,7 +291,7 @@ func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, user if err != nil { newLabel = "" } else { - _, err := transaction.GetPeerIdByLabel(ctx, store.LockingStrengthNone, accountID, update.Name) + _, err := transaction.GetPeerIdByLabel(ctx, store.LockingStrengthNone, accountID, newLabel) if err == nil { newLabel = "" } @@ -266,6 +329,10 @@ func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, user inactivityExpirationChanged = true } + if err = transaction.IncrementNetworkSerial(ctx, accountID); err != nil { + return fmt.Errorf("failed to increment network serial: %w", err) + } + return transaction.SavePeer(ctx, accountID, peer) }) if err != nil { @@ -309,14 +376,148 @@ func (am *DefaultAccountManager) UpdatePeer(ctx context.Context, accountID, user } } - am.networkMapController.OnPeerUpdated(accountID, peer) + changedPeerIDs := []string{peer.ID} + affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs) + affectedPeerIDs = append(affectedPeerIDs, peer.ID) + err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs) + if err != nil { + return nil, fmt.Errorf("notify network map controller of peer update: %w", err) + } return peer, nil } +func (am *DefaultAccountManager) CreatePeerJob(ctx context.Context, accountID, peerID, userID string, job *types.Job) error { + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Create) + if err != nil { + return status.NewPermissionValidationError(err) + } + if !allowed { + return status.NewPermissionDeniedError() + } + + p, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) + if err != nil { + return err + } + + if p.AccountID != accountID { + return status.NewPeerNotPartOfAccountError() + } + + meetMinVer, err := version.MeetsMinVersion(remoteJobsMinVer, p.Meta.WtVersion) + if !version.IsDevelopmentVersion(p.Meta.WtVersion) && (!meetMinVer || err != nil) { + return status.Errorf(status.PreconditionFailed, "peer version %s does not meet the minimum required version %s for remote jobs", p.Meta.WtVersion, remoteJobsMinVer) + } + + if !am.jobManager.IsPeerConnected(peerID) { + return status.Errorf(status.BadRequest, "peer not connected") + } + + // check if already has pending jobs + // todo: The job checks here are not protected. The user can run this function from multiple threads, + // and each thread can think there is no job yet. This means entries in the pending job map will be overwritten, + // and only one will be kept, but potentially another one will overwrite it in the queue. + if am.jobManager.IsPeerHasPendingJobs(peerID) { + return status.Errorf(status.BadRequest, "peer already has pending job") + } + + jobStream, err := job.ToStreamJobRequest() + if err != nil { + return status.Errorf(status.BadRequest, "invalid job request %v", err) + } + + // try sending job first + if err := am.jobManager.SendJob(ctx, accountID, peerID, jobStream); err != nil { + return status.Errorf(status.Internal, "failed to send job: %v", err) + } + + var peer *nbpeer.Peer + var eventsToStore func() + + // persist job in DB only if send succeeded + err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + peer, err = transaction.GetPeerByID(ctx, store.LockingStrengthUpdate, accountID, peerID) + if err != nil { + return err + } + if err := transaction.CreatePeerJob(ctx, job); err != nil { + return err + } + + jobMeta := map[string]any{ + "for_peer_name": peer.Name, + "job_type": job.Workload.Type, + } + + eventsToStore = func() { + am.StoreEvent(ctx, userID, peer.ID, accountID, activity.JobCreatedByUser, jobMeta) + } + return nil + }) + if err != nil { + return err + } + eventsToStore() + return nil +} + +func (am *DefaultAccountManager) GetAllPeerJobs(ctx context.Context, accountID, userID, peerID string) ([]*types.Job, error) { + // todo: Create permissions for job + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !allowed { + return nil, status.NewPermissionDeniedError() + } + + peerAccountID, err := am.Store.GetAccountIDByPeerID(ctx, store.LockingStrengthNone, peerID) + if err != nil { + return nil, err + } + + if peerAccountID != accountID { + return nil, status.NewPeerNotPartOfAccountError() + } + + accountJobs, err := am.Store.GetPeerJobs(ctx, accountID, peerID) + if err != nil { + return nil, err + } + + return accountJobs, nil +} + +func (am *DefaultAccountManager) GetPeerJobByID(ctx context.Context, accountID, userID, peerID, jobID string) (*types.Job, error) { + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.RemoteJobs, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !allowed { + return nil, status.NewPermissionDeniedError() + } + + peerAccountID, err := am.Store.GetAccountIDByPeerID(ctx, store.LockingStrengthNone, peerID) + if err != nil { + return nil, err + } + + if peerAccountID != accountID { + return nil, status.NewPeerNotPartOfAccountError() + } + + job, err := am.Store.GetPeerJobByID(ctx, accountID, jobID) + if err != nil { + return nil, err + } + + return job, nil +} + // DeletePeer removes peer from the account by its IP func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peerID, userID string) error { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Delete) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Delete) if err != nil { return status.NewPermissionValidationError(err) } @@ -333,11 +534,51 @@ func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peer return status.NewPeerNotPartOfAccountError() } - var peer *nbpeer.Peer - var eventsToStore []func() + serviceID, err := am.serviceManager.GetServiceIDByTargetID(ctx, accountID, peerID) + if err != nil { + return fmt.Errorf("failed to check if resource is used by service: %w", err) + } + if serviceID != "" { + return status.NewPeerInUseError(peerID, serviceID) + } - err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - peer, err = transaction.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) + change := affectedpeers.Change{ChangedPeerIDs: []string{peerID}} + settings, eventsToStore, snap, err := am.deletePeerInTransaction(ctx, accountID, userID, peerID, change) + if err != nil { + return err + } + + for _, storeEvent := range eventsToStore { + storeEvent() + } + + if err = am.integratedPeerValidator.PeerDeleted(ctx, accountID, peerID, settings.Extra); err != nil { + log.WithContext(ctx).Errorf("failed to delete peer %s from integrated validator: %v", peerID, err) + } + + affectedPeerIDs := snap.Expand(ctx, accountID, change) + if err = am.networkMapController.OnPeersDeleted(ctx, accountID, []string{peerID}, affectedPeerIDs); err != nil { + log.WithContext(ctx).Errorf("failed to delete peer %s from network map: %v", peerID, err) + } + + return nil +} + +// deletePeerInTransaction loads the peer + settings, captures the affected-peers +// snapshot (before the delete, while the peer's group memberships still exist), +// then deletes the peer and bumps the network serial — all in one transaction. +func (am *DefaultAccountManager) deletePeerInTransaction(ctx context.Context, accountID, userID, peerID string, change affectedpeers.Change) (*types.Settings, []func(), *affectedpeers.Snapshot, error) { + var settings *types.Settings + var eventsToStore []func() + var snap *affectedpeers.Snapshot + + err := am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + peer, err := transaction.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) + if err != nil { + return err + } + + settings, err = transaction.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) if err != nil { return err } @@ -346,8 +587,11 @@ func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peer return err } - eventsToStore, err = deletePeers(ctx, am, transaction, accountID, userID, []*nbpeer.Peer{peer}) - if err != nil { + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return err + } + + if eventsToStore, err = deletePeers(ctx, am, transaction, accountID, userID, []*nbpeer.Peer{peer}, settings); err != nil { return fmt.Errorf("failed to delete peer: %w", err) } @@ -357,24 +601,7 @@ func (am *DefaultAccountManager) DeletePeer(ctx context.Context, accountID, peer return nil }) - if err != nil { - return err - } - - for _, storeEvent := range eventsToStore { - storeEvent() - } - - err = am.networkMapController.DeletePeer(ctx, accountID, peer.ID) - if err != nil { - log.WithContext(ctx).Errorf("failed to delete peer %s from network map: %v", peer.ID, err) - } - - if err := am.networkMapController.OnPeerDeleted(ctx, accountID, peerID); err != nil { - log.WithContext(ctx).Errorf("failed to update network map cache for peer %s: %v", peerID, err) - } - - return nil + return settings, eventsToStore, snap, err } // GetNetworkMap returns Network map for a given peer (omits original peer from the Peers result) @@ -392,6 +619,120 @@ func (am *DefaultAccountManager) GetPeerNetwork(ctx context.Context, peerID stri return account.Network.Copy(), err } +// peerWillHaveIPv6 checks whether the peer's future group memberships +// (auto-groups + allGroupID) overlap with IPv6EnabledGroups. +func peerWillHaveIPv6(settings *types.Settings, groupsToAdd []string, allGroupID string) bool { + enabledSet := make(map[string]struct{}, len(settings.IPv6EnabledGroups)) + for _, gid := range settings.IPv6EnabledGroups { + enabledSet[gid] = struct{}{} + } + + if allGroupID != "" { + if _, ok := enabledSet[allGroupID]; ok { + return true + } + } + for _, gid := range groupsToAdd { + if _, ok := enabledSet[gid]; ok { + return true + } + } + return false +} + +type peerAddAuthConfig struct { + AccountID string + SetupKeyID string + SetupKeyName string + GroupsToAdd []string + AllowExtraDNSLabels bool + Ephemeral bool +} + +func (am *DefaultAccountManager) processPeerAddAuth(ctx context.Context, accountID, userID, encodedHashedKey string, peer *nbpeer.Peer, temporary, addedByUser, addedBySetupKey bool, opEvent *activity.Event) (*peerAddAuthConfig, error) { + config := &peerAddAuthConfig{ + AccountID: accountID, + Ephemeral: peer.Ephemeral, + } + + switch { + case addedByUser: + if err := am.handleUserAddedPeer(ctx, accountID, userID, temporary, opEvent, config); err != nil { + return nil, err + } + case addedBySetupKey: + if err := am.handleSetupKeyAddedPeer(ctx, encodedHashedKey, peer, opEvent, config); err != nil { + return nil, err + } + default: + if peer.ProxyMeta.Embedded { + log.WithContext(ctx).Debugf("adding peer for proxy embedded, accountID: %s", accountID) + } else { + log.WithContext(ctx).Warnf("adding peer without setup key or userID, accountID: %s", accountID) + } + } + + opEvent.AccountID = config.AccountID + if temporary { + config.Ephemeral = true + } + + return config, nil +} + +func (am *DefaultAccountManager) handleUserAddedPeer(ctx context.Context, accountID, userID string, temporary bool, opEvent *activity.Event, config *peerAddAuthConfig) error { + user, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) + if err != nil { + return status.Errorf(status.NotFound, "failed adding new peer: user not found") + } + if user.PendingApproval { + return status.Errorf(status.PermissionDenied, "user pending approval cannot add peers") + } + + if temporary { + allowed, _, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Create) + if err != nil { + return status.NewPermissionValidationError(err) + } + if !allowed { + return status.NewPermissionDeniedError() + } + } else { + config.AccountID = user.AccountID + config.GroupsToAdd = user.AutoGroups + } + + opEvent.InitiatorID = userID + opEvent.Activity = activity.PeerAddedByUser + return nil +} + +func (am *DefaultAccountManager) handleSetupKeyAddedPeer(ctx context.Context, encodedHashedKey string, peer *nbpeer.Peer, opEvent *activity.Event, config *peerAddAuthConfig) error { + sk, err := am.Store.GetSetupKeyBySecret(ctx, store.LockingStrengthNone, encodedHashedKey) + if err != nil { + return status.Errorf(status.NotFound, "couldn't add peer: setup key is invalid") + } + + if !sk.IsValid() { + return status.Errorf(status.NotFound, "couldn't add peer: setup key is invalid") + } + + if !sk.AllowExtraDNSLabels && len(peer.ExtraDNSLabels) > 0 { + return status.Errorf(status.PreconditionFailed, "couldn't add peer: setup key doesn't allow extra DNS labels") + } + + opEvent.InitiatorID = sk.Id + opEvent.Activity = activity.PeerAddedWithSetupKey + config.GroupsToAdd = sk.AutoGroups + config.Ephemeral = sk.Ephemeral + config.SetupKeyID = sk.Id + config.SetupKeyName = sk.Name + config.AllowExtraDNSLabels = sk.AllowExtraDNSLabels + config.AccountID = sk.AccountID + + return nil +} + // AddPeer adds a new peer to the Store. // Each Account has a list of pre-authorized SetupKey and if no Account has a given key err with a code status.PermissionDenied // will be returned, meaning the setup key is invalid or not found. @@ -399,16 +740,17 @@ func (am *DefaultAccountManager) GetPeerNetwork(ctx context.Context, peerID stri // to it. We also add the User ID to the peer metadata to identify registrant. If no userID provided, then fail with status.PermissionDenied // Each new Peer will be assigned a new next net.IP from the Account.Network and Account.Network.LastIP will be updated (IP's are not reused). // The peer property is just a placeholder for the Peer properties to pass further -func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) { - if setupKey == "" && userID == "" { +func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) { + if setupKey == "" && userID == "" && !peer.ProxyMeta.Embedded { // no auth method provided => reject access - return nil, nil, nil, status.Errorf(status.Unauthenticated, "no peer auth method provided, please use a setup key or interactive SSO login") + return nil, nil, nil, false, status.ErrNoAuthMethodProvided } upperKey := strings.ToUpper(setupKey) hashedKey := sha256.Sum256([]byte(upperKey)) encodedHashedKey := b64.StdEncoding.EncodeToString(hashedKey[:]) addedByUser := len(userID) > 0 + addedBySetupKey := len(setupKey) > 0 // This is a handling for the case when the same machine (with the same WireGuard pub key) tries to register twice. // Such case is possible when AddPeer function takes long time to finish after AcquireWriteLockByUID (e.g., database is slow) @@ -417,7 +759,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe // The connecting peer should be able to recover with a retry. _, err := am.Store.GetPeerByPeerPubKey(ctx, store.LockingStrengthNone, peer.Key) if err == nil { - return nil, nil, nil, status.Errorf(status.PreconditionFailed, "peer has been already registered") + return nil, nil, nil, false, status.Errorf(status.PreconditionFailed, "peer has been already registered") } opEvent := &activity.Event{ @@ -426,63 +768,12 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe var newPeer *nbpeer.Peer - var setupKeyID string - var setupKeyName string - var ephemeral bool - var groupsToAdd []string - var allowExtraDNSLabels bool - if addedByUser { - user, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) - if err != nil { - return nil, nil, nil, status.Errorf(status.NotFound, "failed adding new peer: user not found") - } - if user.PendingApproval { - return nil, nil, nil, status.Errorf(status.PermissionDenied, "user pending approval cannot add peers") - } - if temporary { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Create) - if err != nil { - return nil, nil, nil, status.NewPermissionValidationError(err) - } - - if !allowed { - return nil, nil, nil, status.NewPermissionDeniedError() - } - } else { - accountID = user.AccountID - groupsToAdd = user.AutoGroups - } - opEvent.InitiatorID = userID - opEvent.Activity = activity.PeerAddedByUser - } else { - // Validate the setup key - sk, err := am.Store.GetSetupKeyBySecret(ctx, store.LockingStrengthNone, encodedHashedKey) - if err != nil { - return nil, nil, nil, status.Errorf(status.NotFound, "couldn't add peer: setup key is invalid") - } - - // we will check key twice for early return - if !sk.IsValid() { - return nil, nil, nil, status.Errorf(status.NotFound, "couldn't add peer: setup key is invalid") - } - - opEvent.InitiatorID = sk.Id - opEvent.Activity = activity.PeerAddedWithSetupKey - groupsToAdd = sk.AutoGroups - ephemeral = sk.Ephemeral - setupKeyID = sk.Id - setupKeyName = sk.Name - allowExtraDNSLabels = sk.AllowExtraDNSLabels - accountID = sk.AccountID - if !sk.AllowExtraDNSLabels && len(peer.ExtraDNSLabels) > 0 { - return nil, nil, nil, status.Errorf(status.PreconditionFailed, "couldn't add peer: setup key doesn't allow extra DNS labels") - } - } - opEvent.AccountID = accountID - - if temporary { - ephemeral = true + peerAddConfig, err := am.processPeerAddAuth(ctx, accountID, userID, encodedHashedKey, peer, temporary, addedByUser, addedBySetupKey, opEvent) + if err != nil { + return nil, nil, nil, false, err } + accountID = peerAddConfig.AccountID + ephemeral := peerAddConfig.Ephemeral if (strings.ToLower(peer.Meta.Hostname) == "iphone" || strings.ToLower(peer.Meta.Hostname) == "ipad") && userID != "" { if am.idpManager != nil { @@ -494,7 +785,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe } if err := domain.ValidateDomainsList(peer.ExtraDNSLabels); err != nil { - return nil, nil, nil, status.Errorf(status.InvalidArgument, "invalid extra DNS labels: %v", err) + return nil, nil, nil, false, status.Errorf(status.InvalidArgument, "invalid extra DNS labels: %v", err) } registrationTime := time.Now().UTC() @@ -512,14 +803,15 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe CreatedAt: registrationTime, LoginExpirationEnabled: addedByUser && !temporary, Ephemeral: ephemeral, + ProxyMeta: peer.ProxyMeta, Location: peer.Location, InactivityExpirationEnabled: addedByUser && !temporary, ExtraDNSLabels: peer.ExtraDNSLabels, - AllowExtraDNSLabels: allowExtraDNSLabels, + AllowExtraDNSLabels: peerAddConfig.AllowExtraDNSLabels, } settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to get account settings: %w", err) + return nil, nil, nil, false, fmt.Errorf("failed to get account settings: %w", err) } if am.geo != nil && newPeer.Location.ConnectionIP != nil { @@ -533,44 +825,73 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe } } - newPeer = am.integratedPeerValidator.PreparePeer(ctx, accountID, newPeer, groupsToAdd, settings.Extra, temporary) + newPeer = am.integratedPeerValidator.PreparePeer(ctx, accountID, newPeer, peerAddConfig.GroupsToAdd, settings.Extra, temporary) network, err := am.Store.GetAccountNetwork(ctx, store.LockingStrengthNone, accountID) if err != nil { - return nil, nil, nil, fmt.Errorf("failed getting network: %w", err) + return nil, nil, nil, false, fmt.Errorf("failed getting network: %w", err) } maxAttempts := 10 for attempt := 1; attempt <= maxAttempts; attempt++ { - var freeIP net.IP - freeIP, err = types.AllocateRandomPeerIP(network.Net) + netPrefix, err := netip.ParsePrefix(network.Net.String()) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to get free IP: %w", err) + return nil, nil, nil, false, fmt.Errorf("parse network prefix: %w", err) + } + freeIP, err := types.AllocateRandomPeerIP(netPrefix) + if err != nil { + return nil, nil, nil, false, fmt.Errorf("failed to get free IP: %w", err) } var freeLabel string if ephemeral || attempt > 1 { freeLabel, err = getPeerIPDNSLabel(freeIP, peer.Meta.Hostname) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to get free DNS label: %w", err) + return nil, nil, nil, false, fmt.Errorf("failed to get free DNS label: %w", err) } } else { freeLabel, err = nbdns.GetParsedDomainLabel(peer.Meta.Hostname) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to get free DNS label: %w", err) + return nil, nil, nil, false, fmt.Errorf("failed to get free DNS label: %w", err) } } newPeer.DNSLabel = freeLabel newPeer.IP = freeIP + if len(settings.IPv6EnabledGroups) > 0 && network.NetV6.IP != nil { + // Embedded proxy peers are not group members but participate in any + // IPv6-enabled overlay so reverse-proxy traffic reaches v6-only peers. + allocate := peer.ProxyMeta.Embedded + if !allocate { + var allGroupID string + if allGroup, err := am.Store.GetGroupByName(ctx, store.LockingStrengthNone, accountID, types.GroupAllName); err == nil { + allGroupID = allGroup.ID + } else { + log.WithContext(ctx).Debugf("get All group for IPv6 allocation: %v", err) + } + allocate = peerWillHaveIPv6(settings, peerAddConfig.GroupsToAdd, allGroupID) + } + if allocate { + v6Prefix, err := netip.ParsePrefix(network.NetV6.String()) + if err != nil { + return nil, nil, nil, false, fmt.Errorf("parse IPv6 prefix: %w", err) + } + freeIPv6, err := types.AllocateRandomPeerIPv6(v6Prefix) + if err != nil { + return nil, nil, nil, false, fmt.Errorf("allocate peer IPv6: %w", err) + } + newPeer.IPv6 = freeIPv6 + } + } + err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { err = transaction.AddPeerToAccount(ctx, newPeer) if err != nil { return err } - if len(groupsToAdd) > 0 { - for _, g := range groupsToAdd { + if len(peerAddConfig.GroupsToAdd) > 0 { + for _, g := range peerAddConfig.GroupsToAdd { err = transaction.AddPeerToGroup(ctx, newPeer.AccountID, newPeer.ID, g) if err != nil { return err @@ -578,22 +899,20 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe } } - err = transaction.AddPeerToAllGroup(ctx, accountID, newPeer.ID) - if err != nil { - return fmt.Errorf("failed adding peer to All group: %w", err) + if !peer.ProxyMeta.Embedded { + err = transaction.AddPeerToAllGroup(ctx, accountID, newPeer.ID) + if err != nil { + return fmt.Errorf("failed adding peer to All group: %w", err) + } } - if temporary { - // we are running the on disconnect handler so that it is considered not connected as we are adding the peer manually - am.ephemeralManager.OnPeerDisconnected(ctx, newPeer) - } - - if addedByUser { + switch { + case addedByUser: err := transaction.SaveUserLastLogin(ctx, accountID, userID, newPeer.GetLastLogin()) if err != nil { log.WithContext(ctx).Debugf("failed to update user last login: %v", err) } - } else { + case addedBySetupKey: sk, err := transaction.GetSetupKeyBySecret(ctx, store.LockingStrengthUpdate, encodedHashedKey) if err != nil { return fmt.Errorf("failed to get setup key: %w", err) @@ -604,7 +923,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe return status.Errorf(status.PreconditionFailed, "couldn't add peer: setup key is invalid") } - err = transaction.IncrementSetupKeyUsage(ctx, setupKeyID) + err = transaction.IncrementSetupKeyUsage(ctx, peerAddConfig.SetupKeyID) if err != nil { return fmt.Errorf("failed to increment setup key usage: %w", err) } @@ -615,6 +934,11 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe return fmt.Errorf("failed to increment network serial: %w", err) } + if ephemeral { + // we should track ephemeral peers to be able to clean them if the peer doesn't sync and isn't marked as connected + am.networkMapController.TrackEphemeralPeer(ctx, newPeer) + } + log.WithContext(ctx).Debugf("Peer %s added to account %s", newPeer.ID, accountID) return nil }) @@ -627,51 +951,60 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe continue } - return nil, nil, nil, fmt.Errorf("failed to add peer to database: %w", err) + return nil, nil, nil, false, fmt.Errorf("failed to add peer to database: %w", err) } - if err != nil { - return nil, nil, nil, fmt.Errorf("failed to add peer to database after %d attempts: %w", maxAttempts, err) - } - if newPeer == nil { - return nil, nil, nil, fmt.Errorf("new peer is nil") + return nil, nil, nil, false, fmt.Errorf("new peer is nil") } opEvent.TargetID = newPeer.ID opEvent.Meta = newPeer.EventMeta(am.networkMapController.GetDNSDomain(settings)) if !addedByUser { - opEvent.Meta["setup_key_name"] = setupKeyName + opEvent.Meta["setup_key_name"] = peerAddConfig.SetupKeyName + } + requiresApproval := newPeer.Status != nil && newPeer.Status.RequiresApproval + if requiresApproval { + opEvent.Meta["pending_approval"] = true } - am.StoreEvent(ctx, opEvent.InitiatorID, opEvent.TargetID, opEvent.AccountID, opEvent.Activity, opEvent.Meta) + if !temporary { + am.StoreEvent(ctx, opEvent.InitiatorID, opEvent.TargetID, opEvent.AccountID, opEvent.Activity, opEvent.Meta) + } - if err := am.networkMapController.OnPeerAdded(ctx, accountID, newPeer.ID); err != nil { + network, postureChecks, enableSSH, err := getPeerLoginInfo(ctx, am.Store, accountID, newPeer, !requiresApproval) + if err != nil { + return nil, nil, nil, false, err + } + + changedPeerIDs := []string{newPeer.ID} + affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs) + if err := am.networkMapController.OnPeersAdded(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil { log.WithContext(ctx).Errorf("failed to update network map cache for peer %s: %v", newPeer.ID, err) } - p, nmap, pc, _, err := am.networkMapController.GetValidatedPeerWithMap(ctx, false, accountID, newPeer) - return p, nmap, pc, err + return newPeer, network, postureChecks, enableSSH, nil } -func getPeerIPDNSLabel(ip net.IP, peerHostName string) (string, error) { - ip = ip.To4() +func getPeerIPDNSLabel(ip netip.Addr, peerHostName string) (string, error) { + if !ip.Is4() { + return "", fmt.Errorf("DNS label generation requires an IPv4 address, got %s", ip) + } + b := ip.As4() dnsName, err := nbdns.GetParsedDomainLabel(peerHostName) if err != nil { return "", fmt.Errorf("failed to parse peer host name %s: %w", peerHostName, err) } - return fmt.Sprintf("%s-%d-%d", dnsName, ip[2], ip[3]), nil + return fmt.Sprintf("%s-%d-%d", dnsName, b[2], b[3]), nil } // SyncPeer checks whether peer is eligible for receiving NetworkMap (authenticated) and returns its NetworkMap if eligible func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSync, accountID string) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) { var peer *nbpeer.Peer - var peerNotValid bool - var isStatusChanged bool - var updated, versionChanged bool + var ipv6CapabilityChanged bool + var metaDiff nbpeer.MetaDiff var err error - var postureChecks []*posture.Checks settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) if err != nil { @@ -699,28 +1032,16 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy return status.NewPeerLoginExpiredError() } - peerGroupIDs, err := getPeerGroupIDs(ctx, transaction, accountID, peer.ID) - if err != nil { - return err - } - - peerNotValid, isStatusChanged, err = am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra) - if err != nil { - return err - } - - updated, versionChanged = peer.UpdateMetaIfNew(sync.Meta) - if updated { + oldHasIPv6Cap := peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay) + newLocation := am.resolvePeerLocation(ctx, peer, sync.RealIP) + metaDiff = peer.UpdateMetaIfNew(ctx, sync.Meta, newLocation) + ipv6CapabilityChanged = oldHasIPv6Cap != peer.HasCapability(nbpeer.PeerCapabilityIPv6Overlay) + if metaDiff.Updated() { am.metrics.AccountManagerMetrics().CountPeerMetUpdate() log.WithContext(ctx).Tracef("peer %s metadata updated", peer.ID) if err = transaction.SavePeer(ctx, accountID, peer); err != nil { return err } - - postureChecks, err = getPeerPostureChecks(ctx, transaction, accountID, peer.ID) - if err != nil { - return err - } } return nil }) @@ -728,14 +1049,85 @@ func (am *DefaultAccountManager) SyncPeer(ctx context.Context, sync types.PeerSy return nil, nil, nil, 0, err } - if isStatusChanged || sync.UpdateAccountPeers || (updated && (len(postureChecks) > 0 || versionChanged)) { - am.networkMapController.OnPeerUpdated(accountID, peer) + peerGroupIDs, err := getPeerGroupIDs(ctx, am.Store, accountID, peer.ID) + if err != nil { + return nil, nil, nil, 0, err } - return am.networkMapController.GetValidatedPeerWithMap(ctx, peerNotValid, accountID, peer) + peerNotValid, isStatusChanged, err := am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra) + if err != nil { + return nil, nil, nil, 0, err + } + + nmap, resPostureChecks, dnsFwdPort, err := am.networkMapController.GetValidatedPeerWithMap(ctx, peerNotValid, accountID, peer.ID) + if err != nil { + return nil, nil, nil, 0, err + } + + metaDiffAffectsPosture := posture.AffectsPosture(ctx, &metaDiff, resPostureChecks) + if requiresPeerUpdate(ctx, isStatusChanged, sync.UpdateAccountPeers, ipv6CapabilityChanged, metaDiffAffectsPosture, metaDiff.VersionChanged(), metaDiff.HostnameChanged()) { + changedPeerIDs := []string{peer.ID} + affectedPeerIDs := am.syncPeerAffectedPeers(ctx, accountID, peer.ID, nmap, peerNotValid, metaDiffAffectsPosture) + if err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil { + return nil, nil, nil, 0, fmt.Errorf("notify network map controller of peer update: %w", err) + } + } + + return peer, nmap, resPostureChecks, dnsFwdPort, nil } -func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, login types.PeerLogin, err error) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) { +func requiresPeerUpdate(ctx context.Context, isStatusChanged, updateAccountPeers, ipv6CapabilityChanged, metaDiffAffectsPosture, versionChanged, hostname bool) bool { + var reason string + switch { + case isStatusChanged: + reason = "status changed" + case updateAccountPeers: + reason = "update account peers" + case ipv6CapabilityChanged: + reason = "ipv6 capability changed" + case metaDiffAffectsPosture: + reason = "meta diff affects posture" + case versionChanged: + reason = "version changed" + case hostname: + reason = "hostname changed" + default: + return false + } + + log.WithContext(ctx).Tracef("peer update required: %s", reason) + return true +} + +// syncPeerAffectedPeers resolves the peers affected by a SyncPeer change. The +// peer's own validated network map is bidirectional for policy and routing +// reachability, so when the peer stays valid and no source-posture gate is in +// play it already lists every affected peer — reuse it and skip the full +// dependency walk. Posture checks gate the source side of a policy only, so a +// metadata change that flips a posture result removes this peer from others' +// maps asymmetrically; that case (and an invalid peer, whose map is empty) falls +// back to the resolver. +func (am *DefaultAccountManager) syncPeerAffectedPeers(ctx context.Context, accountID, peerID string, nmap *types.NetworkMap, peerNotValid, metaChangeAffectedPosture bool) []string { + if peerNotValid || metaChangeAffectedPosture { + return am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, []string{peerID}) + } + return affectedPeerIDsFromNetworkMap(nmap, peerID) +} + +// markConnectedAffectedPeers resolves the peers affected when a peer connects +// (login-expiry reconnect or embedded-proxy connect). The connecting peer's +// network map already lists them bidirectionally — the synthesized +// private-service policy puts proxy access-group members in the proxy peer's own +// map, and these edges carry no source-posture gate. An invalid peer has an +// empty map, so fall back to the resolver in that case. +func (am *DefaultAccountManager) markConnectedAffectedPeers(ctx context.Context, accountID, peerID string, nmap *types.NetworkMap) []string { + if nmap == nil || len(nmap.Peers)+len(nmap.OfflinePeers) == 0 { + return am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, []string{peerID}) + } + return affectedPeerIDsFromNetworkMap(nmap, peerID) +} + +func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, login types.PeerLogin, err error) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) { if errStatus, ok := status.FromError(err); ok && errStatus.Type() == status.NotFound { // we couldn't find this peer by its public key which can mean that peer hasn't been registered yet. // Try registering it. @@ -751,12 +1143,12 @@ func (am *DefaultAccountManager) handlePeerLoginNotFound(ctx context.Context, lo } log.WithContext(ctx).Errorf("failed while logging in peer %s: %v", login.WireGuardPubKey, err) - return nil, nil, nil, status.Errorf(status.Internal, "failed while logging in peer") + return nil, nil, nil, false, status.Errorf(status.Internal, "failed while logging in peer") } // LoginPeer logs in or registers a peer. // If peer doesn't exist the function checks whether a setup key or a user is present and registers a new peer if so. -func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) { +func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.PeerLogin) (*nbpeer.Peer, *types.Network, []*posture.Checks, bool, error) { accountID, err := am.Store.GetAccountIDByPeerPubKey(ctx, login.WireGuardPubKey) if err != nil { return am.handlePeerLoginNotFound(ctx, login, err) @@ -768,32 +1160,25 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer if login.UserID == "" { err = am.checkIFPeerNeedsLoginWithoutLock(ctx, accountID, login) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, false, err } } var peer *nbpeer.Peer - var updateRemotePeers bool - var isRequiresApproval bool - var isStatusChanged bool - var isPeerUpdated bool - var postureChecks []*posture.Checks + var shouldStorePeer, shouldUpdatePeers bool + var peerGroupIDs []string settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, false, err } - startTransaction := time.Now() err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { peer, err = transaction.GetPeerByPeerPubKey(ctx, store.LockingStrengthUpdate, login.WireGuardPubKey) if err != nil { return err } - // this flag prevents unnecessary calls to the persistent store. - shouldStorePeer := false - if login.UserID != "" { if peer.UserID != login.UserID { log.Warnf("user mismatch when logging in peer %s: peer user %s, login user %s ", peer.ID, peer.UserID, login.UserID) @@ -807,35 +1192,13 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer if changed { shouldStorePeer = true - updateRemotePeers = true - } - } - - peerGroupIDs, err := getPeerGroupIDs(ctx, transaction, accountID, peer.ID) - if err != nil { - return err - } - - isRequiresApproval, isStatusChanged, err = am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra) - if err != nil { - return err - } - - isPeerUpdated, _ = peer.UpdateMetaIfNew(login.Meta) - if isPeerUpdated { - am.metrics.AccountManagerMetrics().CountPeerMetUpdate() - shouldStorePeer = true - - postureChecks, err = getPeerPostureChecks(ctx, transaction, accountID, peer.ID) - if err != nil { - return err + shouldUpdatePeers = true } } if peer.SSHKey != login.SSHKey { peer.SSHKey = login.SSHKey shouldStorePeer = true - updateRemotePeers = true } if !peer.AllowExtraDNSLabels && len(login.ExtraDNSLabels) > 0 { @@ -851,26 +1214,156 @@ func (am *DefaultAccountManager) LoginPeer(ctx context.Context, login types.Peer return nil }) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, false, err } - log.WithContext(ctx).Debugf("LoginPeer: transaction took %v", time.Since(startTransaction)) + // This is needed to keep in memory for the peer config. Otherwise browser client will end in a retry loop + peer.Meta = login.Meta - if updateRemotePeers || isStatusChanged || (isPeerUpdated && len(postureChecks) > 0) { - am.networkMapController.OnPeerUpdated(accountID, peer) + peerGroupIDs, err = getPeerGroupIDs(ctx, am.Store, accountID, peer.ID) + if err != nil { + return nil, nil, nil, false, err } - p, nmap, pc, _, err := am.networkMapController.GetValidatedPeerWithMap(ctx, isRequiresApproval, accountID, peer) - return p, nmap, pc, err + isRequiresApproval, _, err := am.integratedPeerValidator.IsNotValidPeer(ctx, accountID, peer, peerGroupIDs, settings.Extra) + if err != nil { + return nil, nil, nil, false, err + } + + network, postureChecks, enableSSH, err := getPeerLoginInfo(ctx, am.Store, accountID, peer, !isRequiresApproval) + if err != nil { + return nil, nil, nil, false, err + } + + if shouldUpdatePeers { + changedPeerIDs := []string{peer.ID} + affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, changedPeerIDs) + if err = am.networkMapController.OnPeersUpdated(ctx, accountID, changedPeerIDs, affectedPeerIDs); err != nil { + return nil, nil, nil, false, fmt.Errorf("notify network map controller of peer update: %w", err) + } + } + + return peer, network, postureChecks, enableSSH, nil +} + +// ExtendPeerSession refreshes the peer's SSO session deadline by updating +// LastLogin after a successful JWT validation. The tunnel is untouched: no +// network map sync, no peer reconnect. +// +// Preconditions enforced here: +// - userID must be present (caller validated the JWT and extracted the user ID). +// - The peer must exist and be SSO-registered (AddedWithSSOLogin) with +// LoginExpirationEnabled. +// - Account-level PeerLoginExpirationEnabled must be true. +// - The JWT user must match peer.UserID (mirrors LoginPeer at peer.go ~1028). +// +// Returns the new absolute UTC deadline. +func (am *DefaultAccountManager) ExtendPeerSession(ctx context.Context, peerPubKey, userID string) (time.Time, error) { + if userID == "" { + return time.Time{}, status.Errorf(status.PermissionDenied, "session extend requires a JWT") + } + + accountID, err := am.Store.GetAccountIDByPeerPubKey(ctx, peerPubKey) + if err != nil { + return time.Time{}, err + } + + settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return time.Time{}, err + } + if !settings.PeerLoginExpirationEnabled { + return time.Time{}, status.Errorf(status.PreconditionFailed, "peer login expiration is disabled for the account") + } + + var refreshed *nbpeer.Peer + err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + peer, err := transaction.GetPeerByPeerPubKey(ctx, store.LockingStrengthUpdate, peerPubKey) + if err != nil { + return err + } + + if !peer.AddedWithSSOLogin() || !peer.LoginExpirationEnabled { + return status.Errorf(status.PreconditionFailed, "peer is not eligible for session extension") + } + + if peer.UserID != userID { + log.WithContext(ctx).Warnf("user mismatch when extending session for peer %s: peer user %s, jwt user %s", peer.ID, peer.UserID, userID) + return status.NewPeerLoginMismatchError() + } + + peer = peer.UpdateLastLogin() + if err := transaction.SavePeer(ctx, accountID, peer); err != nil { + return err + } + + if err := transaction.SaveUserLastLogin(ctx, accountID, userID, peer.GetLastLogin()); err != nil { + log.WithContext(ctx).Debugf("failed to update user last login during session extend: %v", err) + } + + am.StoreEvent(ctx, userID, peer.ID, accountID, activity.UserExtendedPeerSession, peer.EventMeta(am.networkMapController.GetDNSDomain(settings))) + refreshed = peer + return nil + }) + if err != nil { + return time.Time{}, err + } + + // Reschedule the per-account expiration job. schedulePeerLoginExpiration + // is a no-op when a job is already running, but the running job will pick + // up the new LastLogin on its next tick. Calling it here is harmless and + // guarantees a job is in flight even if a prior one ended right before + // the extend. + am.schedulePeerLoginExpiration(ctx, accountID) + + return refreshed.SessionExpiresAt(settings.PeerLoginExpirationEnabled, settings.PeerLoginExpiration), nil +} + +// getPeerLoginInfo computes the login/register response data (network, posture +// checks, SSH) from the store without building the peer's full network map. +func getPeerLoginInfo(ctx context.Context, transaction store.Store, accountID string, peer *nbpeer.Peer, isValid bool) (*types.Network, []*posture.Checks, bool, error) { + network, err := transaction.GetAccountNetwork(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, nil, false, fmt.Errorf("get account network: %w", err) + } + + if !isValid { + return network, nil, false, nil + } + + policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, nil, false, err + } + + peerGroupIDs, err := transaction.GetPeerGroupIDs(ctx, store.LockingStrengthNone, accountID, peer.ID) + if err != nil { + return nil, nil, false, err + } + + postureChecks, err := getPeerPostureChecks(ctx, transaction, accountID, peerGroupIDs, policies) + if err != nil { + return nil, nil, false, err + } + + enableSSH, err := isPeerSSHEnabled(ctx, peer, policies, peerGroupIDs) + if err != nil { + return nil, nil, false, err + } + + return network, postureChecks, enableSSH, nil +} + +func isPeerSSHEnabled(ctx context.Context, peer *nbpeer.Peer, policies []*types.Policy, peerGroupIDs []string) (bool, error) { + groupIDsMap := make(map[string]struct{}, len(peerGroupIDs)) + for _, peerID := range peerGroupIDs { + groupIDsMap[peerID] = struct{}{} + } + return types.PeerSSHEnabledFromPolicies(policies, peer.ID, groupIDsMap, peer.SSHEnabled), nil } // getPeerPostureChecks returns the posture checks for the peer. -func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountID, peerID string) ([]*posture.Checks, error) { - policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - if err != nil { - return nil, err - } - +func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountID string, peerGroupIDs []string, policies []*types.Policy) ([]*posture.Checks, error) { if len(policies) == 0 { return nil, nil } @@ -882,11 +1375,7 @@ func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountI continue } - postureChecksIDs, err := processPeerPostureChecks(ctx, transaction, policy, accountID, peerID) - if err != nil { - return nil, err - } - + postureChecksIDs := processPeerPostureChecks(policy, peerGroupIDs) peerPostureChecksIDs = append(peerPostureChecksIDs, postureChecksIDs...) } @@ -899,29 +1388,19 @@ func getPeerPostureChecks(ctx context.Context, transaction store.Store, accountI } // processPeerPostureChecks checks if the peer is in the source group of the policy and returns the posture checks. -func processPeerPostureChecks(ctx context.Context, transaction store.Store, policy *types.Policy, accountID, peerID string) ([]string, error) { +func processPeerPostureChecks(policy *types.Policy, peerGroupIDs []string) []string { for _, rule := range policy.Rules { if !rule.Enabled { continue } - sourceGroups, err := transaction.GetGroupsByIDs(ctx, store.LockingStrengthNone, accountID, rule.Sources) - if err != nil { - return nil, err - } - for _, sourceGroup := range rule.Sources { - group, ok := sourceGroups[sourceGroup] - if !ok { - return nil, fmt.Errorf("failed to check peer in policy source group") - } - - if slices.Contains(group.Peers, peerID) { - return policy.SourcePostureChecks, nil + if slices.Contains(peerGroupIDs, sourceGroup) { + return policy.SourcePostureChecks } } } - return nil, nil + return nil } // checkIFPeerNeedsLoginWithoutLock checks if the peer needs login without acquiring the account lock. The check validate if the peer was not added via SSO @@ -1011,14 +1490,15 @@ func peerLoginExpired(ctx context.Context, peer *nbpeer.Peer, settings *types.Se return false } -// GetPeer for a given accountID, peerID and userID error if not found. +// GetPeer returns a peer visible to the user within an account. +// Users with "peers:read" permission can access any peer. Otherwise, users can access only their own peer. func (am *DefaultAccountManager) GetPeer(ctx context.Context, accountID, peerID, userID string) (*nbpeer.Peer, error) { peer, err := am.Store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) if err != nil { return nil, err } - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -1036,47 +1516,111 @@ func (am *DefaultAccountManager) GetPeer(ctx context.Context, accountID, peerID, return peer, nil } - return am.checkIfUserOwnsPeer(ctx, accountID, userID, peer) -} - -func (am *DefaultAccountManager) checkIfUserOwnsPeer(ctx context.Context, accountID, userID string, peer *nbpeer.Peer) (*nbpeer.Peer, error) { - account, err := am.requestBuffer.GetAccountWithBackpressure(ctx, accountID) - if err != nil { - return nil, err - } - - approvedPeersMap, err := am.integratedPeerValidator.GetValidatedPeers(ctx, accountID, maps.Values(account.Groups), maps.Values(account.Peers), account.Settings.Extra) - if err != nil { - return nil, err - } - - // it is also possible that user doesn't own the peer but some of his peers have access to it, - // this is a valid case, show the peer as well. - userPeers, err := am.Store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID) - if err != nil { - return nil, err - } - - for _, p := range userPeers { - aclPeers, _ := account.GetPeerConnectionResources(ctx, p, approvedPeersMap) - for _, aclPeer := range aclPeers { - if aclPeer.ID == peer.ID { - return peer, nil - } - } - } - return nil, status.Errorf(status.Internal, "user %s has no access to peer %s under account %s", userID, peer.ID, accountID) } // UpdateAccountPeers updates all peers that belong to an account. // Should be called when changes have to be synced to peers. -func (am *DefaultAccountManager) UpdateAccountPeers(ctx context.Context, accountID string) { - _ = am.networkMapController.UpdateAccountPeers(ctx, accountID) +func (am *DefaultAccountManager) UpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) { + _ = am.networkMapController.UpdateAccountPeers(ctx, accountID, reason) } -func (am *DefaultAccountManager) BufferUpdateAccountPeers(ctx context.Context, accountID string) { - _ = am.networkMapController.BufferUpdateAccountPeers(ctx, accountID) +// ExpandAndUpdateAffected expands a Snapshot (loaded INSIDE the now-committed +// transaction) into the affected peers and dispatches the network-map refresh. +// Pure in-memory work plus dispatch, so it runs AFTER commit — the fan-out walk +// never holds the write lock, over the consistent in-tx snapshot. Exported so the +// networks sub-package managers (which hold only account.Manager) share it. +func (am *DefaultAccountManager) ExpandAndUpdateAffected(ctx context.Context, accountID string, snap *affectedpeers.Snapshot, change affectedpeers.Change) { + go am.dispatchAffected(ctx, accountID, []*affectedpeers.Snapshot{snap}, []affectedpeers.Change{change}) +} + +// dispatchAffected expands one or more (snapshot, change) pairs — collected across +// one or several transactions — unions their affected peers, and dispatches a +// single network-map refresh. Each snapshot must already be loaded inside its +// transaction; this runs AFTER commit (pure in-memory + dispatch). It is spawned +// in a goroutine that outlives the request, so it detaches from the request +// context's cancellation up front. +func (am *DefaultAccountManager) dispatchAffected(ctx context.Context, accountID string, snaps []*affectedpeers.Snapshot, changes []affectedpeers.Change) { + ctx = context.WithoutCancel(ctx) + + var lists [][]string + for i, snap := range snaps { + if snap == nil { + continue + } + lists = append(lists, snap.Expand(ctx, accountID, changes[i])) + } + + affectedPeerIDs := unionStrings(lists...) + if len(affectedPeerIDs) == 0 { + log.WithContext(ctx).Tracef("no affected peers for account %s", accountID) + return + } + + log.WithContext(ctx).Debugf("updating %d affected peers for account %s: %v", len(affectedPeerIDs), accountID, affectedPeerIDs) + _ = am.networkMapController.UpdateAffectedPeers(ctx, accountID, affectedPeerIDs) +} + +// unionStrings concatenates the given string lists into one deduplicated slice, +// preserving first-occurrence order. +func unionStrings(lists ...[]string) []string { + seen := make(map[string]struct{}) + var out []string + for _, list := range lists { + for _, id := range list { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + } + return out +} + +// affectedPeerIDsFromNetworkMap returns the peer IDs referenced by a peer's +// network map (its connected and offline peers, which include routing and proxy +// peers), excluding the peer itself. For a freshly added peer these are, by ACL +// symmetry, exactly the peers its addition affects. +func affectedPeerIDsFromNetworkMap(nmap *types.NetworkMap, selfPeerID string) []string { + if nmap == nil { + return nil + } + seen := make(map[string]struct{}, len(nmap.Peers)+len(nmap.OfflinePeers)) + ids := make([]string, 0, len(nmap.Peers)+len(nmap.OfflinePeers)) + add := func(peers []*types.ComponentPeer) { + for _, p := range peers { + if p == nil || p.ID == "" || p.ID == selfPeerID { + continue + } + if _, ok := seen[p.ID]; ok { + continue + } + seen[p.ID] = struct{}{} + ids = append(ids, p.ID) + } + } + add(nmap.Peers) + add(nmap.OfflinePeers) + return ids +} + +// resolveAffectedPeersForPeerChanges loads a snapshot and expands it for a peer +// change. The graph is unchanged by these paths, so it runs out of the mutating +// transaction (after commit); the resolver derives the peers' group memberships +// during the walk, so the caller passes only the changed peer IDs. +func (am *DefaultAccountManager) resolveAffectedPeersForPeerChanges(ctx context.Context, s store.Store, accountID string, changedPeerIDs []string) []string { + change := affectedpeers.Change{ChangedPeerIDs: changedPeerIDs} + snap, err := affectedpeers.Load(ctx, s, accountID, change) + if err != nil { + log.WithContext(ctx).Errorf("failed to load snapshot for affected peers: %v", err) + return nil + } + return snap.Expand(ctx, accountID, change) +} + +func (am *DefaultAccountManager) BufferUpdateAccountPeers(ctx context.Context, accountID string, reason types.UpdateReason) { + _ = am.networkMapController.BufferUpdateAccountPeers(ctx, accountID, reason) } // UpdateAccountPeer updates a single peer that belongs to an account. @@ -1186,6 +1730,10 @@ func (am *DefaultAccountManager) getExpiredPeers(ctx context.Context, accountID var peers []*nbpeer.Peer for _, peer := range peersWithExpiry { + if peer.Status.LoginExpired { + continue + } + expired, _ := peer.LoginExpired(settings.PeerLoginExpiration) if expired { peers = append(peers, peer) @@ -1230,13 +1778,9 @@ func getPeerGroupIDs(ctx context.Context, transaction store.Store, accountID str // deletePeers deletes all specified peers and sends updates to the remote peers. // Returns a slice of functions to save events after successful peer deletion. -func deletePeers(ctx context.Context, am *DefaultAccountManager, transaction store.Store, accountID, userID string, peers []*nbpeer.Peer) ([]func(), error) { +func deletePeers(ctx context.Context, am *DefaultAccountManager, transaction store.Store, accountID, userID string, peers []*nbpeer.Peer, settings *types.Settings) ([]func(), error) { var peerDeletedEvents []func() - settings, err := transaction.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) - if err != nil { - return nil, err - } dnsDomain := am.networkMapController.GetDNSDomain(settings) for _, peer := range peers { @@ -1244,10 +1788,6 @@ func deletePeers(ctx context.Context, am *DefaultAccountManager, transaction sto return nil, fmt.Errorf("failed to remove peer %s from groups", peer.ID) } - if err := am.integratedPeerValidator.PeerDeleted(ctx, accountID, peer.ID, settings.Extra); err != nil { - return nil, err - } - peerPolicyRules, err := transaction.GetPolicyRulesByResourceID(ctx, store.LockingStrengthNone, accountID, peer.ID) if err != nil { return nil, err @@ -1271,9 +1811,11 @@ func deletePeers(ctx context.Context, am *DefaultAccountManager, transaction sto if err = transaction.DeletePeer(ctx, accountID, peer.ID); err != nil { return nil, err } - peerDeletedEvents = append(peerDeletedEvents, func() { - am.StoreEvent(ctx, userID, peer.ID, accountID, activity.PeerRemovedByUser, peer.EventMeta(dnsDomain)) - }) + if !(peer.ProxyMeta.Embedded || peer.Meta.KernelVersion == "wasm") { + peerDeletedEvents = append(peerDeletedEvents, func() { + am.StoreEvent(ctx, userID, peer.ID, accountID, activity.PeerRemovedByUser, peer.EventMeta(dnsDomain)) + }) + } } return peerDeletedEvents, nil diff --git a/management/server/peer/peer.go b/management/server/peer/peer.go index a898fd782..7c4971285 100644 --- a/management/server/peer/peer.go +++ b/management/server/peer/peer.go @@ -1,14 +1,26 @@ package peer import ( + "context" + "fmt" "net" "net/netip" "slices" - "sort" + "strings" "time" + log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/shared/management/http/api" + sharedTypes "github.com/netbirdio/netbird/shared/management/types" +) + +// Peer capability constants mirror the proto enum values. +const ( + PeerCapabilitySourcePrefixes int32 = 1 + PeerCapabilityIPv6Overlay int32 = 2 + PeerCapabilityComponentNetworkMap int32 = 3 ) // Peer represents a machine connected to the network. @@ -19,11 +31,15 @@ type Peer struct { // AccountID is a reference to Account that this object belongs AccountID string `json:"-" gorm:"index"` // WireGuard public key - Key string `gorm:"index"` + Key string // uniqueness index (check migrations) // IP address of the Peer - IP net.IP `gorm:"serializer:json"` // uniqueness index per accountID (check migrations) + IP netip.Addr `gorm:"serializer:json"` // uniqueness index per accountID (check migrations) + // IPv6 overlay address of the Peer, zero value if IPv6 is not enabled for the account. + IPv6 netip.Addr `gorm:"serializer:json"` // Meta is a Peer system meta data Meta PeerSystemMeta `gorm:"embedded;embeddedPrefix:meta_"` + // ProxyMeta is metadata related to proxy peers + ProxyMeta ProxyMeta `gorm:"embedded;embeddedPrefix:proxy_meta_"` // Name is peer's name (machine name) Name string `gorm:"index"` // DNSLabel is the parsed peer name for domain resolution. It is used to form an FQDN by appending the account's @@ -48,6 +64,7 @@ type Peer struct { CreatedAt time.Time // Indicate ephemeral peer attribute Ephemeral bool `gorm:"index"` + // Geo location based on connection IP Location Location `gorm:"embedded;embeddedPrefix:location_"` @@ -57,9 +74,25 @@ type Peer struct { AllowExtraDNSLabels bool } +type ProxyMeta struct { + Embedded bool `gorm:"index"` + Cluster string `gorm:"index"` +} + type PeerStatus struct { //nolint:revive - // LastSeen is the last time peer was connected to the management service + // LastSeen is the last time the peer status was updated (i.e. the last + // time we observed the peer being alive on a sync stream). Written by + // the database (CURRENT_TIMESTAMP) — callers do not supply it. LastSeen time.Time + // SessionStartedAt records when the currently-active sync stream began, + // stored as Unix nanoseconds. It acts as the optimistic-locking token + // for status updates: a stream is only allowed to mutate the peer's + // status when its own token strictly exceeds the stored token (when connecting) + // or matches it exactly (for disconnects). Zero means "no + // active session". Integer nanoseconds are used so equality is + // precision-safe across drivers, and so the predicates compose to a + // single bigint comparison. + SessionStartedAt int64 `gorm:"not null;default:0"` // Connected indicates whether peer is connected to the management service or not Connected bool // LoginExpired @@ -76,6 +109,15 @@ type Location struct { GeoNameID uint // city level geoname id } +// equal reports whether two locations match. ConnectionIP is a net.IP slice, so it uses +// IP.Equal, not ==. +func (l Location) equal(other Location) bool { + return l.CountryCode == other.CountryCode && + l.CityName == other.CityName && + l.GeoNameID == other.GeoNameID && + l.ConnectionIP.Equal(other.ConnectionIP) +} + // NetworkAddress is the IP address with network and MAC address of a network interface type NetworkAddress struct { NetIP netip.Prefix `gorm:"serializer:json"` @@ -107,6 +149,7 @@ type Flags struct { DisableFirewall bool BlockLANAccess bool BlockInbound bool + DisableIPv6 bool LazyConnectionEnabled bool } @@ -130,51 +173,12 @@ type PeerSystemMeta struct { //nolint:revive Environment Environment `gorm:"serializer:json"` Flags Flags `gorm:"serializer:json"` Files []File `gorm:"serializer:json"` + Capabilities []int32 `gorm:"serializer:json"` + SyncMessageVersion int } func (p PeerSystemMeta) isEqual(other PeerSystemMeta) bool { - sort.Slice(p.NetworkAddresses, func(i, j int) bool { - return p.NetworkAddresses[i].Mac < p.NetworkAddresses[j].Mac - }) - sort.Slice(other.NetworkAddresses, func(i, j int) bool { - return other.NetworkAddresses[i].Mac < other.NetworkAddresses[j].Mac - }) - equalNetworkAddresses := slices.EqualFunc(p.NetworkAddresses, other.NetworkAddresses, func(addr NetworkAddress, oAddr NetworkAddress) bool { - return addr.Mac == oAddr.Mac && addr.NetIP == oAddr.NetIP - }) - if !equalNetworkAddresses { - return false - } - - sort.Slice(p.Files, func(i, j int) bool { - return p.Files[i].Path < p.Files[j].Path - }) - sort.Slice(other.Files, func(i, j int) bool { - return other.Files[i].Path < other.Files[j].Path - }) - equalFiles := slices.EqualFunc(p.Files, other.Files, func(file File, oFile File) bool { - return file.Path == oFile.Path && file.Exist == oFile.Exist && file.ProcessIsRunning == oFile.ProcessIsRunning - }) - if !equalFiles { - return false - } - - return p.Hostname == other.Hostname && - p.GoOS == other.GoOS && - p.Kernel == other.Kernel && - p.KernelVersion == other.KernelVersion && - p.Core == other.Core && - p.Platform == other.Platform && - p.OS == other.OS && - p.OSVersion == other.OSVersion && - p.WtVersion == other.WtVersion && - p.UIVersion == other.UIVersion && - p.SystemSerialNumber == other.SystemSerialNumber && - p.SystemProductName == other.SystemProductName && - p.SystemManufacturer == other.SystemManufacturer && - p.Environment.Cloud == other.Environment.Cloud && - p.Environment.Platform == other.Environment.Platform && - p.Flags.isEqual(other.Flags) + return len(metaDiff(p, other)) == 0 } func (p PeerSystemMeta) isEmpty() bool { @@ -202,6 +206,74 @@ func (p *Peer) AddedWithSSOLogin() bool { return p.UserID != "" } +// ToComponent converts the peer to its self-contained components +// representation, carrying exactly the subset of peer data that crosses the +// components wire format. Returns nil for a nil peer so callers can convert +// possibly-missing peers without guarding. +func (p *Peer) ToComponent() *sharedTypes.ComponentPeer { + if p == nil { + return nil + } + cp := &sharedTypes.ComponentPeer{ + ID: p.ID, + Key: p.Key, + IP: p.IP, + IPv6: p.IPv6, + DNSLabel: p.DNSLabel, + SSHKey: p.SSHKey, + SSHEnabled: p.SSHEnabled, + ServerSSHAllowed: p.Meta.Flags.ServerSSHAllowed, + AgentVersion: p.Meta.WtVersion, + SupportsSourcePrefixes: p.SupportsSourcePrefixes(), + SupportsIPv6: p.SupportsIPv6(), + LoginExpirationEnabled: p.LoginExpirationEnabled, + AddedWithSSOLogin: p.AddedWithSSOLogin(), + } + if p.LastLogin != nil { + cp.LastLogin = *p.LastLogin + } + return cp +} + +// HasCapability reports whether the peer has the given capability. +func (p *Peer) HasCapability(capability int32) bool { + return slices.Contains(p.Meta.Capabilities, capability) +} + +// SupportsIPv6 reports whether the peer supports IPv6 overlay. +func (p *Peer) SupportsIPv6() bool { + return !p.Meta.Flags.DisableIPv6 && p.HasCapability(PeerCapabilityIPv6Overlay) +} + +// SupportsSourcePrefixes reports whether the peer reads SourcePrefixes. +func (p *Peer) SupportsSourcePrefixes() bool { + return p.HasCapability(PeerCapabilitySourcePrefixes) +} + +// SupportsComponentNetworkMap reports whether the peer assembles its +// NetworkMap from server-shipped components instead of consuming a fully +// expanded NetworkMap. Determines whether the network_map controller skips +// Calculate() server-side and emits the components envelope. +func (p *Peer) SupportsComponentNetworkMap() bool { + return p.HasCapability(PeerCapabilityComponentNetworkMap) +} + +func capabilitiesEqual(a, b []int32) bool { + if len(a) != len(b) { + return false + } + set := make(map[int32]struct{}, len(a)) + for _, c := range a { + set[c] = struct{}{} + } + for _, c := range b { + if _, ok := set[c]; !ok { + return false + } + } + return true +} + // Copy copies Peer object func (p *Peer) Copy() *Peer { peerStatus := p.Status @@ -213,6 +285,7 @@ func (p *Peer) Copy() *Peer { AccountID: p.AccountID, Key: p.Key, IP: p.IP, + IPv6: p.IPv6, Meta: p.Meta, Name: p.Name, DNSLabel: p.DNSLabel, @@ -224,6 +297,7 @@ func (p *Peer) Copy() *Peer { LastLogin: p.LastLogin, CreatedAt: p.CreatedAt, Ephemeral: p.Ephemeral, + ProxyMeta: p.ProxyMeta, Location: p.Location, InactivityExpirationEnabled: p.InactivityExpirationEnabled, ExtraDNSLabels: slices.Clone(p.ExtraDNSLabels), @@ -231,26 +305,176 @@ func (p *Peer) Copy() *Peer { } } -// UpdateMetaIfNew updates peer's system metadata if new information is provided -// returns true if meta was updated, false otherwise -func (p *Peer) UpdateMetaIfNew(meta PeerSystemMeta) (updated, versionChanged bool) { +// UpdateMetaIfNew updates peer's system metadata and connection geo location if +// new information is provided. newLocation is the geo location resolved from the +// peer's current connection IP, or nil when there is nothing to apply (geo +// disabled, no real IP, or the IP is unchanged); the caller owns the expensive +// lookup and the same-IP guard. It returns a MetaDiff describing what changed; +// diff.Updated() reports whether the peer needs to be persisted. +func (p *Peer) UpdateMetaIfNew(ctx context.Context, meta PeerSystemMeta, newLocation *Location) MetaDiff { if meta.isEmpty() { - return updated, versionChanged + return MetaDiff{} } - versionChanged = p.Meta.WtVersion != meta.WtVersion - // Avoid overwriting UIVersion if the update was triggered sole by the CLI client if meta.UIVersion == "" { meta.UIVersion = p.Meta.UIVersion } - if p.Meta.isEqual(meta) { - return updated, versionChanged + effectiveLocation := p.Location + if newLocation != nil { + effectiveLocation = *newLocation } - p.Meta = meta - updated = true - return updated, versionChanged + + diff := diffMeta(p.Meta, meta, p.Location, effectiveLocation) + if diff.Updated() { + p.Meta = meta + } + p.Location = effectiveLocation + + if diff.Updated() { + log.WithContext(ctx).Debug(diff.LogSummary()) + } + + return diff +} + +// MetaDiff holds a peer's full before/after state across a sync: both metas and both +// connection locations (the location lives on Peer, not PeerSystemMeta, but posture +// checks read it). Changed lists what moved, for logging and the persistence decision; +// the snapshots let a posture check be replayed against old and new. Everything is derived +// from these fields, so there are no parallel per-field flags to keep in sync. +type MetaDiff struct { + OldMeta PeerSystemMeta + NewMeta PeerSystemMeta + OldLocation Location + NewLocation Location + + Changed []string +} + +// Updated reports whether anything changed and the peer must be persisted. diffMeta fills +// Changed in the pass that builds the diff, so this is a length check, not a re-comparison. +// Pointer receiver: MetaDiff embeds two metas, so copying it per call is wasteful. +func (d *MetaDiff) Updated() bool { + return len(d.Changed) != 0 +} + +// VersionChanged reports whether the WireGuard client version changed (a client upgrade). +func (d *MetaDiff) VersionChanged() bool { + return d.OldMeta.WtVersion != d.NewMeta.WtVersion +} + +// HostnameChanged reports whether the peer's hostname changed. +func (d *MetaDiff) HostnameChanged() bool { + return d.OldMeta.Hostname != d.NewMeta.Hostname +} + +// LogSummary renders the changed fields as a single human-readable line. +func (d *MetaDiff) LogSummary() string { + return fmt.Sprintf("peer meta updated, %d field(s) changed: %s", + len(d.Changed), strings.Join(d.Changed, ", ")) +} + +func metaDiff(oldMeta, newMeta PeerSystemMeta) []string { + return diffMeta(oldMeta, newMeta, Location{}, Location{}).Changed +} + +// diffMeta snapshots a peer's old and new state and records a Changed entry per field that +// moved. It is the single source of truth for the comparison: isEqual is an empty Changed +// list, so the log line and the persistence decision can never disagree. +func diffMeta(oldMeta, newMeta PeerSystemMeta, oldLocation, newLocation Location) MetaDiff { + d := MetaDiff{OldMeta: oldMeta, NewMeta: newMeta, OldLocation: oldLocation, NewLocation: newLocation} + add := func(field string, oldVal, newVal any) { + d.Changed = append(d.Changed, fmt.Sprintf("%s: %v -> %v", field, oldVal, newVal)) + } + + if oldMeta.Hostname != newMeta.Hostname { + add("hostname", oldMeta.Hostname, newMeta.Hostname) + } + if oldMeta.GoOS != newMeta.GoOS { + add("goos", oldMeta.GoOS, newMeta.GoOS) + } + if oldMeta.Kernel != newMeta.Kernel { + add("kernel", oldMeta.Kernel, newMeta.Kernel) + } + if oldMeta.KernelVersion != newMeta.KernelVersion { + add("kernel_version", oldMeta.KernelVersion, newMeta.KernelVersion) + } + if oldMeta.Core != newMeta.Core { + add("core", oldMeta.Core, newMeta.Core) + } + if oldMeta.Platform != newMeta.Platform { + add("platform", oldMeta.Platform, newMeta.Platform) + } + if oldMeta.OS != newMeta.OS { + add("os", oldMeta.OS, newMeta.OS) + } + if oldMeta.OSVersion != newMeta.OSVersion { + add("os_version", oldMeta.OSVersion, newMeta.OSVersion) + } + if oldMeta.WtVersion != newMeta.WtVersion { + add("wt_version", oldMeta.WtVersion, newMeta.WtVersion) + } + if oldMeta.UIVersion != newMeta.UIVersion { + add("ui_version", oldMeta.UIVersion, newMeta.UIVersion) + } + if oldMeta.SystemSerialNumber != newMeta.SystemSerialNumber { + add("system_serial_number", oldMeta.SystemSerialNumber, newMeta.SystemSerialNumber) + } + if oldMeta.SystemProductName != newMeta.SystemProductName { + add("system_product_name", oldMeta.SystemProductName, newMeta.SystemProductName) + } + if oldMeta.SystemManufacturer != newMeta.SystemManufacturer { + add("system_manufacturer", oldMeta.SystemManufacturer, newMeta.SystemManufacturer) + } + if oldMeta.Environment.Cloud != newMeta.Environment.Cloud { + add("environment_cloud", oldMeta.Environment.Cloud, newMeta.Environment.Cloud) + } + if oldMeta.Environment.Platform != newMeta.Environment.Platform { + add("environment_platform", oldMeta.Environment.Platform, newMeta.Environment.Platform) + } + if !oldMeta.Flags.isEqual(newMeta.Flags) { + add("flags", fmt.Sprintf("%+v", oldMeta.Flags), fmt.Sprintf("%+v", newMeta.Flags)) + } + if !capabilitiesEqual(oldMeta.Capabilities, newMeta.Capabilities) { + add("capabilities", oldMeta.Capabilities, newMeta.Capabilities) + } + if !sameMultiset(oldMeta.NetworkAddresses, newMeta.NetworkAddresses) { + add("network_addresses", fmt.Sprintf("%v", oldMeta.NetworkAddresses), fmt.Sprintf("%v", newMeta.NetworkAddresses)) + } + if !sameMultiset(oldMeta.Files, newMeta.Files) { + add("files", fmt.Sprintf("%v", oldMeta.Files), fmt.Sprintf("%v", newMeta.Files)) + } + if oldMeta.SyncMessageVersion != newMeta.SyncMessageVersion { + add("sync_meta_version", fmt.Sprintf("%d", oldMeta.SyncMessageVersion), fmt.Sprintf("%d", newMeta.SyncMessageVersion)) + } + + if !oldLocation.equal(newLocation) { + add("connection_ip", oldLocation.ConnectionIP, newLocation.ConnectionIP) + } + + return d +} + +// sameMultiset reports whether two slices contain the same elements with the +// same multiplicity, ignoring order. The element type is the comparison key, so +// every field participates in equality. +func sameMultiset[T comparable](a, b []T) bool { + if len(a) != len(b) { + return false + } + counts := make(map[T]int, len(a)) + for _, v := range a { + counts[v]++ + } + for _, v := range b { + counts[v]-- + if counts[v] == 0 { + delete(counts, v) + } + } + return len(counts) == 0 } // GetLastLogin returns the last login time of the peer. @@ -304,6 +528,22 @@ func (p *Peer) LoginExpired(expiresIn time.Duration) (bool, time.Duration) { return timeLeft <= 0, timeLeft } +// SessionExpiresAt returns the absolute UTC instant at which the peer's SSO +// session expires, derived from LastLogin and the account-level +// PeerLoginExpiration setting. Returns the zero value when login expiration +// does not apply (peer not SSO-registered, peer-level toggle off, or account +// expiry disabled). Callers should treat the zero value as "no deadline". +func (p *Peer) SessionExpiresAt(accountExpirationEnabled bool, expiresIn time.Duration) time.Time { + if !accountExpirationEnabled || !p.AddedWithSSOLogin() || !p.LoginExpirationEnabled { + return time.Time{} + } + last := p.GetLastLogin() + if last.IsZero() { + return time.Time{} + } + return last.Add(expiresIn).UTC() +} + // FQDN returns peers FQDN combined of the peer's DNS label and the system's DNS domain func (p *Peer) FQDN(dnsDomain string) string { if dnsDomain == "" { @@ -314,15 +554,23 @@ func (p *Peer) FQDN(dnsDomain string) string { // EventMeta returns activity event meta related to the peer func (p *Peer) EventMeta(dnsDomain string) map[string]any { - return map[string]any{"name": p.Name, "fqdn": p.FQDN(dnsDomain), "ip": p.IP, "created_at": p.CreatedAt, + meta := map[string]any{"name": p.Name, "fqdn": p.FQDN(dnsDomain), "ip": p.IP, "created_at": p.CreatedAt, "location_city_name": p.Location.CityName, "location_country_code": p.Location.CountryCode, "location_geo_name_id": p.Location.GeoNameID, "location_connection_ip": p.Location.ConnectionIP} + if p.IPv6.IsValid() { + meta["ipv6"] = p.IPv6.String() + } + return meta } -// Copy PeerStatus +// Copy PeerStatus. SessionStartedAt must be propagated so clone-based +// callers (Peer.Copy, MarkLoginExpired, UpdateLastLogin) don't silently +// reset the fencing token to zero — that would let any subsequent +// SavePeerStatus write reopen the optimistic-lock window. func (p *PeerStatus) Copy() *PeerStatus { return &PeerStatus{ LastSeen: p.LastSeen, + SessionStartedAt: p.SessionStartedAt, Connected: p.Connected, LoginExpired: p.LoginExpired, RequiresApproval: p.RequiresApproval, @@ -343,9 +591,10 @@ func (p *Peer) FromAPITemporaryAccessRequest(a *api.PeerTemporaryAccessRequest) p.Name = a.Name p.Key = a.WgPubKey p.Meta = PeerSystemMeta{ - Hostname: a.Name, - GoOS: "js", - OS: "js", + Hostname: a.Name, + GoOS: "js", + OS: "js", + KernelVersion: "wasm", } } @@ -359,5 +608,6 @@ func (f Flags) isEqual(other Flags) bool { f.DisableFirewall == other.DisableFirewall && f.BlockLANAccess == other.BlockLANAccess && f.BlockInbound == other.BlockInbound && - f.LazyConnectionEnabled == other.LazyConnectionEnabled + f.LazyConnectionEnabled == other.LazyConnectionEnabled && + f.DisableIPv6 == other.DisableIPv6 } diff --git a/management/server/peer/peer_metadiff_test.go b/management/server/peer/peer_metadiff_test.go new file mode 100644 index 000000000..1256cdb02 --- /dev/null +++ b/management/server/peer/peer_metadiff_test.go @@ -0,0 +1,113 @@ +package peer + +import ( + "net/netip" + "reflect" + "testing" + + "github.com/stretchr/testify/require" +) + +// metaDiffExtraEntries accounts for PeerSystemMeta fields that metaDiff does not +// map 1:1 to a single diff entry. Today the only such field is Environment, which +// is exploded into two checks (Cloud, Platform) and therefore yields one extra +// entry beyond its single struct field. If you teach metaDiff to explode another +// field into N entries, bump this by N-1; if you collapse a field, lower it. +const metaDiffExtraEntries = 1 + +// TestMetaDiff_CoversAllFields fully populates a PeerSystemMeta with non-zero +// values and diffs it against the zero value, then asserts metaDiff emits exactly +// one entry per exported field (plus metaDiffExtraEntries for fields it explodes). +// +// The expected count is derived from the struct via reflection, so adding a field +// to PeerSystemMeta raises the expectation automatically — but the actual diff +// only grows if metaDiff was taught to compare the new field. A mismatch means +// someone changed the struct without updating metaDiff (or this test's +// extra-entry accounting), which is exactly what we want to catch. +func TestMetaDiff_CoversAllFields(t *testing.T) { + var full PeerSystemMeta + exported := populateAll(t, reflect.ValueOf(&full).Elem()) + require.NotZero(t, exported, "expected PeerSystemMeta to expose fields") + + diff := metaDiff(PeerSystemMeta{}, full) + + require.Len(t, diff, exported+metaDiffExtraEntries, + "metaDiff entry count no longer matches PeerSystemMeta's fields: a field was "+ + "likely added or removed without updating metaDiff (or metaDiffExtraEntries). "+ + "diff was: %v", diff) + + require.False(t, full.isEqual(PeerSystemMeta{}), + "isEqual must report a fully-populated meta as different from the zero value") +} + +// TestFlags_isEqualChecksEveryField guards the one field that the count-based +// TestMetaDiff_CoversAllFields cannot: metaDiff collapses all of Flags into a +// single "flags" diff entry, so a new Flags field that Flags.isEqual forgets to +// compare would not change the diff count. This flips each Flags field on its own +// and asserts Flags.isEqual notices, so adding a Flags field without comparing it +// fails here. +func TestFlags_isEqualChecksEveryField(t *testing.T) { + typ := reflect.TypeOf(Flags{}) + for i := 0; i < typ.NumField(); i++ { + f := typ.Field(i) + require.Equal(t, reflect.Bool, f.Type.Kind(), + "Flags.%s is not a bool; extend this test to set it non-zero", f.Name) + + var a, b Flags + reflect.ValueOf(&b).Elem().Field(i).SetBool(true) + require.False(t, a.isEqual(b), "Flags.isEqual ignores field %s", f.Name) + } +} + +// populateAll sets every exported field of the struct to a deterministic non-zero +// value, recursing into nested structs and the element type of struct slices so +// that each leaf differs from zero. It returns the number of exported fields on +// the top-level struct. netip.Prefix is treated as an opaque leaf (it has no +// settable exported fields and is comparable with ==). +func populateAll(t *testing.T, v reflect.Value) int { + t.Helper() + + typ := v.Type() + exported := 0 + for i := 0; i < typ.NumField(); i++ { + f := typ.Field(i) + if f.PkgPath != "" { // unexported + continue + } + exported++ + setNonZero(t, v.Field(i)) + } + return exported +} + +// setNonZero assigns a deterministic non-zero value to a field based on its kind, +// recursing into nested structs and populating one element of slice fields. +func setNonZero(t *testing.T, field reflect.Value) { + t.Helper() + + if field.Type() == reflect.TypeOf(netip.Prefix{}) { + field.Set(reflect.ValueOf(netip.MustParsePrefix("10.0.0.0/24"))) + return + } + + switch field.Kind() { + case reflect.String: + field.SetString("non-zero") + case reflect.Bool: + field.SetBool(true) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + field.SetInt(7) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + field.SetUint(7) + case reflect.Float32, reflect.Float64: + field.SetFloat(7) + case reflect.Struct: + populateAll(t, field) + case reflect.Slice: + s := reflect.MakeSlice(field.Type(), 1, 1) + setNonZero(t, s.Index(0)) + field.Set(s) + default: + t.Fatalf("unhandled field kind %s; extend setNonZero", field.Kind()) + } +} diff --git a/management/server/peer/peer_test.go b/management/server/peer/peer_test.go index 1aa3f6ffc..c5b512069 100644 --- a/management/server/peer/peer_test.go +++ b/management/server/peer/peer_test.go @@ -5,6 +5,7 @@ import ( "net/netip" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -141,3 +142,25 @@ func TestFlags_IsEqual(t *testing.T) { }) } } + +func TestPeerCapabilities(t *testing.T) { + tests := []struct { + name string + capabilities []int32 + ipv6 bool + srcPrefixes bool + }{ + {"no capabilities", nil, false, false}, + {"only source prefixes", []int32{PeerCapabilitySourcePrefixes}, false, true}, + {"only ipv6", []int32{PeerCapabilityIPv6Overlay}, true, false}, + {"both", []int32{PeerCapabilitySourcePrefixes, PeerCapabilityIPv6Overlay}, true, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Peer{Meta: PeerSystemMeta{Capabilities: tt.capabilities}} + assert.Equal(t, tt.ipv6, p.SupportsIPv6()) + assert.Equal(t, tt.srcPrefixes, p.SupportsSourcePrefixes()) + }) + } +} diff --git a/management/server/peer_test.go b/management/server/peer_test.go index 95c609595..a7f8ba695 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -28,12 +28,17 @@ import ( "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache" "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" + "github.com/netbirdio/netbird/management/internals/modules/peers" + ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/internals/shared/grpc" + nbcache "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/http/testing/testing_tools" "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" + "github.com/netbirdio/netbird/management/server/job" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" + "github.com/netbirdio/netbird/shared/auth" "github.com/netbirdio/netbird/shared/management/status" "github.com/netbirdio/netbird/management/server/util" @@ -44,6 +49,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/geolocation" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/management/server/store" @@ -174,11 +180,6 @@ func TestAccountManager_GetNetworkMap(t *testing.T) { testGetNetworkMapGeneral(t) } -func TestAccountManager_GetNetworkMap_Experimental(t *testing.T) { - t.Setenv(network_map.EnvNewNetworkMapBuilder, "true") - testGetNetworkMapGeneral(t) -} - func testGetNetworkMapGeneral(t *testing.T) { manager, _, err := createManager(t) if err != nil { @@ -205,7 +206,7 @@ func testGetNetworkMapGeneral(t *testing.T) { return } - peer1, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer1, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey1.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"}, }, false) @@ -219,7 +220,7 @@ func testGetNetworkMapGeneral(t *testing.T) { t.Fatal(err) return } - _, _, _, err = manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + _, _, _, _, err = manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey2.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"}, }, false) @@ -278,7 +279,7 @@ func TestAccountManager_GetNetworkMapWithPolicy(t *testing.T) { return } - peer1, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer1, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey1.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"}, }, false) @@ -292,7 +293,7 @@ func TestAccountManager_GetNetworkMapWithPolicy(t *testing.T) { t.Fatal(err) return } - peer2, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer2, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey2.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"}, }, false) @@ -454,7 +455,7 @@ func TestAccountManager_GetPeerNetwork(t *testing.T) { return } - peer1, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer1, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey1.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"}, }, false) @@ -468,7 +469,7 @@ func TestAccountManager_GetPeerNetwork(t *testing.T) { t.Fatal(err) return } - _, _, _, err = manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + _, _, _, _, err = manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey2.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"}, }, false) @@ -500,7 +501,7 @@ func TestDefaultAccountManager_GetPeer(t *testing.T) { accountID := "test_account" adminUser := "account_creator" someUser := "some_user" - account := newAccountWithId(context.Background(), accountID, adminUser, "", false) + account := newAccountWithId(context.Background(), accountID, adminUser, "", "", "", false) account.Users[someUser] = &types.User{ Id: someUser, Role: types.UserRoleUser, @@ -526,7 +527,7 @@ func TestDefaultAccountManager_GetPeer(t *testing.T) { return } - peer1, _, _, err := manager.AddPeer(context.Background(), "", "", someUser, &nbpeer.Peer{ + peer1, _, _, _, err := manager.AddPeer(context.Background(), "", "", someUser, &nbpeer.Peer{ Key: peerKey1.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"}, }, false) @@ -542,7 +543,7 @@ func TestDefaultAccountManager_GetPeer(t *testing.T) { } // the second peer added with a setup key - peer2, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ + peer2, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", &nbpeer.Peer{ Key: peerKey2.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"}, }, false) @@ -559,25 +560,9 @@ func TestDefaultAccountManager_GetPeer(t *testing.T) { } assert.NotNil(t, peer) - // the user can see peer2 because peer1 of the user has access to peer2 due to the All group and the default rule 0 all-to-all access - peer, err = manager.GetPeer(context.Background(), accountID, peer2.ID, someUser) - if err != nil { - t.Fatal(err) - return - } - assert.NotNil(t, peer) - - // delete the all-to-all policy so that user's peer1 has no access to peer2 - for _, policy := range account.Policies { - err = manager.DeletePolicy(context.Background(), accountID, policy.ID, adminUser) - if err != nil { - t.Fatal(err) - return - } - } - - // at this point the user can't see the details of peer2 - peer, err = manager.GetPeer(context.Background(), accountID, peer2.ID, someUser) //nolint + // the user can NOT see peer2 because it is not owned by them. + // Regular users only see peers they directly own. + _, err = manager.GetPeer(context.Background(), accountID, peer2.ID, someUser) assert.Error(t, err) // admin users can always access all the peers @@ -687,7 +672,7 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) { accountID := "test_account" adminUser := "account_creator" someUser := "some_user" - account := newAccountWithId(context.Background(), accountID, adminUser, "", false) + account := newAccountWithId(context.Background(), accountID, adminUser, "", "", "", false) account.Users[someUser] = &types.User{ Id: someUser, Role: testCase.role, @@ -714,7 +699,7 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) { return } - _, _, _, err = manager.AddPeer(context.Background(), "", "", someUser, &nbpeer.Peer{ + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", someUser, &nbpeer.Peer{ Key: peerKey1.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-1"}, }, false) @@ -723,7 +708,7 @@ func TestDefaultAccountManager_GetPeers(t *testing.T) { return } - _, _, _, err = manager.AddPeer(context.Background(), "", "", adminUser, &nbpeer.Peer{ + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", adminUser, &nbpeer.Peer{ Key: peerKey2.PublicKey().String(), Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer-2"}, }, false) @@ -757,7 +742,7 @@ func setupTestAccountManager(b testing.TB, peers int, groups int) (*DefaultAccou adminUser := "account_creator" regularUser := "regular_user" - account := newAccountWithId(context.Background(), accountID, adminUser, "", false) + account := newAccountWithId(context.Background(), accountID, adminUser, "", "", "", false) account.Users[regularUser] = &types.User{ Id: regularUser, Role: types.UserRoleUser, @@ -770,7 +755,8 @@ func setupTestAccountManager(b testing.TB, peers int, groups int) (*DefaultAccou ID: fmt.Sprintf("peer-%d", i), DNSLabel: fmt.Sprintf("peer-%d", i), Key: peerKey.PublicKey().String(), - IP: net.ParseIP(fmt.Sprintf("100.64.%d.%d", i/256, i%256)), + IP: netip.MustParseAddr(fmt.Sprintf("100.64.%d.%d", i/256, i%256)), + IPv6: netip.MustParseAddr(fmt.Sprintf("fd00::%d", i+1)), Status: &nbpeer.PeerStatus{LastSeen: time.Now().UTC(), Connected: true}, UserID: regularUser, } @@ -799,7 +785,15 @@ func setupTestAccountManager(b testing.TB, peers int, groups int) (*DefaultAccou account.Networks = append(account.Networks, network) ips := account.GetTakenIPs() - peerIP, err := types.AllocatePeerIP(account.Network.Net, ips) + peerIP, err := types.AllocatePeerIP(netip.MustParsePrefix(account.Network.Net.String()), ips) + if err != nil { + return nil, nil, "", "", err + } + v6Prefix, err := netip.ParsePrefix(account.Network.NetV6.String()) + if err != nil { + return nil, nil, "", "", err + } + peerIPv6, err := types.AllocateRandomPeerIPv6(v6Prefix) if err != nil { return nil, nil, "", "", err } @@ -810,6 +804,7 @@ func setupTestAccountManager(b testing.TB, peers int, groups int) (*DefaultAccou DNSLabel: fmt.Sprintf("peer-nr-%d", len(account.Peers)+1), Key: peerKey.PublicKey().String(), IP: peerIP, + IPv6: peerIPv6, Status: &nbpeer.PeerStatus{LastSeen: time.Now().UTC(), Connected: true}, UserID: regularUser, Meta: nbpeer.PeerSystemMeta{ @@ -991,7 +986,7 @@ func BenchmarkUpdateAccountPeers(b *testing.B) { start := time.Now() for i := 0; i < b.N; i++ { - manager.UpdateAccountPeers(ctx, account.Id) + manager.UpdateAccountPeers(ctx, account.Id, types.UpdateReason{}) } duration := time.Since(start) @@ -1011,11 +1006,6 @@ func BenchmarkUpdateAccountPeers(b *testing.B) { } } -func TestUpdateAccountPeers_Experimental(t *testing.T) { - t.Setenv(network_map.EnvNewNetworkMapBuilder, "true") - testUpdateAccountPeers(t) -} - func TestUpdateAccountPeers(t *testing.T) { testUpdateAccountPeers(t) } @@ -1054,11 +1044,11 @@ func testUpdateAccountPeers(t *testing.T) { peerChannels[peerID] = updateManager.CreateChannel(ctx, peerID) } - manager.UpdateAccountPeers(ctx, account.Id) + manager.UpdateAccountPeers(ctx, account.Id, types.UpdateReason{}) for _, channel := range peerChannels { update := <-channel - assert.Nil(t, update.Update.NetbirdConfig) + assert.Nil(t, update.Update.NetbirdConfig, "fan-out updates must not carry a NetbirdConfig; clients treat a config without relay as relay disabled and wipe their relay URLs") assert.Equal(t, tc.peers, len(update.Update.NetworkMap.RemotePeers)) assert.Equal(t, tc.peers*2, len(update.Update.NetworkMap.FirewallRules)) } @@ -1089,7 +1079,8 @@ func TestToSyncResponse(t *testing.T) { }, } peer := &nbpeer.Peer{ - IP: net.ParseIP("192.168.1.1"), + IP: netip.MustParseAddr("192.168.1.1"), + IPv6: netip.MustParseAddr("fd00::1"), SSHEnabled: true, Key: "peer-key", DNSLabel: "peer1", @@ -1100,9 +1091,21 @@ func TestToSyncResponse(t *testing.T) { Signature: "turn-pass", } networkMap := &types.NetworkMap{ - Network: &types.Network{Net: *ipnet, Serial: 1000}, - Peers: []*nbpeer.Peer{{IP: net.ParseIP("192.168.1.2"), Key: "peer2-key", DNSLabel: "peer2", SSHEnabled: true, SSHKey: "peer2-ssh-key"}}, - OfflinePeers: []*nbpeer.Peer{{IP: net.ParseIP("192.168.1.3"), Key: "peer3-key", DNSLabel: "peer3", SSHEnabled: true, SSHKey: "peer3-ssh-key"}}, + Network: &types.Network{Net: *ipnet, Serial: 1000}, + Peers: []*types.ComponentPeer{{ + IP: netip.MustParseAddr("192.168.1.2"), + IPv6: netip.MustParseAddr("fd00::2"), + Key: "peer2-key", + DNSLabel: "peer2", + SSHEnabled: true, + SSHKey: "peer2-ssh-key"}}, + OfflinePeers: []*types.ComponentPeer{{ + IP: netip.MustParseAddr("192.168.1.3"), + IPv6: netip.MustParseAddr("fd00::3"), + Key: "peer3-key", + DNSLabel: "peer3", + SSHEnabled: true, + SSHKey: "peer3-ssh-key"}}, Routes: []*nbroute.Route{ { ID: "route1", @@ -1177,7 +1180,7 @@ func TestToSyncResponse(t *testing.T) { } dnsCache := &cache.DNSConfigCache{} accountSettings := &types.Settings{RoutingPeerDNSResolutionEnabled: true} - response := grpc.ToSyncResponse(context.Background(), config, peer, turnRelayToken, turnRelayToken, networkMap, dnsName, checks, dnsCache, accountSettings, nil, []string{}, int64(dnsForwarderPort)) + response := grpc.ToSyncResponse(context.Background(), config, config.HttpConfig, config.DeviceAuthorizationFlow, peer, turnRelayToken, turnRelayToken, networkMap, dnsName, checks, dnsCache, accountSettings, nil, []string{}, int64(dnsForwarderPort)) assert.NotNil(t, response) // assert peer config @@ -1228,6 +1231,7 @@ func TestToSyncResponse(t *testing.T) { assert.Equal(t, "route1", response.NetworkMap.Routes[0].NetID) // assert network map DNSConfig assert.Equal(t, true, response.NetworkMap.DNSConfig.ServiceEnable) + //nolint assert.Equal(t, int64(dnsForwarderPort), response.NetworkMap.DNSConfig.ForwarderPort) assert.Equal(t, 1, len(response.NetworkMap.DNSConfig.CustomZones)) assert.Equal(t, 2, len(response.NetworkMap.DNSConfig.NameServerGroups)) @@ -1248,6 +1252,7 @@ func TestToSyncResponse(t *testing.T) { assert.Equal(t, int64(53), response.NetworkMap.DNSConfig.NameServerGroups[0].NameServers[0].GetPort()) // assert network map Firewall assert.Equal(t, 1, len(response.NetworkMap.FirewallRules)) + //nolint:staticcheck // testing backward-compatible field assert.Equal(t, "192.168.1.2", response.NetworkMap.FirewallRules[0].PeerIP) assert.Equal(t, proto.RuleDirection_IN, response.NetworkMap.FirewallRules[0].Direction) assert.Equal(t, proto.RuleAction_ACCEPT, response.NetworkMap.FirewallRules[0].Action) @@ -1286,13 +1291,18 @@ func Test_RegisterPeerByUser(t *testing.T) { t.Cleanup(ctrl.Finish) settingsMockManager := settings.NewMockManager(ctrl) permissionsManager := permissions.NewManager(s) + peersManager := peers.NewManager(s, permissionsManager) ctx := context.Background() + + cacheStore, err := nbcache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100) + require.NoError(t, err) + updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, s) - networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock()) + networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}) - am, err := BuildManager(context.Background(), s, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false) + am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) assert.NoError(t, err) existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" @@ -1305,7 +1315,8 @@ func Test_RegisterPeerByUser(t *testing.T) { ID: xid.New().String(), AccountID: existingAccountID, Key: "newPeerKey", - IP: net.IP{123, 123, 123, 123}, + IP: netip.AddrFrom4([4]byte{123, 123, 123, 123}), + IPv6: netip.MustParseAddr("fd00::7b:7b:7b:7b"), Meta: nbpeer.PeerSystemMeta{ Hostname: "newPeer", GoOS: "linux", @@ -1322,7 +1333,7 @@ func Test_RegisterPeerByUser(t *testing.T) { }, } - addedPeer, _, _, err := am.AddPeer(context.Background(), "", "", existingUserID, newPeer, false) + addedPeer, _, _, _, err := am.AddPeer(context.Background(), "", "", existingUserID, newPeer, false) require.NoError(t, err) assert.Equal(t, newPeer.ExtraDNSLabels, addedPeer.ExtraDNSLabels) @@ -1371,13 +1382,18 @@ func Test_RegisterPeerBySetupKey(t *testing.T) { Return(&types.ExtraSettings{}, nil). AnyTimes() permissionsManager := permissions.NewManager(s) + peersManager := peers.NewManager(s, permissionsManager) ctx := context.Background() + + cacheStore, err := nbcache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100) + require.NoError(t, err) + updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, s) - networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock()) + networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}) - am, err := BuildManager(context.Background(), s, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false) + am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) assert.NoError(t, err) existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" @@ -1388,7 +1404,8 @@ func Test_RegisterPeerBySetupKey(t *testing.T) { newPeerTemplate := &nbpeer.Peer{ AccountID: existingAccountID, UserID: "", - IP: net.IP{123, 123, 123, 123}, + IP: netip.AddrFrom4([4]byte{123, 123, 123, 123}), + IPv6: netip.MustParseAddr("fd00::7b:7b:7b:7b"), Meta: nbpeer.PeerSystemMeta{ Hostname: "newPeer", GoOS: "linux", @@ -1449,7 +1466,7 @@ func Test_RegisterPeerBySetupKey(t *testing.T) { ExtraDNSLabels: newPeerTemplate.ExtraDNSLabels, } - addedPeer, _, _, err := am.AddPeer(context.Background(), "", tc.existingSetupKeyID, "", currentPeer, false) + addedPeer, _, _, _, err := am.AddPeer(context.Background(), "", tc.existingSetupKeyID, "", currentPeer, false) if tc.expectAddPeerError { require.Error(t, err, "Expected an error when adding peer with setup key: %s", tc.existingSetupKeyID) @@ -1524,13 +1541,18 @@ func Test_RegisterPeerRollbackOnFailure(t *testing.T) { settingsMockManager := settings.NewMockManager(ctrl) permissionsManager := permissions.NewManager(s) + peersManager := peers.NewManager(s, permissionsManager) ctx := context.Background() + + cacheStore, err := nbcache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100) + require.NoError(t, err) + updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, s) - networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock()) + networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}) - am, err := BuildManager(context.Background(), s, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false) + am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) assert.NoError(t, err) existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" @@ -1544,7 +1566,8 @@ func Test_RegisterPeerRollbackOnFailure(t *testing.T) { AccountID: existingAccountID, Key: "newPeerKey", UserID: "", - IP: net.IP{123, 123, 123, 123}, + IP: netip.AddrFrom4([4]byte{123, 123, 123, 123}), + IPv6: netip.MustParseAddr("fd00::7b:7b:7b:7b"), Meta: nbpeer.PeerSystemMeta{ Hostname: "newPeer", GoOS: "linux", @@ -1555,7 +1578,7 @@ func Test_RegisterPeerRollbackOnFailure(t *testing.T) { SSHEnabled: false, } - _, _, _, err = am.AddPeer(context.Background(), "", faultyKey, "", newPeer, false) + _, _, _, _, err = am.AddPeer(context.Background(), "", faultyKey, "", newPeer, false) require.Error(t, err) _, err = s.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, newPeer.Key) @@ -1579,7 +1602,6 @@ func Test_RegisterPeerRollbackOnFailure(t *testing.T) { } func Test_LoginPeer(t *testing.T) { - t.Setenv(network_map.EnvNewNetworkMapBuilder, "true") if runtime.GOOS == "windows" { t.Skip("The SQLite store is not properly supported by Windows yet") } @@ -1604,13 +1626,18 @@ func Test_LoginPeer(t *testing.T) { Return(&types.ExtraSettings{}, nil). AnyTimes() permissionsManager := permissions.NewManager(s) + peersManager := peers.NewManager(s, permissionsManager) ctx := context.Background() + + cacheStore, err := nbcache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100) + require.NoError(t, err) + updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, s) - networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock()) + networkMapController := controller.NewController(ctx, s, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(s, peers.NewManager(s, permissionsManager)), &config.Config{}) - am, err := BuildManager(context.Background(), s, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false) + am, err := BuildManager(context.Background(), nil, s, networkMapController, job.NewJobManager(nil, s, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) assert.NoError(t, err) existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" @@ -1625,7 +1652,8 @@ func Test_LoginPeer(t *testing.T) { newPeerTemplate := &nbpeer.Peer{ AccountID: existingAccountID, UserID: "", - IP: net.IP{123, 123, 123, 123}, + IP: netip.AddrFrom4([4]byte{123, 123, 123, 123}), + IPv6: netip.MustParseAddr("fd00::7b:7b:7b:7b"), Meta: nbpeer.PeerSystemMeta{ Hostname: "newPeer", GoOS: "linux", @@ -1696,7 +1724,7 @@ func Test_LoginPeer(t *testing.T) { if sk.AllowExtraDNSLabels { currentPeer.ExtraDNSLabels = newPeerTemplate.ExtraDNSLabels } - _, _, _, err = am.AddPeer(context.Background(), "", tc.setupKey, "", currentPeer, false) + _, _, _, _, err = am.AddPeer(context.Background(), "", tc.setupKey, "", currentPeer, false) require.NoError(t, err, "Expected no error when adding peer with setup key: %s", tc.setupKey) loginInput := types.PeerLogin{ @@ -1712,12 +1740,12 @@ func Test_LoginPeer(t *testing.T) { loginInput.ExtraDNSLabels = tc.extraDNSLabels } - loggedinPeer, networkMap, postureChecks, loginErr := am.LoginPeer(context.Background(), loginInput) + loggedinPeer, network, postureChecks, _, loginErr := am.LoginPeer(context.Background(), loginInput) if tc.expectLoginError { require.Error(t, loginErr, "Expected an error during LoginPeer with setup key: %s", tc.setupKey) assert.Contains(t, loginErr.Error(), tc.expectedErrorMsgSubstring, "Error message mismatch") assert.Nil(t, loggedinPeer, "LoggedinPeer should be nil on error") - assert.Nil(t, networkMap, "NetworkMap should be nil on error") + assert.Nil(t, network, "Network should be nil on error") assert.Nil(t, postureChecks, "PostureChecks should be empty or nil on error") return } @@ -1730,7 +1758,7 @@ func Test_LoginPeer(t *testing.T) { } else { assert.Equal(t, currentPeer.ExtraDNSLabels, loggedinPeer.ExtraDNSLabels, "ExtraDNSLabels mismatch on loggedinPeer") } - assert.NotNil(t, networkMap, "networkMap should not be nil on success") + assert.NotNil(t, network, "network should not be nil on success") assert.Equal(t, existingAccountID, loggedinPeer.AccountID, "AccountID mismatch for logged peer") @@ -1828,7 +1856,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { t.Run("adding peer to unlinked group", func(t *testing.T) { done := make(chan struct{}) go func() { - peerShouldReceiveUpdate(t, updMsg) // + peerShouldNotReceiveUpdate(t, updMsg) close(done) }() @@ -1836,7 +1864,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { require.NoError(t, err) expectedPeerKey := key.PublicKey().String() - peer4, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ + peer4, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ Key: expectedPeerKey, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, }, false) @@ -1853,7 +1881,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { t.Run("deleting peer with unlinked group", func(t *testing.T) { done := make(chan struct{}) go func() { - peerShouldReceiveUpdate(t, updMsg) + peerShouldNotReceiveUpdate(t, updMsg) close(done) }() @@ -1881,7 +1909,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -1903,7 +1931,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -1959,7 +1987,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { require.NoError(t, err) expectedPeerKey := key.PublicKey().String() - peer4, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ + peer4, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser1", &nbpeer.Peer{ Key: expectedPeerKey, LoginExpirationEnabled: true, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, @@ -1968,7 +1996,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -1986,12 +2014,15 @@ func TestPeerAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) - // Adding peer to group linked with route should update account peers and send peer update + // drain any buffered updates from previous subtests + drainPeerUpdates(updMsg) + + // Adding peer to group linked with route should update peers in that group, not unrelated peers t.Run("adding peer to group linked with route", func(t *testing.T) { route := nbroute.Route{ ID: "testingRoute1", @@ -2015,7 +2046,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { done := make(chan struct{}) go func() { - peerShouldReceiveUpdate(t, updMsg) + peerShouldNotReceiveUpdate(t, updMsg) close(done) }() @@ -2023,7 +2054,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { require.NoError(t, err) expectedPeerKey := key.PublicKey().String() - peer5, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ + peer5, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ Key: expectedPeerKey, LoginExpirationEnabled: true, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, @@ -2033,15 +2064,15 @@ func TestPeerAccountPeersUpdate(t *testing.T) { select { case <-done: case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldReceiveUpdate") + t.Error("timeout waiting for peerShouldNotReceiveUpdate") } }) - // Deleting peer with linked group to route should update account peers and send peer update + // Deleting peer with linked group to route should update peers in that group, not unrelated peers t.Run("deleting peer with linked group to route", func(t *testing.T) { done := make(chan struct{}) go func() { - peerShouldReceiveUpdate(t, updMsg) + peerShouldNotReceiveUpdate(t, updMsg) close(done) }() @@ -2051,11 +2082,11 @@ func TestPeerAccountPeersUpdate(t *testing.T) { select { case <-done: case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldReceiveUpdate") + t.Error("timeout waiting for peerShouldNotReceiveUpdate") } }) - // Adding peer to group linked with name server group should update account peers and send peer update + // Adding peer to group linked with name server group should update peers in that group, not unrelated peers t.Run("adding peer to group linked with name server group", func(t *testing.T) { _, err = manager.CreateNameServerGroup( context.Background(), account.Id, "nsGroup", "nsGroup", []nbdns.NameServer{{ @@ -2070,7 +2101,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { done := make(chan struct{}) go func() { - peerShouldReceiveUpdate(t, updMsg) + peerShouldNotReceiveUpdate(t, updMsg) close(done) }() @@ -2078,7 +2109,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { require.NoError(t, err) expectedPeerKey := key.PublicKey().String() - peer6, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser3", &nbpeer.Peer{ + peer6, _, _, _, err = manager.AddPeer(context.Background(), "", "", "regularUser3", &nbpeer.Peer{ Key: expectedPeerKey, LoginExpirationEnabled: true, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, @@ -2088,15 +2119,15 @@ func TestPeerAccountPeersUpdate(t *testing.T) { select { case <-done: case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldReceiveUpdate") + t.Error("timeout waiting for peerShouldNotReceiveUpdate") } }) - // Deleting peer with linked group to name server group should update account peers and send peer update + // Deleting peer with linked group to name server group should update peers in that group, not unrelated peers t.Run("deleting peer with linked group to route", func(t *testing.T) { done := make(chan struct{}) go func() { - peerShouldReceiveUpdate(t, updMsg) + peerShouldNotReceiveUpdate(t, updMsg) close(done) }() @@ -2106,7 +2137,7 @@ func TestPeerAccountPeersUpdate(t *testing.T) { select { case <-done: case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldReceiveUpdate") + t.Error("timeout waiting for peerShouldNotReceiveUpdate") } }) } @@ -2121,18 +2152,22 @@ func Test_DeletePeer(t *testing.T) { // account with an admin and a regular user accountID := "test_account" adminUser := "account_creator" - account := newAccountWithId(context.Background(), accountID, adminUser, "", false) + account := newAccountWithId(context.Background(), accountID, adminUser, "", "", "", false) account.Peers = map[string]*nbpeer.Peer{ "peer1": { ID: "peer1", AccountID: accountID, - IP: net.IP{1, 1, 1, 1}, + Key: "key1", + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + IPv6: netip.MustParseAddr("fd00::1"), DNSLabel: "peer1.test", }, "peer2": { ID: "peer2", AccountID: accountID, - IP: net.IP{2, 2, 2, 2}, + Key: "key2", + IP: netip.AddrFrom4([4]byte{2, 2, 2, 2}), + IPv6: netip.MustParseAddr("fd00::2"), DNSLabel: "peer2.test", }, } @@ -2187,6 +2222,9 @@ func Test_IsUniqueConstraintError(t *testing.T) { ID: "test-peer-id", AccountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", DNSLabel: "test-peer-dns-label", + Status: &nbpeer.PeerStatus{ + LastSeen: time.Now(), + }, } for _, tt := range tests { @@ -2249,7 +2287,7 @@ func Test_AddPeer(t *testing.T) { <-start - _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", newPeer, false) + _, _, _, _, err := manager.AddPeer(context.Background(), "", setupKey.Key, "", newPeer, false) if err != nil { errs <- fmt.Errorf("AddPeer failed for peer %d: %w", i, err) return @@ -2304,12 +2342,12 @@ func TestAddPeer_UserPendingApprovalBlocked(t *testing.T) { } // Create account - account := newAccountWithId(context.Background(), "test-account", "owner", "", false) + account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false) err = manager.Store.SaveAccount(context.Background(), account) require.NoError(t, err) // Create user pending approval - pendingUser := types.NewRegularUser("pending-user") + pendingUser := types.NewRegularUser("pending-user", "", "") pendingUser.AccountID = account.Id pendingUser.Blocked = true pendingUser.PendingApproval = true @@ -2329,7 +2367,7 @@ func TestAddPeer_UserPendingApprovalBlocked(t *testing.T) { }, } - _, _, _, err = manager.AddPeer(context.Background(), "", "", pendingUser.Id, peer, false) + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", pendingUser.Id, peer, false) require.Error(t, err) assert.Contains(t, err.Error(), "user pending approval cannot add peers") } @@ -2341,12 +2379,12 @@ func TestAddPeer_ApprovedUserCanAddPeers(t *testing.T) { } // Create account - account := newAccountWithId(context.Background(), "test-account", "owner", "", false) + account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false) err = manager.Store.SaveAccount(context.Background(), account) require.NoError(t, err) // Create regular user (not pending approval) - regularUser := types.NewRegularUser("regular-user") + regularUser := types.NewRegularUser("regular-user", "", "") regularUser.AccountID = account.Id err = manager.Store.SaveUser(context.Background(), regularUser) require.NoError(t, err) @@ -2364,7 +2402,7 @@ func TestAddPeer_ApprovedUserCanAddPeers(t *testing.T) { }, } - _, _, _, err = manager.AddPeer(context.Background(), "", "", regularUser.Id, peer, false) + _, _, _, _, err = manager.AddPeer(context.Background(), "", "", regularUser.Id, peer, false) require.NoError(t, err, "Regular user should be able to add peers") } @@ -2375,12 +2413,12 @@ func TestLoginPeer_UserPendingApprovalBlocked(t *testing.T) { } // Create account - account := newAccountWithId(context.Background(), "test-account", "owner", "", false) + account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false) err = manager.Store.SaveAccount(context.Background(), account) require.NoError(t, err) // Create user pending approval - pendingUser := types.NewRegularUser("pending-user") + pendingUser := types.NewRegularUser("pending-user", "", "") pendingUser.AccountID = account.Id pendingUser.Blocked = true pendingUser.PendingApproval = true @@ -2407,7 +2445,7 @@ func TestLoginPeer_UserPendingApprovalBlocked(t *testing.T) { WtVersion: "0.28.0", }, } - existingPeer, _, _, err := manager.AddPeer(context.Background(), "", "", pendingUser.Id, newPeer, false) + existingPeer, _, _, _, err := manager.AddPeer(context.Background(), "", "", pendingUser.Id, newPeer, false) require.NoError(t, err) // Now set the user back to pending approval after peer was created @@ -2426,7 +2464,7 @@ func TestLoginPeer_UserPendingApprovalBlocked(t *testing.T) { }, } - _, _, _, err = manager.LoginPeer(context.Background(), login) + _, _, _, _, err = manager.LoginPeer(context.Background(), login) require.Error(t, err) e, ok := status.FromError(err) require.True(t, ok, "error is not a gRPC status error") @@ -2440,12 +2478,12 @@ func TestLoginPeer_ApprovedUserCanLogin(t *testing.T) { } // Create account - account := newAccountWithId(context.Background(), "test-account", "owner", "", false) + account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false) err = manager.Store.SaveAccount(context.Background(), account) require.NoError(t, err) // Create regular user (not pending approval) - regularUser := types.NewRegularUser("regular-user") + regularUser := types.NewRegularUser("regular-user", "", "") regularUser.AccountID = account.Id err = manager.Store.SaveUser(context.Background(), regularUser) require.NoError(t, err) @@ -2463,7 +2501,7 @@ func TestLoginPeer_ApprovedUserCanLogin(t *testing.T) { WtVersion: "0.28.0", }, } - existingPeer, _, _, err := manager.AddPeer(context.Background(), "", "", regularUser.Id, newPeer, false) + existingPeer, _, _, _, err := manager.AddPeer(context.Background(), "", "", regularUser.Id, newPeer, false) require.NoError(t, err) // Try to login with regular user @@ -2476,6 +2514,521 @@ func TestLoginPeer_ApprovedUserCanLogin(t *testing.T) { }, } - _, _, _, err = manager.LoginPeer(context.Background(), login) + _, _, _, _, err = manager.LoginPeer(context.Background(), login) require.NoError(t, err, "Regular user should be able to login peers") } + +func TestHandleUserAddedPeer(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false) + err = manager.Store.SaveAccount(context.Background(), account) + require.NoError(t, err) + + t.Run("regular user can add peer", func(t *testing.T) { + regularUser := types.NewRegularUser("regular-user-1", "", "") + regularUser.AccountID = account.Id + regularUser.AutoGroups = []string{"group1", "group2"} + err = manager.Store.SaveUser(context.Background(), regularUser) + require.NoError(t, err) + + opEvent := &activity.Event{} + config := &peerAddAuthConfig{} + + err = manager.handleUserAddedPeer(context.Background(), account.Id, regularUser.Id, false, opEvent, config) + require.NoError(t, err) + assert.Equal(t, account.Id, config.AccountID) + assert.Equal(t, regularUser.AutoGroups, config.GroupsToAdd) + assert.Equal(t, regularUser.Id, opEvent.InitiatorID) + assert.Equal(t, activity.PeerAddedByUser, opEvent.Activity) + }) + + t.Run("pending approval user cannot add peer", func(t *testing.T) { + pendingUser := types.NewRegularUser("pending-user", "", "") + pendingUser.AccountID = account.Id + pendingUser.PendingApproval = true + err = manager.Store.SaveUser(context.Background(), pendingUser) + require.NoError(t, err) + + opEvent := &activity.Event{} + config := &peerAddAuthConfig{} + + err = manager.handleUserAddedPeer(context.Background(), account.Id, pendingUser.Id, false, opEvent, config) + require.Error(t, err) + assert.Contains(t, err.Error(), "user pending approval cannot add peers") + }) + + t.Run("user not found", func(t *testing.T) { + opEvent := &activity.Event{} + config := &peerAddAuthConfig{} + + err = manager.handleUserAddedPeer(context.Background(), account.Id, "non-existent-user", false, opEvent, config) + require.Error(t, err) + assert.Contains(t, err.Error(), "user not found") + }) + + t.Run("temporary peer requires permissions", func(t *testing.T) { + regularUser := types.NewRegularUser("regular-user-2", "", "") + regularUser.AccountID = account.Id + err = manager.Store.SaveUser(context.Background(), regularUser) + require.NoError(t, err) + + opEvent := &activity.Event{} + config := &peerAddAuthConfig{} + + // Should fail because user doesn't have permissions for temporary peers + err = manager.handleUserAddedPeer(context.Background(), account.Id, regularUser.Id, true, opEvent, config) + require.Error(t, err) + }) +} + +func TestHandleSetupKeyAddedPeer(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false) + err = manager.Store.SaveAccount(context.Background(), account) + require.NoError(t, err) + + // Create admin user for setup key creation + adminUser := types.NewAdminUser("admin-user") + adminUser.AccountID = account.Id + err = manager.Store.SaveUser(context.Background(), adminUser) + require.NoError(t, err) + + t.Run("valid setup key", func(t *testing.T) { + setupKey, err := manager.CreateSetupKey(context.Background(), account.Id, "test-key", types.SetupKeyReusable, time.Hour, []string{}, 0, adminUser.Id, false, false) + require.NoError(t, err) + + upperKey := strings.ToUpper(setupKey.Key) + hashedKey := sha256.Sum256([]byte(upperKey)) + encodedHashedKey := b64.StdEncoding.EncodeToString(hashedKey[:]) + + opEvent := &activity.Event{} + config := &peerAddAuthConfig{} + peer := &nbpeer.Peer{ExtraDNSLabels: []string{}} + + err = manager.handleSetupKeyAddedPeer(context.Background(), encodedHashedKey, peer, opEvent, config) + require.NoError(t, err) + assert.Equal(t, setupKey.Id, config.SetupKeyID) + assert.Equal(t, setupKey.Name, config.SetupKeyName) + assert.Equal(t, setupKey.AutoGroups, config.GroupsToAdd) + assert.Equal(t, setupKey.Ephemeral, config.Ephemeral) + assert.Equal(t, setupKey.Id, opEvent.InitiatorID) + assert.Equal(t, activity.PeerAddedWithSetupKey, opEvent.Activity) + }) + + t.Run("invalid setup key", func(t *testing.T) { + invalidKey := "invalid-key" + hashedKey := sha256.Sum256([]byte(invalidKey)) + encodedHashedKey := b64.StdEncoding.EncodeToString(hashedKey[:]) + + opEvent := &activity.Event{} + config := &peerAddAuthConfig{} + peer := &nbpeer.Peer{} + + err = manager.handleSetupKeyAddedPeer(context.Background(), encodedHashedKey, peer, opEvent, config) + require.Error(t, err) + assert.Contains(t, err.Error(), "setup key is invalid") + }) + + t.Run("expired setup key", func(t *testing.T) { + setupKey, err := manager.CreateSetupKey(context.Background(), account.Id, "expired-key", types.SetupKeyReusable, time.Millisecond, []string{}, 0, adminUser.Id, false, false) + require.NoError(t, err) + + // Wait for key to expire + time.Sleep(10 * time.Millisecond) + + upperKey := strings.ToUpper(setupKey.Key) + hashedKey := sha256.Sum256([]byte(upperKey)) + encodedHashedKey := b64.StdEncoding.EncodeToString(hashedKey[:]) + + opEvent := &activity.Event{} + config := &peerAddAuthConfig{} + peer := &nbpeer.Peer{} + + err = manager.handleSetupKeyAddedPeer(context.Background(), encodedHashedKey, peer, opEvent, config) + require.Error(t, err) + assert.Contains(t, err.Error(), "setup key is invalid") + }) + + t.Run("extra DNS labels not allowed", func(t *testing.T) { + setupKey, err := manager.CreateSetupKey(context.Background(), account.Id, "no-dns-key", types.SetupKeyReusable, time.Hour, []string{}, 0, adminUser.Id, false, false) + require.NoError(t, err) + + upperKey := strings.ToUpper(setupKey.Key) + hashedKey := sha256.Sum256([]byte(upperKey)) + encodedHashedKey := b64.StdEncoding.EncodeToString(hashedKey[:]) + + opEvent := &activity.Event{} + config := &peerAddAuthConfig{} + peer := &nbpeer.Peer{ExtraDNSLabels: []string{"custom.label"}} + + err = manager.handleSetupKeyAddedPeer(context.Background(), encodedHashedKey, peer, opEvent, config) + require.Error(t, err) + assert.Contains(t, err.Error(), "doesn't allow extra DNS labels") + }) + + t.Run("extra DNS labels allowed", func(t *testing.T) { + setupKey, err := manager.CreateSetupKey(context.Background(), account.Id, "dns-key", types.SetupKeyReusable, time.Hour, []string{}, 0, adminUser.Id, false, true) + require.NoError(t, err) + + upperKey := strings.ToUpper(setupKey.Key) + hashedKey := sha256.Sum256([]byte(upperKey)) + encodedHashedKey := b64.StdEncoding.EncodeToString(hashedKey[:]) + + opEvent := &activity.Event{} + config := &peerAddAuthConfig{} + peer := &nbpeer.Peer{ExtraDNSLabels: []string{"custom.label"}} + + err = manager.handleSetupKeyAddedPeer(context.Background(), encodedHashedKey, peer, opEvent, config) + require.NoError(t, err) + assert.True(t, config.AllowExtraDNSLabels) + }) +} + +func TestProcessPeerAddAuth(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err) + + account := newAccountWithId(context.Background(), "test-account", "owner", "", "", "", false) + err = manager.Store.SaveAccount(context.Background(), account) + require.NoError(t, err) + + adminUser := types.NewAdminUser("admin") + adminUser.AccountID = account.Id + err = manager.Store.SaveUser(context.Background(), adminUser) + require.NoError(t, err) + + t.Run("user authentication flow", func(t *testing.T) { + regularUser := types.NewRegularUser("user-auth-test", "", "") + regularUser.AccountID = account.Id + regularUser.AutoGroups = []string{"group1"} + err = manager.Store.SaveUser(context.Background(), regularUser) + require.NoError(t, err) + + opEvent := &activity.Event{Timestamp: time.Now()} + peer := &nbpeer.Peer{Ephemeral: false} + + config, err := manager.processPeerAddAuth(context.Background(), account.Id, regularUser.Id, "", peer, false, true, false, opEvent) + require.NoError(t, err) + assert.Equal(t, account.Id, config.AccountID) + assert.False(t, config.Ephemeral) + assert.Equal(t, regularUser.AutoGroups, config.GroupsToAdd) + assert.Equal(t, account.Id, opEvent.AccountID) + }) + + t.Run("setup key authentication flow", func(t *testing.T) { + setupKey, err := manager.CreateSetupKey(context.Background(), account.Id, "auth-test-key", types.SetupKeyReusable, time.Hour, []string{}, 0, adminUser.Id, true, false) + require.NoError(t, err) + + upperKey := strings.ToUpper(setupKey.Key) + hashedKey := sha256.Sum256([]byte(upperKey)) + encodedHashedKey := b64.StdEncoding.EncodeToString(hashedKey[:]) + + opEvent := &activity.Event{Timestamp: time.Now()} + peer := &nbpeer.Peer{Ephemeral: false} + + config, err := manager.processPeerAddAuth(context.Background(), account.Id, "", encodedHashedKey, peer, false, false, true, opEvent) + require.NoError(t, err) + assert.Equal(t, account.Id, config.AccountID) + assert.True(t, config.Ephemeral) // setupKey.Ephemeral is true + assert.Equal(t, setupKey.AutoGroups, config.GroupsToAdd) + assert.Equal(t, account.Id, opEvent.AccountID) + }) + + t.Run("temporary flag overrides ephemeral", func(t *testing.T) { + regularUser := types.NewRegularUser("temp-user", "", "") + regularUser.AccountID = account.Id + err = manager.Store.SaveUser(context.Background(), regularUser) + require.NoError(t, err) + + opEvent := &activity.Event{Timestamp: time.Now()} + peer := &nbpeer.Peer{Ephemeral: false} + + config, err := manager.processPeerAddAuth(context.Background(), account.Id, regularUser.Id, "", peer, true, true, false, opEvent) + require.Error(t, err) // Will fail permission check but that's expected + _ = config // avoid unused warning + }) + + t.Run("proxy embedded peer (no auth)", func(t *testing.T) { + opEvent := &activity.Event{Timestamp: time.Now()} + peer := &nbpeer.Peer{ + Ephemeral: false, + ProxyMeta: nbpeer.ProxyMeta{Embedded: true}, + } + + config, err := manager.processPeerAddAuth(context.Background(), account.Id, "", "", peer, false, false, false, opEvent) + require.NoError(t, err) + assert.Equal(t, account.Id, config.AccountID) + assert.False(t, config.Ephemeral) + assert.Empty(t, config.GroupsToAdd) + }) +} + +func TestPeerWillHaveIPv6(t *testing.T) { + settings := &types.Settings{ + IPv6EnabledGroups: []string{"all-group-id", "group-a"}, + } + + assert.True(t, peerWillHaveIPv6(settings, nil, "all-group-id"), "peer in All group should get IPv6") + assert.True(t, peerWillHaveIPv6(settings, []string{"group-a"}, ""), "peer with matching auto-group should get IPv6") + assert.False(t, peerWillHaveIPv6(settings, []string{"group-b"}, "other-all"), "peer with no matching groups should not get IPv6") + assert.False(t, peerWillHaveIPv6(settings, nil, ""), "embedded peer with no groups should not get IPv6") + + emptySettings := &types.Settings{IPv6EnabledGroups: []string{}} + assert.False(t, peerWillHaveIPv6(emptySettings, []string{"group-a"}, "all-group-id"), "no IPv6 groups means no IPv6") +} + +// TestSyncPeer_IPv6CapabilityChangePropagates ensures that when a peer reports +// a new IPv6 overlay capability via SyncPeer (e.g. after a client upgrade or +// flipping --disable-ipv6) without bumping its WtVersion, other account peers +// receive a fresh network map so their AAAA records for it become unstale. +func TestSyncPeer_IPv6CapabilityChangePropagates(t *testing.T) { + manager, updateManager, _, peer1, peer2, _ := setupNetworkMapTest(t) + + updMsg := updateManager.CreateChannel(context.Background(), peer1.ID) + t.Cleanup(func() { + updateManager.CloseChannel(context.Background(), peer1.ID) + }) + + // Drain any initial updates from setup. + drain := func() { + for { + select { + case <-updMsg: + case <-time.After(200 * time.Millisecond): + return + } + } + } + drain() + + t.Run("no propagation when capabilities are unchanged", func(t *testing.T) { + _, _, _, _, err := manager.SyncPeer(context.Background(), types.PeerSync{ + WireGuardPubKey: peer2.Key, + Meta: peer2.Meta, + }, peer2.AccountID) + require.NoError(t, err) + peerShouldNotReceiveUpdate(t, updMsg) + }) + + t.Run("propagation when IPv6 capability is added", func(t *testing.T) { + newMeta := peer2.Meta + newMeta.Capabilities = append([]int32{}, peer2.Meta.Capabilities...) + newMeta.Capabilities = append(newMeta.Capabilities, nbpeer.PeerCapabilityIPv6Overlay) + + _, _, _, _, err := manager.SyncPeer(context.Background(), types.PeerSync{ + WireGuardPubKey: peer2.Key, + Meta: newMeta, + }, peer2.AccountID) + require.NoError(t, err) + peerShouldReceiveUpdate(t, updMsg) + }) +} + +func TestUpdatePeer_DnsLabelCollisionWithFQDN(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err, "unable to create account manager") + + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err, "unable to create an account") + + // Add first peer with hostname that produces DNS label "netbird1" + key1, err := wgtypes.GenerateKey() + require.NoError(t, err) + peer1, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + Key: key1.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: "netbird1.netbird.cloud"}, + }, false) + require.NoError(t, err, "unable to add first peer") + assert.Equal(t, "netbird1", peer1.DNSLabel) + + // Add second peer with a different hostname + key2, err := wgtypes.GenerateKey() + require.NoError(t, err) + peer2, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + Key: key2.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: "ip-10-29-5-130"}, + }, false) + require.NoError(t, err) + + update := peer2.Copy() + update.Name = "netbird1.demo.netbird.cloud" + updated, err := manager.UpdatePeer(context.Background(), accountID, userID, update) + require.NoError(t, err, "renaming peer should not fail with duplicate DNS label error") + assert.Equal(t, "netbird1.demo.netbird.cloud", updated.Name) + assert.NotEqual(t, "netbird1", updated.DNSLabel, "DNS label should not collide with existing peer") + assert.Contains(t, updated.DNSLabel, "netbird1-", "DNS label should be IP-based fallback") +} + +func TestUpdatePeer_DnsLabelUniqueName(t *testing.T) { + manager, _, err := createManager(t) + require.NoError(t, err, "unable to create account manager") + + accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID}) + require.NoError(t, err, "unable to create an account") + + key1, err := wgtypes.GenerateKey() + require.NoError(t, err) + peer1, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + Key: key1.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: "web-server"}, + }, false) + require.NoError(t, err) + assert.Equal(t, "web-server", peer1.DNSLabel) + + // Add second peer and rename it to a unique FQDN whose first label doesn't collide + key2, err := wgtypes.GenerateKey() + require.NoError(t, err) + peer2, _, _, _, err := manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{ + Key: key2.PublicKey().String(), + Meta: nbpeer.PeerSystemMeta{Hostname: "old-name"}, + }, false) + require.NoError(t, err) + + update := peer2.Copy() + update.Name = "api-server.example.com" + updated, err := manager.UpdatePeer(context.Background(), accountID, userID, update) + require.NoError(t, err, "renaming to unique FQDN should succeed") + assert.Equal(t, "api-server", updated.DNSLabel, "DNS label should be first label of FQDN") +} + +// fakeGeo is a configurable geolocation.Geolocation implementation for tests. It +// returns a record built from the configured city geoname id, or an error when set. +type fakeGeo struct { + geoNameID uint + isoCode string + cityName string + err error +} + +func (g *fakeGeo) Lookup(net.IP) (*geolocation.Record, error) { + if g.err != nil { + return nil, g.err + } + record := &geolocation.Record{} + record.City.GeonameID = g.geoNameID + record.City.Names.En = g.cityName + record.Country.ISOCode = g.isoCode + return record, nil +} + +func (g *fakeGeo) GetAllCountries() ([]geolocation.Country, error) { return nil, nil } + +func (g *fakeGeo) GetCitiesByCountry(string) ([]geolocation.City, error) { return nil, nil } + +func (g *fakeGeo) Stop() error { return nil } + +func TestResolvePeerLocation(t *testing.T) { + realIP := net.ParseIP("203.0.113.10") + + tests := []struct { + name string + geo geolocation.Geolocation + peer *nbpeer.Peer + realIP net.IP + want *nbpeer.Location + wantNil bool + }{ + { + name: "no geo configured returns nil", + geo: nil, + peer: &nbpeer.Peer{ID: "p1"}, + realIP: realIP, + wantNil: true, + }, + { + name: "nil real IP returns nil", + geo: &fakeGeo{geoNameID: 100}, + peer: &nbpeer.Peer{ID: "p1"}, + realIP: nil, + wantNil: true, + }, + { + name: "lookup error returns nil", + geo: &fakeGeo{err: fmt.Errorf("lookup boom")}, + peer: &nbpeer.Peer{ID: "p1"}, + realIP: realIP, + wantNil: true, + }, + { + name: "same IP and same geoname returns nil", + geo: &fakeGeo{geoNameID: 100, isoCode: "US", cityName: "City A"}, + peer: &nbpeer.Peer{ + ID: "p1", + Location: nbpeer.Location{ + ConnectionIP: realIP, + GeoNameID: 100, + }, + }, + realIP: realIP, + wantNil: true, + }, + { + name: "same IP but changed geoname returns location", + geo: &fakeGeo{geoNameID: 200, isoCode: "US", cityName: "City B"}, + peer: &nbpeer.Peer{ + ID: "p1", + Location: nbpeer.Location{ + ConnectionIP: realIP, + GeoNameID: 100, + }, + }, + realIP: realIP, + want: &nbpeer.Location{ + ConnectionIP: realIP, + CountryCode: "US", + CityName: "City B", + GeoNameID: 200, + }, + }, + { + name: "different IP returns location", + geo: &fakeGeo{geoNameID: 100, isoCode: "US", cityName: "City A"}, + peer: &nbpeer.Peer{ + ID: "p1", + Location: nbpeer.Location{ + ConnectionIP: net.ParseIP("198.51.100.7"), + GeoNameID: 100, + }, + }, + realIP: realIP, + want: &nbpeer.Location{ + ConnectionIP: realIP, + CountryCode: "US", + CityName: "City A", + GeoNameID: 100, + }, + }, + { + name: "no prior location returns location", + geo: &fakeGeo{geoNameID: 100, isoCode: "US", cityName: "City A"}, + peer: &nbpeer.Peer{ID: "p1"}, + realIP: realIP, + want: &nbpeer.Location{ + ConnectionIP: realIP, + CountryCode: "US", + CityName: "City A", + GeoNameID: 100, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + am := &DefaultAccountManager{geo: tt.geo} + got := am.resolvePeerLocation(context.Background(), tt.peer, tt.realIP) + if tt.wantNil { + assert.Nil(t, got, "resolved location should be nil") + return + } + require.NotNil(t, got, "resolved location should not be nil") + assert.True(t, tt.want.ConnectionIP.Equal(got.ConnectionIP), "connection IP should match") + assert.Equal(t, tt.want.CountryCode, got.CountryCode, "country code should match") + assert.Equal(t, tt.want.CityName, got.CityName, "city name should match") + assert.Equal(t, tt.want.GeoNameID, got.GeoNameID, "geoname id should match") + }) + } +} diff --git a/management/server/peers/manager.go b/management/server/peers/manager.go deleted file mode 100644 index cb135f4ac..000000000 --- a/management/server/peers/manager.go +++ /dev/null @@ -1,68 +0,0 @@ -package peers - -//go:generate go run github.com/golang/mock/mockgen -package peers -destination=manager_mock.go -source=./manager.go -build_flags=-mod=mod - -import ( - "context" - "fmt" - - "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/management/server/permissions" - "github.com/netbirdio/netbird/management/server/permissions/modules" - "github.com/netbirdio/netbird/management/server/permissions/operations" - "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/shared/management/status" -) - -type Manager interface { - GetPeer(ctx context.Context, accountID, userID, peerID string) (*peer.Peer, error) - GetPeerAccountID(ctx context.Context, peerID string) (string, error) - GetAllPeers(ctx context.Context, accountID, userID string) ([]*peer.Peer, error) - GetPeersByGroupIDs(ctx context.Context, accountID string, groupsIDs []string) ([]*peer.Peer, error) -} - -type managerImpl struct { - store store.Store - permissionsManager permissions.Manager -} - -func NewManager(store store.Store, permissionsManager permissions.Manager) Manager { - return &managerImpl{ - store: store, - permissionsManager: permissionsManager, - } -} - -func (m *managerImpl) GetPeer(ctx context.Context, accountID, userID, peerID string) (*peer.Peer, error) { - allowed, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read) - if err != nil { - return nil, fmt.Errorf("failed to validate user permissions: %w", err) - } - - if !allowed { - return nil, status.NewPermissionDeniedError() - } - - return m.store.GetPeerByID(ctx, store.LockingStrengthNone, accountID, peerID) -} - -func (m *managerImpl) GetAllPeers(ctx context.Context, accountID, userID string) ([]*peer.Peer, error) { - allowed, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Peers, operations.Read) - if err != nil { - return nil, fmt.Errorf("failed to validate user permissions: %w", err) - } - - if !allowed { - return m.store.GetUserPeers(ctx, store.LockingStrengthNone, accountID, userID) - } - - return m.store.GetAccountPeers(ctx, store.LockingStrengthNone, accountID, "", "") -} - -func (m *managerImpl) GetPeerAccountID(ctx context.Context, peerID string) (string, error) { - return m.store.GetAccountIDByPeerID(ctx, store.LockingStrengthNone, peerID) -} - -func (m *managerImpl) GetPeersByGroupIDs(ctx context.Context, accountID string, groupsIDs []string) ([]*peer.Peer, error) { - return m.store.GetPeersByGroupIDs(ctx, accountID, groupsIDs) -} diff --git a/management/server/peers/manager_mock.go b/management/server/peers/manager_mock.go deleted file mode 100644 index 994f8346b..000000000 --- a/management/server/peers/manager_mock.go +++ /dev/null @@ -1,96 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: ./manager.go - -// Package peers is a generated GoMock package. -package peers - -import ( - context "context" - reflect "reflect" - - gomock "github.com/golang/mock/gomock" - peer "github.com/netbirdio/netbird/management/server/peer" -) - -// MockManager is a mock of Manager interface. -type MockManager struct { - ctrl *gomock.Controller - recorder *MockManagerMockRecorder -} - -// MockManagerMockRecorder is the mock recorder for MockManager. -type MockManagerMockRecorder struct { - mock *MockManager -} - -// NewMockManager creates a new mock instance. -func NewMockManager(ctrl *gomock.Controller) *MockManager { - mock := &MockManager{ctrl: ctrl} - mock.recorder = &MockManagerMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockManager) EXPECT() *MockManagerMockRecorder { - return m.recorder -} - -// GetAllPeers mocks base method. -func (m *MockManager) GetAllPeers(ctx context.Context, accountID, userID string) ([]*peer.Peer, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAllPeers", ctx, accountID, userID) - ret0, _ := ret[0].([]*peer.Peer) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetAllPeers indicates an expected call of GetAllPeers. -func (mr *MockManagerMockRecorder) GetAllPeers(ctx, accountID, userID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllPeers", reflect.TypeOf((*MockManager)(nil).GetAllPeers), ctx, accountID, userID) -} - -// GetPeer mocks base method. -func (m *MockManager) GetPeer(ctx context.Context, accountID, userID, peerID string) (*peer.Peer, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetPeer", ctx, accountID, userID, peerID) - ret0, _ := ret[0].(*peer.Peer) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetPeer indicates an expected call of GetPeer. -func (mr *MockManagerMockRecorder) GetPeer(ctx, accountID, userID, peerID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeer", reflect.TypeOf((*MockManager)(nil).GetPeer), ctx, accountID, userID, peerID) -} - -// GetPeerAccountID mocks base method. -func (m *MockManager) GetPeerAccountID(ctx context.Context, peerID string) (string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetPeerAccountID", ctx, peerID) - ret0, _ := ret[0].(string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetPeerAccountID indicates an expected call of GetPeerAccountID. -func (mr *MockManagerMockRecorder) GetPeerAccountID(ctx, peerID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerAccountID", reflect.TypeOf((*MockManager)(nil).GetPeerAccountID), ctx, peerID) -} - -// GetPeersByGroupIDs mocks base method. -func (m *MockManager) GetPeersByGroupIDs(ctx context.Context, accountID string, groupsIDs []string) ([]*peer.Peer, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetPeersByGroupIDs", ctx, accountID, groupsIDs) - ret0, _ := ret[0].([]*peer.Peer) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetPeersByGroupIDs indicates an expected call of GetPeersByGroupIDs. -func (mr *MockManagerMockRecorder) GetPeersByGroupIDs(ctx, accountID, groupsIDs interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersByGroupIDs", reflect.TypeOf((*MockManager)(nil).GetPeersByGroupIDs), ctx, accountID, groupsIDs) -} diff --git a/management/server/permissions/manager.go b/management/server/permissions/manager.go index e6bdd2025..995f234d8 100644 --- a/management/server/permissions/manager.go +++ b/management/server/permissions/manager.go @@ -9,6 +9,7 @@ import ( "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" + nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/permissions/roles" @@ -18,9 +19,9 @@ import ( ) type Manager interface { - ValidateUserPermissions(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, error) + ValidateUserPermissions(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, context.Context, error) ValidateRoleModuleAccess(ctx context.Context, accountID string, role roles.RolePermissions, module modules.Module, operation operations.Operation) bool - ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) error + ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) (context.Context, error) GetPermissionsByRole(ctx context.Context, role types.UserRole) (roles.Permissions, error) SetAccountManager(accountManager account.Manager) @@ -42,42 +43,43 @@ func (m *managerImpl) ValidateUserPermissions( userID string, module modules.Module, operation operations.Operation, -) (bool, error) { +) (bool, context.Context, error) { if userID == activity.SystemInitiator { - return true, nil + return true, ctx, nil } user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) if err != nil { - return false, err + return false, ctx, err } if user == nil { - return false, status.NewUserNotFoundError(userID) + return false, ctx, status.NewUserNotFoundError(userID) } if user.IsBlocked() && !user.PendingApproval { - return false, status.NewUserBlockedError() + return false, ctx, status.NewUserBlockedError() } if user.IsBlocked() && user.PendingApproval { - return false, status.NewUserPendingApprovalError() + return false, ctx, status.NewUserPendingApprovalError() } - if err := m.ValidateAccountAccess(ctx, accountID, user, false); err != nil { - return false, err + ctxEnriched, err := m.ValidateAccountAccess(ctx, accountID, user, false) + if err != nil { + return false, ctx, err } if operation == operations.Read && user.IsServiceUser { - return true, nil // this should be replaced by proper granular access role + return true, ctxEnriched, nil // this should be replaced by proper granular access role } role, ok := roles.RolesMap[user.Role] if !ok { - return false, status.NewUserRoleNotFoundError(string(user.Role)) + return false, ctxEnriched, status.NewUserRoleNotFoundError(string(user.Role)) } - return m.ValidateRoleModuleAccess(ctx, accountID, role, module, operation), nil + return m.ValidateRoleModuleAccess(ctx, accountID, role, module, operation), ctxEnriched, nil } func (m *managerImpl) ValidateRoleModuleAccess( @@ -98,11 +100,14 @@ func (m *managerImpl) ValidateRoleModuleAccess( return role.AutoAllowNew[operation] } -func (m *managerImpl) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) error { +func (m *managerImpl) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) (context.Context, error) { if user.AccountID != accountID { - return status.NewUserNotPartOfAccountError() + return ctx, status.NewUserNotPartOfAccountError() } - return nil + + ctx = nbcontext.WithRole(ctx, string(user.Role)) + + return ctx, nil } func (m *managerImpl) GetPermissionsByRole(ctx context.Context, role types.UserRole) (roles.Permissions, error) { diff --git a/management/server/permissions/manager_mock.go b/management/server/permissions/manager_mock.go index ec9f263f9..934e33398 100644 --- a/management/server/permissions/manager_mock.go +++ b/management/server/permissions/manager_mock.go @@ -67,11 +67,12 @@ func (mr *MockManagerMockRecorder) SetAccountManager(accountManager interface{}) } // ValidateAccountAccess mocks base method. -func (m *MockManager) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) error { +func (m *MockManager) ValidateAccountAccess(ctx context.Context, accountID string, user *types.User, allowOwnerAndAdmin bool) (context.Context, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ValidateAccountAccess", ctx, accountID, user, allowOwnerAndAdmin) - ret0, _ := ret[0].(error) - return ret0 + ret0, _ := ret[0].(context.Context) + ret1, _ := ret[1].(error) + return ret0, ret1 } // ValidateAccountAccess indicates an expected call of ValidateAccountAccess. @@ -95,12 +96,13 @@ func (mr *MockManagerMockRecorder) ValidateRoleModuleAccess(ctx, accountID, role } // ValidateUserPermissions mocks base method. -func (m *MockManager) ValidateUserPermissions(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, error) { +func (m *MockManager) ValidateUserPermissions(ctx context.Context, accountID, userID string, module modules.Module, operation operations.Operation) (bool, context.Context, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ValidateUserPermissions", ctx, accountID, userID, module, operation) ret0, _ := ret[0].(bool) - ret1, _ := ret[1].(error) - return ret0, ret1 + ret1, _ := ret[1].(context.Context) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 } // ValidateUserPermissions indicates an expected call of ValidateUserPermissions. diff --git a/management/server/permissions/modules/module.go b/management/server/permissions/modules/module.go index 3d021a235..a3a9c554d 100644 --- a/management/server/permissions/modules/module.go +++ b/management/server/permissions/modules/module.go @@ -3,33 +3,41 @@ package modules type Module string const ( - Networks Module = "networks" - Peers Module = "peers" - Groups Module = "groups" - Settings Module = "settings" - Accounts Module = "accounts" - Dns Module = "dns" - Nameservers Module = "nameservers" - Events Module = "events" - Policies Module = "policies" - Routes Module = "routes" - Users Module = "users" - SetupKeys Module = "setup_keys" - Pats Module = "pats" + Networks Module = "networks" + Peers Module = "peers" + RemoteJobs Module = "remote_jobs" + Groups Module = "groups" + Settings Module = "settings" + Accounts Module = "accounts" + Dns Module = "dns" + Nameservers Module = "nameservers" + Events Module = "events" + Policies Module = "policies" + Routes Module = "routes" + Users Module = "users" + SetupKeys Module = "setup_keys" + Pats Module = "pats" + IdentityProviders Module = "identity_providers" + Services Module = "services" + AgentNetwork Module = "agent_network" ) var All = map[Module]struct{}{ - Networks: {}, - Peers: {}, - Groups: {}, - Settings: {}, - Accounts: {}, - Dns: {}, - Nameservers: {}, - Events: {}, - Policies: {}, - Routes: {}, - Users: {}, - SetupKeys: {}, - Pats: {}, + Networks: {}, + Peers: {}, + RemoteJobs: {}, + Groups: {}, + Settings: {}, + Accounts: {}, + Dns: {}, + Nameservers: {}, + Events: {}, + Policies: {}, + Routes: {}, + Users: {}, + SetupKeys: {}, + Pats: {}, + IdentityProviders: {}, + Services: {}, + AgentNetwork: {}, } diff --git a/management/server/permissions/roles/network_admin.go b/management/server/permissions/roles/network_admin.go index e95d58381..8f69d46ad 100644 --- a/management/server/permissions/roles/network_admin.go +++ b/management/server/permissions/roles/network_admin.go @@ -93,5 +93,11 @@ var NetworkAdmin = RolePermissions{ operations.Update: false, operations.Delete: false, }, + modules.IdentityProviders: { + operations.Read: true, + operations.Create: false, + operations.Update: false, + operations.Delete: false, + }, }, } diff --git a/management/server/policy.go b/management/server/policy.go index 3e84c3d10..30b101aae 100644 --- a/management/server/policy.go +++ b/management/server/policy.go @@ -5,6 +5,7 @@ import ( _ "embed" "github.com/rs/xid" + log "github.com/sirupsen/logrus" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" @@ -12,13 +13,14 @@ import ( "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/shared/management/status" ) // GetPolicy from the store func (am *DefaultAccountManager) GetPolicy(ctx context.Context, accountID, policyID, userID string) (*types.Policy, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -35,7 +37,7 @@ func (am *DefaultAccountManager) SavePolicy(ctx context.Context, accountID, user if !create { operation = operations.Update } - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operation) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operation) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -44,26 +46,47 @@ func (am *DefaultAccountManager) SavePolicy(ctx context.Context, accountID, user } var isUpdate = policy.ID != "" - var updateAccountPeers bool + var existingPolicy *types.Policy var action = activity.PolicyAdded + var unchanged bool + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - if err = validatePolicy(ctx, transaction, accountID, policy); err != nil { - return err - } - - updateAccountPeers, err = arePolicyChangesAffectPeers(ctx, transaction, accountID, policy, isUpdate) + existingPolicy, err = validatePolicy(ctx, transaction, accountID, policy) if err != nil { return err } - saveFunc := transaction.CreatePolicy if isUpdate { + if policy.Equal(existingPolicy) { + log.WithContext(ctx).Tracef("policy update skipped because equal to stored one - policy id %s", policy.ID) + unchanged = true + return nil + } + action = activity.PolicyUpdated - saveFunc = transaction.SavePolicy + + policy.PublicID = existingPolicy.PublicID + + if err = transaction.SavePolicy(ctx, policy); err != nil { + return err + } + } else { + policy.PublicID = xid.New().String() + if err = transaction.CreatePolicy(ctx, policy); err != nil { + return err + } } - if err = saveFunc(ctx, policy); err != nil { + // On update carry both the old and new policy so peers losing access via a + // removed rule still refresh; on create there is no prior policy. + if isUpdate { + change = affectedpeers.Change{Policies: []*types.Policy{existingPolicy, policy}} + } else { + change = affectedpeers.Change{Policies: []*types.Policy{policy}} + } + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } @@ -73,18 +96,20 @@ func (am *DefaultAccountManager) SavePolicy(ctx context.Context, accountID, user return nil, err } + if unchanged { + return policy, nil + } + am.StoreEvent(ctx, userID, policy.ID, accountID, action, policy.EventMeta()) - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return policy, nil } // DeletePolicy from the store func (am *DefaultAccountManager) DeletePolicy(ctx context.Context, accountID, policyID, userID string) error { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Delete) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Delete) if err != nil { return status.NewPermissionValidationError(err) } @@ -93,7 +118,8 @@ func (am *DefaultAccountManager) DeletePolicy(ctx context.Context, accountID, po } var policy *types.Policy - var updateAccountPeers bool + var snap *affectedpeers.Snapshot + change := affectedpeers.Change{} err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { policy, err = transaction.GetPolicyByID(ctx, store.LockingStrengthUpdate, accountID, policyID) @@ -101,8 +127,9 @@ func (am *DefaultAccountManager) DeletePolicy(ctx context.Context, accountID, po return err } - updateAccountPeers, err = arePolicyChangesAffectPeers(ctx, transaction, accountID, policy, false) - if err != nil { + // Load before delete: pre-state still references the policy. + change = affectedpeers.Change{Policies: []*types.Policy{policy}} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } @@ -118,16 +145,14 @@ func (am *DefaultAccountManager) DeletePolicy(ctx context.Context, accountID, po am.StoreEvent(ctx, userID, policyID, accountID, activity.PolicyRemoved, policy.EventMeta()) - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } // ListPolicies from the store. func (am *DefaultAccountManager) ListPolicies(ctx context.Context, accountID, userID string) ([]*types.Policy, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -138,49 +163,15 @@ func (am *DefaultAccountManager) ListPolicies(ctx context.Context, accountID, us return am.Store.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) } -// arePolicyChangesAffectPeers checks if changes to a policy will affect any associated peers. -func arePolicyChangesAffectPeers(ctx context.Context, transaction store.Store, accountID string, policy *types.Policy, isUpdate bool) (bool, error) { - if isUpdate { - existingPolicy, err := transaction.GetPolicyByID(ctx, store.LockingStrengthNone, accountID, policy.ID) - if err != nil { - return false, err - } - - if !policy.Enabled && !existingPolicy.Enabled { - return false, nil - } - - for _, rule := range existingPolicy.Rules { - if rule.SourceResource.Type != "" || rule.DestinationResource.Type != "" { - return true, nil - } - } - - hasPeers, err := anyGroupHasPeersOrResources(ctx, transaction, policy.AccountID, existingPolicy.RuleGroups()) - if err != nil { - return false, err - } - - if hasPeers { - return true, nil - } - } - - for _, rule := range policy.Rules { - if rule.SourceResource.Type != "" || rule.DestinationResource.Type != "" { - return true, nil - } - } - - return anyGroupHasPeersOrResources(ctx, transaction, policy.AccountID, policy.RuleGroups()) -} - -// validatePolicy validates the policy and its rules. -func validatePolicy(ctx context.Context, transaction store.Store, accountID string, policy *types.Policy) error { +// validatePolicy validates the policy and its rules. For updates it returns +// the existing policy loaded from the store so callers can avoid a second read. +func validatePolicy(ctx context.Context, transaction store.Store, accountID string, policy *types.Policy) (*types.Policy, error) { + var existingPolicy *types.Policy if policy.ID != "" { - existingPolicy, err := transaction.GetPolicyByID(ctx, store.LockingStrengthNone, accountID, policy.ID) + var err error + existingPolicy, err = transaction.GetPolicyByID(ctx, store.LockingStrengthNone, accountID, policy.ID) if err != nil { - return err + return nil, err } // TODO: Refactor to support multiple rules per policy @@ -191,7 +182,7 @@ func validatePolicy(ctx context.Context, transaction store.Store, accountID stri for _, rule := range policy.Rules { if rule.ID != "" && !existingRuleIDs[rule.ID] { - return status.Errorf(status.InvalidArgument, "invalid rule ID: %s", rule.ID) + return nil, status.Errorf(status.InvalidArgument, "invalid rule ID: %s", rule.ID) } } } else { @@ -201,12 +192,12 @@ func validatePolicy(ctx context.Context, transaction store.Store, accountID stri groups, err := transaction.GetGroupsByIDs(ctx, store.LockingStrengthNone, accountID, policy.RuleGroups()) if err != nil { - return err + return nil, err } postureChecks, err := transaction.GetPostureChecksByIDs(ctx, store.LockingStrengthNone, accountID, policy.SourcePostureChecks) if err != nil { - return err + return nil, err } for i, rule := range policy.Rules { @@ -225,7 +216,7 @@ func validatePolicy(ctx context.Context, transaction store.Store, accountID stri policy.SourcePostureChecks = getValidPostureCheckIDs(postureChecks, policy.SourcePostureChecks) } - return nil + return existingPolicy, nil } // getValidPostureCheckIDs filters and returns only the valid posture check IDs from the provided list. diff --git a/management/server/policy_test.go b/management/server/policy_test.go index 90fe8f036..6fb573b9e 100644 --- a/management/server/policy_test.go +++ b/management/server/policy_test.go @@ -3,7 +3,7 @@ package server import ( "context" "fmt" - "net" + "net/netip" "testing" "time" @@ -20,53 +20,53 @@ func TestAccount_getPeersByPolicy(t *testing.T) { Peers: map[string]*nbpeer.Peer{ "peerA": { ID: "peerA", - IP: net.ParseIP("100.65.14.88"), + IP: netip.MustParseAddr("100.65.14.88"), Status: &nbpeer.PeerStatus{}, }, "peerB": { ID: "peerB", - IP: net.ParseIP("100.65.80.39"), + IP: netip.MustParseAddr("100.65.80.39"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{WtVersion: "0.48.0"}, }, "peerC": { ID: "peerC", - IP: net.ParseIP("100.65.254.139"), + IP: netip.MustParseAddr("100.65.254.139"), Status: &nbpeer.PeerStatus{}, }, "peerD": { ID: "peerD", - IP: net.ParseIP("100.65.62.5"), + IP: netip.MustParseAddr("100.65.62.5"), Status: &nbpeer.PeerStatus{}, }, "peerE": { ID: "peerE", - IP: net.ParseIP("100.65.32.206"), + IP: netip.MustParseAddr("100.65.32.206"), Status: &nbpeer.PeerStatus{}, }, "peerF": { ID: "peerF", - IP: net.ParseIP("100.65.250.202"), + IP: netip.MustParseAddr("100.65.250.202"), Status: &nbpeer.PeerStatus{}, }, "peerG": { ID: "peerG", - IP: net.ParseIP("100.65.13.186"), + IP: netip.MustParseAddr("100.65.13.186"), Status: &nbpeer.PeerStatus{}, }, "peerH": { ID: "peerH", - IP: net.ParseIP("100.65.29.55"), + IP: netip.MustParseAddr("100.65.29.55"), Status: &nbpeer.PeerStatus{}, }, "peerI": { ID: "peerI", - IP: net.ParseIP("100.65.31.2"), + IP: netip.MustParseAddr("100.65.31.2"), Status: &nbpeer.PeerStatus{}, }, "peerK": { ID: "peerK", - IP: net.ParseIP("100.32.80.1"), + IP: netip.MustParseAddr("100.32.80.1"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{WtVersion: "0.30.0"}, }, @@ -246,14 +246,14 @@ func TestAccount_getPeersByPolicy(t *testing.T) { t.Run("check that all peers get map", func(t *testing.T) { for _, p := range account.Peers { - peers, firewallRules := account.GetPeerConnectionResources(context.Background(), p, validatedPeers) + peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), p, validatedPeers, account.GetActiveGroupUsers()) assert.GreaterOrEqual(t, len(peers), 1, "minimum number peers should present") assert.GreaterOrEqual(t, len(firewallRules), 1, "minimum number of firewall rules should present") } }) t.Run("check first peer map details", func(t *testing.T) { - peers, firewallRules := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], validatedPeers) + peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], validatedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 8) assert.Contains(t, peers, account.Peers["peerA"]) assert.Contains(t, peers, account.Peers["peerC"]) @@ -509,7 +509,7 @@ func TestAccount_getPeersByPolicy(t *testing.T) { }) t.Run("check port ranges support for older peers", func(t *testing.T) { - peers, firewallRules := account.GetPeerConnectionResources(context.Background(), account.Peers["peerK"], validatedPeers) + peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerK"], validatedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 1) assert.Contains(t, peers, account.Peers["peerI"]) @@ -540,17 +540,17 @@ func TestAccount_getPeersByPolicyDirect(t *testing.T) { Peers: map[string]*nbpeer.Peer{ "peerA": { ID: "peerA", - IP: net.ParseIP("100.65.14.88"), + IP: netip.MustParseAddr("100.65.14.88"), Status: &nbpeer.PeerStatus{}, }, "peerB": { ID: "peerB", - IP: net.ParseIP("100.65.80.39"), + IP: netip.MustParseAddr("100.65.80.39"), Status: &nbpeer.PeerStatus{}, }, "peerC": { ID: "peerC", - IP: net.ParseIP("100.65.254.139"), + IP: netip.MustParseAddr("100.65.254.139"), Status: &nbpeer.PeerStatus{}, }, }, @@ -635,7 +635,7 @@ func TestAccount_getPeersByPolicyDirect(t *testing.T) { } t.Run("check first peer map", func(t *testing.T) { - peers, firewallRules := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers) + peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) assert.Contains(t, peers, account.Peers["peerC"]) expectedFirewallRules := []*types.FirewallRule{ @@ -665,7 +665,7 @@ func TestAccount_getPeersByPolicyDirect(t *testing.T) { }) t.Run("check second peer map", func(t *testing.T) { - peers, firewallRules := account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers) + peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) assert.Contains(t, peers, account.Peers["peerB"]) expectedFirewallRules := []*types.FirewallRule{ @@ -697,7 +697,7 @@ func TestAccount_getPeersByPolicyDirect(t *testing.T) { account.Policies[1].Rules[0].Bidirectional = false t.Run("check first peer map directional only", func(t *testing.T) { - peers, firewallRules := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers) + peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) assert.Contains(t, peers, account.Peers["peerC"]) expectedFirewallRules := []*types.FirewallRule{ @@ -719,7 +719,7 @@ func TestAccount_getPeersByPolicyDirect(t *testing.T) { }) t.Run("check second peer map directional only", func(t *testing.T) { - peers, firewallRules := account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers) + peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) assert.Contains(t, peers, account.Peers["peerB"]) expectedFirewallRules := []*types.FirewallRule{ @@ -746,7 +746,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { Peers: map[string]*nbpeer.Peer{ "peerA": { ID: "peerA", - IP: net.ParseIP("100.65.14.88"), + IP: netip.MustParseAddr("100.65.14.88"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ GoOS: "linux", @@ -756,7 +756,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { }, "peerB": { ID: "peerB", - IP: net.ParseIP("100.65.80.39"), + IP: netip.MustParseAddr("100.65.80.39"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ GoOS: "linux", @@ -766,7 +766,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { }, "peerC": { ID: "peerC", - IP: net.ParseIP("100.65.254.139"), + IP: netip.MustParseAddr("100.65.254.139"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ GoOS: "linux", @@ -776,7 +776,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { }, "peerD": { ID: "peerD", - IP: net.ParseIP("100.65.62.5"), + IP: netip.MustParseAddr("100.65.62.5"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ GoOS: "linux", @@ -786,7 +786,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { }, "peerE": { ID: "peerE", - IP: net.ParseIP("100.65.32.206"), + IP: netip.MustParseAddr("100.65.32.206"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ GoOS: "linux", @@ -796,7 +796,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { }, "peerF": { ID: "peerF", - IP: net.ParseIP("100.65.250.202"), + IP: netip.MustParseAddr("100.65.250.202"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ GoOS: "linux", @@ -806,7 +806,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { }, "peerG": { ID: "peerG", - IP: net.ParseIP("100.65.13.186"), + IP: netip.MustParseAddr("100.65.13.186"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ GoOS: "linux", @@ -816,7 +816,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { }, "peerH": { ID: "peerH", - IP: net.ParseIP("100.65.29.55"), + IP: netip.MustParseAddr("100.65.29.55"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ GoOS: "linux", @@ -826,7 +826,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { }, "peerI": { ID: "peerI", - IP: net.ParseIP("100.65.21.56"), + IP: netip.MustParseAddr("100.65.21.56"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ GoOS: "windows", @@ -917,7 +917,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { t.Run("verify peer's network map with default group peer list", func(t *testing.T) { // peerB doesn't fulfill the NB posture check but is included in the destination group Swarm, // will establish a connection with all source peers satisfying the NB posture check. - peers, firewallRules := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers) + peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 4) assert.Len(t, firewallRules, 4) assert.Contains(t, peers, account.Peers["peerA"]) @@ -927,7 +927,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { // peerC satisfy the NB posture check, should establish connection to all destination group peer's // We expect a single permissive firewall rule which all outgoing connections - peers, firewallRules = account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers) + peers, firewallRules, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, len(account.Groups["GroupSwarm"].Peers)) assert.Len(t, firewallRules, 7) expectedFirewallRules := []*types.FirewallRule{ @@ -992,7 +992,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { // peerE doesn't fulfill the NB posture check and exists in only destination group Swarm, // all source group peers satisfying the NB posture check should establish connection - peers, firewallRules = account.GetPeerConnectionResources(context.Background(), account.Peers["peerE"], approvedPeers) + peers, firewallRules, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerE"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 4) assert.Len(t, firewallRules, 4) assert.Contains(t, peers, account.Peers["peerA"]) @@ -1002,7 +1002,7 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { // peerI doesn't fulfill the OS version posture check and exists in only destination group Swarm, // all source group peers satisfying the NB posture check should establish connection - peers, firewallRules = account.GetPeerConnectionResources(context.Background(), account.Peers["peerI"], approvedPeers) + peers, firewallRules, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerI"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 4) assert.Len(t, firewallRules, 4) assert.Contains(t, peers, account.Peers["peerA"]) @@ -1017,19 +1017,19 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { // peerB doesn't satisfy the NB posture check, and doesn't exist in destination group peer's // no connection should be established to any peer of destination group - peers, firewallRules := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers) + peers, firewallRules, _, _ := account.GetPeerConnectionResources(context.Background(), account.Peers["peerB"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 0) assert.Len(t, firewallRules, 0) // peerI doesn't satisfy the OS version posture check, and doesn't exist in destination group peer's // no connection should be established to any peer of destination group - peers, firewallRules = account.GetPeerConnectionResources(context.Background(), account.Peers["peerI"], approvedPeers) + peers, firewallRules, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerI"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 0) assert.Len(t, firewallRules, 0) // peerC satisfy the NB posture check, should establish connection to all destination group peer's // We expect a single permissive firewall rule which all outgoing connections - peers, firewallRules = account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers) + peers, firewallRules, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerC"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, len(account.Groups["GroupSwarm"].Peers)) assert.Len(t, firewallRules, len(account.Groups["GroupSwarm"].Peers)) @@ -1044,14 +1044,14 @@ func TestAccount_getPeersByPolicyPostureChecks(t *testing.T) { // peerE doesn't fulfill the NB posture check and exists in only destination group Swarm, // all source group peers satisfying the NB posture check should establish connection - peers, firewallRules = account.GetPeerConnectionResources(context.Background(), account.Peers["peerE"], approvedPeers) + peers, firewallRules, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerE"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 3) assert.Len(t, firewallRules, 3) assert.Contains(t, peers, account.Peers["peerA"]) assert.Contains(t, peers, account.Peers["peerC"]) assert.Contains(t, peers, account.Peers["peerD"]) - peers, firewallRules = account.GetPeerConnectionResources(context.Background(), account.Peers["peerA"], approvedPeers) + peers, firewallRules, _, _ = account.GetPeerConnectionResources(context.Background(), account.Peers["peerA"], approvedPeers, account.GetActiveGroupUsers()) assert.Len(t, peers, 5) // assert peers from Group Swarm assert.Contains(t, peers, account.Peers["peerD"]) @@ -1231,7 +1231,7 @@ func TestPolicyAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -1263,7 +1263,7 @@ func TestPolicyAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -1294,7 +1294,7 @@ func TestPolicyAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -1314,17 +1314,19 @@ func TestPolicyAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) - // Updating disabled policy with destination and source groups containing peers should not update account's peers - // or send peer update + // Updating disabled policy with destination and source groups containing peers should still update account's peers + // because affected peer resolution does not filter by policy enabled state t.Run("updating disabled policy with source and destination groups with peers", func(t *testing.T) { + drainPeerUpdates(updMsg) + done := make(chan struct{}) go func() { - peerShouldNotReceiveUpdate(t, updMsg) + peerShouldReceiveUpdate(t, updMsg) close(done) }() @@ -1335,8 +1337,8 @@ func TestPolicyAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldNotReceiveUpdate") + case <-time.After(peerUpdateTimeout): + t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -1355,7 +1357,7 @@ func TestPolicyAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -1373,7 +1375,7 @@ func TestPolicyAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } @@ -1393,7 +1395,7 @@ func TestPolicyAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) diff --git a/management/server/posture/affects_posture_test.go b/management/server/posture/affects_posture_test.go new file mode 100644 index 000000000..6aa54d892 --- /dev/null +++ b/management/server/posture/affects_posture_test.go @@ -0,0 +1,202 @@ +package posture + +import ( + "context" + "net" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" +) + +// diffFrom builds a MetaDiff from the old/new snapshots AffectsPosture replays against. +func diffFrom(oldMeta, newMeta nbpeer.PeerSystemMeta, oldLoc, newLoc nbpeer.Location) *nbpeer.MetaDiff { + return &nbpeer.MetaDiff{ + OldMeta: oldMeta, + NewMeta: newMeta, + OldLocation: oldLoc, + NewLocation: newLoc, + } +} + +func checks(def ChecksDefinition) []*Checks { + return []*Checks{{Checks: def}} +} + +func TestAffectsPosture_NilDiff(t *testing.T) { + assert.False(t, AffectsPosture(context.Background(), nil, checks(ChecksDefinition{ + NBVersionCheck: &NBVersionCheck{MinVersion: "1.0.0"}, + }))) +} + +func TestAffectsPosture_NBVersion(t *testing.T) { + c := checks(ChecksDefinition{NBVersionCheck: &NBVersionCheck{MinVersion: "1.2.0"}}) + + tests := []struct { + name string + oldVer, newVer string + want bool + }{ + {"both above min, no flip", "1.3.0", "1.4.0", false}, + {"both below min, no flip", "1.0.0", "1.1.0", false}, + {"crosses up below->above", "1.1.0", "1.3.0", true}, + {"crosses down above->below", "1.3.0", "1.1.0", true}, + {"unparsable old only -> flip", "garbage", "1.3.0", true}, + {"unparsable both -> no flip", "garbage", "junk", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + diff := diffFrom( + nbpeer.PeerSystemMeta{WtVersion: tt.oldVer}, + nbpeer.PeerSystemMeta{WtVersion: tt.newVer}, + nbpeer.Location{}, nbpeer.Location{}, + ) + assert.Equal(t, tt.want, AffectsPosture(context.Background(), diff, c)) + }) + } +} + +func TestAffectsPosture_OSVersion_KernelBumpWithinMin(t *testing.T) { + c := checks(ChecksDefinition{OSVersionCheck: &OSVersionCheck{ + Linux: &MinKernelVersionCheck{MinKernelVersion: "5.0.0"}, + }}) + + // Kernel moves but stays above the minimum: verdict stays pass -> not affected. + withinMin := diffFrom( + nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.10.0-arch1"}, + nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.15.0-arch2"}, + nbpeer.Location{}, nbpeer.Location{}, + ) + assert.False(t, AffectsPosture(context.Background(), withinMin, c)) + + // Kernel drops below the minimum: verdict flips pass -> fail -> affected. + crossesDown := diffFrom( + nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "5.10.0-arch1"}, + nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "4.19.0-arch1"}, + nbpeer.Location{}, nbpeer.Location{}, + ) + assert.True(t, AffectsPosture(context.Background(), crossesDown, c)) +} + +func TestAffectsPosture_OSVersion_GoOSSwitchFlipsVerdict(t *testing.T) { + // Only Linux is constrained. An OS outside the switch (freebsd) passes; switching to a + // failing linux kernel flips the verdict pass -> fail. + c := checks(ChecksDefinition{OSVersionCheck: &OSVersionCheck{ + Linux: &MinKernelVersionCheck{MinKernelVersion: "6.0.0"}, + }}) + + diff := diffFrom( + nbpeer.PeerSystemMeta{GoOS: "freebsd"}, + nbpeer.PeerSystemMeta{GoOS: "linux", KernelVersion: "4.19.0"}, + nbpeer.Location{}, nbpeer.Location{}, + ) + assert.True(t, AffectsPosture(context.Background(), diff, c)) +} + +func TestAffectsPosture_Process_GoOSSwitchFlipsVerdict(t *testing.T) { + // Process runs at a linux path. Switching GoOS to windows (no WindowsPath configured) + // flips the verdict. + c := checks(ChecksDefinition{ProcessCheck: &ProcessCheck{ + Processes: []Process{{LinuxPath: "/usr/bin/foo"}}, + }}) + + files := []nbpeer.File{{Path: "/usr/bin/foo", ProcessIsRunning: true}} + diff := diffFrom( + nbpeer.PeerSystemMeta{GoOS: "linux", Files: files}, + nbpeer.PeerSystemMeta{GoOS: "windows", Files: files}, + nbpeer.Location{}, nbpeer.Location{}, + ) + assert.True(t, AffectsPosture(context.Background(), diff, c)) +} + +func TestAffectsPosture_Process_UnrelatedFileChange(t *testing.T) { + // A tracked process stays running while an unrelated file is added: the verdict does + // not move, so posture is not affected. + c := checks(ChecksDefinition{ProcessCheck: &ProcessCheck{ + Processes: []Process{{LinuxPath: "/usr/bin/foo"}}, + }}) + + diff := diffFrom( + nbpeer.PeerSystemMeta{GoOS: "linux", Files: []nbpeer.File{ + {Path: "/usr/bin/foo", ProcessIsRunning: true}, + }}, + nbpeer.PeerSystemMeta{GoOS: "linux", Files: []nbpeer.File{ + {Path: "/usr/bin/foo", ProcessIsRunning: true}, + {Path: "/usr/bin/bar", ProcessIsRunning: true}, + }}, + nbpeer.Location{}, nbpeer.Location{}, + ) + assert.False(t, AffectsPosture(context.Background(), diff, c)) +} + +func TestAffectsPosture_GeoLocation(t *testing.T) { + c := checks(ChecksDefinition{GeoLocationCheck: &GeoLocationCheck{ + Action: CheckActionAllow, + Locations: []Location{{CountryCode: "DE"}}, + }}) + + // Moving within allowed countries keeps the verdict; moving out flips it. + stayAllowed := diffFrom( + nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{}, + nbpeer.Location{CountryCode: "DE", CityName: "Berlin"}, + nbpeer.Location{CountryCode: "DE", CityName: "Munich"}, + ) + assert.False(t, AffectsPosture(context.Background(), stayAllowed, c)) + + moveOut := diffFrom( + nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{}, + nbpeer.Location{CountryCode: "DE"}, + nbpeer.Location{CountryCode: "FR"}, + ) + assert.True(t, AffectsPosture(context.Background(), moveOut, c)) +} + +func TestAffectsPosture_PeerNetworkRange_ConnectionIP(t *testing.T) { + // The check reads the connection IP. Moving out of the allowed range flips the verdict; + // moving within it does not. + _, allowed, _ := net.ParseCIDR("10.0.0.0/8") + c := checks(ChecksDefinition{PeerNetworkRangeCheck: &PeerNetworkRangeCheck{ + Action: CheckActionAllow, + Ranges: []netip.Prefix{netip.MustParsePrefix(allowed.String())}, + }}) + + movesOutOfRange := diffFrom( + nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{}, + nbpeer.Location{ConnectionIP: net.ParseIP("10.1.2.3")}, + nbpeer.Location{ConnectionIP: net.ParseIP("8.8.8.8")}, + ) + assert.True(t, AffectsPosture(context.Background(), movesOutOfRange, c)) + + staysInRange := diffFrom( + nbpeer.PeerSystemMeta{}, nbpeer.PeerSystemMeta{}, + nbpeer.Location{ConnectionIP: net.ParseIP("10.1.2.3")}, + nbpeer.Location{ConnectionIP: net.ParseIP("10.9.9.9")}, + ) + assert.False(t, AffectsPosture(context.Background(), staysInRange, c)) +} + +func TestAffectsPosture_IrrelevantFieldChange(t *testing.T) { + // Hostname changes but no check reads it: not affected even with checks present. + c := checks(ChecksDefinition{ + NBVersionCheck: &NBVersionCheck{MinVersion: "1.0.0"}, + GeoLocationCheck: &GeoLocationCheck{Action: CheckActionAllow, Locations: []Location{{CountryCode: "DE"}}}, + }) + + diff := diffFrom( + nbpeer.PeerSystemMeta{Hostname: "old", WtVersion: "1.5.0"}, + nbpeer.PeerSystemMeta{Hostname: "new", WtVersion: "1.5.0"}, + nbpeer.Location{CountryCode: "DE"}, nbpeer.Location{CountryCode: "DE"}, + ) + assert.False(t, AffectsPosture(context.Background(), diff, c)) +} + +func TestAffectsPosture_NoChecks(t *testing.T) { + diff := diffFrom( + nbpeer.PeerSystemMeta{WtVersion: "1.0.0"}, + nbpeer.PeerSystemMeta{WtVersion: "2.0.0"}, + nbpeer.Location{}, nbpeer.Location{}, + ) + assert.False(t, AffectsPosture(context.Background(), diff, nil)) +} diff --git a/management/server/posture/checks.go b/management/server/posture/checks.go index d65dc5045..72b719252 100644 --- a/management/server/posture/checks.go +++ b/management/server/posture/checks.go @@ -7,8 +7,10 @@ import ( "regexp" "github.com/hashicorp/go-version" - "github.com/netbirdio/netbird/shared/management/http/api" + log "github.com/sirupsen/logrus" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/status" ) @@ -47,10 +49,52 @@ type Checks struct { // AccountID is a reference to the Account that this object belongs AccountID string `json:"-" gorm:"index"` + PublicID string `json:"-"` + // Checks is a set of objects that perform the actual checks Checks ChecksDefinition `gorm:"serializer:json"` } +// AffectsPosture reports whether the change in diff flips the verdict of any check. It +// replays each check against the peer's old and new state and compares verdicts, so a +// change that moves a field but stays the right side of a threshold (e.g. a kernel bump +// still above the minimum) does not force a re-evaluation. See verdictChanged for how an +// evaluation error counts. +func AffectsPosture(ctx context.Context, diff *nbpeer.MetaDiff, checks []*Checks) bool { + if diff == nil { + return false + } + + oldPeer := nbpeer.Peer{Meta: diff.OldMeta, Location: diff.OldLocation} + newPeer := nbpeer.Peer{Meta: diff.NewMeta, Location: diff.NewLocation} + + for _, c := range checks { + for _, check := range c.GetChecks() { + if verdictChanged(ctx, check, oldPeer, newPeer) { + return true + } + } + } + return false +} + +// verdictChanged replays check against old and new state and reports whether the verdict +// differs. Like callers, it treats an evaluation error as deny: two errors are the same +// verdict (no change), an error on one side only is a flip. +func verdictChanged(ctx context.Context, check Check, oldPeer, newPeer nbpeer.Peer) bool { + oldPass, oldErr := check.Check(ctx, oldPeer) + newPass, newErr := check.Check(ctx, newPeer) + + oldVerdict := oldPass && (oldErr == nil) + newVerdict := newPass && (newErr == nil) + changed := oldVerdict != newVerdict + + log.WithContext(ctx).Tracef("posture check %s replay: verdict %t -> %t (changed=%t), errs: %v -> %v", + check.Name(), oldVerdict, newVerdict, changed, oldErr, newErr) + + return changed +} + // ChecksDefinition contains definition of actual check type ChecksDefinition struct { NBVersionCheck *NBVersionCheck `json:",omitempty"` @@ -125,6 +169,7 @@ func (pc *Checks) Copy() *Checks { Name: pc.Name, Description: pc.Description, AccountID: pc.AccountID, + PublicID: pc.PublicID, Checks: pc.Checks.Copy(), } return checks diff --git a/management/server/posture/nb_version.go b/management/server/posture/nb_version.go index 33bf01ad1..3cace3b5f 100644 --- a/management/server/posture/nb_version.go +++ b/management/server/posture/nb_version.go @@ -3,12 +3,9 @@ package posture import ( "context" "fmt" - "strings" - - "github.com/hashicorp/go-version" - log "github.com/sirupsen/logrus" nbpeer "github.com/netbirdio/netbird/management/server/peer" + nbversion "github.com/netbirdio/netbird/version" ) type NBVersionCheck struct { @@ -17,14 +14,8 @@ type NBVersionCheck struct { var _ Check = (*NBVersionCheck)(nil) -// sanitizeVersion removes anything after the pre-release tag (e.g., "-dev", "-alpha", etc.) -func sanitizeVersion(version string) string { - parts := strings.Split(version, "-") - return parts[0] -} - func (n *NBVersionCheck) Check(ctx context.Context, peer nbpeer.Peer) (bool, error) { - meetsMin, err := MeetsMinVersion(n.MinVersion, peer.Meta.WtVersion) + meetsMin, err := nbversion.MeetsMinVersion(n.MinVersion, peer.Meta.WtVersion) if err != nil { return false, err } @@ -33,9 +24,6 @@ func (n *NBVersionCheck) Check(ctx context.Context, peer nbpeer.Peer) (bool, err return true, nil } - log.WithContext(ctx).Debugf("peer %s NB version %s is older than minimum allowed version %s", - peer.ID, peer.Meta.WtVersion, n.MinVersion) - return false, nil } @@ -52,21 +40,3 @@ func (n *NBVersionCheck) Validate() error { } return nil } - -// MeetsMinVersion checks if the peer's version meets or exceeds the minimum required version -func MeetsMinVersion(minVer, peerVer string) (bool, error) { - peerVer = sanitizeVersion(peerVer) - minVer = sanitizeVersion(minVer) - - peerNBVer, err := version.NewVersion(peerVer) - if err != nil { - return false, err - } - - constraints, err := version.NewConstraint(">= " + minVer) - if err != nil { - return false, err - } - - return constraints.Check(peerNBVer), nil -} diff --git a/management/server/posture/nb_version_test.go b/management/server/posture/nb_version_test.go index d3478afc2..1bf485453 100644 --- a/management/server/posture/nb_version_test.go +++ b/management/server/posture/nb_version_test.go @@ -139,68 +139,3 @@ func TestNBVersionCheck_Validate(t *testing.T) { }) } } - -func TestMeetsMinVersion(t *testing.T) { - tests := []struct { - name string - minVer string - peerVer string - want bool - wantErr bool - }{ - { - name: "Peer version greater than min version", - minVer: "0.26.0", - peerVer: "0.60.1", - want: true, - wantErr: false, - }, - { - name: "Peer version equals min version", - minVer: "1.0.0", - peerVer: "1.0.0", - want: true, - wantErr: false, - }, - { - name: "Peer version less than min version", - minVer: "1.0.0", - peerVer: "0.9.9", - want: false, - wantErr: false, - }, - { - name: "Peer version with pre-release tag greater than min version", - minVer: "1.0.0", - peerVer: "1.0.1-alpha", - want: true, - wantErr: false, - }, - { - name: "Invalid peer version format", - minVer: "1.0.0", - peerVer: "dev", - want: false, - wantErr: true, - }, - { - name: "Invalid min version format", - minVer: "invalid.version", - peerVer: "1.0.0", - want: false, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := MeetsMinVersion(tt.minVer, tt.peerVer) - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/management/server/posture/network.go b/management/server/posture/network.go index f78744143..4b4b3ccaa 100644 --- a/management/server/posture/network.go +++ b/management/server/posture/network.go @@ -17,19 +17,48 @@ type PeerNetworkRangeCheck struct { var _ Check = (*PeerNetworkRangeCheck)(nil) +// prefixContains reports whether outer fully contains inner (equal counts as contained). +// Requires the same address family, that outer is no more specific than inner (its +// netmask is shorter or equal), and that inner's network address falls inside outer. +// This is stricter than netip.Prefix.Contains(Addr) — a peer's /24 NIC will not match a +// configured /32 rule, since the rule covers a single host but the NIC describes a whole +// subnet whose host bits are unknown. +func prefixContains(outer, inner netip.Prefix) bool { + outer = outer.Masked() + inner = inner.Masked() + return outer.Bits() <= inner.Bits() && + outer.Addr().BitLen() == inner.Addr().BitLen() && // same family + outer.Contains(inner.Addr()) +} + +// Check evaluates configured ranges against the peer's local network interface prefixes +// and its public connection IP (as a /32 or /128). A configured range matches when it +// fully contains one of those prefixes, so operators can target both private subnets +// and public CIDRs (e.g. 1.0.0.0/24, 2.2.2.2/32). Including the connection IP is what +// lets a public-range posture check work — peer.Meta.NetworkAddresses only carries +// local NIC addresses. func (p *PeerNetworkRangeCheck) Check(ctx context.Context, peer nbpeer.Peer) (bool, error) { - if len(peer.Meta.NetworkAddresses) == 0 { + peerPrefixes := make([]netip.Prefix, 0, len(peer.Meta.NetworkAddresses)+1) + for _, peerNetAddr := range peer.Meta.NetworkAddresses { + peerPrefixes = append(peerPrefixes, peerNetAddr.NetIP) + } + // Unmap collapses 4-in-6 forms (::ffff:a.b.c.d) so an IPv4 range matches. + if connIP := peer.Location.ConnectionIP; len(connIP) > 0 { + if addr, ok := netip.AddrFromSlice(connIP); ok { + addr = addr.Unmap() + peerPrefixes = append(peerPrefixes, netip.PrefixFrom(addr, addr.BitLen())) + } + } + + if len(peerPrefixes) == 0 { return false, fmt.Errorf("peer's does not contain peer network range addresses") } - maskedPrefixes := make([]netip.Prefix, 0, len(p.Ranges)) - for _, prefix := range p.Ranges { - maskedPrefixes = append(maskedPrefixes, prefix.Masked()) - } - - for _, peerNetAddr := range peer.Meta.NetworkAddresses { - peerMaskedPrefix := peerNetAddr.NetIP.Masked() - if slices.Contains(maskedPrefixes, peerMaskedPrefix) { + for _, peerPrefix := range peerPrefixes { + for _, rangePrefix := range p.Ranges { + if !prefixContains(rangePrefix, peerPrefix) { + continue + } switch p.Action { case CheckActionDeny: return false, nil diff --git a/management/server/posture/network_test.go b/management/server/posture/network_test.go index a841bbe08..4af394c62 100644 --- a/management/server/posture/network_test.go +++ b/management/server/posture/network_test.go @@ -2,6 +2,7 @@ package posture import ( "context" + "net" "net/netip" "testing" @@ -134,6 +135,205 @@ func TestPeerNetworkRangeCheck_Check(t *testing.T) { wantErr: true, isValid: false, }, + { + name: "Peer connection IP matches the denied /32", + check: PeerNetworkRangeCheck{ + Action: CheckActionDeny, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("109.41.115.194/32"), + }, + }, + peer: nbpeer.Peer{ + Meta: nbpeer.PeerSystemMeta{ + NetworkAddresses: []nbpeer.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.0.123/24")}, + }, + }, + Location: nbpeer.Location{ConnectionIP: net.ParseIP("109.41.115.194")}, + }, + wantErr: false, + isValid: false, + }, + { + name: "Peer connection IP does not match the denied /32", + check: PeerNetworkRangeCheck{ + Action: CheckActionDeny, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("109.41.115.194/32"), + }, + }, + peer: nbpeer.Peer{ + Meta: nbpeer.PeerSystemMeta{ + NetworkAddresses: []nbpeer.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.0.123/24")}, + }, + }, + Location: nbpeer.Location{ConnectionIP: net.ParseIP("8.8.8.8")}, + }, + wantErr: false, + isValid: true, + }, + { + name: "Peer connection IP matches the allowed /32 with no NetworkAddresses", + check: PeerNetworkRangeCheck{ + Action: CheckActionAllow, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("109.41.115.194/32"), + }, + }, + peer: nbpeer.Peer{ + Location: nbpeer.Location{ConnectionIP: net.ParseIP("109.41.115.194")}, + }, + wantErr: false, + isValid: true, + }, + { + name: "IPv6 connection IP matches the denied /128", + check: PeerNetworkRangeCheck{ + Action: CheckActionDeny, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("2001:db8::1/128"), + }, + }, + peer: nbpeer.Peer{ + Location: nbpeer.Location{ConnectionIP: net.ParseIP("2001:db8::1")}, + }, + wantErr: false, + isValid: false, + }, + { + name: "IPv6 connection IP does not match the denied /128", + check: PeerNetworkRangeCheck{ + Action: CheckActionDeny, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("2001:db8::1/128"), + }, + }, + peer: nbpeer.Peer{ + Location: nbpeer.Location{ConnectionIP: net.ParseIP("2001:db8::2")}, + }, + wantErr: false, + isValid: true, + }, + { + name: "IPv4-mapped IPv6 connection IP matches IPv4 /32", + check: PeerNetworkRangeCheck{ + Action: CheckActionDeny, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("109.41.115.194/32"), + }, + }, + peer: nbpeer.Peer{ + Location: nbpeer.Location{ConnectionIP: net.ParseIP("::ffff:109.41.115.194")}, + }, + wantErr: false, + isValid: false, + }, + { + name: "Connection IP falls inside an allowed /24 range", + check: PeerNetworkRangeCheck{ + Action: CheckActionAllow, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("1.0.0.0/24"), + netip.MustParsePrefix("2.2.2.2/32"), + }, + }, + peer: nbpeer.Peer{ + Location: nbpeer.Location{ConnectionIP: net.ParseIP("1.0.0.55")}, + }, + wantErr: false, + isValid: true, + }, + { + name: "Connection IP falls inside an allowed /23 range", + check: PeerNetworkRangeCheck{ + Action: CheckActionAllow, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("3.0.0.0/23"), + }, + }, + peer: nbpeer.Peer{ + Location: nbpeer.Location{ConnectionIP: net.ParseIP("3.0.1.200")}, + }, + wantErr: false, + isValid: true, + }, + { + name: "Connection IP outside the allowed /24 range", + check: PeerNetworkRangeCheck{ + Action: CheckActionAllow, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("1.0.0.0/24"), + }, + }, + peer: nbpeer.Peer{ + Location: nbpeer.Location{ConnectionIP: net.ParseIP("1.0.1.5")}, + }, + wantErr: false, + isValid: false, + }, + { + name: "Connection IP inside a denied /24 range", + check: PeerNetworkRangeCheck{ + Action: CheckActionDeny, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("1.0.0.0/24"), + }, + }, + peer: nbpeer.Peer{ + Location: nbpeer.Location{ConnectionIP: net.ParseIP("1.0.0.7")}, + }, + wantErr: false, + isValid: false, + }, + { + name: "Local NIC /24 does not match a /32 rule even if host bit lines up", + check: PeerNetworkRangeCheck{ + Action: CheckActionAllow, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("192.168.0.5/32"), + }, + }, + peer: nbpeer.Peer{ + Meta: nbpeer.PeerSystemMeta{ + NetworkAddresses: []nbpeer.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.0.5/24")}, + }, + }, + }, + wantErr: false, + isValid: false, + }, + { + name: "Local NIC address inside an allowed /16 range", + check: PeerNetworkRangeCheck{ + Action: CheckActionAllow, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("192.168.0.0/16"), + }, + }, + peer: nbpeer.Peer{ + Meta: nbpeer.PeerSystemMeta{ + NetworkAddresses: []nbpeer.NetworkAddress{ + {NetIP: netip.MustParsePrefix("192.168.5.7/24")}, + }, + }, + }, + wantErr: false, + isValid: true, + }, + { + name: "Empty NetworkAddresses and empty ConnectionIP still errors", + check: PeerNetworkRangeCheck{ + Action: CheckActionDeny, + Ranges: []netip.Prefix{ + netip.MustParsePrefix("109.41.115.194/32"), + }, + }, + peer: nbpeer.Peer{}, + wantErr: true, + isValid: false, + }, } for _, tt := range tests { diff --git a/management/server/posture/os_version.go b/management/server/posture/os_version.go index 411f4c2c6..724bebfcc 100644 --- a/management/server/posture/os_version.go +++ b/management/server/posture/os_version.go @@ -82,7 +82,7 @@ func (c *OSVersionCheck) Validate() error { func checkMinVersion(ctx context.Context, peerGoOS, peerVersion string, check *MinVersionCheck) (bool, error) { if check == nil { - log.WithContext(ctx).Debugf("peer %s OS is not allowed in the check", peerGoOS) + log.WithContext(ctx).Tracef("peer %s OS is not allowed in the check", peerGoOS) return false, nil } @@ -100,14 +100,12 @@ func checkMinVersion(ctx context.Context, peerGoOS, peerVersion string, check *M return true, nil } - log.WithContext(ctx).Debugf("peer %s OS version %s is older than minimum allowed version %s", peerGoOS, peerVersion, check.MinVersion) - return false, nil } func checkMinKernelVersion(ctx context.Context, peerGoOS, peerVersion string, check *MinKernelVersionCheck) (bool, error) { if check == nil { - log.WithContext(ctx).Debugf("peer %s OS is not allowed in the check", peerGoOS) + log.WithContext(ctx).Tracef("peer %s OS is not allowed in the check", peerGoOS) return false, nil } @@ -125,7 +123,5 @@ func checkMinKernelVersion(ctx context.Context, peerGoOS, peerVersion string, ch return true, nil } - log.WithContext(ctx).Debugf("peer %s kernel version %s is older than minimum allowed version %s", peerGoOS, peerVersion, check.MinKernelVersion) - return false, nil } diff --git a/management/server/posture_checks.go b/management/server/posture_checks.go index ac8ea35de..081226866 100644 --- a/management/server/posture_checks.go +++ b/management/server/posture_checks.go @@ -7,6 +7,7 @@ import ( "github.com/rs/xid" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/posture" @@ -15,7 +16,7 @@ import ( ) func (am *DefaultAccountManager) GetPostureChecks(ctx context.Context, accountID, postureChecksID, userID string) (*posture.Checks, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -32,7 +33,7 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI if !create { operation = operations.Update } - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operation) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operation) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -40,9 +41,10 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI return nil, status.NewPermissionDeniedError() } - var updateAccountPeers bool var isUpdate = postureChecks.ID != "" var action = activity.PostureCheckCreated + var snap *affectedpeers.Snapshot + change := affectedpeers.Change{PostureCheckIDs: []string{postureChecks.ID}} err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err = validatePostureChecks(ctx, transaction, accountID, postureChecks); err != nil { @@ -50,12 +52,15 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI } if isUpdate { - updateAccountPeers, err = arePostureCheckChangesAffectPeers(ctx, transaction, accountID, postureChecks.ID) + existing, err := transaction.GetPostureChecksByID(ctx, store.LockingStrengthNone, accountID, postureChecks.ID) if err != nil { return err } + postureChecks.PublicID = existing.PublicID action = activity.PostureCheckUpdated + } else { + postureChecks.PublicID = xid.New().String() } postureChecks.AccountID = accountID @@ -64,6 +69,11 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI } if isUpdate { + // Editing a posture check does not change which policies reference it, + // so loading after the save is fine. + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return err + } return transaction.IncrementNetworkSerial(ctx, accountID) } @@ -75,16 +85,14 @@ func (am *DefaultAccountManager) SavePostureChecks(ctx context.Context, accountI am.StoreEvent(ctx, userID, postureChecks.ID, accountID, action, postureChecks.EventMeta()) - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return postureChecks, nil } // DeletePostureChecks deletes a posture check by ID. func (am *DefaultAccountManager) DeletePostureChecks(ctx context.Context, accountID, postureChecksID, userID string) error { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Read) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Delete) if err != nil { return status.NewPermissionValidationError(err) } @@ -121,7 +129,7 @@ func (am *DefaultAccountManager) DeletePostureChecks(ctx context.Context, accoun // ListPostureChecks returns a list of posture checks. func (am *DefaultAccountManager) ListPostureChecks(ctx context.Context, accountID, userID string) ([]*posture.Checks, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Policies, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -132,33 +140,10 @@ func (am *DefaultAccountManager) ListPostureChecks(ctx context.Context, accountI return am.Store.GetAccountPostureChecks(ctx, store.LockingStrengthNone, accountID) } -// arePostureCheckChangesAffectPeers checks if the changes in posture checks are affecting peers. -func arePostureCheckChangesAffectPeers(ctx context.Context, transaction store.Store, accountID, postureCheckID string) (bool, error) { - policies, err := transaction.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID) - if err != nil { - return false, err - } - - for _, policy := range policies { - if slices.Contains(policy.SourcePostureChecks, postureCheckID) { - hasPeers, err := anyGroupHasPeersOrResources(ctx, transaction, accountID, policy.RuleGroups()) - if err != nil { - return false, err - } - - if hasPeers { - return true, nil - } - } - } - - return false, nil -} - // validatePostureChecks validates the posture checks. func validatePostureChecks(ctx context.Context, transaction store.Store, accountID string, postureChecks *posture.Checks) error { if err := postureChecks.Validate(); err != nil { - return status.Errorf(status.InvalidArgument, err.Error()) //nolint + return status.Errorf(status.InvalidArgument, "%v", err.Error()) //nolint } // If the posture check already has an ID, verify its existence in the store. diff --git a/management/server/posture_checks_test.go b/management/server/posture_checks_test.go index 13152ed12..74738e72d 100644 --- a/management/server/posture_checks_test.go +++ b/management/server/posture_checks_test.go @@ -109,7 +109,7 @@ func initTestPostureChecksAccount(am *DefaultAccountManager) (*types.Account, er ID: "peer1", } - account := newAccountWithId(context.Background(), accountID, groupAdminUserID, domain, false) + account := newAccountWithId(context.Background(), accountID, groupAdminUserID, domain, "", "", false) account.Users[admin.Id] = admin account.Users[user.Id] = user account.Peers["peer1"] = peer1 @@ -244,7 +244,7 @@ func TestPostureCheckAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -273,7 +273,7 @@ func TestPostureCheckAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -292,7 +292,7 @@ func TestPostureCheckAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -395,7 +395,7 @@ func TestPostureCheckAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -438,7 +438,7 @@ func TestPostureCheckAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -489,6 +489,7 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) { policy := &types.Policy{ AccountID: account.Id, + Enabled: true, Rules: []*types.PolicyRule{ { Enabled: true, @@ -503,21 +504,20 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) { require.NoError(t, err, "failed to save policy") t.Run("posture check exists and is linked to policy with peers", func(t *testing.T) { - result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID) - require.NoError(t, err) - assert.True(t, result) + groupIDs, _ := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID) + assert.NotEmpty(t, groupIDs) }) t.Run("posture check exists but is not linked to any policy", func(t *testing.T) { - result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckB.ID) - require.NoError(t, err) - assert.False(t, result) + groupIDs, directPeerIDs := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckB.ID) + assert.Empty(t, groupIDs) + assert.Empty(t, directPeerIDs) }) t.Run("posture check does not exist", func(t *testing.T) { - result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, "unknown") - require.NoError(t, err) - assert.False(t, result) + groupIDs, directPeerIDs := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, "unknown") + assert.Empty(t, groupIDs) + assert.Empty(t, directPeerIDs) }) t.Run("posture check is linked to policy with no peers in source groups", func(t *testing.T) { @@ -526,9 +526,8 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) { _, err = manager.SavePolicy(context.Background(), account.Id, adminUserID, policy, true) require.NoError(t, err, "failed to update policy") - result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID) - require.NoError(t, err) - assert.True(t, result) + groupIDs, _ := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID) + assert.NotEmpty(t, groupIDs) }) t.Run("posture check is linked to policy with no peers in destination groups", func(t *testing.T) { @@ -537,9 +536,8 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) { _, err = manager.SavePolicy(context.Background(), account.Id, adminUserID, policy, true) require.NoError(t, err, "failed to update policy") - result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID) - require.NoError(t, err) - assert.True(t, result) + groupIDs, _ := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID) + assert.NotEmpty(t, groupIDs) }) t.Run("posture check is linked to policy but no peers in groups", func(t *testing.T) { @@ -547,9 +545,9 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) { err = manager.UpdateGroup(context.Background(), account.Id, adminUserID, groupA) require.NoError(t, err, "failed to save groups") - result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID) - require.NoError(t, err) - assert.False(t, result) + // The collector returns groups even if they have no peers — the groups are still referenced + groupIDs, _ := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID) + assert.NotEmpty(t, groupIDs) }) t.Run("posture check is linked to policy with non-existent group", func(t *testing.T) { @@ -558,8 +556,68 @@ func TestArePostureCheckChangesAffectPeers(t *testing.T) { _, err = manager.SavePolicy(context.Background(), account.Id, adminUserID, policy, true) require.NoError(t, err, "failed to update policy") - result, err := arePostureCheckChangesAffectPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID) - require.NoError(t, err) - assert.False(t, result) + // Non-existent groups are filtered out during SavePolicy validation, + // so the saved policy has empty Sources/Destinations + groupIDs, directPeerIDs := collectPostureCheckAffectedGroupsAndPeers(context.Background(), manager.Store, account.Id, postureCheckA.ID) + assert.Empty(t, groupIDs) + assert.Empty(t, directPeerIDs) }) } + +// TestSavePostureChecks_AllocatesSeqIDOnCreate verifies that the create path +// (no incoming ID) allocates a non-zero AccountSeqID via the +// account_seq_counters table. +func TestSavePostureChecks_AllocatesSeqIDOnCreate(t *testing.T) { + am, _, err := createManager(t) + require.NoError(t, err) + + account, err := initTestPostureChecksAccount(am) + require.NoError(t, err) + + created, err := am.SavePostureChecks(context.Background(), account.Id, adminUserID, &posture.Checks{ + Name: "seq-allocation-test", + Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.26.0"}, + }, + }, true) + require.NoError(t, err) + require.NotEqual(t, "", created.PublicID, "SavePostureChecks on create must create PublicID") +} + +// TestSavePostureChecks_PreservesSeqIDOnUpdate verifies the update path does +// not reset AccountSeqID even when the caller passes a zero value (REST +// handler shape, because the field is `json:"-"`). +func TestSavePostureChecks_PreservesSeqIDOnUpdate(t *testing.T) { + am, _, err := createManager(t) + require.NoError(t, err) + + account, err := initTestPostureChecksAccount(am) + require.NoError(t, err) + + created, err := am.SavePostureChecks(context.Background(), account.Id, adminUserID, &posture.Checks{ + Name: "seq-preserve-original", + Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.26.0"}, + }, + }, true) + require.NoError(t, err) + originalPublicID := created.PublicID + require.NotEqual(t, "", originalPublicID) + + update := &posture.Checks{ + ID: created.ID, + Name: "seq-preserve-renamed", + Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.27.0"}, + }, + } + require.Equal(t, "", update.PublicID, "incoming struct must mirror an HTTP handler shape") + + _, err = am.SavePostureChecks(context.Background(), account.Id, adminUserID, update, false) + require.NoError(t, err) + + got, err := am.GetPostureChecks(context.Background(), account.Id, created.ID, adminUserID) + require.NoError(t, err) + require.Equal(t, originalPublicID, got.PublicID, "PublicID must survive SavePostureChecks update") + require.Equal(t, "seq-preserve-renamed", got.Name) +} diff --git a/management/server/route.go b/management/server/route.go index 2b4f11d05..5a55bf2b3 100644 --- a/management/server/route.go +++ b/management/server/route.go @@ -10,6 +10,7 @@ import ( "github.com/rs/xid" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/operations" "github.com/netbirdio/netbird/management/server/store" @@ -21,7 +22,7 @@ import ( // GetRoute gets a route object from account and route IDs func (am *DefaultAccountManager) GetRoute(ctx context.Context, accountID string, routeID route.ID, userID string) (*route.Route, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Read) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -134,7 +135,7 @@ func getRouteDescriptor(prefix netip.Prefix, domains domain.List) string { // CreateRoute creates and saves a new route func (am *DefaultAccountManager) CreateRoute(ctx context.Context, accountID string, prefix netip.Prefix, networkType route.NetworkType, domains domain.List, peerID string, peerGroupIDs []string, description string, netID route.NetID, masquerade bool, metric int, groups, accessControlGroupIDs []string, enabled bool, userID string, keepRoute bool, skipAutoApply bool) (*route.Route, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Create) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Create) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -147,7 +148,8 @@ func (am *DefaultAccountManager) CreateRoute(ctx context.Context, accountID stri } var newRoute *route.Route - var updateAccountPeers bool + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { newRoute = &route.Route{ @@ -173,12 +175,14 @@ func (am *DefaultAccountManager) CreateRoute(ctx context.Context, accountID stri return err } - updateAccountPeers, err = areRouteChangesAffectPeers(ctx, transaction, newRoute) - if err != nil { + newRoute.PublicID = xid.New().String() + + if err = transaction.SaveRoute(ctx, newRoute); err != nil { return err } - if err = transaction.SaveRoute(ctx, newRoute); err != nil { + change = affectedpeers.Change{Routes: []*route.Route{newRoute}} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } @@ -190,16 +194,14 @@ func (am *DefaultAccountManager) CreateRoute(ctx context.Context, accountID stri am.StoreEvent(ctx, userID, string(newRoute.ID), accountID, activity.RouteCreated, newRoute.EventMeta()) - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return newRoute, nil } // SaveRoute saves route func (am *DefaultAccountManager) SaveRoute(ctx context.Context, accountID, userID string, routeToSave *route.Route) error { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Update) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Update) if err != nil { return status.NewPermissionValidationError(err) } @@ -208,8 +210,8 @@ func (am *DefaultAccountManager) SaveRoute(ctx context.Context, accountID, userI } var oldRoute *route.Route - var oldRouteAffectsPeers bool - var newRouteAffectsPeers bool + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { if err = validateRoute(ctx, transaction, accountID, routeToSave); err != nil { @@ -221,21 +223,18 @@ func (am *DefaultAccountManager) SaveRoute(ctx context.Context, accountID, userI return err } - oldRouteAffectsPeers, err = areRouteChangesAffectPeers(ctx, transaction, oldRoute) - if err != nil { - return err - } - - newRouteAffectsPeers, err = areRouteChangesAffectPeers(ctx, transaction, routeToSave) - if err != nil { - return err - } routeToSave.AccountID = accountID + routeToSave.PublicID = oldRoute.PublicID if err = transaction.SaveRoute(ctx, routeToSave); err != nil { return err } + change = affectedpeers.Change{Routes: []*route.Route{routeToSave, oldRoute}} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return err + } + return transaction.IncrementNetworkSerial(ctx, accountID) }) if err != nil { @@ -244,16 +243,14 @@ func (am *DefaultAccountManager) SaveRoute(ctx context.Context, accountID, userI am.StoreEvent(ctx, userID, string(routeToSave.ID), accountID, activity.RouteUpdated, routeToSave.EventMeta()) - if oldRouteAffectsPeers || newRouteAffectsPeers { - am.UpdateAccountPeers(ctx, accountID) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } // DeleteRoute deletes route with routeID func (am *DefaultAccountManager) DeleteRoute(ctx context.Context, accountID string, routeID route.ID, userID string) error { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Delete) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Delete) if err != nil { return status.NewPermissionValidationError(err) } @@ -261,17 +258,19 @@ func (am *DefaultAccountManager) DeleteRoute(ctx context.Context, accountID stri return status.NewPermissionDeniedError() } - var route *route.Route - var updateAccountPeers bool + var rt *route.Route + var snap *affectedpeers.Snapshot + var change affectedpeers.Change err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - route, err = transaction.GetRouteByID(ctx, store.LockingStrengthUpdate, accountID, string(routeID)) + rt, err = transaction.GetRouteByID(ctx, store.LockingStrengthUpdate, accountID, string(routeID)) if err != nil { return err } - updateAccountPeers, err = areRouteChangesAffectPeers(ctx, transaction, route) - if err != nil { + // Load before delete: pre-state captures everyone referencing the route. + change = affectedpeers.Change{Routes: []*route.Route{rt}} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { return err } @@ -285,18 +284,16 @@ func (am *DefaultAccountManager) DeleteRoute(ctx context.Context, accountID stri return fmt.Errorf("failed to delete route %s: %w", routeID, err) } - am.StoreEvent(ctx, userID, string(route.ID), accountID, activity.RouteRemoved, route.EventMeta()) + am.StoreEvent(ctx, userID, string(rt.ID), accountID, activity.RouteRemoved, rt.EventMeta()) - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) - } + am.ExpandAndUpdateAffected(ctx, accountID, snap, change) return nil } // ListRoutes returns a list of routes from account func (am *DefaultAccountManager) ListRoutes(ctx context.Context, accountID, userID string) ([]*route.Route, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Read) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Routes, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -377,25 +374,6 @@ func getPlaceholderIP() netip.Prefix { return netip.PrefixFrom(netip.AddrFrom4([4]byte{192, 0, 2, 0}), 32) } -// areRouteChangesAffectPeers checks if a given route affects peers by determining -// if it has a routing peer, distribution, or peer groups that include peers. -func areRouteChangesAffectPeers(ctx context.Context, transaction store.Store, route *route.Route) (bool, error) { - if route.Peer != "" { - return true, nil - } - - hasPeers, err := anyGroupHasPeersOrResources(ctx, transaction, route.AccountID, route.Groups) - if err != nil { - return false, err - } - - if hasPeers { - return true, nil - } - - return anyGroupHasPeersOrResources(ctx, transaction, route.AccountID, route.PeerGroups) -} - // GetRoutesByPrefixOrDomains return list of routes by account and route prefix func getRoutesByPrefixOrDomains(ctx context.Context, transaction store.Store, accountID string, prefix netip.Prefix, domains domain.List) ([]*route.Route, error) { accountRoutes, err := transaction.GetAccountRoutes(ctx, store.LockingStrengthNone, accountID) diff --git a/management/server/route_test.go b/management/server/route_test.go index 27fe033c8..5ae18c253 100644 --- a/management/server/route_test.go +++ b/management/server/route_test.go @@ -2,10 +2,7 @@ package server import ( "context" - "fmt" - "net" "net/netip" - "sort" "testing" "time" @@ -16,8 +13,13 @@ import ( "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" + ephemeral_manager "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" + "github.com/netbirdio/netbird/management/internals/server/config" "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/cache" "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" + "github.com/netbirdio/netbird/management/server/job" 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" @@ -1286,13 +1288,20 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, *update_channel. Return(&types.ExtraSettings{}, nil) permissionsManager := permissions.NewManager(store) + peersManager := peers.NewManager(store, permissionsManager) ctx := context.Background() + + cacheStore, err := cache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100) + if err != nil { + return nil, nil, err + } + updateManager := update_channel.NewPeersUpdateManager(metrics) requestBuffer := NewAccountRequestBuffer(ctx, store) - networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock()) + networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), ephemeral_manager.NewEphemeralManager(store, peers.NewManager(store, permissionsManager)), &config.Config{}) - am, err := BuildManager(context.Background(), store, networkMapController, nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false) + am, err := BuildManager(context.Background(), nil, store, networkMapController, job.NewJobManager(nil, store, peersManager), nil, "", eventStore, nil, false, MockIntegratedValidator{}, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) if err != nil { return nil, nil, err } @@ -1317,20 +1326,30 @@ func initTestRouteAccount(t *testing.T, am *DefaultAccountManager) (*types.Accou accountID := "testingAcc" domain := "example.com" - account := newAccountWithId(context.Background(), accountID, userID, domain, false) + account := newAccountWithId(context.Background(), accountID, userID, domain, "", "", false) err := am.Store.SaveAccount(context.Background(), account) if err != nil { return nil, err } + v6Prefix, err := netip.ParsePrefix(account.Network.NetV6.String()) + if err != nil { + return nil, err + } + ips := account.GetTakenIPs() - peer1IP, err := types.AllocatePeerIP(account.Network.Net, ips) + peer1IP, err := types.AllocatePeerIP(netip.MustParsePrefix(account.Network.Net.String()), ips) + if err != nil { + return nil, err + } + peer1IPv6, err := types.AllocateRandomPeerIPv6(v6Prefix) if err != nil { return nil, err } peer1 := &nbpeer.Peer{ IP: peer1IP, + IPv6: peer1IPv6, ID: peer1ID, Key: peer1Key, Name: "test-host1@netbird.io", @@ -1351,13 +1370,18 @@ func initTestRouteAccount(t *testing.T, am *DefaultAccountManager) (*types.Accou account.Peers[peer1.ID] = peer1 ips = account.GetTakenIPs() - peer2IP, err := types.AllocatePeerIP(account.Network.Net, ips) + peer2IP, err := types.AllocatePeerIP(netip.MustParsePrefix(account.Network.Net.String()), ips) + if err != nil { + return nil, err + } + peer2IPv6, err := types.AllocateRandomPeerIPv6(v6Prefix) if err != nil { return nil, err } peer2 := &nbpeer.Peer{ IP: peer2IP, + IPv6: peer2IPv6, ID: peer2ID, Key: peer2Key, Name: "test-host2@netbird.io", @@ -1378,13 +1402,18 @@ func initTestRouteAccount(t *testing.T, am *DefaultAccountManager) (*types.Accou account.Peers[peer2.ID] = peer2 ips = account.GetTakenIPs() - peer3IP, err := types.AllocatePeerIP(account.Network.Net, ips) + peer3IP, err := types.AllocatePeerIP(netip.MustParsePrefix(account.Network.Net.String()), ips) + if err != nil { + return nil, err + } + peer3IPv6, err := types.AllocateRandomPeerIPv6(v6Prefix) if err != nil { return nil, err } peer3 := &nbpeer.Peer{ IP: peer3IP, + IPv6: peer3IPv6, ID: peer3ID, Key: peer3Key, Name: "test-host3@netbird.io", @@ -1405,13 +1434,18 @@ func initTestRouteAccount(t *testing.T, am *DefaultAccountManager) (*types.Accou account.Peers[peer3.ID] = peer3 ips = account.GetTakenIPs() - peer4IP, err := types.AllocatePeerIP(account.Network.Net, ips) + peer4IP, err := types.AllocatePeerIP(netip.MustParsePrefix(account.Network.Net.String()), ips) + if err != nil { + return nil, err + } + peer4IPv6, err := types.AllocateRandomPeerIPv6(v6Prefix) if err != nil { return nil, err } peer4 := &nbpeer.Peer{ IP: peer4IP, + IPv6: peer4IPv6, ID: peer4ID, Key: peer4Key, Name: "test-host4@netbird.io", @@ -1432,13 +1466,18 @@ func initTestRouteAccount(t *testing.T, am *DefaultAccountManager) (*types.Accou account.Peers[peer4.ID] = peer4 ips = account.GetTakenIPs() - peer5IP, err := types.AllocatePeerIP(account.Network.Net, ips) + peer5IP, err := types.AllocatePeerIP(netip.MustParsePrefix(account.Network.Net.String()), ips) + if err != nil { + return nil, err + } + peer5IPv6, err := types.AllocateRandomPeerIPv6(v6Prefix) if err != nil { return nil, err } peer5 := &nbpeer.Peer{ IP: peer5IP, + IPv6: peer5IPv6, ID: peer5ID, Key: peer5Key, Name: "test-host5@netbird.io", @@ -1539,7 +1578,8 @@ func TestAccount_getPeersRoutesFirewall(t *testing.T) { Peers: map[string]*nbpeer.Peer{ "peerA": { ID: "peerA", - IP: net.ParseIP("100.65.14.88"), + IP: netip.MustParseAddr("100.65.14.88"), + IPv6: netip.MustParseAddr("fd00::1"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ GoOS: "linux", @@ -1547,18 +1587,21 @@ func TestAccount_getPeersRoutesFirewall(t *testing.T) { }, "peerB": { ID: "peerB", - IP: net.ParseIP(peerBIp), + IP: netip.MustParseAddr(peerBIp), + IPv6: netip.MustParseAddr("fd00::2"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{}, }, "peerC": { ID: "peerC", - IP: net.ParseIP(peerCIp), + IP: netip.MustParseAddr(peerCIp), + IPv6: netip.MustParseAddr("fd00::3"), Status: &nbpeer.PeerStatus{}, }, "peerD": { ID: "peerD", - IP: net.ParseIP("100.65.62.5"), + IP: netip.MustParseAddr("100.65.62.5"), + IPv6: netip.MustParseAddr("fd00::4"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ GoOS: "linux", @@ -1566,7 +1609,8 @@ func TestAccount_getPeersRoutesFirewall(t *testing.T) { }, "peerE": { ID: "peerE", - IP: net.ParseIP("100.65.32.206"), + IP: netip.MustParseAddr("100.65.32.206"), + IPv6: netip.MustParseAddr("fd00::5"), Key: peer1Key, Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ @@ -1575,27 +1619,32 @@ func TestAccount_getPeersRoutesFirewall(t *testing.T) { }, "peerF": { ID: "peerF", - IP: net.ParseIP("100.65.250.202"), + IP: netip.MustParseAddr("100.65.250.202"), + IPv6: netip.MustParseAddr("fd00::6"), Status: &nbpeer.PeerStatus{}, }, "peerG": { ID: "peerG", - IP: net.ParseIP("100.65.13.186"), + IP: netip.MustParseAddr("100.65.13.186"), + IPv6: netip.MustParseAddr("fd00::7"), Status: &nbpeer.PeerStatus{}, }, "peerH": { ID: "peerH", - IP: net.ParseIP(peerHIp), + IP: netip.MustParseAddr(peerHIp), + IPv6: netip.MustParseAddr("fd00::8"), Status: &nbpeer.PeerStatus{}, }, "peerJ": { ID: "peerJ", - IP: net.ParseIP(peerJIp), + IP: netip.MustParseAddr(peerJIp), + IPv6: netip.MustParseAddr("fd00::a"), Status: &nbpeer.PeerStatus{}, }, "peerK": { ID: "peerK", - IP: net.ParseIP(peerKIp), + IP: netip.MustParseAddr(peerKIp), + IPv6: netip.MustParseAddr("fd00::b"), Status: &nbpeer.PeerStatus{}, }, }, @@ -1828,11 +1877,6 @@ func TestAccount_getPeersRoutesFirewall(t *testing.T) { }, } - validatedPeers := make(map[string]struct{}) - for p := range account.Peers { - validatedPeers[p] = struct{}{} - } - t.Run("check applied policies for the route", func(t *testing.T) { route1 := account.Routes["route1"] policies := types.GetAllRoutePoliciesFromGroups(account, route1.AccessControlGroups) @@ -1846,116 +1890,6 @@ func TestAccount_getPeersRoutesFirewall(t *testing.T) { policies = types.GetAllRoutePoliciesFromGroups(account, route3.AccessControlGroups) assert.Len(t, policies, 0) }) - - t.Run("check peer routes firewall rules", func(t *testing.T) { - routesFirewallRules := account.GetPeerRoutesFirewallRules(context.Background(), "peerA", validatedPeers) - assert.Len(t, routesFirewallRules, 4) - - expectedRoutesFirewallRules := []*types.RouteFirewallRule{ - { - SourceRanges: []string{ - fmt.Sprintf(types.AllowedIPsFormat, peerCIp), - fmt.Sprintf(types.AllowedIPsFormat, peerHIp), - fmt.Sprintf(types.AllowedIPsFormat, peerBIp), - }, - Action: "accept", - Destination: "192.168.0.0/16", - Protocol: "all", - Port: 80, - RouteID: "route1:peerA", - }, - { - SourceRanges: []string{ - fmt.Sprintf(types.AllowedIPsFormat, peerCIp), - fmt.Sprintf(types.AllowedIPsFormat, peerHIp), - fmt.Sprintf(types.AllowedIPsFormat, peerBIp), - }, - Action: "accept", - Destination: "192.168.0.0/16", - Protocol: "all", - Port: 320, - RouteID: "route1:peerA", - }, - } - additionalFirewallRule := []*types.RouteFirewallRule{ - { - SourceRanges: []string{ - fmt.Sprintf(types.AllowedIPsFormat, peerJIp), - }, - Action: "accept", - Destination: "192.168.10.0/16", - Protocol: "tcp", - Port: 80, - RouteID: "route4:peerA", - }, - { - SourceRanges: []string{ - fmt.Sprintf(types.AllowedIPsFormat, peerKIp), - }, - Action: "accept", - Destination: "192.168.10.0/16", - Protocol: "all", - RouteID: "route4:peerA", - }, - } - - assert.ElementsMatch(t, orderRuleSourceRanges(routesFirewallRules), orderRuleSourceRanges(append(expectedRoutesFirewallRules, additionalFirewallRule...))) - - // peerD is also the routing peer for route1, should contain same routes firewall rules as peerA - routesFirewallRules = account.GetPeerRoutesFirewallRules(context.Background(), "peerD", validatedPeers) - assert.Len(t, routesFirewallRules, 2) - for _, rule := range expectedRoutesFirewallRules { - rule.RouteID = "route1:peerD" - } - assert.ElementsMatch(t, orderRuleSourceRanges(routesFirewallRules), orderRuleSourceRanges(expectedRoutesFirewallRules)) - - // peerE is a single routing peer for route 2 and route 3 - routesFirewallRules = account.GetPeerRoutesFirewallRules(context.Background(), "peerE", validatedPeers) - assert.Len(t, routesFirewallRules, 3) - - expectedRoutesFirewallRules = []*types.RouteFirewallRule{ - { - SourceRanges: []string{"100.65.250.202/32", "100.65.13.186/32"}, - Action: "accept", - Destination: existingNetwork.String(), - Protocol: "tcp", - PortRange: types.RulePortRange{Start: 80, End: 350}, - RouteID: "route2", - }, - { - SourceRanges: []string{"0.0.0.0/0"}, - Action: "accept", - Destination: "192.0.2.0/32", - Protocol: "all", - Domains: domain.List{"example.com"}, - IsDynamic: true, - RouteID: "route3", - }, - { - SourceRanges: []string{"::/0"}, - Action: "accept", - Destination: "192.0.2.0/32", - Protocol: "all", - Domains: domain.List{"example.com"}, - IsDynamic: true, - RouteID: "route3", - }, - } - assert.ElementsMatch(t, orderRuleSourceRanges(routesFirewallRules), orderRuleSourceRanges(expectedRoutesFirewallRules)) - - // peerC is part of route1 distribution groups but should not receive the routes firewall rules - routesFirewallRules = account.GetPeerRoutesFirewallRules(context.Background(), "peerC", validatedPeers) - assert.Len(t, routesFirewallRules, 0) - }) - -} - -// orderList is a helper function to sort a list of strings -func orderRuleSourceRanges(ruleList []*types.RouteFirewallRule) []*types.RouteFirewallRule { - for _, rule := range ruleList { - sort.Strings(rule.SourceRanges) - } - return ruleList } func TestRouteAccountPeersUpdate(t *testing.T) { @@ -2028,8 +1962,10 @@ func TestRouteAccountPeersUpdate(t *testing.T) { }) - // Creating a route with no routing peer and having peers in groups should update account peers and send peer update + // Creating a route with no routing peer and having peers in groups that don't include peer1 should not send peer1 an update t.Run("creating a route with peers in PeerGroups and Groups", func(t *testing.T) { + drainPeerUpdates(updMsg) + route := route.Route{ ID: "testingRoute2", Network: netip.MustParsePrefix("192.0.2.0/32"), @@ -2045,7 +1981,7 @@ func TestRouteAccountPeersUpdate(t *testing.T) { done := make(chan struct{}) go func() { - peerShouldReceiveUpdate(t, updMsg) + peerShouldNotReceiveUpdate(t, updMsg) close(done) }() @@ -2059,7 +1995,7 @@ func TestRouteAccountPeersUpdate(t *testing.T) { select { case <-done: case <-time.After(time.Second): - t.Error("timeout waiting for peerShouldReceiveUpdate") + t.Error("timeout waiting for peerShouldNotReceiveUpdate") } }) @@ -2095,7 +2031,7 @@ func TestRouteAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -2115,7 +2051,7 @@ func TestRouteAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -2133,7 +2069,7 @@ func TestRouteAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -2173,7 +2109,7 @@ func TestRouteAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -2213,7 +2149,7 @@ func TestRouteAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -2234,84 +2170,101 @@ func TestAccount_GetPeerNetworkResourceFirewallRules(t *testing.T) { Peers: map[string]*nbpeer.Peer{ "peerA": { ID: "peerA", - IP: net.ParseIP("100.65.14.88"), + IP: netip.MustParseAddr("100.65.14.88"), + IPv6: netip.MustParseAddr("fd00::1"), Key: "peerA", Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ - GoOS: "linux", + GoOS: "linux", + Capabilities: []int32{nbpeer.PeerCapabilityIPv6Overlay}, }, }, "peerB": { ID: "peerB", - IP: net.ParseIP(peerBIp), + IP: netip.MustParseAddr(peerBIp), + IPv6: netip.MustParseAddr("fd00::2"), Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{}, }, "peerC": { ID: "peerC", - IP: net.ParseIP(peerCIp), + IP: netip.MustParseAddr(peerCIp), + IPv6: netip.MustParseAddr("fd00::3"), Status: &nbpeer.PeerStatus{}, }, "peerD": { ID: "peerD", - IP: net.ParseIP("100.65.62.5"), + IP: netip.MustParseAddr("100.65.62.5"), + IPv6: netip.MustParseAddr("fd00::4"), Key: "peerD", Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ - GoOS: "linux", + GoOS: "linux", + Capabilities: []int32{nbpeer.PeerCapabilityIPv6Overlay}, }, }, "peerE": { ID: "peerE", - IP: net.ParseIP("100.65.32.206"), + IP: netip.MustParseAddr("100.65.32.206"), + IPv6: netip.MustParseAddr("fd00::5"), Key: "peerE", Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ - GoOS: "linux", + GoOS: "linux", + Capabilities: []int32{nbpeer.PeerCapabilityIPv6Overlay}, }, }, "peerF": { ID: "peerF", - IP: net.ParseIP("100.65.250.202"), + IP: netip.MustParseAddr("100.65.250.202"), + IPv6: netip.MustParseAddr("fd00::6"), Status: &nbpeer.PeerStatus{}, }, "peerG": { ID: "peerG", - IP: net.ParseIP("100.65.13.186"), + IP: netip.MustParseAddr("100.65.13.186"), + IPv6: netip.MustParseAddr("fd00::7"), Status: &nbpeer.PeerStatus{}, }, "peerH": { ID: "peerH", - IP: net.ParseIP(peerHIp), + IP: netip.MustParseAddr(peerHIp), + IPv6: netip.MustParseAddr("fd00::8"), Status: &nbpeer.PeerStatus{}, }, "peerJ": { ID: "peerJ", - IP: net.ParseIP(peerJIp), + IP: netip.MustParseAddr(peerJIp), + IPv6: netip.MustParseAddr("fd00::a"), Status: &nbpeer.PeerStatus{}, }, "peerK": { ID: "peerK", - IP: net.ParseIP(peerKIp), + IP: netip.MustParseAddr(peerKIp), + IPv6: netip.MustParseAddr("fd00::b"), Status: &nbpeer.PeerStatus{}, }, "peerL": { ID: "peerL", - IP: net.ParseIP("100.65.19.186"), + IP: netip.MustParseAddr("100.65.19.186"), + IPv6: netip.MustParseAddr("fd00::d"), Key: "peerL", Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ - GoOS: "linux", + GoOS: "linux", + Capabilities: []int32{nbpeer.PeerCapabilityIPv6Overlay}, }, }, "peerM": { ID: "peerM", - IP: net.ParseIP(peerMIp), + IP: netip.MustParseAddr(peerMIp), + IPv6: netip.MustParseAddr("fd00::e"), Status: &nbpeer.PeerStatus{}, }, "peerN": { ID: "peerN", - IP: net.ParseIP("100.65.20.18"), + IP: netip.MustParseAddr("100.65.20.18"), + IPv6: netip.MustParseAddr("fd00::f"), Key: "peerN", Status: &nbpeer.PeerStatus{}, Meta: nbpeer.PeerSystemMeta{ @@ -2320,7 +2273,8 @@ func TestAccount_GetPeerNetworkResourceFirewallRules(t *testing.T) { }, "peerO": { ID: "peerO", - IP: net.ParseIP(peerOIp), + IP: netip.MustParseAddr(peerOIp), + IPv6: netip.MustParseAddr("fd00::10"), Status: &nbpeer.PeerStatus{}, }, }, @@ -2653,11 +2607,6 @@ func TestAccount_GetPeerNetworkResourceFirewallRules(t *testing.T) { }, } - validatedPeers := make(map[string]struct{}) - for p := range account.Peers { - validatedPeers[p] = struct{}{} - } - t.Run("validate applied policies for different network resources", func(t *testing.T) { // Test case: Resource1 is directly applied to the policy (policyResource1) policies := account.GetPoliciesForNetworkResource("resource1") @@ -2681,127 +2630,4 @@ func TestAccount_GetPeerNetworkResourceFirewallRules(t *testing.T) { policies = account.GetPoliciesForNetworkResource("resource6") assert.Len(t, policies, 1, "resource6 should have exactly 1 policy applied via access control groups") }) - - t.Run("validate routing peer firewall rules for network resources", func(t *testing.T) { - resourcePoliciesMap := account.GetResourcePoliciesMap() - resourceRoutersMap := account.GetResourceRoutersMap() - _, routes, sourcePeers := account.GetNetworkResourcesRoutesToSync(context.Background(), "peerA", resourcePoliciesMap, resourceRoutersMap) - firewallRules := account.GetPeerNetworkResourceFirewallRules(context.Background(), account.Peers["peerA"], validatedPeers, routes, resourcePoliciesMap) - assert.Len(t, firewallRules, 4) - assert.Len(t, sourcePeers, 5) - - expectedFirewallRules := []*types.RouteFirewallRule{ - { - SourceRanges: []string{ - fmt.Sprintf(types.AllowedIPsFormat, peerCIp), - fmt.Sprintf(types.AllowedIPsFormat, peerHIp), - fmt.Sprintf(types.AllowedIPsFormat, peerBIp), - }, - Action: "accept", - Destination: "192.168.0.0/16", - Protocol: "all", - Port: 80, - RouteID: "resource2:peerA", - }, - { - SourceRanges: []string{ - fmt.Sprintf(types.AllowedIPsFormat, peerCIp), - fmt.Sprintf(types.AllowedIPsFormat, peerHIp), - fmt.Sprintf(types.AllowedIPsFormat, peerBIp), - }, - Action: "accept", - Destination: "192.168.0.0/16", - Protocol: "all", - Port: 320, - RouteID: "resource2:peerA", - }, - } - - additionalFirewallRules := []*types.RouteFirewallRule{ - { - SourceRanges: []string{ - fmt.Sprintf(types.AllowedIPsFormat, peerJIp), - }, - Action: "accept", - Destination: "192.0.2.0/32", - Protocol: "tcp", - Port: 80, - Domains: domain.List{"example.com"}, - IsDynamic: true, - RouteID: "resource4:peerA", - }, - { - SourceRanges: []string{ - fmt.Sprintf(types.AllowedIPsFormat, peerKIp), - }, - Action: "accept", - Destination: "192.0.2.0/32", - Protocol: "all", - Domains: domain.List{"example.com"}, - IsDynamic: true, - RouteID: "resource4:peerA", - }, - } - assert.ElementsMatch(t, orderRuleSourceRanges(firewallRules), orderRuleSourceRanges(append(expectedFirewallRules, additionalFirewallRules...))) - - // peerD is also the routing peer for resource2 - _, routes, sourcePeers = account.GetNetworkResourcesRoutesToSync(context.Background(), "peerD", resourcePoliciesMap, resourceRoutersMap) - firewallRules = account.GetPeerNetworkResourceFirewallRules(context.Background(), account.Peers["peerD"], validatedPeers, routes, resourcePoliciesMap) - assert.Len(t, firewallRules, 2) - for _, rule := range expectedFirewallRules { - rule.RouteID = "resource2:peerD" - } - assert.ElementsMatch(t, orderRuleSourceRanges(firewallRules), orderRuleSourceRanges(expectedFirewallRules)) - assert.Len(t, sourcePeers, 3) - - // peerE is a single routing peer for resource1 and resource3 - // PeerE should only receive rules for resource1 since resource3 has no applied policy - _, routes, sourcePeers = account.GetNetworkResourcesRoutesToSync(context.Background(), "peerE", resourcePoliciesMap, resourceRoutersMap) - firewallRules = account.GetPeerNetworkResourceFirewallRules(context.Background(), account.Peers["peerE"], validatedPeers, routes, resourcePoliciesMap) - assert.Len(t, firewallRules, 1) - assert.Len(t, sourcePeers, 2) - - expectedFirewallRules = []*types.RouteFirewallRule{ - { - SourceRanges: []string{"100.65.250.202/32", "100.65.13.186/32"}, - Action: "accept", - Destination: "10.10.10.0/24", - Protocol: "tcp", - PortRange: types.RulePortRange{Start: 80, End: 350}, - RouteID: "resource1:peerE", - }, - } - assert.ElementsMatch(t, orderRuleSourceRanges(firewallRules), orderRuleSourceRanges(expectedFirewallRules)) - - // peerC is part of distribution groups for resource2 but should not receive the firewall rules - firewallRules = account.GetPeerRoutesFirewallRules(context.Background(), "peerC", validatedPeers) - assert.Len(t, firewallRules, 0) - - // peerL is the single routing peer for resource5 - _, routes, sourcePeers = account.GetNetworkResourcesRoutesToSync(context.Background(), "peerL", resourcePoliciesMap, resourceRoutersMap) - assert.Len(t, routes, 1) - firewallRules = account.GetPeerNetworkResourceFirewallRules(context.Background(), account.Peers["peerL"], validatedPeers, routes, resourcePoliciesMap) - assert.Len(t, firewallRules, 1) - assert.Len(t, sourcePeers, 1) - - expectedFirewallRules = []*types.RouteFirewallRule{ - { - SourceRanges: []string{"100.65.29.67/32"}, - Action: "accept", - Destination: "10.12.12.1/32", - Protocol: "tcp", - Port: 8080, - RouteID: "resource5:peerL", - }, - } - assert.ElementsMatch(t, orderRuleSourceRanges(firewallRules), orderRuleSourceRanges(expectedFirewallRules)) - - _, routes, sourcePeers = account.GetNetworkResourcesRoutesToSync(context.Background(), "peerM", resourcePoliciesMap, resourceRoutersMap) - assert.Len(t, routes, 1) - assert.Len(t, sourcePeers, 0) - - _, routes, sourcePeers = account.GetNetworkResourcesRoutesToSync(context.Background(), "peerN", resourcePoliciesMap, resourceRoutersMap) - assert.Len(t, routes, 1) - assert.Len(t, sourcePeers, 2) - }) } diff --git a/management/server/settings/manager.go b/management/server/settings/manager.go index f16b609f8..f84739193 100644 --- a/management/server/settings/manager.go +++ b/management/server/settings/manager.go @@ -5,9 +5,7 @@ package settings import ( "context" "fmt" - "time" - - log "github.com/sirupsen/logrus" + "net/netip" "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/integrations/extra_settings" @@ -25,6 +23,16 @@ type Manager interface { GetSettings(ctx context.Context, accountID string, userID string) (*types.Settings, error) GetExtraSettings(ctx context.Context, accountID string) (*types.ExtraSettings, error) UpdateExtraSettings(ctx context.Context, accountID, userID string, extraSettings *types.ExtraSettings) (bool, error) + // GetEffectiveNetworkRanges returns the actual allocated network ranges (v4 and v6). + // This includes auto-allocated ranges even when no custom override was set. + GetEffectiveNetworkRanges(ctx context.Context, accountID string) (v4, v6 netip.Prefix, err error) +} + +// IdpConfig holds IdP-related configuration that is set at runtime +// and not stored in the database. +type IdpConfig struct { + EmbeddedIdpEnabled bool + LocalAuthDisabled bool } type managerImpl struct { @@ -32,14 +40,16 @@ type managerImpl struct { extraSettingsManager extra_settings.Manager userManager users.Manager permissionsManager permissions.Manager + idpConfig IdpConfig } -func NewManager(store store.Store, userManager users.Manager, extraSettingsManager extra_settings.Manager, permissionsManager permissions.Manager) Manager { +func NewManager(store store.Store, userManager users.Manager, extraSettingsManager extra_settings.Manager, permissionsManager permissions.Manager, idpConfig IdpConfig) Manager { return &managerImpl{ store: store, extraSettingsManager: extraSettingsManager, userManager: userManager, permissionsManager: permissionsManager, + idpConfig: idpConfig, } } @@ -48,13 +58,8 @@ func (m *managerImpl) GetExtraSettingsManager() extra_settings.Manager { } func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string) (*types.Settings, error) { - start := time.Now() - defer func() { - log.WithContext(ctx).Debugf("GetSettings took %s", time.Since(start)) - }() - if userID != activity.SystemInitiator { - ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Read) + ok, _, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Settings, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -82,6 +87,10 @@ func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string) settings.Extra.FlowDnsCollectionEnabled = extraSettings.FlowDnsCollectionEnabled } + // Fill in IdP-related runtime settings + settings.EmbeddedIdpEnabled = m.idpConfig.EmbeddedIdpEnabled + settings.LocalAuthDisabled = m.idpConfig.LocalAuthDisabled + return settings, nil } @@ -110,3 +119,28 @@ func (m *managerImpl) GetExtraSettings(ctx context.Context, accountID string) (* func (m *managerImpl) UpdateExtraSettings(ctx context.Context, accountID, userID string, extraSettings *types.ExtraSettings) (bool, error) { return m.extraSettingsManager.UpdateExtraSettings(ctx, accountID, userID, extraSettings) } + +// GetEffectiveNetworkRanges returns the actual allocated network ranges from the account's network object. +func (m *managerImpl) GetEffectiveNetworkRanges(ctx context.Context, accountID string) (netip.Prefix, netip.Prefix, error) { + network, err := m.store.GetAccountNetwork(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return netip.Prefix{}, netip.Prefix{}, fmt.Errorf("get account network: %w", err) + } + + var v4, v6 netip.Prefix + if network.Net.IP != nil { + addr, ok := netip.AddrFromSlice(network.Net.IP) + if ok { + ones, _ := network.Net.Mask.Size() + v4 = netip.PrefixFrom(addr.Unmap(), ones) + } + } + if network.NetV6.IP != nil { + addr, ok := netip.AddrFromSlice(network.NetV6.IP) + if ok { + ones, _ := network.NetV6.Mask.Size() + v6 = netip.PrefixFrom(addr.Unmap(), ones) + } + } + return v4, v6, nil +} diff --git a/management/server/settings/manager_mock.go b/management/server/settings/manager_mock.go index dc2f2ebfe..4bedb2cf7 100644 --- a/management/server/settings/manager_mock.go +++ b/management/server/settings/manager_mock.go @@ -6,6 +6,7 @@ package settings import ( context "context" + netip "net/netip" reflect "reflect" gomock "github.com/golang/mock/gomock" @@ -94,3 +95,19 @@ func (mr *MockManagerMockRecorder) UpdateExtraSettings(ctx, accountID, userID, e mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateExtraSettings", reflect.TypeOf((*MockManager)(nil).UpdateExtraSettings), ctx, accountID, userID, extraSettings) } + +// GetEffectiveNetworkRanges mocks base method. +func (m *MockManager) GetEffectiveNetworkRanges(ctx context.Context, accountID string) (netip.Prefix, netip.Prefix, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEffectiveNetworkRanges", ctx, accountID) + ret0, _ := ret[0].(netip.Prefix) + ret1, _ := ret[1].(netip.Prefix) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetEffectiveNetworkRanges indicates an expected call of GetEffectiveNetworkRanges. +func (mr *MockManagerMockRecorder) GetEffectiveNetworkRanges(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEffectiveNetworkRanges", reflect.TypeOf((*MockManager)(nil).GetEffectiveNetworkRanges), ctx, accountID) +} diff --git a/management/server/setupkey.go b/management/server/setupkey.go index 8d0509871..cfc44377d 100644 --- a/management/server/setupkey.go +++ b/management/server/setupkey.go @@ -56,7 +56,7 @@ type SetupKeyUpdateOperation struct { func (am *DefaultAccountManager) CreateSetupKey(ctx context.Context, accountID string, keyName string, keyType types.SetupKeyType, expiresIn time.Duration, autoGroups []string, usageLimit int, userID string, ephemeral bool, allowExtraDNSLabels bool) (*types.SetupKey, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Create) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Create) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -105,7 +105,7 @@ func (am *DefaultAccountManager) SaveSetupKey(ctx context.Context, accountID str return nil, status.Errorf(status.InvalidArgument, "provided setup key to update is nil") } - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Update) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Update) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -162,7 +162,7 @@ func (am *DefaultAccountManager) SaveSetupKey(ctx context.Context, accountID str // ListSetupKeys returns a list of all setup keys of the account func (am *DefaultAccountManager) ListSetupKeys(ctx context.Context, accountID, userID string) ([]*types.SetupKey, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Read) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -175,7 +175,7 @@ func (am *DefaultAccountManager) ListSetupKeys(ctx context.Context, accountID, u // GetSetupKey looks up a SetupKey by KeyID, returns NotFound error if not found. func (am *DefaultAccountManager) GetSetupKey(ctx context.Context, accountID, userID, keyID string) (*types.SetupKey, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Read) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -198,7 +198,7 @@ func (am *DefaultAccountManager) GetSetupKey(ctx context.Context, accountID, use // DeleteSetupKey removes the setup key from the account func (am *DefaultAccountManager) DeleteSetupKey(ctx context.Context, accountID, userID, keyID string) error { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Delete) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.SetupKeys, operations.Delete) if err != nil { return status.NewPermissionValidationError(err) } diff --git a/management/server/setupkey_test.go b/management/server/setupkey_test.go index bc361bbd7..2d43ea28b 100644 --- a/management/server/setupkey_test.go +++ b/management/server/setupkey_test.go @@ -15,6 +15,7 @@ import ( "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/auth" ) func TestDefaultAccountManager_SaveSetupKey(t *testing.T) { @@ -24,7 +25,7 @@ func TestDefaultAccountManager_SaveSetupKey(t *testing.T) { } userID := "testingUser" - account, err := manager.GetOrCreateAccountByUser(context.Background(), userID, "") + account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID}) if err != nil { t.Fatal(err) } @@ -99,7 +100,7 @@ func TestDefaultAccountManager_CreateSetupKey(t *testing.T) { } userID := "testingUser" - account, err := manager.GetOrCreateAccountByUser(context.Background(), userID, "") + account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID}) if err != nil { t.Fatal(err) } @@ -204,7 +205,7 @@ func TestGetSetupKeys(t *testing.T) { } userID := "testingUser" - account, err := manager.GetOrCreateAccountByUser(context.Background(), userID, "") + account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID}) if err != nil { t.Fatal(err) } @@ -425,6 +426,10 @@ func TestSetupKeyAccountPeersUpdate(t *testing.T) { updateManager.CloseChannel(context.Background(), peer1.ID) }) + // The setup policy above dispatches affected-peer updates asynchronously; drain + // any in-flight ones so the assertions only observe the setup-key operations. + settleAffectedUpdates(updMsg) + var setupKey *types.SetupKey // Creating setup key should not update account peers and not send peer update @@ -471,7 +476,7 @@ func TestDefaultAccountManager_CreateSetupKey_ShouldNotAllowToUpdateRevokedKey(t } userID := "testingUser" - account, err := manager.GetOrCreateAccountByUser(context.Background(), userID, "") + account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: userID}) if err != nil { t.Fatal(err) } diff --git a/management/server/store/file_store.go b/management/server/store/file_store.go index d5d9337ca..a776a8b42 100644 --- a/management/server/store/file_store.go +++ b/management/server/store/file_store.go @@ -16,6 +16,7 @@ import ( "github.com/netbirdio/netbird/management/server/types" nbutil "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/util" + "github.com/netbirdio/netbird/util/crypt" ) // storeFileName Store file name. Stored in the datadir @@ -263,3 +264,25 @@ func (s *FileStore) Close(ctx context.Context) error { func (s *FileStore) GetStoreEngine() types.Engine { return types.FileStoreEngine } + +// SetFieldEncrypt is a no-op for FileStore as it doesn't support field encryption. +func (s *FileStore) SetFieldEncrypt(_ *crypt.FieldEncrypt) { + // no-op: FileStore stores data in plaintext JSON; encryption is not supported +} + +// GetCustomDomainsCounts is a no-op for FileStore as it doesn't support custom domains. +func (s *FileStore) GetCustomDomainsCounts(_ context.Context) (int64, int64, error) { + return 0, 0, nil +} + +// GetProxyMetrics is a no-op for FileStore — proxy/cluster state isn't +// persisted in the JSON file format. +func (s *FileStore) GetProxyMetrics(_ context.Context) (ProxyMetrics, error) { + return ProxyMetrics{}, nil +} + +// GetAgentNetworkMetrics is a no-op for FileStore — agent-network state isn't +// persisted in the JSON file format. +func (s *FileStore) GetAgentNetworkMetrics(_ context.Context) (AgentNetworkMetrics, error) { + return AgentNetworkMetrics{}, nil +} diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 94b7fc1cc..3ad870ad3 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -7,6 +7,8 @@ import ( "errors" "fmt" "net" + "net/netip" + "net/url" "os" "path/filepath" "runtime" @@ -18,6 +20,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + "github.com/rs/xid" log "github.com/sirupsen/logrus" "gorm.io/driver/mysql" "gorm.io/driver/postgres" @@ -27,7 +30,14 @@ import ( "gorm.io/gorm/logger" nbdns "github.com/netbirdio/netbird/dns" - nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/internals/modules/zones" + "github.com/netbirdio/netbird/management/internals/modules/zones/records" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" networkTypes "github.com/netbirdio/netbird/management/server/networks/types" @@ -38,17 +48,19 @@ import ( "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/status" + "github.com/netbirdio/netbird/util/crypt" ) const ( - storeSqliteFileName = "store.db" - idQueryCondition = "id = ?" - keyQueryCondition = "key = ?" - mysqlKeyQueryCondition = "`key` = ?" - accountAndIDQueryCondition = "account_id = ? and id = ?" - accountAndIDsQueryCondition = "account_id = ? AND id IN ?" - accountIDCondition = "account_id = ?" - peerNotFoundFMT = "peer %s not found" + storeSqliteFileName = "store.db" + idQueryCondition = "id = ?" + keyQueryCondition = "key = ?" + mysqlKeyQueryCondition = "`key` = ?" + accountAndIDQueryCondition = "account_id = ? and id = ?" + accountAndPeerIDQueryCondition = "account_id = ? and peer_id = ?" + accountAndIDsQueryCondition = "account_id = ? AND id IN ?" + accountIDCondition = "account_id = ?" + peerNotFoundFMT = "peer %s not found" pgMaxConnections = 30 pgMinConnections = 1 @@ -58,12 +70,14 @@ const ( // SqlStore represents an account storage backed by a Sql DB persisted to disk type SqlStore struct { - db *gorm.DB - globalAccountLock sync.Mutex - metrics telemetry.AppMetrics - installationPK int - storeEngine types.Engine - pool *pgxpool.Pool + db *gorm.DB + globalAccountLock sync.Mutex + metrics telemetry.AppMetrics + installationPK int + storeEngine types.Engine + pool *pgxpool.Pool + fieldEncrypt *crypt.FieldEncrypt + transactionTimeout time.Duration } type installation struct { @@ -85,6 +99,14 @@ func NewSqlStore(ctx context.Context, db *gorm.DB, storeEngine types.Engine, met conns = runtime.NumCPU() } + transactionTimeout := 5 * time.Minute + if v := os.Getenv("NB_STORE_TRANSACTION_TIMEOUT"); v != "" { + if parsed, err := time.ParseDuration(v); err == nil { + transactionTimeout = parsed + } + } + log.WithContext(ctx).Infof("Setting transaction timeout to %v", transactionTimeout) + if storeEngine == types.SqliteStoreEngine { if err == nil { log.WithContext(ctx).Warnf("setting NB_SQL_MAX_OPEN_CONNS is not supported for sqlite, using default value 1") @@ -102,17 +124,24 @@ func NewSqlStore(ctx context.Context, db *gorm.DB, storeEngine types.Engine, met if skipMigration { log.WithContext(ctx).Infof("skipping migration") - return &SqlStore{db: db, storeEngine: storeEngine, metrics: metrics, installationPK: 1}, nil + return &SqlStore{db: db, storeEngine: storeEngine, metrics: metrics, installationPK: 1, transactionTimeout: transactionTimeout}, nil } if err := migratePreAuto(ctx, db); err != nil { return nil, fmt.Errorf("migratePreAuto: %w", err) } err = db.AutoMigrate( - &types.SetupKey{}, &nbpeer.Peer{}, &types.User{}, &types.PersonalAccessToken{}, &types.Group{}, &types.GroupPeer{}, + &types.SetupKey{}, &nbpeer.Peer{}, &types.User{}, &types.PersonalAccessToken{}, &types.ProxyAccessToken{}, + &types.Group{}, &types.GroupPeer{}, &types.Account{}, &types.Policy{}, &types.PolicyRule{}, &route.Route{}, &nbdns.NameServerGroup{}, &installation{}, &types.ExtraSettings{}, &posture.Checks{}, &nbpeer.NetworkAddress{}, &networkTypes.Network{}, &routerTypes.NetworkRouter{}, &resourceTypes.NetworkResource{}, &types.AccountOnboarding{}, + &types.Job{}, &zones.Zone{}, &records.Record{}, &types.UserInviteRecord{}, &rpservice.Service{}, &rpservice.Target{}, &domain.Domain{}, + &accesslogs.AccessLogEntry{}, &proxy.Proxy{}, + &agentNetworkTypes.Provider{}, &agentNetworkTypes.Policy{}, &agentNetworkTypes.Guardrail{}, &agentNetworkTypes.Settings{}, + &agentNetworkTypes.Consumption{}, &agentNetworkTypes.AccountBudgetRule{}, + &agentNetworkTypes.AgentNetworkAccessLog{}, &agentNetworkTypes.AgentNetworkAccessLogGroup{}, + &agentNetworkTypes.AgentNetworkUsage{}, &agentNetworkTypes.AgentNetworkUsageGroup{}, ) if err != nil { return nil, fmt.Errorf("auto migratePreAuto: %w", err) @@ -121,7 +150,7 @@ func NewSqlStore(ctx context.Context, db *gorm.DB, storeEngine types.Engine, met return nil, fmt.Errorf("migratePostAuto: %w", err) } - return &SqlStore{db: db, storeEngine: storeEngine, metrics: metrics, installationPK: 1}, nil + return &SqlStore{db: db, storeEngine: storeEngine, metrics: metrics, installationPK: 1, transactionTimeout: transactionTimeout}, nil } func GetKeyQueryCondition(s *SqlStore) string { @@ -131,6 +160,96 @@ func GetKeyQueryCondition(s *SqlStore) string { return keyQueryCondition } +// SaveJob persists a job in DB +func (s *SqlStore) CreatePeerJob(ctx context.Context, job *types.Job) error { + result := s.db.Create(job) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to create job in store: %s", result.Error) + return status.Errorf(status.Internal, "failed to create job in store") + } + return nil +} + +func (s *SqlStore) CompletePeerJob(ctx context.Context, job *types.Job) error { + result := s.db. + Model(&types.Job{}). + Where(idQueryCondition, job.ID). + Updates(job) + + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to update job in store: %s", result.Error) + return status.Errorf(status.Internal, "failed to update job in store") + } + return nil +} + +// job was pending for too long and has been cancelled +func (s *SqlStore) MarkPendingJobsAsFailed(ctx context.Context, accountID, peerID, jobID, reason string) error { + now := time.Now().UTC() + result := s.db. + Model(&types.Job{}). + Where(accountAndPeerIDQueryCondition+" AND id = ?"+" AND status = ?", accountID, peerID, jobID, types.JobStatusPending). + Updates(types.Job{ + Status: types.JobStatusFailed, + FailedReason: reason, + CompletedAt: &now, + }) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to mark pending jobs as Failed job in store: %s", result.Error) + return status.Errorf(status.Internal, "failed to mark pending job as Failed in store") + } + return nil +} + +// job was pending for too long and has been cancelled +func (s *SqlStore) MarkAllPendingJobsAsFailed(ctx context.Context, accountID, peerID, reason string) error { + now := time.Now().UTC() + result := s.db. + Model(&types.Job{}). + Where(accountAndPeerIDQueryCondition+" AND status = ?", accountID, peerID, types.JobStatusPending). + Updates(types.Job{ + Status: types.JobStatusFailed, + FailedReason: reason, + CompletedAt: &now, + }) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to mark pending jobs as Failed job in store: %s", result.Error) + return status.Errorf(status.Internal, "failed to mark pending job as Failed in store") + } + return nil +} + +// GetJobByID fetches job by ID +func (s *SqlStore) GetPeerJobByID(ctx context.Context, accountID, jobID string) (*types.Job, error) { + var job types.Job + err := s.db. + Where(accountAndIDQueryCondition, accountID, jobID). + First(&job).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "job %s not found", jobID) + } + if err != nil { + log.WithContext(ctx).Errorf("failed to fetch job from store: %s", err) + return nil, err + } + return &job, nil +} + +// get all jobs +func (s *SqlStore) GetPeerJobs(ctx context.Context, accountID, peerID string) ([]*types.Job, error) { + var jobs []*types.Job + err := s.db. + Where(accountAndPeerIDQueryCondition, accountID, peerID). + Order("created_at DESC"). + Find(&jobs).Error + if err != nil { + log.WithContext(ctx).Errorf("failed to fetch jobs from store: %s", err) + return nil, err + } + + return jobs, nil +} + // AcquireGlobalLock acquires global lock across all the accounts and returns a function that releases the lock func (s *SqlStore) AcquireGlobalLock(ctx context.Context) (unlock func()) { log.WithContext(ctx).Tracef("acquiring global lock") @@ -151,7 +270,8 @@ func (s *SqlStore) AcquireGlobalLock(ctx context.Context) (unlock func()) { return unlock } -// Deprecated: Full account operations are no longer supported +// Deprecated: Full +// account operations are no longer supported func (s *SqlStore) SaveAccount(ctx context.Context, account *types.Account) error { start := time.Now() defer func() { @@ -166,6 +286,13 @@ func (s *SqlStore) SaveAccount(ctx context.Context, account *types.Account) erro generateAccountSQLTypes(account) + // Encrypt sensitive user data before saving + for i := range account.UsersG { + if err := account.UsersG[i].EncryptSensitiveData(s.fieldEncrypt); err != nil { + return fmt.Errorf("encrypt user: %w", err) + } + } + for _, group := range account.GroupsG { group.StoreGroupPeers() } @@ -276,6 +403,11 @@ func (s *SqlStore) DeleteAccount(ctx context.Context, account *types.Account) er return result.Error } + result = tx.Select(clause.Associations).Delete(account.Services, "account_id = ?", account.Id) + if result.Error != nil { + return result.Error + } + result = tx.Select(clause.Associations).Delete(account) if result.Error != nil { return result.Error @@ -288,7 +420,7 @@ func (s *SqlStore) DeleteAccount(ctx context.Context, account *types.Account) er if s.metrics != nil { s.metrics.StoreMetrics().CountPersistenceDuration(took) } - log.WithContext(ctx).Debugf("took %d ms to delete an account to the store", took.Milliseconds()) + log.WithContext(ctx).Tracef("took %d ms to delete an account to the store", took.Milliseconds()) return err } @@ -337,7 +469,6 @@ func (s *SqlStore) SavePeer(ctx context.Context, accountID string, peer *nbpeer. return nil }) - if err != nil { return err } @@ -373,8 +504,9 @@ func (s *SqlStore) SavePeerStatus(ctx context.Context, accountID, peerID string, peerCopy.Status = &peerStatus fieldsToUpdate := []string{ - "peer_status_last_seen", "peer_status_connected", - "peer_status_login_expired", "peer_status_required_approval", + "peer_status_last_seen", "peer_status_session_started_at", + "peer_status_connected", "peer_status_login_expired", + "peer_status_requires_approval", } result := s.db.Model(&nbpeer.Peer{}). Select(fieldsToUpdate). @@ -391,26 +523,79 @@ func (s *SqlStore) SavePeerStatus(ctx context.Context, accountID, peerID string, return nil } -func (s *SqlStore) SavePeerLocation(ctx context.Context, accountID string, peerWithLocation *nbpeer.Peer) error { - // To maintain data integrity, we create a copy of the peer's location to prevent unintended updates to other fields. - var peerCopy nbpeer.Peer - // Since the location field has been migrated to JSON serialization, - // updating the struct ensures the correct data format is inserted into the database. - peerCopy.Location = peerWithLocation.Location - - result := s.db.Model(&nbpeer.Peer{}). - Where(accountAndIDQueryCondition, accountID, peerWithLocation.ID). - Updates(peerCopy) - +// MarkPeerConnectedIfNewerSession is an atomic optimistic-locked update. +// The peer is marked connected with the given session token only when +// the stored SessionStartedAt is strictly smaller than the incoming +// one — equivalently, when no newer stream has already taken ownership. +// The sentinel zero (set on peer creation or after a disconnect) counts +// as the smallest possible token. This is the write half of the +// fencing protocol described on PeerStatus.SessionStartedAt. +// +// The post-write side effects in the caller — geo lookup, +// schedulePeerLoginExpiration, checkAndSchedulePeerInactivityExpiration, +// OnPeersUpdated — all run AFTER this method returns and are deliberately +// outside the database write so they cannot extend the row-lock window. +// +// LastSeen is set to the database's clock (CURRENT_TIMESTAMP) at the +// moment the row is written. The caller never supplies LastSeen because +// the value would otherwise drift under lock contention — a Go-side +// time.Now() taken before the write can land minutes later than the +// actual UPDATE under load, which previously caused real ordering bugs. +func (s *SqlStore) MarkPeerConnectedIfNewerSession(ctx context.Context, accountID, peerID string, newSessionStartedAt int64) (bool, error) { + result := s.db.WithContext(ctx). + Model(&nbpeer.Peer{}). + Where(accountAndIDQueryCondition, accountID, peerID). + Where("peer_status_session_started_at < ?", newSessionStartedAt). + Updates(map[string]any{ + "peer_status_connected": true, + "peer_status_last_seen": gorm.Expr("CURRENT_TIMESTAMP"), + "peer_status_session_started_at": newSessionStartedAt, + "peer_status_login_expired": false, + }) if result.Error != nil { - return status.Errorf(status.Internal, "failed to save peer locations to store: %v", result.Error) + return false, status.Errorf(status.Internal, "mark peer connected: %v", result.Error) + } + return result.RowsAffected > 0, nil +} + +// MarkPeerDisconnectedIfSameSession is an atomic optimistic-locked update. +// The peer is marked disconnected only when the stored SessionStartedAt +// matches the incoming token — meaning the stream that owns the current +// session is the one ending. If a newer stream has already replaced the +// session, the update is skipped. LastSeen is set to CURRENT_TIMESTAMP at +// write time; see MarkPeerConnectedIfNewerSession for the rationale. +// +// A zero sessionStartedAt is rejected at the call site; the underlying +// WHERE on equality would otherwise match every never-connected peer. +func (s *SqlStore) MarkPeerDisconnectedIfSameSession(ctx context.Context, accountID, peerID string, sessionStartedAt int64) (bool, error) { + if sessionStartedAt == 0 { + return false, nil + } + result := s.db.WithContext(ctx). + Model(&nbpeer.Peer{}). + Where(accountAndIDQueryCondition, accountID, peerID). + Where("peer_status_session_started_at = ?", sessionStartedAt). + Updates(map[string]any{ + "peer_status_connected": false, + "peer_status_last_seen": gorm.Expr("CURRENT_TIMESTAMP"), + "peer_status_session_started_at": int64(0), + }) + if result.Error != nil { + return false, status.Errorf(status.Internal, "mark peer disconnected: %v", result.Error) + } + return result.RowsAffected > 0, nil +} + +// ApproveAccountPeers marks all peers that currently require approval in the given account as approved. +func (s *SqlStore) ApproveAccountPeers(ctx context.Context, accountID string) (int, error) { + result := s.db.Model(&nbpeer.Peer{}). + Where("account_id = ? AND peer_status_requires_approval = ?", accountID, true). + Update("peer_status_requires_approval", false) + if result.Error != nil { + return 0, status.Errorf(status.Internal, "failed to approve pending account peers: %v", result.Error) } - if result.RowsAffected == 0 { - return status.Errorf(status.NotFound, peerNotFoundFMT, peerWithLocation.ID) - } - - return nil + return int(result.RowsAffected), nil } // SaveUsers saves the given list of users to the database. @@ -419,7 +604,18 @@ func (s *SqlStore) SaveUsers(ctx context.Context, users []*types.User) error { return nil } - result := s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&users) + usersCopy := make([]*types.User, len(users)) + for i, user := range users { + userCopy := user.Copy() + userCopy.Email = user.Email + userCopy.Name = user.Name + if err := userCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil { + return fmt.Errorf("encrypt user: %w", err) + } + usersCopy[i] = userCopy + } + + result := s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&usersCopy) if result.Error != nil { log.WithContext(ctx).Errorf("failed to save users to store: %s", result.Error) return status.Errorf(status.Internal, "failed to save users to store") @@ -429,7 +625,15 @@ func (s *SqlStore) SaveUsers(ctx context.Context, users []*types.User) error { // SaveUser saves the given user to the database. func (s *SqlStore) SaveUser(ctx context.Context, user *types.User) error { - result := s.db.Save(user) + userCopy := user.Copy() + userCopy.Email = user.Email + userCopy.Name = user.Name + + if err := userCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil { + return fmt.Errorf("encrypt user: %w", err) + } + + result := s.db.Save(userCopy) if result.Error != nil { log.WithContext(ctx).Errorf("failed to save user to store: %s", result.Error) return status.Errorf(status.Internal, "failed to save user to store") @@ -438,6 +642,22 @@ func (s *SqlStore) SaveUser(ctx context.Context, user *types.User) error { } // CreateGroups creates the given list of groups to the database. +// groupUpsertColumns is the explicit allowlist of columns that get updated when +// CreateGroups / UpdateGroups hit a PK conflict. public_id is intentionally +// omitted so a caller passing an entity with the zero value (e.g. an HTTP +// handler-built struct) cannot reset the persisted public_id during an upsert. +// Keep this in sync with the Group schema in management/server/types/group.go. +func groupUpsertColumns() clause.Set { + return clause.AssignmentColumns([]string{ + "account_id", + "name", + "issued", + "integration_ref_id", + "integration_ref_integration_type", + "resources", + }) +} + func (s *SqlStore) CreateGroups(ctx context.Context, accountID string, groups []*types.Group) error { if len(groups) == 0 { return nil @@ -447,8 +667,9 @@ func (s *SqlStore) CreateGroups(ctx context.Context, accountID string, groups [] result := tx. Clauses( clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, Where: clause.Where{Exprs: []clause.Expression{clause.Eq{Column: "groups.account_id", Value: accountID}}}, - UpdateAll: true, + DoUpdates: groupUpsertColumns(), }, ). Omit(clause.Associations). @@ -472,8 +693,9 @@ func (s *SqlStore) UpdateGroups(ctx context.Context, accountID string, groups [] result := tx. Clauses( clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, Where: clause.Where{Exprs: []clause.Expression{clause.Eq{Column: "groups.account_id", Value: accountID}}}, - UpdateAll: true, + DoUpdates: groupUpsertColumns(), }, ). Omit(clause.Associations). @@ -579,20 +801,21 @@ func (s *SqlStore) GetUserByPATID(ctx context.Context, lockStrength LockingStren return nil, status.NewGetUserFromStoreError() } + if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt user: %w", err) + } + return &user, nil } func (s *SqlStore) GetUserByUserID(ctx context.Context, lockStrength LockingStrength, userID string) (*types.User, error) { - ctx, cancel := getDebuggingCtx(ctx) - defer cancel() - tx := s.db if lockStrength != LockingStrengthNone { tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) } var user types.User - result := tx.WithContext(ctx).Take(&user, idQueryCondition, userID) + result := tx.Take(&user, idQueryCondition, userID) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { return nil, status.NewUserNotFoundError(userID) @@ -600,6 +823,10 @@ func (s *SqlStore) GetUserByUserID(ctx context.Context, lockStrength LockingStre return nil, status.NewGetUserFromStoreError() } + if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt user: %w", err) + } + return &user, nil } @@ -636,6 +863,12 @@ func (s *SqlStore) GetAccountUsers(ctx context.Context, lockStrength LockingStre return nil, status.Errorf(status.Internal, "issue getting users from store") } + for _, user := range users { + if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt user: %w", err) + } + } + return users, nil } @@ -654,9 +887,137 @@ func (s *SqlStore) GetAccountOwner(ctx context.Context, lockStrength LockingStre return nil, status.Errorf(status.Internal, "failed to get account owner from the store") } + if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt user: %w", err) + } + return &user, nil } +// SaveUserInvite saves a user invite to the database +func (s *SqlStore) SaveUserInvite(ctx context.Context, invite *types.UserInviteRecord) error { + inviteCopy := invite.Copy() + if err := inviteCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil { + return fmt.Errorf("encrypt invite: %w", err) + } + + result := s.db.Save(inviteCopy) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save user invite to store: %s", result.Error) + return status.Errorf(status.Internal, "failed to save user invite to store") + } + return nil +} + +// GetUserInviteByID retrieves a user invite by its ID and account ID +func (s *SqlStore) GetUserInviteByID(ctx context.Context, lockStrength LockingStrength, accountID, inviteID string) (*types.UserInviteRecord, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var invite types.UserInviteRecord + result := tx.Where("account_id = ?", accountID).Take(&invite, idQueryCondition, inviteID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "user invite not found") + } + log.WithContext(ctx).Errorf("failed to get user invite from store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get user invite from store") + } + + if err := invite.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt invite: %w", err) + } + + return &invite, nil +} + +// GetUserInviteByHashedToken retrieves a user invite by its hashed token +func (s *SqlStore) GetUserInviteByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types.UserInviteRecord, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var invite types.UserInviteRecord + result := tx.Take(&invite, "hashed_token = ?", hashedToken) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "user invite not found") + } + log.WithContext(ctx).Errorf("failed to get user invite from store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get user invite from store") + } + + if err := invite.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt invite: %w", err) + } + + return &invite, nil +} + +// GetUserInviteByEmail retrieves a user invite by account ID and email. +// Since email is encrypted with random IVs, we fetch all invites for the account +// and compare emails in memory after decryption. +func (s *SqlStore) GetUserInviteByEmail(ctx context.Context, lockStrength LockingStrength, accountID, email string) (*types.UserInviteRecord, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var invites []*types.UserInviteRecord + result := tx.Find(&invites, "account_id = ?", accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get user invites from store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get user invites from store") + } + + for _, invite := range invites { + if err := invite.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt invite: %w", err) + } + if strings.EqualFold(invite.Email, email) { + return invite, nil + } + } + + return nil, status.Errorf(status.NotFound, "user invite not found for email") +} + +// GetAccountUserInvites retrieves all user invites for an account +func (s *SqlStore) GetAccountUserInvites(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.UserInviteRecord, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var invites []*types.UserInviteRecord + result := tx.Find(&invites, "account_id = ?", accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get user invites from store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get user invites from store") + } + + for _, invite := range invites { + if err := invite.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt invite: %w", err) + } + } + + return invites, nil +} + +// DeleteUserInvite deletes a user invite by its ID +func (s *SqlStore) DeleteUserInvite(ctx context.Context, inviteID string) error { + result := s.db.Delete(&types.UserInviteRecord{}, idQueryCondition, inviteID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete user invite from store: %s", result.Error) + return status.Errorf(status.Internal, "failed to delete user invite from store") + } + return nil +} + func (s *SqlStore) GetAccountGroups(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.Group, error) { tx := s.db if lockStrength != LockingStrengthNone { @@ -719,6 +1080,50 @@ func (s *SqlStore) GetAccountsCounter(ctx context.Context) (int64, error) { return count, nil } +// GetCustomDomainsCounts returns the total and validated custom domain counts. +func (s *SqlStore) GetCustomDomainsCounts(ctx context.Context) (int64, int64, error) { + var total, validated int64 + if err := s.db.Model(&domain.Domain{}).Count(&total).Error; err != nil { + return 0, 0, err + } + if err := s.db.Model(&domain.Domain{}).Where("validated = ?", true).Count(&validated).Error; err != nil { + return 0, 0, err + } + return total, validated, nil +} + +// GetProxyMetrics aggregates per-cluster + per-proxy counts for the +// self-hosted telemetry payload. Single round-trip via conditional +// aggregations so a large proxies table doesn't fan out into multiple +// queries. +func (s *SqlStore) GetProxyMetrics(ctx context.Context) (ProxyMetrics, error) { + var m ProxyMetrics + activeCutoff := time.Now().Add(-proxyActiveThreshold) + + // COUNT(DISTINCT ... CASE WHEN ...) is portable across sqlite/postgres + // (MySQL too) and keeps the round-trip to one. proxy.StatusConnected + // is the same string the cluster-capability queries use; the active + // window matches the cluster-capability semantics (only proxies + // heartbeating within ~2 * heartbeat interval count as connected). + row := s.db.WithContext(ctx). + Model(&proxy.Proxy{}). + Select( + "COUNT(DISTINCT cluster_address) AS clusters, "+ + "COUNT(DISTINCT CASE WHEN account_id IS NOT NULL THEN cluster_address END) AS clusters_byop, "+ + "COUNT(DISTINCT CASE WHEN private = ? THEN cluster_address END) AS clusters_private, "+ + "COUNT(*) AS proxies, "+ + "COUNT(CASE WHEN status = ? AND last_seen > ? THEN 1 END) AS proxies_connected", + true, + proxy.StatusConnected, + activeCutoff, + ). + Row() + if err := row.Scan(&m.Clusters, &m.ClustersBYOP, &m.ClustersPrivate, &m.Proxies, &m.ProxiesConnected); err != nil { + return ProxyMetrics{}, fmt.Errorf("scan proxy metrics: %w", err) + } + return m, nil +} + func (s *SqlStore) GetAllAccounts(ctx context.Context) (all []*types.Account) { var accounts []types.Account result := s.db.Find(&accounts) @@ -812,6 +1217,8 @@ func (s *SqlStore) getAccountGorm(ctx context.Context, accountID string) (*types Preload("NetworkRouters"). Preload("NetworkResources"). Preload("Onboarding"). + Preload("Services.Targets"). + Preload("Domains"). Take(&account, idQueryCondition, accountID) if result.Error != nil { log.WithContext(ctx).Errorf("error when getting account %s from the store: %s", accountID, result.Error) @@ -848,6 +1255,9 @@ func (s *SqlStore) getAccountGorm(ctx context.Context, accountID string) (*types if user.AutoGroups == nil { user.AutoGroups = []string{} } + if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt user: %w", err) + } account.Users[user.Id] = &user user.PATsG = nil } @@ -885,7 +1295,6 @@ func (s *SqlStore) getAccountGorm(ctx context.Context, accountID string) (*types account.NameServerGroups[ns.ID] = &ns } account.NameServerGroupsG = nil - account.InitOnce() return &account, nil } @@ -896,7 +1305,7 @@ func (s *SqlStore) getAccountPgx(ctx context.Context, accountID string) (*types. } var wg sync.WaitGroup - errChan := make(chan error, 12) + errChan := make(chan error, 16) wg.Add(1) go func() { @@ -986,6 +1395,28 @@ func (s *SqlStore) getAccountPgx(ctx context.Context, accountID string) (*types. account.PostureChecks = checks }() + wg.Add(1) + go func() { + defer wg.Done() + services, err := s.getServices(ctx, accountID) + if err != nil { + errChan <- err + return + } + account.Services = services + }() + + wg.Add(1) + go func() { + defer wg.Done() + domains, err := s.ListCustomDomains(ctx, accountID) + if err != nil { + errChan <- err + return + } + account.Domains = domains + }() + wg.Add(1) go func() { defer wg.Done() @@ -1123,6 +1554,9 @@ func (s *SqlStore) getAccountPgx(ctx context.Context, accountID string) (*types. account.Users = make(map[string]*types.User, len(account.UsersG)) for i := range account.UsersG { user := &account.UsersG[i] + if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt user: %w", err) + } user.PATs = make(map[string]*types.PersonalAccessToken) if userPats, ok := patsByUserID[user.Id]; ok { for j := range userPats { @@ -1179,7 +1613,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc SELECT id, created_by, created_at, domain, domain_category, is_domain_primary_account, -- Embedded Network - network_identifier, network_net, network_dns, network_serial, + network_identifier, network_net, network_net_v6, network_dns, network_serial, -- Embedded DNSSettings dns_settings_disabled_management_groups, -- Embedded Settings @@ -1188,7 +1622,10 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc settings_regular_users_view_blocked, settings_groups_propagation_enabled, settings_jwt_groups_enabled, settings_jwt_groups_claim_name, settings_jwt_allow_groups, settings_routing_peer_dns_resolution_enabled, settings_dns_domain, settings_network_range, - settings_lazy_connection_enabled, + settings_network_range_v6, settings_ipv6_enabled_groups, settings_lazy_connection_enabled, + settings_local_mfa_enabled, settings_metrics_push_enabled, settings_agent_network_only, + settings_dashboard_features, settings_auto_update_version, settings_auto_update_always, + settings_peer_expose_enabled, settings_peer_expose_groups, -- Embedded ExtraSettings settings_extra_peer_approval_enabled, settings_extra_user_approval_required, settings_extra_integrated_validator, settings_extra_integrated_validator_groups @@ -1207,12 +1644,23 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc sRoutingPeerDNSResolutionEnabled sql.NullBool sDNSDomain sql.NullString sNetworkRange sql.NullString + sNetworkRangeV6 sql.NullString + sIPv6EnabledGroups sql.NullString sLazyConnectionEnabled sql.NullBool + sLocalMFAEnabled sql.NullBool + sMetricsPushEnabled sql.NullBool + sAgentNetworkOnly sql.NullBool + sDashboardFeatures sql.NullString + autoUpdateVersion sql.NullString + autoUpdateAlways sql.NullBool + peerExposeEnabled sql.NullBool + peerExposeGroups sql.NullString sExtraPeerApprovalEnabled sql.NullBool sExtraUserApprovalRequired sql.NullBool sExtraIntegratedValidator sql.NullString sExtraIntegratedValidatorGroups sql.NullString networkNet sql.NullString + networkNetV6 sql.NullString dnsSettingsDisabledGroups sql.NullString networkIdentifier sql.NullString networkDns sql.NullString @@ -1221,14 +1669,17 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc ) err := s.pool.QueryRow(ctx, accountQuery, accountID).Scan( &account.Id, &account.CreatedBy, &createdAt, &account.Domain, &account.DomainCategory, &account.IsDomainPrimaryAccount, - &networkIdentifier, &networkNet, &networkDns, &networkSerial, + &networkIdentifier, &networkNet, &networkNetV6, &networkDns, &networkSerial, &dnsSettingsDisabledGroups, &sPeerLoginExpirationEnabled, &sPeerLoginExpiration, &sPeerInactivityExpirationEnabled, &sPeerInactivityExpiration, &sRegularUsersViewBlocked, &sGroupsPropagationEnabled, &sJWTGroupsEnabled, &sJWTGroupsClaimName, &sJWTAllowGroups, &sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange, - &sLazyConnectionEnabled, + &sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled, + &sLocalMFAEnabled, &sMetricsPushEnabled, &sAgentNetworkOnly, + &sDashboardFeatures, &autoUpdateVersion, &autoUpdateAlways, + &peerExposeEnabled, &peerExposeGroups, &sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired, &sExtraIntegratedValidator, &sExtraIntegratedValidatorGroups, ) @@ -1291,12 +1742,47 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc if sLazyConnectionEnabled.Valid { account.Settings.LazyConnectionEnabled = sLazyConnectionEnabled.Bool } + if sLocalMFAEnabled.Valid { + account.Settings.LocalMfaEnabled = sLocalMFAEnabled.Bool + } + if sMetricsPushEnabled.Valid { + account.Settings.MetricsPushEnabled = sMetricsPushEnabled.Bool + } + if sAgentNetworkOnly.Valid { + account.Settings.AgentNetworkOnly = sAgentNetworkOnly.Bool + } + if sDashboardFeatures.Valid && sDashboardFeatures.String != "" { + if err := json.Unmarshal([]byte(sDashboardFeatures.String), &account.Settings.DashboardFeatures); err != nil { + log.WithContext(ctx).Warnf("failed to unmarshal dashboard features for account %s: %v", accountID, err) + } + } if sJWTAllowGroups.Valid { _ = json.Unmarshal([]byte(sJWTAllowGroups.String), &account.Settings.JWTAllowGroups) } if sNetworkRange.Valid { _ = json.Unmarshal([]byte(sNetworkRange.String), &account.Settings.NetworkRange) } + if networkNetV6.Valid { + _ = json.Unmarshal([]byte(networkNetV6.String), &account.Network.NetV6) + } + if sNetworkRangeV6.Valid { + _ = json.Unmarshal([]byte(sNetworkRangeV6.String), &account.Settings.NetworkRangeV6) + } + if sIPv6EnabledGroups.Valid { + _ = json.Unmarshal([]byte(sIPv6EnabledGroups.String), &account.Settings.IPv6EnabledGroups) + } + if autoUpdateAlways.Valid { + account.Settings.AutoUpdateAlways = autoUpdateAlways.Bool + } + if autoUpdateVersion.Valid { + account.Settings.AutoUpdateVersion = autoUpdateVersion.String + } + if peerExposeEnabled.Valid { + account.Settings.PeerExposeEnabled = peerExposeEnabled.Bool + } + if peerExposeGroups.Valid { + _ = json.Unmarshal([]byte(peerExposeGroups.String), &account.Settings.PeerExposeGroups) + } if sExtraPeerApprovalEnabled.Valid { account.Settings.Extra.PeerApprovalEnabled = sExtraPeerApprovalEnabled.Bool @@ -1310,7 +1796,6 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc if sExtraIntegratedValidatorGroups.Valid { _ = json.Unmarshal([]byte(sExtraIntegratedValidatorGroups.String), &account.Settings.Extra.IntegratedValidatorGroups) } - account.InitOnce() return &account, nil } @@ -1379,12 +1864,13 @@ func (s *SqlStore) getSetupKeys(ctx context.Context, accountID string) ([]types. func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Peer, error) { const query = `SELECT id, account_id, key, ip, name, dns_label, user_id, ssh_key, ssh_enabled, login_expiration_enabled, - inactivity_expiration_enabled, last_login, created_at, ephemeral, extra_dns_labels, allow_extra_dns_labels, meta_hostname, - meta_go_os, meta_kernel, meta_core, meta_platform, meta_os, meta_os_version, meta_wt_version, meta_ui_version, + inactivity_expiration_enabled, last_login, created_at, ephemeral, extra_dns_labels, allow_extra_dns_labels, meta_hostname, + meta_go_os, meta_kernel, meta_core, meta_platform, meta_os, meta_os_version, meta_wt_version, meta_ui_version, meta_kernel_version, meta_network_addresses, meta_system_serial_number, meta_system_product_name, meta_system_manufacturer, - meta_environment, meta_flags, meta_files, peer_status_last_seen, peer_status_connected, peer_status_login_expired, - peer_status_requires_approval, location_connection_ip, location_country_code, location_city_name, - location_geo_name_id FROM peers WHERE account_id = $1` + meta_environment, meta_flags, meta_files, meta_capabilities, peer_status_last_seen, peer_status_session_started_at, + peer_status_connected, peer_status_login_expired, peer_status_requires_approval, location_connection_ip, + location_country_code, location_city_name, location_geo_name_id, proxy_meta_embedded, proxy_meta_cluster, ipv6, meta_sync_message_version + FROM peers WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { return nil, err @@ -1397,22 +1883,25 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee lastLogin, createdAt sql.NullTime sshEnabled, loginExpirationEnabled, inactivityExpirationEnabled, ephemeral, allowExtraDNSLabels sql.NullBool peerStatusLastSeen sql.NullTime - peerStatusConnected, peerStatusLoginExpired, peerStatusRequiresApproval sql.NullBool - ip, extraDNS, netAddr, env, flags, files, connIP []byte + peerStatusSessionStartedAt sql.NullInt64 + peerStatusConnected, peerStatusLoginExpired, peerStatusRequiresApproval, proxyEmbedded sql.NullBool + ip, extraDNS, netAddr, env, flags, files, capabilities, connIP, ipv6 []byte metaHostname, metaGoOS, metaKernel, metaCore, metaPlatform sql.NullString metaOS, metaOSVersion, metaWtVersion, metaUIVersion, metaKernelVersion sql.NullString metaSystemSerialNumber, metaSystemProductName, metaSystemManufacturer sql.NullString - locationCountryCode, locationCityName sql.NullString + locationCountryCode, locationCityName, proxyCluster sql.NullString locationGeoNameID sql.NullInt64 + metaSyncMessageVersion sql.NullInt32 ) err := row.Scan(&p.ID, &p.AccountID, &p.Key, &ip, &p.Name, &p.DNSLabel, &p.UserID, &p.SSHKey, &sshEnabled, &loginExpirationEnabled, &inactivityExpirationEnabled, &lastLogin, &createdAt, &ephemeral, &extraDNS, &allowExtraDNSLabels, &metaHostname, &metaGoOS, &metaKernel, &metaCore, &metaPlatform, &metaOS, &metaOSVersion, &metaWtVersion, &metaUIVersion, &metaKernelVersion, &netAddr, - &metaSystemSerialNumber, &metaSystemProductName, &metaSystemManufacturer, &env, &flags, &files, - &peerStatusLastSeen, &peerStatusConnected, &peerStatusLoginExpired, &peerStatusRequiresApproval, &connIP, - &locationCountryCode, &locationCityName, &locationGeoNameID) + &metaSystemSerialNumber, &metaSystemProductName, &metaSystemManufacturer, &env, &flags, &files, &capabilities, + &peerStatusLastSeen, &peerStatusSessionStartedAt, &peerStatusConnected, &peerStatusLoginExpired, + &peerStatusRequiresApproval, &connIP, &locationCountryCode, &locationCityName, &locationGeoNameID, + &proxyEmbedded, &proxyCluster, &ipv6, &metaSyncMessageVersion) if err == nil { if lastLogin.Valid { @@ -1439,6 +1928,9 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee if peerStatusLastSeen.Valid { p.Status.LastSeen = peerStatusLastSeen.Time } + if peerStatusSessionStartedAt.Valid { + p.Status.SessionStartedAt = peerStatusSessionStartedAt.Int64 + } if peerStatusConnected.Valid { p.Status.Connected = peerStatusConnected.Bool } @@ -1496,9 +1988,18 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee if locationGeoNameID.Valid { p.Location.GeoNameID = uint(locationGeoNameID.Int64) } + if proxyEmbedded.Valid { + p.ProxyMeta.Embedded = proxyEmbedded.Bool + } + if proxyCluster.Valid { + p.ProxyMeta.Cluster = proxyCluster.String + } if ip != nil { _ = json.Unmarshal(ip, &p.IP) } + if ipv6 != nil { + _ = json.Unmarshal(ipv6, &p.IPv6) + } if extraDNS != nil { _ = json.Unmarshal(extraDNS, &p.ExtraDNSLabels) } @@ -1514,9 +2015,15 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee if files != nil { _ = json.Unmarshal(files, &p.Meta.Files) } + if capabilities != nil { + _ = json.Unmarshal(capabilities, &p.Meta.Capabilities) + } if connIP != nil { _ = json.Unmarshal(connIP, &p.Location.ConnectionIP) } + if metaSyncMessageVersion.Valid { + p.Meta.SyncMessageVersion = int(metaSyncMessageVersion.Int32) + } } return p, err }) @@ -1527,7 +2034,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee } func (s *SqlStore) getUsers(ctx context.Context, accountID string) ([]types.User, error) { - const query = `SELECT id, account_id, role, is_service_user, non_deletable, service_user_name, auto_groups, blocked, pending_approval, last_login, created_at, issued, integration_ref_id, integration_ref_integration_type FROM users WHERE account_id = $1` + const query = `SELECT id, account_id, role, is_service_user, non_deletable, service_user_name, auto_groups, blocked, pending_approval, last_login, created_at, issued, integration_ref_id, integration_ref_integration_type, email, name FROM users WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { return nil, err @@ -1537,7 +2044,7 @@ func (s *SqlStore) getUsers(ctx context.Context, accountID string) ([]types.User var autoGroups []byte var lastLogin, createdAt sql.NullTime var isServiceUser, nonDeletable, blocked, pendingApproval sql.NullBool - err := row.Scan(&u.Id, &u.AccountID, &u.Role, &isServiceUser, &nonDeletable, &u.ServiceUserName, &autoGroups, &blocked, &pendingApproval, &lastLogin, &createdAt, &u.Issued, &u.IntegrationReference.ID, &u.IntegrationReference.IntegrationType) + err := row.Scan(&u.Id, &u.AccountID, &u.Role, &isServiceUser, &nonDeletable, &u.ServiceUserName, &autoGroups, &blocked, &pendingApproval, &lastLogin, &createdAt, &u.Issued, &u.IntegrationReference.ID, &u.IntegrationReference.IntegrationType, &u.Email, &u.Name) if err == nil { if lastLogin.Valid { u.LastLogin = &lastLogin.Time @@ -1572,7 +2079,7 @@ func (s *SqlStore) getUsers(ctx context.Context, accountID string) ([]types.User } func (s *SqlStore) getGroups(ctx context.Context, accountID string) ([]*types.Group, error) { - const query = `SELECT id, account_id, name, issued, resources, integration_ref_id, integration_ref_integration_type FROM groups WHERE account_id = $1` + const query = `SELECT id, account_id, public_id, name, issued, resources, integration_ref_id, integration_ref_integration_type FROM groups WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { return nil, err @@ -1582,7 +2089,7 @@ func (s *SqlStore) getGroups(ctx context.Context, accountID string) ([]*types.Gr var resources []byte var refID sql.NullInt64 var refType sql.NullString - err := row.Scan(&g.ID, &g.AccountID, &g.Name, &g.Issued, &resources, &refID, &refType) + err := row.Scan(&g.ID, &g.AccountID, &g.PublicID, &g.Name, &g.Issued, &resources, &refID, &refType) if err == nil { if refID.Valid { g.IntegrationReference.ID = int(refID.Int64) @@ -1607,7 +2114,7 @@ func (s *SqlStore) getGroups(ctx context.Context, accountID string) ([]*types.Gr } func (s *SqlStore) getPolicies(ctx context.Context, accountID string) ([]*types.Policy, error) { - const query = `SELECT id, account_id, name, description, enabled, source_posture_checks FROM policies WHERE account_id = $1` + const query = `SELECT id, account_id, public_id, name, description, enabled, source_posture_checks FROM policies WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { return nil, err @@ -1616,7 +2123,7 @@ func (s *SqlStore) getPolicies(ctx context.Context, accountID string) ([]*types. var p types.Policy var checks []byte var enabled sql.NullBool - err := row.Scan(&p.ID, &p.AccountID, &p.Name, &p.Description, &enabled, &checks) + err := row.Scan(&p.ID, &p.AccountID, &p.PublicID, &p.Name, &p.Description, &enabled, &checks) if err == nil { if enabled.Valid { p.Enabled = enabled.Bool @@ -1634,7 +2141,7 @@ func (s *SqlStore) getPolicies(ctx context.Context, accountID string) ([]*types. } func (s *SqlStore) getRoutes(ctx context.Context, accountID string) ([]route.Route, error) { - const query = `SELECT id, account_id, network, domains, keep_route, net_id, description, peer, peer_groups, network_type, masquerade, metric, enabled, groups, access_control_groups, skip_auto_apply FROM routes WHERE account_id = $1` + const query = `SELECT 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 FROM routes WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { return nil, err @@ -1644,7 +2151,7 @@ func (s *SqlStore) getRoutes(ctx context.Context, accountID string) ([]route.Rou var network, domains, peerGroups, groups, accessGroups []byte var keepRoute, masquerade, enabled, skipAutoApply sql.NullBool var metric sql.NullInt64 - err := row.Scan(&r.ID, &r.AccountID, &network, &domains, &keepRoute, &r.NetID, &r.Description, &r.Peer, &peerGroups, &r.NetworkType, &masquerade, &metric, &enabled, &groups, &accessGroups, &skipAutoApply) + err := row.Scan(&r.ID, &r.AccountID, &r.PublicID, &network, &domains, &keepRoute, &r.NetID, &r.Description, &r.Peer, &peerGroups, &r.NetworkType, &masquerade, &metric, &enabled, &groups, &accessGroups, &skipAutoApply) if err == nil { if keepRoute.Valid { r.KeepRoute = keepRoute.Bool @@ -1686,7 +2193,7 @@ func (s *SqlStore) getRoutes(ctx context.Context, accountID string) ([]route.Rou } func (s *SqlStore) getNameServerGroups(ctx context.Context, accountID string) ([]nbdns.NameServerGroup, error) { - const query = `SELECT id, account_id, name, description, name_servers, groups, "primary", domains, enabled, search_domains_enabled FROM name_server_groups WHERE account_id = $1` + const query = `SELECT id, account_id, public_id, name, description, name_servers, groups, "primary", domains, enabled, search_domains_enabled FROM name_server_groups WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { return nil, err @@ -1695,7 +2202,7 @@ func (s *SqlStore) getNameServerGroups(ctx context.Context, accountID string) ([ var n nbdns.NameServerGroup var ns, groups, domains []byte var primary, enabled, searchDomainsEnabled sql.NullBool - err := row.Scan(&n.ID, &n.AccountID, &n.Name, &n.Description, &ns, &groups, &primary, &domains, &enabled, &searchDomainsEnabled) + err := row.Scan(&n.ID, &n.AccountID, &n.PublicID, &n.Name, &n.Description, &ns, &groups, &primary, &domains, &enabled, &searchDomainsEnabled) if err == nil { if primary.Valid { n.Primary = primary.Bool @@ -1731,7 +2238,7 @@ func (s *SqlStore) getNameServerGroups(ctx context.Context, accountID string) ([ } func (s *SqlStore) getPostureChecks(ctx context.Context, accountID string) ([]*posture.Checks, error) { - const query = `SELECT id, account_id, name, description, checks FROM posture_checks WHERE account_id = $1` + const query = `SELECT id, account_id, public_id, name, description, checks FROM posture_checks WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { return nil, err @@ -1739,7 +2246,7 @@ func (s *SqlStore) getPostureChecks(ctx context.Context, accountID string) ([]*p checks, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (*posture.Checks, error) { var c posture.Checks var checksDef []byte - err := row.Scan(&c.ID, &c.AccountID, &c.Name, &c.Description, &checksDef) + err := row.Scan(&c.ID, &c.AccountID, &c.PublicID, &c.Name, &c.Description, &checksDef) if err == nil && checksDef != nil { _ = json.Unmarshal(checksDef, &c.Checks) } @@ -1751,8 +2258,175 @@ func (s *SqlStore) getPostureChecks(ctx context.Context, accountID string) ([]*p return checks, nil } +func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpservice.Service, error) { + const serviceQuery = `SELECT id, account_id, name, domain, enabled, auth, + meta_created_at, meta_certificate_issued_at, meta_status, proxy_cluster, + pass_host_header, rewrite_redirects, session_private_key, session_public_key, + mode, listen_port, port_auto_assigned, source, source_peer, terminated, + private, access_groups + FROM services WHERE account_id = $1` + + const targetsQuery = `SELECT id, account_id, service_id, path, host, port, protocol, + target_id, target_type, enabled + FROM targets WHERE service_id = ANY($1)` + + serviceRows, err := s.pool.Query(ctx, serviceQuery, accountID) + if err != nil { + return nil, err + } + + services, err := pgx.CollectRows(serviceRows, func(row pgx.CollectableRow) (*rpservice.Service, error) { + var s rpservice.Service + var auth []byte + var accessGroups []byte + var createdAt, certIssuedAt sql.NullTime + var status, proxyCluster, sessionPrivateKey, sessionPublicKey sql.NullString + var mode, source, sourcePeer sql.NullString + var terminated, portAutoAssigned, private sql.NullBool + var listenPort sql.NullInt64 + err := row.Scan( + &s.ID, + &s.AccountID, + &s.Name, + &s.Domain, + &s.Enabled, + &auth, + &createdAt, + &certIssuedAt, + &status, + &proxyCluster, + &s.PassHostHeader, + &s.RewriteRedirects, + &sessionPrivateKey, + &sessionPublicKey, + &mode, + &listenPort, + &portAutoAssigned, + &source, + &sourcePeer, + &terminated, + &private, + &accessGroups, + ) + if err != nil { + return nil, err + } + + if auth != nil { + if err := json.Unmarshal(auth, &s.Auth); err != nil { + return nil, err + } + } + + if len(accessGroups) > 0 { + if err := json.Unmarshal(accessGroups, &s.AccessGroups); err != nil { + return nil, fmt.Errorf("unmarshal access_groups: %w", err) + } + } + + if private.Valid { + s.Private = private.Bool + } + + s.Meta = rpservice.Meta{} + if createdAt.Valid { + s.Meta.CreatedAt = createdAt.Time + } + if certIssuedAt.Valid { + t := certIssuedAt.Time + s.Meta.CertificateIssuedAt = &t + } + if status.Valid { + s.Meta.Status = status.String + } + if proxyCluster.Valid { + s.ProxyCluster = proxyCluster.String + } + if sessionPrivateKey.Valid { + s.SessionPrivateKey = sessionPrivateKey.String + } + if sessionPublicKey.Valid { + s.SessionPublicKey = sessionPublicKey.String + } + if mode.Valid { + s.Mode = mode.String + } + if source.Valid { + s.Source = source.String + } + if sourcePeer.Valid { + s.SourcePeer = sourcePeer.String + } + if terminated.Valid { + s.Terminated = terminated.Bool + } + if portAutoAssigned.Valid { + s.PortAutoAssigned = portAutoAssigned.Bool + } + if listenPort.Valid { + s.ListenPort = uint16(listenPort.Int64) + } + s.Targets = []*rpservice.Target{} + return &s, nil + }) + if err != nil { + return nil, err + } + + if len(services) == 0 { + return services, nil + } + + serviceIDs := make([]string, len(services)) + serviceMap := make(map[string]*rpservice.Service) + for i, s := range services { + serviceIDs[i] = s.ID + serviceMap[s.ID] = s + } + + targetRows, err := s.pool.Query(ctx, targetsQuery, serviceIDs) + if err != nil { + return nil, err + } + + targets, err := pgx.CollectRows(targetRows, func(row pgx.CollectableRow) (*rpservice.Target, error) { + var t rpservice.Target + var path sql.NullString + err := row.Scan( + &t.ID, + &t.AccountID, + &t.ServiceID, + &path, + &t.Host, + &t.Port, + &t.Protocol, + &t.TargetId, + &t.TargetType, + &t.Enabled, + ) + if err != nil { + return nil, err + } + if path.Valid { + t.Path = &path.String + } + return &t, nil + }) + if err != nil { + return nil, err + } + + for _, target := range targets { + if service, ok := serviceMap[target.ServiceID]; ok { + service.Targets = append(service.Targets, target) + } + } + + return services, nil +} + func (s *SqlStore) getNetworks(ctx context.Context, accountID string) ([]*networkTypes.Network, error) { - const query = `SELECT id, account_id, name, description FROM networks WHERE account_id = $1` + const query = `SELECT id, account_id, public_id, name, description FROM networks WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { return nil, err @@ -1769,7 +2443,7 @@ func (s *SqlStore) getNetworks(ctx context.Context, accountID string) ([]*networ } func (s *SqlStore) getNetworkRouters(ctx context.Context, accountID string) ([]*routerTypes.NetworkRouter, error) { - const query = `SELECT id, network_id, account_id, peer, peer_groups, masquerade, metric, enabled FROM network_routers WHERE account_id = $1` + const query = `SELECT id, network_id, account_id, public_id, peer, peer_groups, masquerade, metric, enabled FROM network_routers WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { return nil, err @@ -1779,7 +2453,7 @@ func (s *SqlStore) getNetworkRouters(ctx context.Context, accountID string) ([]* var peerGroups []byte var masquerade, enabled sql.NullBool var metric sql.NullInt64 - err := row.Scan(&r.ID, &r.NetworkID, &r.AccountID, &r.Peer, &peerGroups, &masquerade, &metric, &enabled) + err := row.Scan(&r.ID, &r.NetworkID, &r.AccountID, &r.PublicID, &r.Peer, &peerGroups, &masquerade, &metric, &enabled) if err == nil { if masquerade.Valid { r.Masquerade = masquerade.Bool @@ -1807,7 +2481,7 @@ func (s *SqlStore) getNetworkRouters(ctx context.Context, accountID string) ([]* } func (s *SqlStore) getNetworkResources(ctx context.Context, accountID string) ([]*resourceTypes.NetworkResource, error) { - const query = `SELECT id, network_id, account_id, name, description, type, domain, prefix, enabled FROM network_resources WHERE account_id = $1` + const query = `SELECT id, network_id, account_id, public_id, name, description, type, domain, prefix, enabled FROM network_resources WHERE account_id = $1` rows, err := s.pool.Query(ctx, query, accountID) if err != nil { return nil, err @@ -1816,7 +2490,7 @@ func (s *SqlStore) getNetworkResources(ctx context.Context, accountID string) ([ var r resourceTypes.NetworkResource var prefix []byte var enabled sql.NullBool - err := row.Scan(&r.ID, &r.NetworkID, &r.AccountID, &r.Name, &r.Description, &r.Type, &r.Domain, &prefix, &enabled) + err := row.Scan(&r.ID, &r.NetworkID, &r.AccountID, &r.PublicID, &r.Name, &r.Description, &r.Type, &r.Domain, &prefix, &enabled) if err == nil { if enabled.Valid { r.Enabled = enabled.Bool @@ -1902,16 +2576,17 @@ func (s *SqlStore) getPolicyRules(ctx context.Context, policyIDs []string) ([]*t if len(policyIDs) == 0 { return nil, nil } - const query = `SELECT id, policy_id, name, description, enabled, action, destinations, destination_resource, sources, source_resource, bidirectional, protocol, ports, port_ranges FROM policy_rules WHERE policy_id = ANY($1)` + const query = `SELECT id, policy_id, name, description, enabled, action, destinations, destination_resource, sources, source_resource, bidirectional, protocol, ports, port_ranges, authorized_groups, authorized_user FROM policy_rules WHERE policy_id = ANY($1)` rows, err := s.pool.Query(ctx, query, policyIDs) if err != nil { return nil, err } rules, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (*types.PolicyRule, error) { var r types.PolicyRule - var dest, destRes, sources, sourceRes, ports, portRanges []byte + var dest, destRes, sources, sourceRes, ports, portRanges, authorizedGroups []byte var enabled, bidirectional sql.NullBool - err := row.Scan(&r.ID, &r.PolicyID, &r.Name, &r.Description, &enabled, &r.Action, &dest, &destRes, &sources, &sourceRes, &bidirectional, &r.Protocol, &ports, &portRanges) + var authorizedUser sql.NullString + err := row.Scan(&r.ID, &r.PolicyID, &r.Name, &r.Description, &enabled, &r.Action, &dest, &destRes, &sources, &sourceRes, &bidirectional, &r.Protocol, &ports, &portRanges, &authorizedGroups, &authorizedUser) if err == nil { if enabled.Valid { r.Enabled = enabled.Bool @@ -1937,6 +2612,12 @@ func (s *SqlStore) getPolicyRules(ctx context.Context, policyIDs []string) ([]*t if portRanges != nil { _ = json.Unmarshal(portRanges, &r.PortRanges) } + if authorizedGroups != nil { + _ = json.Unmarshal(authorizedGroups, &r.AuthorizedGroups) + } + if authorizedUser.Valid { + r.AuthorizedUser = authorizedUser.String + } } return &r, err }) @@ -2097,7 +2778,7 @@ func (s *SqlStore) GetAccountIDBySetupKey(ctx context.Context, setupKey string) return accountID, nil } -func (s *SqlStore) GetTakenIPs(ctx context.Context, lockStrength LockingStrength, accountID string) ([]net.IP, error) { +func (s *SqlStore) GetTakenIPs(ctx context.Context, lockStrength LockingStrength, accountID string) ([]netip.Addr, error) { tx := s.db if lockStrength != LockingStrengthNone { tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) @@ -2105,7 +2786,6 @@ func (s *SqlStore) GetTakenIPs(ctx context.Context, lockStrength LockingStrength var ipJSONStrings []string - // Fetch the IP addresses as JSON strings result := tx.Model(&nbpeer.Peer{}). Where("account_id = ?", accountID). Pluck("ip", &ipJSONStrings) @@ -2116,14 +2796,13 @@ func (s *SqlStore) GetTakenIPs(ctx context.Context, lockStrength LockingStrength return nil, status.Errorf(status.Internal, "issue getting IPs from store: %s", result.Error) } - // Convert the JSON strings to net.IP objects - ips := make([]net.IP, len(ipJSONStrings)) + ips := make([]netip.Addr, len(ipJSONStrings)) for i, ipJSON := range ipJSONStrings { - var ip net.IP + var ip netip.Addr if err := json.Unmarshal([]byte(ipJSON), &ip); err != nil { return nil, status.Errorf(status.Internal, "issue parsing IP JSON from store") } - ips[i] = ip + ips[i] = ip.Unmap() } return ips, nil @@ -2152,16 +2831,13 @@ func (s *SqlStore) GetPeerLabelsInAccount(ctx context.Context, lockStrength Lock } func (s *SqlStore) GetAccountNetwork(ctx context.Context, lockStrength LockingStrength, accountID string) (*types.Network, error) { - ctx, cancel := getDebuggingCtx(ctx) - defer cancel() - tx := s.db if lockStrength != LockingStrengthNone { tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) } var accountNetwork types.AccountNetwork - if err := tx.WithContext(ctx).Model(&types.Account{}).Where(idQueryCondition, accountID).Take(&accountNetwork).Error; err != nil { + if err := tx.Model(&types.Account{}).Where(idQueryCondition, accountID).Take(&accountNetwork).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.NewAccountNotFoundError(accountID) } @@ -2171,16 +2847,13 @@ func (s *SqlStore) GetAccountNetwork(ctx context.Context, lockStrength LockingSt } func (s *SqlStore) GetPeerByPeerPubKey(ctx context.Context, lockStrength LockingStrength, peerKey string) (*nbpeer.Peer, error) { - ctx, cancel := getDebuggingCtx(ctx) - defer cancel() - tx := s.db if lockStrength != LockingStrengthNone { tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) } var peer nbpeer.Peer - result := tx.WithContext(ctx).Take(&peer, GetKeyQueryCondition(s), peerKey) + result := tx.Take(&peer, GetKeyQueryCondition(s), peerKey) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { @@ -2229,11 +2902,8 @@ func (s *SqlStore) GetAccountCreatedBy(ctx context.Context, lockStrength Locking // SaveUserLastLogin stores the last login time for a user in DB. func (s *SqlStore) SaveUserLastLogin(ctx context.Context, accountID, userID string, lastLogin time.Time) error { - ctx, cancel := getDebuggingCtx(ctx) - defer cancel() - var user types.User - result := s.db.WithContext(ctx).Take(&user, accountAndIDQueryCondition, accountID, userID) + result := s.db.Take(&user, accountAndIDQueryCondition, accountID, userID) if result.Error != nil { if errors.Is(result.Error, gorm.ErrRecordNotFound) { return status.NewUserNotFoundError(userID) @@ -2280,14 +2950,43 @@ func (s *SqlStore) GetStoreEngine() types.Engine { // NewSqliteStore creates a new SQLite store. func NewSqliteStore(ctx context.Context, dataDir string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) { - storeStr := fmt.Sprintf("%s?cache=shared", storeSqliteFileName) - if runtime.GOOS == "windows" { - // Vo avoid `The process cannot access the file because it is being used by another process` on Windows - storeStr = storeSqliteFileName + storeFile := storeSqliteFileName + if envFile, ok := os.LookupEnv("NB_STORE_ENGINE_SQLITE_FILE"); ok && envFile != "" { + storeFile = envFile } - file := filepath.Join(dataDir, storeStr) - db, err := gorm.Open(sqlite.Open(file), getGormConfig()) + // Separate file path from any SQLite URI query parameters (e.g., "store.db?mode=rwc") + filePath, query, hasQuery := strings.Cut(storeFile, "?") + + connStr := filePath + if !filepath.IsAbs(filePath) { + connStr = filepath.Join(dataDir, filePath) + } + + // Compose query parameters. User-provided ?_busy_timeout (or its mattn alias + // ?_timeout) overrides our default; otherwise inject 30s so SQLite waits at + // most that long on a lock instead of blocking the only Go-side connection. + // mattn/go-sqlite3 applies PRAGMA from the DSN on every fresh connection, so + // the value survives ConnMaxIdleTime/ConnMaxLifetime recycling. cache=shared + // stays the default on non-Windows for the same reason as before. + parsed, _ := url.ParseQuery(query) + var defaults []string + if parsed.Get("_busy_timeout") == "" && parsed.Get("_timeout") == "" { + defaults = append(defaults, "_busy_timeout=30000") + } + if !hasQuery && runtime.GOOS != "windows" { + // To avoid `The process cannot access the file because it is being used by another process` on Windows + defaults = append(defaults, "cache=shared") + } + parts := defaults + if hasQuery { + parts = append(parts, query) + } + if len(parts) > 0 { + connStr += "?" + strings.Join(parts, "&") + } + + db, err := gorm.Open(sqlite.Open(connStr), getGormConfig()) if err != nil { return nil, err } @@ -2357,7 +3056,7 @@ func getGormConfig() *gorm.Config { // newPostgresStore initializes a new Postgres store. func newPostgresStore(ctx context.Context, metrics telemetry.AppMetrics, skipMigration bool) (Store, error) { - dsn, ok := os.LookupEnv(postgresDsnEnv) + dsn, ok := lookupDSNEnv(postgresDsnEnv, postgresDsnEnvLegacy) if !ok { return nil, fmt.Errorf("%s is not set", postgresDsnEnv) } @@ -2366,7 +3065,7 @@ func newPostgresStore(ctx context.Context, metrics telemetry.AppMetrics, skipMig // newMysqlStore initializes a new MySQL store. func newMysqlStore(ctx context.Context, metrics telemetry.AppMetrics, skipMigration bool) (Store, error) { - dsn, ok := os.LookupEnv(mysqlDsnEnv) + dsn, ok := lookupDSNEnv(mysqlDsnEnv, mysqlDsnEnvLegacy) if !ok { return nil, fmt.Errorf("%s is not set", mysqlDsnEnv) } @@ -2491,16 +3190,13 @@ func NewMysqlStoreFromSqlStore(ctx context.Context, sqliteStore *SqlStore, dsn s } func (s *SqlStore) GetSetupKeyBySecret(ctx context.Context, lockStrength LockingStrength, key string) (*types.SetupKey, error) { - ctx, cancel := getDebuggingCtx(ctx) - defer cancel() - tx := s.db if lockStrength != LockingStrengthNone { tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) } var setupKey types.SetupKey - result := tx.WithContext(ctx). + result := tx. Take(&setupKey, GetKeyQueryCondition(s), key) if result.Error != nil { @@ -2514,10 +3210,7 @@ func (s *SqlStore) GetSetupKeyBySecret(ctx context.Context, lockStrength Locking } func (s *SqlStore) IncrementSetupKeyUsage(ctx context.Context, setupKeyID string) error { - ctx, cancel := getDebuggingCtx(ctx) - defer cancel() - - result := s.db.WithContext(ctx).Model(&types.SetupKey{}). + result := s.db.Model(&types.SetupKey{}). Where(idQueryCondition, setupKeyID). Updates(map[string]interface{}{ "used_times": gorm.Expr("used_times + 1"), @@ -2537,11 +3230,8 @@ func (s *SqlStore) IncrementSetupKeyUsage(ctx context.Context, setupKeyID string // AddPeerToAllGroup adds a peer to the 'All' group. Method always needs to run in a transaction func (s *SqlStore) AddPeerToAllGroup(ctx context.Context, accountID string, peerID string) error { - ctx, cancel := getDebuggingCtx(ctx) - defer cancel() - var groupID string - _ = s.db.WithContext(ctx).Model(types.Group{}). + _ = s.db.Model(types.Group{}). Select("id"). Where("account_id = ? AND name = ?", accountID, "All"). Limit(1). @@ -2559,7 +3249,6 @@ func (s *SqlStore) AddPeerToAllGroup(ctx context.Context, accountID string, peer GroupID: groupID, PeerID: peerID, }).Error - if err != nil { return status.Errorf(status.Internal, "error adding peer to group 'All': %v", err) } @@ -2569,9 +3258,6 @@ func (s *SqlStore) AddPeerToAllGroup(ctx context.Context, accountID string, peer // AddPeerToGroup adds a peer to a group func (s *SqlStore) AddPeerToGroup(ctx context.Context, accountID, peerID, groupID string) error { - ctx, cancel := getDebuggingCtx(ctx) - defer cancel() - peer := &types.GroupPeer{ AccountID: accountID, GroupID: groupID, @@ -2582,7 +3268,6 @@ func (s *SqlStore) AddPeerToGroup(ctx context.Context, accountID, peerID, groupI Columns: []clause.Column{{Name: "group_id"}, {Name: "peer_id"}}, DoNothing: true, }).Create(peer).Error - if err != nil { log.WithContext(ctx).Errorf("failed to add peer %s to group %s for account %s: %v", peerID, groupID, accountID, err) return status.Errorf(status.Internal, "failed to add peer to group") @@ -2595,7 +3280,6 @@ func (s *SqlStore) AddPeerToGroup(ctx context.Context, accountID, peerID, groupI func (s *SqlStore) RemovePeerFromGroup(ctx context.Context, peerID string, groupID string) error { err := s.db. Delete(&types.GroupPeer{}, "group_id = ? AND peer_id = ?", groupID, peerID).Error - if err != nil { log.WithContext(ctx).Errorf("failed to remove peer %s from group %s: %v", peerID, groupID, err) return status.Errorf(status.Internal, "failed to remove peer from group") @@ -2608,7 +3292,6 @@ func (s *SqlStore) RemovePeerFromGroup(ctx context.Context, peerID string, group func (s *SqlStore) RemovePeerFromAllGroups(ctx context.Context, peerID string) error { err := s.db. Delete(&types.GroupPeer{}, "peer_id = ?", peerID).Error - if err != nil { log.WithContext(ctx).Errorf("failed to remove peer %s from all groups: %v", peerID, err) return status.Errorf(status.Internal, "failed to remove peer from all groups") @@ -2732,7 +3415,7 @@ func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStre query = query.Where("name LIKE ?", "%"+nameFilter+"%") } if ipFilter != "" { - query = query.Where("ip LIKE ?", "%"+ipFilter+"%") + query = query.Where("ip LIKE ? OR ipv6 LIKE ?", "%"+ipFilter+"%", "%"+ipFilter+"%") } if err := query.Find(&peers).Error; err != nil { @@ -2768,10 +3451,7 @@ func (s *SqlStore) GetUserPeers(ctx context.Context, lockStrength LockingStrengt } func (s *SqlStore) AddPeerToAccount(ctx context.Context, peer *nbpeer.Peer) error { - ctx, cancel := getDebuggingCtx(ctx) - defer cancel() - - if err := s.db.WithContext(ctx).Create(peer).Error; err != nil { + if err := s.db.Create(peer).Error; err != nil { return status.Errorf(status.Internal, "issue adding peer to account: %s", err) } @@ -2829,7 +3509,7 @@ func (s *SqlStore) GetAccountPeersWithExpiration(ctx context.Context, lockStreng var peers []*nbpeer.Peer result := tx. - Where("login_expiration_enabled = ? AND user_id IS NOT NULL AND user_id != ''", true). + Where("login_expiration_enabled = ? AND peer_status_login_expired != ? AND user_id IS NOT NULL AND user_id != ''", true, true). Find(&peers, accountIDCondition, accountID) if err := result.Error; err != nil { log.WithContext(ctx).Errorf("failed to get peers with expiration from the store: %s", result.Error) @@ -2897,10 +3577,7 @@ func (s *SqlStore) DeletePeer(ctx context.Context, accountID string, peerID stri } func (s *SqlStore) IncrementNetworkSerial(ctx context.Context, accountId string) error { - ctx, cancel := getDebuggingCtx(ctx) - defer cancel() - - result := s.db.WithContext(ctx).Model(&types.Account{}).Where(idQueryCondition, accountId).Update("network_serial", gorm.Expr("network_serial + 1")) + result := s.db.Model(&types.Account{}).Where(idQueryCondition, accountId).Update("network_serial", gorm.Expr("network_serial + 1")) if result.Error != nil { log.WithContext(ctx).Errorf("failed to increment network serial count in store: %v", result.Error) return status.Errorf(status.Internal, "failed to increment network serial count in store") @@ -2909,8 +3586,11 @@ func (s *SqlStore) IncrementNetworkSerial(ctx context.Context, accountId string) } func (s *SqlStore) ExecuteInTransaction(ctx context.Context, operation func(store Store) error) error { + timeoutCtx, cancel := context.WithTimeout(ctx, s.transactionTimeout) + defer cancel() + startTime := time.Now() - tx := s.db.Begin() + tx := s.db.WithContext(timeoutCtx).Begin() if tx.Error != nil { return tx.Error } @@ -2945,6 +3625,9 @@ func (s *SqlStore) ExecuteInTransaction(ctx context.Context, operation func(stor err := operation(repo) if err != nil { tx.Rollback() + if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) { + log.WithContext(ctx).Warnf("transaction exceeded %s timeout after %v, stack: %s", s.transactionTimeout, time.Since(startTime), debug.Stack()) + } return err } @@ -2957,19 +3640,26 @@ func (s *SqlStore) ExecuteInTransaction(ctx context.Context, operation func(stor } err = tx.Commit().Error + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) { + log.WithContext(ctx).Warnf("transaction commit exceeded %s timeout after %v, stack: %s", s.transactionTimeout, time.Since(startTime), debug.Stack()) + } + return err + } log.WithContext(ctx).Tracef("transaction took %v", time.Since(startTime)) if s.metrics != nil { s.metrics.StoreMetrics().CountTransactionDuration(time.Since(startTime)) } - return err + return nil } func (s *SqlStore) withTx(tx *gorm.DB) Store { return &SqlStore{ - db: tx, - storeEngine: s.storeEngine, + db: tx, + storeEngine: s.storeEngine, + fieldEncrypt: s.fieldEncrypt, } } @@ -3002,6 +3692,11 @@ func (s *SqlStore) GetDB() *gorm.DB { return s.db } +// SetFieldEncrypt sets the field encryptor for encrypting sensitive user data. +func (s *SqlStore) SetFieldEncrypt(enc *crypt.FieldEncrypt) { + s.fieldEncrypt = enc +} + func (s *SqlStore) GetAccountDNSSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*types.DNSSettings, error) { tx := s.db if lockStrength != LockingStrengthNone { @@ -3157,7 +3852,7 @@ func (s *SqlStore) UpdateGroup(ctx context.Context, group *types.Group) error { return status.Errorf(status.InvalidArgument, "group is nil") } - if err := s.db.Omit(clause.Associations).Save(group).Error; err != nil { + if err := s.db.Omit(clause.Associations, "public_id").Save(group).Error; err != nil { log.WithContext(ctx).Errorf("failed to save group to store: %v", err) return status.Errorf(status.Internal, "failed to save group to store") } @@ -3245,7 +3940,7 @@ func (s *SqlStore) CreatePolicy(ctx context.Context, policy *types.Policy) error // SavePolicy saves a policy to the database. func (s *SqlStore) SavePolicy(ctx context.Context, policy *types.Policy) error { - result := s.db.Session(&gorm.Session{FullSaveAssociations: true}).Save(policy) + result := s.db.Session(&gorm.Session{FullSaveAssociations: true}).Omit("public_id").Save(policy) if err := result.Error; err != nil { log.WithContext(ctx).Errorf("failed to save policy to the store: %s", err) return status.Errorf(status.Internal, "failed to save policy to store") @@ -3596,9 +4291,10 @@ func (s *SqlStore) SaveAccountSettings(ctx context.Context, accountID string, se return status.Errorf(status.Internal, "failed to save account settings to store") } - if result.RowsAffected == 0 { - return status.NewAccountNotFoundError(accountID) - } + // MySQL reports RowsAffected=0 for no-op updates where values don't change, + // unlike SQLite/Postgres which report matched rows. Skip the check since the + // caller (UpdateAccountSettings) already verified the account exists via + // GetAccountSettings with LockingStrengthUpdate. return nil } @@ -3717,11 +4413,27 @@ func (s *SqlStore) GetNetworkRouterByID(ctx context.Context, lockStrength Lockin return netRouter, nil } -func (s *SqlStore) SaveNetworkRouter(ctx context.Context, router *routerTypes.NetworkRouter) error { - result := s.db.Save(router) +func (s *SqlStore) CreateNetworkRouter(ctx context.Context, router *routerTypes.NetworkRouter) error { + if err := s.db.Create(router).Error; err != nil { + log.WithContext(ctx).Errorf("failed to create network router in store: %v", err) + return status.Errorf(status.Internal, "failed to create network router in store") + } + + return nil +} + +func (s *SqlStore) UpdateNetworkRouter(ctx context.Context, router *routerTypes.NetworkRouter) error { + result := s.db. + Select("*"). + Where(accountAndIDQueryCondition, router.AccountID, router.ID). + Updates(router) if result.Error != nil { - log.WithContext(ctx).Errorf("failed to save network router to store: %v", result.Error) - return status.Errorf(status.Internal, "failed to save network router to store") + log.WithContext(ctx).Errorf("failed to update network router in store: %v", result.Error) + return status.Errorf(status.Internal, "failed to update network router in store") + } + + if result.RowsAffected == 0 { + return status.NewNetworkRouterNotFoundError(router.ID) } return nil @@ -3944,19 +4656,143 @@ func (s *SqlStore) DeletePAT(ctx context.Context, userID, patID string) error { return nil } +// GetProxyAccessTokenByHashedToken retrieves a proxy access token by its hashed value. +func (s *SqlStore) GetProxyAccessTokenByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken types.HashedProxyToken) (*types.ProxyAccessToken, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var token types.ProxyAccessToken + result := tx.Take(&token, "hashed_token = ?", hashedToken) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "proxy access token not found") + } + return nil, status.Errorf(status.Internal, "get proxy access token: %v", result.Error) + } + + return &token, nil +} + +// GetAllProxyAccessTokens retrieves all proxy access tokens. +func (s *SqlStore) GetAllProxyAccessTokens(ctx context.Context, lockStrength LockingStrength) ([]*types.ProxyAccessToken, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var tokens []*types.ProxyAccessToken + result := tx.Find(&tokens) + if result.Error != nil { + return nil, status.Errorf(status.Internal, "get proxy access tokens: %v", result.Error) + } + + return tokens, nil +} + +// SaveProxyAccessToken saves a proxy access token to the database. +func (s *SqlStore) SaveProxyAccessToken(ctx context.Context, token *types.ProxyAccessToken) error { + if result := s.db.Create(token); result.Error != nil { + return status.Errorf(status.Internal, "save proxy access token: %v", result.Error) + } + return nil +} + +// RevokeProxyAccessToken revokes a proxy access token by its ID. +func (s *SqlStore) RevokeProxyAccessToken(ctx context.Context, tokenID string) error { + result := s.db.Model(&types.ProxyAccessToken{}).Where(idQueryCondition, tokenID).Update("revoked", true) + if result.Error != nil { + return status.Errorf(status.Internal, "revoke proxy access token: %v", result.Error) + } + + if result.RowsAffected == 0 { + return status.Errorf(status.NotFound, "proxy access token not found") + } + + return nil +} + +func (s *SqlStore) GetProxyAccessTokensByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.ProxyAccessToken, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var tokens []*types.ProxyAccessToken + result := tx.Where("account_id = ?", accountID).Find(&tokens) + if result.Error != nil { + return nil, status.Errorf(status.Internal, "get proxy access tokens by account: %v", result.Error) + } + + return tokens, nil +} + +func (s *SqlStore) IsProxyAccessTokenValid(ctx context.Context, tokenID string) (bool, error) { + token, err := s.GetProxyAccessTokenByID(ctx, LockingStrengthNone, tokenID) + if err != nil { + return false, err + } + return token.IsValid(), nil +} + +func (s *SqlStore) GetProxyAccessTokenByID(ctx context.Context, lockStrength LockingStrength, tokenID string) (*types.ProxyAccessToken, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var token types.ProxyAccessToken + result := tx.Take(&token, idQueryCondition, tokenID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "proxy access token not found") + } + return nil, status.Errorf(status.Internal, "get proxy access token by ID: %v", result.Error) + } + + return &token, nil +} + +// MarkProxyAccessTokenUsed updates the last used timestamp for a proxy access token. +func (s *SqlStore) MarkProxyAccessTokenUsed(ctx context.Context, tokenID string) error { + result := s.db.Model(&types.ProxyAccessToken{}). + Where(idQueryCondition, tokenID). + Update("last_used", time.Now().UTC()) + if result.Error != nil { + return status.Errorf(status.Internal, "mark proxy access token as used: %v", result.Error) + } + + if result.RowsAffected == 0 { + return status.Errorf(status.NotFound, "proxy access token not found") + } + + return nil +} + func (s *SqlStore) GetPeerByIP(ctx context.Context, lockStrength LockingStrength, accountID string, ip net.IP) (*nbpeer.Peer, error) { tx := s.db if lockStrength != LockingStrengthNone { tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) } + column := "ip" + if ip.To4() == nil { + column = "ipv6" + } jsonValue := fmt.Sprintf(`"%s"`, ip.String()) var peer nbpeer.Peer result := tx. - Take(&peer, "account_id = ? AND ip = ?", accountID, jsonValue) + Take(&peer, fmt.Sprintf("account_id = ? AND %s = ?", column), accountID, jsonValue) if result.Error != nil { - // no logging here + // A tunnel-IP miss is an expected outcome (e.g. the proxy's + // ValidateTunnelPeer probing an address that isn't in the + // account roster); surface it as NotFound so callers can tell + // it apart from a real store failure. + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "peer with ip %s not found", ip.String()) + } return nil, status.Errorf(status.Internal, "failed to get peer from store") } @@ -4022,36 +4858,6 @@ func (s *SqlStore) GetAccountGroupPeers(ctx context.Context, lockStrength Lockin return groupPeers, nil } -func getDebuggingCtx(grpcCtx context.Context) (context.Context, context.CancelFunc) { - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - userID, ok := grpcCtx.Value(nbcontext.UserIDKey).(string) - if ok { - //nolint - ctx = context.WithValue(ctx, nbcontext.UserIDKey, userID) - } - - requestID, ok := grpcCtx.Value(nbcontext.RequestIDKey).(string) - if ok { - //nolint - ctx = context.WithValue(ctx, nbcontext.RequestIDKey, requestID) - } - - accountID, ok := grpcCtx.Value(nbcontext.AccountIDKey).(string) - if ok { - //nolint - ctx = context.WithValue(ctx, nbcontext.AccountIDKey, accountID) - } - - go func() { - select { - case <-ctx.Done(): - case <-grpcCtx.Done(): - log.WithContext(grpcCtx).Warnf("grpc context ended early, error: %v", grpcCtx.Err()) - } - }() - return ctx, cancel -} - func (s *SqlStore) IsPrimaryAccount(ctx context.Context, accountID string) (bool, string, error) { var info types.PrimaryAccountInfo result := s.db.Model(&types.Account{}). @@ -4091,7 +4897,7 @@ func (s *SqlStore) UpdateAccountNetwork(ctx context.Context, accountID string, i Network: &types.Network{Net: ipNet}, } - result := s.db.WithContext(ctx). + result := s.db. Model(&types.Account{}). Where(idQueryCondition, accountID). Updates(&patch) @@ -4106,6 +4912,27 @@ func (s *SqlStore) UpdateAccountNetwork(ctx context.Context, accountID string, i return nil } +// UpdateAccountNetworkV6 updates the IPv6 network range for the account. +func (s *SqlStore) UpdateAccountNetworkV6(ctx context.Context, accountID string, ipNet net.IPNet) error { + patch := accountNetworkPatch{ + Network: &types.Network{NetV6: ipNet}, + } + + result := s.db. + Model(&types.Account{}). + Where(idQueryCondition, accountID). + Updates(&patch) + + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to update account network v6: %v", result.Error) + return status.Errorf(status.Internal, "update account network v6") + } + if result.RowsAffected == 0 { + return status.NewAccountNotFoundError(accountID) + } + return nil +} + func (s *SqlStore) GetPeersByGroupIDs(ctx context.Context, accountID string, groupIDs []string) ([]*nbpeer.Peer, error) { if len(groupIDs) == 0 { return []*nbpeer.Peer{}, nil @@ -4124,3 +4951,1595 @@ func (s *SqlStore) GetPeersByGroupIDs(ctx context.Context, accountID string, gro return peers, nil } + +func (s *SqlStore) GetPeerIDsByGroups(ctx context.Context, accountID string, groupIDs []string) ([]string, error) { + if len(groupIDs) == 0 { + return nil, nil + } + + var peerIDs []string + result := s.db.Model(&types.GroupPeer{}). + Select("DISTINCT peer_id"). + Where("account_id = ? AND group_id IN ?", accountID, groupIDs). + Pluck("peer_id", &peerIDs) + if result.Error != nil { + return nil, status.Errorf(status.Internal, "failed to get peer IDs by groups: %s", result.Error) + } + + return peerIDs, nil +} + +func (s *SqlStore) GetGroupIDsByPeerIDs(ctx context.Context, accountID string, peerIDs []string) ([]string, error) { + if len(peerIDs) == 0 { + return nil, nil + } + + var groupIDs []string + result := s.db.Model(&types.GroupPeer{}). + Select("DISTINCT group_id"). + Where("account_id = ? AND peer_id IN ?", accountID, peerIDs). + Pluck("group_id", &groupIDs) + if result.Error != nil { + return nil, status.Errorf(status.Internal, "failed to get group IDs by peers: %s", result.Error) + } + + return groupIDs, nil +} + +// GetEmbeddedProxyPeerIDsByCluster returns peer IDs of all embedded proxy peers +// in the account, grouped by their ProxyCluster. The map is nil when no embedded +// proxy peers exist. +func (s *SqlStore) GetEmbeddedProxyPeerIDsByCluster(ctx context.Context, accountID string) (map[string][]string, error) { + type row struct { + ID string + Cluster string + } + var rows []row + result := s.db.Model(&nbpeer.Peer{}). + Select("id, proxy_meta_cluster AS cluster"). + Where("account_id = ? AND proxy_meta_embedded = ?", accountID, true). + Scan(&rows) + if result.Error != nil { + return nil, status.Errorf(status.Internal, "failed to get embedded proxy peers: %s", result.Error) + } + + out := make(map[string][]string, len(rows)) + for _, r := range rows { + out[r.Cluster] = append(out[r.Cluster], r.ID) + } + return out, nil +} + +func (s *SqlStore) GetUserIDByPeerKey(ctx context.Context, lockStrength LockingStrength, peerKey string) (string, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var userID string + result := tx.Model(&nbpeer.Peer{}). + Select("user_id"). + Take(&userID, GetKeyQueryCondition(s), peerKey) + + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return "", status.Errorf(status.NotFound, "peer not found: index lookup failed") + } + return "", status.Errorf(status.Internal, "failed to get user ID by peer key") + } + + return userID, nil +} + +func (s *SqlStore) CreateZone(ctx context.Context, zone *zones.Zone) error { + result := s.db.Create(zone) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to create zone to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to create zone to store") + } + + return nil +} + +func (s *SqlStore) UpdateZone(ctx context.Context, zone *zones.Zone) error { + result := s.db.Select("*").Save(zone) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to update zone to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to update zone to store") + } + + return nil +} + +func (s *SqlStore) DeleteZone(ctx context.Context, accountID, zoneID string) error { + result := s.db.Delete(&zones.Zone{}, accountAndIDQueryCondition, accountID, zoneID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete zone from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete zone from store") + } + + if result.RowsAffected == 0 { + return status.NewZoneNotFoundError(zoneID) + } + + return nil +} + +func (s *SqlStore) GetZoneByID(ctx context.Context, lockStrength LockingStrength, accountID, zoneID string) (*zones.Zone, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var zone *zones.Zone + result := tx.Preload("Records").Take(&zone, accountAndIDQueryCondition, accountID, zoneID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewZoneNotFoundError(zoneID) + } + + log.WithContext(ctx).Errorf("failed to get zone from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get zone from store") + } + + return zone, nil +} + +func (s *SqlStore) GetZoneByDomain(ctx context.Context, accountID, domain string) (*zones.Zone, error) { + var zone *zones.Zone + result := s.db.Where("account_id = ? AND domain = ?", accountID, domain).First(&zone) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewZoneNotFoundError(domain) + } + + log.WithContext(ctx).Errorf("failed to get zone by domain from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get zone by domain from store") + } + + return zone, nil +} + +func (s *SqlStore) GetAccountZones(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*zones.Zone, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var zones []*zones.Zone + result := tx.Preload("Records").Find(&zones, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get zones from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get zones from store") + } + + return zones, nil +} + +func (s *SqlStore) CreateDNSRecord(ctx context.Context, record *records.Record) error { + result := s.db.Create(record) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to create dns record to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to create dns record to store") + } + + return nil +} + +func (s *SqlStore) UpdateDNSRecord(ctx context.Context, record *records.Record) error { + result := s.db.Select("*").Save(record) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to update dns record to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to update dns record to store") + } + + return nil +} + +func (s *SqlStore) DeleteDNSRecord(ctx context.Context, accountID, zoneID, recordID string) error { + result := s.db.Delete(&records.Record{}, "account_id = ? AND zone_id = ? AND id = ?", accountID, zoneID, recordID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete dns record from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete dns record from store") + } + + if result.RowsAffected == 0 { + return status.NewDNSRecordNotFoundError(recordID) + } + + return nil +} + +func (s *SqlStore) GetDNSRecordByID(ctx context.Context, lockStrength LockingStrength, accountID, zoneID, recordID string) (*records.Record, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var record *records.Record + result := tx.Where("account_id = ? AND zone_id = ? AND id = ?", accountID, zoneID, recordID).Take(&record) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewDNSRecordNotFoundError(recordID) + } + + log.WithContext(ctx).Errorf("failed to get dns record from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get dns record from store") + } + + return record, nil +} + +func (s *SqlStore) GetZoneDNSRecords(ctx context.Context, lockStrength LockingStrength, accountID, zoneID string) ([]*records.Record, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var recordsList []*records.Record + result := tx.Where("account_id = ? AND zone_id = ?", accountID, zoneID).Find(&recordsList) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get zone dns records from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get zone dns records from store") + } + + return recordsList, nil +} + +func (s *SqlStore) GetZoneDNSRecordsByName(ctx context.Context, lockStrength LockingStrength, accountID, zoneID, name string) ([]*records.Record, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var recordsList []*records.Record + result := tx.Where("account_id = ? AND zone_id = ? AND name = ?", accountID, zoneID, name).Find(&recordsList) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get zone dns records by name from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get zone dns records by name from store") + } + + return recordsList, nil +} + +func (s *SqlStore) DeleteZoneDNSRecords(ctx context.Context, accountID, zoneID string) error { + result := s.db.Delete(&records.Record{}, "account_id = ? AND zone_id = ?", accountID, zoneID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete zone dns records from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete zone dns records from store") + } + + return nil +} + +func (s *SqlStore) GetPeerIDByKey(ctx context.Context, lockStrength LockingStrength, key string) (string, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var peerID string + result := tx.Model(&nbpeer.Peer{}). + Select("id"). + Where(GetKeyQueryCondition(s), key). + Limit(1). + Scan(&peerID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get peer ID by key: %s", result.Error) + return "", status.Errorf(status.Internal, "failed to get peer ID by key") + } + + return peerID, nil +} + +func (s *SqlStore) CreateService(ctx context.Context, service *rpservice.Service) error { + serviceCopy := service.Copy() + if err := serviceCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil { + return fmt.Errorf("encrypt service data: %w", err) + } + result := s.db.Create(serviceCopy) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to create service to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to create service to store") + } + + return nil +} + +func (s *SqlStore) UpdateService(ctx context.Context, service *rpservice.Service) error { + serviceCopy := service.Copy() + if err := serviceCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil { + return fmt.Errorf("encrypt service data: %w", err) + } + + // Create target type instance outside transaction to avoid variable shadowing + targetType := &rpservice.Target{} + + // Use a transaction to ensure atomic updates of the service and its targets + err := s.db.Transaction(func(tx *gorm.DB) error { + // Delete existing targets + if err := tx.Where("service_id = ?", serviceCopy.ID).Delete(targetType).Error; err != nil { + return err + } + + // Update the service and create new targets + if err := tx.Session(&gorm.Session{FullSaveAssociations: true}).Save(serviceCopy).Error; err != nil { + return err + } + + return nil + }) + if err != nil { + log.WithContext(ctx).Errorf("failed to update service to store: %v", err) + return status.Errorf(status.Internal, "failed to update service to store") + } + + return nil +} + +func (s *SqlStore) DeleteService(ctx context.Context, accountID, serviceID string) error { + result := s.db.Delete(&rpservice.Service{}, accountAndIDQueryCondition, accountID, serviceID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete service from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete service from store") + } + + if result.RowsAffected == 0 { + return status.Errorf(status.NotFound, "service %s not found", serviceID) + } + + return nil +} + +func (s *SqlStore) DeleteTarget(ctx context.Context, accountID string, serviceID string, targetID uint) error { + result := s.db.Delete(&rpservice.Target{}, "account_id = ? AND service_id = ? AND id = ?", accountID, serviceID, targetID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete target from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete target from store") + } + + if result.RowsAffected == 0 { + return status.Errorf(status.NotFound, "target not found for service %s", serviceID) + } + + return nil +} + +func (s *SqlStore) DeleteServiceTargets(ctx context.Context, accountID string, serviceID string) error { + result := s.db.Delete(&rpservice.Target{}, "account_id = ? AND service_id = ?", accountID, serviceID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete targets from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete targets from store") + } + + return nil +} + +// GetTargetsByServiceID retrieves all targets for a given service +func (s *SqlStore) GetTargetsByServiceID(ctx context.Context, lockStrength LockingStrength, accountID string, serviceID string) ([]*rpservice.Target, error) { + var targets []*rpservice.Target + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + result := tx.Where("account_id = ? AND service_id = ?", accountID, serviceID).Find(&targets) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get targets from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get targets from store") + } + + return targets, nil +} + +func (s *SqlStore) GetServiceByID(ctx context.Context, lockStrength LockingStrength, accountID, serviceID string) (*rpservice.Service, error) { + tx := s.db.Preload("Targets") + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var service *rpservice.Service + result := tx.Take(&service, accountAndIDQueryCondition, accountID, serviceID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "service %s not found", serviceID) + } + + log.WithContext(ctx).Errorf("failed to get service from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get service from store") + } + + if err := service.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt service data: %w", err) + } + + return service, nil +} + +func (s *SqlStore) GetServiceByDomain(ctx context.Context, domain string) (*rpservice.Service, error) { + var service *rpservice.Service + result := s.db.Preload("Targets").Where("domain = ?", domain).First(&service) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "service with domain %s not found", domain) + } + + log.WithContext(ctx).Errorf("failed to get service by domain from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get service by domain from store") + } + + if err := service.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt service data: %w", err) + } + + return service, nil +} + +func (s *SqlStore) GetServices(ctx context.Context, lockStrength LockingStrength) ([]*rpservice.Service, error) { + tx := s.db.Preload("Targets") + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var serviceList []*rpservice.Service + result := tx.Find(&serviceList) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get services from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get services from store") + } + + for _, service := range serviceList { + if err := service.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt service data: %w", err) + } + } + + return serviceList, nil +} + +func (s *SqlStore) GetAccountServices(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*rpservice.Service, error) { + tx := s.db.Preload("Targets") + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var serviceList []*rpservice.Service + result := tx.Find(&serviceList, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get services from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get services from store") + } + + for _, service := range serviceList { + if err := service.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt service data: %w", err) + } + } + + return serviceList, nil +} + +// RenewEphemeralService updates the last_renewed_at timestamp for an ephemeral service. +func (s *SqlStore) RenewEphemeralService(ctx context.Context, accountID, peerID, serviceID string) error { + result := s.db.Model(&rpservice.Service{}). + Where("id = ? AND account_id = ? AND source_peer = ? AND source = ?", serviceID, accountID, peerID, rpservice.SourceEphemeral). + Update("meta_last_renewed_at", time.Now()) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to renew ephemeral service: %v", result.Error) + return status.Errorf(status.Internal, "renew ephemeral service") + } + if result.RowsAffected == 0 { + return status.Errorf(status.NotFound, "no active expose session for service %s", serviceID) + } + return nil +} + +// GetExpiredEphemeralServices returns ephemeral services whose last renewal exceeds the given TTL. +// Only the fields needed for reaping are selected. The limit parameter caps the batch size to +// avoid loading too many rows in a single tick. Rows with empty source_peer are excluded to +// skip malformed legacy data. +func (s *SqlStore) GetExpiredEphemeralServices(ctx context.Context, ttl time.Duration, limit int) ([]*rpservice.Service, error) { + cutoff := time.Now().Add(-ttl) + var services []*rpservice.Service + result := s.db. + Select("id", "account_id", "source_peer", "domain"). + Where("source = ? AND source_peer <> '' AND meta_last_renewed_at < ?", rpservice.SourceEphemeral, cutoff). + Limit(limit). + Find(&services) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get expired ephemeral services: %v", result.Error) + return nil, status.Errorf(status.Internal, "get expired ephemeral services") + } + return services, nil +} + +// CountEphemeralServicesByPeer returns the count of ephemeral services for a specific peer. +// Use LockingStrengthUpdate inside a transaction to serialize concurrent create operations. +// The locking is applied via a row-level SELECT ... FOR UPDATE (not on the aggregate) to +// stay compatible with Postgres, which disallows FOR UPDATE on COUNT(*). +func (s *SqlStore) CountEphemeralServicesByPeer(ctx context.Context, lockStrength LockingStrength, accountID, peerID string) (int64, error) { + if lockStrength == LockingStrengthNone { + var count int64 + result := s.db.Model(&rpservice.Service{}). + Where("account_id = ? AND source_peer = ? AND source = ?", accountID, peerID, rpservice.SourceEphemeral). + Count(&count) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to count ephemeral services: %v", result.Error) + return 0, status.Errorf(status.Internal, "count ephemeral services") + } + return count, nil + } + + var ids []string + result := s.db.Model(&rpservice.Service{}). + Clauses(clause.Locking{Strength: string(lockStrength)}). + Select("id"). + Where("account_id = ? AND source_peer = ? AND source = ?", accountID, peerID, rpservice.SourceEphemeral). + Pluck("id", &ids) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to count ephemeral services: %v", result.Error) + return 0, status.Errorf(status.Internal, "count ephemeral services") + } + return int64(len(ids)), nil +} + +// EphemeralServiceExists checks if an ephemeral service exists for the given peer and domain. +// Use LockingStrengthUpdate inside a transaction to serialize concurrent create operations. +func (s *SqlStore) EphemeralServiceExists(ctx context.Context, lockStrength LockingStrength, accountID, peerID, domain string) (bool, error) { + if lockStrength == LockingStrengthNone { + var count int64 + result := s.db.Model(&rpservice.Service{}). + Where("account_id = ? AND source_peer = ? AND domain = ? AND source = ?", accountID, peerID, domain, rpservice.SourceEphemeral). + Count(&count) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to check ephemeral service existence: %v", result.Error) + return false, status.Errorf(status.Internal, "check ephemeral service existence") + } + return count > 0, nil + } + + var id string + result := s.db.Model(&rpservice.Service{}). + Clauses(clause.Locking{Strength: string(lockStrength)}). + Select("id"). + Where("account_id = ? AND source_peer = ? AND domain = ? AND source = ?", accountID, peerID, domain, rpservice.SourceEphemeral). + Limit(1). + Pluck("id", &id) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to check ephemeral service existence: %v", result.Error) + return false, status.Errorf(status.Internal, "check ephemeral service existence") + } + return id != "", nil +} + +// GetServicesByClusterAndPort returns services matching the given proxy cluster, mode, and listen port. +func (s *SqlStore) GetServicesByClusterAndPort(ctx context.Context, lockStrength LockingStrength, proxyCluster string, mode string, listenPort uint16) ([]*rpservice.Service, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var services []*rpservice.Service + result := tx.Where("proxy_cluster = ? AND mode = ? AND listen_port = ?", proxyCluster, mode, listenPort).Find(&services) + if result.Error != nil { + return nil, status.Errorf(status.Internal, "query services by cluster and port") + } + + return services, nil +} + +// GetServicesByCluster returns all services for the given proxy cluster. +func (s *SqlStore) GetServicesByCluster(ctx context.Context, lockStrength LockingStrength, proxyCluster string) ([]*rpservice.Service, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var services []*rpservice.Service + result := tx.Where("proxy_cluster = ?", proxyCluster).Find(&services) + if result.Error != nil { + return nil, status.Errorf(status.Internal, "query services by cluster") + } + return services, nil +} + +func (s *SqlStore) GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error) { + tx := s.db + + customDomain := &domain.Domain{} + result := tx.Take(&customDomain, accountAndIDQueryCondition, accountID, domainID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "custom domain %s not found", domainID) + } + + log.WithContext(ctx).Errorf("failed to get custom domain from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get custom domain from store") + } + + return customDomain, nil +} + +func (s *SqlStore) ListFreeDomains(ctx context.Context, accountID string) ([]string, error) { + return nil, nil +} + +func (s *SqlStore) ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) { + tx := s.db + + var domains []*domain.Domain + result := tx.Find(&domains, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get reverse proxy custom domains from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get reverse proxy custom domains from store") + } + + return domains, nil +} + +func (s *SqlStore) CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error) { + newDomain := &domain.Domain{ + ID: xid.New().String(), // Generate our own ID because gorm doesn't always configure the database to handle this for us. + Domain: domainName, + AccountID: accountID, + TargetCluster: targetCluster, + Type: domain.TypeCustom, + Validated: validated, + } + result := s.db.Create(newDomain) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to create reverse proxy custom domain to store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to create reverse proxy custom domain to store") + } + + return newDomain, nil +} + +func (s *SqlStore) UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error) { + d.AccountID = accountID + result := s.db.Select("*").Save(d) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to update reverse proxy custom domain to store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to update reverse proxy custom domain to store") + } + + return d, nil +} + +func (s *SqlStore) DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error { + result := s.db.Delete(domain.Domain{}, accountAndIDQueryCondition, accountID, domainID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete reverse proxy custom domain from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete reverse proxy custom domain from store") + } + + if result.RowsAffected == 0 { + return status.Errorf(status.NotFound, "reverse proxy custom domain %s not found", domainID) + } + + return nil +} + +// CreateAccessLog creates a new access log entry in the database +func (s *SqlStore) CreateAccessLog(ctx context.Context, logEntry *accesslogs.AccessLogEntry) error { + result := s.db.Create(logEntry) + if result.Error != nil { + log.WithContext(ctx).WithFields(log.Fields{ + "service_id": logEntry.ServiceID, + "method": logEntry.Method, + "host": logEntry.Host, + "path": logEntry.Path, + }).Errorf("failed to create access log entry in store: %v", result.Error) + return status.Errorf(status.Internal, "failed to create access log entry in store") + } + return nil +} + +// CreateAgentNetworkAccessLog persists a flattened agent-network access-log +// entry together with its authorising-group child rows in a single +// transaction. +func (s *SqlStore) CreateAgentNetworkAccessLog(ctx context.Context, entry *agentNetworkTypes.AgentNetworkAccessLog, groups []agentNetworkTypes.AgentNetworkAccessLogGroup) error { + err := s.db.Transaction(func(tx *gorm.DB) error { + // Idempotent on the log id / (log_id, group_id) so a proxy resend of the + // same entry can't fail the request. + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(entry).Error; err != nil { + return err + } + if len(groups) > 0 { + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&groups).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + log.WithContext(ctx).WithFields(log.Fields{ + "account_id": entry.AccountID, + "service_id": entry.ServiceID, + "model": entry.Model, + }).Errorf("failed to create agent-network access log entry in store: %v", err) + return status.Errorf(status.Internal, "failed to create agent-network access log entry in store") + } + return nil +} + +// CreateAgentNetworkUsage persists a stripped agent-network usage record +// together with its authorising-group child rows in a single transaction. +func (s *SqlStore) CreateAgentNetworkUsage(ctx context.Context, usage *agentNetworkTypes.AgentNetworkUsage, groups []agentNetworkTypes.AgentNetworkUsageGroup) error { + err := s.db.Transaction(func(tx *gorm.DB) error { + // Idempotent on the usage id / (usage_id, group_id) so a proxy resend of + // the same entry can't fail the request. + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(usage).Error; err != nil { + return err + } + if len(groups) > 0 { + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&groups).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + log.WithContext(ctx).WithFields(log.Fields{ + "account_id": usage.AccountID, + "model": usage.Model, + }).Errorf("failed to create agent-network usage record in store: %v", err) + return status.Errorf(status.Internal, "failed to create agent-network usage record in store") + } + return nil +} + +// DeleteOldAgentNetworkAccessLogs deletes an account's access-log rows (and +// their authorising-group child rows) older than the cutoff. Usage records are +// untouched — they are the long-term aggregate. Returns the number of log rows +// deleted. +func (s *SqlStore) DeleteOldAgentNetworkAccessLogs(ctx context.Context, accountID string, olderThan time.Time) (int64, error) { + var deleted int64 + err := s.db.Transaction(func(tx *gorm.DB) error { + // Remove group child rows for the soon-to-be-deleted logs first. + if err := tx.Exec( + "DELETE FROM agent_network_access_log_group WHERE account_id = ? AND log_id IN (SELECT id FROM agent_network_access_log WHERE account_id = ? AND timestamp < ?)", + accountID, accountID, olderThan, + ).Error; err != nil { + return err + } + res := tx.Where("account_id = ? AND timestamp < ?", accountID, olderThan). + Delete(&agentNetworkTypes.AgentNetworkAccessLog{}) + if res.Error != nil { + return res.Error + } + deleted = res.RowsAffected + return nil + }) + if err != nil { + log.WithContext(ctx).Errorf("failed to delete old agent-network access logs for account %s: %v", accountID, err) + return 0, status.Errorf(status.Internal, "failed to delete old agent-network access logs") + } + return deleted, nil +} + +// GetAgentNetworkUsageRows returns the stripped usage rows for an account that +// match the filter (date / user / group / provider / model). Aggregation into +// time buckets happens in the manager so granularities stay engine-portable. +func (s *SqlStore) GetAgentNetworkUsageRows(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkUsage, error) { + var rows []*agentNetworkTypes.AgentNetworkUsage + + query := s.applyAgentNetworkUsageFilters( + s.db.Where(accountIDCondition, accountID), + filter, + ).Order("timestamp ASC") + + if lockStrength != LockingStrengthNone { + query = query.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + if err := query.Find(&rows).Error; err != nil { + log.WithContext(ctx).Errorf("failed to get agent-network usage rows from store: %v", err) + return nil, status.Errorf(status.Internal, "failed to get agent-network usage rows from store") + } + return rows, nil +} + +// applyAgentNetworkUsageFilters applies the shared access-log filter's +// date/user/group/provider/model conditions to a usage-table query. Pagination, +// sort and free-text search are ignored — the overview is an aggregate. +func (s *SqlStore) applyAgentNetworkUsageFilters(query *gorm.DB, filter agentNetworkTypes.AgentNetworkAccessLogFilter) *gorm.DB { + if filter.UserID != nil { + query = query.Where("user_id = ?", *filter.UserID) + } + if filter.SessionID != nil { + query = query.Where("session_id = ?", *filter.SessionID) + } + if len(filter.ProviderIDs) > 0 { + query = query.Where("resolved_provider_id IN ?", filter.ProviderIDs) + } + if len(filter.Models) > 0 { + query = query.Where("model IN ?", filter.Models) + } + if len(filter.GroupIDs) > 0 { + query = query.Where( + "id IN (SELECT usage_id FROM agent_network_request_usage_group WHERE group_id IN ?)", + filter.GroupIDs, + ) + } + if filter.StartDate != nil { + query = query.Where("timestamp >= ?", *filter.StartDate) + } + if filter.EndDate != nil { + query = query.Where("timestamp <= ?", *filter.EndDate) + } + return query +} + +// GetAgentNetworkAccessLogs retrieves flattened agent-network access logs for +// an account with server-side pagination, filtering and sorting. Authorising +// group ids are hydrated from the group child table for the returned page. +func (s *SqlStore) GetAgentNetworkAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLog, int64, error) { + var logs []*agentNetworkTypes.AgentNetworkAccessLog + var totalCount int64 + + countQuery := s.applyAgentNetworkAccessLogFilters( + s.db.Model(&agentNetworkTypes.AgentNetworkAccessLog{}).Where(accountIDCondition, accountID), + filter, + ) + if err := countQuery.Count(&totalCount).Error; err != nil { + log.WithContext(ctx).Errorf("failed to count agent-network access logs: %v", err) + return nil, 0, status.Errorf(status.Internal, "failed to count agent-network access logs") + } + + query := s.applyAgentNetworkAccessLogFilters( + s.db.Where(accountIDCondition, accountID), + filter, + ). + Order(filter.GetSortColumn() + " " + filter.GetSortOrder()). + Limit(filter.GetLimit()). + Offset(filter.GetOffset()) + + if lockStrength != LockingStrengthNone { + query = query.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + if err := query.Find(&logs).Error; err != nil { + log.WithContext(ctx).Errorf("failed to get agent-network access logs from store: %v", err) + return nil, 0, status.Errorf(status.Internal, "failed to get agent-network access logs from store") + } + + if err := s.hydrateAgentNetworkAccessLogGroups(ctx, accountID, logs); err != nil { + return nil, 0, err + } + + return logs, totalCount, nil +} + +// applyAgentNetworkAccessLogFilters applies the filter conditions to a query. +func (s *SqlStore) applyAgentNetworkAccessLogFilters(query *gorm.DB, filter agentNetworkTypes.AgentNetworkAccessLogFilter) *gorm.DB { + if filter.Search != nil { + p := "%" + *filter.Search + "%" + query = query.Where( + "id LIKE ? OR host LIKE ? OR path LIKE ? OR model LIKE ? OR user_id IN (SELECT id FROM users WHERE email LIKE ? OR name LIKE ?)", + p, p, p, p, p, p, + ) + } + if filter.UserID != nil { + query = query.Where("user_id = ?", *filter.UserID) + } + if filter.SessionID != nil { + query = query.Where("session_id = ?", *filter.SessionID) + } + if filter.Decision != nil { + query = query.Where("decision = ?", *filter.Decision) + } + if filter.PathPrefix != nil { + query = query.Where("path LIKE ?", *filter.PathPrefix+"%") + } + if len(filter.ProviderIDs) > 0 { + query = query.Where("resolved_provider_id IN ?", filter.ProviderIDs) + } + if len(filter.Models) > 0 { + query = query.Where("model IN ?", filter.Models) + } + if len(filter.GroupIDs) > 0 { + query = query.Where( + "id IN (SELECT log_id FROM agent_network_access_log_group WHERE group_id IN ?)", + filter.GroupIDs, + ) + } + if filter.StartDate != nil { + query = query.Where("timestamp >= ?", *filter.StartDate) + } + if filter.EndDate != nil { + query = query.Where("timestamp <= ?", *filter.EndDate) + } + return query +} + +// hydrateAgentNetworkAccessLogGroups loads the authorising group ids for the +// given page of entries and assigns them onto each entry's GroupIDs field. +func (s *SqlStore) hydrateAgentNetworkAccessLogGroups(ctx context.Context, accountID string, logs []*agentNetworkTypes.AgentNetworkAccessLog) error { + if len(logs) == 0 { + return nil + } + + ids := make([]string, 0, len(logs)) + for _, l := range logs { + ids = append(ids, l.ID) + } + + var rows []agentNetworkTypes.AgentNetworkAccessLogGroup + if err := s.db. + Where(accountIDCondition, accountID). + Where("log_id IN ?", ids). + Find(&rows).Error; err != nil { + log.WithContext(ctx).Errorf("failed to hydrate agent-network access log groups: %v", err) + return status.Errorf(status.Internal, "failed to hydrate agent-network access log groups") + } + + byLog := make(map[string][]string, len(logs)) + for _, r := range rows { + byLog[r.LogID] = append(byLog[r.LogID], r.GroupID) + } + for _, l := range logs { + l.GroupIDs = byLog[l.ID] + } + return nil +} + +// agentNetworkSessionKeyExpr is the SQL group key for session-grouped access +// logs: the row's session id, or — when the client sent none — the row id, so +// session-less requests each form their own singleton group. COALESCE/NULLIF +// are standard SQL, so this stays portable across SQLite and Postgres. +const agentNetworkSessionKeyExpr = "COALESCE(NULLIF(session_id, ''), id)" + +// GetAgentNetworkAccessLogSessions retrieves agent-network access logs grouped +// by session, with server-side pagination, filtering and sorting at the session +// level. It paginates over the distinct session keys (ordered by the requested +// session-level aggregate), fetches every entry for the page's sessions, and +// folds them into per-session summaries. The returned count is the number of +// matching sessions. Filters apply to the entries, so a session's summary +// reflects only its filter-matching requests. +func (s *SqlStore) GetAgentNetworkAccessLogSessions(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLogSession, int64, error) { + // Count distinct sessions via a grouped subquery — portable and avoids + // relying on COUNT(DISTINCT ) quoting quirks. + sessionsSubquery := s.applyAgentNetworkAccessLogFilters( + s.db.Model(&agentNetworkTypes.AgentNetworkAccessLog{}).Where(accountIDCondition, accountID), + filter, + ). + Select(agentNetworkSessionKeyExpr + " AS session_key"). + Group(agentNetworkSessionKeyExpr) + + var totalCount int64 + if err := s.db.Table("(?) AS sessions", sessionsSubquery).Count(&totalCount).Error; err != nil { + log.WithContext(ctx).Errorf("failed to count agent-network access-log sessions: %v", err) + return nil, 0, status.Errorf(status.Internal, "failed to count agent-network access-log sessions") + } + + // The page of session keys, ordered by the session-level aggregate. The + // session-key tiebreaker keeps pagination deterministic when the primary + // aggregate ties. + type sessionKeyRow struct { + SessionKey string + } + var keyRows []sessionKeyRow + keyQuery := s.applyAgentNetworkAccessLogFilters( + s.db.Model(&agentNetworkTypes.AgentNetworkAccessLog{}).Where(accountIDCondition, accountID), + filter, + ). + Select(agentNetworkSessionKeyExpr + " AS session_key"). + Group(agentNetworkSessionKeyExpr). + Order(filter.GetSessionSortExpr() + " " + filter.GetSortOrder()). + Order("session_key ASC"). + Limit(filter.GetLimit()). + Offset(filter.GetOffset()) + if err := keyQuery.Scan(&keyRows).Error; err != nil { + log.WithContext(ctx).Errorf("failed to list agent-network access-log session keys: %v", err) + return nil, 0, status.Errorf(status.Internal, "failed to list agent-network access-log session keys") + } + if len(keyRows) == 0 { + return nil, totalCount, nil + } + + keys := make([]string, 0, len(keyRows)) + for _, r := range keyRows { + keys = append(keys, r.SessionKey) + } + + // All entries for the page's sessions, contiguous per session and oldest + // first within each — the fold relies on that ordering. + var entries []*agentNetworkTypes.AgentNetworkAccessLog + entriesQuery := s.applyAgentNetworkAccessLogFilters( + s.db.Where(accountIDCondition, accountID), + filter, + ). + Where(agentNetworkSessionKeyExpr+" IN ?", keys). + Order(agentNetworkSessionKeyExpr + ", timestamp ASC") + + if lockStrength != LockingStrengthNone { + entriesQuery = entriesQuery.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + if err := entriesQuery.Find(&entries).Error; err != nil { + log.WithContext(ctx).Errorf("failed to get agent-network access-log session entries: %v", err) + return nil, 0, status.Errorf(status.Internal, "failed to get agent-network access-log session entries") + } + + if err := s.hydrateAgentNetworkAccessLogGroups(ctx, accountID, entries); err != nil { + return nil, 0, err + } + + return agentNetworkTypes.FoldAccessLogSessions(keys, entries), totalCount, nil +} + +// GetAccountAccessLogs retrieves access logs for a given account with pagination and filtering +func (s *SqlStore) GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { + var logs []*accesslogs.AccessLogEntry + var totalCount int64 + + baseQuery := s.db. + Model(&accesslogs.AccessLogEntry{}). + Where(accountIDCondition, accountID) + + baseQuery = s.applyAccessLogFilters(baseQuery, filter) + + if err := baseQuery.Count(&totalCount).Error; err != nil { + log.WithContext(ctx).Errorf("failed to count access logs: %v", err) + return nil, 0, status.Errorf(status.Internal, "failed to count access logs") + } + + query := s.db. + Where(accountIDCondition, accountID) + + query = s.applyAccessLogFilters(query, filter) + + sortColumns := filter.GetSortColumn() + sortOrder := strings.ToUpper(filter.GetSortOrder()) + + var orderClauses []string + for _, col := range strings.Split(sortColumns, ",") { + col = strings.TrimSpace(col) + if col != "" { + orderClauses = append(orderClauses, col+" "+sortOrder) + } + } + orderClause := strings.Join(orderClauses, ", ") + + query = query. + Order(orderClause). + Limit(filter.GetLimit()). + Offset(filter.GetOffset()) + + if lockStrength != LockingStrengthNone { + query = query.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + result := query.Find(&logs) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get access logs from store: %v", result.Error) + return nil, 0, status.Errorf(status.Internal, "failed to get access logs from store") + } + + return logs, totalCount, nil +} + +// DeleteOldAccessLogs deletes all access logs older than the specified time +func (s *SqlStore) DeleteOldAccessLogs(ctx context.Context, olderThan time.Time) (int64, error) { + result := s.db. + Where("timestamp < ?", olderThan). + Delete(&accesslogs.AccessLogEntry{}) + + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete old access logs: %v", result.Error) + return 0, status.Errorf(status.Internal, "failed to delete old access logs") + } + + return result.RowsAffected, nil +} + +// applyAccessLogFilters applies filter conditions to the query +func (s *SqlStore) applyAccessLogFilters(query *gorm.DB, filter accesslogs.AccessLogFilter) *gorm.DB { + if filter.Search != nil { + searchPattern := "%" + *filter.Search + "%" + query = query.Where( + "id LIKE ? OR location_connection_ip LIKE ? OR host LIKE ? OR path LIKE ? OR CONCAT(host, path) LIKE ? OR user_id IN (SELECT id FROM users WHERE email LIKE ? OR name LIKE ?)", + searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, + ) + } + + if filter.SourceIP != nil { + query = query.Where("location_connection_ip = ?", *filter.SourceIP) + } + + if filter.Host != nil { + query = query.Where("host = ?", *filter.Host) + } + + if filter.Path != nil { + // Support LIKE pattern for path filtering + query = query.Where("path LIKE ?", "%"+*filter.Path+"%") + } + + if filter.UserID != nil { + query = query.Where("user_id = ?", *filter.UserID) + } + + if filter.Method != nil { + query = query.Where("method = ?", *filter.Method) + } + + if filter.Status != nil { + switch *filter.Status { + case "success": + query = query.Where("status_code >= ? AND status_code < ?", 200, 400) + case "failed": + query = query.Where("status_code < ? OR status_code >= ?", 200, 400) + } + } + + if filter.StatusCode != nil { + query = query.Where("status_code = ?", *filter.StatusCode) + } + + if filter.StartDate != nil { + query = query.Where("timestamp >= ?", *filter.StartDate) + } + + if filter.EndDate != nil { + query = query.Where("timestamp <= ?", *filter.EndDate) + } + + return query +} + +func (s *SqlStore) GetServiceTargetByTargetID(ctx context.Context, lockStrength LockingStrength, accountID string, targetID string) (*rpservice.Target, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var target *rpservice.Target + result := tx.Take(&target, "account_id = ? AND target_id = ?", accountID, targetID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "service target with ID %s not found", targetID) + } + + log.WithContext(ctx).Errorf("failed to get service target from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get service target from store") + } + + return target, nil +} + +// SaveProxy saves or updates a proxy in the database +func (s *SqlStore) SaveProxy(ctx context.Context, p *proxy.Proxy) error { + result := s.db.Save(p) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save proxy: %v", result.Error) + return status.Errorf(status.Internal, "failed to save proxy") + } + return nil +} + +// DisconnectProxy marks a proxy as disconnected only if the session ID matches. +// This prevents a slow-to-close old session from overwriting a newer reconnection. +func (s *SqlStore) DisconnectProxy(ctx context.Context, proxyID, sessionID string) error { + now := time.Now() + result := s.db. + Model(&proxy.Proxy{}). + Where("id = ? AND session_id = ?", proxyID, sessionID). + Updates(map[string]any{ + "status": proxy.StatusDisconnected, + "disconnected_at": now, + "last_seen": now, + }) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to disconnect proxy %s session %s: %v", proxyID, sessionID, result.Error) + return status.Errorf(status.Internal, "failed to disconnect proxy") + } + if result.RowsAffected == 0 { + log.WithContext(ctx).Debugf("proxy %s session %s: no row updated (superseded by newer session)", proxyID, sessionID) + } + return nil +} + +// GetAllProxies returns all reverse proxy instance rows. +func (s *SqlStore) GetAllProxies(ctx context.Context) ([]*proxy.Proxy, error) { + var proxies []*proxy.Proxy + result := s.db.Order("cluster_address, id").Find(&proxies) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get proxies: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get proxies") + } + return proxies, nil +} + +// DisconnectAllProxies force-marks every proxy that is not already disconnected +// as disconnected, regardless of session ID. Unlike DisconnectProxy it is not +// session-guarded: it is an administrative repair helper, not part of the +// connection lifecycle. last_seen is left untouched so the stale-proxy reaper +// keeps working off the real last heartbeat. Returns the number of proxies updated. +func (s *SqlStore) DisconnectAllProxies(ctx context.Context) (int64, error) { + result := s.db. + Model(&proxy.Proxy{}). + Where("status != ?", proxy.StatusDisconnected). + Updates(map[string]any{ + "status": proxy.StatusDisconnected, + "disconnected_at": time.Now(), + }) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to disconnect all proxies: %v", result.Error) + return 0, status.Errorf(status.Internal, "failed to disconnect all proxies") + } + return result.RowsAffected, nil +} + +// UpdateProxyHeartbeat updates the last_seen timestamp for the proxy's current session. +func (s *SqlStore) UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) error { + now := time.Now() + + result := s.db. + Model(&proxy.Proxy{}). + Where("id = ? AND session_id = ?", p.ID, p.SessionID). + Updates(map[string]any{ + "last_seen": now, + "status": proxy.StatusConnected, + "disconnected_at": nil, + }) + + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to update proxy heartbeat: %v", result.Error) + return status.Errorf(status.Internal, "failed to update proxy heartbeat") + } + + if result.RowsAffected == 0 { + p.LastSeen = now + p.ConnectedAt = &now + p.Status = proxy.StatusConnected + if err := s.db.Create(p).Error; err != nil { + log.WithContext(ctx).Debugf("proxy %s session %s: heartbeat fallback insert skipped: %v", p.ID, p.SessionID, err) + } + } + + return nil +} + +// GetActiveProxyClusterAddresses returns the unique cluster addresses of active +// shared proxies (those without an account scope). BYOP cluster addresses are +// excluded; use GetActiveProxyClusterAddressesForAccount to retrieve them. +func (s *SqlStore) GetActiveProxyClusterAddresses(ctx context.Context) ([]string, error) { + var addresses []string + + result := s.db. + Model(&proxy.Proxy{}). + Where("account_id IS NULL AND status = ? AND last_seen > ?", proxy.StatusConnected, time.Now().Add(-proxyActiveThreshold)). + Distinct("cluster_address"). + Pluck("cluster_address", &addresses) + + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get active proxy cluster addresses: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get active proxy cluster addresses") + } + + return addresses, nil +} + +func (s *SqlStore) GetActiveProxyClusterAddressesForAccount(ctx context.Context, accountID string) ([]string, error) { + var addresses []string + + result := s.db. + Model(&proxy.Proxy{}). + Where("account_id = ? AND status = ? AND last_seen > ?", accountID, proxy.StatusConnected, time.Now().Add(-proxyActiveThreshold)). + Distinct("cluster_address"). + Pluck("cluster_address", &addresses) + + if result.Error != nil { + return nil, status.Errorf(status.Internal, "failed to get active proxy cluster addresses for account") + } + + return addresses, nil +} + +func (s *SqlStore) GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error) { + var p proxy.Proxy + result := s.db.Where("account_id = ?", accountID).Take(&p) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "proxy not found for account") + } + return nil, status.Errorf(status.Internal, "get proxy by account ID: %v", result.Error) + } + return &p, nil +} + +func (s *SqlStore) CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error) { + var count int64 + result := s.db.Model(&proxy.Proxy{}).Where("account_id = ?", accountID).Count(&count) + if result.Error != nil { + return 0, status.Errorf(status.Internal, "count proxies by account ID: %v", result.Error) + } + return count, nil +} + +func (s *SqlStore) IsClusterAddressConflicting(ctx context.Context, clusterAddress, accountID string) (bool, error) { + var count int64 + result := s.db. + Model(&proxy.Proxy{}). + Where("cluster_address = ? AND (account_id IS NULL OR account_id != ?)", clusterAddress, accountID). + Count(&count) + if result.Error != nil { + return false, status.Errorf(status.Internal, "check cluster address conflict: %v", result.Error) + } + return count > 0, nil +} + +func (s *SqlStore) DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error { + result := s.db. + Where("cluster_address = ? AND account_id = ?", clusterAddress, accountID). + Delete(&proxy.Proxy{}) + if result.Error != nil { + return status.Errorf(status.Internal, "delete account cluster: %v", result.Error) + } + if result.RowsAffected == 0 { + return status.Errorf(status.NotFound, "cluster not found") + } + return nil +} + +// GetProxyClusters returns every cluster the account can see (shared +// plus its own BYOP), regardless of whether any proxy in the cluster +// is currently heartbeating. Online and ConnectedProxies are derived +// from the 2-min active window so the dashboard can render offline +// clusters distinctly; the 1-hour heartbeat reaper still removes rows +// that go quiet for too long. +// +// AccountOwned is determined by whether any proxy row in the group +// carries a non-NULL account_id; the caller maps that to Cluster.Type. +// Capability flags are NOT filled here — the handler enriches them via +// the per-cluster capability lookups. +func (s *SqlStore) GetProxyClusters(ctx context.Context, accountID string) ([]proxy.Cluster, error) { + activeCutoff := time.Now().Add(-proxyActiveThreshold) + + type clusterRow struct { + ID string + Address string + ConnectedProxies int + Online bool + AccountOwned bool + } + + var rows []clusterRow + result := s.db.Model(&proxy.Proxy{}). + Select( + "MIN(id) AS id, "+ + "cluster_address AS address, "+ + // COUNT(CASE WHEN ... THEN 1 END) counts only non-NULL — i.e. only + // rows that satisfy the predicate — so it works portably across + // sqlite/postgres/mysql without dialect-specific FILTER syntax. + "COUNT(CASE WHEN status = ? AND last_seen > ? THEN 1 END) AS connected_proxies, "+ + // MAX(CASE …) > 0 expresses BOOL_OR in a way Postgres tolerates + // (Postgres can't MAX a boolean column). + "MAX(CASE WHEN status = ? AND last_seen > ? THEN 1 ELSE 0 END) > 0 AS online, "+ + "MAX(CASE WHEN account_id IS NOT NULL THEN 1 ELSE 0 END) > 0 AS account_owned", + proxy.StatusConnected, activeCutoff, + proxy.StatusConnected, activeCutoff, + ). + Where("account_id IS NULL OR account_id = ?", accountID). + Group("cluster_address"). + Scan(&rows) + + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get proxy clusters: %v", result.Error) + return nil, status.Errorf(status.Internal, "get proxy clusters") + } + + clusters := make([]proxy.Cluster, 0, len(rows)) + for _, r := range rows { + c := proxy.Cluster{ + ID: r.ID, + Address: r.Address, + Online: r.Online, + ConnectedProxies: r.ConnectedProxies, + } + if r.AccountOwned { + c.Type = proxy.ClusterTypeAccount + } else { + c.Type = proxy.ClusterTypeShared + } + clusters = append(clusters, c) + } + + return clusters, nil +} + +// proxyActiveThreshold is the maximum age of a heartbeat for a proxy to be +// considered active. Must be at least 2x the heartbeat interval (1 min). +const proxyActiveThreshold = 2 * time.Minute + +var validCapabilityColumns = map[string]struct{}{ + "supports_custom_ports": {}, + "require_subdomain": {}, + "supports_crowdsec": {}, + "private": {}, +} + +// GetClusterSupportsCustomPorts returns whether any active proxy in the cluster +// supports custom ports. Returns nil when no proxy reported the capability. +func (s *SqlStore) GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool { + return s.getClusterCapability(ctx, clusterAddr, "supports_custom_ports") +} + +// GetClusterRequireSubdomain returns whether any active proxy in the cluster +// requires a subdomain. Returns nil when no proxy reported the capability. +func (s *SqlStore) GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool { + return s.getClusterCapability(ctx, clusterAddr, "require_subdomain") +} + +// GetClusterSupportsPrivate reports whether any active proxy in the cluster +// has the private capability (nil = unreported). +func (s *SqlStore) GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool { + return s.getClusterCapability(ctx, clusterAddr, "private") +} + +// GetClusterSupportsCrowdSec returns whether all active proxies in the cluster +// have CrowdSec configured. Returns nil when no proxy reported the capability. +// Unlike other capabilities that use ANY-true (for rolling upgrades), CrowdSec +// requires unanimous support: a single unconfigured proxy would let requests +// bypass reputation checks. +func (s *SqlStore) GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool { + return s.getClusterUnanimousCapability(ctx, clusterAddr, "supports_crowdsec") +} + +// getClusterUnanimousCapability returns an aggregated boolean capability +// requiring all active proxies in the cluster to report true. +func (s *SqlStore) getClusterUnanimousCapability(ctx context.Context, clusterAddr, column string) *bool { + if _, ok := validCapabilityColumns[column]; !ok { + log.WithContext(ctx).Errorf("invalid capability column: %s", column) + return nil + } + + var result struct { + Total int64 + Reported int64 + AllTrue bool + } + + // All active proxies must have reported the capability (no NULLs) and all + // must report true. A single unreported or false proxy means the cluster + // does not unanimously support the capability. + err := s.db.WithContext(ctx). + Model(&proxy.Proxy{}). + Select("COUNT(*) AS total, "+ + "COUNT(CASE WHEN "+column+" IS NOT NULL THEN 1 END) AS reported, "+ + "COUNT(*) > 0 AND COUNT(*) = COUNT(CASE WHEN "+column+" = true THEN 1 END) AS all_true"). + Where("cluster_address = ? AND status = ? AND last_seen > ?", + clusterAddr, "connected", time.Now().Add(-proxyActiveThreshold)). + Scan(&result).Error + if err != nil { + log.WithContext(ctx).Errorf("query cluster capability %s for %s: %v", column, clusterAddr, err) + return nil + } + + if result.Total == 0 || result.Reported == 0 { + return nil + } + + // If any proxy has not reported (NULL), we can't confirm unanimous support. + if result.Reported < result.Total { + v := false + return &v + } + + return &result.AllTrue +} + +// getClusterCapability returns an aggregated boolean capability for the given +// cluster. It checks active (connected, recently seen) proxies and returns: +// - *true if any proxy in the cluster has the capability set to true, +// - *false if at least one proxy reported but none set it to true, +// - nil if no proxy reported the capability at all. +func (s *SqlStore) getClusterCapability(ctx context.Context, clusterAddr, column string) *bool { + if _, ok := validCapabilityColumns[column]; !ok { + log.WithContext(ctx).Errorf("invalid capability column: %s", column) + return nil + } + + var result struct { + HasCapability bool + AnyTrue bool + } + + err := s.db. + WithContext(ctx). + Model(&proxy.Proxy{}). + Select("COUNT(CASE WHEN "+column+" IS NOT NULL THEN 1 END) > 0 AS has_capability, "+ + "COALESCE(MAX(CASE WHEN "+column+" = true THEN 1 ELSE 0 END), 0) = 1 AS any_true"). + Where("cluster_address = ? AND status = ? AND last_seen > ?", + clusterAddr, "connected", time.Now().Add(-proxyActiveThreshold)). + Scan(&result).Error + if err != nil { + log.WithContext(ctx).Errorf("query cluster capability %s for %s: %v", column, clusterAddr, err) + return nil + } + + if !result.HasCapability { + return nil + } + + return &result.AnyTrue +} + +// CleanupStaleProxies deletes proxies that haven't sent heartbeat in the specified duration +func (s *SqlStore) CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error { + cutoffTime := time.Now().Add(-inactivityDuration) + + result := s.db. + Where("last_seen < ?", cutoffTime). + Delete(&proxy.Proxy{}) + + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to cleanup stale proxies: %v", result.Error) + return status.Errorf(status.Internal, "failed to cleanup stale proxies") + } + + if result.RowsAffected > 0 { + log.WithContext(ctx).Infof("Cleaned up %d stale proxies", result.RowsAffected) + } + + return nil +} + +// GetRoutingPeerNetworks returns the distinct network names where the peer is assigned as a routing peer +// in an enabled network router, either directly or via peer groups. +func (s *SqlStore) GetRoutingPeerNetworks(_ context.Context, accountID, peerID string) ([]string, error) { + var routers []*routerTypes.NetworkRouter + if err := s.db.Select("peer, peer_groups, network_id").Where("account_id = ? AND enabled = true", accountID).Find(&routers).Error; err != nil { + return nil, status.Errorf(status.Internal, "failed to get enabled routers: %v", err) + } + + if len(routers) == 0 { + return nil, nil + } + + var groupPeers []types.GroupPeer + if err := s.db.Select("group_id").Where("account_id = ? AND peer_id = ?", accountID, peerID).Find(&groupPeers).Error; err != nil { + return nil, status.Errorf(status.Internal, "failed to get peer group memberships: %v", err) + } + + groupSet := make(map[string]struct{}, len(groupPeers)) + for _, gp := range groupPeers { + groupSet[gp.GroupID] = struct{}{} + } + + networkIDs := make(map[string]struct{}) + for _, r := range routers { + if r.Peer == peerID { + networkIDs[r.NetworkID] = struct{}{} + } else if r.Peer == "" { + for _, pg := range r.PeerGroups { + if _, ok := groupSet[pg]; ok { + networkIDs[r.NetworkID] = struct{}{} + break + } + } + } + } + + if len(networkIDs) == 0 { + return nil, nil + } + + ids := make([]string, 0, len(networkIDs)) + for id := range networkIDs { + ids = append(ids, id) + } + + var networks []*networkTypes.Network + if err := s.db.Select("name").Where("account_id = ? AND id IN ?", accountID, ids).Find(&networks).Error; err != nil { + return nil, status.Errorf(status.Internal, "failed to get networks: %v", err) + } + + names := make([]string, 0, len(networks)) + for _, n := range networks { + names = append(names, n.Name) + } + + return names, nil +} diff --git a/management/server/store/sql_store_agentnetwork.go b/management/server/store/sql_store_agentnetwork.go new file mode 100644 index 000000000..b72dc735f --- /dev/null +++ b/management/server/store/sql_store_agentnetwork.go @@ -0,0 +1,664 @@ +package store + +import ( + "context" + "errors" + "fmt" + "math" + "time" + + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +// GetAllAgentNetworkProviders returns Agent Network providers across +// every account. Used by the synthesizer to build the global service map. +func (s *SqlStore) GetAllAgentNetworkProviders(ctx context.Context, lockStrength LockingStrength) ([]*agentNetworkTypes.Provider, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var providers []*agentNetworkTypes.Provider + if result := tx.Find(&providers); result.Error != nil { + log.WithContext(ctx).Errorf("failed to get all agent network providers from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get all agent network providers from store") + } + + for _, provider := range providers { + if err := provider.DecryptSensitiveData(s.fieldEncrypt); err != nil { + log.WithContext(ctx).Errorf("failed to decrypt agent network provider %s: %v", provider.ID, err) + return nil, status.Errorf(status.Internal, "failed to decrypt agent network provider") + } + } + + return providers, nil +} + +// GetAgentNetworkMetrics returns aggregated agent-network adoption + usage +// counts for the self-hosted metrics worker. Each value is a single cheap +// aggregate; token/cost are summed over the always-collected per-request usage +// ledger (independent of the log-collection toggle) so they reflect real usage. +func (s *SqlStore) GetAgentNetworkMetrics(ctx context.Context) (AgentNetworkMetrics, error) { + var m AgentNetworkMetrics + db := s.db.WithContext(ctx) + + // Providers + distinct adopting accounts in one round-trip. + provRow := db.Model(&agentNetworkTypes.Provider{}). + Select("COUNT(*) AS providers, COUNT(DISTINCT account_id) AS accounts").Row() + if err := provRow.Scan(&m.Providers, &m.Accounts); err != nil { + return AgentNetworkMetrics{}, fmt.Errorf("scan agent network provider metrics: %w", err) + } + + if err := db.Model(&agentNetworkTypes.Policy{}).Count(&m.Policies).Error; err != nil { + return AgentNetworkMetrics{}, fmt.Errorf("count agent network policies: %w", err) + } + + if err := db.Model(&agentNetworkTypes.AccountBudgetRule{}).Count(&m.BudgetRules).Error; err != nil { + return AgentNetworkMetrics{}, fmt.Errorf("count agent network budget rules: %w", err) + } + + if err := db.Model(&agentNetworkTypes.Settings{}). + Where("enable_log_collection = ?", true).Count(&m.LogCollectionEnabled).Error; err != nil { + return AgentNetworkMetrics{}, fmt.Errorf("count agent network log-collection accounts: %w", err) + } + + // COALESCE so an empty ledger scans as 0 instead of NULL. + usageRow := db.Model(&agentNetworkTypes.AgentNetworkUsage{}). + Select("COALESCE(SUM(input_tokens), 0) AS input_tokens, " + + "COALESCE(SUM(output_tokens), 0) AS output_tokens, " + + "COALESCE(SUM" + agentNetworkTypes.CostUSDSQLExpr + ", 0) AS cost_usd").Row() + if err := usageRow.Scan(&m.InputTokens, &m.OutputTokens, &m.CostUSD); err != nil { + return AgentNetworkMetrics{}, fmt.Errorf("scan agent network usage metrics: %w", err) + } + + return m, nil +} + +func (s *SqlStore) GetAccountAgentNetworkProviders(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Provider, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var providers []*agentNetworkTypes.Provider + result := tx.Find(&providers, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get agent network providers from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network providers from store") + } + + for _, provider := range providers { + if err := provider.DecryptSensitiveData(s.fieldEncrypt); err != nil { + log.WithContext(ctx).Errorf("failed to decrypt agent network provider %s: %v", provider.ID, err) + return nil, status.Errorf(status.Internal, "failed to decrypt agent network provider") + } + } + + return providers, nil +} + +func (s *SqlStore) GetAgentNetworkProviderByID(ctx context.Context, lockStrength LockingStrength, accountID, providerID string) (*agentNetworkTypes.Provider, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var provider *agentNetworkTypes.Provider + result := tx.Take(&provider, accountAndIDQueryCondition, accountID, providerID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewAgentNetworkProviderNotFoundError(providerID) + } + + log.WithContext(ctx).Errorf("failed to get agent network provider from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network provider from store") + } + + if err := provider.DecryptSensitiveData(s.fieldEncrypt); err != nil { + log.WithContext(ctx).Errorf("failed to decrypt agent network provider %s: %v", provider.ID, err) + return nil, status.Errorf(status.Internal, "failed to decrypt agent network provider") + } + + return provider, nil +} + +func (s *SqlStore) SaveAgentNetworkProvider(ctx context.Context, provider *agentNetworkTypes.Provider) error { + providerCopy := provider.Copy() + if err := providerCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil { + log.WithContext(ctx).Errorf("failed to encrypt agent network provider %s: %v", provider.ID, err) + return status.Errorf(status.Internal, "failed to encrypt agent network provider") + } + + result := s.db.Save(providerCopy) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save agent network provider to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to save agent network provider to store") + } + + return nil +} + +func (s *SqlStore) DeleteAgentNetworkProvider(ctx context.Context, accountID, providerID string) error { + result := s.db.Delete(&agentNetworkTypes.Provider{}, accountAndIDQueryCondition, accountID, providerID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete agent network provider from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete agent network provider from store") + } + + if result.RowsAffected == 0 { + return status.NewAgentNetworkProviderNotFoundError(providerID) + } + + return nil +} + +func (s *SqlStore) GetAccountAgentNetworkPolicies(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Policy, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var policies []*agentNetworkTypes.Policy + result := tx.Find(&policies, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get agent network policies from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network policies from store") + } + + return policies, nil +} + +func (s *SqlStore) GetAgentNetworkPolicyByID(ctx context.Context, lockStrength LockingStrength, accountID, policyID string) (*agentNetworkTypes.Policy, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var policy *agentNetworkTypes.Policy + result := tx.Take(&policy, accountAndIDQueryCondition, accountID, policyID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewAgentNetworkPolicyNotFoundError(policyID) + } + + log.WithContext(ctx).Errorf("failed to get agent network policy from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network policy from store") + } + + return policy, nil +} + +func (s *SqlStore) SaveAgentNetworkPolicy(ctx context.Context, policy *agentNetworkTypes.Policy) error { + result := s.db.Save(policy) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save agent network policy to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to save agent network policy to store") + } + + return nil +} + +func (s *SqlStore) DeleteAgentNetworkPolicy(ctx context.Context, accountID, policyID string) error { + result := s.db.Delete(&agentNetworkTypes.Policy{}, accountAndIDQueryCondition, accountID, policyID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete agent network policy from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete agent network policy from store") + } + + if result.RowsAffected == 0 { + return status.NewAgentNetworkPolicyNotFoundError(policyID) + } + + return nil +} + +func (s *SqlStore) GetAccountAgentNetworkGuardrails(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Guardrail, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var guardrails []*agentNetworkTypes.Guardrail + result := tx.Find(&guardrails, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get agent network guardrails from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network guardrails from store") + } + + return guardrails, nil +} + +func (s *SqlStore) GetAgentNetworkGuardrailByID(ctx context.Context, lockStrength LockingStrength, accountID, guardrailID string) (*agentNetworkTypes.Guardrail, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var guardrail *agentNetworkTypes.Guardrail + result := tx.Take(&guardrail, accountAndIDQueryCondition, accountID, guardrailID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewAgentNetworkGuardrailNotFoundError(guardrailID) + } + + log.WithContext(ctx).Errorf("failed to get agent network guardrail from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network guardrail from store") + } + + return guardrail, nil +} + +func (s *SqlStore) SaveAgentNetworkGuardrail(ctx context.Context, guardrail *agentNetworkTypes.Guardrail) error { + result := s.db.Save(guardrail) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save agent network guardrail to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to save agent network guardrail to store") + } + + return nil +} + +func (s *SqlStore) DeleteAgentNetworkGuardrail(ctx context.Context, accountID, guardrailID string) error { + result := s.db.Delete(&agentNetworkTypes.Guardrail{}, accountAndIDQueryCondition, accountID, guardrailID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete agent network guardrail from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete agent network guardrail from store") + } + + if result.RowsAffected == 0 { + return status.NewAgentNetworkGuardrailNotFoundError(guardrailID) + } + + return nil +} + +// GetAgentNetworkSettings returns the per-account Agent Network +// settings row. Returns status.NotFound when no row exists. +func (s *SqlStore) GetAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*agentNetworkTypes.Settings, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var settings agentNetworkTypes.Settings + result := tx.Take(&settings, "account_id = ?", accountID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "agent network settings for account %s not found", accountID) + } + + log.WithContext(ctx).Errorf("failed to get agent network settings from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network settings from store") + } + + return &settings, nil +} + +// GetAllAgentNetworkSettings returns every account's settings row. Used by the +// access-log retention sweep to learn each account's retention window. +func (s *SqlStore) GetAllAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength) ([]*agentNetworkTypes.Settings, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var settings []*agentNetworkTypes.Settings + if err := tx.Find(&settings).Error; err != nil { + log.WithContext(ctx).Errorf("failed to list agent network settings: %v", err) + return nil, status.Errorf(status.Internal, "failed to list agent network settings") + } + return settings, nil +} + +// GetAgentNetworkSettingsByCluster returns every Settings row pinned to +// the given proxy cluster. Used by the bootstrap label generator to +// build the set of subdomains already taken on a cluster. +func (s *SqlStore) GetAgentNetworkSettingsByCluster(ctx context.Context, lockStrength LockingStrength, cluster string) ([]*agentNetworkTypes.Settings, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var settings []*agentNetworkTypes.Settings + result := tx.Find(&settings, "cluster = ?", cluster) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get agent network settings by cluster from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network settings by cluster from store") + } + + return settings, nil +} + +// SaveAgentNetworkSettings upserts the per-account Agent Network +// settings row. +func (s *SqlStore) SaveAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error { + result := s.db.Save(settings) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save agent network settings to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to save agent network settings to store") + } + + return nil +} + +// IncrementAgentNetworkConsumption atomically upserts the consumption +// row keyed on (account, dim_kind, dim_id, window_seconds, window_start) +// and adds the supplied deltas. Concurrent calls from multiple proxy +// nodes converge — the database performs the increment server-side via +// ON CONFLICT DO UPDATE so no read-modify-write race exists. +func (s *SqlStore) IncrementAgentNetworkConsumption( + ctx context.Context, + accountID string, + kind agentNetworkTypes.ConsumptionDimension, + dimID string, + windowSeconds int64, + windowStart time.Time, + tokensIn, tokensOut int64, + costUSD float64, +) error { + if accountID == "" || dimID == "" || windowSeconds <= 0 { + return status.Errorf(status.InvalidArgument, "account_id, dim_id and window_seconds must be set") + } + // Deltas are added server-side via ON CONFLICT; a negative or non-finite + // value would silently decrement / poison the persisted totals. + if tokensIn < 0 || tokensOut < 0 || costUSD < 0 || math.IsNaN(costUSD) || math.IsInf(costUSD, 0) { + return status.Errorf(status.InvalidArgument, "consumption deltas must be non-negative and finite") + } + row := agentNetworkTypes.Consumption{ + AccountID: accountID, + DimensionKind: kind, + DimensionID: dimID, + WindowSeconds: windowSeconds, + WindowStartUTC: windowStart.UTC(), + TokensInput: tokensIn, + TokensOutput: tokensOut, + CostUSD: costUSD, + UpdatedAt: time.Now().UTC(), + } + const tbl = "agent_network_consumption" + err := s.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "account_id"}, + {Name: "dim_kind"}, + {Name: "dim_id"}, + {Name: "window_seconds"}, + {Name: "window_start_utc"}, + }, + DoUpdates: clause.Assignments(map[string]any{ + "tokens_input": gorm.Expr(tbl+".tokens_input + ?", tokensIn), + "tokens_output": gorm.Expr(tbl+".tokens_output + ?", tokensOut), + "cost_usd": gorm.Expr(tbl+".cost_usd + ?", costUSD), + "updated_at": time.Now().UTC(), + }), + }).Create(&row).Error + if err != nil { + log.WithContext(ctx).Errorf("failed to increment agent network consumption: %v", err) + return status.Errorf(status.Internal, "failed to increment agent network consumption") + } + return nil +} + +// GetAgentNetworkConsumption returns the consumption row for the exact +// window key. Returns a zero-valued row (not found mapped to zero) so +// callers can use the result as the headroom basis without nil checks. +func (s *SqlStore) GetAgentNetworkConsumption( + ctx context.Context, + lockStrength LockingStrength, + accountID string, + kind agentNetworkTypes.ConsumptionDimension, + dimID string, + windowSeconds int64, + windowStart time.Time, +) (*agentNetworkTypes.Consumption, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + var row agentNetworkTypes.Consumption + result := tx.Take(&row, + "account_id = ? AND dim_kind = ? AND dim_id = ? AND window_seconds = ? AND window_start_utc = ?", + accountID, kind, dimID, windowSeconds, windowStart.UTC()) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return &agentNetworkTypes.Consumption{ + AccountID: accountID, + DimensionKind: kind, + DimensionID: dimID, + WindowSeconds: windowSeconds, + WindowStartUTC: windowStart.UTC(), + }, nil + } + log.WithContext(ctx).Errorf("failed to get agent network consumption: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network consumption") + } + return &row, nil +} + +// GetAgentNetworkConsumptionBatch reads many consumption counters for one +// account in a single query, returning a map keyed by the exact +// ConsumptionKey. Missing counters are simply absent from the map (callers +// treat absence as a zero counter). Replaces the per-cap point reads the +// policy selector previously issued one at a time. +func (s *SqlStore) GetAgentNetworkConsumptionBatch( + ctx context.Context, + lockStrength LockingStrength, + accountID string, + keys []agentNetworkTypes.ConsumptionKey, +) (map[agentNetworkTypes.ConsumptionKey]*agentNetworkTypes.Consumption, error) { + out := make(map[agentNetworkTypes.ConsumptionKey]*agentNetworkTypes.Consumption, len(keys)) + if len(keys) == 0 { + return out, nil + } + + // Collect the distinct dim ids, windows and window starts so a single + // query scopes to exactly the current windows in play, then filter the + // returned rows down to the exact requested keys. + wanted := make(map[agentNetworkTypes.ConsumptionKey]struct{}, len(keys)) + dimSet := make(map[string]struct{}) + winSet := make(map[int64]struct{}) + startSet := make(map[time.Time]struct{}) + for _, k := range keys { + k.WindowStartUTC = k.WindowStartUTC.UTC() + wanted[k] = struct{}{} + dimSet[k.DimID] = struct{}{} + winSet[k.WindowSeconds] = struct{}{} + startSet[k.WindowStartUTC] = struct{}{} + } + dimIDs := make([]string, 0, len(dimSet)) + for d := range dimSet { + dimIDs = append(dimIDs, d) + } + windows := make([]int64, 0, len(winSet)) + for w := range winSet { + windows = append(windows, w) + } + starts := make([]time.Time, 0, len(startSet)) + for t := range startSet { + starts = append(starts, t) + } + + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + var rows []*agentNetworkTypes.Consumption + result := tx.Find(&rows, + "account_id = ? AND dim_id IN ? AND window_seconds IN ? AND window_start_utc IN ?", + accountID, dimIDs, windows, starts) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to batch-get agent network consumption: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network consumption") + } + for _, row := range rows { + k := agentNetworkTypes.ConsumptionKey{ + Kind: row.DimensionKind, + DimID: row.DimensionID, + WindowSeconds: row.WindowSeconds, + WindowStartUTC: row.WindowStartUTC.UTC(), + } + if _, ok := wanted[k]; ok { + out[k] = row + } + } + return out, nil +} + +// IncrementAgentNetworkConsumptionBatch applies the same usage delta to every +// supplied counter inside a single transaction, so all per-(dimension, window) +// counters a served request books are written atomically in one round-trip +// instead of one upsert per counter. Keys are deduplicated by the caller. +func (s *SqlStore) IncrementAgentNetworkConsumptionBatch( + ctx context.Context, + accountID string, + keys []agentNetworkTypes.ConsumptionKey, + tokensIn, tokensOut int64, + costUSD float64, +) error { + if accountID == "" { + return status.Errorf(status.InvalidArgument, "account_id must be set") + } + if tokensIn < 0 || tokensOut < 0 || costUSD < 0 || math.IsNaN(costUSD) || math.IsInf(costUSD, 0) { + return status.Errorf(status.InvalidArgument, "consumption deltas must be non-negative and finite") + } + if len(keys) == 0 { + return nil + } + + const tbl = "agent_network_consumption" + err := s.db.Transaction(func(tx *gorm.DB) error { + for _, k := range keys { + if k.DimID == "" || k.WindowSeconds <= 0 { + return status.Errorf(status.InvalidArgument, "dim_id and window_seconds must be set") + } + now := time.Now().UTC() + row := agentNetworkTypes.Consumption{ + AccountID: accountID, + DimensionKind: k.Kind, + DimensionID: k.DimID, + WindowSeconds: k.WindowSeconds, + WindowStartUTC: k.WindowStartUTC.UTC(), + TokensInput: tokensIn, + TokensOutput: tokensOut, + CostUSD: costUSD, + UpdatedAt: now, + } + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "account_id"}, + {Name: "dim_kind"}, + {Name: "dim_id"}, + {Name: "window_seconds"}, + {Name: "window_start_utc"}, + }, + DoUpdates: clause.Assignments(map[string]any{ + "tokens_input": gorm.Expr(tbl+".tokens_input + ?", tokensIn), + "tokens_output": gorm.Expr(tbl+".tokens_output + ?", tokensOut), + "cost_usd": gorm.Expr(tbl+".cost_usd + ?", costUSD), + "updated_at": now, + }), + }).Create(&row).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + log.WithContext(ctx).Errorf("failed to batch-increment agent network consumption: %v", err) + return status.Errorf(status.Internal, "failed to increment agent network consumption") + } + return nil +} + +// ListAgentNetworkConsumption returns every consumption row recorded +// for the account, ordered by window_start descending. Backs the +// dashboard's basic counter view. +func (s *SqlStore) ListAgentNetworkConsumption( + ctx context.Context, + lockStrength LockingStrength, + accountID string, +) ([]*agentNetworkTypes.Consumption, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + var rows []*agentNetworkTypes.Consumption + result := tx. + Order("window_start_utc DESC"). + Find(&rows, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to list agent network consumption: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to list agent network consumption") + } + return rows, nil +} + +// GetAccountAgentNetworkBudgetRules returns every account-level budget rule for +// the account. +func (s *SqlStore) GetAccountAgentNetworkBudgetRules(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.AccountBudgetRule, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var rules []*agentNetworkTypes.AccountBudgetRule + result := tx.Find(&rules, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get agent network budget rules from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network budget rules from store") + } + + return rules, nil +} + +// GetAgentNetworkBudgetRuleByID returns a single budget rule scoped to the +// account, or a NotFound error. +func (s *SqlStore) GetAgentNetworkBudgetRuleByID(ctx context.Context, lockStrength LockingStrength, accountID, ruleID string) (*agentNetworkTypes.AccountBudgetRule, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var rule *agentNetworkTypes.AccountBudgetRule + result := tx.Take(&rule, accountAndIDQueryCondition, accountID, ruleID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewAgentNetworkBudgetRuleNotFoundError(ruleID) + } + + log.WithContext(ctx).Errorf("failed to get agent network budget rule from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get agent network budget rule from store") + } + + return rule, nil +} + +// SaveAgentNetworkBudgetRule upserts a budget rule. +func (s *SqlStore) SaveAgentNetworkBudgetRule(ctx context.Context, rule *agentNetworkTypes.AccountBudgetRule) error { + result := s.db.Save(rule) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save agent network budget rule to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to save agent network budget rule to store") + } + + return nil +} + +// DeleteAgentNetworkBudgetRule removes a budget rule scoped to the account. +func (s *SqlStore) DeleteAgentNetworkBudgetRule(ctx context.Context, accountID, ruleID string) error { + result := s.db.Delete(&agentNetworkTypes.AccountBudgetRule{}, accountAndIDQueryCondition, accountID, ruleID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete agent network budget rule from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete agent network budget rule from store") + } + + if result.RowsAffected == 0 { + return status.NewAgentNetworkBudgetRuleNotFoundError(ruleID) + } + + return nil +} diff --git a/management/server/store/sql_store_agentnetwork_accesslog_test.go b/management/server/store/sql_store_agentnetwork_accesslog_test.go new file mode 100644 index 000000000..8ba79a062 --- /dev/null +++ b/management/server/store/sql_store_agentnetwork_accesslog_test.go @@ -0,0 +1,302 @@ +package store + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" +) + +// TestAgentNetworkUsage_RealStore_RoundTrip drives CreateAgentNetworkUsage and +// CreateAgentNetworkAccessLog through a real sqlite store to prove the schema +// migrates and the inserts succeed for both a populated (allowed) entry and a +// stripped (denied) entry. +func TestAgentNetworkUsage_RealStore_RoundTrip(t *testing.T) { + ctx := context.Background() + s, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + const accountID = "acc-anet-usage-1" + now := time.Now().UTC() + + // Populated (allowed) usage row with two authorising groups. + usage := &agentNetworkTypes.AgentNetworkUsage{ + ID: "log-allowed-1", + AccountID: accountID, + Timestamp: now, + UserID: "user-alice", + ResolvedProviderID: "prov-openai-1", + Provider: "openai", + Model: "gpt-4o", + SessionID: "sess-round-trip-1", + InputTokens: 1200, + OutputTokens: 640, + TotalTokens: 1840, + InputCostUSD: 0.0231, + } + usageGroups := []agentNetworkTypes.AgentNetworkUsageGroup{ + {UsageID: usage.ID, GroupID: "grp-eng", AccountID: accountID}, + {UsageID: usage.ID, GroupID: "grp-oncall", AccountID: accountID}, + } + require.NoError(t, s.CreateAgentNetworkUsage(ctx, usage, usageGroups), "populated usage insert must succeed") + + // Stripped (denied / 403) usage row: no provider/model/tokens, no groups. + denied := &agentNetworkTypes.AgentNetworkUsage{ + ID: "log-denied-1", + AccountID: accountID, + Timestamp: now, + UserID: "user-bob", + } + require.NoError(t, s.CreateAgentNetworkUsage(ctx, denied, nil), "stripped usage insert must succeed") + + // Idempotency: re-inserting the same id must not error. + require.NoError(t, s.CreateAgentNetworkUsage(ctx, usage, usageGroups), "duplicate usage insert must be idempotent") + + // Access-log row + group children. + entry := &agentNetworkTypes.AgentNetworkAccessLog{ + ID: "log-allowed-1", + AccountID: accountID, + ServiceID: "agent-net-svc-1", + Timestamp: now, + UserID: "user-alice", + StatusCode: 200, + Provider: "openai", + Model: "gpt-4o", + SessionID: "sess-round-trip-1", + InputTokens: 1200, + OutputTokens: 640, + TotalTokens: 1840, + InputCostUSD: 0.0231, + } + entryGroups := []agentNetworkTypes.AgentNetworkAccessLogGroup{ + {LogID: entry.ID, GroupID: "grp-eng", AccountID: accountID}, + {LogID: entry.ID, GroupID: "grp-oncall", AccountID: accountID}, + } + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, entry, entryGroups), "access-log insert must succeed") + + // Read back through the filtered list + verify group hydration. + logs, total, err := s.GetAgentNetworkAccessLogs(ctx, LockingStrengthNone, accountID, agentNetworkTypes.AgentNetworkAccessLogFilter{Page: 1, PageSize: 50}) + require.NoError(t, err, "list must succeed") + assert.Equal(t, int64(1), total, "one access-log row expected") + require.Len(t, logs, 1) + assert.ElementsMatch(t, []string{"grp-eng", "grp-oncall"}, logs[0].GroupIDs, "group ids must hydrate") + assert.Equal(t, "sess-round-trip-1", logs[0].SessionID, "session id must persist and read back on the access-log row") + + // Session filter narrows the access-log listing to one conversation. + sessionID := "sess-round-trip-1" + sessLogs, sessTotal, err := s.GetAgentNetworkAccessLogs(ctx, LockingStrengthNone, accountID, + agentNetworkTypes.AgentNetworkAccessLogFilter{Page: 1, PageSize: 50, SessionID: &sessionID}) + require.NoError(t, err) + assert.Equal(t, int64(1), sessTotal, "session filter must match the one row with that session id") + require.Len(t, sessLogs, 1) + assert.Equal(t, entry.ID, sessLogs[0].ID, "session filter must return the matching log row") + + bogus := "no-such-session" + _, emptyTotal, err := s.GetAgentNetworkAccessLogs(ctx, LockingStrengthNone, accountID, + agentNetworkTypes.AgentNetworkAccessLogFilter{Page: 1, PageSize: 50, SessionID: &bogus}) + require.NoError(t, err) + assert.Equal(t, int64(0), emptyTotal, "unknown session id must match nothing") + + // Session filter also narrows the always-on usage rows. + sessUsage, err := s.GetAgentNetworkUsageRows(ctx, LockingStrengthNone, accountID, + agentNetworkTypes.AgentNetworkAccessLogFilter{SessionID: &sessionID}) + require.NoError(t, err) + require.Len(t, sessUsage, 1, "session filter must narrow usage rows to the matching session") + assert.Equal(t, "sess-round-trip-1", sessUsage[0].SessionID, "usage row must carry the session id") +} + +// TestAgentNetworkUsageOverview_DailyAggregation drives GetAgentNetworkUsageRows +// + AggregateUsageByGranularity end-to-end against a real sqlite store, with +// two rows on the same day and one on another, plus a model filter. +func TestAgentNetworkUsageOverview_DailyAggregation(t *testing.T) { + ctx := context.Background() + s, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + const accountID = "acc-anet-overview-1" + day1 := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC) + day1b := time.Date(2026, 5, 5, 22, 0, 0, 0, time.UTC) + day2 := time.Date(2026, 5, 6, 9, 0, 0, 0, time.UTC) + + mk := func(id string, ts time.Time, model string, in, out int64, cost float64) *agentNetworkTypes.AgentNetworkUsage { + return &agentNetworkTypes.AgentNetworkUsage{ + ID: id, AccountID: accountID, Timestamp: ts, Model: model, + InputTokens: in, OutputTokens: out, TotalTokens: in + out, InputCostUSD: cost, + } + } + require.NoError(t, s.CreateAgentNetworkUsage(ctx, mk("u1", day1, "gpt-4o", 100, 50, 0.10), nil)) + require.NoError(t, s.CreateAgentNetworkUsage(ctx, mk("u2", day1b, "gpt-4o", 200, 80, 0.20), nil)) + require.NoError(t, s.CreateAgentNetworkUsage(ctx, mk("u3", day2, "claude-3", 10, 5, 0.01), nil)) + + rows, err := s.GetAgentNetworkUsageRows(ctx, LockingStrengthNone, accountID, agentNetworkTypes.AgentNetworkAccessLogFilter{}) + require.NoError(t, err) + require.Len(t, rows, 3, "all three usage rows expected") + + buckets := agentNetworkTypes.AggregateUsageByGranularity(rows, agentNetworkTypes.UsageGranularityDay) + require.Len(t, buckets, 2, "two distinct days expected") + assert.Equal(t, "2026-05-05", buckets[0].PeriodStart, "oldest-first ordering") + assert.Equal(t, int64(300), buckets[0].InputTokens, "same-day input tokens summed") + assert.Equal(t, int64(130), buckets[0].OutputTokens) + assert.InDelta(t, 0.30, buckets[0].TotalCostUSD(), 1e-9, "same-day cost summed") + assert.Equal(t, "2026-05-06", buckets[1].PeriodStart) + assert.Equal(t, int64(15), buckets[1].TotalTokens) + + // Model filter narrows to a single day. + model := "claude-3" + filtered, err := s.GetAgentNetworkUsageRows(ctx, LockingStrengthNone, accountID, agentNetworkTypes.AgentNetworkAccessLogFilter{Models: []string{model}}) + require.NoError(t, err) + require.Len(t, filtered, 1, "model filter must narrow rows") + assert.Equal(t, "u3", filtered[0].ID) +} + +// TestAgentNetworkAccessLogSessions_RealStore drives GetAgentNetworkAccessLogSessions +// against a real sqlite store: session grouping + aggregation, recency ordering, +// singleton groups for session-less requests, session pagination, the model +// filter narrowing sessions, and aggregate sorting. +func TestAgentNetworkAccessLogSessions_RealStore(t *testing.T) { + ctx := context.Background() + s, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + const accountID = "acc-anet-sessions-1" + base := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC) + at := func(h int) time.Time { return base.Add(time.Duration(h) * time.Hour) } + + mk := func(id, session, user, provider, model, decision string, ts time.Time, cost float64) *agentNetworkTypes.AgentNetworkAccessLog { + return &agentNetworkTypes.AgentNetworkAccessLog{ + ID: id, AccountID: accountID, ServiceID: "svc", Timestamp: ts, + UserID: user, StatusCode: 200, Provider: provider, Model: model, + SessionID: session, Decision: decision, + InputTokens: 100, OutputTokens: 50, TotalTokens: 150, InputCostUSD: cost, + } + } + + // Two-request session s1 (alice), a one-request denied session s2 (bob), and + // two session-less requests (empty session id) that must each form their own + // singleton group. + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, mk("s1-a", "s1", "alice", "openai", "gpt-4o", "allow", at(1), 0.10), + []agentNetworkTypes.AgentNetworkAccessLogGroup{{LogID: "s1-a", GroupID: "grp-eng", AccountID: accountID}})) + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, mk("s1-b", "s1", "alice", "openai", "gpt-4o", "allow", at(2), 0.20), + []agentNetworkTypes.AgentNetworkAccessLogGroup{{LogID: "s1-b", GroupID: "grp-oncall", AccountID: accountID}})) + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, mk("s2-a", "s2", "bob", "anthropic", "claude-3", "deny", at(3), 0.05), nil)) + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, mk("se-old", "", "carol", "openai", "o1", "allow", at(0), 0.01), nil)) + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, mk("se-new", "", "dave", "mistral", "mistral-large", "allow", at(4), 0.02), nil)) + + // Default sort: last activity (MAX timestamp) descending. + sessions, total, err := s.GetAgentNetworkAccessLogSessions(ctx, LockingStrengthNone, accountID, + agentNetworkTypes.AgentNetworkAccessLogFilter{Page: 1, PageSize: 50}) + require.NoError(t, err) + assert.Equal(t, int64(4), total, "four sessions: s1, s2, and two singletons") + require.Len(t, sessions, 4) + + // se-new(t4) > s2(t3) > s1(t2) > se-old(t0) + assert.Equal(t, "", sessions[0].SessionID, "newest is a session-less singleton") + assert.Equal(t, "se-new", sessions[0].Entries[0].ID) + assert.Equal(t, "s2", sessions[1].SessionID) + assert.Equal(t, "s1", sessions[2].SessionID) + assert.Equal(t, "se-old", sessions[3].Entries[0].ID) + + // s1 aggregation. + s1 := sessions[2] + assert.Equal(t, 2, s1.RequestCount, "s1 has two requests") + assert.Equal(t, int64(300), s1.TotalTokens, "tokens summed across the session") + assert.InDelta(t, 0.30, s1.TotalCostUSD(), 1e-9, "cost summed across the session") + assert.Equal(t, "alice", s1.UserID) + assert.Equal(t, "allow", s1.Decision) + // SQLite hands times back in time.Local; normalise to UTC so the instant is + // compared, not the (differing) *Location pointer. + assert.Equal(t, at(1), s1.StartedAt.UTC(), "started = earliest entry") + assert.Equal(t, at(2), s1.EndedAt.UTC(), "ended = latest entry") + assert.ElementsMatch(t, []string{"openai"}, s1.Providers) + assert.ElementsMatch(t, []string{"gpt-4o"}, s1.Models) + assert.ElementsMatch(t, []string{"grp-eng", "grp-oncall"}, s1.GroupIDs, "union of the entries' authorising groups") + + // Denied session rolls up to deny. + assert.Equal(t, "deny", sessions[1].Decision, "any denied request makes the session deny") + + // Pagination over sessions: 2 per page. + page1, total, err := s.GetAgentNetworkAccessLogSessions(ctx, LockingStrengthNone, accountID, + agentNetworkTypes.AgentNetworkAccessLogFilter{Page: 1, PageSize: 2}) + require.NoError(t, err) + assert.Equal(t, int64(4), total, "total still counts all sessions") + require.Len(t, page1, 2) + assert.Equal(t, "se-new", page1[0].Entries[0].ID) + assert.Equal(t, "s2", page1[1].SessionID) + + page2, _, err := s.GetAgentNetworkAccessLogSessions(ctx, LockingStrengthNone, accountID, + agentNetworkTypes.AgentNetworkAccessLogFilter{Page: 2, PageSize: 2}) + require.NoError(t, err) + require.Len(t, page2, 2) + assert.Equal(t, "s1", page2[0].SessionID) + assert.Equal(t, "se-old", page2[1].Entries[0].ID) + + // Model filter narrows to the session(s) with matching entries. + model := "claude-3" + filtered, fTotal, err := s.GetAgentNetworkAccessLogSessions(ctx, LockingStrengthNone, accountID, + agentNetworkTypes.AgentNetworkAccessLogFilter{Page: 1, PageSize: 50, Models: []string{model}}) + require.NoError(t, err) + assert.Equal(t, int64(1), fTotal, "only s2 has a claude-3 request") + require.Len(t, filtered, 1) + assert.Equal(t, "s2", filtered[0].SessionID) + + // Sort by total session cost, descending: s1 (0.30) leads despite not being + // the most recent. + byCost, _, err := s.GetAgentNetworkAccessLogSessions(ctx, LockingStrengthNone, accountID, + agentNetworkTypes.AgentNetworkAccessLogFilter{Page: 1, PageSize: 50, SortBy: "cost_usd", SortOrder: "desc"}) + require.NoError(t, err) + require.Len(t, byCost, 4) + assert.Equal(t, "s1", byCost[0].SessionID, "highest-cost session sorts first") +} + +// TestDeleteOldAgentNetworkAccessLogs verifies the retention sweep removes only +// access-log rows (and their group children) older than the cutoff, leaving +// recent rows — and never touching usage records. +func TestDeleteOldAgentNetworkAccessLogs(t *testing.T) { + ctx := context.Background() + s, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + const accountID = "acc-anet-retention-1" + old := time.Now().UTC().AddDate(0, 0, -40) + recent := time.Now().UTC().AddDate(0, 0, -1) + + mkLog := func(id string, ts time.Time) (*agentNetworkTypes.AgentNetworkAccessLog, []agentNetworkTypes.AgentNetworkAccessLogGroup) { + return &agentNetworkTypes.AgentNetworkAccessLog{ + ID: id, AccountID: accountID, ServiceID: "svc", Timestamp: ts, StatusCode: 200, Model: "gpt-4o", + }, []agentNetworkTypes.AgentNetworkAccessLogGroup{ + {LogID: id, GroupID: "grp-eng", AccountID: accountID}, + } + } + oldEntry, oldGroups := mkLog("old-1", old) + recentEntry, recentGroups := mkLog("recent-1", recent) + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, oldEntry, oldGroups)) + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, recentEntry, recentGroups)) + // A usage row for the old request must survive the access-log sweep. + require.NoError(t, s.CreateAgentNetworkUsage(ctx, &agentNetworkTypes.AgentNetworkUsage{ + ID: "old-1", AccountID: accountID, Timestamp: old, Model: "gpt-4o", InputTokens: 10, TotalTokens: 10, + }, nil)) + + cutoff := time.Now().UTC().AddDate(0, 0, -30) + deleted, err := s.DeleteOldAgentNetworkAccessLogs(ctx, accountID, cutoff) + require.NoError(t, err) + assert.Equal(t, int64(1), deleted, "only the 40-day-old log is deleted") + + logs, total, err := s.GetAgentNetworkAccessLogs(ctx, LockingStrengthNone, accountID, agentNetworkTypes.AgentNetworkAccessLogFilter{Page: 1, PageSize: 50}) + require.NoError(t, err) + assert.Equal(t, int64(1), total, "the recent log remains") + require.Len(t, logs, 1) + assert.Equal(t, "recent-1", logs[0].ID) + + // Usage is untouched by the access-log retention sweep. + usage, err := s.GetAgentNetworkUsageRows(ctx, LockingStrengthNone, accountID, agentNetworkTypes.AgentNetworkAccessLogFilter{}) + require.NoError(t, err) + require.Len(t, usage, 1, "usage record for the deleted log must survive") +} diff --git a/management/server/store/sql_store_agentnetwork_budgetrule_test.go b/management/server/store/sql_store_agentnetwork_budgetrule_test.go new file mode 100644 index 000000000..3bf7b797d --- /dev/null +++ b/management/server/store/sql_store_agentnetwork_budgetrule_test.go @@ -0,0 +1,112 @@ +package store + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" +) + +// TestAgentNetworkBudgetRule_RealStore_RoundTrip is the GC-0 no-mock guard: it +// drives the budget-rule CRUD through a real sqlite store and asserts the full +// object — targets and the reused PolicyLimits cap shape — survives the +// save → gorm/JSON serialize → reload round-trip, then that delete removes it +// and a second delete reports NotFound. +func TestAgentNetworkBudgetRule_RealStore_RoundTrip(t *testing.T) { + ctx := context.Background() + s, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + const accountID = "acc-budgetrule-1" + rule := agentNetworkTypes.NewAccountBudgetRule(accountID) + rule.Name = "eng-monthly" + rule.TargetGroups = []string{"grp-eng", "grp-oncall"} + rule.TargetUsers = []string{"user-alice"} + rule.Limits = agentNetworkTypes.PolicyLimits{ + TokenLimit: agentNetworkTypes.PolicyTokenLimit{ + Enabled: true, GroupCap: 100_000, UserCap: 10_000, WindowSeconds: 2_592_000, + }, + BudgetLimit: agentNetworkTypes.PolicyBudgetLimit{ + Enabled: true, GroupCapUsd: 500, UserCapUsd: 50, WindowSeconds: 2_592_000, + }, + } + require.NoError(t, s.SaveAgentNetworkBudgetRule(ctx, rule), "save must succeed") + + got, err := s.GetAgentNetworkBudgetRuleByID(ctx, LockingStrengthNone, accountID, rule.ID) + require.NoError(t, err, "get by id must succeed after save") + assert.Equal(t, rule.Name, got.Name, "name must round-trip") + assert.Equal(t, []string{"grp-eng", "grp-oncall"}, got.TargetGroups, "target groups must round-trip") + assert.Equal(t, []string{"user-alice"}, got.TargetUsers, "target users must round-trip") + assert.Equal(t, rule.Limits, got.Limits, "the reused PolicyLimits cap shape must round-trip intact") + assert.True(t, got.Enabled, "enabled must round-trip") + + list, err := s.GetAccountAgentNetworkBudgetRules(ctx, LockingStrengthNone, accountID) + require.NoError(t, err, "list must succeed") + require.Len(t, list, 1, "exactly the one saved rule must be listed") + assert.Equal(t, rule.ID, list[0].ID, "listed rule id must match") + + require.NoError(t, s.DeleteAgentNetworkBudgetRule(ctx, accountID, rule.ID), "delete must succeed") + + _, err = s.GetAgentNetworkBudgetRuleByID(ctx, LockingStrengthNone, accountID, rule.ID) + assert.Error(t, err, "get after delete must report not found") + + err = s.DeleteAgentNetworkBudgetRule(ctx, accountID, rule.ID) + assert.Error(t, err, "deleting an absent rule must report not found") +} + +// TestAgentNetworkBudgetRule_RealStore_ScopedByAccount pins that rules are +// account-scoped: a rule under one account is invisible to another. +func TestAgentNetworkBudgetRule_RealStore_ScopedByAccount(t *testing.T) { + ctx := context.Background() + s, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + defer cleanup() + + ruleA := agentNetworkTypes.NewAccountBudgetRule("acc-A") + require.NoError(t, s.SaveAgentNetworkBudgetRule(ctx, ruleA)) + + list, err := s.GetAccountAgentNetworkBudgetRules(ctx, LockingStrengthNone, "acc-B") + require.NoError(t, err) + assert.Empty(t, list, "account B must not see account A's budget rule") + + _, err = s.GetAgentNetworkBudgetRuleByID(ctx, LockingStrengthNone, "acc-B", ruleA.ID) + assert.Error(t, err, "cross-account get by id must not resolve") +} + +// TestAgentNetworkSettings_RealStore_CollectionTogglesRoundTrip pins the GC-0 +// additive settings columns: the three collection toggles default off on a +// fresh row and survive a save/reload at their set values. +func TestAgentNetworkSettings_RealStore_CollectionTogglesRoundTrip(t *testing.T) { + ctx := context.Background() + s, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + defer cleanup() + + const accountID = "acc-settings-toggles" + require.NoError(t, s.SaveAgentNetworkSettings(ctx, &agentNetworkTypes.Settings{ + AccountID: accountID, + Cluster: "eu.proxy.netbird.io", + Subdomain: "violet", + })) + + got, err := s.GetAgentNetworkSettings(ctx, LockingStrengthNone, accountID) + require.NoError(t, err) + assert.False(t, got.EnableLogCollection, "log collection must default off") + assert.False(t, got.EnablePromptCollection, "prompt collection must default off") + assert.False(t, got.RedactPii, "redact pii must default off") + + got.EnableLogCollection = true + got.EnablePromptCollection = true + got.RedactPii = true + require.NoError(t, s.SaveAgentNetworkSettings(ctx, got)) + + reloaded, err := s.GetAgentNetworkSettings(ctx, LockingStrengthNone, accountID) + require.NoError(t, err) + assert.True(t, reloaded.EnableLogCollection, "log collection must round-trip on") + assert.True(t, reloaded.EnablePromptCollection, "prompt collection must round-trip on") + assert.True(t, reloaded.RedactPii, "redact pii must round-trip on") +} diff --git a/management/server/store/sql_store_get_account_test.go b/management/server/store/sql_store_get_account_test.go index 8ff04d68a..56f2a6c41 100644 --- a/management/server/store/sql_store_get_account_test.go +++ b/management/server/store/sql_store_get_account_test.go @@ -4,6 +4,8 @@ import ( "context" "net" "net/netip" + "os" + "runtime" "testing" "time" @@ -21,6 +23,63 @@ import ( "github.com/netbirdio/netbird/route" ) +// TestGetAccount_LoadsCustomDomains verifies GetAccount populates account.Domains. +// SynthesizePrivateServiceZones depends on this relation to anchor a custom-domain +// private service's DNS zone; without the preload the relation is empty and the +// service is silently skipped, so a custom domain never resolves on clients. +func TestGetAccount_LoadsCustomDomains(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("The SQLite store is not properly supported by Windows yet") + } + + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + require.NoError(t, err) + defer cleanup() + + assertGetAccountLoadsCustomDomains(t, store) +} + +func TestPostgresql_GetAccount_LoadsCustomDomains(t *testing.T) { + if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { + t.Skip("skip CI tests on darwin and windows") + } + + t.Setenv("NETBIRD_STORE_ENGINE", string(types.PostgresStoreEngine)) + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanup) + + assertGetAccountLoadsCustomDomains(t, store) +} + +// assertGetAccountLoadsCustomDomains exercises both the gorm and pgx GetAccount +// paths: it persists two custom domains and asserts the relation comes back +// populated, which SynthesizePrivateServiceZones relies on. +func assertGetAccountLoadsCustomDomains(t *testing.T, store Store) { + t.Helper() + ctx := context.Background() + + accountID := "acct-custom-domains" + require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, accountID, "user-1", ""))) + + _, err := store.CreateCustomDomain(ctx, accountID, "example.com", "eu.proxy.netbird.io", true) + require.NoError(t, err, "creating the first custom domain must succeed") + _, err = store.CreateCustomDomain(ctx, accountID, "apps.acme.io", "us.proxy.netbird.io", false) + require.NoError(t, err, "creating the second custom domain must succeed") + + account, err := store.GetAccount(ctx, accountID) + require.NoError(t, err) + require.Len(t, account.Domains, 2, "GetAccount must preload the account's custom domains") + + byDomain := map[string]string{} + for _, d := range account.Domains { + require.NotNil(t, d) + byDomain[d.Domain] = d.TargetCluster + } + assert.Equal(t, "eu.proxy.netbird.io", byDomain["example.com"], "custom domain must carry its target cluster") + assert.Equal(t, "us.proxy.netbird.io", byDomain["apps.acme.io"], "custom domain must carry its target cluster") +} + // TestGetAccount_ComprehensiveFieldValidation validates that GetAccount properly loads // all fields and nested objects from the database, including deeply nested structures. func TestGetAccount_ComprehensiveFieldValidation(t *testing.T) { @@ -148,7 +207,8 @@ func TestGetAccount_ComprehensiveFieldValidation(t *testing.T) { AccountID: accountID, Key: "peer-key-1-AAAA", Name: "Peer 1", - IP: net.ParseIP("100.64.0.1"), + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), Meta: nbpeer.PeerSystemMeta{ Hostname: "peer1.example.com", GoOS: "linux", @@ -195,7 +255,8 @@ func TestGetAccount_ComprehensiveFieldValidation(t *testing.T) { AccountID: accountID, Key: "peer-key-2-BBBB", Name: "Peer 2", - IP: net.ParseIP("100.64.0.2"), + IP: netip.MustParseAddr("100.64.0.2"), + IPv6: netip.MustParseAddr("fd00::2"), Meta: nbpeer.PeerSystemMeta{ Hostname: "peer2.example.com", GoOS: "darwin", @@ -232,7 +293,8 @@ func TestGetAccount_ComprehensiveFieldValidation(t *testing.T) { AccountID: accountID, Key: "peer-key-3-CCCC", Name: "Peer 3 (Ephemeral)", - IP: net.ParseIP("100.64.0.3"), + IP: netip.MustParseAddr("100.64.0.3"), + IPv6: netip.MustParseAddr("fd00::3"), Meta: nbpeer.PeerSystemMeta{ Hostname: "peer3.example.com", GoOS: "windows", @@ -710,7 +772,7 @@ func TestGetAccount_ComprehensiveFieldValidation(t *testing.T) { require.True(t, exists, "Peer 1 should exist") assert.Equal(t, "Peer 1", p1.Name, "Peer 1 name mismatch") assert.Equal(t, "peer-key-1-AAAA", p1.Key, "Peer 1 key mismatch") - assert.True(t, p1.IP.Equal(net.ParseIP("100.64.0.1")), "Peer 1 IP mismatch") + assert.Equal(t, netip.MustParseAddr("100.64.0.1"), p1.IP, "Peer 1 IP mismatch") assert.Equal(t, userID1, p1.UserID, "Peer 1 user ID mismatch") assert.True(t, p1.SSHEnabled, "Peer 1 SSH should be enabled") assert.Equal(t, "ssh-rsa AAAAB3NzaC1...", p1.SSHKey, "Peer 1 SSH key mismatch") @@ -997,9 +1059,10 @@ func TestGetAccount_ComprehensiveFieldValidation(t *testing.T) { // Find posture checks by ID var pc1, pc2 *posture.Checks for _, pc := range retrievedAccount.PostureChecks { - if pc.ID == postureCheckID1 { + switch pc.ID { + case postureCheckID1: pc1 = pc - } else if pc.ID == postureCheckID2 { + case postureCheckID2: pc2 = pc } } diff --git a/management/server/store/sql_store_idp_migration.go b/management/server/store/sql_store_idp_migration.go new file mode 100644 index 000000000..64962845b --- /dev/null +++ b/management/server/store/sql_store_idp_migration.go @@ -0,0 +1,177 @@ +package store + +// This file contains migration-only methods on SqlStore. +// They satisfy the migration.Store interface via duck typing. +// Delete this file when migration tooling is no longer needed. + +import ( + "context" + "fmt" + + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + + "github.com/netbirdio/netbird/management/server/idp/migration" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +func (s *SqlStore) CheckSchema(checks []migration.SchemaCheck) []migration.SchemaError { + migrator := s.db.Migrator() + var errs []migration.SchemaError + + for _, check := range checks { + if !migrator.HasTable(check.Table) { + errs = append(errs, migration.SchemaError{Table: check.Table}) + continue + } + for _, col := range check.Columns { + if !migrator.HasColumn(check.Table, col) { + errs = append(errs, migration.SchemaError{Table: check.Table, Column: col}) + } + } + } + + return errs +} + +func (s *SqlStore) ListUsers(ctx context.Context) ([]*types.User, error) { + tx := s.db + var users []*types.User + result := tx.Find(&users) + if result.Error != nil { + log.WithContext(ctx).Errorf("error when listing users from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "issue listing users from store") + } + + for _, user := range users { + if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt user: %w", err) + } + } + + return users, nil +} + +// txDeferFKConstraints defers foreign key constraint checks for the duration of the transaction. +// MySQL is already handled by s.transaction (SET FOREIGN_KEY_CHECKS = 0). +func (s *SqlStore) txDeferFKConstraints(tx *gorm.DB) error { + if s.storeEngine == types.SqliteStoreEngine { + return tx.Exec("PRAGMA defer_foreign_keys = ON").Error + } + + if s.storeEngine != types.PostgresStoreEngine { + return nil + } + + // GORM creates FK constraints as NOT DEFERRABLE by default, so + // SET CONSTRAINTS ALL DEFERRED is a no-op unless we ALTER them first. + err := tx.Exec(` + DO $$ DECLARE r RECORD; + BEGIN + FOR r IN SELECT conname, conrelid::regclass AS tbl + FROM pg_constraint WHERE contype = 'f' AND NOT condeferrable + LOOP + EXECUTE format('ALTER TABLE %s ALTER CONSTRAINT %I DEFERRABLE INITIALLY IMMEDIATE', r.tbl, r.conname); + END LOOP; + END $$ + `).Error + if err != nil { + return fmt.Errorf("make FK constraints deferrable: %w", err) + } + return tx.Exec("SET CONSTRAINTS ALL DEFERRED").Error +} + +// txRestoreFKConstraints reverts FK constraints back to NOT DEFERRABLE after the +// deferred updates are done but before the transaction commits. +func (s *SqlStore) txRestoreFKConstraints(tx *gorm.DB) error { + if s.storeEngine != types.PostgresStoreEngine { + return nil + } + + return tx.Exec(` + DO $$ DECLARE r RECORD; + BEGIN + FOR r IN SELECT conname, conrelid::regclass AS tbl + FROM pg_constraint WHERE contype = 'f' AND condeferrable + LOOP + EXECUTE format('ALTER TABLE %s ALTER CONSTRAINT %I NOT DEFERRABLE', r.tbl, r.conname); + END LOOP; + END $$ + `).Error +} + +func (s *SqlStore) UpdateUserInfo(ctx context.Context, userID, email, name string) error { + user := &types.User{Email: email, Name: name} + if err := user.EncryptSensitiveData(s.fieldEncrypt); err != nil { + return fmt.Errorf("encrypt user info: %w", err) + } + + result := s.db.Model(&types.User{}).Where("id = ?", userID).Updates(map[string]any{ + "email": user.Email, + "name": user.Name, + }) + if result.Error != nil { + log.WithContext(ctx).Errorf("error updating user info for %s: %s", userID, result.Error) + return status.Errorf(status.Internal, "failed to update user info") + } + + return nil +} + +func (s *SqlStore) UpdateUserID(ctx context.Context, accountID, oldUserID, newUserID string) error { + type fkUpdate struct { + model any + column string + where string + } + + updates := []fkUpdate{ + {&types.PersonalAccessToken{}, "user_id", "user_id = ?"}, + {&types.PersonalAccessToken{}, "created_by", "created_by = ?"}, + {&nbpeer.Peer{}, "user_id", "user_id = ?"}, + {&types.UserInviteRecord{}, "created_by", "created_by = ?"}, + {&types.Account{}, "created_by", "created_by = ?"}, + {&types.ProxyAccessToken{}, "created_by", "created_by = ?"}, + {&types.Job{}, "triggered_by", "triggered_by = ?"}, + } + + log.Info("Updating user ID in the store") + err := s.transaction(func(tx *gorm.DB) error { + if err := s.txDeferFKConstraints(tx); err != nil { + return err + } + + for _, u := range updates { + if err := tx.Model(u.model).Where(u.where, oldUserID).Update(u.column, newUserID).Error; err != nil { + return fmt.Errorf("update %s: %w", u.column, err) + } + } + + if err := tx.Model(&types.User{}).Where(accountAndIDQueryCondition, accountID, oldUserID).Update("id", newUserID).Error; err != nil { + return fmt.Errorf("update users: %w", err) + } + + return nil + }) + if err != nil { + log.WithContext(ctx).Errorf("failed to update user ID in the store: %s", err) + return status.Errorf(status.Internal, "failed to update user ID in store") + } + + log.Info("Restoring FK constraints") + err = s.transaction(func(tx *gorm.DB) error { + if err := s.txRestoreFKConstraints(tx); err != nil { + return fmt.Errorf("restore FK constraints: %w", err) + } + + return nil + }) + if err != nil { + log.WithContext(ctx).Errorf("failed to restore FK constraints after user ID update: %s", err) + return status.Errorf(status.Internal, "failed to restore FK constraints after user ID update") + } + + return nil +} diff --git a/management/server/store/sql_store_proxy_clusters_test.go b/management/server/store/sql_store_proxy_clusters_test.go new file mode 100644 index 000000000..cdacfedae --- /dev/null +++ b/management/server/store/sql_store_proxy_clusters_test.go @@ -0,0 +1,109 @@ +package store + +import ( + "context" + "os" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" +) + +// TestSqlStore_GetProxyClusters_DerivesOnlineAndType guards the +// account-visible cluster list against silent regressions in two +// dimensions: +// +// 1. Online derivation: a cluster with one stale and one fresh proxy +// is online and counts only the fresh proxy; a cluster whose +// proxies all heartbeated outside the 2-min window appears offline +// with connected_proxies = 0 (rather than disappearing, which is +// what the old query did). +// 2. Type derivation: a cluster scoped to the calling account is +// reported as `account`; a cluster with account_id IS NULL is +// reported as `shared`. Clusters scoped to other accounts must not +// leak into the result. +// +// Capability flags are intentionally not asserted here — they're filled +// by the manager (handler) layer from the per-cluster capability +// lookups, not by the store query. +func TestSqlStore_GetProxyClusters_DerivesOnlineAndType(t *testing.T) { + if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { + t.Skip("skip CI tests on darwin and windows") + } + + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + ctx := context.Background() + accountID := "acct-clusters" + require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, accountID, "user-1", ""))) + + otherAccountID := "acct-other" + require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, otherAccountID, "user-2", ""))) + + acctID := accountID + otherID := otherAccountID + + fresh := time.Now().Add(-30 * time.Second) + stale := time.Now().Add(-30 * time.Minute) + + mustSave := func(id, cluster string, accID *string, status string, lastSeen time.Time) { + require.NoError(t, store.SaveProxy(ctx, &rpproxy.Proxy{ + ID: id, + SessionID: id + "-sess", + ClusterAddress: cluster, + IPAddress: "10.0.0.1", + AccountID: accID, + LastSeen: lastSeen, + Status: status, + })) + } + + // shared-mixed: one fresh + one stale proxy → online, connected=1 + mustSave("p-shared-fresh", "shared-mixed.netbird.io", nil, rpproxy.StatusConnected, fresh) + mustSave("p-shared-stale", "shared-mixed.netbird.io", nil, rpproxy.StatusConnected, stale) + + // shared-offline: only stale proxies → offline, connected=0, + // but row must still appear (this is the new semantic — old + // query would have dropped it entirely). + mustSave("p-shared-off", "shared-offline.netbird.io", nil, rpproxy.StatusConnected, stale) + + // account-online: BYOP cluster owned by acctID, fresh + mustSave("p-acct-fresh", "byop.acct.example", &acctID, rpproxy.StatusConnected, fresh) + + // other-account: must not surface for acctID + mustSave("p-other", "byop.other.example", &otherID, rpproxy.StatusConnected, fresh) + + clusters, err := store.GetProxyClusters(ctx, accountID) + require.NoError(t, err) + + byAddr := map[string]rpproxy.Cluster{} + for _, c := range clusters { + byAddr[c.Address] = c + } + + assert.NotContains(t, byAddr, "byop.other.example", + "another account's BYOP cluster must not leak into this account's listing") + + require.Contains(t, byAddr, "shared-mixed.netbird.io") + mixed := byAddr["shared-mixed.netbird.io"] + assert.Equal(t, rpproxy.ClusterTypeShared, mixed.Type, "shared cluster (account_id IS NULL) must be reported as Type=shared") + assert.True(t, mixed.Online, "cluster with a fresh proxy must be online") + assert.Equal(t, 1, mixed.ConnectedProxies, "connected_proxies must count only fresh proxies; the stale one should not bump the count") + + require.Contains(t, byAddr, "shared-offline.netbird.io", + "offline clusters must still appear so the dashboard can render them — the old GetActiveProxyClusters would have dropped this row, which is the regression this test guards against") + offline := byAddr["shared-offline.netbird.io"] + assert.Equal(t, rpproxy.ClusterTypeShared, offline.Type) + assert.False(t, offline.Online, "no fresh heartbeat → offline") + assert.Equal(t, 0, offline.ConnectedProxies, "no fresh proxies → connected_proxies=0") + + require.Contains(t, byAddr, "byop.acct.example") + acct := byAddr["byop.acct.example"] + assert.Equal(t, rpproxy.ClusterTypeAccount, acct.Type, "BYOP cluster owned by the account must be reported as Type=account") + assert.True(t, acct.Online) + assert.Equal(t, 1, acct.ConnectedProxies) + }) +} diff --git a/management/server/store/sql_store_proxy_disconnect_test.go b/management/server/store/sql_store_proxy_disconnect_test.go new file mode 100644 index 000000000..2d0f34680 --- /dev/null +++ b/management/server/store/sql_store_proxy_disconnect_test.go @@ -0,0 +1,156 @@ +package store + +import ( + "context" + "os" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" +) + +// TestSqlStore_DisconnectAllProxies guards the administrative +// force-disconnect helper: +// +// 1. Every proxy that is not already disconnected is marked +// disconnected regardless of its session ID (unlike +// DisconnectProxy, which is session-guarded). +// 2. Rows that are already disconnected are left untouched, so their +// original disconnected_at is preserved and the returned count +// reflects only the rows that actually changed. +// 3. last_seen is not modified — the stale-proxy reaper keeps working +// off the real last heartbeat. +func TestSqlStore_DisconnectAllProxies(t *testing.T) { + if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { + t.Skip("skip CI tests on darwin and windows") + } + + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + ctx := context.Background() + + lastSeenFresh := time.Now().Add(-30 * time.Second) + lastSeenStale := time.Now().Add(-30 * time.Minute) + oldDisconnectedAt := time.Now().Add(-time.Hour) + + accountID := "acct-disconnect" + proxies := []*rpproxy.Proxy{ + { + ID: "p-connected-fresh", + SessionID: "sess-1", + ClusterAddress: "cluster-a.example.com", + IPAddress: "10.0.0.1", + LastSeen: lastSeenFresh, + Status: rpproxy.StatusConnected, + }, + { + ID: "p-connected-stale", + SessionID: "sess-2", + ClusterAddress: "cluster-b.example.com", + IPAddress: "10.0.0.2", + AccountID: &accountID, + LastSeen: lastSeenStale, + Status: rpproxy.StatusConnected, + }, + { + ID: "p-already-disconnected", + SessionID: "sess-3", + ClusterAddress: "cluster-a.example.com", + IPAddress: "10.0.0.3", + LastSeen: lastSeenStale, + Status: rpproxy.StatusDisconnected, + DisconnectedAt: &oldDisconnectedAt, + }, + } + for _, p := range proxies { + require.NoError(t, store.SaveProxy(ctx, p)) + } + + all, err := store.GetAllProxies(ctx) + require.NoError(t, err) + require.Len(t, all, 3) + + disconnected, err := store.DisconnectAllProxies(ctx) + require.NoError(t, err) + assert.Equal(t, int64(2), disconnected) + + all, err = store.GetAllProxies(ctx) + require.NoError(t, err) + require.Len(t, all, 3) + + byID := make(map[string]*rpproxy.Proxy, len(all)) + for _, p := range all { + byID[p.ID] = p + } + + for id, p := range byID { + assert.Equal(t, rpproxy.StatusDisconnected, p.Status, "proxy %s should be disconnected", id) + require.NotNil(t, p.DisconnectedAt, "proxy %s should have disconnected_at set", id) + } + + // force-marked rows carry a fresh disconnected_at; the untouched row keeps its original one + assert.WithinDuration(t, time.Now(), *byID["p-connected-fresh"].DisconnectedAt, 10*time.Second) + assert.WithinDuration(t, time.Now(), *byID["p-connected-stale"].DisconnectedAt, 10*time.Second) + assert.WithinDuration(t, oldDisconnectedAt, *byID["p-already-disconnected"].DisconnectedAt, time.Second) + + // last_seen is preserved so the stale reaper schedule is unaffected + assert.WithinDuration(t, lastSeenFresh, byID["p-connected-fresh"].LastSeen, time.Second) + assert.WithinDuration(t, lastSeenStale, byID["p-connected-stale"].LastSeen, time.Second) + + // idempotent: a second run has nothing left to update + disconnected, err = store.DisconnectAllProxies(ctx) + require.NoError(t, err) + assert.Equal(t, int64(0), disconnected) + }) +} + +func TestSqlStore_UpdateProxyHeartbeatRestoresDisconnectedCurrentSession(t *testing.T) { + if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { + t.Skip("skip CI tests on darwin and windows") + } + + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + ctx := context.Background() + proxy := &rpproxy.Proxy{ + ID: "p-heartbeat", + SessionID: "sess-heartbeat", + ClusterAddress: "cluster-heartbeat.example.com", + IPAddress: "10.0.0.10", + LastSeen: time.Now().Add(-30 * time.Second), + Status: rpproxy.StatusConnected, + } + require.NoError(t, store.SaveProxy(ctx, proxy)) + + disconnected, err := store.DisconnectAllProxies(ctx) + require.NoError(t, err) + require.Equal(t, int64(1), disconnected) + + require.NoError(t, store.UpdateProxyHeartbeat(ctx, &rpproxy.Proxy{ID: proxy.ID, SessionID: proxy.SessionID})) + + all, err := store.GetAllProxies(ctx) + require.NoError(t, err) + require.Len(t, all, 1) + assert.Equal(t, rpproxy.StatusConnected, all[0].Status) + assert.Nil(t, all[0].DisconnectedAt) + assert.WithinDuration(t, time.Now(), all[0].LastSeen, 10*time.Second) + + addresses, err := store.GetActiveProxyClusterAddresses(ctx) + require.NoError(t, err) + assert.Contains(t, addresses, proxy.ClusterAddress) + }) +} + +func TestSqlStore_GetAllProxies_Empty(t *testing.T) { + if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { + t.Skip("skip CI tests on darwin and windows") + } + + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + all, err := store.GetAllProxies(context.Background()) + require.NoError(t, err) + assert.Empty(t, all) + }) +} diff --git a/management/server/store/sql_store_service_test.go b/management/server/store/sql_store_service_test.go new file mode 100644 index 000000000..34999da4b --- /dev/null +++ b/management/server/store/sql_store_service_test.go @@ -0,0 +1,46 @@ +package store + +import ( + "context" + "os" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" +) + +func TestSqlStore_GetAccount_PrivateServiceRoundtrip(t *testing.T) { + if os.Getenv("CI") == "true" && (runtime.GOOS == "darwin" || runtime.GOOS == "windows") { + t.Skip("skip CI tests on darwin and windows") + } + + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + ctx := context.Background() + account := newAccountWithId(ctx, "account_private_svc", "testuser", "") + require.NoError(t, store.SaveAccount(ctx, account)) + + svc := &rpservice.Service{ + ID: "svc-private", + AccountID: account.Id, + Name: "private-svc", + Domain: "private.example", + ProxyCluster: "cluster.example", + Enabled: true, + Mode: rpservice.ModeHTTP, + Private: true, + AccessGroups: []string{"grp-admins", "grp-ops"}, + } + require.NoError(t, store.CreateService(ctx, svc)) + + loaded, err := store.GetAccount(ctx, account.Id) + require.NoError(t, err) + require.Len(t, loaded.Services, 1) + + got := loaded.Services[0] + assert.True(t, got.Private) + assert.Equal(t, []string{"grp-admins", "grp-ops"}, got.AccessGroups) + }) +} diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index d40c4664c..ed3419dd7 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -6,10 +6,10 @@ import ( b64 "encoding/base64" "encoding/binary" "fmt" - "math/rand" "net" "net/netip" "os" + "reflect" "runtime" "sort" "sync" @@ -22,6 +22,10 @@ import ( "github.com/stretchr/testify/require" nbdns "github.com/netbirdio/netbird/dns" + proxydomain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/internals/modules/zones" + "github.com/netbirdio/netbird/management/internals/modules/zones/records" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" networkTypes "github.com/netbirdio/netbird/management/server/networks/types" @@ -30,8 +34,9 @@ import ( "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/management/server/util" nbroute "github.com/netbirdio/netbird/route" - route2 "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/status" + "github.com/netbirdio/netbird/shared/testing_helpers" + "github.com/netbirdio/netbird/util/crypt" ) func runTestForAllEngines(t *testing.T, testDataFile string, f func(t *testing.T, store Store)) { @@ -42,6 +47,7 @@ func runTestForAllEngines(t *testing.T, testDataFile string, f func(t *testing.T } t.Setenv("NETBIRD_STORE_ENGINE", string(engine)) store, cleanUp, err := NewTestStoreFromSQL(context.Background(), testDataFile, t.TempDir()) + assert.NoError(t, err, "engine: ", string(engine)) t.Cleanup(cleanUp) assert.NoError(t, err) t.Run(string(engine), func(t *testing.T) { @@ -88,13 +94,14 @@ func runLargeTest(t *testing.T, store Store) { account.SetupKeys[setupKey.Key] = setupKey const numPerAccount = 6000 for n := 0; n < numPerAccount; n++ { - netIP := randomIPv4() + netIP := sequentialIPv4(n) peerID := fmt.Sprintf("%s-peer-%d", account.Id, n) + addr, _ := netip.AddrFromSlice(netIP) peer := &nbpeer.Peer{ ID: peerID, Key: peerID, - IP: netIP, + IP: addr.Unmap(), Name: peerID, DNSLabel: peerID, UserID: "testuser", @@ -109,12 +116,12 @@ func runLargeTest(t *testing.T, store Store) { AccountID: account.Id, } account.Users[user.Id] = user - route := &route2.Route{ - ID: route2.ID(fmt.Sprintf("network-id-%d", n)), + route := &nbroute.Route{ + ID: nbroute.ID(fmt.Sprintf("network-id-%d", n)), Description: "base route", - NetID: route2.NetID(fmt.Sprintf("network-id-%d", n)), + NetID: nbroute.NetID(fmt.Sprintf("network-id-%d", n)), Network: netip.MustParsePrefix(netIP.String() + "/24"), - NetworkType: route2.IPv4Network, + NetworkType: nbroute.IPv4Network, Metric: 9999, Masquerade: false, Enabled: true, @@ -211,12 +218,12 @@ func runLargeTest(t *testing.T, store Store) { } } -func randomIPv4() net.IP { - rand.New(rand.NewSource(time.Now().UnixNano())) +// sequentialIPv4 returns a unique IPv4 address for the given index, avoiding +// the random collisions that would otherwise violate the unique (account_id, ip) +// index when generating a large number of peers. +func sequentialIPv4(n int) net.IP { b := make([]byte, 4) - for i := range b { - b[i] = byte(rand.Intn(256)) - } + binary.BigEndian.PutUint32(b, 0x0A000000+uint32(n)) return net.IP(b) } @@ -231,7 +238,8 @@ func Test_SaveAccount(t *testing.T) { account.SetupKeys[setupKey.Key] = setupKey account.Peers["testpeer"] = &nbpeer.Peer{ Key: "peerkey", - IP: net.IP{127, 0, 0, 1}, + IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::1"), Meta: nbpeer.PeerSystemMeta{}, Name: "peer name", Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, @@ -245,7 +253,8 @@ func Test_SaveAccount(t *testing.T) { account2.SetupKeys[setupKey.Key] = setupKey account2.Peers["testpeer2"] = &nbpeer.Peer{ Key: "peerkey2", - IP: net.IP{127, 0, 0, 2}, + IP: netip.AddrFrom4([4]byte{127, 0, 0, 2}), + IPv6: netip.MustParseAddr("fd00::2"), Meta: nbpeer.PeerSystemMeta{}, Name: "peer name 2", Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, @@ -290,6 +299,53 @@ func Test_SaveAccount(t *testing.T) { }) } +func Test_AccountSettings_SaveAndRetrieve(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("The SQLite store is not properly supported by Windows yet") + } + + populateFields := testing_helpers.NewPopulateFields().WithCustomFieldSetter( + reflect.PointerTo(reflect.TypeOf(types.ExtraSettings{})), func(this *testing_helpers.PopulateFields, field reflect.Value) (int, error) { + es := types.ExtraSettings{} + reflectedEs := reflect.ValueOf(&es).Elem() + n, err := this.PopulateAll(reflectedEs) + if err != nil { + return n, err + } + field.Set(reflectedEs.Addr()) + return n, nil + }).WithCustomFieldSetter( + reflect.PointerTo(reflect.TypeOf(types.DashboardFeatures{})), func(this *testing_helpers.PopulateFields, field reflect.Value) (int, error) { + t := true + df := types.DashboardFeatures{AgentNetwork: &t} + reflectedDf := reflect.ValueOf(&df).Elem() + field.Set(reflectedDf.Addr()) + return 1, nil + }).WithSkippedTag("gorm", "-") + + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + account := newAccountWithId(context.Background(), "account_id", "testuser", "") + setupKey, _ := types.GenerateDefaultSetupKey() + account.SetupKeys[setupKey.Key] = setupKey + + settings := types.Settings{} + numOfExportedFields, err := populateFields.PopulateAll(reflect.ValueOf(&settings).Elem()) + assert.NoError(t, err) + assert.Equal(t, 27, numOfExportedFields) + account.Settings = &settings + + err = store.SaveAccount(context.Background(), account) + assert.NoError(t, err) + + accountFromDb, err := store.GetAccount(context.Background(), account.Id) + assert.NoError(t, err) + assert.NotNil(t, accountFromDb) + assert.NotNil(t, accountFromDb.Settings) + + assert.True(t, reflect.DeepEqual(&settings, accountFromDb.Settings), "created settings and settings retrieved from the db should match") + }) +} + func TestSqlite_DeleteAccount(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("The SQLite store is not properly supported by Windows yet") @@ -312,7 +368,8 @@ func TestSqlite_DeleteAccount(t *testing.T) { account.SetupKeys[setupKey.Key] = setupKey account.Peers["testpeer"] = &nbpeer.Peer{ Key: "peerkey", - IP: net.IP{127, 0, 0, 1}, + IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::1"), Meta: nbpeer.PeerSystemMeta{}, Name: "peer name", Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, @@ -348,6 +405,35 @@ func TestSqlite_DeleteAccount(t *testing.T) { }, } + account.Services = []*rpservice.Service{ + { + ID: "service_id", + AccountID: account.Id, + Name: "test service", + Domain: "svc.example.com", + Enabled: true, + Targets: []*rpservice.Target{ + { + AccountID: account.Id, + ServiceID: "service_id", + Host: "localhost", + Port: 8080, + Protocol: "http", + Enabled: true, + }, + }, + }, + } + + account.Domains = []*proxydomain.Domain{ + { + ID: "domain_id", + Domain: "custom.example.com", + AccountID: account.Id, + Validated: true, + }, + } + err = store.SaveAccount(context.Background(), account) require.NoError(t, err) @@ -409,6 +495,20 @@ func TestSqlite_DeleteAccount(t *testing.T) { require.NoError(t, err, "expecting no error after removing DeleteAccount when searching for network resources") require.Len(t, resources, 0, "expecting no network resources to be found after DeleteAccount") } + + domains, err := store.ListCustomDomains(context.Background(), account.Id) + require.NoError(t, err, "expecting no error after DeleteAccount when searching for custom domains") + require.Len(t, domains, 0, "expecting no custom domains to be found after DeleteAccount") + + var services []*rpservice.Service + err = store.(*SqlStore).db.Model(&rpservice.Service{}).Find(&services, "account_id = ?", account.Id).Error + require.NoError(t, err, "expecting no error after DeleteAccount when searching for services") + require.Len(t, services, 0, "expecting no services to be found after DeleteAccount") + + var targets []*rpservice.Target + err = store.(*SqlStore).db.Model(&rpservice.Target{}).Find(&targets, "account_id = ?", account.Id).Error + require.NoError(t, err, "expecting no error after DeleteAccount when searching for service targets") + require.Len(t, targets, 0, "expecting no service targets to be found after DeleteAccount") } func Test_GetAccount(t *testing.T) { @@ -440,53 +540,82 @@ func Test_GetAccount(t *testing.T) { }) } +// TestSqlStore_GetPeerByIP_NotFound pins the not-found semantics the +// proxy's ValidateTunnelPeer relies on: a tunnel-IP that isn't in the +// account roster must surface as a NotFound error (not a generic +// Internal) so callers can distinguish an expected miss from a real +// store failure. A known IP still resolves. +func TestSqlStore_GetPeerByIP_NotFound(t *testing.T) { + runTestForAllEngines(t, "../testdata/store.sql", func(t *testing.T, store Store) { + const accountID = "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + peer, err := store.GetPeerByIP(context.Background(), LockingStrengthNone, accountID, net.ParseIP("192.168.0.0")) + require.NoError(t, err, "known tunnel IP must resolve") + require.NotNil(t, peer) + + _, err = store.GetPeerByIP(context.Background(), LockingStrengthNone, accountID, net.ParseIP("100.65.0.99")) + require.Error(t, err, "unknown tunnel IP must error") + parsedErr, ok := status.FromError(err) + require.True(t, ok, "error must be a status error") + require.Equal(t, status.NotFound, parsedErr.Type(), "tunnel-IP miss must be NotFound, not Internal") + }) +} + func TestSqlStore_SavePeer(t *testing.T) { - store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanUp) - assert.NoError(t, err) + populateFields := testing_helpers.NewPopulateFields() - account, err := store.GetAccount(context.Background(), "bf1c8084-ba50-4ce7-9439-34653001fc3b") - require.NoError(t, err) + runTestForAllEngines(t, "../testdata/store.sql", func(t *testing.T, store Store) { + account, err := store.GetAccount(context.Background(), "bf1c8084-ba50-4ce7-9439-34653001fc3b") + require.NoError(t, err) - // save status of non-existing peer - peer := &nbpeer.Peer{ - Key: "peerkey", - ID: "testpeer", - IP: net.IP{127, 0, 0, 1}, - Meta: nbpeer.PeerSystemMeta{Hostname: "testingpeer"}, - Name: "peer name", - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, - CreatedAt: time.Now().UTC(), - } - ctx := context.Background() - err = store.SavePeer(ctx, account.Id, peer) - assert.Error(t, err) - parsedErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") + metadata := nbpeer.PeerSystemMeta{} + reflectedMetadata := reflect.ValueOf(&metadata).Elem() - // save new status of existing peer - account.Peers[peer.ID] = peer + numOfFields, err := populateFields.PopulateAll(reflectedMetadata) + assert.NoError(t, err) + assert.Equal(t, 32, numOfFields) - err = store.SaveAccount(context.Background(), account) - require.NoError(t, err) + // save status of non-existing peer + peer := &nbpeer.Peer{ + Key: "peerkey", + ID: "testpeer", + IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::1"), + Meta: metadata, //nbpeer.PeerSystemMeta{Hostname: "testingpeer"}, + Name: "peer name", + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, + CreatedAt: time.Now().UTC(), + } + ctx := context.Background() + err = store.SavePeer(ctx, account.Id, peer) + assert.Error(t, err) + parsedErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") - updatedPeer := peer.Copy() - updatedPeer.Status.Connected = false - updatedPeer.Meta.Hostname = "updatedpeer" + // save new status of existing peer + account.Peers[peer.ID] = peer - err = store.SavePeer(ctx, account.Id, updatedPeer) - require.NoError(t, err) + err = store.SaveAccount(context.Background(), account) + require.NoError(t, err) - account, err = store.GetAccount(context.Background(), account.Id) - require.NoError(t, err) + updatedPeer := peer.Copy() + updatedPeer.Status.Connected = false + updatedPeer.Meta.Hostname = "updatedpeer" - actual := account.Peers[peer.ID] - assert.Equal(t, updatedPeer.Meta, actual.Meta) - assert.Equal(t, updatedPeer.Status.Connected, actual.Status.Connected) - assert.Equal(t, updatedPeer.Status.LoginExpired, actual.Status.LoginExpired) - assert.Equal(t, updatedPeer.Status.RequiresApproval, actual.Status.RequiresApproval) - assert.WithinDurationf(t, updatedPeer.Status.LastSeen, actual.Status.LastSeen.UTC(), time.Millisecond, "LastSeen should be equal") + err = store.SavePeer(ctx, account.Id, updatedPeer) + require.NoError(t, err) + + account, err = store.GetAccount(context.Background(), account.Id) + require.NoError(t, err) + + actual := account.Peers[peer.ID] + assert.Equal(t, updatedPeer.Meta, actual.Meta) + assert.Equal(t, updatedPeer.Status.Connected, actual.Status.Connected) + assert.Equal(t, updatedPeer.Status.LoginExpired, actual.Status.LoginExpired) + assert.Equal(t, updatedPeer.Status.RequiresApproval, actual.Status.RequiresApproval) + assert.WithinDurationf(t, updatedPeer.Status.LastSeen, actual.Status.LastSeen.UTC(), time.Millisecond, "LastSeen should be equal") + }) } func TestSqlStore_SavePeerStatus(t *testing.T) { @@ -509,7 +638,8 @@ func TestSqlStore_SavePeerStatus(t *testing.T) { account.Peers["testpeer"] = &nbpeer.Peer{ Key: "peerkey", ID: "testpeer", - IP: net.IP{127, 0, 0, 1}, + IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::1"), Meta: nbpeer.PeerSystemMeta{}, Name: "peer name", Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, @@ -545,56 +675,6 @@ func TestSqlStore_SavePeerStatus(t *testing.T) { assert.WithinDurationf(t, newStatus.LastSeen, actual.LastSeen.UTC(), time.Millisecond, "LastSeen should be equal") } -func TestSqlStore_SavePeerLocation(t *testing.T) { - store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanUp) - assert.NoError(t, err) - - account, err := store.GetAccount(context.Background(), "bf1c8084-ba50-4ce7-9439-34653001fc3b") - require.NoError(t, err) - - peer := &nbpeer.Peer{ - AccountID: account.Id, - ID: "testpeer", - Location: nbpeer.Location{ - ConnectionIP: net.ParseIP("0.0.0.0"), - CountryCode: "YY", - CityName: "City", - GeoNameID: 1, - }, - CreatedAt: time.Now().UTC(), - Meta: nbpeer.PeerSystemMeta{}, - } - // error is expected as peer is not in store yet - err = store.SavePeerLocation(context.Background(), account.Id, peer) - assert.Error(t, err) - - account.Peers[peer.ID] = peer - err = store.SaveAccount(context.Background(), account) - require.NoError(t, err) - - peer.Location.ConnectionIP = net.ParseIP("35.1.1.1") - peer.Location.CountryCode = "DE" - peer.Location.CityName = "Berlin" - peer.Location.GeoNameID = 2950159 - - err = store.SavePeerLocation(context.Background(), account.Id, account.Peers[peer.ID]) - assert.NoError(t, err) - - account, err = store.GetAccount(context.Background(), account.Id) - require.NoError(t, err) - - actual := account.Peers[peer.ID].Location - assert.Equal(t, peer.Location, actual) - - peer.ID = "non-existing-peer" - err = store.SavePeerLocation(context.Background(), account.Id, peer) - assert.Error(t, err) - parsedErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") -} - func Test_TestGetAccountByPrivateDomain(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("The SQLite store is not properly supported by Windows yet") @@ -688,7 +768,7 @@ func TestMigrate(t *testing.T) { require.NoError(t, err, "Failed to insert Gob data") type route struct { - route2.Route + nbroute.Route Network netip.Prefix `gorm:"serializer:gob"` PeerGroups []string `gorm:"serializer:gob"` } @@ -697,7 +777,7 @@ func TestMigrate(t *testing.T) { rt := &route{ Network: prefix, PeerGroups: []string{"group1", "group2"}, - Route: route2.Route{ID: "route1"}, + Route: nbroute.Route{ID: "route1"}, } err = store.(*SqlStore).db.Save(rt).Error @@ -713,7 +793,7 @@ func TestMigrate(t *testing.T) { require.NoError(t, err, "Failed to delete Gob data") prefix = netip.MustParsePrefix("12.0.0.0/24") - nRT := &route2.Route{ + nRT := &nbroute.Route{ Network: prefix, ID: "route2", Peer: "peer-id", @@ -737,7 +817,8 @@ func newAccount(store Store, id int) error { account.SetupKeys[setupKey.Key] = setupKey account.Peers["p"+str] = &nbpeer.Peer{ Key: "peerkey" + str, - IP: net.IP{127, 0, 0, 1}, + IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::1"), Meta: nbpeer.PeerSystemMeta{}, Name: "peer name", Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, @@ -776,7 +857,8 @@ func TestPostgresql_SaveAccount(t *testing.T) { account.SetupKeys[setupKey.Key] = setupKey account.Peers["testpeer"] = &nbpeer.Peer{ Key: "peerkey", - IP: net.IP{127, 0, 0, 1}, + IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::1"), Meta: nbpeer.PeerSystemMeta{}, Name: "peer name", Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, @@ -790,7 +872,8 @@ func TestPostgresql_SaveAccount(t *testing.T) { account2.SetupKeys[setupKey.Key] = setupKey account2.Peers["testpeer2"] = &nbpeer.Peer{ Key: "peerkey2", - IP: net.IP{127, 0, 0, 2}, + IP: netip.AddrFrom4([4]byte{127, 0, 0, 2}), + IPv6: netip.MustParseAddr("fd00::2"), Meta: nbpeer.PeerSystemMeta{}, Name: "peer name 2", Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, @@ -856,7 +939,8 @@ func TestPostgresql_DeleteAccount(t *testing.T) { account.SetupKeys[setupKey.Key] = setupKey account.Peers["testpeer"] = &nbpeer.Peer{ Key: "peerkey", - IP: net.IP{127, 0, 0, 1}, + IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::1"), Meta: nbpeer.PeerSystemMeta{}, Name: "peer name", Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, @@ -963,35 +1047,39 @@ func TestSqlite_GetTakenIPs(t *testing.T) { takenIPs, err := store.GetTakenIPs(context.Background(), LockingStrengthNone, existingAccountID) require.NoError(t, err) - assert.Equal(t, []net.IP{}, takenIPs) + assert.Equal(t, []netip.Addr{}, takenIPs) peer1 := &nbpeer.Peer{ ID: "peer1", AccountID: existingAccountID, + Key: "key1", DNSLabel: "peer1", - IP: net.IP{1, 1, 1, 1}, + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + IPv6: netip.MustParseAddr("fd00::1:1:1:1"), } err = store.AddPeerToAccount(context.Background(), peer1) require.NoError(t, err) takenIPs, err = store.GetTakenIPs(context.Background(), LockingStrengthNone, existingAccountID) require.NoError(t, err) - ip1 := net.IP{1, 1, 1, 1}.To16() - assert.Equal(t, []net.IP{ip1}, takenIPs) + ip1 := netip.AddrFrom4([4]byte{1, 1, 1, 1}) + assert.Equal(t, []netip.Addr{ip1}, takenIPs) peer2 := &nbpeer.Peer{ ID: "peer1second", AccountID: existingAccountID, + Key: "key2", DNSLabel: "peer1-1", - IP: net.IP{2, 2, 2, 2}, + IP: netip.AddrFrom4([4]byte{2, 2, 2, 2}), + IPv6: netip.MustParseAddr("fd00::2:2:2:2"), } err = store.AddPeerToAccount(context.Background(), peer2) require.NoError(t, err) takenIPs, err = store.GetTakenIPs(context.Background(), LockingStrengthNone, existingAccountID) require.NoError(t, err) - ip2 := net.IP{2, 2, 2, 2}.To16() - assert.Equal(t, []net.IP{ip1, ip2}, takenIPs) + ip2 := netip.AddrFrom4([4]byte{2, 2, 2, 2}) + assert.Equal(t, []netip.Addr{ip1, ip2}, takenIPs) } func TestSqlite_GetPeerLabelsInAccount(t *testing.T) { @@ -1009,8 +1097,10 @@ func TestSqlite_GetPeerLabelsInAccount(t *testing.T) { peer1 := &nbpeer.Peer{ ID: "peer1", AccountID: existingAccountID, + Key: "key1", DNSLabel: "peer1", - IP: net.IP{1, 1, 1, 1}, + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + IPv6: netip.MustParseAddr("fd00::1:1:1:1"), } err = store.AddPeerToAccount(context.Background(), peer1) require.NoError(t, err) @@ -1022,8 +1112,10 @@ func TestSqlite_GetPeerLabelsInAccount(t *testing.T) { peer2 := &nbpeer.Peer{ ID: "peer1second", AccountID: existingAccountID, + Key: "key2", DNSLabel: "peer1-1", - IP: net.IP{2, 2, 2, 2}, + IP: netip.AddrFrom4([4]byte{2, 2, 2, 2}), + IPv6: netip.MustParseAddr("fd00::2:2:2:2"), } err = store.AddPeerToAccount(context.Background(), peer2) require.NoError(t, err) @@ -1048,6 +1140,7 @@ func Test_AddPeerWithSameDnsLabel(t *testing.T) { peer1 := &nbpeer.Peer{ ID: "peer1", AccountID: existingAccountID, + Key: "key1", DNSLabel: "peer1.domain.test", } err = store.AddPeerToAccount(context.Background(), peer1) @@ -1056,6 +1149,7 @@ func Test_AddPeerWithSameDnsLabel(t *testing.T) { peer2 := &nbpeer.Peer{ ID: "peer1second", AccountID: existingAccountID, + Key: "key2", DNSLabel: "peer1.domain.test", } err = store.AddPeerToAccount(context.Background(), peer2) @@ -1073,7 +1167,9 @@ func Test_AddPeerWithSameIP(t *testing.T) { peer1 := &nbpeer.Peer{ ID: "peer1", AccountID: existingAccountID, - IP: net.IP{1, 1, 1, 1}, + Key: "key1", + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + IPv6: netip.MustParseAddr("fd00::1:1:1:1"), } err = store.AddPeerToAccount(context.Background(), peer1) require.NoError(t, err) @@ -1081,7 +1177,9 @@ func Test_AddPeerWithSameIP(t *testing.T) { peer2 := &nbpeer.Peer{ ID: "peer1second", AccountID: existingAccountID, - IP: net.IP{1, 1, 1, 1}, + Key: "key2", + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + IPv6: netip.MustParseAddr("fd00::2:2:2:2"), } err = store.AddPeerToAccount(context.Background(), peer2) require.Error(t, err) @@ -1204,6 +1302,61 @@ func TestSqlite_CreateAndGetObjectInTransaction(t *testing.T) { assert.NoError(t, err) } +func TestSqlStore_SaveAccountPersistsAgentNetworkOnly(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + account, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.False(t, account.Settings.AgentNetworkOnly, "setting should default to false") + + account.Settings.AgentNetworkOnly = true + require.NoError(t, store.SaveAccount(context.Background(), account)) + + reloaded, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.True(t, reloaded.Settings.AgentNetworkOnly, "setting should survive a save/load round-trip") + + reloaded.Settings.AgentNetworkOnly = false + require.NoError(t, store.SaveAccount(context.Background(), reloaded)) + + disabled, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.False(t, disabled.Settings.AgentNetworkOnly, "disabling should persist") +} + +func TestSqlStore_SaveAccountPersistsDashboardFeatures(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + account, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.Nil(t, account.Settings.DashboardFeatures, "dashboard features should default to unset") + + agentNetwork := true + account.Settings.DashboardFeatures = &types.DashboardFeatures{AgentNetwork: &agentNetwork} + require.NoError(t, store.SaveAccount(context.Background(), account)) + + reloaded, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.NotNil(t, reloaded.Settings.DashboardFeatures, "dashboard features should survive a save/load round-trip") + require.NotNil(t, reloaded.Settings.DashboardFeatures.AgentNetwork, "agent network flag should be set") + require.True(t, *reloaded.Settings.DashboardFeatures.AgentNetwork, "agent network flag should persist as true") + + disabled := false + reloaded.Settings.DashboardFeatures = &types.DashboardFeatures{AgentNetwork: &disabled} + require.NoError(t, store.SaveAccount(context.Background(), reloaded)) + + reloadedDisabled, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.NotNil(t, reloadedDisabled.Settings.DashboardFeatures.AgentNetwork, "agent network flag should remain set") + require.False(t, *reloadedDisabled.Settings.DashboardFeatures.AgentNetwork, "explicit false should persist") +} + func TestSqlStore_GetAccountUsers(t *testing.T) { store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) t.Cleanup(cleanup) @@ -1350,6 +1503,9 @@ func TestSqlStore_GetGroupsByIDs(t *testing.T) { } func TestSqlStore_CreateGroup(t *testing.T) { + if os.Getenv("CI") == "true" { + t.Log("Skipping MySQL test on CI") + } t.Setenv("NETBIRD_STORE_ENGINE", string(types.MysqlStoreEngine)) store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) t.Cleanup(cleanup) @@ -2090,7 +2246,7 @@ func newAccountWithId(ctx context.Context, accountID, userID, domain string) *ty setupKeys := map[string]*types.SetupKey{} nameServersGroups := make(map[string]*nbdns.NameServerGroup) - owner := types.NewOwnerUser(userID) + owner := types.NewOwnerUser(userID, "", "") owner.AccountID = accountID users[userID] = owner @@ -2325,7 +2481,7 @@ func TestSqlStore_GetNetworkRouterByID(t *testing.T) { } } -func TestSqlStore_SaveNetworkRouter(t *testing.T) { +func TestSqlStore_CreateNetworkRouter(t *testing.T) { store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) t.Cleanup(cleanup) require.NoError(t, err) @@ -2336,7 +2492,7 @@ func TestSqlStore_SaveNetworkRouter(t *testing.T) { netRouter, err := routerTypes.NewNetworkRouter(accountID, networkID, "", []string{"net-router-grp"}, true, 0, true) require.NoError(t, err) - err = store.SaveNetworkRouter(context.Background(), netRouter) + err = store.CreateNetworkRouter(context.Background(), netRouter) require.NoError(t, err) savedNetRouter, err := store.GetNetworkRouterByID(context.Background(), LockingStrengthNone, accountID, netRouter.ID) @@ -2344,6 +2500,39 @@ func TestSqlStore_SaveNetworkRouter(t *testing.T) { require.Equal(t, netRouter, savedNetRouter) } +func TestSqlStore_UpdateNetworkRouter(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + networkID := "ct286bi7qv930dsrrug0" + routerID := "ctc20ji7qv9ck2sebc80" + + netRouter := &routerTypes.NetworkRouter{ + ID: routerID, + AccountID: accountID, + NetworkID: networkID, + Peer: "", + PeerGroups: []string{"net-router-grp"}, + Masquerade: true, + Metric: 42, + Enabled: true, + } + + err = store.UpdateNetworkRouter(context.Background(), netRouter) + require.NoError(t, err) + + savedNetRouter, err := store.GetNetworkRouterByID(context.Background(), LockingStrengthNone, accountID, routerID) + require.NoError(t, err) + require.Equal(t, netRouter, savedNetRouter) + + // Updating a router under a different account must not match any row. + netRouter.AccountID = "non-existent-account" + err = store.UpdateNetworkRouter(context.Background(), netRouter) + require.Error(t, err) +} + func TestSqlStore_DeleteNetworkRouter(t *testing.T) { store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) t.Cleanup(cleanup) @@ -2582,7 +2771,8 @@ func TestSqlStore_AddPeerToAccount(t *testing.T) { ID: "peer1", AccountID: accountID, Key: "key", - IP: net.IP{1, 1, 1, 1}, + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + IPv6: netip.MustParseAddr("fd00::1:1:1:1"), Meta: nbpeer.PeerSystemMeta{ Hostname: "hostname", GoOS: "linux", @@ -2671,7 +2861,7 @@ func TestSqlStore_GetAccountPeers(t *testing.T) { { name: "should retrieve peers for an existing account ID", accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", - expectedCount: 4, + expectedCount: 5, }, { name: "should return no peers for a non-existing account ID", @@ -2693,7 +2883,7 @@ func TestSqlStore_GetAccountPeers(t *testing.T) { name: "should filter peers by partial name", accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", nameFilter: "host", - expectedCount: 3, + expectedCount: 4, }, { name: "should filter peers by ip", @@ -2719,14 +2909,16 @@ func TestSqlStore_GetAccountPeersWithExpiration(t *testing.T) { require.NoError(t, err) tests := []struct { - name string - accountID string - expectedCount int + name string + accountID string + expectedCount int + expectedPeerIDs []string }{ { - name: "should retrieve peers with expiration for an existing account ID", - accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", - expectedCount: 1, + name: "should retrieve only non-expired peers with expiration enabled", + accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", + expectedCount: 1, + expectedPeerIDs: []string{"notexpired01"}, }, { name: "should return no peers with expiration for a non-existing account ID", @@ -2745,10 +2937,30 @@ func TestSqlStore_GetAccountPeersWithExpiration(t *testing.T) { peers, err := store.GetAccountPeersWithExpiration(context.Background(), LockingStrengthNone, tt.accountID) require.NoError(t, err) require.Len(t, peers, tt.expectedCount) + for i, peer := range peers { + assert.Equal(t, tt.expectedPeerIDs[i], peer.ID) + } }) } } +func TestSqlStore_GetAccountPeersWithExpiration_ExcludesAlreadyExpired(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_with_expired_peers.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + peers, err := store.GetAccountPeersWithExpiration(context.Background(), LockingStrengthNone, accountID) + require.NoError(t, err) + + // Verify the already-expired peer (cg05lnblo1hkg2j514p0) is not returned + for _, peer := range peers { + assert.NotEqual(t, "cg05lnblo1hkg2j514p0", peer.ID, "already expired peer should not be returned") + assert.False(t, peer.Status.LoginExpired, "returned peers should not have LoginExpired set") + } +} + func TestSqlStore_GetAccountPeersWithInactivity(t *testing.T) { store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_with_expired_peers.sql", t.TempDir()) t.Cleanup(cleanup) @@ -2829,7 +3041,7 @@ func TestSqlStore_GetUserPeers(t *testing.T) { name: "should retrieve peers for another valid account ID and user ID", accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", userID: "edafee4e-63fb-11ec-90d6-0242ac120003", - expectedCount: 2, + expectedCount: 3, }, { name: "should return no peers for existing account ID with empty user ID", @@ -3114,6 +3326,138 @@ func TestSqlStore_SaveUsers(t *testing.T) { require.Equal(t, users[1].AutoGroups, user.AutoGroups) } +func TestSqlStore_SaveUserWithEncryption(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + // Enable encryption + key, err := crypt.GenerateKey() + require.NoError(t, err) + fieldEncrypt, err := crypt.NewFieldEncrypt(key) + require.NoError(t, err) + store.SetFieldEncrypt(fieldEncrypt) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + // rawUser is used to read raw (potentially encrypted) data from the database + // without any gorm hooks or automatic decryption + type rawUser struct { + Id string + Email string + Name string + } + + t.Run("save user with empty email and name", func(t *testing.T) { + user := &types.User{ + Id: "user-empty-fields", + AccountID: accountID, + Role: types.UserRoleUser, + Email: "", + Name: "", + AutoGroups: []string{"groupA"}, + } + err = store.SaveUser(context.Background(), user) + require.NoError(t, err) + + // Verify using direct database query that empty strings remain empty (not encrypted) + var raw rawUser + err = store.(*SqlStore).db.Table("users").Select("id, email, name").Where("id = ?", user.Id).First(&raw).Error + require.NoError(t, err) + require.Equal(t, "", raw.Email, "empty email should remain empty in database") + require.Equal(t, "", raw.Name, "empty name should remain empty in database") + + // Verify manual decryption returns empty strings + decryptedEmail, err := fieldEncrypt.Decrypt(raw.Email) + require.NoError(t, err) + require.Equal(t, "", decryptedEmail) + + decryptedName, err := fieldEncrypt.Decrypt(raw.Name) + require.NoError(t, err) + require.Equal(t, "", decryptedName) + }) + + t.Run("save user with email and name", func(t *testing.T) { + user := &types.User{ + Id: "user-with-fields", + AccountID: accountID, + Role: types.UserRoleAdmin, + Email: "test@example.com", + Name: "Test User", + AutoGroups: []string{"groupB"}, + } + err = store.SaveUser(context.Background(), user) + require.NoError(t, err) + + // Verify using direct database query that the data is encrypted (not plaintext) + var raw rawUser + err = store.(*SqlStore).db.Table("users").Select("id, email, name").Where("id = ?", user.Id).First(&raw).Error + require.NoError(t, err) + require.NotEqual(t, "test@example.com", raw.Email, "email should be encrypted in database") + require.NotEqual(t, "Test User", raw.Name, "name should be encrypted in database") + + // Verify manual decryption returns correct values + decryptedEmail, err := fieldEncrypt.Decrypt(raw.Email) + require.NoError(t, err) + require.Equal(t, "test@example.com", decryptedEmail) + + decryptedName, err := fieldEncrypt.Decrypt(raw.Name) + require.NoError(t, err) + require.Equal(t, "Test User", decryptedName) + }) + + t.Run("save multiple users with mixed fields", func(t *testing.T) { + users := []*types.User{ + { + Id: "batch-user-1", + AccountID: accountID, + Email: "", + Name: "", + }, + { + Id: "batch-user-2", + AccountID: accountID, + Email: "batch@example.com", + Name: "Batch User", + }, + } + err = store.SaveUsers(context.Background(), users) + require.NoError(t, err) + + // Verify first user (empty fields) using direct database query + var raw1 rawUser + err = store.(*SqlStore).db.Table("users").Select("id, email, name").Where("id = ?", "batch-user-1").First(&raw1).Error + require.NoError(t, err) + require.Equal(t, "", raw1.Email, "empty email should remain empty in database") + require.Equal(t, "", raw1.Name, "empty name should remain empty in database") + + // Verify second user (with fields) using direct database query + var raw2 rawUser + err = store.(*SqlStore).db.Table("users").Select("id, email, name").Where("id = ?", "batch-user-2").First(&raw2).Error + require.NoError(t, err) + require.NotEqual(t, "batch@example.com", raw2.Email, "email should be encrypted in database") + require.NotEqual(t, "Batch User", raw2.Name, "name should be encrypted in database") + + // Verify manual decryption returns empty strings for first user + decryptedEmail1, err := fieldEncrypt.Decrypt(raw1.Email) + require.NoError(t, err) + require.Equal(t, "", decryptedEmail1) + + decryptedName1, err := fieldEncrypt.Decrypt(raw1.Name) + require.NoError(t, err) + require.Equal(t, "", decryptedName1) + + // Verify manual decryption returns correct values for second user + decryptedEmail2, err := fieldEncrypt.Decrypt(raw2.Email) + require.NoError(t, err) + require.Equal(t, "batch@example.com", decryptedEmail2) + + decryptedName2, err := fieldEncrypt.Decrypt(raw2.Name) + require.NoError(t, err) + require.Equal(t, "Batch User", decryptedName2) + }) +} + func TestSqlStore_DeleteUser(t *testing.T) { store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) t.Cleanup(cleanup) @@ -3411,13 +3755,13 @@ func TestSqlStore_SaveRoute(t *testing.T) { accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - route := &route2.Route{ + route := &nbroute.Route{ ID: "route-id", AccountID: accountID, Network: netip.MustParsePrefix("10.10.0.0/16"), NetID: "netID", PeerGroups: []string{"routeA"}, - NetworkType: route2.IPv4Network, + NetworkType: nbroute.IPv4Network, Masquerade: true, Metric: 9999, Enabled: true, @@ -3564,6 +3908,7 @@ func BenchmarkGetAccountPeers(b *testing.B) { peer := &nbpeer.Peer{ ID: fmt.Sprintf("peer-%d", i), AccountID: accountID, + Key: fmt.Sprintf("key-%d", i), DNSLabel: fmt.Sprintf("peer%d.example.com", i), IP: intToIPv4(uint32(i)), } @@ -3602,10 +3947,10 @@ func BenchmarkGetAccountPeers(b *testing.B) { } } -func intToIPv4(n uint32) net.IP { - ip := make(net.IP, 4) - binary.BigEndian.PutUint32(ip, n) - return ip +func intToIPv4(n uint32) netip.Addr { + var b [4]byte + binary.BigEndian.PutUint32(b[:], n) + return netip.AddrFrom4(b) } func TestSqlStore_GetPeersByGroupIDs(t *testing.T) { @@ -3717,3 +4062,700 @@ func TestSqlStore_GetPeersByGroupIDs(t *testing.T) { }) } } + +func TestSqlStore_GetUserIDByPeerKey(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + userID := "test-user-123" + peerKey := "peer-key-abc" + + peer := &nbpeer.Peer{ + ID: "test-peer-1", + Key: peerKey, + AccountID: existingAccountID, + UserID: userID, + IP: netip.AddrFrom4([4]byte{10, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::a00:1"), + DNSLabel: "test-peer-1", + } + + err = store.AddPeerToAccount(context.Background(), peer) + require.NoError(t, err) + + retrievedUserID, err := store.GetUserIDByPeerKey(context.Background(), LockingStrengthNone, peerKey) + require.NoError(t, err) + assert.Equal(t, userID, retrievedUserID) +} + +func TestSqlStore_GetUserIDByPeerKey_NotFound(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + nonExistentPeerKey := "non-existent-peer-key" + + userID, err := store.GetUserIDByPeerKey(context.Background(), LockingStrengthNone, nonExistentPeerKey) + require.Error(t, err) + assert.Equal(t, "", userID) +} + +func TestSqlStore_GetUserIDByPeerKey_NoUserID(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + peerKey := "peer-key-abc" + + peer := &nbpeer.Peer{ + ID: "test-peer-1", + Key: peerKey, + AccountID: existingAccountID, + UserID: "", + IP: netip.AddrFrom4([4]byte{10, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::a00:1"), + DNSLabel: "test-peer-1", + } + + err = store.AddPeerToAccount(context.Background(), peer) + require.NoError(t, err) + + retrievedUserID, err := store.GetUserIDByPeerKey(context.Background(), LockingStrengthNone, peerKey) + require.NoError(t, err) + assert.Equal(t, "", retrievedUserID) +} + +func TestSqlStore_ApproveAccountPeers(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + accountID := "test-account" + ctx := context.Background() + + account := newAccountWithId(ctx, accountID, "testuser", "example.com") + err := store.SaveAccount(ctx, account) + require.NoError(t, err) + + peers := []*nbpeer.Peer{ + { + ID: "peer1", + AccountID: accountID, + DNSLabel: "peer1.netbird.cloud", + Key: "peer1-key", + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), + Status: &nbpeer.PeerStatus{ + RequiresApproval: true, + LastSeen: time.Now().UTC(), + }, + }, + { + ID: "peer2", + AccountID: accountID, + DNSLabel: "peer2.netbird.cloud", + Key: "peer2-key", + IP: netip.MustParseAddr("100.64.0.2"), + IPv6: netip.MustParseAddr("fd00::2"), + Status: &nbpeer.PeerStatus{ + RequiresApproval: true, + LastSeen: time.Now().UTC(), + }, + }, + { + ID: "peer3", + AccountID: accountID, + DNSLabel: "peer3.netbird.cloud", + Key: "peer3-key", + IP: netip.MustParseAddr("100.64.0.3"), + IPv6: netip.MustParseAddr("fd00::3"), + Status: &nbpeer.PeerStatus{ + RequiresApproval: false, + LastSeen: time.Now().UTC(), + }, + }, + } + + for _, peer := range peers { + err = store.AddPeerToAccount(ctx, peer) + require.NoError(t, err) + } + + t.Run("approve all pending peers", func(t *testing.T) { + count, err := store.ApproveAccountPeers(ctx, accountID) + require.NoError(t, err) + assert.Equal(t, 2, count) + + allPeers, err := store.GetAccountPeers(ctx, LockingStrengthNone, accountID, "", "") + require.NoError(t, err) + + for _, peer := range allPeers { + assert.False(t, peer.Status.RequiresApproval, "peer %s should not require approval", peer.ID) + } + }) + + t.Run("no peers to approve", func(t *testing.T) { + count, err := store.ApproveAccountPeers(ctx, accountID) + require.NoError(t, err) + assert.Equal(t, 0, count) + }) + + t.Run("non-existent account", func(t *testing.T) { + count, err := store.ApproveAccountPeers(ctx, "non-existent") + require.NoError(t, err) + assert.Equal(t, 0, count) + }) + }) +} + +func TestSqlStore_ExecuteInTransaction_Timeout(t *testing.T) { + if os.Getenv("NETBIRD_STORE_ENGINE") == "mysql" { + t.Skip("Skipping timeout test for MySQL") + } + + t.Setenv("NB_STORE_TRANSACTION_TIMEOUT", "1s") + + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanup) + + sqlStore, ok := store.(*SqlStore) + require.True(t, ok) + assert.Equal(t, 1*time.Second, sqlStore.transactionTimeout) + + ctx := context.Background() + err = sqlStore.ExecuteInTransaction(ctx, func(transaction Store) error { + // Sleep for 2 seconds to exceed the 1 second timeout + time.Sleep(2 * time.Second) + return nil + }) + + // The transaction should fail with an error (either timeout or already rolled back) + require.Error(t, err) + assert.Contains(t, err.Error(), "transaction has already been committed or rolled back", "expected transaction rolled back error, got: %v", err) +} + +func TestSqlStore_CreateZone(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + savedZone, err := store.GetZoneByID(context.Background(), LockingStrengthNone, accountID, zone.ID) + require.NoError(t, err) + require.NotNil(t, savedZone) + assert.Equal(t, zone.ID, savedZone.ID) + assert.Equal(t, zone.Name, savedZone.Name) + assert.Equal(t, zone.Domain, savedZone.Domain) + assert.Equal(t, zone.Enabled, savedZone.Enabled) + assert.Equal(t, zone.EnableSearchDomain, savedZone.EnableSearchDomain) + assert.Equal(t, zone.DistributionGroups, savedZone.DistributionGroups) +} + +func TestSqlStore_GetZoneByID(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + tests := []struct { + name string + accountID string + zoneID string + expectError bool + }{ + { + name: "retrieve existing zone", + accountID: accountID, + zoneID: zone.ID, + expectError: false, + }, + { + name: "retrieve non-existing zone", + accountID: accountID, + zoneID: "non-existing", + expectError: true, + }, + { + name: "retrieve with empty zone ID", + accountID: accountID, + zoneID: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savedZone, err := store.GetZoneByID(context.Background(), LockingStrengthNone, tt.accountID, tt.zoneID) + if tt.expectError { + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + require.Nil(t, savedZone) + } else { + require.NoError(t, err) + require.NotNil(t, savedZone) + assert.Equal(t, tt.zoneID, savedZone.ID) + } + }) + } +} + +func TestSqlStore_GetAccountZones(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone1 := zones.NewZone(accountID, "Zone 1", "example1.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone1) + require.NoError(t, err) + + zone2 := zones.NewZone(accountID, "Zone 2", "example2.com", true, true, []string{"group1", "group2"}) + err = store.CreateZone(context.Background(), zone2) + require.NoError(t, err) + + allZones, err := store.GetAccountZones(context.Background(), LockingStrengthNone, accountID) + require.NoError(t, err) + require.NotNil(t, allZones) + assert.GreaterOrEqual(t, len(allZones), 2) + + zoneIDs := make(map[string]bool) + for _, z := range allZones { + zoneIDs[z.ID] = true + } + assert.True(t, zoneIDs[zone1.ID]) + assert.True(t, zoneIDs[zone2.ID]) +} + +func TestSqlStore_GetZoneByDomain(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + otherAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3c" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + tests := []struct { + name string + accountID string + domain string + expectError bool + errorType status.Type + }{ + { + name: "retrieve existing zone by domain", + accountID: accountID, + domain: "example.com", + expectError: false, + }, + { + name: "retrieve non-existing zone domain", + accountID: accountID, + domain: "non-existing.com", + expectError: true, + errorType: status.NotFound, + }, + { + name: "retrieve with empty domain", + accountID: accountID, + domain: "", + expectError: true, + errorType: status.NotFound, + }, + { + name: "retrieve with different account ID", + accountID: otherAccountID, + domain: "example.com", + expectError: true, + errorType: status.NotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savedZone, err := store.GetZoneByDomain(context.Background(), tt.accountID, tt.domain) + if tt.expectError { + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, tt.errorType, sErr.Type()) + require.Nil(t, savedZone) + } else { + require.NoError(t, err) + require.NotNil(t, savedZone) + assert.Equal(t, tt.domain, savedZone.Domain) + assert.Equal(t, zone.ID, savedZone.ID) + assert.Equal(t, zone.Name, savedZone.Name) + } + }) + } +} + +func TestSqlStore_UpdateZone(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + zone.Name = "Updated Zone" + zone.Domain = "updated.com" + zone.Enabled = false + zone.EnableSearchDomain = true + zone.DistributionGroups = []string{"group2", "group3"} + + err = store.UpdateZone(context.Background(), zone) + require.NoError(t, err) + + updatedZone, err := store.GetZoneByID(context.Background(), LockingStrengthNone, accountID, zone.ID) + require.NoError(t, err) + require.NotNil(t, updatedZone) + assert.Equal(t, "Updated Zone", updatedZone.Name) + assert.Equal(t, "updated.com", updatedZone.Domain) + assert.False(t, updatedZone.Enabled) + assert.True(t, updatedZone.EnableSearchDomain) + assert.Equal(t, []string{"group2", "group3"}, updatedZone.DistributionGroups) +} + +func TestSqlStore_DeleteZone(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + err = store.DeleteZone(context.Background(), accountID, zone.ID) + require.NoError(t, err) + + deletedZone, err := store.GetZoneByID(context.Background(), LockingStrengthNone, accountID, zone.ID) + require.Error(t, err) + require.Nil(t, deletedZone) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) +} + +func TestSqlStore_CreateDNSRecord(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + record := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300) + + err = store.CreateDNSRecord(context.Background(), record) + require.NoError(t, err) + + savedRecord, err := store.GetDNSRecordByID(context.Background(), LockingStrengthNone, accountID, zone.ID, record.ID) + require.NoError(t, err) + require.NotNil(t, savedRecord) + assert.Equal(t, record.ID, savedRecord.ID) + assert.Equal(t, record.Name, savedRecord.Name) + assert.Equal(t, record.Type, savedRecord.Type) + assert.Equal(t, record.Content, savedRecord.Content) + assert.Equal(t, record.TTL, savedRecord.TTL) + assert.Equal(t, zone.ID, savedRecord.ZoneID) +} + +func TestSqlStore_GetDNSRecordByID(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + record := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300) + err = store.CreateDNSRecord(context.Background(), record) + require.NoError(t, err) + + tests := []struct { + name string + accountID string + zoneID string + recordID string + expectError bool + }{ + { + name: "retrieve existing record", + accountID: accountID, + zoneID: zone.ID, + recordID: record.ID, + expectError: false, + }, + { + name: "retrieve non-existing record", + accountID: accountID, + zoneID: zone.ID, + recordID: "non-existing", + expectError: true, + }, + { + name: "retrieve with empty record ID", + accountID: accountID, + zoneID: zone.ID, + recordID: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savedRecord, err := store.GetDNSRecordByID(context.Background(), LockingStrengthNone, tt.accountID, tt.zoneID, tt.recordID) + if tt.expectError { + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + require.Nil(t, savedRecord) + } else { + require.NoError(t, err) + require.NotNil(t, savedRecord) + assert.Equal(t, tt.recordID, savedRecord.ID) + } + }) + } +} + +func TestSqlStore_GetZoneDNSRecords(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + recordA := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300) + err = store.CreateDNSRecord(context.Background(), recordA) + require.NoError(t, err) + + recordAAAA := records.NewRecord(accountID, zone.ID, "ipv6.example.com", records.RecordTypeAAAA, "2001:db8::1", 300) + err = store.CreateDNSRecord(context.Background(), recordAAAA) + require.NoError(t, err) + + recordCNAME := records.NewRecord(accountID, zone.ID, "alias.example.com", records.RecordTypeCNAME, "www.example.com", 300) + err = store.CreateDNSRecord(context.Background(), recordCNAME) + require.NoError(t, err) + + allRecords, err := store.GetZoneDNSRecords(context.Background(), LockingStrengthNone, accountID, zone.ID) + require.NoError(t, err) + require.NotNil(t, allRecords) + assert.Equal(t, 3, len(allRecords)) + + recordIDs := make(map[string]bool) + for _, r := range allRecords { + recordIDs[r.ID] = true + } + assert.True(t, recordIDs[recordA.ID]) + assert.True(t, recordIDs[recordAAAA.ID]) + assert.True(t, recordIDs[recordCNAME.ID]) +} + +func TestSqlStore_GetZoneDNSRecordsByName(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + record1 := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300) + err = store.CreateDNSRecord(context.Background(), record1) + require.NoError(t, err) + + record2 := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeAAAA, "2001:db8::1", 300) + err = store.CreateDNSRecord(context.Background(), record2) + require.NoError(t, err) + + record3 := records.NewRecord(accountID, zone.ID, "mail.example.com", records.RecordTypeA, "192.168.1.2", 600) + err = store.CreateDNSRecord(context.Background(), record3) + require.NoError(t, err) + + recordsByName, err := store.GetZoneDNSRecordsByName(context.Background(), LockingStrengthNone, accountID, zone.ID, "www.example.com") + require.NoError(t, err) + require.NotNil(t, recordsByName) + assert.Equal(t, 2, len(recordsByName)) + + for _, r := range recordsByName { + assert.Equal(t, "www.example.com", r.Name) + } +} + +func TestSqlStore_UpdateDNSRecord(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + record := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300) + err = store.CreateDNSRecord(context.Background(), record) + require.NoError(t, err) + + record.Name = "api.example.com" + record.Content = "192.168.1.100" + record.TTL = 600 + + err = store.UpdateDNSRecord(context.Background(), record) + require.NoError(t, err) + + updatedRecord, err := store.GetDNSRecordByID(context.Background(), LockingStrengthNone, accountID, zone.ID, record.ID) + require.NoError(t, err) + require.NotNil(t, updatedRecord) + assert.Equal(t, "api.example.com", updatedRecord.Name) + assert.Equal(t, "192.168.1.100", updatedRecord.Content) + assert.Equal(t, 600, updatedRecord.TTL) +} + +func TestSqlStore_DeleteDNSRecord(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + record := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300) + err = store.CreateDNSRecord(context.Background(), record) + require.NoError(t, err) + + err = store.DeleteDNSRecord(context.Background(), accountID, zone.ID, record.ID) + require.NoError(t, err) + + deletedRecord, err := store.GetDNSRecordByID(context.Background(), LockingStrengthNone, accountID, zone.ID, record.ID) + require.Error(t, err) + require.Nil(t, deletedRecord) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) +} + +func TestSqlStore_DeleteZoneDNSRecords(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + record1 := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300) + err = store.CreateDNSRecord(context.Background(), record1) + require.NoError(t, err) + + record2 := records.NewRecord(accountID, zone.ID, "mail.example.com", records.RecordTypeA, "192.168.1.2", 600) + err = store.CreateDNSRecord(context.Background(), record2) + require.NoError(t, err) + + allRecords, err := store.GetZoneDNSRecords(context.Background(), LockingStrengthNone, accountID, zone.ID) + require.NoError(t, err) + assert.Equal(t, 2, len(allRecords)) + + err = store.DeleteZoneDNSRecords(context.Background(), accountID, zone.ID) + require.NoError(t, err) + + remainingRecords, err := store.GetZoneDNSRecords(context.Background(), LockingStrengthNone, accountID, zone.ID) + require.NoError(t, err) + assert.Equal(t, 0, len(remainingRecords)) +} + +// TestNewSqliteStore_BusyTimeoutApplied opens a fresh SQLite store and verifies +// that the _busy_timeout DSN parameter took effect at the driver level. Without +// this, lock contention on the single SQLite connection waits indefinitely on +// the Go side and can be hidden behind the 5-minute transactionTimeout. +func TestNewSqliteStore_BusyTimeoutApplied(t *testing.T) { + dir := t.TempDir() + store, err := NewSqliteStore(context.Background(), dir, nil, true) + require.NoError(t, err) + t.Cleanup(func() { + _ = store.Close(context.Background()) + }) + + sqlDB, err := store.db.DB() + require.NoError(t, err) + row := sqlDB.QueryRow("PRAGMA busy_timeout") + var busyTimeout int + require.NoError(t, row.Scan(&busyTimeout)) + assert.Equal(t, 30000, busyTimeout, "SQLite busy_timeout must be set via DSN so it survives connection recycling") +} + +// TestNewSqliteStore_BusyTimeoutRespectsUserOverride confirms that an operator +// passing _busy_timeout or its mattn alias _timeout via NB_STORE_ENGINE_SQLITE_FILE +// wins over our 30s default. This guards the DSN merge logic in NewSqliteStore. +func TestNewSqliteStore_BusyTimeoutRespectsUserOverride(t *testing.T) { + cases := []struct { + name string + envFile string + expected int + }{ + {name: "explicit _busy_timeout wins", envFile: "store.db?_busy_timeout=5000", expected: 5000}, + {name: "alias _timeout wins", envFile: "store.db?_timeout=7000", expected: 7000}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("NB_STORE_ENGINE_SQLITE_FILE", tc.envFile) + dir := t.TempDir() + store, err := NewSqliteStore(context.Background(), dir, nil, true) + require.NoError(t, err) + t.Cleanup(func() { + _ = store.Close(context.Background()) + }) + + sqlDB, err := store.db.DB() + require.NoError(t, err) + row := sqlDB.QueryRow("PRAGMA busy_timeout") + var busyTimeout int + require.NoError(t, row.Scan(&busyTimeout)) + assert.Equal(t, tc.expected, busyTimeout) + }) + } +} diff --git a/management/server/store/sql_store_user_invite_test.go b/management/server/store/sql_store_user_invite_test.go new file mode 100644 index 000000000..fb6934a2e --- /dev/null +++ b/management/server/store/sql_store_user_invite_test.go @@ -0,0 +1,520 @@ +package store + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/types" +) + +func TestSqlStore_SaveUserInvite(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + if store == nil { + t.Skip("store is nil") + } + ctx := context.Background() + + invite := &types.UserInviteRecord{ + ID: "invite-1", + AccountID: "account-1", + Email: "test@example.com", + Name: "Test User", + Role: "user", + AutoGroups: []string{"group-1", "group-2"}, + HashedToken: "hashed-token-123", + ExpiresAt: time.Now().Add(72 * time.Hour), + CreatedAt: time.Now(), + CreatedBy: "admin-user", + } + + err := store.SaveUserInvite(ctx, invite) + require.NoError(t, err) + + // Verify the invite was saved + retrieved, err := store.GetUserInviteByID(ctx, LockingStrengthNone, invite.AccountID, invite.ID) + require.NoError(t, err) + assert.Equal(t, invite.ID, retrieved.ID) + assert.Equal(t, invite.Email, retrieved.Email) + assert.Equal(t, invite.Name, retrieved.Name) + assert.Equal(t, invite.Role, retrieved.Role) + assert.Equal(t, invite.AutoGroups, retrieved.AutoGroups) + assert.Equal(t, invite.CreatedBy, retrieved.CreatedBy) + }) +} + +func TestSqlStore_SaveUserInvite_Update(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + if store == nil { + t.Skip("store is nil") + } + ctx := context.Background() + + invite := &types.UserInviteRecord{ + ID: "invite-update", + AccountID: "account-1", + Email: "test@example.com", + Name: "Test User", + Role: "user", + AutoGroups: []string{"group-1"}, + HashedToken: "hashed-token-123", + ExpiresAt: time.Now().Add(72 * time.Hour), + CreatedAt: time.Now(), + CreatedBy: "admin-user", + } + + err := store.SaveUserInvite(ctx, invite) + require.NoError(t, err) + + // Update the invite with a new token + invite.HashedToken = "new-hashed-token" + invite.ExpiresAt = time.Now().Add(24 * time.Hour) + + err = store.SaveUserInvite(ctx, invite) + require.NoError(t, err) + + // Verify the update + retrieved, err := store.GetUserInviteByID(ctx, LockingStrengthNone, invite.AccountID, invite.ID) + require.NoError(t, err) + assert.Equal(t, "new-hashed-token", retrieved.HashedToken) + }) +} + +func TestSqlStore_GetUserInviteByID(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + if store == nil { + t.Skip("store is nil") + } + ctx := context.Background() + + invite := &types.UserInviteRecord{ + ID: "invite-get-by-id", + AccountID: "account-1", + Email: "getbyid@example.com", + Name: "Get By ID User", + Role: "admin", + AutoGroups: []string{}, + HashedToken: "hashed-token-get", + ExpiresAt: time.Now().Add(72 * time.Hour), + CreatedAt: time.Now(), + CreatedBy: "admin-user", + } + + err := store.SaveUserInvite(ctx, invite) + require.NoError(t, err) + + // Get by ID - success + retrieved, err := store.GetUserInviteByID(ctx, LockingStrengthNone, invite.AccountID, invite.ID) + require.NoError(t, err) + assert.Equal(t, invite.ID, retrieved.ID) + assert.Equal(t, invite.Email, retrieved.Email) + + // Get by ID - wrong account + _, err = store.GetUserInviteByID(ctx, LockingStrengthNone, "wrong-account", invite.ID) + assert.Error(t, err) + + // Get by ID - not found + _, err = store.GetUserInviteByID(ctx, LockingStrengthNone, invite.AccountID, "non-existent") + assert.Error(t, err) + }) +} + +func TestSqlStore_GetUserInviteByHashedToken(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + if store == nil { + t.Skip("store is nil") + } + ctx := context.Background() + + invite := &types.UserInviteRecord{ + ID: "invite-get-by-token", + AccountID: "account-1", + Email: "getbytoken@example.com", + Name: "Get By Token User", + Role: "user", + AutoGroups: []string{"group-1"}, + HashedToken: "unique-hashed-token-456", + ExpiresAt: time.Now().Add(72 * time.Hour), + CreatedAt: time.Now(), + CreatedBy: "admin-user", + } + + err := store.SaveUserInvite(ctx, invite) + require.NoError(t, err) + + // Get by hashed token - success + retrieved, err := store.GetUserInviteByHashedToken(ctx, LockingStrengthNone, invite.HashedToken) + require.NoError(t, err) + assert.Equal(t, invite.ID, retrieved.ID) + assert.Equal(t, invite.Email, retrieved.Email) + + // Get by hashed token - not found + _, err = store.GetUserInviteByHashedToken(ctx, LockingStrengthNone, "non-existent-token") + assert.Error(t, err) + }) +} + +func TestSqlStore_GetUserInviteByEmail(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + if store == nil { + t.Skip("store is nil") + } + ctx := context.Background() + + invite := &types.UserInviteRecord{ + ID: "invite-get-by-email", + AccountID: "account-email-test", + Email: "unique-email@example.com", + Name: "Get By Email User", + Role: "user", + AutoGroups: []string{}, + HashedToken: "hashed-token-email", + ExpiresAt: time.Now().Add(72 * time.Hour), + CreatedAt: time.Now(), + CreatedBy: "admin-user", + } + + err := store.SaveUserInvite(ctx, invite) + require.NoError(t, err) + + // Get by email - success + retrieved, err := store.GetUserInviteByEmail(ctx, LockingStrengthNone, invite.AccountID, invite.Email) + require.NoError(t, err) + assert.Equal(t, invite.ID, retrieved.ID) + + // Get by email - case insensitive + retrieved, err = store.GetUserInviteByEmail(ctx, LockingStrengthNone, invite.AccountID, "UNIQUE-EMAIL@EXAMPLE.COM") + require.NoError(t, err) + assert.Equal(t, invite.ID, retrieved.ID) + + // Get by email - wrong account + _, err = store.GetUserInviteByEmail(ctx, LockingStrengthNone, "wrong-account", invite.Email) + assert.Error(t, err) + + // Get by email - not found + _, err = store.GetUserInviteByEmail(ctx, LockingStrengthNone, invite.AccountID, "nonexistent@example.com") + assert.Error(t, err) + }) +} + +func TestSqlStore_GetAccountUserInvites(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + if store == nil { + t.Skip("store is nil") + } + ctx := context.Background() + + accountID := "account-list-invites" + + invites := []*types.UserInviteRecord{ + { + ID: "invite-list-1", + AccountID: accountID, + Email: "user1@example.com", + Name: "User One", + Role: "user", + AutoGroups: []string{"group-1"}, + HashedToken: "hashed-token-list-1", + ExpiresAt: time.Now().Add(72 * time.Hour), + CreatedAt: time.Now(), + CreatedBy: "admin-user", + }, + { + ID: "invite-list-2", + AccountID: accountID, + Email: "user2@example.com", + Name: "User Two", + Role: "admin", + AutoGroups: []string{"group-2"}, + HashedToken: "hashed-token-list-2", + ExpiresAt: time.Now().Add(24 * time.Hour), + CreatedAt: time.Now(), + CreatedBy: "admin-user", + }, + { + ID: "invite-list-3", + AccountID: "different-account", + Email: "user3@example.com", + Name: "User Three", + Role: "user", + AutoGroups: []string{}, + HashedToken: "hashed-token-list-3", + ExpiresAt: time.Now().Add(72 * time.Hour), + CreatedAt: time.Now(), + CreatedBy: "admin-user", + }, + } + + for _, invite := range invites { + err := store.SaveUserInvite(ctx, invite) + require.NoError(t, err) + } + + // Get all invites for the account + retrieved, err := store.GetAccountUserInvites(ctx, LockingStrengthNone, accountID) + require.NoError(t, err) + assert.Len(t, retrieved, 2) + + // Verify the invites belong to the correct account + for _, invite := range retrieved { + assert.Equal(t, accountID, invite.AccountID) + } + + // Get invites for account with no invites + retrieved, err = store.GetAccountUserInvites(ctx, LockingStrengthNone, "empty-account") + require.NoError(t, err) + assert.Len(t, retrieved, 0) + }) +} + +func TestSqlStore_DeleteUserInvite(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + if store == nil { + t.Skip("store is nil") + } + ctx := context.Background() + + invite := &types.UserInviteRecord{ + ID: "invite-delete", + AccountID: "account-delete-test", + Email: "delete@example.com", + Name: "Delete User", + Role: "user", + AutoGroups: []string{}, + HashedToken: "hashed-token-delete", + ExpiresAt: time.Now().Add(72 * time.Hour), + CreatedAt: time.Now(), + CreatedBy: "admin-user", + } + + err := store.SaveUserInvite(ctx, invite) + require.NoError(t, err) + + // Verify invite exists + _, err = store.GetUserInviteByID(ctx, LockingStrengthNone, invite.AccountID, invite.ID) + require.NoError(t, err) + + // Delete the invite + err = store.DeleteUserInvite(ctx, invite.ID) + require.NoError(t, err) + + // Verify invite is deleted + _, err = store.GetUserInviteByID(ctx, LockingStrengthNone, invite.AccountID, invite.ID) + assert.Error(t, err) + }) +} + +func TestSqlStore_UserInvite_EncryptedFields(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + if store == nil { + t.Skip("store is nil") + } + ctx := context.Background() + + invite := &types.UserInviteRecord{ + ID: "invite-encrypted", + AccountID: "account-encrypted", + Email: "sensitive-email@example.com", + Name: "Sensitive Name", + Role: "user", + AutoGroups: []string{"group-1"}, + HashedToken: "hashed-token-encrypted", + ExpiresAt: time.Now().Add(72 * time.Hour), + CreatedAt: time.Now(), + CreatedBy: "admin-user", + } + + err := store.SaveUserInvite(ctx, invite) + require.NoError(t, err) + + // Retrieve and verify decryption works + retrieved, err := store.GetUserInviteByID(ctx, LockingStrengthNone, invite.AccountID, invite.ID) + require.NoError(t, err) + assert.Equal(t, "sensitive-email@example.com", retrieved.Email) + assert.Equal(t, "Sensitive Name", retrieved.Name) + }) +} + +func TestSqlStore_DeleteUserInvite_NonExistent(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + if store == nil { + t.Skip("store is nil") + } + ctx := context.Background() + + // Deleting a non-existent invite should not return an error + err := store.DeleteUserInvite(ctx, "non-existent-invite-id") + require.NoError(t, err) + }) +} + +func TestSqlStore_UserInvite_SameEmailDifferentAccounts(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + if store == nil { + t.Skip("store is nil") + } + ctx := context.Background() + + email := "shared-email@example.com" + + // Create invite in first account + invite1 := &types.UserInviteRecord{ + ID: "invite-account1", + AccountID: "account-1", + Email: email, + Name: "User Account 1", + Role: "user", + AutoGroups: []string{}, + HashedToken: "hashed-token-account1", + ExpiresAt: time.Now().Add(72 * time.Hour), + CreatedAt: time.Now(), + CreatedBy: "admin-1", + } + + // Create invite in second account with same email + invite2 := &types.UserInviteRecord{ + ID: "invite-account2", + AccountID: "account-2", + Email: email, + Name: "User Account 2", + Role: "admin", + AutoGroups: []string{"group-1"}, + HashedToken: "hashed-token-account2", + ExpiresAt: time.Now().Add(72 * time.Hour), + CreatedAt: time.Now(), + CreatedBy: "admin-2", + } + + err := store.SaveUserInvite(ctx, invite1) + require.NoError(t, err) + + err = store.SaveUserInvite(ctx, invite2) + require.NoError(t, err) + + // Verify each account gets the correct invite by email + retrieved1, err := store.GetUserInviteByEmail(ctx, LockingStrengthNone, "account-1", email) + require.NoError(t, err) + assert.Equal(t, "invite-account1", retrieved1.ID) + assert.Equal(t, "User Account 1", retrieved1.Name) + + retrieved2, err := store.GetUserInviteByEmail(ctx, LockingStrengthNone, "account-2", email) + require.NoError(t, err) + assert.Equal(t, "invite-account2", retrieved2.ID) + assert.Equal(t, "User Account 2", retrieved2.Name) + }) +} + +func TestSqlStore_UserInvite_LockingStrength(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + if store == nil { + t.Skip("store is nil") + } + ctx := context.Background() + + invite := &types.UserInviteRecord{ + ID: "invite-locking", + AccountID: "account-locking", + Email: "locking@example.com", + Name: "Locking Test User", + Role: "user", + AutoGroups: []string{}, + HashedToken: "hashed-token-locking", + ExpiresAt: time.Now().Add(72 * time.Hour), + CreatedAt: time.Now(), + CreatedBy: "admin-user", + } + + err := store.SaveUserInvite(ctx, invite) + require.NoError(t, err) + + // Test with different locking strengths + lockStrengths := []LockingStrength{LockingStrengthNone, LockingStrengthShare, LockingStrengthUpdate} + + for _, strength := range lockStrengths { + retrieved, err := store.GetUserInviteByID(ctx, strength, invite.AccountID, invite.ID) + require.NoError(t, err) + assert.Equal(t, invite.ID, retrieved.ID) + + retrieved, err = store.GetUserInviteByHashedToken(ctx, strength, invite.HashedToken) + require.NoError(t, err) + assert.Equal(t, invite.ID, retrieved.ID) + + retrieved, err = store.GetUserInviteByEmail(ctx, strength, invite.AccountID, invite.Email) + require.NoError(t, err) + assert.Equal(t, invite.ID, retrieved.ID) + + invites, err := store.GetAccountUserInvites(ctx, strength, invite.AccountID) + require.NoError(t, err) + assert.Len(t, invites, 1) + } + }) +} + +func TestSqlStore_UserInvite_EmptyAutoGroups(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + if store == nil { + t.Skip("store is nil") + } + ctx := context.Background() + + // Test with nil AutoGroups + invite := &types.UserInviteRecord{ + ID: "invite-nil-autogroups", + AccountID: "account-autogroups", + Email: "nilgroups@example.com", + Name: "Nil Groups User", + Role: "user", + AutoGroups: nil, + HashedToken: "hashed-token-nil", + ExpiresAt: time.Now().Add(72 * time.Hour), + CreatedAt: time.Now(), + CreatedBy: "admin-user", + } + + err := store.SaveUserInvite(ctx, invite) + require.NoError(t, err) + + retrieved, err := store.GetUserInviteByID(ctx, LockingStrengthNone, invite.AccountID, invite.ID) + require.NoError(t, err) + // Should return empty slice or nil, both are acceptable + assert.Empty(t, retrieved.AutoGroups) + }) +} + +func TestSqlStore_UserInvite_TimestampPrecision(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + if store == nil { + t.Skip("store is nil") + } + ctx := context.Background() + + now := time.Now().UTC().Truncate(time.Millisecond) + expiresAt := now.Add(72 * time.Hour) + + invite := &types.UserInviteRecord{ + ID: "invite-timestamp", + AccountID: "account-timestamp", + Email: "timestamp@example.com", + Name: "Timestamp User", + Role: "user", + AutoGroups: []string{}, + HashedToken: "hashed-token-timestamp", + ExpiresAt: expiresAt, + CreatedAt: now, + CreatedBy: "admin-user", + } + + err := store.SaveUserInvite(ctx, invite) + require.NoError(t, err) + + retrieved, err := store.GetUserInviteByID(ctx, LockingStrengthNone, invite.AccountID, invite.ID) + require.NoError(t, err) + + // Verify timestamps are preserved (within reasonable precision) + assert.WithinDuration(t, now, retrieved.CreatedAt, time.Second) + assert.WithinDuration(t, expiresAt, retrieved.ExpiresAt, time.Second) + }) +} diff --git a/management/server/store/sqlstore_bench_test.go b/management/server/store/sqlstore_bench_test.go index 350a1da83..a38b4a8c1 100644 --- a/management/server/store/sqlstore_bench_test.go +++ b/management/server/store/sqlstore_bench_test.go @@ -20,6 +20,8 @@ import ( "github.com/stretchr/testify/assert" nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" networkTypes "github.com/netbirdio/netbird/management/server/networks/types" @@ -263,7 +265,8 @@ func setupBenchmarkDB(b testing.TB) (*SqlStore, func(), string) { &types.Policy{}, &types.PolicyRule{}, &route.Route{}, &nbdns.NameServerGroup{}, &posture.Checks{}, &networkTypes.Network{}, &routerTypes.NetworkRouter{}, &resourceTypes.NetworkResource{}, - &types.AccountOnboarding{}, + &types.AccountOnboarding{}, &service.Service{}, &service.Target{}, + &domain.Domain{}, } for i := len(models) - 1; i >= 0; i-- { @@ -341,7 +344,8 @@ func setupBenchmarkDB(b testing.TB) (*SqlStore, func(), string) { ID: fmt.Sprintf("peer-%d", i), AccountID: accountID, Key: fmt.Sprintf("peerkey-%d", i), - IP: net.ParseIP(fmt.Sprintf("100.64.0.%d", i+1)), + IP: netip.MustParseAddr(fmt.Sprintf("100.64.0.%d", i+1)), + IPv6: netip.MustParseAddr(fmt.Sprintf("fd00::%d", i+1)), Name: fmt.Sprintf("peer-name-%d", i), Status: &nbpeer.PeerStatus{Connected: i%2 == 0, LastSeen: time.Now()}, }) diff --git a/management/server/store/store.go b/management/server/store/store.go index 007e2b739..1beea72fd 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -1,5 +1,7 @@ package store +//go:generate go run github.com/golang/mock/mockgen -package store -destination=store_mock.go -source=./store.go -build_flags=-mod=mod + import ( "context" "errors" @@ -23,11 +25,19 @@ import ( "gorm.io/gorm" "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/internals/modules/zones" + "github.com/netbirdio/netbird/management/internals/modules/zones/records" "github.com/netbirdio/netbird/management/server/telemetry" "github.com/netbirdio/netbird/management/server/testutil" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/util" + "github.com/netbirdio/netbird/util/crypt" + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" "github.com/netbirdio/netbird/management/server/migration" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" @@ -89,6 +99,13 @@ type Store interface { DeleteHashedPAT2TokenIDIndex(hashedToken string) error DeleteTokenID2UserIDIndex(tokenID string) error + SaveUserInvite(ctx context.Context, invite *types.UserInviteRecord) error + GetUserInviteByID(ctx context.Context, lockStrength LockingStrength, accountID, inviteID string) (*types.UserInviteRecord, error) + GetUserInviteByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types.UserInviteRecord, error) + GetUserInviteByEmail(ctx context.Context, lockStrength LockingStrength, accountID, email string) (*types.UserInviteRecord, error) + GetAccountUserInvites(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.UserInviteRecord, error) + DeleteUserInvite(ctx context.Context, inviteID string) error + GetPATByID(ctx context.Context, lockStrength LockingStrength, userID, patID string) (*types.PersonalAccessToken, error) GetUserPATs(ctx context.Context, lockStrength LockingStrength, userID string) ([]*types.PersonalAccessToken, error) GetPATByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types.PersonalAccessToken, error) @@ -96,10 +113,19 @@ type Store interface { SavePAT(ctx context.Context, pat *types.PersonalAccessToken) error DeletePAT(ctx context.Context, userID, patID string) error + GetProxyAccessTokenByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken types.HashedProxyToken) (*types.ProxyAccessToken, error) + GetAllProxyAccessTokens(ctx context.Context, lockStrength LockingStrength) ([]*types.ProxyAccessToken, error) + GetProxyAccessTokensByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.ProxyAccessToken, error) + GetProxyAccessTokenByID(ctx context.Context, lockStrength LockingStrength, tokenID string) (*types.ProxyAccessToken, error) + IsProxyAccessTokenValid(ctx context.Context, tokenID string) (bool, error) + SaveProxyAccessToken(ctx context.Context, token *types.ProxyAccessToken) error + RevokeProxyAccessToken(ctx context.Context, tokenID string) error + MarkProxyAccessTokenUsed(ctx context.Context, tokenID string) error + GetAccountGroups(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.Group, error) GetResourceGroups(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) ([]*types.Group, error) GetGroupByID(ctx context.Context, lockStrength LockingStrength, accountID, groupID string) (*types.Group, error) - GetGroupByName(ctx context.Context, lockStrength LockingStrength, groupName, accountID string) (*types.Group, error) + GetGroupByName(ctx context.Context, lockStrength LockingStrength, accountID, groupName string) (*types.Group, error) GetGroupsByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, groupIDs []string) (map[string]*types.Group, error) CreateGroups(ctx context.Context, accountID string, groups []*types.Group) error UpdateGroups(ctx context.Context, accountID string, groups []*types.Group) error @@ -137,12 +163,30 @@ type Store interface { GetPeerByID(ctx context.Context, lockStrength LockingStrength, accountID string, peerID string) (*nbpeer.Peer, error) GetPeersByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, peerIDs []string) (map[string]*nbpeer.Peer, error) GetPeersByGroupIDs(ctx context.Context, accountID string, groupIDs []string) ([]*nbpeer.Peer, error) + GetPeerIDsByGroups(ctx context.Context, accountID string, groupIDs []string) ([]string, error) + GetGroupIDsByPeerIDs(ctx context.Context, accountID string, peerIDs []string) ([]string, error) + GetEmbeddedProxyPeerIDsByCluster(ctx context.Context, accountID string) (map[string][]string, error) GetAccountPeersWithExpiration(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*nbpeer.Peer, error) GetAccountPeersWithInactivity(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*nbpeer.Peer, error) GetAllEphemeralPeers(ctx context.Context, lockStrength LockingStrength) ([]*nbpeer.Peer, error) SavePeer(ctx context.Context, accountID string, peer *nbpeer.Peer) error SavePeerStatus(ctx context.Context, accountID, peerID string, status nbpeer.PeerStatus) error - SavePeerLocation(ctx context.Context, accountID string, peer *nbpeer.Peer) error + // MarkPeerConnectedIfNewerSession sets the peer to connected with the + // given session token, but only when the stored SessionStartedAt is + // strictly less than newSessionStartedAt (the sentinel zero counts as + // "older"). LastSeen is recorded by the database at the moment the + // row is updated — never by the caller — so it always reflects the + // real write time even under lock contention. + // Returns true when the update happened, false when this stream lost + // the race against a newer session. + MarkPeerConnectedIfNewerSession(ctx context.Context, accountID, peerID string, newSessionStartedAt int64) (bool, error) + // MarkPeerDisconnectedIfSameSession sets the peer to disconnected and + // resets SessionStartedAt to zero, but only when the stored + // SessionStartedAt equals the given sessionStartedAt. LastSeen is + // recorded by the database. Returns true when the update happened, + // false when a newer session has taken over. + MarkPeerDisconnectedIfSameSession(ctx context.Context, accountID, peerID string, sessionStartedAt int64) (bool, error) + ApproveAccountPeers(ctx context.Context, accountID string) (int, error) DeletePeer(ctx context.Context, accountID string, peerID string) error GetSetupKeyBySecret(ctx context.Context, lockStrength LockingStrength, key string) (*types.SetupKey, error) @@ -162,7 +206,7 @@ type Store interface { SaveNameServerGroup(ctx context.Context, nameServerGroup *dns.NameServerGroup) error DeleteNameServerGroup(ctx context.Context, accountID, nameServerGroupID string) error - GetTakenIPs(ctx context.Context, lockStrength LockingStrength, accountId string) ([]net.IP, error) + GetTakenIPs(ctx context.Context, lockStrength LockingStrength, accountId string) ([]netip.Addr, error) IncrementNetworkSerial(ctx context.Context, accountId string) error GetAccountNetwork(ctx context.Context, lockStrength LockingStrength, accountId string) (*types.Network, error) @@ -187,7 +231,8 @@ type Store interface { GetNetworkRoutersByNetID(ctx context.Context, lockStrength LockingStrength, accountID, netID string) ([]*routerTypes.NetworkRouter, error) GetNetworkRoutersByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*routerTypes.NetworkRouter, error) GetNetworkRouterByID(ctx context.Context, lockStrength LockingStrength, accountID, routerID string) (*routerTypes.NetworkRouter, error) - SaveNetworkRouter(ctx context.Context, router *routerTypes.NetworkRouter) error + CreateNetworkRouter(ctx context.Context, router *routerTypes.NetworkRouter) error + UpdateNetworkRouter(ctx context.Context, router *routerTypes.NetworkRouter) error DeleteNetworkRouter(ctx context.Context, accountID, routerID string) error GetNetworkResourcesByNetID(ctx context.Context, lockStrength LockingStrength, accountID, netID string) ([]*resourceTypes.NetworkResource, error) @@ -202,14 +247,197 @@ type Store interface { IsPrimaryAccount(ctx context.Context, accountID string) (bool, string, error) MarkAccountPrimary(ctx context.Context, accountID string) error UpdateAccountNetwork(ctx context.Context, accountID string, ipNet net.IPNet) error + UpdateAccountNetworkV6(ctx context.Context, accountID string, ipNet net.IPNet) error GetPolicyRulesByResourceID(ctx context.Context, lockStrength LockingStrength, accountID string, peerID string) ([]*types.PolicyRule, error) + + // SetFieldEncrypt sets the field encryptor for encrypting sensitive user data. + SetFieldEncrypt(enc *crypt.FieldEncrypt) + GetUserIDByPeerKey(ctx context.Context, lockStrength LockingStrength, peerKey string) (string, error) + + CreateZone(ctx context.Context, zone *zones.Zone) error + UpdateZone(ctx context.Context, zone *zones.Zone) error + DeleteZone(ctx context.Context, accountID, zoneID string) error + GetZoneByID(ctx context.Context, lockStrength LockingStrength, accountID, zoneID string) (*zones.Zone, error) + GetZoneByDomain(ctx context.Context, accountID, domain string) (*zones.Zone, error) + GetAccountZones(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*zones.Zone, error) + + CreateDNSRecord(ctx context.Context, record *records.Record) error + UpdateDNSRecord(ctx context.Context, record *records.Record) error + DeleteDNSRecord(ctx context.Context, accountID, zoneID, recordID string) error + GetDNSRecordByID(ctx context.Context, lockStrength LockingStrength, accountID, zoneID, recordID string) (*records.Record, error) + GetZoneDNSRecords(ctx context.Context, lockStrength LockingStrength, accountID, zoneID string) ([]*records.Record, error) + GetZoneDNSRecordsByName(ctx context.Context, lockStrength LockingStrength, accountID, zoneID, name string) ([]*records.Record, error) + DeleteZoneDNSRecords(ctx context.Context, accountID, zoneID string) error + CreatePeerJob(ctx context.Context, job *types.Job) error + CompletePeerJob(ctx context.Context, job *types.Job) error + GetPeerJobByID(ctx context.Context, accountID, jobID string) (*types.Job, error) + GetPeerJobs(ctx context.Context, accountID, peerID string) ([]*types.Job, error) + MarkPendingJobsAsFailed(ctx context.Context, accountID, peerID, jobID, reason string) error + MarkAllPendingJobsAsFailed(ctx context.Context, accountID, peerID, reason string) error + GetPeerIDByKey(ctx context.Context, lockStrength LockingStrength, key string) (string, error) + + CreateService(ctx context.Context, service *rpservice.Service) error + UpdateService(ctx context.Context, service *rpservice.Service) error + DeleteService(ctx context.Context, accountID, serviceID string) error + GetServiceByID(ctx context.Context, lockStrength LockingStrength, accountID, serviceID string) (*rpservice.Service, error) + GetServiceByDomain(ctx context.Context, domain string) (*rpservice.Service, error) + GetServices(ctx context.Context, lockStrength LockingStrength) ([]*rpservice.Service, error) + GetAccountServices(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*rpservice.Service, error) + + RenewEphemeralService(ctx context.Context, accountID, peerID, serviceID string) error + GetExpiredEphemeralServices(ctx context.Context, ttl time.Duration, limit int) ([]*rpservice.Service, error) + CountEphemeralServicesByPeer(ctx context.Context, lockStrength LockingStrength, accountID, peerID string) (int64, error) + EphemeralServiceExists(ctx context.Context, lockStrength LockingStrength, accountID, peerID, domain string) (bool, error) + GetServicesByClusterAndPort(ctx context.Context, lockStrength LockingStrength, proxyCluster string, mode string, listenPort uint16) ([]*rpservice.Service, error) + GetServicesByCluster(ctx context.Context, lockStrength LockingStrength, proxyCluster string) ([]*rpservice.Service, error) + + GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error) + ListFreeDomains(ctx context.Context, accountID string) ([]string, error) + ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) + CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error) + UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error) + DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error + + CreateAccessLog(ctx context.Context, log *accesslogs.AccessLogEntry) error + GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) + DeleteOldAccessLogs(ctx context.Context, olderThan time.Time) (int64, error) + CreateAgentNetworkAccessLog(ctx context.Context, entry *agentNetworkTypes.AgentNetworkAccessLog, groups []agentNetworkTypes.AgentNetworkAccessLogGroup) error + CreateAgentNetworkUsage(ctx context.Context, usage *agentNetworkTypes.AgentNetworkUsage, groups []agentNetworkTypes.AgentNetworkUsageGroup) error + GetAgentNetworkAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLog, int64, error) + GetAgentNetworkAccessLogSessions(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLogSession, int64, error) + GetAgentNetworkUsageRows(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkUsage, error) + DeleteOldAgentNetworkAccessLogs(ctx context.Context, accountID string, olderThan time.Time) (int64, error) + GetServiceTargetByTargetID(ctx context.Context, lockStrength LockingStrength, accountID string, targetID string) (*rpservice.Target, error) + GetTargetsByServiceID(ctx context.Context, lockStrength LockingStrength, accountID string, serviceID string) ([]*rpservice.Target, error) + DeleteTarget(ctx context.Context, accountID string, serviceID string, targetID uint) error + DeleteServiceTargets(ctx context.Context, accountID string, serviceID string) error + + SaveProxy(ctx context.Context, proxy *proxy.Proxy) error + DisconnectProxy(ctx context.Context, proxyID, sessionID string) error + UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) error + GetActiveProxyClusterAddresses(ctx context.Context) ([]string, error) + GetActiveProxyClusterAddressesForAccount(ctx context.Context, accountID string) ([]string, error) + GetProxyClusters(ctx context.Context, accountID string) ([]proxy.Cluster, error) + GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool + GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool + GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool + GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool + CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error + GetAllProxies(ctx context.Context) ([]*proxy.Proxy, error) + DisconnectAllProxies(ctx context.Context) (int64, error) + GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error) + CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error) + IsClusterAddressConflicting(ctx context.Context, clusterAddress, accountID string) (bool, error) + DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error + + GetCustomDomainsCounts(ctx context.Context) (total int64, validated int64, err error) + + // GetProxyMetrics returns aggregated proxy / cluster counts for the + // self-hosted metrics worker. Self-hosted only — file-based stores + // return a zero-valued struct. + GetProxyMetrics(ctx context.Context) (ProxyMetrics, error) + + // GetAgentNetworkMetrics returns aggregated agent-network adoption + usage + // counts for the self-hosted metrics worker. Self-hosted only — file-based + // stores return a zero-valued struct. + GetAgentNetworkMetrics(ctx context.Context) (AgentNetworkMetrics, error) + + GetRoutingPeerNetworks(ctx context.Context, accountID, peerID string) ([]string, error) + + // Agent Network persistence (providers, policies, guardrails, settings). + GetAllAgentNetworkProviders(ctx context.Context, lockStrength LockingStrength) ([]*agentNetworkTypes.Provider, error) + GetAccountAgentNetworkProviders(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Provider, error) + GetAgentNetworkProviderByID(ctx context.Context, lockStrength LockingStrength, accountID, providerID string) (*agentNetworkTypes.Provider, error) + SaveAgentNetworkProvider(ctx context.Context, provider *agentNetworkTypes.Provider) error + DeleteAgentNetworkProvider(ctx context.Context, accountID, providerID string) error + GetAccountAgentNetworkPolicies(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Policy, error) + GetAgentNetworkPolicyByID(ctx context.Context, lockStrength LockingStrength, accountID, policyID string) (*agentNetworkTypes.Policy, error) + SaveAgentNetworkPolicy(ctx context.Context, policy *agentNetworkTypes.Policy) error + DeleteAgentNetworkPolicy(ctx context.Context, accountID, policyID string) error + GetAccountAgentNetworkGuardrails(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Guardrail, error) + GetAgentNetworkGuardrailByID(ctx context.Context, lockStrength LockingStrength, accountID, guardrailID string) (*agentNetworkTypes.Guardrail, error) + SaveAgentNetworkGuardrail(ctx context.Context, guardrail *agentNetworkTypes.Guardrail) error + DeleteAgentNetworkGuardrail(ctx context.Context, accountID, guardrailID string) error + GetAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*agentNetworkTypes.Settings, error) + GetAllAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength) ([]*agentNetworkTypes.Settings, error) + GetAgentNetworkSettingsByCluster(ctx context.Context, lockStrength LockingStrength, cluster string) ([]*agentNetworkTypes.Settings, error) + SaveAgentNetworkSettings(ctx context.Context, settings *agentNetworkTypes.Settings) error + IncrementAgentNetworkConsumption(ctx context.Context, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time, tokensIn, tokensOut int64, costUSD float64) error + IncrementAgentNetworkConsumptionBatch(ctx context.Context, accountID string, keys []agentNetworkTypes.ConsumptionKey, tokensIn, tokensOut int64, costUSD float64) error + GetAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time) (*agentNetworkTypes.Consumption, error) + GetAgentNetworkConsumptionBatch(ctx context.Context, lockStrength LockingStrength, accountID string, keys []agentNetworkTypes.ConsumptionKey) (map[agentNetworkTypes.ConsumptionKey]*agentNetworkTypes.Consumption, error) + ListAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Consumption, error) + GetAccountAgentNetworkBudgetRules(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.AccountBudgetRule, error) + GetAgentNetworkBudgetRuleByID(ctx context.Context, lockStrength LockingStrength, accountID, ruleID string) (*agentNetworkTypes.AccountBudgetRule, error) + SaveAgentNetworkBudgetRule(ctx context.Context, rule *agentNetworkTypes.AccountBudgetRule) error + DeleteAgentNetworkBudgetRule(ctx context.Context, accountID, ruleID string) error +} + +// ProxyMetrics aggregates self-hosted proxy + cluster usage signals +// surfaced to the telemetry payload. Each field is best-effort: when a +// store cannot answer (e.g. FileStore) all fields are zero. +type ProxyMetrics struct { + // Clusters counts distinct cluster_address values across the proxies + // table — every cluster the management server has heard from, online or not. + Clusters int64 + // ClustersBYOP counts distinct cluster_address values that are owned + // by an account (account_id IS NOT NULL). These are bring-your-own-proxy + // installations as opposed to NetBird-operated shared clusters. + ClustersBYOP int64 + // ClustersPrivate counts distinct cluster_address values where at + // least one proxy reported the private capability (embedded + // `netbird proxy` running inside a client). + ClustersPrivate int64 + // Proxies is the total number of proxy rows currently persisted. + Proxies int64 + // ProxiesConnected is the subset of proxies whose status is + // "connected" AND last_seen falls within the active heartbeat window + // (~2 * heartbeat interval). Proxies the controller hasn't pruned + // yet but that are visibly stale don't count. + ProxiesConnected int64 +} + +// AgentNetworkMetrics aggregates self-hosted agent-network adoption + usage +// signals surfaced to the telemetry payload. Each field is best-effort: when a +// store cannot answer (e.g. FileStore) all fields are zero. +type AgentNetworkMetrics struct { + // Accounts is the number of distinct accounts with at least one provider + // configured (agent-network adoption). + Accounts int64 + // Providers is the total number of configured providers across all accounts. + Providers int64 + // Policies is the total number of agent-network policies across all accounts. + Policies int64 + // BudgetRules is the total number of account-level budget rules ("budget + // limits") across all accounts. + BudgetRules int64 + // LogCollectionEnabled is the number of accounts that have agent-network + // log collection turned on. + LogCollectionEnabled int64 + // InputTokens / OutputTokens / CostUSD are summed over the always-collected + // per-request usage ledger (agent_network_request_usage), independent of the + // log-collection toggle. They reflect total metered LLM usage served through + // agent networks. + InputTokens int64 + OutputTokens int64 + CostUSD float64 } const ( - postgresDsnEnv = "NETBIRD_STORE_ENGINE_POSTGRES_DSN" - mysqlDsnEnv = "NETBIRD_STORE_ENGINE_MYSQL_DSN" + postgresDsnEnv = "NB_STORE_ENGINE_POSTGRES_DSN" + postgresDsnEnvLegacy = "NETBIRD_STORE_ENGINE_POSTGRES_DSN" + mysqlDsnEnv = "NB_STORE_ENGINE_MYSQL_DSN" + mysqlDsnEnvLegacy = "NETBIRD_STORE_ENGINE_MYSQL_DSN" ) +// lookupDSNEnv checks the NB_ env var first, then falls back to the legacy NETBIRD_ env var. +func lookupDSNEnv(nbKey, legacyKey string) (string, bool) { + if v, ok := os.LookupEnv(nbKey); ok { + return v, true + } + return os.LookupEnv(legacyKey) +} + var supportedEngines = []types.Engine{types.SqliteStoreEngine, types.PostgresStoreEngine, types.MysqlStoreEngine} func getStoreEngineFromEnv() types.Engine { @@ -338,8 +566,52 @@ func getMigrationsPreAuto(ctx context.Context) []migrationFunc { func(db *gorm.DB) error { return migration.DropIndex[routerTypes.NetworkRouter](ctx, db, "idx_network_routers_id") }, + func(db *gorm.DB) error { + return migration.MigrateNewField[types.User](ctx, db, "name", "") + }, + func(db *gorm.DB) error { + return migration.MigrateNewField[types.User](ctx, db, "email", "") + }, + func(db *gorm.DB) error { + return migration.MigrateNewField[nbpeer.Peer](ctx, db, "peer_status_session_started_at", int64(0)) + }, + func(db *gorm.DB) error { + return migration.RemoveDuplicatePeerKeys(ctx, db) + }, + func(db *gorm.DB) error { + return migration.CleanupOrphanedResources[rpservice.Service, types.Account](ctx, db, "account_id") + }, + func(db *gorm.DB) error { + return migration.CleanupOrphanedResources[domain.Domain, types.Account](ctx, db, "account_id") + }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[types.Policy](ctx, db) + }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[types.Group](ctx, db) + }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[route.Route](ctx, db) + }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[resourceTypes.NetworkResource](ctx, db) + }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[routerTypes.NetworkRouter](ctx, db) + }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[dns.NameServerGroup](ctx, db) + }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[networkTypes.Network](ctx, db) + }, + func(db *gorm.DB) error { + return migration.BackfillPublicIDs[posture.Checks](ctx, db) + }, } -} // migratePostAuto migrates the SQLite database to the latest schema +} + +// migratePostAuto migrates the SQLite database to the latest schema func migratePostAuto(ctx context.Context, db *gorm.DB) error { migrations := getMigrationsPostAuto(ctx) @@ -369,6 +641,23 @@ func getMigrationsPostAuto(ctx context.Context) []migrationFunc { } }) }, + func(db *gorm.DB) error { + return migration.DropIndex[nbpeer.Peer](ctx, db, "idx_peers_key") + }, + func(db *gorm.DB) error { + return migration.CreateIndexIfNotExists[nbpeer.Peer](ctx, db, "idx_peers_key_unique", "key") + }, + func(db *gorm.DB) error { + return migration.DropIndex[proxy.Proxy](ctx, db, "idx_proxy_account_id_unique") + }, + // Post-auto so the per-bucket cost columns already exist when the legacy + // aggregates are folded into them and dropped. + func(db *gorm.DB) error { + return migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkAccessLog](ctx, db) + }, + func(db *gorm.DB) error { + return migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db) + }, } } @@ -477,7 +766,7 @@ func getSqlStoreEngine(ctx context.Context, store *SqlStore, kind types.Engine) } func newReusedPostgresStore(ctx context.Context, store *SqlStore, kind types.Engine) (*SqlStore, func(), error) { - dsn, ok := os.LookupEnv(postgresDsnEnv) + dsn, ok := lookupDSNEnv(postgresDsnEnv, postgresDsnEnvLegacy) if !ok || dsn == "" { var err error _, dsn, err = testutil.CreatePostgresTestContainer() @@ -515,7 +804,7 @@ func newReusedPostgresStore(ctx context.Context, store *SqlStore, kind types.Eng } func newReusedMysqlStore(ctx context.Context, store *SqlStore, kind types.Engine) (*SqlStore, func(), error) { - dsn, ok := os.LookupEnv(mysqlDsnEnv) + dsn, ok := lookupDSNEnv(mysqlDsnEnv, mysqlDsnEnvLegacy) if !ok || dsn == "" { var err error _, dsn, err = testutil.CreateMysqlTestContainer() diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go new file mode 100644 index 000000000..428632a86 --- /dev/null +++ b/management/server/store/store_mock.go @@ -0,0 +1,3805 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./store.go + +// Package store is a generated GoMock package. +package store + +import ( + context "context" + net "net" + netip "net/netip" + reflect "reflect" + time "time" + + gomock "github.com/golang/mock/gomock" + dns "github.com/netbirdio/netbird/dns" + types "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + accesslogs "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + domain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + proxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + service "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + zones "github.com/netbirdio/netbird/management/internals/modules/zones" + records "github.com/netbirdio/netbird/management/internals/modules/zones/records" + types0 "github.com/netbirdio/netbird/management/server/networks/resources/types" + types1 "github.com/netbirdio/netbird/management/server/networks/routers/types" + types2 "github.com/netbirdio/netbird/management/server/networks/types" + peer "github.com/netbirdio/netbird/management/server/peer" + posture "github.com/netbirdio/netbird/management/server/posture" + types3 "github.com/netbirdio/netbird/management/server/types" + route "github.com/netbirdio/netbird/route" + crypt "github.com/netbirdio/netbird/util/crypt" +) + +// MockStore is a mock of Store interface. +type MockStore struct { + ctrl *gomock.Controller + recorder *MockStoreMockRecorder +} + +// MockStoreMockRecorder is the mock recorder for MockStore. +type MockStoreMockRecorder struct { + mock *MockStore +} + +// NewMockStore creates a new mock instance. +func NewMockStore(ctrl *gomock.Controller) *MockStore { + mock := &MockStore{ctrl: ctrl} + mock.recorder = &MockStoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStore) EXPECT() *MockStoreMockRecorder { + return m.recorder +} + +// AccountExists mocks base method. +func (m *MockStore) AccountExists(ctx context.Context, lockStrength LockingStrength, id string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AccountExists", ctx, lockStrength, id) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AccountExists indicates an expected call of AccountExists. +func (mr *MockStoreMockRecorder) AccountExists(ctx, lockStrength, id interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AccountExists", reflect.TypeOf((*MockStore)(nil).AccountExists), ctx, lockStrength, id) +} + +// AcquireGlobalLock mocks base method. +func (m *MockStore) AcquireGlobalLock(ctx context.Context) func() { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AcquireGlobalLock", ctx) + ret0, _ := ret[0].(func()) + return ret0 +} + +// AcquireGlobalLock indicates an expected call of AcquireGlobalLock. +func (mr *MockStoreMockRecorder) AcquireGlobalLock(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AcquireGlobalLock", reflect.TypeOf((*MockStore)(nil).AcquireGlobalLock), ctx) +} + +// AddPeerToAccount mocks base method. +func (m *MockStore) AddPeerToAccount(ctx context.Context, peer *peer.Peer) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddPeerToAccount", ctx, peer) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddPeerToAccount indicates an expected call of AddPeerToAccount. +func (mr *MockStoreMockRecorder) AddPeerToAccount(ctx, peer interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddPeerToAccount", reflect.TypeOf((*MockStore)(nil).AddPeerToAccount), ctx, peer) +} + +// AddPeerToAllGroup mocks base method. +func (m *MockStore) AddPeerToAllGroup(ctx context.Context, accountID, peerID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddPeerToAllGroup", ctx, accountID, peerID) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddPeerToAllGroup indicates an expected call of AddPeerToAllGroup. +func (mr *MockStoreMockRecorder) AddPeerToAllGroup(ctx, accountID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddPeerToAllGroup", reflect.TypeOf((*MockStore)(nil).AddPeerToAllGroup), ctx, accountID, peerID) +} + +// AddPeerToGroup mocks base method. +func (m *MockStore) AddPeerToGroup(ctx context.Context, accountID, peerId, groupID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddPeerToGroup", ctx, accountID, peerId, groupID) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddPeerToGroup indicates an expected call of AddPeerToGroup. +func (mr *MockStoreMockRecorder) AddPeerToGroup(ctx, accountID, peerId, groupID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddPeerToGroup", reflect.TypeOf((*MockStore)(nil).AddPeerToGroup), ctx, accountID, peerId, groupID) +} + +// AddResourceToGroup mocks base method. +func (m *MockStore) AddResourceToGroup(ctx context.Context, accountId, groupID string, resource *types3.Resource) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddResourceToGroup", ctx, accountId, groupID, resource) + ret0, _ := ret[0].(error) + return ret0 +} + +// AddResourceToGroup indicates an expected call of AddResourceToGroup. +func (mr *MockStoreMockRecorder) AddResourceToGroup(ctx, accountId, groupID, resource interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddResourceToGroup", reflect.TypeOf((*MockStore)(nil).AddResourceToGroup), ctx, accountId, groupID, resource) +} + +// ApproveAccountPeers mocks base method. +func (m *MockStore) ApproveAccountPeers(ctx context.Context, accountID string) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ApproveAccountPeers", ctx, accountID) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ApproveAccountPeers indicates an expected call of ApproveAccountPeers. +func (mr *MockStoreMockRecorder) ApproveAccountPeers(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApproveAccountPeers", reflect.TypeOf((*MockStore)(nil).ApproveAccountPeers), ctx, accountID) +} + +// CleanupStaleProxies mocks base method. +func (m *MockStore) CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CleanupStaleProxies", ctx, inactivityDuration) + ret0, _ := ret[0].(error) + return ret0 +} + +// CleanupStaleProxies indicates an expected call of CleanupStaleProxies. +func (mr *MockStoreMockRecorder) CleanupStaleProxies(ctx, inactivityDuration interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanupStaleProxies", reflect.TypeOf((*MockStore)(nil).CleanupStaleProxies), ctx, inactivityDuration) +} + +// Close mocks base method. +func (m *MockStore) Close(ctx context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Close", ctx) + ret0, _ := ret[0].(error) + return ret0 +} + +// Close indicates an expected call of Close. +func (mr *MockStoreMockRecorder) Close(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockStore)(nil).Close), ctx) +} + +// CompletePeerJob mocks base method. +func (m *MockStore) CompletePeerJob(ctx context.Context, job *types3.Job) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CompletePeerJob", ctx, job) + ret0, _ := ret[0].(error) + return ret0 +} + +// CompletePeerJob indicates an expected call of CompletePeerJob. +func (mr *MockStoreMockRecorder) CompletePeerJob(ctx, job interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CompletePeerJob", reflect.TypeOf((*MockStore)(nil).CompletePeerJob), ctx, job) +} + +// CountAccountsByPrivateDomain mocks base method. +func (m *MockStore) CountAccountsByPrivateDomain(ctx context.Context, domain string) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CountAccountsByPrivateDomain", ctx, domain) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CountAccountsByPrivateDomain indicates an expected call of CountAccountsByPrivateDomain. +func (mr *MockStoreMockRecorder) CountAccountsByPrivateDomain(ctx, domain interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountAccountsByPrivateDomain", reflect.TypeOf((*MockStore)(nil).CountAccountsByPrivateDomain), ctx, domain) +} + +// CountEphemeralServicesByPeer mocks base method. +func (m *MockStore) CountEphemeralServicesByPeer(ctx context.Context, lockStrength LockingStrength, accountID, peerID string) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CountEphemeralServicesByPeer", ctx, lockStrength, accountID, peerID) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CountEphemeralServicesByPeer indicates an expected call of CountEphemeralServicesByPeer. +func (mr *MockStoreMockRecorder) CountEphemeralServicesByPeer(ctx, lockStrength, accountID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountEphemeralServicesByPeer", reflect.TypeOf((*MockStore)(nil).CountEphemeralServicesByPeer), ctx, lockStrength, accountID, peerID) +} + +// CountProxiesByAccountID mocks base method. +func (m *MockStore) CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CountProxiesByAccountID", ctx, accountID) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CountProxiesByAccountID indicates an expected call of CountProxiesByAccountID. +func (mr *MockStoreMockRecorder) CountProxiesByAccountID(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountProxiesByAccountID", reflect.TypeOf((*MockStore)(nil).CountProxiesByAccountID), ctx, accountID) +} + +// CreateAccessLog mocks base method. +func (m *MockStore) CreateAccessLog(ctx context.Context, log *accesslogs.AccessLogEntry) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateAccessLog", ctx, log) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateAccessLog indicates an expected call of CreateAccessLog. +func (mr *MockStoreMockRecorder) CreateAccessLog(ctx, log interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAccessLog", reflect.TypeOf((*MockStore)(nil).CreateAccessLog), ctx, log) +} + +// CreateAgentNetworkAccessLog mocks base method. +func (m *MockStore) CreateAgentNetworkAccessLog(ctx context.Context, entry *types.AgentNetworkAccessLog, groups []types.AgentNetworkAccessLogGroup) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateAgentNetworkAccessLog", ctx, entry, groups) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateAgentNetworkAccessLog indicates an expected call of CreateAgentNetworkAccessLog. +func (mr *MockStoreMockRecorder) CreateAgentNetworkAccessLog(ctx, entry, groups interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkAccessLog", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkAccessLog), ctx, entry, groups) +} + +// CreateAgentNetworkUsage mocks base method. +func (m *MockStore) CreateAgentNetworkUsage(ctx context.Context, usage *types.AgentNetworkUsage, groups []types.AgentNetworkUsageGroup) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateAgentNetworkUsage", ctx, usage, groups) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateAgentNetworkUsage indicates an expected call of CreateAgentNetworkUsage. +func (mr *MockStoreMockRecorder) CreateAgentNetworkUsage(ctx, usage, groups interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAgentNetworkUsage", reflect.TypeOf((*MockStore)(nil).CreateAgentNetworkUsage), ctx, usage, groups) +} + +// CreateCustomDomain mocks base method. +func (m *MockStore) CreateCustomDomain(ctx context.Context, accountID, domainName, targetCluster string, validated bool) (*domain.Domain, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateCustomDomain", ctx, accountID, domainName, targetCluster, validated) + ret0, _ := ret[0].(*domain.Domain) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateCustomDomain indicates an expected call of CreateCustomDomain. +func (mr *MockStoreMockRecorder) CreateCustomDomain(ctx, accountID, domainName, targetCluster, validated interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateCustomDomain", reflect.TypeOf((*MockStore)(nil).CreateCustomDomain), ctx, accountID, domainName, targetCluster, validated) +} + +// CreateDNSRecord mocks base method. +func (m *MockStore) CreateDNSRecord(ctx context.Context, record *records.Record) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateDNSRecord", ctx, record) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateDNSRecord indicates an expected call of CreateDNSRecord. +func (mr *MockStoreMockRecorder) CreateDNSRecord(ctx, record interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateDNSRecord", reflect.TypeOf((*MockStore)(nil).CreateDNSRecord), ctx, record) +} + +// CreateGroup mocks base method. +func (m *MockStore) CreateGroup(ctx context.Context, group *types3.Group) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateGroup", ctx, group) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateGroup indicates an expected call of CreateGroup. +func (mr *MockStoreMockRecorder) CreateGroup(ctx, group interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateGroup", reflect.TypeOf((*MockStore)(nil).CreateGroup), ctx, group) +} + +// CreateGroups mocks base method. +func (m *MockStore) CreateGroups(ctx context.Context, accountID string, groups []*types3.Group) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateGroups", ctx, accountID, groups) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateGroups indicates an expected call of CreateGroups. +func (mr *MockStoreMockRecorder) CreateGroups(ctx, accountID, groups interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateGroups", reflect.TypeOf((*MockStore)(nil).CreateGroups), ctx, accountID, groups) +} + +// CreateNetworkRouter mocks base method. +func (m *MockStore) CreateNetworkRouter(ctx context.Context, router *types1.NetworkRouter) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateNetworkRouter", ctx, router) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateNetworkRouter indicates an expected call of CreateNetworkRouter. +func (mr *MockStoreMockRecorder) CreateNetworkRouter(ctx, router interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateNetworkRouter", reflect.TypeOf((*MockStore)(nil).CreateNetworkRouter), ctx, router) +} + +// CreatePeerJob mocks base method. +func (m *MockStore) CreatePeerJob(ctx context.Context, job *types3.Job) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreatePeerJob", ctx, job) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreatePeerJob indicates an expected call of CreatePeerJob. +func (mr *MockStoreMockRecorder) CreatePeerJob(ctx, job interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePeerJob", reflect.TypeOf((*MockStore)(nil).CreatePeerJob), ctx, job) +} + +// CreatePolicy mocks base method. +func (m *MockStore) CreatePolicy(ctx context.Context, policy *types3.Policy) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreatePolicy", ctx, policy) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreatePolicy indicates an expected call of CreatePolicy. +func (mr *MockStoreMockRecorder) CreatePolicy(ctx, policy interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePolicy", reflect.TypeOf((*MockStore)(nil).CreatePolicy), ctx, policy) +} + +// CreateService mocks base method. +func (m *MockStore) CreateService(ctx context.Context, service *service.Service) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateService", ctx, service) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateService indicates an expected call of CreateService. +func (mr *MockStoreMockRecorder) CreateService(ctx, service interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateService", reflect.TypeOf((*MockStore)(nil).CreateService), ctx, service) +} + +// CreateZone mocks base method. +func (m *MockStore) CreateZone(ctx context.Context, zone *zones.Zone) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateZone", ctx, zone) + ret0, _ := ret[0].(error) + return ret0 +} + +// CreateZone indicates an expected call of CreateZone. +func (mr *MockStoreMockRecorder) CreateZone(ctx, zone interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateZone", reflect.TypeOf((*MockStore)(nil).CreateZone), ctx, zone) +} + +// DeleteAccount mocks base method. +func (m *MockStore) DeleteAccount(ctx context.Context, account *types3.Account) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAccount", ctx, account) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAccount indicates an expected call of DeleteAccount. +func (mr *MockStoreMockRecorder) DeleteAccount(ctx, account interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccount", reflect.TypeOf((*MockStore)(nil).DeleteAccount), ctx, account) +} + +// DeleteAccountCluster mocks base method. +func (m *MockStore) DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAccountCluster", ctx, clusterAddress, accountID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAccountCluster indicates an expected call of DeleteAccountCluster. +func (mr *MockStoreMockRecorder) DeleteAccountCluster(ctx, clusterAddress, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccountCluster", reflect.TypeOf((*MockStore)(nil).DeleteAccountCluster), ctx, clusterAddress, accountID) +} + +// DeleteAgentNetworkBudgetRule mocks base method. +func (m *MockStore) DeleteAgentNetworkBudgetRule(ctx context.Context, accountID, ruleID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAgentNetworkBudgetRule", ctx, accountID, ruleID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAgentNetworkBudgetRule indicates an expected call of DeleteAgentNetworkBudgetRule. +func (mr *MockStoreMockRecorder) DeleteAgentNetworkBudgetRule(ctx, accountID, ruleID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkBudgetRule", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkBudgetRule), ctx, accountID, ruleID) +} + +// DeleteAgentNetworkGuardrail mocks base method. +func (m *MockStore) DeleteAgentNetworkGuardrail(ctx context.Context, accountID, guardrailID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAgentNetworkGuardrail", ctx, accountID, guardrailID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAgentNetworkGuardrail indicates an expected call of DeleteAgentNetworkGuardrail. +func (mr *MockStoreMockRecorder) DeleteAgentNetworkGuardrail(ctx, accountID, guardrailID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkGuardrail", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkGuardrail), ctx, accountID, guardrailID) +} + +// DeleteAgentNetworkPolicy mocks base method. +func (m *MockStore) DeleteAgentNetworkPolicy(ctx context.Context, accountID, policyID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAgentNetworkPolicy", ctx, accountID, policyID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAgentNetworkPolicy indicates an expected call of DeleteAgentNetworkPolicy. +func (mr *MockStoreMockRecorder) DeleteAgentNetworkPolicy(ctx, accountID, policyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkPolicy", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkPolicy), ctx, accountID, policyID) +} + +// DeleteAgentNetworkProvider mocks base method. +func (m *MockStore) DeleteAgentNetworkProvider(ctx context.Context, accountID, providerID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAgentNetworkProvider", ctx, accountID, providerID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAgentNetworkProvider indicates an expected call of DeleteAgentNetworkProvider. +func (mr *MockStoreMockRecorder) DeleteAgentNetworkProvider(ctx, accountID, providerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkProvider", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkProvider), ctx, accountID, providerID) +} + +// DeleteCustomDomain mocks base method. +func (m *MockStore) DeleteCustomDomain(ctx context.Context, accountID, domainID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteCustomDomain", ctx, accountID, domainID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteCustomDomain indicates an expected call of DeleteCustomDomain. +func (mr *MockStoreMockRecorder) DeleteCustomDomain(ctx, accountID, domainID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCustomDomain", reflect.TypeOf((*MockStore)(nil).DeleteCustomDomain), ctx, accountID, domainID) +} + +// DeleteDNSRecord mocks base method. +func (m *MockStore) DeleteDNSRecord(ctx context.Context, accountID, zoneID, recordID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteDNSRecord", ctx, accountID, zoneID, recordID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteDNSRecord indicates an expected call of DeleteDNSRecord. +func (mr *MockStoreMockRecorder) DeleteDNSRecord(ctx, accountID, zoneID, recordID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteDNSRecord", reflect.TypeOf((*MockStore)(nil).DeleteDNSRecord), ctx, accountID, zoneID, recordID) +} + +// DeleteGroup mocks base method. +func (m *MockStore) DeleteGroup(ctx context.Context, accountID, groupID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteGroup", ctx, accountID, groupID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteGroup indicates an expected call of DeleteGroup. +func (mr *MockStoreMockRecorder) DeleteGroup(ctx, accountID, groupID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteGroup", reflect.TypeOf((*MockStore)(nil).DeleteGroup), ctx, accountID, groupID) +} + +// DeleteGroups mocks base method. +func (m *MockStore) DeleteGroups(ctx context.Context, accountID string, groupIDs []string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteGroups", ctx, accountID, groupIDs) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteGroups indicates an expected call of DeleteGroups. +func (mr *MockStoreMockRecorder) DeleteGroups(ctx, accountID, groupIDs interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteGroups", reflect.TypeOf((*MockStore)(nil).DeleteGroups), ctx, accountID, groupIDs) +} + +// DeleteHashedPAT2TokenIDIndex mocks base method. +func (m *MockStore) DeleteHashedPAT2TokenIDIndex(hashedToken string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteHashedPAT2TokenIDIndex", hashedToken) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteHashedPAT2TokenIDIndex indicates an expected call of DeleteHashedPAT2TokenIDIndex. +func (mr *MockStoreMockRecorder) DeleteHashedPAT2TokenIDIndex(hashedToken interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteHashedPAT2TokenIDIndex", reflect.TypeOf((*MockStore)(nil).DeleteHashedPAT2TokenIDIndex), hashedToken) +} + +// DeleteNameServerGroup mocks base method. +func (m *MockStore) DeleteNameServerGroup(ctx context.Context, accountID, nameServerGroupID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteNameServerGroup", ctx, accountID, nameServerGroupID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteNameServerGroup indicates an expected call of DeleteNameServerGroup. +func (mr *MockStoreMockRecorder) DeleteNameServerGroup(ctx, accountID, nameServerGroupID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNameServerGroup", reflect.TypeOf((*MockStore)(nil).DeleteNameServerGroup), ctx, accountID, nameServerGroupID) +} + +// DeleteNetwork mocks base method. +func (m *MockStore) DeleteNetwork(ctx context.Context, accountID, networkID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteNetwork", ctx, accountID, networkID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteNetwork indicates an expected call of DeleteNetwork. +func (mr *MockStoreMockRecorder) DeleteNetwork(ctx, accountID, networkID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNetwork", reflect.TypeOf((*MockStore)(nil).DeleteNetwork), ctx, accountID, networkID) +} + +// DeleteNetworkResource mocks base method. +func (m *MockStore) DeleteNetworkResource(ctx context.Context, accountID, resourceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteNetworkResource", ctx, accountID, resourceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteNetworkResource indicates an expected call of DeleteNetworkResource. +func (mr *MockStoreMockRecorder) DeleteNetworkResource(ctx, accountID, resourceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNetworkResource", reflect.TypeOf((*MockStore)(nil).DeleteNetworkResource), ctx, accountID, resourceID) +} + +// DeleteNetworkRouter mocks base method. +func (m *MockStore) DeleteNetworkRouter(ctx context.Context, accountID, routerID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteNetworkRouter", ctx, accountID, routerID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteNetworkRouter indicates an expected call of DeleteNetworkRouter. +func (mr *MockStoreMockRecorder) DeleteNetworkRouter(ctx, accountID, routerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNetworkRouter", reflect.TypeOf((*MockStore)(nil).DeleteNetworkRouter), ctx, accountID, routerID) +} + +// DeleteOldAccessLogs mocks base method. +func (m *MockStore) DeleteOldAccessLogs(ctx context.Context, olderThan time.Time) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteOldAccessLogs", ctx, olderThan) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteOldAccessLogs indicates an expected call of DeleteOldAccessLogs. +func (mr *MockStoreMockRecorder) DeleteOldAccessLogs(ctx, olderThan interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldAccessLogs", reflect.TypeOf((*MockStore)(nil).DeleteOldAccessLogs), ctx, olderThan) +} + +// DeleteOldAgentNetworkAccessLogs mocks base method. +func (m *MockStore) DeleteOldAgentNetworkAccessLogs(ctx context.Context, accountID string, olderThan time.Time) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteOldAgentNetworkAccessLogs", ctx, accountID, olderThan) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteOldAgentNetworkAccessLogs indicates an expected call of DeleteOldAgentNetworkAccessLogs. +func (mr *MockStoreMockRecorder) DeleteOldAgentNetworkAccessLogs(ctx, accountID, olderThan interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldAgentNetworkAccessLogs", reflect.TypeOf((*MockStore)(nil).DeleteOldAgentNetworkAccessLogs), ctx, accountID, olderThan) +} + +// DeletePAT mocks base method. +func (m *MockStore) DeletePAT(ctx context.Context, userID, patID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeletePAT", ctx, userID, patID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeletePAT indicates an expected call of DeletePAT. +func (mr *MockStoreMockRecorder) DeletePAT(ctx, userID, patID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePAT", reflect.TypeOf((*MockStore)(nil).DeletePAT), ctx, userID, patID) +} + +// DeletePeer mocks base method. +func (m *MockStore) DeletePeer(ctx context.Context, accountID, peerID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeletePeer", ctx, accountID, peerID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeletePeer indicates an expected call of DeletePeer. +func (mr *MockStoreMockRecorder) DeletePeer(ctx, accountID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePeer", reflect.TypeOf((*MockStore)(nil).DeletePeer), ctx, accountID, peerID) +} + +// DeletePolicy mocks base method. +func (m *MockStore) DeletePolicy(ctx context.Context, accountID, policyID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeletePolicy", ctx, accountID, policyID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeletePolicy indicates an expected call of DeletePolicy. +func (mr *MockStoreMockRecorder) DeletePolicy(ctx, accountID, policyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePolicy", reflect.TypeOf((*MockStore)(nil).DeletePolicy), ctx, accountID, policyID) +} + +// DeletePostureChecks mocks base method. +func (m *MockStore) DeletePostureChecks(ctx context.Context, accountID, postureChecksID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeletePostureChecks", ctx, accountID, postureChecksID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeletePostureChecks indicates an expected call of DeletePostureChecks. +func (mr *MockStoreMockRecorder) DeletePostureChecks(ctx, accountID, postureChecksID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePostureChecks", reflect.TypeOf((*MockStore)(nil).DeletePostureChecks), ctx, accountID, postureChecksID) +} + +// DeleteRoute mocks base method. +func (m *MockStore) DeleteRoute(ctx context.Context, accountID, routeID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteRoute", ctx, accountID, routeID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteRoute indicates an expected call of DeleteRoute. +func (mr *MockStoreMockRecorder) DeleteRoute(ctx, accountID, routeID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteRoute", reflect.TypeOf((*MockStore)(nil).DeleteRoute), ctx, accountID, routeID) +} + +// DeleteService mocks base method. +func (m *MockStore) DeleteService(ctx context.Context, accountID, serviceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteService", ctx, accountID, serviceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteService indicates an expected call of DeleteService. +func (mr *MockStoreMockRecorder) DeleteService(ctx, accountID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteService", reflect.TypeOf((*MockStore)(nil).DeleteService), ctx, accountID, serviceID) +} + +// DeleteServiceTargets mocks base method. +func (m *MockStore) DeleteServiceTargets(ctx context.Context, accountID, serviceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteServiceTargets", ctx, accountID, serviceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteServiceTargets indicates an expected call of DeleteServiceTargets. +func (mr *MockStoreMockRecorder) DeleteServiceTargets(ctx, accountID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteServiceTargets", reflect.TypeOf((*MockStore)(nil).DeleteServiceTargets), ctx, accountID, serviceID) +} + +// DeleteSetupKey mocks base method. +func (m *MockStore) DeleteSetupKey(ctx context.Context, accountID, keyID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteSetupKey", ctx, accountID, keyID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteSetupKey indicates an expected call of DeleteSetupKey. +func (mr *MockStoreMockRecorder) DeleteSetupKey(ctx, accountID, keyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteSetupKey", reflect.TypeOf((*MockStore)(nil).DeleteSetupKey), ctx, accountID, keyID) +} + +// DeleteTarget mocks base method. +func (m *MockStore) DeleteTarget(ctx context.Context, accountID, serviceID string, targetID uint) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteTarget", ctx, accountID, serviceID, targetID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteTarget indicates an expected call of DeleteTarget. +func (mr *MockStoreMockRecorder) DeleteTarget(ctx, accountID, serviceID, targetID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTarget", reflect.TypeOf((*MockStore)(nil).DeleteTarget), ctx, accountID, serviceID, targetID) +} + +// DeleteTokenID2UserIDIndex mocks base method. +func (m *MockStore) DeleteTokenID2UserIDIndex(tokenID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteTokenID2UserIDIndex", tokenID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteTokenID2UserIDIndex indicates an expected call of DeleteTokenID2UserIDIndex. +func (mr *MockStoreMockRecorder) DeleteTokenID2UserIDIndex(tokenID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTokenID2UserIDIndex", reflect.TypeOf((*MockStore)(nil).DeleteTokenID2UserIDIndex), tokenID) +} + +// DeleteUser mocks base method. +func (m *MockStore) DeleteUser(ctx context.Context, accountID, userID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteUser", ctx, accountID, userID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteUser indicates an expected call of DeleteUser. +func (mr *MockStoreMockRecorder) DeleteUser(ctx, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUser", reflect.TypeOf((*MockStore)(nil).DeleteUser), ctx, accountID, userID) +} + +// DeleteUserInvite mocks base method. +func (m *MockStore) DeleteUserInvite(ctx context.Context, inviteID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteUserInvite", ctx, inviteID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteUserInvite indicates an expected call of DeleteUserInvite. +func (mr *MockStoreMockRecorder) DeleteUserInvite(ctx, inviteID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserInvite", reflect.TypeOf((*MockStore)(nil).DeleteUserInvite), ctx, inviteID) +} + +// DeleteZone mocks base method. +func (m *MockStore) DeleteZone(ctx context.Context, accountID, zoneID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteZone", ctx, accountID, zoneID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteZone indicates an expected call of DeleteZone. +func (mr *MockStoreMockRecorder) DeleteZone(ctx, accountID, zoneID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZone", reflect.TypeOf((*MockStore)(nil).DeleteZone), ctx, accountID, zoneID) +} + +// DeleteZoneDNSRecords mocks base method. +func (m *MockStore) DeleteZoneDNSRecords(ctx context.Context, accountID, zoneID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteZoneDNSRecords", ctx, accountID, zoneID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteZoneDNSRecords indicates an expected call of DeleteZoneDNSRecords. +func (mr *MockStoreMockRecorder) DeleteZoneDNSRecords(ctx, accountID, zoneID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZoneDNSRecords", reflect.TypeOf((*MockStore)(nil).DeleteZoneDNSRecords), ctx, accountID, zoneID) +} + +// DisconnectAllProxies mocks base method. +func (m *MockStore) DisconnectAllProxies(ctx context.Context) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DisconnectAllProxies", ctx) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DisconnectAllProxies indicates an expected call of DisconnectAllProxies. +func (mr *MockStoreMockRecorder) DisconnectAllProxies(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DisconnectAllProxies", reflect.TypeOf((*MockStore)(nil).DisconnectAllProxies), ctx) +} + +// DisconnectProxy mocks base method. +func (m *MockStore) DisconnectProxy(ctx context.Context, proxyID, sessionID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DisconnectProxy", ctx, proxyID, sessionID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DisconnectProxy indicates an expected call of DisconnectProxy. +func (mr *MockStoreMockRecorder) DisconnectProxy(ctx, proxyID, sessionID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DisconnectProxy", reflect.TypeOf((*MockStore)(nil).DisconnectProxy), ctx, proxyID, sessionID) +} + +// EphemeralServiceExists mocks base method. +func (m *MockStore) EphemeralServiceExists(ctx context.Context, lockStrength LockingStrength, accountID, peerID, domain string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EphemeralServiceExists", ctx, lockStrength, accountID, peerID, domain) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// EphemeralServiceExists indicates an expected call of EphemeralServiceExists. +func (mr *MockStoreMockRecorder) EphemeralServiceExists(ctx, lockStrength, accountID, peerID, domain interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EphemeralServiceExists", reflect.TypeOf((*MockStore)(nil).EphemeralServiceExists), ctx, lockStrength, accountID, peerID, domain) +} + +// ExecuteInTransaction mocks base method. +func (m *MockStore) ExecuteInTransaction(ctx context.Context, f func(Store) error) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ExecuteInTransaction", ctx, f) + ret0, _ := ret[0].(error) + return ret0 +} + +// ExecuteInTransaction indicates an expected call of ExecuteInTransaction. +func (mr *MockStoreMockRecorder) ExecuteInTransaction(ctx, f interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecuteInTransaction", reflect.TypeOf((*MockStore)(nil).ExecuteInTransaction), ctx, f) +} + +// GetAccount mocks base method. +func (m *MockStore) GetAccount(ctx context.Context, accountID string) (*types3.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccount", ctx, accountID) + ret0, _ := ret[0].(*types3.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccount indicates an expected call of GetAccount. +func (mr *MockStoreMockRecorder) GetAccount(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccount", reflect.TypeOf((*MockStore)(nil).GetAccount), ctx, accountID) +} + +// GetAccountAccessLogs mocks base method. +func (m *MockStore) GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountAccessLogs", ctx, lockStrength, accountID, filter) + ret0, _ := ret[0].([]*accesslogs.AccessLogEntry) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetAccountAccessLogs indicates an expected call of GetAccountAccessLogs. +func (mr *MockStoreMockRecorder) GetAccountAccessLogs(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAccessLogs", reflect.TypeOf((*MockStore)(nil).GetAccountAccessLogs), ctx, lockStrength, accountID, filter) +} + +// GetAccountAgentNetworkBudgetRules mocks base method. +func (m *MockStore) GetAccountAgentNetworkBudgetRules(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.AccountBudgetRule, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountAgentNetworkBudgetRules", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types.AccountBudgetRule) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountAgentNetworkBudgetRules indicates an expected call of GetAccountAgentNetworkBudgetRules. +func (mr *MockStoreMockRecorder) GetAccountAgentNetworkBudgetRules(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkBudgetRules", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkBudgetRules), ctx, lockStrength, accountID) +} + +// GetAccountAgentNetworkGuardrails mocks base method. +func (m *MockStore) GetAccountAgentNetworkGuardrails(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.Guardrail, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountAgentNetworkGuardrails", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types.Guardrail) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountAgentNetworkGuardrails indicates an expected call of GetAccountAgentNetworkGuardrails. +func (mr *MockStoreMockRecorder) GetAccountAgentNetworkGuardrails(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkGuardrails", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkGuardrails), ctx, lockStrength, accountID) +} + +// GetAccountAgentNetworkPolicies mocks base method. +func (m *MockStore) GetAccountAgentNetworkPolicies(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.Policy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountAgentNetworkPolicies", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types.Policy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountAgentNetworkPolicies indicates an expected call of GetAccountAgentNetworkPolicies. +func (mr *MockStoreMockRecorder) GetAccountAgentNetworkPolicies(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkPolicies", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkPolicies), ctx, lockStrength, accountID) +} + +// GetAccountAgentNetworkProviders mocks base method. +func (m *MockStore) GetAccountAgentNetworkProviders(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.Provider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountAgentNetworkProviders", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types.Provider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountAgentNetworkProviders indicates an expected call of GetAccountAgentNetworkProviders. +func (mr *MockStoreMockRecorder) GetAccountAgentNetworkProviders(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAgentNetworkProviders", reflect.TypeOf((*MockStore)(nil).GetAccountAgentNetworkProviders), ctx, lockStrength, accountID) +} + +// GetAccountByPeerID mocks base method. +func (m *MockStore) GetAccountByPeerID(ctx context.Context, peerID string) (*types3.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountByPeerID", ctx, peerID) + ret0, _ := ret[0].(*types3.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountByPeerID indicates an expected call of GetAccountByPeerID. +func (mr *MockStoreMockRecorder) GetAccountByPeerID(ctx, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountByPeerID", reflect.TypeOf((*MockStore)(nil).GetAccountByPeerID), ctx, peerID) +} + +// GetAccountByPeerPubKey mocks base method. +func (m *MockStore) GetAccountByPeerPubKey(ctx context.Context, peerKey string) (*types3.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountByPeerPubKey", ctx, peerKey) + ret0, _ := ret[0].(*types3.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountByPeerPubKey indicates an expected call of GetAccountByPeerPubKey. +func (mr *MockStoreMockRecorder) GetAccountByPeerPubKey(ctx, peerKey interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountByPeerPubKey", reflect.TypeOf((*MockStore)(nil).GetAccountByPeerPubKey), ctx, peerKey) +} + +// GetAccountByPrivateDomain mocks base method. +func (m *MockStore) GetAccountByPrivateDomain(ctx context.Context, domain string) (*types3.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountByPrivateDomain", ctx, domain) + ret0, _ := ret[0].(*types3.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountByPrivateDomain indicates an expected call of GetAccountByPrivateDomain. +func (mr *MockStoreMockRecorder) GetAccountByPrivateDomain(ctx, domain interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountByPrivateDomain", reflect.TypeOf((*MockStore)(nil).GetAccountByPrivateDomain), ctx, domain) +} + +// GetAccountBySetupKey mocks base method. +func (m *MockStore) GetAccountBySetupKey(ctx context.Context, setupKey string) (*types3.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountBySetupKey", ctx, setupKey) + ret0, _ := ret[0].(*types3.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountBySetupKey indicates an expected call of GetAccountBySetupKey. +func (mr *MockStoreMockRecorder) GetAccountBySetupKey(ctx, setupKey interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountBySetupKey", reflect.TypeOf((*MockStore)(nil).GetAccountBySetupKey), ctx, setupKey) +} + +// GetAccountByUser mocks base method. +func (m *MockStore) GetAccountByUser(ctx context.Context, userID string) (*types3.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountByUser", ctx, userID) + ret0, _ := ret[0].(*types3.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountByUser indicates an expected call of GetAccountByUser. +func (mr *MockStoreMockRecorder) GetAccountByUser(ctx, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountByUser", reflect.TypeOf((*MockStore)(nil).GetAccountByUser), ctx, userID) +} + +// GetAccountCreatedBy mocks base method. +func (m *MockStore) GetAccountCreatedBy(ctx context.Context, lockStrength LockingStrength, accountID string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountCreatedBy", ctx, lockStrength, accountID) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountCreatedBy indicates an expected call of GetAccountCreatedBy. +func (mr *MockStoreMockRecorder) GetAccountCreatedBy(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountCreatedBy", reflect.TypeOf((*MockStore)(nil).GetAccountCreatedBy), ctx, lockStrength, accountID) +} + +// GetAccountDNSSettings mocks base method. +func (m *MockStore) GetAccountDNSSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*types3.DNSSettings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountDNSSettings", ctx, lockStrength, accountID) + ret0, _ := ret[0].(*types3.DNSSettings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountDNSSettings indicates an expected call of GetAccountDNSSettings. +func (mr *MockStoreMockRecorder) GetAccountDNSSettings(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountDNSSettings", reflect.TypeOf((*MockStore)(nil).GetAccountDNSSettings), ctx, lockStrength, accountID) +} + +// GetAccountDomainAndCategory mocks base method. +func (m *MockStore) GetAccountDomainAndCategory(ctx context.Context, lockStrength LockingStrength, accountID string) (string, string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountDomainAndCategory", ctx, lockStrength, accountID) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetAccountDomainAndCategory indicates an expected call of GetAccountDomainAndCategory. +func (mr *MockStoreMockRecorder) GetAccountDomainAndCategory(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountDomainAndCategory", reflect.TypeOf((*MockStore)(nil).GetAccountDomainAndCategory), ctx, lockStrength, accountID) +} + +// GetAccountGroupPeers mocks base method. +func (m *MockStore) GetAccountGroupPeers(ctx context.Context, lockStrength LockingStrength, accountID string) (map[string]map[string]struct{}, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountGroupPeers", ctx, lockStrength, accountID) + ret0, _ := ret[0].(map[string]map[string]struct{}) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountGroupPeers indicates an expected call of GetAccountGroupPeers. +func (mr *MockStoreMockRecorder) GetAccountGroupPeers(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountGroupPeers", reflect.TypeOf((*MockStore)(nil).GetAccountGroupPeers), ctx, lockStrength, accountID) +} + +// GetAccountGroups mocks base method. +func (m *MockStore) GetAccountGroups(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types3.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountGroups", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types3.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountGroups indicates an expected call of GetAccountGroups. +func (mr *MockStoreMockRecorder) GetAccountGroups(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountGroups", reflect.TypeOf((*MockStore)(nil).GetAccountGroups), ctx, lockStrength, accountID) +} + +// GetAccountIDByPeerID mocks base method. +func (m *MockStore) GetAccountIDByPeerID(ctx context.Context, lockStrength LockingStrength, peerID string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountIDByPeerID", ctx, lockStrength, peerID) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountIDByPeerID indicates an expected call of GetAccountIDByPeerID. +func (mr *MockStoreMockRecorder) GetAccountIDByPeerID(ctx, lockStrength, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDByPeerID", reflect.TypeOf((*MockStore)(nil).GetAccountIDByPeerID), ctx, lockStrength, peerID) +} + +// GetAccountIDByPeerPubKey mocks base method. +func (m *MockStore) GetAccountIDByPeerPubKey(ctx context.Context, peerKey string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountIDByPeerPubKey", ctx, peerKey) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountIDByPeerPubKey indicates an expected call of GetAccountIDByPeerPubKey. +func (mr *MockStoreMockRecorder) GetAccountIDByPeerPubKey(ctx, peerKey interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDByPeerPubKey", reflect.TypeOf((*MockStore)(nil).GetAccountIDByPeerPubKey), ctx, peerKey) +} + +// GetAccountIDByPrivateDomain mocks base method. +func (m *MockStore) GetAccountIDByPrivateDomain(ctx context.Context, lockStrength LockingStrength, domain string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountIDByPrivateDomain", ctx, lockStrength, domain) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountIDByPrivateDomain indicates an expected call of GetAccountIDByPrivateDomain. +func (mr *MockStoreMockRecorder) GetAccountIDByPrivateDomain(ctx, lockStrength, domain interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDByPrivateDomain", reflect.TypeOf((*MockStore)(nil).GetAccountIDByPrivateDomain), ctx, lockStrength, domain) +} + +// GetAccountIDBySetupKey mocks base method. +func (m *MockStore) GetAccountIDBySetupKey(ctx context.Context, peerKey string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountIDBySetupKey", ctx, peerKey) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountIDBySetupKey indicates an expected call of GetAccountIDBySetupKey. +func (mr *MockStoreMockRecorder) GetAccountIDBySetupKey(ctx, peerKey interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDBySetupKey", reflect.TypeOf((*MockStore)(nil).GetAccountIDBySetupKey), ctx, peerKey) +} + +// GetAccountIDByUserID mocks base method. +func (m *MockStore) GetAccountIDByUserID(ctx context.Context, lockStrength LockingStrength, userID string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountIDByUserID", ctx, lockStrength, userID) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountIDByUserID indicates an expected call of GetAccountIDByUserID. +func (mr *MockStoreMockRecorder) GetAccountIDByUserID(ctx, lockStrength, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountIDByUserID", reflect.TypeOf((*MockStore)(nil).GetAccountIDByUserID), ctx, lockStrength, userID) +} + +// GetAccountMeta mocks base method. +func (m *MockStore) GetAccountMeta(ctx context.Context, lockStrength LockingStrength, accountID string) (*types3.AccountMeta, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountMeta", ctx, lockStrength, accountID) + ret0, _ := ret[0].(*types3.AccountMeta) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountMeta indicates an expected call of GetAccountMeta. +func (mr *MockStoreMockRecorder) GetAccountMeta(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountMeta", reflect.TypeOf((*MockStore)(nil).GetAccountMeta), ctx, lockStrength, accountID) +} + +// GetAccountNameServerGroups mocks base method. +func (m *MockStore) GetAccountNameServerGroups(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*dns.NameServerGroup, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountNameServerGroups", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*dns.NameServerGroup) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountNameServerGroups indicates an expected call of GetAccountNameServerGroups. +func (mr *MockStoreMockRecorder) GetAccountNameServerGroups(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountNameServerGroups", reflect.TypeOf((*MockStore)(nil).GetAccountNameServerGroups), ctx, lockStrength, accountID) +} + +// GetAccountNetwork mocks base method. +func (m *MockStore) GetAccountNetwork(ctx context.Context, lockStrength LockingStrength, accountId string) (*types3.Network, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountNetwork", ctx, lockStrength, accountId) + ret0, _ := ret[0].(*types3.Network) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountNetwork indicates an expected call of GetAccountNetwork. +func (mr *MockStoreMockRecorder) GetAccountNetwork(ctx, lockStrength, accountId interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountNetwork", reflect.TypeOf((*MockStore)(nil).GetAccountNetwork), ctx, lockStrength, accountId) +} + +// GetAccountNetworks mocks base method. +func (m *MockStore) GetAccountNetworks(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types2.Network, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountNetworks", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types2.Network) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountNetworks indicates an expected call of GetAccountNetworks. +func (mr *MockStoreMockRecorder) GetAccountNetworks(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountNetworks", reflect.TypeOf((*MockStore)(nil).GetAccountNetworks), ctx, lockStrength, accountID) +} + +// GetAccountOnboarding mocks base method. +func (m *MockStore) GetAccountOnboarding(ctx context.Context, accountID string) (*types3.AccountOnboarding, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountOnboarding", ctx, accountID) + ret0, _ := ret[0].(*types3.AccountOnboarding) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountOnboarding indicates an expected call of GetAccountOnboarding. +func (mr *MockStoreMockRecorder) GetAccountOnboarding(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountOnboarding", reflect.TypeOf((*MockStore)(nil).GetAccountOnboarding), ctx, accountID) +} + +// GetAccountOwner mocks base method. +func (m *MockStore) GetAccountOwner(ctx context.Context, lockStrength LockingStrength, accountID string) (*types3.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountOwner", ctx, lockStrength, accountID) + ret0, _ := ret[0].(*types3.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountOwner indicates an expected call of GetAccountOwner. +func (mr *MockStoreMockRecorder) GetAccountOwner(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountOwner", reflect.TypeOf((*MockStore)(nil).GetAccountOwner), ctx, lockStrength, accountID) +} + +// GetAccountPeers mocks base method. +func (m *MockStore) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter string) ([]*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountPeers", ctx, lockStrength, accountID, nameFilter, ipFilter) + ret0, _ := ret[0].([]*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountPeers indicates an expected call of GetAccountPeers. +func (mr *MockStoreMockRecorder) GetAccountPeers(ctx, lockStrength, accountID, nameFilter, ipFilter interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeers", reflect.TypeOf((*MockStore)(nil).GetAccountPeers), ctx, lockStrength, accountID, nameFilter, ipFilter) +} + +// GetAccountPeersWithExpiration mocks base method. +func (m *MockStore) GetAccountPeersWithExpiration(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountPeersWithExpiration", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountPeersWithExpiration indicates an expected call of GetAccountPeersWithExpiration. +func (mr *MockStoreMockRecorder) GetAccountPeersWithExpiration(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeersWithExpiration", reflect.TypeOf((*MockStore)(nil).GetAccountPeersWithExpiration), ctx, lockStrength, accountID) +} + +// GetAccountPeersWithInactivity mocks base method. +func (m *MockStore) GetAccountPeersWithInactivity(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountPeersWithInactivity", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountPeersWithInactivity indicates an expected call of GetAccountPeersWithInactivity. +func (mr *MockStoreMockRecorder) GetAccountPeersWithInactivity(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPeersWithInactivity", reflect.TypeOf((*MockStore)(nil).GetAccountPeersWithInactivity), ctx, lockStrength, accountID) +} + +// GetAccountPolicies mocks base method. +func (m *MockStore) GetAccountPolicies(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types3.Policy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountPolicies", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types3.Policy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountPolicies indicates an expected call of GetAccountPolicies. +func (mr *MockStoreMockRecorder) GetAccountPolicies(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPolicies", reflect.TypeOf((*MockStore)(nil).GetAccountPolicies), ctx, lockStrength, accountID) +} + +// GetAccountPostureChecks mocks base method. +func (m *MockStore) GetAccountPostureChecks(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*posture.Checks, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountPostureChecks", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*posture.Checks) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountPostureChecks indicates an expected call of GetAccountPostureChecks. +func (mr *MockStoreMockRecorder) GetAccountPostureChecks(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountPostureChecks", reflect.TypeOf((*MockStore)(nil).GetAccountPostureChecks), ctx, lockStrength, accountID) +} + +// GetAccountRoutes mocks base method. +func (m *MockStore) GetAccountRoutes(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*route.Route, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountRoutes", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*route.Route) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountRoutes indicates an expected call of GetAccountRoutes. +func (mr *MockStoreMockRecorder) GetAccountRoutes(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountRoutes", reflect.TypeOf((*MockStore)(nil).GetAccountRoutes), ctx, lockStrength, accountID) +} + +// GetAccountServices mocks base method. +func (m *MockStore) GetAccountServices(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*service.Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountServices", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*service.Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountServices indicates an expected call of GetAccountServices. +func (mr *MockStoreMockRecorder) GetAccountServices(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountServices", reflect.TypeOf((*MockStore)(nil).GetAccountServices), ctx, lockStrength, accountID) +} + +// GetAccountSettings mocks base method. +func (m *MockStore) GetAccountSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*types3.Settings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountSettings", ctx, lockStrength, accountID) + ret0, _ := ret[0].(*types3.Settings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountSettings indicates an expected call of GetAccountSettings. +func (mr *MockStoreMockRecorder) GetAccountSettings(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountSettings", reflect.TypeOf((*MockStore)(nil).GetAccountSettings), ctx, lockStrength, accountID) +} + +// GetAccountSetupKeys mocks base method. +func (m *MockStore) GetAccountSetupKeys(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types3.SetupKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountSetupKeys", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types3.SetupKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountSetupKeys indicates an expected call of GetAccountSetupKeys. +func (mr *MockStoreMockRecorder) GetAccountSetupKeys(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountSetupKeys", reflect.TypeOf((*MockStore)(nil).GetAccountSetupKeys), ctx, lockStrength, accountID) +} + +// GetAccountUserInvites mocks base method. +func (m *MockStore) GetAccountUserInvites(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types3.UserInviteRecord, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountUserInvites", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types3.UserInviteRecord) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountUserInvites indicates an expected call of GetAccountUserInvites. +func (mr *MockStoreMockRecorder) GetAccountUserInvites(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountUserInvites", reflect.TypeOf((*MockStore)(nil).GetAccountUserInvites), ctx, lockStrength, accountID) +} + +// GetAccountUsers mocks base method. +func (m *MockStore) GetAccountUsers(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types3.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountUsers", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types3.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountUsers indicates an expected call of GetAccountUsers. +func (mr *MockStoreMockRecorder) GetAccountUsers(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountUsers", reflect.TypeOf((*MockStore)(nil).GetAccountUsers), ctx, lockStrength, accountID) +} + +// GetAccountZones mocks base method. +func (m *MockStore) GetAccountZones(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*zones.Zone, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountZones", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*zones.Zone) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountZones indicates an expected call of GetAccountZones. +func (mr *MockStoreMockRecorder) GetAccountZones(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountZones", reflect.TypeOf((*MockStore)(nil).GetAccountZones), ctx, lockStrength, accountID) +} + +// GetAccountsCounter mocks base method. +func (m *MockStore) GetAccountsCounter(ctx context.Context) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAccountsCounter", ctx) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAccountsCounter indicates an expected call of GetAccountsCounter. +func (mr *MockStoreMockRecorder) GetAccountsCounter(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountsCounter", reflect.TypeOf((*MockStore)(nil).GetAccountsCounter), ctx) +} + +// GetActiveProxyClusterAddresses mocks base method. +func (m *MockStore) GetActiveProxyClusterAddresses(ctx context.Context) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetActiveProxyClusterAddresses", ctx) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetActiveProxyClusterAddresses indicates an expected call of GetActiveProxyClusterAddresses. +func (mr *MockStoreMockRecorder) GetActiveProxyClusterAddresses(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveProxyClusterAddresses", reflect.TypeOf((*MockStore)(nil).GetActiveProxyClusterAddresses), ctx) +} + +// GetActiveProxyClusterAddressesForAccount mocks base method. +func (m *MockStore) GetActiveProxyClusterAddressesForAccount(ctx context.Context, accountID string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetActiveProxyClusterAddressesForAccount", ctx, accountID) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetActiveProxyClusterAddressesForAccount indicates an expected call of GetActiveProxyClusterAddressesForAccount. +func (mr *MockStoreMockRecorder) GetActiveProxyClusterAddressesForAccount(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveProxyClusterAddressesForAccount", reflect.TypeOf((*MockStore)(nil).GetActiveProxyClusterAddressesForAccount), ctx, accountID) +} + +// GetAgentNetworkAccessLogSessions mocks base method. +func (m *MockStore) GetAgentNetworkAccessLogSessions(ctx context.Context, lockStrength LockingStrength, accountID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkAccessLogSessions", ctx, lockStrength, accountID, filter) + ret0, _ := ret[0].([]*types.AgentNetworkAccessLogSession) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetAgentNetworkAccessLogSessions indicates an expected call of GetAgentNetworkAccessLogSessions. +func (mr *MockStoreMockRecorder) GetAgentNetworkAccessLogSessions(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkAccessLogSessions", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkAccessLogSessions), ctx, lockStrength, accountID, filter) +} + +// GetAgentNetworkAccessLogs mocks base method. +func (m *MockStore) GetAgentNetworkAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkAccessLogs", ctx, lockStrength, accountID, filter) + ret0, _ := ret[0].([]*types.AgentNetworkAccessLog) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetAgentNetworkAccessLogs indicates an expected call of GetAgentNetworkAccessLogs. +func (mr *MockStoreMockRecorder) GetAgentNetworkAccessLogs(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkAccessLogs", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkAccessLogs), ctx, lockStrength, accountID, filter) +} + +// GetAgentNetworkBudgetRuleByID mocks base method. +func (m *MockStore) GetAgentNetworkBudgetRuleByID(ctx context.Context, lockStrength LockingStrength, accountID, ruleID string) (*types.AccountBudgetRule, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkBudgetRuleByID", ctx, lockStrength, accountID, ruleID) + ret0, _ := ret[0].(*types.AccountBudgetRule) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkBudgetRuleByID indicates an expected call of GetAgentNetworkBudgetRuleByID. +func (mr *MockStoreMockRecorder) GetAgentNetworkBudgetRuleByID(ctx, lockStrength, accountID, ruleID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkBudgetRuleByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkBudgetRuleByID), ctx, lockStrength, accountID, ruleID) +} + +// GetAgentNetworkConsumption mocks base method. +func (m *MockStore) GetAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string, kind types.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time) (*types.Consumption, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkConsumption", ctx, lockStrength, accountID, kind, dimID, windowSeconds, windowStart) + ret0, _ := ret[0].(*types.Consumption) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkConsumption indicates an expected call of GetAgentNetworkConsumption. +func (mr *MockStoreMockRecorder) GetAgentNetworkConsumption(ctx, lockStrength, accountID, kind, dimID, windowSeconds, windowStart interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkConsumption", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkConsumption), ctx, lockStrength, accountID, kind, dimID, windowSeconds, windowStart) +} + +// GetAgentNetworkConsumptionBatch mocks base method. +func (m *MockStore) GetAgentNetworkConsumptionBatch(ctx context.Context, lockStrength LockingStrength, accountID string, keys []types.ConsumptionKey) (map[types.ConsumptionKey]*types.Consumption, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkConsumptionBatch", ctx, lockStrength, accountID, keys) + ret0, _ := ret[0].(map[types.ConsumptionKey]*types.Consumption) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkConsumptionBatch indicates an expected call of GetAgentNetworkConsumptionBatch. +func (mr *MockStoreMockRecorder) GetAgentNetworkConsumptionBatch(ctx, lockStrength, accountID, keys interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkConsumptionBatch", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkConsumptionBatch), ctx, lockStrength, accountID, keys) +} + +// GetAgentNetworkGuardrailByID mocks base method. +func (m *MockStore) GetAgentNetworkGuardrailByID(ctx context.Context, lockStrength LockingStrength, accountID, guardrailID string) (*types.Guardrail, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkGuardrailByID", ctx, lockStrength, accountID, guardrailID) + ret0, _ := ret[0].(*types.Guardrail) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkGuardrailByID indicates an expected call of GetAgentNetworkGuardrailByID. +func (mr *MockStoreMockRecorder) GetAgentNetworkGuardrailByID(ctx, lockStrength, accountID, guardrailID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkGuardrailByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkGuardrailByID), ctx, lockStrength, accountID, guardrailID) +} + +// GetAgentNetworkMetrics mocks base method. +func (m *MockStore) GetAgentNetworkMetrics(ctx context.Context) (AgentNetworkMetrics, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkMetrics", ctx) + ret0, _ := ret[0].(AgentNetworkMetrics) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkMetrics indicates an expected call of GetAgentNetworkMetrics. +func (mr *MockStoreMockRecorder) GetAgentNetworkMetrics(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkMetrics", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkMetrics), ctx) +} + +// GetAgentNetworkPolicyByID mocks base method. +func (m *MockStore) GetAgentNetworkPolicyByID(ctx context.Context, lockStrength LockingStrength, accountID, policyID string) (*types.Policy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkPolicyByID", ctx, lockStrength, accountID, policyID) + ret0, _ := ret[0].(*types.Policy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkPolicyByID indicates an expected call of GetAgentNetworkPolicyByID. +func (mr *MockStoreMockRecorder) GetAgentNetworkPolicyByID(ctx, lockStrength, accountID, policyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkPolicyByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkPolicyByID), ctx, lockStrength, accountID, policyID) +} + +// GetAgentNetworkProviderByID mocks base method. +func (m *MockStore) GetAgentNetworkProviderByID(ctx context.Context, lockStrength LockingStrength, accountID, providerID string) (*types.Provider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkProviderByID", ctx, lockStrength, accountID, providerID) + ret0, _ := ret[0].(*types.Provider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkProviderByID indicates an expected call of GetAgentNetworkProviderByID. +func (mr *MockStoreMockRecorder) GetAgentNetworkProviderByID(ctx, lockStrength, accountID, providerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkProviderByID", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkProviderByID), ctx, lockStrength, accountID, providerID) +} + +// GetAgentNetworkSettings mocks base method. +func (m *MockStore) GetAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*types.Settings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkSettings", ctx, lockStrength, accountID) + ret0, _ := ret[0].(*types.Settings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkSettings indicates an expected call of GetAgentNetworkSettings. +func (mr *MockStoreMockRecorder) GetAgentNetworkSettings(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettings), ctx, lockStrength, accountID) +} + +// GetAgentNetworkSettingsByCluster mocks base method. +func (m *MockStore) GetAgentNetworkSettingsByCluster(ctx context.Context, lockStrength LockingStrength, cluster string) ([]*types.Settings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkSettingsByCluster", ctx, lockStrength, cluster) + ret0, _ := ret[0].([]*types.Settings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkSettingsByCluster indicates an expected call of GetAgentNetworkSettingsByCluster. +func (mr *MockStoreMockRecorder) GetAgentNetworkSettingsByCluster(ctx, lockStrength, cluster interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkSettingsByCluster", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkSettingsByCluster), ctx, lockStrength, cluster) +} + +// GetAgentNetworkUsageRows mocks base method. +func (m *MockStore) GetAgentNetworkUsageRows(ctx context.Context, lockStrength LockingStrength, accountID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkUsage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkUsageRows", ctx, lockStrength, accountID, filter) + ret0, _ := ret[0].([]*types.AgentNetworkUsage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAgentNetworkUsageRows indicates an expected call of GetAgentNetworkUsageRows. +func (mr *MockStoreMockRecorder) GetAgentNetworkUsageRows(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkUsageRows", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkUsageRows), ctx, lockStrength, accountID, filter) +} + +// GetAllAccounts mocks base method. +func (m *MockStore) GetAllAccounts(ctx context.Context) []*types3.Account { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllAccounts", ctx) + ret0, _ := ret[0].([]*types3.Account) + return ret0 +} + +// GetAllAccounts indicates an expected call of GetAllAccounts. +func (mr *MockStoreMockRecorder) GetAllAccounts(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllAccounts", reflect.TypeOf((*MockStore)(nil).GetAllAccounts), ctx) +} + +// GetAllAgentNetworkProviders mocks base method. +func (m *MockStore) GetAllAgentNetworkProviders(ctx context.Context, lockStrength LockingStrength) ([]*types.Provider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllAgentNetworkProviders", ctx, lockStrength) + ret0, _ := ret[0].([]*types.Provider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllAgentNetworkProviders indicates an expected call of GetAllAgentNetworkProviders. +func (mr *MockStoreMockRecorder) GetAllAgentNetworkProviders(ctx, lockStrength interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllAgentNetworkProviders", reflect.TypeOf((*MockStore)(nil).GetAllAgentNetworkProviders), ctx, lockStrength) +} + +// GetAllAgentNetworkSettings mocks base method. +func (m *MockStore) GetAllAgentNetworkSettings(ctx context.Context, lockStrength LockingStrength) ([]*types.Settings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllAgentNetworkSettings", ctx, lockStrength) + ret0, _ := ret[0].([]*types.Settings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllAgentNetworkSettings indicates an expected call of GetAllAgentNetworkSettings. +func (mr *MockStoreMockRecorder) GetAllAgentNetworkSettings(ctx, lockStrength interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).GetAllAgentNetworkSettings), ctx, lockStrength) +} + +// GetAllEphemeralPeers mocks base method. +func (m *MockStore) GetAllEphemeralPeers(ctx context.Context, lockStrength LockingStrength) ([]*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllEphemeralPeers", ctx, lockStrength) + ret0, _ := ret[0].([]*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllEphemeralPeers indicates an expected call of GetAllEphemeralPeers. +func (mr *MockStoreMockRecorder) GetAllEphemeralPeers(ctx, lockStrength interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllEphemeralPeers", reflect.TypeOf((*MockStore)(nil).GetAllEphemeralPeers), ctx, lockStrength) +} + +// GetAllProxies mocks base method. +func (m *MockStore) GetAllProxies(ctx context.Context) ([]*proxy.Proxy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllProxies", ctx) + ret0, _ := ret[0].([]*proxy.Proxy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllProxies indicates an expected call of GetAllProxies. +func (mr *MockStoreMockRecorder) GetAllProxies(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllProxies", reflect.TypeOf((*MockStore)(nil).GetAllProxies), ctx) +} + +// GetAllProxyAccessTokens mocks base method. +func (m *MockStore) GetAllProxyAccessTokens(ctx context.Context, lockStrength LockingStrength) ([]*types3.ProxyAccessToken, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllProxyAccessTokens", ctx, lockStrength) + ret0, _ := ret[0].([]*types3.ProxyAccessToken) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllProxyAccessTokens indicates an expected call of GetAllProxyAccessTokens. +func (mr *MockStoreMockRecorder) GetAllProxyAccessTokens(ctx, lockStrength interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllProxyAccessTokens", reflect.TypeOf((*MockStore)(nil).GetAllProxyAccessTokens), ctx, lockStrength) +} + +// GetAnyAccountID mocks base method. +func (m *MockStore) GetAnyAccountID(ctx context.Context) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAnyAccountID", ctx) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAnyAccountID indicates an expected call of GetAnyAccountID. +func (mr *MockStoreMockRecorder) GetAnyAccountID(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAnyAccountID", reflect.TypeOf((*MockStore)(nil).GetAnyAccountID), ctx) +} + +// GetClusterRequireSubdomain mocks base method. +func (m *MockStore) GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetClusterRequireSubdomain", ctx, clusterAddr) + ret0, _ := ret[0].(*bool) + return ret0 +} + +// GetClusterRequireSubdomain indicates an expected call of GetClusterRequireSubdomain. +func (mr *MockStoreMockRecorder) GetClusterRequireSubdomain(ctx, clusterAddr interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterRequireSubdomain", reflect.TypeOf((*MockStore)(nil).GetClusterRequireSubdomain), ctx, clusterAddr) +} + +// GetClusterSupportsCrowdSec mocks base method. +func (m *MockStore) GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetClusterSupportsCrowdSec", ctx, clusterAddr) + ret0, _ := ret[0].(*bool) + return ret0 +} + +// GetClusterSupportsCrowdSec indicates an expected call of GetClusterSupportsCrowdSec. +func (mr *MockStoreMockRecorder) GetClusterSupportsCrowdSec(ctx, clusterAddr interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterSupportsCrowdSec", reflect.TypeOf((*MockStore)(nil).GetClusterSupportsCrowdSec), ctx, clusterAddr) +} + +// GetClusterSupportsCustomPorts mocks base method. +func (m *MockStore) GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetClusterSupportsCustomPorts", ctx, clusterAddr) + ret0, _ := ret[0].(*bool) + return ret0 +} + +// GetClusterSupportsCustomPorts indicates an expected call of GetClusterSupportsCustomPorts. +func (mr *MockStoreMockRecorder) GetClusterSupportsCustomPorts(ctx, clusterAddr interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterSupportsCustomPorts", reflect.TypeOf((*MockStore)(nil).GetClusterSupportsCustomPorts), ctx, clusterAddr) +} + +// GetClusterSupportsPrivate mocks base method. +func (m *MockStore) GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetClusterSupportsPrivate", ctx, clusterAddr) + ret0, _ := ret[0].(*bool) + return ret0 +} + +// GetClusterSupportsPrivate indicates an expected call of GetClusterSupportsPrivate. +func (mr *MockStoreMockRecorder) GetClusterSupportsPrivate(ctx, clusterAddr interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterSupportsPrivate", reflect.TypeOf((*MockStore)(nil).GetClusterSupportsPrivate), ctx, clusterAddr) +} + +// GetCustomDomain mocks base method. +func (m *MockStore) GetCustomDomain(ctx context.Context, accountID, domainID string) (*domain.Domain, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCustomDomain", ctx, accountID, domainID) + ret0, _ := ret[0].(*domain.Domain) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCustomDomain indicates an expected call of GetCustomDomain. +func (mr *MockStoreMockRecorder) GetCustomDomain(ctx, accountID, domainID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCustomDomain", reflect.TypeOf((*MockStore)(nil).GetCustomDomain), ctx, accountID, domainID) +} + +// GetCustomDomainsCounts mocks base method. +func (m *MockStore) GetCustomDomainsCounts(ctx context.Context) (int64, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCustomDomainsCounts", ctx) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetCustomDomainsCounts indicates an expected call of GetCustomDomainsCounts. +func (mr *MockStoreMockRecorder) GetCustomDomainsCounts(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCustomDomainsCounts", reflect.TypeOf((*MockStore)(nil).GetCustomDomainsCounts), ctx) +} + +// GetDNSRecordByID mocks base method. +func (m *MockStore) GetDNSRecordByID(ctx context.Context, lockStrength LockingStrength, accountID, zoneID, recordID string) (*records.Record, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetDNSRecordByID", ctx, lockStrength, accountID, zoneID, recordID) + ret0, _ := ret[0].(*records.Record) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetDNSRecordByID indicates an expected call of GetDNSRecordByID. +func (mr *MockStoreMockRecorder) GetDNSRecordByID(ctx, lockStrength, accountID, zoneID, recordID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDNSRecordByID", reflect.TypeOf((*MockStore)(nil).GetDNSRecordByID), ctx, lockStrength, accountID, zoneID, recordID) +} + +// GetEmbeddedProxyPeerIDsByCluster mocks base method. +func (m *MockStore) GetEmbeddedProxyPeerIDsByCluster(ctx context.Context, accountID string) (map[string][]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEmbeddedProxyPeerIDsByCluster", ctx, accountID) + ret0, _ := ret[0].(map[string][]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetEmbeddedProxyPeerIDsByCluster indicates an expected call of GetEmbeddedProxyPeerIDsByCluster. +func (mr *MockStoreMockRecorder) GetEmbeddedProxyPeerIDsByCluster(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedProxyPeerIDsByCluster", reflect.TypeOf((*MockStore)(nil).GetEmbeddedProxyPeerIDsByCluster), ctx, accountID) +} + +// GetExpiredEphemeralServices mocks base method. +func (m *MockStore) GetExpiredEphemeralServices(ctx context.Context, ttl time.Duration, limit int) ([]*service.Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetExpiredEphemeralServices", ctx, ttl, limit) + ret0, _ := ret[0].([]*service.Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetExpiredEphemeralServices indicates an expected call of GetExpiredEphemeralServices. +func (mr *MockStoreMockRecorder) GetExpiredEphemeralServices(ctx, ttl, limit interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExpiredEphemeralServices", reflect.TypeOf((*MockStore)(nil).GetExpiredEphemeralServices), ctx, ttl, limit) +} + +// GetGroupByID mocks base method. +func (m *MockStore) GetGroupByID(ctx context.Context, lockStrength LockingStrength, accountID, groupID string) (*types3.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroupByID", ctx, lockStrength, accountID, groupID) + ret0, _ := ret[0].(*types3.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGroupByID indicates an expected call of GetGroupByID. +func (mr *MockStoreMockRecorder) GetGroupByID(ctx, lockStrength, accountID, groupID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupByID", reflect.TypeOf((*MockStore)(nil).GetGroupByID), ctx, lockStrength, accountID, groupID) +} + +// GetGroupByName mocks base method. +func (m *MockStore) GetGroupByName(ctx context.Context, lockStrength LockingStrength, accountID, groupName string) (*types3.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroupByName", ctx, lockStrength, accountID, groupName) + ret0, _ := ret[0].(*types3.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGroupByName indicates an expected call of GetGroupByName. +func (mr *MockStoreMockRecorder) GetGroupByName(ctx, lockStrength, accountID, groupName interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupByName", reflect.TypeOf((*MockStore)(nil).GetGroupByName), ctx, lockStrength, accountID, groupName) +} + +// GetGroupIDsByPeerIDs mocks base method. +func (m *MockStore) GetGroupIDsByPeerIDs(ctx context.Context, accountID string, peerIDs []string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroupIDsByPeerIDs", ctx, accountID, peerIDs) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGroupIDsByPeerIDs indicates an expected call of GetGroupIDsByPeerIDs. +func (mr *MockStoreMockRecorder) GetGroupIDsByPeerIDs(ctx, accountID, peerIDs interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupIDsByPeerIDs", reflect.TypeOf((*MockStore)(nil).GetGroupIDsByPeerIDs), ctx, accountID, peerIDs) +} + +// GetGroupsByIDs mocks base method. +func (m *MockStore) GetGroupsByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, groupIDs []string) (map[string]*types3.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetGroupsByIDs", ctx, lockStrength, accountID, groupIDs) + ret0, _ := ret[0].(map[string]*types3.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetGroupsByIDs indicates an expected call of GetGroupsByIDs. +func (mr *MockStoreMockRecorder) GetGroupsByIDs(ctx, lockStrength, accountID, groupIDs interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupsByIDs", reflect.TypeOf((*MockStore)(nil).GetGroupsByIDs), ctx, lockStrength, accountID, groupIDs) +} + +// GetInstallationID mocks base method. +func (m *MockStore) GetInstallationID() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetInstallationID") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetInstallationID indicates an expected call of GetInstallationID. +func (mr *MockStoreMockRecorder) GetInstallationID() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInstallationID", reflect.TypeOf((*MockStore)(nil).GetInstallationID)) +} + +// GetNameServerGroupByID mocks base method. +func (m *MockStore) GetNameServerGroupByID(ctx context.Context, lockStrength LockingStrength, nameServerGroupID, accountID string) (*dns.NameServerGroup, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNameServerGroupByID", ctx, lockStrength, nameServerGroupID, accountID) + ret0, _ := ret[0].(*dns.NameServerGroup) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNameServerGroupByID indicates an expected call of GetNameServerGroupByID. +func (mr *MockStoreMockRecorder) GetNameServerGroupByID(ctx, lockStrength, nameServerGroupID, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNameServerGroupByID", reflect.TypeOf((*MockStore)(nil).GetNameServerGroupByID), ctx, lockStrength, nameServerGroupID, accountID) +} + +// GetNetworkByID mocks base method. +func (m *MockStore) GetNetworkByID(ctx context.Context, lockStrength LockingStrength, accountID, networkID string) (*types2.Network, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNetworkByID", ctx, lockStrength, accountID, networkID) + ret0, _ := ret[0].(*types2.Network) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNetworkByID indicates an expected call of GetNetworkByID. +func (mr *MockStoreMockRecorder) GetNetworkByID(ctx, lockStrength, accountID, networkID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkByID", reflect.TypeOf((*MockStore)(nil).GetNetworkByID), ctx, lockStrength, accountID, networkID) +} + +// GetNetworkResourceByID mocks base method. +func (m *MockStore) GetNetworkResourceByID(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) (*types0.NetworkResource, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNetworkResourceByID", ctx, lockStrength, accountID, resourceID) + ret0, _ := ret[0].(*types0.NetworkResource) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNetworkResourceByID indicates an expected call of GetNetworkResourceByID. +func (mr *MockStoreMockRecorder) GetNetworkResourceByID(ctx, lockStrength, accountID, resourceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkResourceByID", reflect.TypeOf((*MockStore)(nil).GetNetworkResourceByID), ctx, lockStrength, accountID, resourceID) +} + +// GetNetworkResourceByName mocks base method. +func (m *MockStore) GetNetworkResourceByName(ctx context.Context, lockStrength LockingStrength, accountID, resourceName string) (*types0.NetworkResource, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNetworkResourceByName", ctx, lockStrength, accountID, resourceName) + ret0, _ := ret[0].(*types0.NetworkResource) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNetworkResourceByName indicates an expected call of GetNetworkResourceByName. +func (mr *MockStoreMockRecorder) GetNetworkResourceByName(ctx, lockStrength, accountID, resourceName interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkResourceByName", reflect.TypeOf((*MockStore)(nil).GetNetworkResourceByName), ctx, lockStrength, accountID, resourceName) +} + +// GetNetworkResourcesByAccountID mocks base method. +func (m *MockStore) GetNetworkResourcesByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types0.NetworkResource, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNetworkResourcesByAccountID", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types0.NetworkResource) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNetworkResourcesByAccountID indicates an expected call of GetNetworkResourcesByAccountID. +func (mr *MockStoreMockRecorder) GetNetworkResourcesByAccountID(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkResourcesByAccountID", reflect.TypeOf((*MockStore)(nil).GetNetworkResourcesByAccountID), ctx, lockStrength, accountID) +} + +// GetNetworkResourcesByNetID mocks base method. +func (m *MockStore) GetNetworkResourcesByNetID(ctx context.Context, lockStrength LockingStrength, accountID, netID string) ([]*types0.NetworkResource, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNetworkResourcesByNetID", ctx, lockStrength, accountID, netID) + ret0, _ := ret[0].([]*types0.NetworkResource) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNetworkResourcesByNetID indicates an expected call of GetNetworkResourcesByNetID. +func (mr *MockStoreMockRecorder) GetNetworkResourcesByNetID(ctx, lockStrength, accountID, netID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkResourcesByNetID", reflect.TypeOf((*MockStore)(nil).GetNetworkResourcesByNetID), ctx, lockStrength, accountID, netID) +} + +// GetNetworkRouterByID mocks base method. +func (m *MockStore) GetNetworkRouterByID(ctx context.Context, lockStrength LockingStrength, accountID, routerID string) (*types1.NetworkRouter, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNetworkRouterByID", ctx, lockStrength, accountID, routerID) + ret0, _ := ret[0].(*types1.NetworkRouter) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNetworkRouterByID indicates an expected call of GetNetworkRouterByID. +func (mr *MockStoreMockRecorder) GetNetworkRouterByID(ctx, lockStrength, accountID, routerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkRouterByID", reflect.TypeOf((*MockStore)(nil).GetNetworkRouterByID), ctx, lockStrength, accountID, routerID) +} + +// GetNetworkRoutersByAccountID mocks base method. +func (m *MockStore) GetNetworkRoutersByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types1.NetworkRouter, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNetworkRoutersByAccountID", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types1.NetworkRouter) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNetworkRoutersByAccountID indicates an expected call of GetNetworkRoutersByAccountID. +func (mr *MockStoreMockRecorder) GetNetworkRoutersByAccountID(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkRoutersByAccountID", reflect.TypeOf((*MockStore)(nil).GetNetworkRoutersByAccountID), ctx, lockStrength, accountID) +} + +// GetNetworkRoutersByNetID mocks base method. +func (m *MockStore) GetNetworkRoutersByNetID(ctx context.Context, lockStrength LockingStrength, accountID, netID string) ([]*types1.NetworkRouter, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNetworkRoutersByNetID", ctx, lockStrength, accountID, netID) + ret0, _ := ret[0].([]*types1.NetworkRouter) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNetworkRoutersByNetID indicates an expected call of GetNetworkRoutersByNetID. +func (mr *MockStoreMockRecorder) GetNetworkRoutersByNetID(ctx, lockStrength, accountID, netID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkRoutersByNetID", reflect.TypeOf((*MockStore)(nil).GetNetworkRoutersByNetID), ctx, lockStrength, accountID, netID) +} + +// GetPATByHashedToken mocks base method. +func (m *MockStore) GetPATByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types3.PersonalAccessToken, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPATByHashedToken", ctx, lockStrength, hashedToken) + ret0, _ := ret[0].(*types3.PersonalAccessToken) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPATByHashedToken indicates an expected call of GetPATByHashedToken. +func (mr *MockStoreMockRecorder) GetPATByHashedToken(ctx, lockStrength, hashedToken interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPATByHashedToken", reflect.TypeOf((*MockStore)(nil).GetPATByHashedToken), ctx, lockStrength, hashedToken) +} + +// GetPATByID mocks base method. +func (m *MockStore) GetPATByID(ctx context.Context, lockStrength LockingStrength, userID, patID string) (*types3.PersonalAccessToken, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPATByID", ctx, lockStrength, userID, patID) + ret0, _ := ret[0].(*types3.PersonalAccessToken) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPATByID indicates an expected call of GetPATByID. +func (mr *MockStoreMockRecorder) GetPATByID(ctx, lockStrength, userID, patID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPATByID", reflect.TypeOf((*MockStore)(nil).GetPATByID), ctx, lockStrength, userID, patID) +} + +// GetPeerByID mocks base method. +func (m *MockStore) GetPeerByID(ctx context.Context, lockStrength LockingStrength, accountID, peerID string) (*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerByID", ctx, lockStrength, accountID, peerID) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerByID indicates an expected call of GetPeerByID. +func (mr *MockStoreMockRecorder) GetPeerByID(ctx, lockStrength, accountID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerByID", reflect.TypeOf((*MockStore)(nil).GetPeerByID), ctx, lockStrength, accountID, peerID) +} + +// GetPeerByIP mocks base method. +func (m *MockStore) GetPeerByIP(ctx context.Context, lockStrength LockingStrength, accountID string, ip net.IP) (*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerByIP", ctx, lockStrength, accountID, ip) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerByIP indicates an expected call of GetPeerByIP. +func (mr *MockStoreMockRecorder) GetPeerByIP(ctx, lockStrength, accountID, ip interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerByIP", reflect.TypeOf((*MockStore)(nil).GetPeerByIP), ctx, lockStrength, accountID, ip) +} + +// GetPeerByPeerPubKey mocks base method. +func (m *MockStore) GetPeerByPeerPubKey(ctx context.Context, lockStrength LockingStrength, peerKey string) (*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerByPeerPubKey", ctx, lockStrength, peerKey) + ret0, _ := ret[0].(*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerByPeerPubKey indicates an expected call of GetPeerByPeerPubKey. +func (mr *MockStoreMockRecorder) GetPeerByPeerPubKey(ctx, lockStrength, peerKey interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerByPeerPubKey", reflect.TypeOf((*MockStore)(nil).GetPeerByPeerPubKey), ctx, lockStrength, peerKey) +} + +// GetPeerGroupIDs mocks base method. +func (m *MockStore) GetPeerGroupIDs(ctx context.Context, lockStrength LockingStrength, accountId, peerId string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerGroupIDs", ctx, lockStrength, accountId, peerId) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerGroupIDs indicates an expected call of GetPeerGroupIDs. +func (mr *MockStoreMockRecorder) GetPeerGroupIDs(ctx, lockStrength, accountId, peerId interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerGroupIDs", reflect.TypeOf((*MockStore)(nil).GetPeerGroupIDs), ctx, lockStrength, accountId, peerId) +} + +// GetPeerGroups mocks base method. +func (m *MockStore) GetPeerGroups(ctx context.Context, lockStrength LockingStrength, accountId, peerId string) ([]*types3.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerGroups", ctx, lockStrength, accountId, peerId) + ret0, _ := ret[0].([]*types3.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerGroups indicates an expected call of GetPeerGroups. +func (mr *MockStoreMockRecorder) GetPeerGroups(ctx, lockStrength, accountId, peerId interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerGroups", reflect.TypeOf((*MockStore)(nil).GetPeerGroups), ctx, lockStrength, accountId, peerId) +} + +// GetPeerIDByKey mocks base method. +func (m *MockStore) GetPeerIDByKey(ctx context.Context, lockStrength LockingStrength, key string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerIDByKey", ctx, lockStrength, key) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerIDByKey indicates an expected call of GetPeerIDByKey. +func (mr *MockStoreMockRecorder) GetPeerIDByKey(ctx, lockStrength, key interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerIDByKey", reflect.TypeOf((*MockStore)(nil).GetPeerIDByKey), ctx, lockStrength, key) +} + +// GetPeerIDsByGroups mocks base method. +func (m *MockStore) GetPeerIDsByGroups(ctx context.Context, accountID string, groupIDs []string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerIDsByGroups", ctx, accountID, groupIDs) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerIDsByGroups indicates an expected call of GetPeerIDsByGroups. +func (mr *MockStoreMockRecorder) GetPeerIDsByGroups(ctx, accountID, groupIDs interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerIDsByGroups", reflect.TypeOf((*MockStore)(nil).GetPeerIDsByGroups), ctx, accountID, groupIDs) +} + +// GetPeerIdByLabel mocks base method. +func (m *MockStore) GetPeerIdByLabel(ctx context.Context, lockStrength LockingStrength, accountID, hostname string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerIdByLabel", ctx, lockStrength, accountID, hostname) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerIdByLabel indicates an expected call of GetPeerIdByLabel. +func (mr *MockStoreMockRecorder) GetPeerIdByLabel(ctx, lockStrength, accountID, hostname interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerIdByLabel", reflect.TypeOf((*MockStore)(nil).GetPeerIdByLabel), ctx, lockStrength, accountID, hostname) +} + +// GetPeerJobByID mocks base method. +func (m *MockStore) GetPeerJobByID(ctx context.Context, accountID, jobID string) (*types3.Job, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerJobByID", ctx, accountID, jobID) + ret0, _ := ret[0].(*types3.Job) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerJobByID indicates an expected call of GetPeerJobByID. +func (mr *MockStoreMockRecorder) GetPeerJobByID(ctx, accountID, jobID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerJobByID", reflect.TypeOf((*MockStore)(nil).GetPeerJobByID), ctx, accountID, jobID) +} + +// GetPeerJobs mocks base method. +func (m *MockStore) GetPeerJobs(ctx context.Context, accountID, peerID string) ([]*types3.Job, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerJobs", ctx, accountID, peerID) + ret0, _ := ret[0].([]*types3.Job) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerJobs indicates an expected call of GetPeerJobs. +func (mr *MockStoreMockRecorder) GetPeerJobs(ctx, accountID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerJobs", reflect.TypeOf((*MockStore)(nil).GetPeerJobs), ctx, accountID, peerID) +} + +// GetPeerLabelsInAccount mocks base method. +func (m *MockStore) GetPeerLabelsInAccount(ctx context.Context, lockStrength LockingStrength, accountId, hostname string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeerLabelsInAccount", ctx, lockStrength, accountId, hostname) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeerLabelsInAccount indicates an expected call of GetPeerLabelsInAccount. +func (mr *MockStoreMockRecorder) GetPeerLabelsInAccount(ctx, lockStrength, accountId, hostname interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeerLabelsInAccount", reflect.TypeOf((*MockStore)(nil).GetPeerLabelsInAccount), ctx, lockStrength, accountId, hostname) +} + +// GetPeersByGroupIDs mocks base method. +func (m *MockStore) GetPeersByGroupIDs(ctx context.Context, accountID string, groupIDs []string) ([]*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeersByGroupIDs", ctx, accountID, groupIDs) + ret0, _ := ret[0].([]*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeersByGroupIDs indicates an expected call of GetPeersByGroupIDs. +func (mr *MockStoreMockRecorder) GetPeersByGroupIDs(ctx, accountID, groupIDs interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersByGroupIDs", reflect.TypeOf((*MockStore)(nil).GetPeersByGroupIDs), ctx, accountID, groupIDs) +} + +// GetPeersByIDs mocks base method. +func (m *MockStore) GetPeersByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, peerIDs []string) (map[string]*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPeersByIDs", ctx, lockStrength, accountID, peerIDs) + ret0, _ := ret[0].(map[string]*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPeersByIDs indicates an expected call of GetPeersByIDs. +func (mr *MockStoreMockRecorder) GetPeersByIDs(ctx, lockStrength, accountID, peerIDs interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPeersByIDs", reflect.TypeOf((*MockStore)(nil).GetPeersByIDs), ctx, lockStrength, accountID, peerIDs) +} + +// GetPolicyByID mocks base method. +func (m *MockStore) GetPolicyByID(ctx context.Context, lockStrength LockingStrength, accountID, policyID string) (*types3.Policy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPolicyByID", ctx, lockStrength, accountID, policyID) + ret0, _ := ret[0].(*types3.Policy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPolicyByID indicates an expected call of GetPolicyByID. +func (mr *MockStoreMockRecorder) GetPolicyByID(ctx, lockStrength, accountID, policyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPolicyByID", reflect.TypeOf((*MockStore)(nil).GetPolicyByID), ctx, lockStrength, accountID, policyID) +} + +// GetPolicyRulesByResourceID mocks base method. +func (m *MockStore) GetPolicyRulesByResourceID(ctx context.Context, lockStrength LockingStrength, accountID, peerID string) ([]*types3.PolicyRule, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPolicyRulesByResourceID", ctx, lockStrength, accountID, peerID) + ret0, _ := ret[0].([]*types3.PolicyRule) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPolicyRulesByResourceID indicates an expected call of GetPolicyRulesByResourceID. +func (mr *MockStoreMockRecorder) GetPolicyRulesByResourceID(ctx, lockStrength, accountID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPolicyRulesByResourceID", reflect.TypeOf((*MockStore)(nil).GetPolicyRulesByResourceID), ctx, lockStrength, accountID, peerID) +} + +// GetPostureCheckByChecksDefinition mocks base method. +func (m *MockStore) GetPostureCheckByChecksDefinition(accountID string, checks *posture.ChecksDefinition) (*posture.Checks, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPostureCheckByChecksDefinition", accountID, checks) + ret0, _ := ret[0].(*posture.Checks) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPostureCheckByChecksDefinition indicates an expected call of GetPostureCheckByChecksDefinition. +func (mr *MockStoreMockRecorder) GetPostureCheckByChecksDefinition(accountID, checks interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostureCheckByChecksDefinition", reflect.TypeOf((*MockStore)(nil).GetPostureCheckByChecksDefinition), accountID, checks) +} + +// GetPostureChecksByID mocks base method. +func (m *MockStore) GetPostureChecksByID(ctx context.Context, lockStrength LockingStrength, accountID, postureCheckID string) (*posture.Checks, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPostureChecksByID", ctx, lockStrength, accountID, postureCheckID) + ret0, _ := ret[0].(*posture.Checks) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPostureChecksByID indicates an expected call of GetPostureChecksByID. +func (mr *MockStoreMockRecorder) GetPostureChecksByID(ctx, lockStrength, accountID, postureCheckID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostureChecksByID", reflect.TypeOf((*MockStore)(nil).GetPostureChecksByID), ctx, lockStrength, accountID, postureCheckID) +} + +// GetPostureChecksByIDs mocks base method. +func (m *MockStore) GetPostureChecksByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, postureChecksIDs []string) (map[string]*posture.Checks, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPostureChecksByIDs", ctx, lockStrength, accountID, postureChecksIDs) + ret0, _ := ret[0].(map[string]*posture.Checks) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPostureChecksByIDs indicates an expected call of GetPostureChecksByIDs. +func (mr *MockStoreMockRecorder) GetPostureChecksByIDs(ctx, lockStrength, accountID, postureChecksIDs interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPostureChecksByIDs", reflect.TypeOf((*MockStore)(nil).GetPostureChecksByIDs), ctx, lockStrength, accountID, postureChecksIDs) +} + +// GetProxyAccessTokenByHashedToken mocks base method. +func (m *MockStore) GetProxyAccessTokenByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken types3.HashedProxyToken) (*types3.ProxyAccessToken, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetProxyAccessTokenByHashedToken", ctx, lockStrength, hashedToken) + ret0, _ := ret[0].(*types3.ProxyAccessToken) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetProxyAccessTokenByHashedToken indicates an expected call of GetProxyAccessTokenByHashedToken. +func (mr *MockStoreMockRecorder) GetProxyAccessTokenByHashedToken(ctx, lockStrength, hashedToken interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProxyAccessTokenByHashedToken", reflect.TypeOf((*MockStore)(nil).GetProxyAccessTokenByHashedToken), ctx, lockStrength, hashedToken) +} + +// GetProxyAccessTokenByID mocks base method. +func (m *MockStore) GetProxyAccessTokenByID(ctx context.Context, lockStrength LockingStrength, tokenID string) (*types3.ProxyAccessToken, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetProxyAccessTokenByID", ctx, lockStrength, tokenID) + ret0, _ := ret[0].(*types3.ProxyAccessToken) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetProxyAccessTokenByID indicates an expected call of GetProxyAccessTokenByID. +func (mr *MockStoreMockRecorder) GetProxyAccessTokenByID(ctx, lockStrength, tokenID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProxyAccessTokenByID", reflect.TypeOf((*MockStore)(nil).GetProxyAccessTokenByID), ctx, lockStrength, tokenID) +} + +// GetProxyAccessTokensByAccountID mocks base method. +func (m *MockStore) GetProxyAccessTokensByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types3.ProxyAccessToken, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetProxyAccessTokensByAccountID", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types3.ProxyAccessToken) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetProxyAccessTokensByAccountID indicates an expected call of GetProxyAccessTokensByAccountID. +func (mr *MockStoreMockRecorder) GetProxyAccessTokensByAccountID(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProxyAccessTokensByAccountID", reflect.TypeOf((*MockStore)(nil).GetProxyAccessTokensByAccountID), ctx, lockStrength, accountID) +} + +// GetProxyByAccountID mocks base method. +func (m *MockStore) GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetProxyByAccountID", ctx, accountID) + ret0, _ := ret[0].(*proxy.Proxy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetProxyByAccountID indicates an expected call of GetProxyByAccountID. +func (mr *MockStoreMockRecorder) GetProxyByAccountID(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProxyByAccountID", reflect.TypeOf((*MockStore)(nil).GetProxyByAccountID), ctx, accountID) +} + +// GetProxyClusters mocks base method. +func (m *MockStore) GetProxyClusters(ctx context.Context, accountID string) ([]proxy.Cluster, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetProxyClusters", ctx, accountID) + ret0, _ := ret[0].([]proxy.Cluster) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetProxyClusters indicates an expected call of GetProxyClusters. +func (mr *MockStoreMockRecorder) GetProxyClusters(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProxyClusters", reflect.TypeOf((*MockStore)(nil).GetProxyClusters), ctx, accountID) +} + +// GetProxyMetrics mocks base method. +func (m *MockStore) GetProxyMetrics(ctx context.Context) (ProxyMetrics, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetProxyMetrics", ctx) + ret0, _ := ret[0].(ProxyMetrics) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetProxyMetrics indicates an expected call of GetProxyMetrics. +func (mr *MockStoreMockRecorder) GetProxyMetrics(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProxyMetrics", reflect.TypeOf((*MockStore)(nil).GetProxyMetrics), ctx) +} + +// GetResourceGroups mocks base method. +func (m *MockStore) GetResourceGroups(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) ([]*types3.Group, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetResourceGroups", ctx, lockStrength, accountID, resourceID) + ret0, _ := ret[0].([]*types3.Group) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetResourceGroups indicates an expected call of GetResourceGroups. +func (mr *MockStoreMockRecorder) GetResourceGroups(ctx, lockStrength, accountID, resourceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetResourceGroups", reflect.TypeOf((*MockStore)(nil).GetResourceGroups), ctx, lockStrength, accountID, resourceID) +} + +// GetRouteByID mocks base method. +func (m *MockStore) GetRouteByID(ctx context.Context, lockStrength LockingStrength, accountID, routeID string) (*route.Route, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRouteByID", ctx, lockStrength, accountID, routeID) + ret0, _ := ret[0].(*route.Route) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRouteByID indicates an expected call of GetRouteByID. +func (mr *MockStoreMockRecorder) GetRouteByID(ctx, lockStrength, accountID, routeID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRouteByID", reflect.TypeOf((*MockStore)(nil).GetRouteByID), ctx, lockStrength, accountID, routeID) +} + +// GetRoutingPeerNetworks mocks base method. +func (m *MockStore) GetRoutingPeerNetworks(ctx context.Context, accountID, peerID string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRoutingPeerNetworks", ctx, accountID, peerID) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRoutingPeerNetworks indicates an expected call of GetRoutingPeerNetworks. +func (mr *MockStoreMockRecorder) GetRoutingPeerNetworks(ctx, accountID, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRoutingPeerNetworks", reflect.TypeOf((*MockStore)(nil).GetRoutingPeerNetworks), ctx, accountID, peerID) +} + +// GetServiceByDomain mocks base method. +func (m *MockStore) GetServiceByDomain(ctx context.Context, domain string) (*service.Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetServiceByDomain", ctx, domain) + ret0, _ := ret[0].(*service.Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServiceByDomain indicates an expected call of GetServiceByDomain. +func (mr *MockStoreMockRecorder) GetServiceByDomain(ctx, domain interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceByDomain", reflect.TypeOf((*MockStore)(nil).GetServiceByDomain), ctx, domain) +} + +// GetServiceByID mocks base method. +func (m *MockStore) GetServiceByID(ctx context.Context, lockStrength LockingStrength, accountID, serviceID string) (*service.Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetServiceByID", ctx, lockStrength, accountID, serviceID) + ret0, _ := ret[0].(*service.Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServiceByID indicates an expected call of GetServiceByID. +func (mr *MockStoreMockRecorder) GetServiceByID(ctx, lockStrength, accountID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceByID", reflect.TypeOf((*MockStore)(nil).GetServiceByID), ctx, lockStrength, accountID, serviceID) +} + +// GetServiceTargetByTargetID mocks base method. +func (m *MockStore) GetServiceTargetByTargetID(ctx context.Context, lockStrength LockingStrength, accountID, targetID string) (*service.Target, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetServiceTargetByTargetID", ctx, lockStrength, accountID, targetID) + ret0, _ := ret[0].(*service.Target) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServiceTargetByTargetID indicates an expected call of GetServiceTargetByTargetID. +func (mr *MockStoreMockRecorder) GetServiceTargetByTargetID(ctx, lockStrength, accountID, targetID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceTargetByTargetID", reflect.TypeOf((*MockStore)(nil).GetServiceTargetByTargetID), ctx, lockStrength, accountID, targetID) +} + +// GetServices mocks base method. +func (m *MockStore) GetServices(ctx context.Context, lockStrength LockingStrength) ([]*service.Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetServices", ctx, lockStrength) + ret0, _ := ret[0].([]*service.Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServices indicates an expected call of GetServices. +func (mr *MockStoreMockRecorder) GetServices(ctx, lockStrength interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServices", reflect.TypeOf((*MockStore)(nil).GetServices), ctx, lockStrength) +} + +// GetServicesByCluster mocks base method. +func (m *MockStore) GetServicesByCluster(ctx context.Context, lockStrength LockingStrength, proxyCluster string) ([]*service.Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetServicesByCluster", ctx, lockStrength, proxyCluster) + ret0, _ := ret[0].([]*service.Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServicesByCluster indicates an expected call of GetServicesByCluster. +func (mr *MockStoreMockRecorder) GetServicesByCluster(ctx, lockStrength, proxyCluster interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServicesByCluster", reflect.TypeOf((*MockStore)(nil).GetServicesByCluster), ctx, lockStrength, proxyCluster) +} + +// GetServicesByClusterAndPort mocks base method. +func (m *MockStore) GetServicesByClusterAndPort(ctx context.Context, lockStrength LockingStrength, proxyCluster, mode string, listenPort uint16) ([]*service.Service, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetServicesByClusterAndPort", ctx, lockStrength, proxyCluster, mode, listenPort) + ret0, _ := ret[0].([]*service.Service) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServicesByClusterAndPort indicates an expected call of GetServicesByClusterAndPort. +func (mr *MockStoreMockRecorder) GetServicesByClusterAndPort(ctx, lockStrength, proxyCluster, mode, listenPort interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServicesByClusterAndPort", reflect.TypeOf((*MockStore)(nil).GetServicesByClusterAndPort), ctx, lockStrength, proxyCluster, mode, listenPort) +} + +// GetSetupKeyByID mocks base method. +func (m *MockStore) GetSetupKeyByID(ctx context.Context, lockStrength LockingStrength, accountID, setupKeyID string) (*types3.SetupKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSetupKeyByID", ctx, lockStrength, accountID, setupKeyID) + ret0, _ := ret[0].(*types3.SetupKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSetupKeyByID indicates an expected call of GetSetupKeyByID. +func (mr *MockStoreMockRecorder) GetSetupKeyByID(ctx, lockStrength, accountID, setupKeyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSetupKeyByID", reflect.TypeOf((*MockStore)(nil).GetSetupKeyByID), ctx, lockStrength, accountID, setupKeyID) +} + +// GetSetupKeyBySecret mocks base method. +func (m *MockStore) GetSetupKeyBySecret(ctx context.Context, lockStrength LockingStrength, key string) (*types3.SetupKey, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSetupKeyBySecret", ctx, lockStrength, key) + ret0, _ := ret[0].(*types3.SetupKey) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetSetupKeyBySecret indicates an expected call of GetSetupKeyBySecret. +func (mr *MockStoreMockRecorder) GetSetupKeyBySecret(ctx, lockStrength, key interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSetupKeyBySecret", reflect.TypeOf((*MockStore)(nil).GetSetupKeyBySecret), ctx, lockStrength, key) +} + +// GetStoreEngine mocks base method. +func (m *MockStore) GetStoreEngine() types3.Engine { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetStoreEngine") + ret0, _ := ret[0].(types3.Engine) + return ret0 +} + +// GetStoreEngine indicates an expected call of GetStoreEngine. +func (mr *MockStoreMockRecorder) GetStoreEngine() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStoreEngine", reflect.TypeOf((*MockStore)(nil).GetStoreEngine)) +} + +// GetTakenIPs mocks base method. +func (m *MockStore) GetTakenIPs(ctx context.Context, lockStrength LockingStrength, accountId string) ([]netip.Addr, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTakenIPs", ctx, lockStrength, accountId) + ret0, _ := ret[0].([]netip.Addr) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTakenIPs indicates an expected call of GetTakenIPs. +func (mr *MockStoreMockRecorder) GetTakenIPs(ctx, lockStrength, accountId interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTakenIPs", reflect.TypeOf((*MockStore)(nil).GetTakenIPs), ctx, lockStrength, accountId) +} + +// GetTargetsByServiceID mocks base method. +func (m *MockStore) GetTargetsByServiceID(ctx context.Context, lockStrength LockingStrength, accountID, serviceID string) ([]*service.Target, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTargetsByServiceID", ctx, lockStrength, accountID, serviceID) + ret0, _ := ret[0].([]*service.Target) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTargetsByServiceID indicates an expected call of GetTargetsByServiceID. +func (mr *MockStoreMockRecorder) GetTargetsByServiceID(ctx, lockStrength, accountID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTargetsByServiceID", reflect.TypeOf((*MockStore)(nil).GetTargetsByServiceID), ctx, lockStrength, accountID, serviceID) +} + +// GetTokenIDByHashedToken mocks base method. +func (m *MockStore) GetTokenIDByHashedToken(ctx context.Context, secret string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTokenIDByHashedToken", ctx, secret) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTokenIDByHashedToken indicates an expected call of GetTokenIDByHashedToken. +func (mr *MockStoreMockRecorder) GetTokenIDByHashedToken(ctx, secret interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTokenIDByHashedToken", reflect.TypeOf((*MockStore)(nil).GetTokenIDByHashedToken), ctx, secret) +} + +// GetUserByPATID mocks base method. +func (m *MockStore) GetUserByPATID(ctx context.Context, lockStrength LockingStrength, patID string) (*types3.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserByPATID", ctx, lockStrength, patID) + ret0, _ := ret[0].(*types3.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserByPATID indicates an expected call of GetUserByPATID. +func (mr *MockStoreMockRecorder) GetUserByPATID(ctx, lockStrength, patID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByPATID", reflect.TypeOf((*MockStore)(nil).GetUserByPATID), ctx, lockStrength, patID) +} + +// GetUserByUserID mocks base method. +func (m *MockStore) GetUserByUserID(ctx context.Context, lockStrength LockingStrength, userID string) (*types3.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserByUserID", ctx, lockStrength, userID) + ret0, _ := ret[0].(*types3.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserByUserID indicates an expected call of GetUserByUserID. +func (mr *MockStoreMockRecorder) GetUserByUserID(ctx, lockStrength, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByUserID", reflect.TypeOf((*MockStore)(nil).GetUserByUserID), ctx, lockStrength, userID) +} + +// GetUserIDByPeerKey mocks base method. +func (m *MockStore) GetUserIDByPeerKey(ctx context.Context, lockStrength LockingStrength, peerKey string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserIDByPeerKey", ctx, lockStrength, peerKey) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserIDByPeerKey indicates an expected call of GetUserIDByPeerKey. +func (mr *MockStoreMockRecorder) GetUserIDByPeerKey(ctx, lockStrength, peerKey interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserIDByPeerKey", reflect.TypeOf((*MockStore)(nil).GetUserIDByPeerKey), ctx, lockStrength, peerKey) +} + +// GetUserInviteByEmail mocks base method. +func (m *MockStore) GetUserInviteByEmail(ctx context.Context, lockStrength LockingStrength, accountID, email string) (*types3.UserInviteRecord, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserInviteByEmail", ctx, lockStrength, accountID, email) + ret0, _ := ret[0].(*types3.UserInviteRecord) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserInviteByEmail indicates an expected call of GetUserInviteByEmail. +func (mr *MockStoreMockRecorder) GetUserInviteByEmail(ctx, lockStrength, accountID, email interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserInviteByEmail", reflect.TypeOf((*MockStore)(nil).GetUserInviteByEmail), ctx, lockStrength, accountID, email) +} + +// GetUserInviteByHashedToken mocks base method. +func (m *MockStore) GetUserInviteByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types3.UserInviteRecord, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserInviteByHashedToken", ctx, lockStrength, hashedToken) + ret0, _ := ret[0].(*types3.UserInviteRecord) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserInviteByHashedToken indicates an expected call of GetUserInviteByHashedToken. +func (mr *MockStoreMockRecorder) GetUserInviteByHashedToken(ctx, lockStrength, hashedToken interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserInviteByHashedToken", reflect.TypeOf((*MockStore)(nil).GetUserInviteByHashedToken), ctx, lockStrength, hashedToken) +} + +// GetUserInviteByID mocks base method. +func (m *MockStore) GetUserInviteByID(ctx context.Context, lockStrength LockingStrength, accountID, inviteID string) (*types3.UserInviteRecord, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserInviteByID", ctx, lockStrength, accountID, inviteID) + ret0, _ := ret[0].(*types3.UserInviteRecord) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserInviteByID indicates an expected call of GetUserInviteByID. +func (mr *MockStoreMockRecorder) GetUserInviteByID(ctx, lockStrength, accountID, inviteID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserInviteByID", reflect.TypeOf((*MockStore)(nil).GetUserInviteByID), ctx, lockStrength, accountID, inviteID) +} + +// GetUserPATs mocks base method. +func (m *MockStore) GetUserPATs(ctx context.Context, lockStrength LockingStrength, userID string) ([]*types3.PersonalAccessToken, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserPATs", ctx, lockStrength, userID) + ret0, _ := ret[0].([]*types3.PersonalAccessToken) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserPATs indicates an expected call of GetUserPATs. +func (mr *MockStoreMockRecorder) GetUserPATs(ctx, lockStrength, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserPATs", reflect.TypeOf((*MockStore)(nil).GetUserPATs), ctx, lockStrength, userID) +} + +// GetUserPeers mocks base method. +func (m *MockStore) GetUserPeers(ctx context.Context, lockStrength LockingStrength, accountID, userID string) ([]*peer.Peer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserPeers", ctx, lockStrength, accountID, userID) + ret0, _ := ret[0].([]*peer.Peer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserPeers indicates an expected call of GetUserPeers. +func (mr *MockStoreMockRecorder) GetUserPeers(ctx, lockStrength, accountID, userID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserPeers", reflect.TypeOf((*MockStore)(nil).GetUserPeers), ctx, lockStrength, accountID, userID) +} + +// GetZoneByDomain mocks base method. +func (m *MockStore) GetZoneByDomain(ctx context.Context, accountID, domain string) (*zones.Zone, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetZoneByDomain", ctx, accountID, domain) + ret0, _ := ret[0].(*zones.Zone) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetZoneByDomain indicates an expected call of GetZoneByDomain. +func (mr *MockStoreMockRecorder) GetZoneByDomain(ctx, accountID, domain interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoneByDomain", reflect.TypeOf((*MockStore)(nil).GetZoneByDomain), ctx, accountID, domain) +} + +// GetZoneByID mocks base method. +func (m *MockStore) GetZoneByID(ctx context.Context, lockStrength LockingStrength, accountID, zoneID string) (*zones.Zone, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetZoneByID", ctx, lockStrength, accountID, zoneID) + ret0, _ := ret[0].(*zones.Zone) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetZoneByID indicates an expected call of GetZoneByID. +func (mr *MockStoreMockRecorder) GetZoneByID(ctx, lockStrength, accountID, zoneID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoneByID", reflect.TypeOf((*MockStore)(nil).GetZoneByID), ctx, lockStrength, accountID, zoneID) +} + +// GetZoneDNSRecords mocks base method. +func (m *MockStore) GetZoneDNSRecords(ctx context.Context, lockStrength LockingStrength, accountID, zoneID string) ([]*records.Record, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetZoneDNSRecords", ctx, lockStrength, accountID, zoneID) + ret0, _ := ret[0].([]*records.Record) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetZoneDNSRecords indicates an expected call of GetZoneDNSRecords. +func (mr *MockStoreMockRecorder) GetZoneDNSRecords(ctx, lockStrength, accountID, zoneID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoneDNSRecords", reflect.TypeOf((*MockStore)(nil).GetZoneDNSRecords), ctx, lockStrength, accountID, zoneID) +} + +// GetZoneDNSRecordsByName mocks base method. +func (m *MockStore) GetZoneDNSRecordsByName(ctx context.Context, lockStrength LockingStrength, accountID, zoneID, name string) ([]*records.Record, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetZoneDNSRecordsByName", ctx, lockStrength, accountID, zoneID, name) + ret0, _ := ret[0].([]*records.Record) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetZoneDNSRecordsByName indicates an expected call of GetZoneDNSRecordsByName. +func (mr *MockStoreMockRecorder) GetZoneDNSRecordsByName(ctx, lockStrength, accountID, zoneID, name interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoneDNSRecordsByName", reflect.TypeOf((*MockStore)(nil).GetZoneDNSRecordsByName), ctx, lockStrength, accountID, zoneID, name) +} + +// IncrementAgentNetworkConsumption mocks base method. +func (m *MockStore) IncrementAgentNetworkConsumption(ctx context.Context, accountID string, kind types.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time, tokensIn, tokensOut int64, costUSD float64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IncrementAgentNetworkConsumption", ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD) + ret0, _ := ret[0].(error) + return ret0 +} + +// IncrementAgentNetworkConsumption indicates an expected call of IncrementAgentNetworkConsumption. +func (mr *MockStoreMockRecorder) IncrementAgentNetworkConsumption(ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementAgentNetworkConsumption", reflect.TypeOf((*MockStore)(nil).IncrementAgentNetworkConsumption), ctx, accountID, kind, dimID, windowSeconds, windowStart, tokensIn, tokensOut, costUSD) +} + +// IncrementAgentNetworkConsumptionBatch mocks base method. +func (m *MockStore) IncrementAgentNetworkConsumptionBatch(ctx context.Context, accountID string, keys []types.ConsumptionKey, tokensIn, tokensOut int64, costUSD float64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IncrementAgentNetworkConsumptionBatch", ctx, accountID, keys, tokensIn, tokensOut, costUSD) + ret0, _ := ret[0].(error) + return ret0 +} + +// IncrementAgentNetworkConsumptionBatch indicates an expected call of IncrementAgentNetworkConsumptionBatch. +func (mr *MockStoreMockRecorder) IncrementAgentNetworkConsumptionBatch(ctx, accountID, keys, tokensIn, tokensOut, costUSD interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementAgentNetworkConsumptionBatch", reflect.TypeOf((*MockStore)(nil).IncrementAgentNetworkConsumptionBatch), ctx, accountID, keys, tokensIn, tokensOut, costUSD) +} + +// IncrementNetworkSerial mocks base method. +func (m *MockStore) IncrementNetworkSerial(ctx context.Context, accountId string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IncrementNetworkSerial", ctx, accountId) + ret0, _ := ret[0].(error) + return ret0 +} + +// IncrementNetworkSerial indicates an expected call of IncrementNetworkSerial. +func (mr *MockStoreMockRecorder) IncrementNetworkSerial(ctx, accountId interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementNetworkSerial", reflect.TypeOf((*MockStore)(nil).IncrementNetworkSerial), ctx, accountId) +} + +// IncrementSetupKeyUsage mocks base method. +func (m *MockStore) IncrementSetupKeyUsage(ctx context.Context, setupKeyID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IncrementSetupKeyUsage", ctx, setupKeyID) + ret0, _ := ret[0].(error) + return ret0 +} + +// IncrementSetupKeyUsage indicates an expected call of IncrementSetupKeyUsage. +func (mr *MockStoreMockRecorder) IncrementSetupKeyUsage(ctx, setupKeyID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IncrementSetupKeyUsage", reflect.TypeOf((*MockStore)(nil).IncrementSetupKeyUsage), ctx, setupKeyID) +} + +// IsClusterAddressConflicting mocks base method. +func (m *MockStore) IsClusterAddressConflicting(ctx context.Context, clusterAddress, accountID string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsClusterAddressConflicting", ctx, clusterAddress, accountID) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IsClusterAddressConflicting indicates an expected call of IsClusterAddressConflicting. +func (mr *MockStoreMockRecorder) IsClusterAddressConflicting(ctx, clusterAddress, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsClusterAddressConflicting", reflect.TypeOf((*MockStore)(nil).IsClusterAddressConflicting), ctx, clusterAddress, accountID) +} + +// IsPrimaryAccount mocks base method. +func (m *MockStore) IsPrimaryAccount(ctx context.Context, accountID string) (bool, string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsPrimaryAccount", ctx, accountID) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// IsPrimaryAccount indicates an expected call of IsPrimaryAccount. +func (mr *MockStoreMockRecorder) IsPrimaryAccount(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsPrimaryAccount", reflect.TypeOf((*MockStore)(nil).IsPrimaryAccount), ctx, accountID) +} + +// IsProxyAccessTokenValid mocks base method. +func (m *MockStore) IsProxyAccessTokenValid(ctx context.Context, tokenID string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsProxyAccessTokenValid", ctx, tokenID) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IsProxyAccessTokenValid indicates an expected call of IsProxyAccessTokenValid. +func (mr *MockStoreMockRecorder) IsProxyAccessTokenValid(ctx, tokenID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsProxyAccessTokenValid", reflect.TypeOf((*MockStore)(nil).IsProxyAccessTokenValid), ctx, tokenID) +} + +// ListAgentNetworkConsumption mocks base method. +func (m *MockStore) ListAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.Consumption, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAgentNetworkConsumption", ctx, lockStrength, accountID) + ret0, _ := ret[0].([]*types.Consumption) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAgentNetworkConsumption indicates an expected call of ListAgentNetworkConsumption. +func (mr *MockStoreMockRecorder) ListAgentNetworkConsumption(ctx, lockStrength, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAgentNetworkConsumption", reflect.TypeOf((*MockStore)(nil).ListAgentNetworkConsumption), ctx, lockStrength, accountID) +} + +// ListCustomDomains mocks base method. +func (m *MockStore) ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListCustomDomains", ctx, accountID) + ret0, _ := ret[0].([]*domain.Domain) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListCustomDomains indicates an expected call of ListCustomDomains. +func (mr *MockStoreMockRecorder) ListCustomDomains(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCustomDomains", reflect.TypeOf((*MockStore)(nil).ListCustomDomains), ctx, accountID) +} + +// ListFreeDomains mocks base method. +func (m *MockStore) ListFreeDomains(ctx context.Context, accountID string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListFreeDomains", ctx, accountID) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListFreeDomains indicates an expected call of ListFreeDomains. +func (mr *MockStoreMockRecorder) ListFreeDomains(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListFreeDomains", reflect.TypeOf((*MockStore)(nil).ListFreeDomains), ctx, accountID) +} + +// MarkAccountPrimary mocks base method. +func (m *MockStore) MarkAccountPrimary(ctx context.Context, accountID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkAccountPrimary", ctx, accountID) + ret0, _ := ret[0].(error) + return ret0 +} + +// MarkAccountPrimary indicates an expected call of MarkAccountPrimary. +func (mr *MockStoreMockRecorder) MarkAccountPrimary(ctx, accountID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkAccountPrimary", reflect.TypeOf((*MockStore)(nil).MarkAccountPrimary), ctx, accountID) +} + +// MarkAllPendingJobsAsFailed mocks base method. +func (m *MockStore) MarkAllPendingJobsAsFailed(ctx context.Context, accountID, peerID, reason string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkAllPendingJobsAsFailed", ctx, accountID, peerID, reason) + ret0, _ := ret[0].(error) + return ret0 +} + +// MarkAllPendingJobsAsFailed indicates an expected call of MarkAllPendingJobsAsFailed. +func (mr *MockStoreMockRecorder) MarkAllPendingJobsAsFailed(ctx, accountID, peerID, reason interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkAllPendingJobsAsFailed", reflect.TypeOf((*MockStore)(nil).MarkAllPendingJobsAsFailed), ctx, accountID, peerID, reason) +} + +// MarkPATUsed mocks base method. +func (m *MockStore) MarkPATUsed(ctx context.Context, patID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkPATUsed", ctx, patID) + ret0, _ := ret[0].(error) + return ret0 +} + +// MarkPATUsed indicates an expected call of MarkPATUsed. +func (mr *MockStoreMockRecorder) MarkPATUsed(ctx, patID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPATUsed", reflect.TypeOf((*MockStore)(nil).MarkPATUsed), ctx, patID) +} + +// MarkPeerConnectedIfNewerSession mocks base method. +func (m *MockStore) MarkPeerConnectedIfNewerSession(ctx context.Context, accountID, peerID string, newSessionStartedAt int64) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkPeerConnectedIfNewerSession", ctx, accountID, peerID, newSessionStartedAt) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// MarkPeerConnectedIfNewerSession indicates an expected call of MarkPeerConnectedIfNewerSession. +func (mr *MockStoreMockRecorder) MarkPeerConnectedIfNewerSession(ctx, accountID, peerID, newSessionStartedAt interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerConnectedIfNewerSession", reflect.TypeOf((*MockStore)(nil).MarkPeerConnectedIfNewerSession), ctx, accountID, peerID, newSessionStartedAt) +} + +// MarkPeerDisconnectedIfSameSession mocks base method. +func (m *MockStore) MarkPeerDisconnectedIfSameSession(ctx context.Context, accountID, peerID string, sessionStartedAt int64) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkPeerDisconnectedIfSameSession", ctx, accountID, peerID, sessionStartedAt) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// MarkPeerDisconnectedIfSameSession indicates an expected call of MarkPeerDisconnectedIfSameSession. +func (mr *MockStoreMockRecorder) MarkPeerDisconnectedIfSameSession(ctx, accountID, peerID, sessionStartedAt interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPeerDisconnectedIfSameSession", reflect.TypeOf((*MockStore)(nil).MarkPeerDisconnectedIfSameSession), ctx, accountID, peerID, sessionStartedAt) +} + +// MarkPendingJobsAsFailed mocks base method. +func (m *MockStore) MarkPendingJobsAsFailed(ctx context.Context, accountID, peerID, jobID, reason string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkPendingJobsAsFailed", ctx, accountID, peerID, jobID, reason) + ret0, _ := ret[0].(error) + return ret0 +} + +// MarkPendingJobsAsFailed indicates an expected call of MarkPendingJobsAsFailed. +func (mr *MockStoreMockRecorder) MarkPendingJobsAsFailed(ctx, accountID, peerID, jobID, reason interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkPendingJobsAsFailed", reflect.TypeOf((*MockStore)(nil).MarkPendingJobsAsFailed), ctx, accountID, peerID, jobID, reason) +} + +// MarkProxyAccessTokenUsed mocks base method. +func (m *MockStore) MarkProxyAccessTokenUsed(ctx context.Context, tokenID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkProxyAccessTokenUsed", ctx, tokenID) + ret0, _ := ret[0].(error) + return ret0 +} + +// MarkProxyAccessTokenUsed indicates an expected call of MarkProxyAccessTokenUsed. +func (mr *MockStoreMockRecorder) MarkProxyAccessTokenUsed(ctx, tokenID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkProxyAccessTokenUsed", reflect.TypeOf((*MockStore)(nil).MarkProxyAccessTokenUsed), ctx, tokenID) +} + +// RemovePeerFromAllGroups mocks base method. +func (m *MockStore) RemovePeerFromAllGroups(ctx context.Context, peerID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemovePeerFromAllGroups", ctx, peerID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemovePeerFromAllGroups indicates an expected call of RemovePeerFromAllGroups. +func (mr *MockStoreMockRecorder) RemovePeerFromAllGroups(ctx, peerID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemovePeerFromAllGroups", reflect.TypeOf((*MockStore)(nil).RemovePeerFromAllGroups), ctx, peerID) +} + +// RemovePeerFromGroup mocks base method. +func (m *MockStore) RemovePeerFromGroup(ctx context.Context, peerID, groupID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemovePeerFromGroup", ctx, peerID, groupID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemovePeerFromGroup indicates an expected call of RemovePeerFromGroup. +func (mr *MockStoreMockRecorder) RemovePeerFromGroup(ctx, peerID, groupID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemovePeerFromGroup", reflect.TypeOf((*MockStore)(nil).RemovePeerFromGroup), ctx, peerID, groupID) +} + +// RemoveResourceFromGroup mocks base method. +func (m *MockStore) RemoveResourceFromGroup(ctx context.Context, accountId, groupID, resourceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoveResourceFromGroup", ctx, accountId, groupID, resourceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RemoveResourceFromGroup indicates an expected call of RemoveResourceFromGroup. +func (mr *MockStoreMockRecorder) RemoveResourceFromGroup(ctx, accountId, groupID, resourceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveResourceFromGroup", reflect.TypeOf((*MockStore)(nil).RemoveResourceFromGroup), ctx, accountId, groupID, resourceID) +} + +// RenewEphemeralService mocks base method. +func (m *MockStore) RenewEphemeralService(ctx context.Context, accountID, peerID, serviceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RenewEphemeralService", ctx, accountID, peerID, serviceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RenewEphemeralService indicates an expected call of RenewEphemeralService. +func (mr *MockStoreMockRecorder) RenewEphemeralService(ctx, accountID, peerID, serviceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RenewEphemeralService", reflect.TypeOf((*MockStore)(nil).RenewEphemeralService), ctx, accountID, peerID, serviceID) +} + +// RevokeProxyAccessToken mocks base method. +func (m *MockStore) RevokeProxyAccessToken(ctx context.Context, tokenID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RevokeProxyAccessToken", ctx, tokenID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RevokeProxyAccessToken indicates an expected call of RevokeProxyAccessToken. +func (mr *MockStoreMockRecorder) RevokeProxyAccessToken(ctx, tokenID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RevokeProxyAccessToken", reflect.TypeOf((*MockStore)(nil).RevokeProxyAccessToken), ctx, tokenID) +} + +// SaveAccount mocks base method. +func (m *MockStore) SaveAccount(ctx context.Context, account *types3.Account) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAccount", ctx, account) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAccount indicates an expected call of SaveAccount. +func (mr *MockStoreMockRecorder) SaveAccount(ctx, account interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAccount", reflect.TypeOf((*MockStore)(nil).SaveAccount), ctx, account) +} + +// SaveAccountOnboarding mocks base method. +func (m *MockStore) SaveAccountOnboarding(ctx context.Context, onboarding *types3.AccountOnboarding) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAccountOnboarding", ctx, onboarding) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAccountOnboarding indicates an expected call of SaveAccountOnboarding. +func (mr *MockStoreMockRecorder) SaveAccountOnboarding(ctx, onboarding interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAccountOnboarding", reflect.TypeOf((*MockStore)(nil).SaveAccountOnboarding), ctx, onboarding) +} + +// SaveAccountSettings mocks base method. +func (m *MockStore) SaveAccountSettings(ctx context.Context, accountID string, settings *types3.Settings) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAccountSettings", ctx, accountID, settings) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAccountSettings indicates an expected call of SaveAccountSettings. +func (mr *MockStoreMockRecorder) SaveAccountSettings(ctx, accountID, settings interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAccountSettings", reflect.TypeOf((*MockStore)(nil).SaveAccountSettings), ctx, accountID, settings) +} + +// SaveAgentNetworkBudgetRule mocks base method. +func (m *MockStore) SaveAgentNetworkBudgetRule(ctx context.Context, rule *types.AccountBudgetRule) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAgentNetworkBudgetRule", ctx, rule) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAgentNetworkBudgetRule indicates an expected call of SaveAgentNetworkBudgetRule. +func (mr *MockStoreMockRecorder) SaveAgentNetworkBudgetRule(ctx, rule interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkBudgetRule", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkBudgetRule), ctx, rule) +} + +// SaveAgentNetworkGuardrail mocks base method. +func (m *MockStore) SaveAgentNetworkGuardrail(ctx context.Context, guardrail *types.Guardrail) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAgentNetworkGuardrail", ctx, guardrail) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAgentNetworkGuardrail indicates an expected call of SaveAgentNetworkGuardrail. +func (mr *MockStoreMockRecorder) SaveAgentNetworkGuardrail(ctx, guardrail interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkGuardrail", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkGuardrail), ctx, guardrail) +} + +// SaveAgentNetworkPolicy mocks base method. +func (m *MockStore) SaveAgentNetworkPolicy(ctx context.Context, policy *types.Policy) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAgentNetworkPolicy", ctx, policy) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAgentNetworkPolicy indicates an expected call of SaveAgentNetworkPolicy. +func (mr *MockStoreMockRecorder) SaveAgentNetworkPolicy(ctx, policy interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkPolicy", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkPolicy), ctx, policy) +} + +// SaveAgentNetworkProvider mocks base method. +func (m *MockStore) SaveAgentNetworkProvider(ctx context.Context, provider *types.Provider) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAgentNetworkProvider", ctx, provider) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAgentNetworkProvider indicates an expected call of SaveAgentNetworkProvider. +func (mr *MockStoreMockRecorder) SaveAgentNetworkProvider(ctx, provider interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkProvider", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkProvider), ctx, provider) +} + +// SaveAgentNetworkSettings mocks base method. +func (m *MockStore) SaveAgentNetworkSettings(ctx context.Context, settings *types.Settings) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveAgentNetworkSettings", ctx, settings) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveAgentNetworkSettings indicates an expected call of SaveAgentNetworkSettings. +func (mr *MockStoreMockRecorder) SaveAgentNetworkSettings(ctx, settings interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAgentNetworkSettings", reflect.TypeOf((*MockStore)(nil).SaveAgentNetworkSettings), ctx, settings) +} + +// SaveDNSSettings mocks base method. +func (m *MockStore) SaveDNSSettings(ctx context.Context, accountID string, settings *types3.DNSSettings) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveDNSSettings", ctx, accountID, settings) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveDNSSettings indicates an expected call of SaveDNSSettings. +func (mr *MockStoreMockRecorder) SaveDNSSettings(ctx, accountID, settings interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveDNSSettings", reflect.TypeOf((*MockStore)(nil).SaveDNSSettings), ctx, accountID, settings) +} + +// SaveInstallationID mocks base method. +func (m *MockStore) SaveInstallationID(ctx context.Context, ID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveInstallationID", ctx, ID) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveInstallationID indicates an expected call of SaveInstallationID. +func (mr *MockStoreMockRecorder) SaveInstallationID(ctx, ID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveInstallationID", reflect.TypeOf((*MockStore)(nil).SaveInstallationID), ctx, ID) +} + +// SaveNameServerGroup mocks base method. +func (m *MockStore) SaveNameServerGroup(ctx context.Context, nameServerGroup *dns.NameServerGroup) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveNameServerGroup", ctx, nameServerGroup) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveNameServerGroup indicates an expected call of SaveNameServerGroup. +func (mr *MockStoreMockRecorder) SaveNameServerGroup(ctx, nameServerGroup interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveNameServerGroup", reflect.TypeOf((*MockStore)(nil).SaveNameServerGroup), ctx, nameServerGroup) +} + +// SaveNetwork mocks base method. +func (m *MockStore) SaveNetwork(ctx context.Context, network *types2.Network) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveNetwork", ctx, network) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveNetwork indicates an expected call of SaveNetwork. +func (mr *MockStoreMockRecorder) SaveNetwork(ctx, network interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveNetwork", reflect.TypeOf((*MockStore)(nil).SaveNetwork), ctx, network) +} + +// SaveNetworkResource mocks base method. +func (m *MockStore) SaveNetworkResource(ctx context.Context, resource *types0.NetworkResource) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveNetworkResource", ctx, resource) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveNetworkResource indicates an expected call of SaveNetworkResource. +func (mr *MockStoreMockRecorder) SaveNetworkResource(ctx, resource interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveNetworkResource", reflect.TypeOf((*MockStore)(nil).SaveNetworkResource), ctx, resource) +} + +// SavePAT mocks base method. +func (m *MockStore) SavePAT(ctx context.Context, pat *types3.PersonalAccessToken) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SavePAT", ctx, pat) + ret0, _ := ret[0].(error) + return ret0 +} + +// SavePAT indicates an expected call of SavePAT. +func (mr *MockStoreMockRecorder) SavePAT(ctx, pat interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePAT", reflect.TypeOf((*MockStore)(nil).SavePAT), ctx, pat) +} + +// SavePeer mocks base method. +func (m *MockStore) SavePeer(ctx context.Context, accountID string, peer *peer.Peer) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SavePeer", ctx, accountID, peer) + ret0, _ := ret[0].(error) + return ret0 +} + +// SavePeer indicates an expected call of SavePeer. +func (mr *MockStoreMockRecorder) SavePeer(ctx, accountID, peer interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePeer", reflect.TypeOf((*MockStore)(nil).SavePeer), ctx, accountID, peer) +} + +// SavePeerStatus mocks base method. +func (m *MockStore) SavePeerStatus(ctx context.Context, accountID, peerID string, status peer.PeerStatus) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SavePeerStatus", ctx, accountID, peerID, status) + ret0, _ := ret[0].(error) + return ret0 +} + +// SavePeerStatus indicates an expected call of SavePeerStatus. +func (mr *MockStoreMockRecorder) SavePeerStatus(ctx, accountID, peerID, status interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePeerStatus", reflect.TypeOf((*MockStore)(nil).SavePeerStatus), ctx, accountID, peerID, status) +} + +// SavePolicy mocks base method. +func (m *MockStore) SavePolicy(ctx context.Context, policy *types3.Policy) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SavePolicy", ctx, policy) + ret0, _ := ret[0].(error) + return ret0 +} + +// SavePolicy indicates an expected call of SavePolicy. +func (mr *MockStoreMockRecorder) SavePolicy(ctx, policy interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePolicy", reflect.TypeOf((*MockStore)(nil).SavePolicy), ctx, policy) +} + +// SavePostureChecks mocks base method. +func (m *MockStore) SavePostureChecks(ctx context.Context, postureCheck *posture.Checks) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SavePostureChecks", ctx, postureCheck) + ret0, _ := ret[0].(error) + return ret0 +} + +// SavePostureChecks indicates an expected call of SavePostureChecks. +func (mr *MockStoreMockRecorder) SavePostureChecks(ctx, postureCheck interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SavePostureChecks", reflect.TypeOf((*MockStore)(nil).SavePostureChecks), ctx, postureCheck) +} + +// SaveProxy mocks base method. +func (m *MockStore) SaveProxy(ctx context.Context, proxy *proxy.Proxy) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveProxy", ctx, proxy) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveProxy indicates an expected call of SaveProxy. +func (mr *MockStoreMockRecorder) SaveProxy(ctx, proxy interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveProxy", reflect.TypeOf((*MockStore)(nil).SaveProxy), ctx, proxy) +} + +// SaveProxyAccessToken mocks base method. +func (m *MockStore) SaveProxyAccessToken(ctx context.Context, token *types3.ProxyAccessToken) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveProxyAccessToken", ctx, token) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveProxyAccessToken indicates an expected call of SaveProxyAccessToken. +func (mr *MockStoreMockRecorder) SaveProxyAccessToken(ctx, token interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveProxyAccessToken", reflect.TypeOf((*MockStore)(nil).SaveProxyAccessToken), ctx, token) +} + +// SaveRoute mocks base method. +func (m *MockStore) SaveRoute(ctx context.Context, route *route.Route) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveRoute", ctx, route) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveRoute indicates an expected call of SaveRoute. +func (mr *MockStoreMockRecorder) SaveRoute(ctx, route interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveRoute", reflect.TypeOf((*MockStore)(nil).SaveRoute), ctx, route) +} + +// SaveSetupKey mocks base method. +func (m *MockStore) SaveSetupKey(ctx context.Context, setupKey *types3.SetupKey) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveSetupKey", ctx, setupKey) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveSetupKey indicates an expected call of SaveSetupKey. +func (mr *MockStoreMockRecorder) SaveSetupKey(ctx, setupKey interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveSetupKey", reflect.TypeOf((*MockStore)(nil).SaveSetupKey), ctx, setupKey) +} + +// SaveUser mocks base method. +func (m *MockStore) SaveUser(ctx context.Context, user *types3.User) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveUser", ctx, user) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveUser indicates an expected call of SaveUser. +func (mr *MockStoreMockRecorder) SaveUser(ctx, user interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveUser", reflect.TypeOf((*MockStore)(nil).SaveUser), ctx, user) +} + +// SaveUserInvite mocks base method. +func (m *MockStore) SaveUserInvite(ctx context.Context, invite *types3.UserInviteRecord) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveUserInvite", ctx, invite) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveUserInvite indicates an expected call of SaveUserInvite. +func (mr *MockStoreMockRecorder) SaveUserInvite(ctx, invite interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveUserInvite", reflect.TypeOf((*MockStore)(nil).SaveUserInvite), ctx, invite) +} + +// SaveUserLastLogin mocks base method. +func (m *MockStore) SaveUserLastLogin(ctx context.Context, accountID, userID string, lastLogin time.Time) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveUserLastLogin", ctx, accountID, userID, lastLogin) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveUserLastLogin indicates an expected call of SaveUserLastLogin. +func (mr *MockStoreMockRecorder) SaveUserLastLogin(ctx, accountID, userID, lastLogin interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveUserLastLogin", reflect.TypeOf((*MockStore)(nil).SaveUserLastLogin), ctx, accountID, userID, lastLogin) +} + +// SaveUsers mocks base method. +func (m *MockStore) SaveUsers(ctx context.Context, users []*types3.User) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveUsers", ctx, users) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveUsers indicates an expected call of SaveUsers. +func (mr *MockStoreMockRecorder) SaveUsers(ctx, users interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveUsers", reflect.TypeOf((*MockStore)(nil).SaveUsers), ctx, users) +} + +// SetFieldEncrypt mocks base method. +func (m *MockStore) SetFieldEncrypt(enc *crypt.FieldEncrypt) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetFieldEncrypt", enc) +} + +// SetFieldEncrypt indicates an expected call of SetFieldEncrypt. +func (mr *MockStoreMockRecorder) SetFieldEncrypt(enc interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetFieldEncrypt", reflect.TypeOf((*MockStore)(nil).SetFieldEncrypt), enc) +} + +// UpdateAccountDomainAttributes mocks base method. +func (m *MockStore) UpdateAccountDomainAttributes(ctx context.Context, accountID, domain, category string, isPrimaryDomain bool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateAccountDomainAttributes", ctx, accountID, domain, category, isPrimaryDomain) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateAccountDomainAttributes indicates an expected call of UpdateAccountDomainAttributes. +func (mr *MockStoreMockRecorder) UpdateAccountDomainAttributes(ctx, accountID, domain, category, isPrimaryDomain interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountDomainAttributes", reflect.TypeOf((*MockStore)(nil).UpdateAccountDomainAttributes), ctx, accountID, domain, category, isPrimaryDomain) +} + +// UpdateAccountNetwork mocks base method. +func (m *MockStore) UpdateAccountNetwork(ctx context.Context, accountID string, ipNet net.IPNet) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateAccountNetwork", ctx, accountID, ipNet) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateAccountNetwork indicates an expected call of UpdateAccountNetwork. +func (mr *MockStoreMockRecorder) UpdateAccountNetwork(ctx, accountID, ipNet interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountNetwork", reflect.TypeOf((*MockStore)(nil).UpdateAccountNetwork), ctx, accountID, ipNet) +} + +// UpdateAccountNetworkV6 mocks base method. +func (m *MockStore) UpdateAccountNetworkV6(ctx context.Context, accountID string, ipNet net.IPNet) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateAccountNetworkV6", ctx, accountID, ipNet) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateAccountNetworkV6 indicates an expected call of UpdateAccountNetworkV6. +func (mr *MockStoreMockRecorder) UpdateAccountNetworkV6(ctx, accountID, ipNet interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAccountNetworkV6", reflect.TypeOf((*MockStore)(nil).UpdateAccountNetworkV6), ctx, accountID, ipNet) +} + +// UpdateCustomDomain mocks base method. +func (m *MockStore) UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateCustomDomain", ctx, accountID, d) + ret0, _ := ret[0].(*domain.Domain) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateCustomDomain indicates an expected call of UpdateCustomDomain. +func (mr *MockStoreMockRecorder) UpdateCustomDomain(ctx, accountID, d interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCustomDomain", reflect.TypeOf((*MockStore)(nil).UpdateCustomDomain), ctx, accountID, d) +} + +// UpdateDNSRecord mocks base method. +func (m *MockStore) UpdateDNSRecord(ctx context.Context, record *records.Record) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateDNSRecord", ctx, record) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateDNSRecord indicates an expected call of UpdateDNSRecord. +func (mr *MockStoreMockRecorder) UpdateDNSRecord(ctx, record interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateDNSRecord", reflect.TypeOf((*MockStore)(nil).UpdateDNSRecord), ctx, record) +} + +// UpdateGroup mocks base method. +func (m *MockStore) UpdateGroup(ctx context.Context, group *types3.Group) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateGroup", ctx, group) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateGroup indicates an expected call of UpdateGroup. +func (mr *MockStoreMockRecorder) UpdateGroup(ctx, group interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateGroup", reflect.TypeOf((*MockStore)(nil).UpdateGroup), ctx, group) +} + +// UpdateGroups mocks base method. +func (m *MockStore) UpdateGroups(ctx context.Context, accountID string, groups []*types3.Group) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateGroups", ctx, accountID, groups) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateGroups indicates an expected call of UpdateGroups. +func (mr *MockStoreMockRecorder) UpdateGroups(ctx, accountID, groups interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateGroups", reflect.TypeOf((*MockStore)(nil).UpdateGroups), ctx, accountID, groups) +} + +// UpdateNetworkRouter mocks base method. +func (m *MockStore) UpdateNetworkRouter(ctx context.Context, router *types1.NetworkRouter) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateNetworkRouter", ctx, router) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateNetworkRouter indicates an expected call of UpdateNetworkRouter. +func (mr *MockStoreMockRecorder) UpdateNetworkRouter(ctx, router interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateNetworkRouter", reflect.TypeOf((*MockStore)(nil).UpdateNetworkRouter), ctx, router) +} + +// UpdateProxyHeartbeat mocks base method. +func (m *MockStore) UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateProxyHeartbeat", ctx, p) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateProxyHeartbeat indicates an expected call of UpdateProxyHeartbeat. +func (mr *MockStoreMockRecorder) UpdateProxyHeartbeat(ctx, p interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateProxyHeartbeat", reflect.TypeOf((*MockStore)(nil).UpdateProxyHeartbeat), ctx, p) +} + +// UpdateService mocks base method. +func (m *MockStore) UpdateService(ctx context.Context, service *service.Service) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateService", ctx, service) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateService indicates an expected call of UpdateService. +func (mr *MockStoreMockRecorder) UpdateService(ctx, service interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateService", reflect.TypeOf((*MockStore)(nil).UpdateService), ctx, service) +} + +// UpdateZone mocks base method. +func (m *MockStore) UpdateZone(ctx context.Context, zone *zones.Zone) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateZone", ctx, zone) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateZone indicates an expected call of UpdateZone. +func (mr *MockStoreMockRecorder) UpdateZone(ctx, zone interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateZone", reflect.TypeOf((*MockStore)(nil).UpdateZone), ctx, zone) +} diff --git a/management/server/telemetry/account_aggregator.go b/management/server/telemetry/account_aggregator.go new file mode 100644 index 000000000..cd0863ed6 --- /dev/null +++ b/management/server/telemetry/account_aggregator.go @@ -0,0 +1,185 @@ +package telemetry + +import ( + "context" + "math" + "sync" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" +) + +// AccountDurationAggregator uses OpenTelemetry histograms per account to calculate P95 +// without publishing individual account labels +type AccountDurationAggregator struct { + mu sync.RWMutex + accounts map[string]*accountHistogram + meterProvider *sdkmetric.MeterProvider + manualReader *sdkmetric.ManualReader + + FlushInterval time.Duration + MaxAge time.Duration + ctx context.Context +} + +type accountHistogram struct { + histogram metric.Int64Histogram + lastUpdate time.Time +} + +// NewAccountDurationAggregator creates aggregator using OTel histograms +func NewAccountDurationAggregator(ctx context.Context, flushInterval, maxAge time.Duration) *AccountDurationAggregator { + manualReader := sdkmetric.NewManualReader( + sdkmetric.WithTemporalitySelector(func(kind sdkmetric.InstrumentKind) metricdata.Temporality { + return metricdata.DeltaTemporality + }), + ) + + meterProvider := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(manualReader), + ) + + return &AccountDurationAggregator{ + accounts: make(map[string]*accountHistogram), + meterProvider: meterProvider, + manualReader: manualReader, + FlushInterval: flushInterval, + MaxAge: maxAge, + ctx: ctx, + } +} + +// Record adds a duration for an account using OTel histogram +func (a *AccountDurationAggregator) Record(accountID string, duration time.Duration) { + a.mu.Lock() + defer a.mu.Unlock() + + accHist, exists := a.accounts[accountID] + if !exists { + meter := a.meterProvider.Meter("account-aggregator") + histogram, err := meter.Int64Histogram( + "sync_duration_per_account", + metric.WithUnit("milliseconds"), + ) + if err != nil { + return + } + + accHist = &accountHistogram{ + histogram: histogram, + } + a.accounts[accountID] = accHist + } + + accHist.histogram.Record(a.ctx, duration.Milliseconds(), + metric.WithAttributes(attribute.String("account_id", accountID))) + accHist.lastUpdate = time.Now() +} + +// FlushAndGetP95s extracts P95 from each account's histogram +func (a *AccountDurationAggregator) FlushAndGetP95s() []int64 { + a.mu.Lock() + defer a.mu.Unlock() + + var rm metricdata.ResourceMetrics + err := a.manualReader.Collect(a.ctx, &rm) + if err != nil { + return nil + } + + now := time.Now() + p95s := make([]int64, 0, len(a.accounts)) + + for _, scopeMetrics := range rm.ScopeMetrics { + for _, metric := range scopeMetrics.Metrics { + histogramData, ok := metric.Data.(metricdata.Histogram[int64]) + if !ok { + continue + } + + for _, dataPoint := range histogramData.DataPoints { + a.processDataPoint(dataPoint, now, &p95s) + } + } + } + + a.cleanupStaleAccounts(now) + + return p95s +} + +// processDataPoint extracts P95 from a single histogram data point +func (a *AccountDurationAggregator) processDataPoint(dataPoint metricdata.HistogramDataPoint[int64], now time.Time, p95s *[]int64) { + accountID := extractAccountID(dataPoint) + if accountID == "" { + return + } + + if p95 := calculateP95FromHistogram(dataPoint); p95 > 0 { + *p95s = append(*p95s, p95) + } +} + +// cleanupStaleAccounts removes accounts that haven't been updated recently +func (a *AccountDurationAggregator) cleanupStaleAccounts(now time.Time) { + for accountID := range a.accounts { + if a.isStaleAccount(accountID, now) { + delete(a.accounts, accountID) + } + } +} + +// extractAccountID retrieves the account_id from histogram data point attributes +func extractAccountID(dp metricdata.HistogramDataPoint[int64]) string { + for _, attr := range dp.Attributes.ToSlice() { + if attr.Key == "account_id" { + return attr.Value.AsString() + } + } + return "" +} + +// isStaleAccount checks if an account hasn't been updated recently +func (a *AccountDurationAggregator) isStaleAccount(accountID string, now time.Time) bool { + accHist, exists := a.accounts[accountID] + if !exists { + return false + } + return now.Sub(accHist.lastUpdate) > a.MaxAge +} + +// calculateP95FromHistogram computes P95 from OTel histogram data +func calculateP95FromHistogram(dp metricdata.HistogramDataPoint[int64]) int64 { + if dp.Count == 0 { + return 0 + } + + targetCount := uint64(math.Ceil(float64(dp.Count) * 0.95)) + if targetCount == 0 { + targetCount = 1 + } + var cumulativeCount uint64 + + for i, bucketCount := range dp.BucketCounts { + cumulativeCount += bucketCount + if cumulativeCount >= targetCount { + if i < len(dp.Bounds) { + return int64(dp.Bounds[i]) + } + if maxVal, defined := dp.Max.Value(); defined { + return maxVal + } + return dp.Sum / int64(dp.Count) + } + } + + return dp.Sum / int64(dp.Count) +} + +// Shutdown cleans up resources +func (a *AccountDurationAggregator) Shutdown() error { + return a.meterProvider.Shutdown(a.ctx) +} diff --git a/management/server/telemetry/account_aggregator_test.go b/management/server/telemetry/account_aggregator_test.go new file mode 100644 index 000000000..63b74b1db --- /dev/null +++ b/management/server/telemetry/account_aggregator_test.go @@ -0,0 +1,219 @@ +package telemetry + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeltaTemporality_P95ReflectsCurrentWindow(t *testing.T) { + // Verify that with delta temporality, each flush window only reflects + // recordings since the last flush — not all-time data. + ctx := context.Background() + agg := NewAccountDurationAggregator(ctx, time.Minute, 5*time.Minute) + defer func(agg *AccountDurationAggregator) { + err := agg.Shutdown() + if err != nil { + t.Errorf("failed to shutdown aggregator: %v", err) + } + }(agg) + + // Window 1: Record 100 slow requests (500ms each) + for range 100 { + agg.Record("account-A", 500*time.Millisecond) + } + + p95sWindow1 := agg.FlushAndGetP95s() + require.Len(t, p95sWindow1, 1, "should have P95 for one account") + firstP95 := p95sWindow1[0] + assert.GreaterOrEqual(t, firstP95, int64(200), + "first window P95 should reflect the 500ms recordings") + + // Window 2: Record 100 FAST requests (10ms each) + for range 100 { + agg.Record("account-A", 10*time.Millisecond) + } + + p95sWindow2 := agg.FlushAndGetP95s() + require.Len(t, p95sWindow2, 1, "should have P95 for one account") + secondP95 := p95sWindow2[0] + + // With delta temporality the P95 should drop significantly because + // the first window's slow recordings are no longer included. + assert.Less(t, secondP95, firstP95, + "second window P95 should be lower than first — delta temporality "+ + "ensures each window only reflects recent recordings") +} + +func TestEqualWeightPerAccount(t *testing.T) { + // Verify that each account contributes exactly one P95 value, + // regardless of how many requests it made. + ctx := context.Background() + agg := NewAccountDurationAggregator(ctx, time.Minute, 5*time.Minute) + defer func(agg *AccountDurationAggregator) { + err := agg.Shutdown() + if err != nil { + t.Errorf("failed to shutdown aggregator: %v", err) + } + }(agg) + + // Account A: 10,000 requests at 500ms (noisy customer) + for range 10000 { + agg.Record("account-A", 500*time.Millisecond) + } + + // Accounts B, C, D: 10 requests each at 50ms (normal customers) + for _, id := range []string{"account-B", "account-C", "account-D"} { + for range 10 { + agg.Record(id, 50*time.Millisecond) + } + } + + p95s := agg.FlushAndGetP95s() + + // Should get exactly 4 P95 values — one per account + assert.Len(t, p95s, 4, "each account should contribute exactly one P95") +} + +func TestStaleAccountEviction(t *testing.T) { + ctx := context.Background() + // Use a very short MaxAge so we can test staleness + agg := NewAccountDurationAggregator(ctx, time.Minute, 50*time.Millisecond) + defer func(agg *AccountDurationAggregator) { + err := agg.Shutdown() + if err != nil { + t.Errorf("failed to shutdown aggregator: %v", err) + } + }(agg) + + agg.Record("account-A", 100*time.Millisecond) + agg.Record("account-B", 200*time.Millisecond) + + // Both accounts should appear + p95s := agg.FlushAndGetP95s() + assert.Len(t, p95s, 2, "both accounts should have P95 values") + + // Wait for account-A to become stale, then only update account-B + time.Sleep(60 * time.Millisecond) + agg.Record("account-B", 200*time.Millisecond) + + p95s = agg.FlushAndGetP95s() + assert.Len(t, p95s, 1, "both accounts should have P95 values") + + // account-A should have been evicted from the accounts map + agg.mu.RLock() + _, accountAExists := agg.accounts["account-A"] + _, accountBExists := agg.accounts["account-B"] + agg.mu.RUnlock() + + assert.False(t, accountAExists, "stale account-A should be evicted from map") + assert.True(t, accountBExists, "active account-B should remain in map") +} + +func TestStaleAccountEviction_DoesNotReappear(t *testing.T) { + // Verify that with delta temporality, an evicted stale account does not + // reappear in subsequent flushes. + ctx := context.Background() + agg := NewAccountDurationAggregator(ctx, time.Minute, 50*time.Millisecond) + defer func(agg *AccountDurationAggregator) { + err := agg.Shutdown() + if err != nil { + t.Errorf("failed to shutdown aggregator: %v", err) + } + }(agg) + + agg.Record("account-stale", 100*time.Millisecond) + + // Wait for it to become stale + time.Sleep(60 * time.Millisecond) + + // First flush: should detect staleness and evict + _ = agg.FlushAndGetP95s() + + agg.mu.RLock() + _, exists := agg.accounts["account-stale"] + agg.mu.RUnlock() + assert.False(t, exists, "account should be evicted after first flush") + + // Second flush: with delta temporality, the stale account should NOT reappear + p95sSecond := agg.FlushAndGetP95s() + assert.Empty(t, p95sSecond, + "evicted account should not reappear in subsequent flushes with delta temporality") +} + +func TestP95Calculation_SingleSample(t *testing.T) { + ctx := context.Background() + agg := NewAccountDurationAggregator(ctx, time.Minute, 5*time.Minute) + defer func(agg *AccountDurationAggregator) { + err := agg.Shutdown() + if err != nil { + t.Errorf("failed to shutdown aggregator: %v", err) + } + }(agg) + + agg.Record("account-A", 150*time.Millisecond) + + p95s := agg.FlushAndGetP95s() + require.Len(t, p95s, 1) + // With a single sample, P95 should be the bucket bound containing 150ms + assert.Greater(t, p95s[0], int64(0), "P95 of a single sample should be positive") +} + +func TestP95Calculation_AllSameValue(t *testing.T) { + ctx := context.Background() + agg := NewAccountDurationAggregator(ctx, time.Minute, 5*time.Minute) + defer func(agg *AccountDurationAggregator) { + err := agg.Shutdown() + if err != nil { + t.Errorf("failed to shutdown aggregator: %v", err) + } + }(agg) + + // All samples are 100ms — P95 should be the bucket bound containing 100ms + for range 100 { + agg.Record("account-A", 100*time.Millisecond) + } + + p95s := agg.FlushAndGetP95s() + require.Len(t, p95s, 1) + assert.Greater(t, p95s[0], int64(0)) +} + +func TestMultipleAccounts_IndependentP95s(t *testing.T) { + ctx := context.Background() + agg := NewAccountDurationAggregator(ctx, time.Minute, 5*time.Minute) + defer func(agg *AccountDurationAggregator) { + err := agg.Shutdown() + if err != nil { + t.Errorf("failed to shutdown aggregator: %v", err) + } + }(agg) + + // Account A: all fast (10ms) + for range 100 { + agg.Record("account-fast", 10*time.Millisecond) + } + + // Account B: all slow (5000ms) + for range 100 { + agg.Record("account-slow", 5000*time.Millisecond) + } + + p95s := agg.FlushAndGetP95s() + require.Len(t, p95s, 2, "should have two P95 values") + + // Find min and max — they should differ significantly + minP95 := p95s[0] + maxP95 := p95s[1] + if minP95 > maxP95 { + minP95, maxP95 = maxP95, minP95 + } + + assert.Less(t, minP95, int64(1000), + "fast account P95 should be well under 1000ms") + assert.Greater(t, maxP95, int64(1000), + "slow account P95 should be well over 1000ms") +} diff --git a/management/server/telemetry/accountmanager_metrics.go b/management/server/telemetry/accountmanager_metrics.go index 3b1e078eb..4b370bde3 100644 --- a/management/server/telemetry/accountmanager_metrics.go +++ b/management/server/telemetry/accountmanager_metrics.go @@ -4,6 +4,7 @@ import ( "context" "time" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" ) @@ -11,9 +12,13 @@ import ( type AccountManagerMetrics struct { ctx context.Context updateAccountPeersDurationMs metric.Float64Histogram + updateAccountPeersCounter metric.Int64Counter + nmapCounter metric.Int64Counter getPeerNetworkMapDurationMs metric.Float64Histogram networkMapObjectCount metric.Int64Histogram peerMetaUpdateCount metric.Int64Counter + peerStatusUpdateCounter metric.Int64Counter + peerStatusUpdateDurationMs metric.Float64Histogram } // NewAccountManagerMetrics creates an instance of AccountManagerMetrics @@ -48,6 +53,20 @@ func NewAccountManagerMetrics(ctx context.Context, meter metric.Meter) (*Account return nil, err } + updateAccountPeersCounter, err := meter.Int64Counter("management.account.update.account.peers.counter", + metric.WithUnit("1"), + metric.WithDescription("Number of account peers updates triggered, labeled by resource and operation")) + if err != nil { + return nil, err + } + + nmapCounter, err := meter.Int64Counter("management.network.map.counter", + metric.WithUnit("1"), + metric.WithDescription("Number of network maps computed, labeled by resource and operation trigger")) + if err != nil { + return nil, err + } + peerMetaUpdateCount, err := meter.Int64Counter("management.account.peer.meta.update.counter", metric.WithUnit("1"), metric.WithDescription("Number of updates with new meta data from the peers")) @@ -55,16 +74,61 @@ func NewAccountManagerMetrics(ctx context.Context, meter metric.Meter) (*Account return nil, err } + // peerStatusUpdateCounter records every attempt to mark a peer as connected or disconnected + peerStatusUpdateCounter, err := meter.Int64Counter("management.account.peer.status.update.counter", + metric.WithUnit("1"), + metric.WithDescription("Number of peer status update attempts, labeled by operation (connect|disconnect) and outcome (applied|stale|error|peer_not_found)")) + if err != nil { + return nil, err + } + + peerStatusUpdateDurationMs, err := meter.Float64Histogram("management.account.peer.status.update.duration.ms", + metric.WithUnit("milliseconds"), + metric.WithExplicitBucketBoundaries( + 1, 5, 15, 25, 50, 100, 250, 500, 1000, 2000, 5000, + ), + metric.WithDescription("Duration of a peer status update (fence UPDATE + post-write side effects), labeled by operation")) + if err != nil { + return nil, err + } + return &AccountManagerMetrics{ ctx: ctx, getPeerNetworkMapDurationMs: getPeerNetworkMapDurationMs, updateAccountPeersDurationMs: updateAccountPeersDurationMs, + updateAccountPeersCounter: updateAccountPeersCounter, networkMapObjectCount: networkMapObjectCount, peerMetaUpdateCount: peerMetaUpdateCount, + peerStatusUpdateCounter: peerStatusUpdateCounter, + peerStatusUpdateDurationMs: peerStatusUpdateDurationMs, + nmapCounter: nmapCounter, }, nil } +// PeerStatusOperation labels the kind of fence-locked peer status write. +type PeerStatusOperation string + +// PeerStatusOutcome labels how a fence-locked peer status write resolved. +type PeerStatusOutcome string + +const ( + PeerStatusConnect PeerStatusOperation = "connect" + PeerStatusDisconnect PeerStatusOperation = "disconnect" + + // PeerStatusApplied — the fence WHERE matched and the UPDATE landed. + PeerStatusApplied PeerStatusOutcome = "applied" + // PeerStatusStale — the fence WHERE rejected the write because a + // newer session has already taken ownership (connect: stored token + // >= incoming; disconnect: stored token != incoming). + PeerStatusStale PeerStatusOutcome = "stale" + // PeerStatusError — the store returned a non-NotFound error. + PeerStatusError PeerStatusOutcome = "error" + // PeerStatusPeerNotFound — the peer lookup failed (the peer was + // deleted between the gRPC sync handshake and the status write). + PeerStatusPeerNotFound PeerStatusOutcome = "peer_not_found" +) + // CountUpdateAccountPeersDuration counts the duration of updating account peers func (metrics *AccountManagerMetrics) CountUpdateAccountPeersDuration(duration time.Duration) { metrics.updateAccountPeersDurationMs.Record(metrics.ctx, float64(duration.Nanoseconds())/1e6) @@ -80,7 +144,47 @@ func (metrics *AccountManagerMetrics) CountNetworkMapObjects(count int64) { metrics.networkMapObjectCount.Record(metrics.ctx, count) } +// CountUpdateAccountPeersTriggered increments the counter for account peers updates with resource and operation labels. +func (metrics *AccountManagerMetrics) CountUpdateAccountPeersTriggered(resource, operation string) { + metrics.updateAccountPeersCounter.Add(metrics.ctx, 1, + metric.WithAttributes( + attribute.String("resource", resource), + attribute.String("operation", operation), + ), + ) +} + +// CountNmapTriggered increments the counter for calculated network maps with resource and operation labels. +func (metrics *AccountManagerMetrics) CountNmapTriggered(resource, operation string) { + metrics.nmapCounter.Add(metrics.ctx, 1, + metric.WithAttributes( + attribute.String("resource", resource), + attribute.String("operation", operation), + ), + ) +} + // CountPeerMetUpdate counts the number of peer meta updates func (metrics *AccountManagerMetrics) CountPeerMetUpdate() { metrics.peerMetaUpdateCount.Add(metrics.ctx, 1) } + +// CountPeerStatusUpdate increments the connect/disconnect counter, +// labeled by operation and outcome. Both labels are bounded enums. +func (metrics *AccountManagerMetrics) CountPeerStatusUpdate(op PeerStatusOperation, outcome PeerStatusOutcome) { + metrics.peerStatusUpdateCounter.Add(metrics.ctx, 1, + metric.WithAttributes( + attribute.String("operation", string(op)), + attribute.String("outcome", string(outcome)), + ), + ) +} + +// RecordPeerStatusUpdateDuration records the wall-clock time spent +// running a peer status update (including post-write side effects), +// labeled by operation. +func (metrics *AccountManagerMetrics) RecordPeerStatusUpdateDuration(op PeerStatusOperation, d time.Duration) { + metrics.peerStatusUpdateDurationMs.Record(metrics.ctx, float64(d.Nanoseconds())/1e6, + metric.WithAttributes(attribute.String("operation", string(op))), + ) +} diff --git a/management/server/telemetry/app_metrics.go b/management/server/telemetry/app_metrics.go index 988f91779..fd9087a96 100644 --- a/management/server/telemetry/app_metrics.go +++ b/management/server/telemetry/app_metrics.go @@ -29,6 +29,7 @@ type MockAppMetrics struct { StoreMetricsFunc func() *StoreMetrics UpdateChannelMetricsFunc func() *UpdateChannelMetrics AddAccountManagerMetricsFunc func() *AccountManagerMetrics + EphemeralPeersMetricsFunc func() *EphemeralPeersMetrics } // GetMeter mocks the GetMeter function of the AppMetrics interface @@ -103,6 +104,14 @@ func (mock *MockAppMetrics) AccountManagerMetrics() *AccountManagerMetrics { return nil } +// EphemeralPeersMetrics mocks the MockAppMetrics function of the EphemeralPeersMetrics interface +func (mock *MockAppMetrics) EphemeralPeersMetrics() *EphemeralPeersMetrics { + if mock.EphemeralPeersMetricsFunc != nil { + return mock.EphemeralPeersMetricsFunc() + } + return nil +} + // AppMetrics is metrics interface type AppMetrics interface { GetMeter() metric2.Meter @@ -114,6 +123,7 @@ type AppMetrics interface { StoreMetrics() *StoreMetrics UpdateChannelMetrics() *UpdateChannelMetrics AccountManagerMetrics() *AccountManagerMetrics + EphemeralPeersMetrics() *EphemeralPeersMetrics } // defaultAppMetrics are core application metrics based on OpenTelemetry https://opentelemetry.io/ @@ -122,12 +132,14 @@ type defaultAppMetrics struct { Meter metric2.Meter listener net.Listener ctx context.Context + externallyManaged bool idpMetrics *IDPMetrics httpMiddleware *HTTPMiddleware grpcMetrics *GRPCMetrics storeMetrics *StoreMetrics updateChannelMetrics *UpdateChannelMetrics accountManagerMetrics *AccountManagerMetrics + ephemeralMetrics *EphemeralPeersMetrics } // IDPMetrics returns metrics for the idp package @@ -160,6 +172,11 @@ func (appMetrics *defaultAppMetrics) AccountManagerMetrics() *AccountManagerMetr return appMetrics.accountManagerMetrics } +// EphemeralPeersMetrics returns metrics for the ephemeral peer cleanup loop +func (appMetrics *defaultAppMetrics) EphemeralPeersMetrics() *EphemeralPeersMetrics { + return appMetrics.ephemeralMetrics +} + // Close stop application metrics HTTP handler and closes listener. func (appMetrics *defaultAppMetrics) Close() error { if appMetrics.listener == nil { @@ -171,6 +188,9 @@ func (appMetrics *defaultAppMetrics) Close() error { // Expose metrics on a given port and endpoint. If endpoint is empty a defaultEndpoint one will be used. // Exposes metrics in the Prometheus format https://prometheus.io/ func (appMetrics *defaultAppMetrics) Expose(ctx context.Context, port int, endpoint string) error { + if appMetrics.externallyManaged { + return nil + } if endpoint == "" { endpoint = defaultEndpoint } @@ -241,6 +261,11 @@ func NewDefaultAppMetrics(ctx context.Context) (AppMetrics, error) { return nil, fmt.Errorf("failed to initialize account manager metrics: %w", err) } + ephemeralMetrics, err := NewEphemeralPeersMetrics(ctx, meter) + if err != nil { + return nil, fmt.Errorf("failed to initialize ephemeral peers metrics: %w", err) + } + return &defaultAppMetrics{ Meter: meter, ctx: ctx, @@ -250,5 +275,58 @@ func NewDefaultAppMetrics(ctx context.Context) (AppMetrics, error) { storeMetrics: storeMetrics, updateChannelMetrics: updateChannelMetrics, accountManagerMetrics: accountManagerMetrics, + ephemeralMetrics: ephemeralMetrics, + }, nil +} + +// NewAppMetricsWithMeter creates AppMetrics using an externally provided meter. +// The caller is responsible for exposing metrics via HTTP. Expose() and Close() are no-ops. +func NewAppMetricsWithMeter(ctx context.Context, meter metric2.Meter) (AppMetrics, error) { + idpMetrics, err := NewIDPMetrics(ctx, meter) + if err != nil { + return nil, fmt.Errorf("failed to initialize IDP metrics: %w", err) + } + + middleware, err := NewMetricsMiddleware(ctx, meter) + if err != nil { + return nil, fmt.Errorf("failed to initialize HTTP middleware metrics: %w", err) + } + + grpcMetrics, err := NewGRPCMetrics(ctx, meter) + if err != nil { + return nil, fmt.Errorf("failed to initialize gRPC metrics: %w", err) + } + + storeMetrics, err := NewStoreMetrics(ctx, meter) + if err != nil { + return nil, fmt.Errorf("failed to initialize store metrics: %w", err) + } + + updateChannelMetrics, err := NewUpdateChannelMetrics(ctx, meter) + if err != nil { + return nil, fmt.Errorf("failed to initialize update channel metrics: %w", err) + } + + accountManagerMetrics, err := NewAccountManagerMetrics(ctx, meter) + if err != nil { + return nil, fmt.Errorf("failed to initialize account manager metrics: %w", err) + } + + ephemeralMetrics, err := NewEphemeralPeersMetrics(ctx, meter) + if err != nil { + return nil, fmt.Errorf("failed to initialize ephemeral peers metrics: %w", err) + } + + return &defaultAppMetrics{ + Meter: meter, + ctx: ctx, + externallyManaged: true, + idpMetrics: idpMetrics, + httpMiddleware: middleware, + grpcMetrics: grpcMetrics, + storeMetrics: storeMetrics, + updateChannelMetrics: updateChannelMetrics, + accountManagerMetrics: accountManagerMetrics, + ephemeralMetrics: ephemeralMetrics, }, nil } diff --git a/management/server/telemetry/ephemeral_metrics.go b/management/server/telemetry/ephemeral_metrics.go new file mode 100644 index 000000000..a7fb432f8 --- /dev/null +++ b/management/server/telemetry/ephemeral_metrics.go @@ -0,0 +1,115 @@ +package telemetry + +import ( + "context" + + "go.opentelemetry.io/otel/metric" +) + +// EphemeralPeersMetrics tracks the ephemeral peer cleanup pipeline: how +// many peers are currently scheduled for deletion, how many tick runs +// the cleaner has performed, how many peers it has removed, and how +// many delete batches failed. +type EphemeralPeersMetrics struct { + ctx context.Context + + pending metric.Int64UpDownCounter + cleanupRuns metric.Int64Counter + peersCleaned metric.Int64Counter + errors metric.Int64Counter +} + +// NewEphemeralPeersMetrics constructs the ephemeral cleanup counters. +func NewEphemeralPeersMetrics(ctx context.Context, meter metric.Meter) (*EphemeralPeersMetrics, error) { + pending, err := meter.Int64UpDownCounter("management.ephemeral.peers.pending", + metric.WithUnit("1"), + metric.WithDescription("Number of ephemeral peers currently waiting to be cleaned up")) + if err != nil { + return nil, err + } + + cleanupRuns, err := meter.Int64Counter("management.ephemeral.cleanup.runs.counter", + metric.WithUnit("1"), + metric.WithDescription("Number of ephemeral cleanup ticks that processed at least one peer")) + if err != nil { + return nil, err + } + + peersCleaned, err := meter.Int64Counter("management.ephemeral.peers.cleaned.counter", + metric.WithUnit("1"), + metric.WithDescription("Total number of ephemeral peers deleted by the cleanup loop")) + if err != nil { + return nil, err + } + + errors, err := meter.Int64Counter("management.ephemeral.cleanup.errors.counter", + metric.WithUnit("1"), + metric.WithDescription("Number of ephemeral cleanup batches (per account) that failed to delete")) + if err != nil { + return nil, err + } + + return &EphemeralPeersMetrics{ + ctx: ctx, + pending: pending, + cleanupRuns: cleanupRuns, + peersCleaned: peersCleaned, + errors: errors, + }, nil +} + +// All methods are nil-receiver safe so callers that haven't wired metrics +// (tests, self-hosted with metrics off) can invoke them unconditionally. + +// IncPending bumps the pending gauge when a peer is added to the cleanup list. +func (m *EphemeralPeersMetrics) IncPending() { + if m == nil { + return + } + m.pending.Add(m.ctx, 1) +} + +// AddPending bumps the pending gauge by n — used at startup when the +// initial set of ephemeral peers is loaded from the store. +func (m *EphemeralPeersMetrics) AddPending(n int64) { + if m == nil || n <= 0 { + return + } + m.pending.Add(m.ctx, n) +} + +// DecPending decreases the pending gauge — used both when a peer reconnects +// before its deadline (removed from the list) and when a cleanup tick +// actually deletes it. +func (m *EphemeralPeersMetrics) DecPending(n int64) { + if m == nil || n <= 0 { + return + } + m.pending.Add(m.ctx, -n) +} + +// CountCleanupRun records one cleanup pass that processed >0 peers. Idle +// ticks (nothing to do) deliberately don't increment so the rate +// reflects useful work. +func (m *EphemeralPeersMetrics) CountCleanupRun() { + if m == nil { + return + } + m.cleanupRuns.Add(m.ctx, 1) +} + +// CountPeersCleaned records the number of peers a single tick deleted. +func (m *EphemeralPeersMetrics) CountPeersCleaned(n int64) { + if m == nil || n <= 0 { + return + } + m.peersCleaned.Add(m.ctx, n) +} + +// CountCleanupError records a failed delete batch. +func (m *EphemeralPeersMetrics) CountCleanupError() { + if m == nil { + return + } + m.errors.Add(m.ctx, 1) +} diff --git a/management/server/telemetry/grpc_metrics.go b/management/server/telemetry/grpc_metrics.go index d4301802f..d3239c57a 100644 --- a/management/server/telemetry/grpc_metrics.go +++ b/management/server/telemetry/grpc_metrics.go @@ -13,19 +13,24 @@ const HighLatencyThreshold = time.Second * 7 // GRPCMetrics are gRPC server metrics type GRPCMetrics struct { - meter metric.Meter - syncRequestsCounter metric.Int64Counter - syncRequestsBlockedCounter metric.Int64Counter - syncRequestHighLatencyCounter metric.Int64Counter - loginRequestsCounter metric.Int64Counter - loginRequestsBlockedCounter metric.Int64Counter - loginRequestHighLatencyCounter metric.Int64Counter - getKeyRequestsCounter metric.Int64Counter - activeStreamsGauge metric.Int64ObservableGauge - syncRequestDuration metric.Int64Histogram - loginRequestDuration metric.Int64Histogram - channelQueueLength metric.Int64Histogram - ctx context.Context + meter metric.Meter + syncRequestsCounter metric.Int64Counter + syncRequestsBlockedCounter metric.Int64Counter + loginRequestsCounter metric.Int64Counter + loginRequestsBlockedCounter metric.Int64Counter + loginRequestHighLatencyCounter metric.Int64Counter + getKeyRequestsCounter metric.Int64Counter + activeStreamsGauge metric.Int64ObservableGauge + syncRequestDuration metric.Int64Histogram + syncRequestDurationP95ByAccount metric.Int64Histogram + loginRequestDuration metric.Int64Histogram + loginRequestDurationP95ByAccount metric.Int64Histogram + channelQueueLength metric.Int64Histogram + ctx context.Context + + // Per-account aggregation + syncDurationAggregator *AccountDurationAggregator + loginDurationAggregator *AccountDurationAggregator } // NewGRPCMetrics creates new GRPCMetrics struct and registers common metrics of the gRPC server @@ -46,14 +51,6 @@ func NewGRPCMetrics(ctx context.Context, meter metric.Meter) (*GRPCMetrics, erro return nil, err } - syncRequestHighLatencyCounter, err := meter.Int64Counter("management.grpc.sync.request.high.latency.counter", - metric.WithUnit("1"), - metric.WithDescription("Number of sync gRPC requests from the peers that took longer than the threshold to establish a connection and receive network map updates (update channel)"), - ) - if err != nil { - return nil, err - } - loginRequestsCounter, err := meter.Int64Counter("management.grpc.login.request.counter", metric.WithUnit("1"), metric.WithDescription("Number of login gRPC requests from the peers to authenticate and receive initial configuration and relay credentials"), @@ -102,6 +99,14 @@ func NewGRPCMetrics(ctx context.Context, meter metric.Meter) (*GRPCMetrics, erro return nil, err } + syncRequestDurationP95ByAccount, err := meter.Int64Histogram("management.grpc.sync.request.duration.p95.by.account.ms", + metric.WithUnit("milliseconds"), + metric.WithDescription("P95 duration of sync requests aggregated per account - each data point represents one account's P95"), + ) + if err != nil { + return nil, err + } + loginRequestDuration, err := meter.Int64Histogram("management.grpc.login.request.duration.ms", metric.WithUnit("milliseconds"), metric.WithDescription("Duration of the login gRPC requests from the peers to authenticate and receive initial configuration and relay credentials"), @@ -110,6 +115,14 @@ func NewGRPCMetrics(ctx context.Context, meter metric.Meter) (*GRPCMetrics, erro return nil, err } + loginRequestDurationP95ByAccount, err := meter.Int64Histogram("management.grpc.login.request.duration.p95.by.account.ms", + metric.WithUnit("milliseconds"), + metric.WithDescription("P95 duration of login requests aggregated per account - each data point represents one account's P95"), + ) + if err != nil { + return nil, err + } + // We use histogram here as we have multiple channel at the same time and we want to see a slice at any given time // Then we should be able to extract min, manx, mean and the percentiles. // TODO(yury): This needs custom bucketing as we are interested in the values from 0 to server.channelBufferSize (100) @@ -122,21 +135,32 @@ func NewGRPCMetrics(ctx context.Context, meter metric.Meter) (*GRPCMetrics, erro return nil, err } - return &GRPCMetrics{ - meter: meter, - syncRequestsCounter: syncRequestsCounter, - syncRequestsBlockedCounter: syncRequestsBlockedCounter, - syncRequestHighLatencyCounter: syncRequestHighLatencyCounter, - loginRequestsCounter: loginRequestsCounter, - loginRequestsBlockedCounter: loginRequestsBlockedCounter, - loginRequestHighLatencyCounter: loginRequestHighLatencyCounter, - getKeyRequestsCounter: getKeyRequestsCounter, - activeStreamsGauge: activeStreamsGauge, - syncRequestDuration: syncRequestDuration, - loginRequestDuration: loginRequestDuration, - channelQueueLength: channelQueue, - ctx: ctx, - }, err + syncDurationAggregator := NewAccountDurationAggregator(ctx, 60*time.Second, 5*time.Minute) + loginDurationAggregator := NewAccountDurationAggregator(ctx, 60*time.Second, 5*time.Minute) + + grpcMetrics := &GRPCMetrics{ + meter: meter, + syncRequestsCounter: syncRequestsCounter, + syncRequestsBlockedCounter: syncRequestsBlockedCounter, + loginRequestsCounter: loginRequestsCounter, + loginRequestsBlockedCounter: loginRequestsBlockedCounter, + loginRequestHighLatencyCounter: loginRequestHighLatencyCounter, + getKeyRequestsCounter: getKeyRequestsCounter, + activeStreamsGauge: activeStreamsGauge, + syncRequestDuration: syncRequestDuration, + syncRequestDurationP95ByAccount: syncRequestDurationP95ByAccount, + loginRequestDuration: loginRequestDuration, + loginRequestDurationP95ByAccount: loginRequestDurationP95ByAccount, + channelQueueLength: channelQueue, + ctx: ctx, + syncDurationAggregator: syncDurationAggregator, + loginDurationAggregator: loginDurationAggregator, + } + + go grpcMetrics.startSyncP95Flusher() + go grpcMetrics.startLoginP95Flusher() + + return grpcMetrics, err } // CountSyncRequest counts the number of gRPC sync requests coming to the gRPC API @@ -167,6 +191,9 @@ func (grpcMetrics *GRPCMetrics) CountLoginRequestBlocked() { // CountLoginRequestDuration counts the duration of the login gRPC requests func (grpcMetrics *GRPCMetrics) CountLoginRequestDuration(duration time.Duration, accountID string) { grpcMetrics.loginRequestDuration.Record(grpcMetrics.ctx, duration.Milliseconds()) + + grpcMetrics.loginDurationAggregator.Record(accountID, duration) + if duration > HighLatencyThreshold { grpcMetrics.loginRequestHighLatencyCounter.Add(grpcMetrics.ctx, 1, metric.WithAttributes(attribute.String(AccountIDLabel, accountID))) } @@ -175,8 +202,43 @@ func (grpcMetrics *GRPCMetrics) CountLoginRequestDuration(duration time.Duration // CountSyncRequestDuration counts the duration of the sync gRPC requests func (grpcMetrics *GRPCMetrics) CountSyncRequestDuration(duration time.Duration, accountID string) { grpcMetrics.syncRequestDuration.Record(grpcMetrics.ctx, duration.Milliseconds()) - if duration > HighLatencyThreshold { - grpcMetrics.syncRequestHighLatencyCounter.Add(grpcMetrics.ctx, 1, metric.WithAttributes(attribute.String(AccountIDLabel, accountID))) + + grpcMetrics.syncDurationAggregator.Record(accountID, duration) +} + +// startSyncP95Flusher periodically flushes per-account sync P95 values to the histogram +func (grpcMetrics *GRPCMetrics) startSyncP95Flusher() { + ticker := time.NewTicker(grpcMetrics.syncDurationAggregator.FlushInterval) + defer ticker.Stop() + + for { + select { + case <-grpcMetrics.ctx.Done(): + return + case <-ticker.C: + p95s := grpcMetrics.syncDurationAggregator.FlushAndGetP95s() + for _, p95 := range p95s { + grpcMetrics.syncRequestDurationP95ByAccount.Record(grpcMetrics.ctx, p95) + } + } + } +} + +// startLoginP95Flusher periodically flushes per-account login P95 values to the histogram +func (grpcMetrics *GRPCMetrics) startLoginP95Flusher() { + ticker := time.NewTicker(grpcMetrics.loginDurationAggregator.FlushInterval) + defer ticker.Stop() + + for { + select { + case <-grpcMetrics.ctx.Done(): + return + case <-ticker.C: + p95s := grpcMetrics.loginDurationAggregator.FlushAndGetP95s() + for _, p95 := range p95s { + grpcMetrics.loginRequestDurationP95ByAccount.Record(grpcMetrics.ctx, p95) + } + } } } diff --git a/management/server/telemetry/http_api_metrics.go b/management/server/telemetry/http_api_metrics.go index ae27466d9..360d36949 100644 --- a/management/server/telemetry/http_api_metrics.go +++ b/management/server/telemetry/http_api_metrics.go @@ -7,8 +7,8 @@ import ( "strings" "time" - "github.com/google/uuid" "github.com/gorilla/mux" + "github.com/rs/xid" log "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" @@ -21,6 +21,8 @@ const ( httpRequestCounterPrefix = "management.http.request.counter" httpResponseCounterPrefix = "management.http.response.counter" httpRequestDurationPrefix = "management.http.request.duration.ms" + + RequestIDHeader = "X-Request-Id" ) // WrappedResponseWriter is a wrapper for http.ResponseWriter that allows the @@ -169,9 +171,13 @@ func (m *HTTPMiddleware) Handler(h http.Handler) http.Handler { //nolint ctx := context.WithValue(r.Context(), hook.ExecutionContextKey, hook.HTTPSource) - reqID := uuid.New().String() + reqID := xid.New().String() //nolint ctx = context.WithValue(ctx, nbContext.RequestIDKey, reqID) + //nolint + ctx = context.WithValue(ctx, nbContext.UserAgentKey, r.UserAgent()) + + rw.Header().Set(RequestIDHeader, reqID) log.WithContext(ctx).Tracef("HTTP request %v: %v %v", reqID, r.Method, r.URL) @@ -183,7 +189,22 @@ func (m *HTTPMiddleware) Handler(h http.Handler) http.Handler { w := WrapResponseWriter(rw) - h.ServeHTTP(w, r.WithContext(ctx)) + handlerDone := make(chan struct{}) + context.AfterFunc(ctx, func() { + select { + case <-handlerDone: + default: + log.Debugf("HTTP request context canceled mid-flight: %v %v (reqID=%s, after %v, cause: %v)", + r.Method, r.URL.Path, reqID, time.Since(reqStart), context.Cause(ctx)) + } + }) + + // Hold on to req so auth's in-place ctx update is visible after ServeHTTP. + req := r.WithContext(ctx) + h.ServeHTTP(w, req) + close(handlerDone) + + ctx = req.Context() if w.Status() > 399 { log.WithContext(ctx).Errorf("HTTP response %v: %v %v status %v", reqID, r.Method, r.URL, w.Status()) diff --git a/management/server/telemetry/updatechannel_metrics.go b/management/server/telemetry/updatechannel_metrics.go index 2b280b352..ade46be59 100644 --- a/management/server/telemetry/updatechannel_metrics.go +++ b/management/server/telemetry/updatechannel_metrics.go @@ -10,19 +10,20 @@ import ( // UpdateChannelMetrics represents all metrics related to the UpdateChannel type UpdateChannelMetrics struct { - createChannelDurationMicro metric.Int64Histogram - closeChannelDurationMicro metric.Int64Histogram - closeChannelsDurationMicro metric.Int64Histogram - closeChannels metric.Int64Histogram - sendUpdateDurationMicro metric.Int64Histogram - getAllConnectedPeersDurationMicro metric.Int64Histogram - getAllConnectedPeers metric.Int64Histogram - hasChannelDurationMicro metric.Int64Histogram - calcPostureChecksDurationMicro metric.Int64Histogram - calcPeerNetworkMapDurationMs metric.Int64Histogram - mergeNetworkMapDurationMicro metric.Int64Histogram - toSyncResponseDurationMicro metric.Int64Histogram - ctx context.Context + createChannelDurationMicro metric.Int64Histogram + closeChannelDurationMicro metric.Int64Histogram + closeChannelsDurationMicro metric.Int64Histogram + closeChannels metric.Int64Histogram + sendUpdateDurationMicro metric.Int64Histogram + getAllConnectedPeersDurationMicro metric.Int64Histogram + getAllConnectedPeers metric.Int64Histogram + hasChannelDurationMicro metric.Int64Histogram + calcPostureChecksDurationMicro metric.Int64Histogram + calcPeerNetworkMapDurationMs metric.Int64Histogram + mergeNetworkMapDurationMicro metric.Int64Histogram + toSyncResponseDurationMicro metric.Int64Histogram + toComponentSyncResponseDurationMicro metric.Int64Histogram + ctx context.Context } // NewUpdateChannelMetrics creates an instance of UpdateChannel @@ -125,20 +126,29 @@ func NewUpdateChannelMetrics(ctx context.Context, meter metric.Meter) (*UpdateCh return nil, err } + toComponentSyncResponseDurationMicro, err := meter.Int64Histogram("management.updatechannel.tocomponentsyncresponse.duration.micro", + metric.WithUnit("microseconds"), + metric.WithDescription("Duration of how long it takes to convert components to component sync response"), + ) + if err != nil { + return nil, err + } + return &UpdateChannelMetrics{ - createChannelDurationMicro: createChannelDurationMicro, - closeChannelDurationMicro: closeChannelDurationMicro, - closeChannelsDurationMicro: closeChannelsDurationMicro, - closeChannels: closeChannels, - sendUpdateDurationMicro: sendUpdateDurationMicro, - getAllConnectedPeersDurationMicro: getAllConnectedPeersDurationMicro, - getAllConnectedPeers: getAllConnectedPeers, - hasChannelDurationMicro: hasChannelDurationMicro, - calcPostureChecksDurationMicro: calcPostureChecksDurationMicro, - calcPeerNetworkMapDurationMs: calcPeerNetworkMapDurationMs, - mergeNetworkMapDurationMicro: mergeNetworkMapDurationMicro, - toSyncResponseDurationMicro: toSyncResponseDurationMicro, - ctx: ctx, + createChannelDurationMicro: createChannelDurationMicro, + closeChannelDurationMicro: closeChannelDurationMicro, + closeChannelsDurationMicro: closeChannelsDurationMicro, + closeChannels: closeChannels, + sendUpdateDurationMicro: sendUpdateDurationMicro, + getAllConnectedPeersDurationMicro: getAllConnectedPeersDurationMicro, + getAllConnectedPeers: getAllConnectedPeers, + hasChannelDurationMicro: hasChannelDurationMicro, + calcPostureChecksDurationMicro: calcPostureChecksDurationMicro, + calcPeerNetworkMapDurationMs: calcPeerNetworkMapDurationMs, + mergeNetworkMapDurationMicro: mergeNetworkMapDurationMicro, + toSyncResponseDurationMicro: toSyncResponseDurationMicro, + toComponentSyncResponseDurationMicro: toComponentSyncResponseDurationMicro, + ctx: ctx, }, nil } @@ -193,3 +203,7 @@ func (metrics *UpdateChannelMetrics) CountMergeNetworkMapDuration(duration time. func (metrics *UpdateChannelMetrics) CountToSyncResponseDuration(duration time.Duration) { metrics.toSyncResponseDurationMicro.Record(metrics.ctx, duration.Microseconds()) } + +func (metrics *UpdateChannelMetrics) CountToComponentSyncResponseDuration(duration time.Duration) { + metrics.toComponentSyncResponseDurationMicro.Record(metrics.ctx, duration.Microseconds()) +} diff --git a/management/server/testdata/auth_callback.sql b/management/server/testdata/auth_callback.sql new file mode 100644 index 000000000..fdd91a6d5 --- /dev/null +++ b/management/server/testdata/auth_callback.sql @@ -0,0 +1,17 @@ +-- Schema definitions (must match GORM auto-migrate order) +CREATE TABLE `accounts` (`id` text,`created_by` text,`created_at` datetime,`domain` text,`domain_category` text,`is_domain_primary_account` numeric,`network_identifier` text,`network_net` text,`network_dns` text,`network_serial` integer,`dns_settings_disabled_management_groups` text,`settings_peer_login_expiration_enabled` numeric,`settings_peer_login_expiration` integer,`settings_regular_users_view_blocked` numeric,`settings_groups_propagation_enabled` numeric,`settings_jwt_groups_enabled` numeric,`settings_jwt_groups_claim_name` text,`settings_jwt_allow_groups` text,`settings_extra_peer_approval_enabled` numeric,`settings_extra_integrated_validator_groups` text,PRIMARY KEY (`id`)); +CREATE TABLE `groups` (`id` text,`account_id` text,`name` text,`issued` text,`peers` text,`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_groups_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); +CREATE TABLE `users` (`id` text,`account_id` text,`role` text,`is_service_user` numeric,`non_deletable` numeric,`service_user_name` text,`auto_groups` text,`blocked` numeric,`last_login` datetime DEFAULT NULL,`created_at` datetime,`issued` text DEFAULT "api",`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_users_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); + +-- Test accounts +INSERT INTO accounts VALUES('testAccountId','','2024-10-02 16:01:38.000000000+00:00','test.com','private',1,'testNetworkIdentifier','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',0,86400000000000,0,0,0,'',NULL,NULL,NULL); +INSERT INTO accounts VALUES('otherAccountId','','2024-10-02 16:01:38.000000000+00:00','other.com','private',1,'otherNetworkIdentifier','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',0,86400000000000,0,0,0,'',NULL,NULL,NULL); + +-- Test groups +INSERT INTO "groups" VALUES('allowedGroupId','testAccountId','Allowed Group','api','[]',0,''); +INSERT INTO "groups" VALUES('restrictedGroupId','testAccountId','Restricted Group','api','[]',0,''); + +-- Test users +INSERT INTO users VALUES('allowedUserId','testAccountId','user',0,0,'','["allowedGroupId"]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('nonGroupUserId','testAccountId','user',0,0,'','[]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); +INSERT INTO users VALUES('otherAccountUserId','otherAccountId','user',0,0,'','["allowedGroupId"]',0,NULL,'2024-10-02 16:01:38.000000000+00:00','api',0,''); diff --git a/management/server/testdata/extended-store.sql b/management/server/testdata/extended-store.sql index 0393d1ade..9bb5dbace 100644 --- a/management/server/testdata/extended-store.sql +++ b/management/server/testdata/extended-store.sql @@ -14,7 +14,7 @@ CREATE TABLE `posture_checks` (`id` text,`name` text,`description` text,`account CREATE TABLE `network_addresses` (`net_ip` text,`mac` text); CREATE INDEX `idx_accounts_domain` ON `accounts`(`domain`); CREATE INDEX `idx_setup_keys_account_id` ON `setup_keys`(`account_id`); -CREATE INDEX `idx_peers_key` ON `peers`(`key`); +CREATE UNIQUE INDEX `idx_peers_key_unique` ON `peers`(`key`); CREATE INDEX `idx_peers_account_id` ON `peers`(`account_id`); CREATE INDEX `idx_users_account_id` ON `users`(`account_id`); CREATE INDEX `idx_personal_access_tokens_user_id` ON `personal_access_tokens`(`user_id`); diff --git a/management/server/testdata/networks.sql b/management/server/testdata/networks.sql index bcb202084..911b3bb27 100644 --- a/management/server/testdata/networks.sql +++ b/management/server/testdata/networks.sql @@ -9,9 +9,13 @@ INSERT INTO peers VALUES('testPeerId','testAccountId','5rvhvriKJZ3S9oxYToVj5TzDM CREATE TABLE `networks` (`id` text,`account_id` text,`name` text,`description` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_networks` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); INSERT INTO networks VALUES('testNetworkId','testAccountId','some-name','some-description'); +INSERT INTO networks VALUES('secondNetworkId','testAccountId','second-name','second-description'); CREATE TABLE `network_routers` (`id` text,`network_id` text,`account_id` text,`peer` text,`peer_groups` text,`masquerade` numeric,`metric` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_network_routers` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); INSERT INTO network_routers VALUES('testRouterId','testNetworkId','testAccountId','','["csquuo4jcko732k1ag00"]',0,9999); +INSERT INTO accounts VALUES('otherAccountId','','2024-10-02 16:01:38.000000000+00:00','other.com','private',1,'otherNetworkIdentifier','{"IP":"100.65.0.0","Mask":"//8AAA=="}','',0,'[]',0,86400000000000,0,0,0,'',NULL,NULL,NULL); +INSERT INTO networks VALUES('otherNetworkId','otherAccountId','other-net','other-description'); +INSERT INTO network_routers VALUES('otherRouterId','otherNetworkId','otherAccountId','otherPeer',NULL,0,1); CREATE TABLE `network_resources` (`id` text,`network_id` text,`account_id` text,`name` text,`description` text,`type` text,`address` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_network_resources` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); INSERT INTO network_resources VALUES('testResourceId','testNetworkId','testAccountId','some-name','some-description','host','3.3.3.3/32'); diff --git a/management/server/testdata/store.sql b/management/server/testdata/store.sql index a21783857..022508323 100644 --- a/management/server/testdata/store.sql +++ b/management/server/testdata/store.sql @@ -18,7 +18,7 @@ CREATE TABLE `network_resources` (`id` text,`network_id` text,`account_id` text, CREATE TABLE `networks` (`id` text,`account_id` text,`name` text,`description` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_networks` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`)); CREATE INDEX `idx_accounts_domain` ON `accounts`(`domain`); CREATE INDEX `idx_setup_keys_account_id` ON `setup_keys`(`account_id`); -CREATE INDEX `idx_peers_key` ON `peers`(`key`); +CREATE UNIQUE INDEX `idx_peers_key_unique` ON `peers`(`key`); CREATE INDEX `idx_peers_account_id` ON `peers`(`account_id`); CREATE INDEX `idx_peers_account_id_ip` ON `peers`(`account_id`,`ip`); CREATE INDEX `idx_users_account_id` ON `users`(`account_id`); @@ -54,4 +54,4 @@ INSERT INTO policy_rules VALUES('cs387mkv2d4bgq41b6n0','cs1tnh0hhcjnqoiuebf0','D INSERT INTO network_routers VALUES('ctc20ji7qv9ck2sebc80','ct286bi7qv930dsrrug0','bf1c8084-ba50-4ce7-9439-34653001fc3b','cs1tnh0hhcjnqoiuebeg',NULL,0,0); INSERT INTO network_resources VALUES ('ctc4nci7qv9061u6ilfg','ct286bi7qv930dsrrug0','bf1c8084-ba50-4ce7-9439-34653001fc3b','Host','192.168.1.1'); INSERT INTO networks VALUES('ct286bi7qv930dsrrug0','bf1c8084-ba50-4ce7-9439-34653001fc3b','Test Network','Test Network'); -INSERT INTO peers VALUES('ct286bi7qv930dsrrug0','bf1c8084-ba50-4ce7-9439-34653001fc3b','','','"192.168.0.0"','','','','','','','','','','','','','','','','','test','test','2023-01-01 00:00:00+00:00',0,0,0,'a23efe53-63fb-11ec-90d6-0242ac120003','',0,0,'2023-01-01 00:00:00+00:00','2023-01-01 00:00:00+00:00',0,'','','',0); +INSERT INTO peers VALUES('ct286bi7qv930dsrrug0','bf1c8084-ba50-4ce7-9439-34653001fc3b','6kjbmVq1hmucVzvBXo5OucY5OYv+jSsB1jUTLq291Do=','','"192.168.0.0"','','','','','','','','','','','','','','','','','test','test','2023-01-01 00:00:00+00:00',0,0,0,'a23efe53-63fb-11ec-90d6-0242ac120003','',0,0,'2023-01-01 00:00:00+00:00','2023-01-01 00:00:00+00:00',0,'','','',0); diff --git a/management/server/testdata/store_policy_migrate.sql b/management/server/testdata/store_policy_migrate.sql index a88411795..395276cb1 100644 --- a/management/server/testdata/store_policy_migrate.sql +++ b/management/server/testdata/store_policy_migrate.sql @@ -14,7 +14,7 @@ CREATE TABLE `posture_checks` (`id` text,`name` text,`description` text,`account CREATE TABLE `network_addresses` (`net_ip` text,`mac` text); CREATE INDEX `idx_accounts_domain` ON `accounts`(`domain`); CREATE INDEX `idx_setup_keys_account_id` ON `setup_keys`(`account_id`); -CREATE INDEX `idx_peers_key` ON `peers`(`key`); +CREATE UNIQUE INDEX `idx_peers_key_unique` ON `peers`(`key`); CREATE INDEX `idx_peers_account_id` ON `peers`(`account_id`); CREATE INDEX `idx_users_account_id` ON `users`(`account_id`); CREATE INDEX `idx_personal_access_tokens_user_id` ON `personal_access_tokens`(`user_id`); diff --git a/management/server/testdata/store_with_expired_peers.sql b/management/server/testdata/store_with_expired_peers.sql index f2ef56a23..189bd1262 100644 --- a/management/server/testdata/store_with_expired_peers.sql +++ b/management/server/testdata/store_with_expired_peers.sql @@ -14,7 +14,7 @@ CREATE TABLE `posture_checks` (`id` text,`name` text,`description` text,`account CREATE TABLE `network_addresses` (`net_ip` text,`mac` text); CREATE INDEX `idx_accounts_domain` ON `accounts`(`domain`); CREATE INDEX `idx_setup_keys_account_id` ON `setup_keys`(`account_id`); -CREATE INDEX `idx_peers_key` ON `peers`(`key`); +CREATE UNIQUE INDEX `idx_peers_key_unique` ON `peers`(`key`); CREATE INDEX `idx_peers_account_id` ON `peers`(`account_id`); CREATE INDEX `idx_users_account_id` ON `users`(`account_id`); CREATE INDEX `idx_personal_access_tokens_user_id` ON `personal_access_tokens`(`user_id`); @@ -30,7 +30,8 @@ INSERT INTO setup_keys VALUES('','bf1c8084-ba50-4ce7-9439-34653001fc3b','A2C8E62 INSERT INTO peers VALUES('cfvprsrlo1hqoo49ohog','bf1c8084-ba50-4ce7-9439-34653001fc3b','5rvhvriKJZ3S9oxYToVj5TzDM9u9y8cxg7htIMWlYAg=','72546A29-6BC8-4311-BCFC-9CDBF33F1A48','"100.64.114.31"','f2a34f6a4731','linux','Linux','11','unknown','Debian GNU/Linux','','0.12.0','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'f2a34f6a4731','f2a34f6a4731','2023-03-02 09:21:02.189035775+01:00',0,0,0,'','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILzUUSYG/LGnV8zarb2SGN+tib/PZ+M7cL4WtTzUrTpk',0,1,0,'2023-03-01 19:48:19.817799698+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); INSERT INTO peers VALUES('cg05lnblo1hkg2j514p0','bf1c8084-ba50-4ce7-9439-34653001fc3b','RlSy2vzoG2HyMBTUImXOiVhCBiiBa5qD5xzMxkiFDW4=','','"100.64.39.54"','expiredhost','linux','Linux','22.04','x86_64','Ubuntu','','development','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'expiredhost','expiredhost','2023-03-02 09:19:57.276717255+01:00',0,1,0,'edafee4e-63fb-11ec-90d6-0242ac120003','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMbK5ZXJsGOOWoBT4OmkPtgdPZe2Q7bDuS/zjn2CZxhK',0,1,0,'2023-03-02 09:14:21.791679181+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); INSERT INTO peers VALUES('cg3161rlo1hs9cq94gdg','bf1c8084-ba50-4ce7-9439-34653001fc3b','mVABSKj28gv+JRsf7e0NEGKgSOGTfU/nPB2cpuG56HU=','','"100.64.117.96"','testhost','linux','Linux','22.04','x86_64','Ubuntu','','development','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'testhost','testhost','2023-03-06 18:21:27.252010027+01:00',0,0,0,'edafee4e-63fb-11ec-90d6-0242ac120003','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINWvvUkFFcrj48CWTkNUb/do/n52i1L5dH4DhGu+4ZuM',0,0,0,'2023-03-07 09:02:47.442857106+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); -INSERT INTO peers VALUES('csrnkiq7qv9d8aitqd50','bf1c8084-ba50-4ce7-9439-34653001fc3b','mVABSKj28gv+JRsf7e0NEGKgSOGTfU/nPB2cpuG56HU=','','"100.64.117.97"','testhost','linux','Linux','22.04','x86_64','Ubuntu','','development','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'testhost','testhost-1','2023-03-06 18:21:27.252010027+01:00',0,0,0,'f4f6d672-63fb-11ec-90d6-0242ac120003','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINWvvUkFFcrj48CWTkNUb/do/n52i1L5dH4DhGu+4ZuM',0,0,1,'2023-03-07 09:02:47.442857106+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); +INSERT INTO peers VALUES('csrnkiq7qv9d8aitqd50','bf1c8084-ba50-4ce7-9439-34653001fc3b','nVABSKj28gv+JRsf7e0NEGKgSOGTfU/nPB2cpuG56HX=','','"100.64.117.97"','testhost','linux','Linux','22.04','x86_64','Ubuntu','','development','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'testhost','testhost-1','2023-03-06 18:21:27.252010027+01:00',0,0,0,'f4f6d672-63fb-11ec-90d6-0242ac120003','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINWvvUkFFcrj48CWTkNUb/do/n52i1L5dH4DhGu+4ZuM',0,0,1,'2023-03-07 09:02:47.442857106+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); +INSERT INTO peers VALUES('notexpired01','bf1c8084-ba50-4ce7-9439-34653001fc3b','oVABSKj28gv+JRsf7e0NEGKgSOGTfU/nPB2cpuG56HY=','','"100.64.117.98"','activehost','linux','Linux','22.04','x86_64','Ubuntu','','development','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'activehost','activehost','2023-03-06 18:21:27.252010027+01:00',0,0,0,'edafee4e-63fb-11ec-90d6-0242ac120003','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINWvvUkFFcrj48CWTkNUb/do/n52i1L5dH4DhGu+4ZuM',0,1,0,'2023-03-07 09:02:47.442857106+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0); INSERT INTO users VALUES('f4f6d672-63fb-11ec-90d6-0242ac120003','bf1c8084-ba50-4ce7-9439-34653001fc3b','user',0,0,'','[]',0,NULL,'2024-10-02 17:00:32.528196+02:00','api',0,''); INSERT INTO users VALUES('edafee4e-63fb-11ec-90d6-0242ac120003','bf1c8084-ba50-4ce7-9439-34653001fc3b','admin',0,0,'','[]',0,NULL,'2024-10-02 17:00:32.528196+02:00','api',0,''); INSERT INTO installations VALUES(1,''); diff --git a/management/server/testdata/storev1.sql b/management/server/testdata/storev1.sql index 8b09ec2be..eb5be31b7 100644 --- a/management/server/testdata/storev1.sql +++ b/management/server/testdata/storev1.sql @@ -14,7 +14,7 @@ CREATE TABLE `posture_checks` (`id` text,`name` text,`description` text,`account CREATE TABLE `network_addresses` (`net_ip` text,`mac` text); CREATE INDEX `idx_accounts_domain` ON `accounts`(`domain`); CREATE INDEX `idx_setup_keys_account_id` ON `setup_keys`(`account_id`); -CREATE INDEX `idx_peers_key` ON `peers`(`key`); +CREATE UNIQUE INDEX `idx_peers_key_unique` ON `peers`(`key`); CREATE INDEX `idx_peers_account_id` ON `peers`(`account_id`); CREATE INDEX `idx_users_account_id` ON `users`(`account_id`); CREATE INDEX `idx_personal_access_tokens_user_id` ON `personal_access_tokens`(`user_id`); diff --git a/management/server/testutil/store.go b/management/server/testutil/store.go index db418c45b..07699e2c3 100644 --- a/management/server/testutil/store.go +++ b/management/server/testutil/store.go @@ -1,5 +1,4 @@ //go:build !ios -// +build !ios package testutil @@ -33,8 +32,8 @@ func CreateMysqlTestContainer() (func(), string, error) { } var err error - mysqlContainer, err = mysql.RunContainer(ctx, - testcontainers.WithImage("mlsmaycon/warmed-mysql:8"), + mysqlContainer, err = mysql.Run(ctx, + "mlsmaycon/warmed-mysql:8", mysql.WithDatabase("testing"), mysql.WithUsername("root"), mysql.WithPassword("testing"), @@ -79,8 +78,8 @@ func CreatePostgresTestContainer() (func(), string, error) { } var err error - pgContainer, err = postgres.RunContainer(ctx, - testcontainers.WithImage("postgres:16-alpine"), + pgContainer, err = postgres.Run(ctx, + "postgres:16-alpine", postgres.WithDatabase("netbird"), postgres.WithUsername("root"), postgres.WithPassword("netbird"), @@ -121,7 +120,7 @@ func noOpCleanup() { func CreateRedisTestContainer() (func(), string, error) { ctx := context.Background() - redisContainer, err := testcontainersredis.RunContainer(ctx, testcontainers.WithImage("redis:7")) + redisContainer, err := testcontainersredis.Run(ctx, "redis:7") if err != nil { return nil, "", err } diff --git a/management/server/testutil/store_ios.go b/management/server/testutil/store_ios.go index c3dd839d3..9e3b5ce4a 100644 --- a/management/server/testutil/store_ios.go +++ b/management/server/testutil/store_ios.go @@ -1,5 +1,4 @@ //go:build ios -// +build ios package testutil diff --git a/management/server/types/account.go b/management/server/types/account.go index 8797e1fa3..588e63a09 100644 --- a/management/server/types/account.go +++ b/management/server/types/account.go @@ -3,12 +3,10 @@ package types import ( "context" "fmt" - "net" "net/netip" "slices" "strconv" "strings" - "sync" "time" "github.com/hashicorp/go-multierror" @@ -16,13 +14,17 @@ import ( "github.com/rs/xid" log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/client/ssh/auth" nbdns "github.com/netbirdio/netbird/dns" + proxydomain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + "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/telemetry" "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/route" "github.com/netbirdio/netbird/shared/management/domain" @@ -30,20 +32,17 @@ import ( ) const ( - defaultTTL = 300 + defaultTTL = 300 + // privateServiceDNSRecordTTL is short so proxy-peer changes propagate quickly to clients. + privateServiceDNSRecordTTL = 5 DefaultPeerLoginExpiration = 24 * time.Hour DefaultPeerInactivityExpiration = 10 * time.Minute PublicCategory = "public" PrivateCategory = "private" UnknownCategory = "unknown" - - // firewallRuleMinPortRangesVer defines the minimum peer version that supports port range rules. - firewallRuleMinPortRangesVer = "0.48.0" ) -type LookupMap map[string]struct{} - // AccountMeta is a struct that contains a stripped down version of the Account object. // It doesn't carry any peers, groups, policies, or routes, etc. Just some metadata (e.g. ID, created by, created at, etc). type AccountMeta struct { @@ -82,6 +81,8 @@ type Account struct { NameServerGroupsG []nbdns.NameServerGroup `json:"-" gorm:"foreignKey:AccountID;references:id"` DNSSettings DNSSettings `gorm:"embedded;embeddedPrefix:dns_settings_"` PostureChecks []*posture.Checks `gorm:"foreignKey:AccountID;references:id"` + Services []*service.Service `gorm:"foreignKey:AccountID;references:id"` + Domains []*proxydomain.Domain `gorm:"foreignKey:AccountID;references:id"` // Settings is a dictionary of Account settings Settings *Settings `gorm:"embedded;embeddedPrefix:settings_"` Networks []*networkTypes.Network `gorm:"foreignKey:AccountID;references:id"` @@ -89,12 +90,7 @@ type Account struct { NetworkResources []*resourceTypes.NetworkResource `gorm:"foreignKey:AccountID;references:id"` Onboarding AccountOnboarding `gorm:"foreignKey:AccountID;references:id;constraint:OnDelete:CASCADE"` - NetworkMapCache *NetworkMapBuilder `gorm:"-"` - nmapInitOnce *sync.Once `gorm:"-"` -} - -func (a *Account) InitOnce() { - a.nmapInitOnce = &sync.Once{} + ReverseProxyFreeDomainNonce string } // this class is used by gorm only @@ -132,109 +128,6 @@ func (o AccountOnboarding) IsEqual(onboarding AccountOnboarding) bool { o.SignupFormPending == onboarding.SignupFormPending } -// GetRoutesToSync returns the enabled routes for the peer ID and the routes -// from the ACL peers that have distribution groups associated with the peer ID. -// Please mind, that the returned route.Route objects will contain Peer.Key instead of Peer.ID. -func (a *Account) GetRoutesToSync(ctx context.Context, peerID string, aclPeers []*nbpeer.Peer) []*route.Route { - routes, peerDisabledRoutes := a.getRoutingPeerRoutes(ctx, peerID) - peerRoutesMembership := make(LookupMap) - for _, r := range append(routes, peerDisabledRoutes...) { - peerRoutesMembership[string(r.GetHAUniqueID())] = struct{}{} - } - - groupListMap := a.GetPeerGroups(peerID) - for _, peer := range aclPeers { - activeRoutes, _ := a.getRoutingPeerRoutes(ctx, peer.ID) - groupFilteredRoutes := a.filterRoutesByGroups(activeRoutes, groupListMap) - filteredRoutes := a.filterRoutesFromPeersOfSameHAGroup(groupFilteredRoutes, peerRoutesMembership) - routes = append(routes, filteredRoutes...) - } - - return routes -} - -// filterRoutesFromPeersOfSameHAGroup filters and returns a list of routes that don't share the same HA route membership -func (a *Account) filterRoutesFromPeersOfSameHAGroup(routes []*route.Route, peerMemberships LookupMap) []*route.Route { - var filteredRoutes []*route.Route - for _, r := range routes { - _, found := peerMemberships[string(r.GetHAUniqueID())] - if !found { - filteredRoutes = append(filteredRoutes, r) - } - } - return filteredRoutes -} - -// filterRoutesByGroups returns a list with routes that have distribution groups in the group's map -func (a *Account) filterRoutesByGroups(routes []*route.Route, groupListMap LookupMap) []*route.Route { - var filteredRoutes []*route.Route - for _, r := range routes { - for _, groupID := range r.Groups { - _, found := groupListMap[groupID] - if found { - filteredRoutes = append(filteredRoutes, r) - break - } - } - } - return filteredRoutes -} - -// getRoutingPeerRoutes returns the enabled and disabled lists of routes that the given routing peer serves -// Please mind, that the returned route.Route objects will contain Peer.Key instead of Peer.ID. -// If the given is not a routing peer, then the lists are empty. -func (a *Account) getRoutingPeerRoutes(ctx context.Context, peerID string) (enabledRoutes []*route.Route, disabledRoutes []*route.Route) { - - peer := a.GetPeer(peerID) - if peer == nil { - log.WithContext(ctx).Errorf("peer %s that doesn't exist under account %s", peerID, a.Id) - return enabledRoutes, disabledRoutes - } - - seenRoute := make(map[route.ID]struct{}) - - takeRoute := func(r *route.Route, id string) { - if _, ok := seenRoute[r.ID]; ok { - return - } - seenRoute[r.ID] = struct{}{} - - if r.Enabled { - r.Peer = peer.Key - enabledRoutes = append(enabledRoutes, r) - return - } - disabledRoutes = append(disabledRoutes, r) - } - - for _, r := range a.Routes { - for _, groupID := range r.PeerGroups { - group := a.GetGroup(groupID) - if group == nil { - log.WithContext(ctx).Errorf("route %s has peers group %s that doesn't exist under account %s", r.ID, groupID, a.Id) - continue - } - for _, id := range group.Peers { - if id != peerID { - continue - } - - newPeerRoute := r.Copy() - newPeerRoute.Peer = id - newPeerRoute.PeerGroups = nil - newPeerRoute.ID = route.ID(string(r.ID) + ":" + id) // we have to provide unique route id when distribute network map - takeRoute(newPeerRoute, id) - break - } - } - if r.Peer == peerID { - takeRoute(r.Copy(), peerID) - } - } - - return enabledRoutes, disabledRoutes -} - // GetRoutesByPrefixOrDomains return list of routes by account and route prefix func (a *Account) GetRoutesByPrefixOrDomains(prefix netip.Prefix, domains domain.List) []*route.Route { var routes []*route.Route @@ -254,95 +147,6 @@ func (a *Account) GetGroup(groupID string) *Group { return a.Groups[groupID] } -// GetPeerNetworkMap returns the networkmap for the given peer ID. -func (a *Account) GetPeerNetworkMap( - ctx context.Context, - peerID string, - peersCustomZone nbdns.CustomZone, - validatedPeersMap map[string]struct{}, - resourcePolicies map[string][]*Policy, - routers map[string]map[string]*routerTypes.NetworkRouter, - metrics *telemetry.AccountManagerMetrics, -) *NetworkMap { - start := time.Now() - peer := a.Peers[peerID] - if peer == nil { - return &NetworkMap{ - Network: a.Network.Copy(), - } - } - - if _, ok := validatedPeersMap[peerID]; !ok { - return &NetworkMap{ - Network: a.Network.Copy(), - } - } - - aclPeers, firewallRules := a.GetPeerConnectionResources(ctx, peer, validatedPeersMap) - // exclude expired peers - var peersToConnect []*nbpeer.Peer - var expiredPeers []*nbpeer.Peer - for _, p := range aclPeers { - expired, _ := p.LoginExpired(a.Settings.PeerLoginExpiration) - if a.Settings.PeerLoginExpirationEnabled && expired { - expiredPeers = append(expiredPeers, p) - continue - } - peersToConnect = append(peersToConnect, p) - } - - routesUpdate := a.GetRoutesToSync(ctx, peerID, peersToConnect) - routesFirewallRules := a.GetPeerRoutesFirewallRules(ctx, peerID, validatedPeersMap) - isRouter, networkResourcesRoutes, sourcePeers := a.GetNetworkResourcesRoutesToSync(ctx, peerID, resourcePolicies, routers) - var networkResourcesFirewallRules []*RouteFirewallRule - if isRouter { - networkResourcesFirewallRules = a.GetPeerNetworkResourceFirewallRules(ctx, peer, validatedPeersMap, networkResourcesRoutes, resourcePolicies) - } - peersToConnectIncludingRouters := a.addNetworksRoutingPeers(networkResourcesRoutes, peer, peersToConnect, expiredPeers, isRouter, sourcePeers) - - dnsManagementStatus := a.getPeerDNSManagementStatus(peerID) - dnsUpdate := nbdns.Config{ - ServiceEnable: dnsManagementStatus, - } - - if dnsManagementStatus { - var zones []nbdns.CustomZone - if peersCustomZone.Domain != "" { - records := filterZoneRecordsForPeers(peer, peersCustomZone, peersToConnectIncludingRouters, expiredPeers) - zones = append(zones, nbdns.CustomZone{ - Domain: peersCustomZone.Domain, - Records: records, - }) - } - dnsUpdate.CustomZones = zones - dnsUpdate.NameServerGroups = getPeerNSGroups(a, peerID) - } - - nm := &NetworkMap{ - Peers: peersToConnectIncludingRouters, - Network: a.Network.Copy(), - Routes: slices.Concat(networkResourcesRoutes, routesUpdate), - DNSConfig: dnsUpdate, - OfflinePeers: expiredPeers, - FirewallRules: firewallRules, - RoutesFirewallRules: slices.Concat(networkResourcesFirewallRules, routesFirewallRules), - } - - if metrics != nil { - objectCount := int64(len(peersToConnectIncludingRouters) + len(expiredPeers) + len(routesUpdate) + len(networkResourcesRoutes) + len(firewallRules) + +len(networkResourcesFirewallRules) + len(routesFirewallRules)) - metrics.CountNetworkMapObjects(objectCount) - metrics.CountGetPeerNetworkMapDuration(time.Since(start)) - - if objectCount > 5000 { - log.WithContext(ctx).Tracef("account: %s has a total resource count of %d objects, "+ - "peers to connect: %d, expired peers: %d, routes: %d, firewall rules: %d, network resources routes: %d, network resources firewall rules: %d, routes firewall rules: %d", - a.Id, objectCount, len(peersToConnectIncludingRouters), len(expiredPeers), len(routesUpdate), len(firewallRules), len(networkResourcesRoutes), len(networkResourcesFirewallRules), len(routesFirewallRules)) - } - } - - return nm -} - func (a *Account) addNetworksRoutingPeers( networkResourcesRoutes []*route.Route, peer *nbpeer.Peer, @@ -388,39 +192,6 @@ func (a *Account) addNetworksRoutingPeers( return peersToConnect } -func getPeerNSGroups(account *Account, peerID string) []*nbdns.NameServerGroup { - groupList := account.GetPeerGroups(peerID) - - var peerNSGroups []*nbdns.NameServerGroup - - for _, nsGroup := range account.NameServerGroups { - if !nsGroup.Enabled { - continue - } - for _, gID := range nsGroup.Groups { - _, found := groupList[gID] - if found { - if !peerIsNameserver(account.GetPeer(peerID), nsGroup) { - peerNSGroups = append(peerNSGroups, nsGroup.Copy()) - break - } - } - } - } - - return peerNSGroups -} - -// peerIsNameserver returns true if the peer is a nameserver for a nsGroup -func peerIsNameserver(peer *nbpeer.Peer, nsGroup *nbdns.NameServerGroup) bool { - for _, ns := range nsGroup.NameServers { - if peer.IP.Equal(ns.IP.AsSlice()) { - return true - } - } - return false -} - func AddPeerLabelsToAccount(ctx context.Context, account *Account, peerLabels LookupMap) { for _, peer := range account.Peers { label, err := GetPeerHostLabel(peer.Name, peerLabels) @@ -466,6 +237,150 @@ func getUniqueHostLabel(name string, peerLabels LookupMap) string { return "" } +// SynthesizePrivateServiceZones returns in-memory CustomZones with A records pointing each enabled private service the peer can reach at the cluster's proxy-peer IPs. One zone per cluster (multiple services share); records gated by AccessGroups. +func (a *Account) SynthesizePrivateServiceZones(peerID string) []nbdns.CustomZone { + peer, ok := a.Peers[peerID] + if !ok || peer == nil { + return nil + } + if len(a.Services) == 0 { + return nil + } + + proxyPeersByCluster := a.GetProxyPeers() + if len(proxyPeersByCluster) == 0 { + return nil + } + + peerGroups := a.GetPeerGroups(peerID) + zonesByApex := map[string]*nbdns.CustomZone{} + + for _, svc := range a.Services { + if svc == nil || !svc.Enabled || !svc.Private { + continue + } + if len(svc.AccessGroups) == 0 { + continue + } + if !peerInDistributionGroups(peerGroups, svc.AccessGroups) { + continue + } + proxyPeers := proxyPeersByCluster[svc.ProxyCluster] + if len(proxyPeers) == 0 { + continue + } + + serviceDomainZone := a.privateServiceDomainZone(svc) + if serviceDomainZone == "" { + continue + } + + zone, exists := zonesByApex[serviceDomainZone] + if !exists { + // NonAuthoritative makes this a match-only zone: queries for + // names without an explicit record fall through to the + // upstream resolver instead of returning NXDOMAIN. Without + // it, adding a single private service would black-hole every + // other name under the zone apex. + zone = &nbdns.CustomZone{ + Domain: dns.Fqdn(serviceDomainZone), + Records: []nbdns.SimpleRecord{}, + NonAuthoritative: true, + SearchDomainDisabled: true, + } + zonesByApex[serviceDomainZone] = zone + } + + emitted := 0 + skippedDisconnected := 0 + for _, p := range proxyPeers { + if p == nil || !p.IP.IsValid() { + continue + } + // Only emit a record when the proxy peer is actually + // connected. A disconnected proxy peer's tunnel IP won't + // answer; pointing DNS at it would produce a black hole + // for as long as the record is cached client-side. + if p.Status == nil || !p.Status.Connected { + skippedDisconnected++ + continue + } + zone.Records = append(zone.Records, nbdns.SimpleRecord{ + Name: dns.Fqdn(svc.Domain), + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: privateServiceDNSRecordTTL, + RData: p.IP.String(), + }) + emitted++ + } + // Disagreement with the firewall path is the typical + // "domain doesn't reach client but firewall rules do" + // symptom: the synth service is otherwise fine, only the + // proxy peer's persisted Connected flag is wrong (most + // likely the connection reaper marked it disconnected even + // though the gRPC stream is alive). + if emitted == 0 && skippedDisconnected > 0 { + log.Debugf("private-zone synth: svc %s domain=%s cluster=%s emitted_zero proxy_peers=%d all_disconnected=%d (firewall would still fire)", + svc.ID, svc.Domain, svc.ProxyCluster, len(proxyPeers), skippedDisconnected) + } + } + + out := make([]nbdns.CustomZone, 0, len(zonesByApex)) + for _, zone := range zonesByApex { + if len(zone.Records) == 0 { + continue + } + out = append(out, *zone) + } + if len(out) == 0 && len(a.Services) > 0 { + // Targeted diagnostic for the "firewall yes, DNS no" divergence — + // fires only when services exist but synth returns zero zones, + // so accounts without private services produce no noise. + log.Debugf("private-zone synth: peer %s account %s returned 0 zones from %d candidate service(s)", + peerID, a.Id, len(a.Services)) + } + return out +} + +// privateServiceDomainZone returns the DNS zone name for the given private service domain by +// looking at the proxy cluster domain then the custom domains. +func (a *Account) privateServiceDomainZone(svc *service.Service) string { + if domainFromSuffix(svc.Domain, svc.ProxyCluster) { + return svc.ProxyCluster + } + + // Longest matching custom domain wins + zoneName := "" + for _, d := range a.Domains { + if d == nil || d.TargetCluster != svc.ProxyCluster { + continue + } + if domainFromSuffix(svc.Domain, d.Domain) && len(d.Domain) > len(zoneName) { + zoneName = d.Domain + } + } + return zoneName +} + +func domainFromSuffix(domain, suffix string) bool { + if suffix == "" { + return false + } + return domain == suffix || strings.HasSuffix(domain, "."+suffix) +} + +// peerInDistributionGroups reports whether any of the peer's groups +// matches the service's bearer-auth distribution_groups. +func peerInDistributionGroups(peerGroups LookupMap, distributionGroups []string) bool { + for _, gid := range distributionGroups { + if _, ok := peerGroups[gid]; ok { + return true + } + } + return false +} + func (a *Account) GetPeersCustomZone(ctx context.Context, dnsDomain string) nbdns.CustomZone { var merr *multierror.Error @@ -481,6 +396,8 @@ func (a *Account) GetPeersCustomZone(ctx context.Context, dnsDomain string) nbdn domainSuffix := "." + dnsDomain + ipv6AllowedPeers := a.peerIPv6AllowedSet() + var sb strings.Builder for _, peer := range a.Peers { if peer.DNSLabel == "" { @@ -492,13 +409,31 @@ func (a *Account) GetPeersCustomZone(ctx context.Context, dnsDomain string) nbdn sb.WriteString(peer.DNSLabel) sb.WriteString(domainSuffix) + fqdn := sb.String() customZone.Records = append(customZone.Records, nbdns.SimpleRecord{ - Name: sb.String(), + Name: fqdn, Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: defaultTTL, RData: peer.IP.String(), }) + // Only advertise AAAA for peers that have a valid IPv6, whose client supports it, + // and that belong to an IPv6-enabled group. Old clients don't configure v6 on their + // WireGuard interface, so resolving their AAAA causes connections to hang. + // Capability changes (client upgrade/downgrade, --disable-ipv6 toggle) propagate + // to other peers via SyncPeer/LoginPeer regardless of version change, so AAAA + // records refresh when a peer first reports the IPv6 overlay capability. + _, peerAllowed := ipv6AllowedPeers[peer.ID] + hasIPv6 := peer.IPv6.IsValid() && peer.SupportsIPv6() && peerAllowed + if hasIPv6 { + customZone.Records = append(customZone.Records, nbdns.SimpleRecord{ + Name: fqdn, + Type: int(dns.TypeAAAA), + Class: nbdns.DefaultClass, + TTL: defaultTTL, + RData: peer.IPv6.String(), + }) + } sb.Reset() for _, extraLabel := range peer.ExtraDNSLabels { @@ -506,13 +441,23 @@ func (a *Account) GetPeersCustomZone(ctx context.Context, dnsDomain string) nbdn sb.WriteString(extraLabel) sb.WriteString(domainSuffix) + extraFqdn := sb.String() customZone.Records = append(customZone.Records, nbdns.SimpleRecord{ - Name: sb.String(), + Name: extraFqdn, Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: defaultTTL, RData: peer.IP.String(), }) + if hasIPv6 { + customZone.Records = append(customZone.Records, nbdns.SimpleRecord{ + Name: extraFqdn, + Type: int(dns.TypeAAAA), + Class: nbdns.DefaultClass, + TTL: defaultTTL, + RData: peer.IPv6.String(), + }) + } sb.Reset() } @@ -767,19 +712,6 @@ func (a *Account) GetPeerGroupsList(peerID string) []string { return grps } -func (a *Account) getPeerDNSManagementStatus(peerID string) bool { - peerGroups := a.GetPeerGroups(peerID) - enabled := true - for _, groupID := range a.DNSSettings.DisabledManagementGroups { - _, found := peerGroups[groupID] - if found { - enabled = false - break - } - } - return enabled -} - func (a *Account) GetPeerGroups(peerID string) LookupMap { groupList := make(LookupMap) for groupID, group := range a.Groups { @@ -793,8 +725,41 @@ func (a *Account) GetPeerGroups(peerID string) LookupMap { return groupList } -func (a *Account) GetTakenIPs() []net.IP { - var takenIps []net.IP +// PeerIPv6Allowed reports whether the given peer participates in the IPv6 overlay. +// Returns false if IPv6 is disabled or no groups are configured. +func (a *Account) PeerIPv6Allowed(peerID string) bool { + _, ok := a.peerIPv6AllowedSet()[peerID] + return ok +} + +// peerIPv6AllowedSet returns the set of peer IDs that participate in the IPv6 overlay: +// members of any IPv6-enabled group, plus every embedded proxy peer (which sit outside +// regular group membership but must reach v6-enabled peers). +func (a *Account) peerIPv6AllowedSet() map[string]struct{} { + result := make(map[string]struct{}) + if len(a.Settings.IPv6EnabledGroups) == 0 { + return result + } + for _, groupID := range a.Settings.IPv6EnabledGroups { + group, ok := a.Groups[groupID] + if !ok { + continue + } + for _, peerID := range group.Peers { + result[peerID] = struct{}{} + } + } + for id, p := range a.Peers { + if p != nil && p.ProxyMeta.Embedded { + result[id] = struct{}{} + } + } + return result +} + +// GetTakenIPs returns all peer IP addresses currently allocated in the account. +func (a *Account) GetTakenIPs() []netip.Addr { + takenIps := make([]netip.Addr, 0, len(a.Peers)) for _, existingPeer := range a.Peers { takenIps = append(takenIps, existingPeer.IP) } @@ -875,6 +840,16 @@ func (a *Account) Copy() *Account { networkResources = append(networkResources, resource.Copy()) } + services := []*service.Service{} + for _, svc := range a.Services { + services = append(services, svc.Copy()) + } + + domains := []*proxydomain.Domain{} + for _, domain := range a.Domains { + domains = append(domains, domain.Copy()) + } + return &Account{ Id: a.Id, CreatedBy: a.CreatedBy, @@ -896,9 +871,9 @@ func (a *Account) Copy() *Account { Networks: nets, NetworkRouters: networkRouters, NetworkResources: networkResources, + Services: services, Onboarding: a.Onboarding, - NetworkMapCache: a.NetworkMapCache, - nmapInitOnce: a.nmapInitOnce, + Domains: domains, } } @@ -997,8 +972,10 @@ func (a *Account) UserGroupsRemoveFromPeers(userID string, groups ...string) map // GetPeerConnectionResources for a given peer // // This function returns the list of peers and firewall rules that are applicable to a given peer. -func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.Peer, validatedPeersMap map[string]struct{}) ([]*nbpeer.Peer, []*FirewallRule) { +func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.Peer, validatedPeersMap map[string]struct{}, groupIDToUserIDs map[string][]string) ([]*nbpeer.Peer, []*FirewallRule, map[string]map[string]struct{}, bool) { generateResources, getAccumulatedResources := a.connResourcesGenerator(ctx, peer) + authorizedUsers := make(map[string]map[string]struct{}) // machine user to list of userIDs + sshEnabled := false for _, policy := range a.Policies { if !policy.Enabled { @@ -1041,10 +1018,58 @@ func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.P if peerInDestinations { generateResources(rule, sourcePeers, FirewallRuleDirectionIN) } + + if peerInDestinations && rule.Protocol == PolicyRuleProtocolNetbirdSSH { + sshEnabled = true + switch { + case len(rule.AuthorizedGroups) > 0: + for groupID, localUsers := range rule.AuthorizedGroups { + userIDs, ok := groupIDToUserIDs[groupID] + if !ok { + log.WithContext(ctx).Tracef("no user IDs found for group ID %s", groupID) + continue + } + + if len(localUsers) == 0 { + localUsers = []string{auth.Wildcard} + } + + for _, localUser := range localUsers { + if authorizedUsers[localUser] == nil { + authorizedUsers[localUser] = make(map[string]struct{}) + } + for _, userID := range userIDs { + authorizedUsers[localUser][userID] = struct{}{} + } + } + } + case rule.AuthorizedUser != "": + if authorizedUsers[auth.Wildcard] == nil { + authorizedUsers[auth.Wildcard] = make(map[string]struct{}) + } + authorizedUsers[auth.Wildcard][rule.AuthorizedUser] = struct{}{} + default: + authorizedUsers[auth.Wildcard] = a.getAllowedUserIDs() + } + } else if peerInDestinations && PolicyRuleImpliesLegacySSH(rule) && peer.SSHEnabled { + sshEnabled = true + authorizedUsers[auth.Wildcard] = a.getAllowedUserIDs() + } } } - return getAccumulatedResources() + peers, fwRules := getAccumulatedResources() + return peers, fwRules, authorizedUsers, sshEnabled +} + +func (a *Account) getAllowedUserIDs() map[string]struct{} { + users := make(map[string]struct{}) + for _, nbUser := range a.Users { + if !nbUser.IsBlocked() && !nbUser.IsServiceUser { + users[nbUser.Id] = struct{}{} + } + } + return users } // connResourcesGenerator returns generator and accumulator function which returns the result of generator calls @@ -1057,6 +1082,7 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer peersExists := make(map[string]struct{}) rules := make([]*FirewallRule, 0) peers := make([]*nbpeer.Peer, 0) + targetComponent := targetPeer.ToComponent() return func(rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int) { for _, peer := range groupPeers { @@ -1069,12 +1095,17 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer peersExists[peer.ID] = struct{}{} } + protocol := rule.Protocol + if protocol == PolicyRuleProtocolNetbirdSSH { + protocol = PolicyRuleProtocolTCP + } + fr := FirewallRule{ PolicyID: rule.ID, PeerIP: peer.IP.String(), Direction: direction, Action: string(rule.Action), - Protocol: string(rule.Protocol), + Protocol: string(protocol), } ruleID := rule.ID + fr.PeerIP + strconv.Itoa(direction) + @@ -1086,16 +1117,64 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 { rules = append(rules, &fr) - continue + } else { + rules = append(rules, ExpandPortsAndRanges(fr, rule, targetComponent)...) } - rules = append(rules, expandPortsAndRanges(fr, rule, targetPeer)...) + rules = AppendIPv6FirewallRule(rules, rulesExists, peer.ToComponent(), targetComponent, rule, FirewallRuleContext{ + Direction: direction, + DirStr: strconv.Itoa(direction), + ProtocolStr: string(protocol), + ActionStr: string(rule.Action), + PortsJoined: strings.Join(rule.Ports, ","), + }) } }, func() ([]*nbpeer.Peer, []*FirewallRule) { return peers, rules } } +// PeerSSHEnabledFromPolicies is the network-map-free equivalent of the sshEnabled +// determination in GetPeerConnectionResources / CalculateNetworkMapFromComponents. +func PeerSSHEnabledFromPolicies(policies []*Policy, peerID string, peerGroupIDs map[string]struct{}, peerSSHEnabled bool) bool { + for _, policy := range policies { + if !policy.Enabled { + continue + } + + for _, rule := range policy.Rules { + if !rule.Enabled { + continue + } + + isSSHRule := rule.Protocol == PolicyRuleProtocolNetbirdSSH || + (PolicyRuleImpliesLegacySSH(rule) && peerSSHEnabled) + if !isSSHRule { + continue + } + + if ruleHasDestination(rule, peerID, peerGroupIDs) { + return true + } + } + } + + return false +} + +func ruleHasDestination(rule *PolicyRule, peerID string, peerGroupIDs map[string]struct{}) bool { + if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" { + return rule.DestinationResource.ID == peerID + } + + for _, groupID := range rule.Destinations { + if _, ok := peerGroupIDs[groupID]; ok { + return true + } + } + return false +} + // getAllPeersFromGroups for given peer ID and list of groups // // Returns a list of peers from specified groups that pass specified posture checks @@ -1109,7 +1188,7 @@ func (a *Account) getAllPeersFromGroups(ctx context.Context, groups []string, pe filteredPeers := make([]*nbpeer.Peer, 0, len(uniquePeerIDs)) for _, p := range uniquePeerIDs { peer, ok := a.Peers[p] - if !ok || peer == nil { + if !ok || peer == nil || peer.ProxyMeta.Embedded { continue } @@ -1140,7 +1219,11 @@ func (a *Account) getPeerFromResource(resource Resource, peerID string) ([]*nbpe return []*nbpeer.Peer{}, false } - return []*nbpeer.Peer{peer}, resource.ID == peerID + if peer.ID == peerID { + return []*nbpeer.Peer{}, true + } + + return []*nbpeer.Peer{peer}, false } // validatePostureChecksOnPeer validates the posture checks on a peer @@ -1178,32 +1261,7 @@ func (a *Account) GetPostureChecks(postureChecksID string) *posture.Checks { return nil } -// GetPeerRoutesFirewallRules gets the routes firewall rules associated with a routing peer ID for the account. -func (a *Account) GetPeerRoutesFirewallRules(ctx context.Context, peerID string, validatedPeersMap map[string]struct{}) []*RouteFirewallRule { - routesFirewallRules := make([]*RouteFirewallRule, 0, len(a.Routes)) - - enabledRoutes, _ := a.getRoutingPeerRoutes(ctx, peerID) - for _, route := range enabledRoutes { - // If no access control groups are specified, accept all traffic. - if len(route.AccessControlGroups) == 0 { - defaultPermit := getDefaultPermit(route) - routesFirewallRules = append(routesFirewallRules, defaultPermit...) - continue - } - - distributionPeers := a.getDistributionGroupsPeers(route) - - for _, accessGroup := range route.AccessControlGroups { - policies := GetAllRoutePoliciesFromGroups(a, []string{accessGroup}) - rules := a.getRouteFirewallRules(ctx, peerID, policies, route, validatedPeersMap, distributionPeers) - routesFirewallRules = append(routesFirewallRules, rules...) - } - } - - return routesFirewallRules -} - -func (a *Account) getRouteFirewallRules(ctx context.Context, peerID string, policies []*Policy, route *route.Route, validatedPeersMap map[string]struct{}, distributionPeers map[string]struct{}) []*RouteFirewallRule { +func (a *Account) getRouteFirewallRules(ctx context.Context, peerID string, policies []*Policy, route *route.Route, validatedPeersMap map[string]struct{}, distributionPeers map[string]struct{}, includeIPv6 bool) []*RouteFirewallRule { var fwRules []*RouteFirewallRule for _, policy := range policies { if !policy.Enabled { @@ -1216,14 +1274,14 @@ func (a *Account) getRouteFirewallRules(ctx context.Context, peerID string, poli } rulePeers := a.getRulePeers(rule, policy.SourcePostureChecks, peerID, distributionPeers, validatedPeersMap) - rules := generateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN) + rules := GenerateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN, includeIPv6) fwRules = append(fwRules, rules...) } } return fwRules } -func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}, validatedPeersMap map[string]struct{}) []*nbpeer.Peer { +func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}, validatedPeersMap map[string]struct{}) []*ComponentPeer { distPeersWithPolicy := make(map[string]struct{}) for _, id := range rule.Sources { group := a.Groups[id] @@ -1242,62 +1300,25 @@ func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID } } } + if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { + _, distPeer := distributionPeers[rule.SourceResource.ID] + _, valid := validatedPeersMap[rule.SourceResource.ID] + if distPeer && valid && a.validatePostureChecksOnPeer(context.Background(), postureChecks, rule.SourceResource.ID) { + distPeersWithPolicy[rule.SourceResource.ID] = struct{}{} + } + } - distributionGroupPeers := make([]*nbpeer.Peer, 0, len(distPeersWithPolicy)) + distributionGroupPeers := make([]*ComponentPeer, 0, len(distPeersWithPolicy)) for pID := range distPeersWithPolicy { peer := a.Peers[pID] if peer == nil { continue } - distributionGroupPeers = append(distributionGroupPeers, peer) + distributionGroupPeers = append(distributionGroupPeers, peer.ToComponent()) } return distributionGroupPeers } -func (a *Account) getDistributionGroupsPeers(route *route.Route) map[string]struct{} { - distPeers := make(map[string]struct{}) - for _, id := range route.Groups { - group := a.Groups[id] - if group == nil { - continue - } - - for _, pID := range group.Peers { - distPeers[pID] = struct{}{} - } - } - return distPeers -} - -func getDefaultPermit(route *route.Route) []*RouteFirewallRule { - var rules []*RouteFirewallRule - - sources := []string{"0.0.0.0/0"} - if route.Network.Addr().Is6() { - sources = []string{"::/0"} - } - rule := RouteFirewallRule{ - SourceRanges: sources, - Action: string(PolicyTrafficActionAccept), - Destination: route.Network.String(), - Protocol: string(PolicyRuleProtocolALL), - Domains: route.Domains, - IsDynamic: route.IsDynamic(), - RouteID: route.ID, - } - - rules = append(rules, &rule) - - // dynamic routes always contain an IPv4 placeholder as destination, hence we must add IPv6 rules additionally - if route.IsDynamic() { - ruleV6 := rule - ruleV6.SourceRanges = []string{"::/0"} - rules = append(rules, &ruleV6) - } - - return rules -} - // GetAllRoutePoliciesFromGroups retrieves route policies associated with the specified access control groups // and returns a list of policies that have rules with destinations matching the specified groups. func GetAllRoutePoliciesFromGroups(account *Account, accessControlGroups []string) []*Policy { @@ -1335,7 +1356,7 @@ func (a *Account) GetPeerNetworkResourceFirewallRules(ctx context.Context, peer resourceAppliedPolicies := resourcePolicies[string(route.GetResourceID())] distributionPeers := getPoliciesSourcePeers(resourceAppliedPolicies, a.Groups) - rules := a.getRouteFirewallRules(ctx, peer.ID, resourceAppliedPolicies, route, validatedPeersMap, distributionPeers) + rules := a.getRouteFirewallRules(ctx, peer.ID, resourceAppliedPolicies, route, validatedPeersMap, distributionPeers, peer.SupportsIPv6() && peer.IPv6.IsValid()) for _, rule := range rules { if len(rule.SourceRanges) > 0 { routesFirewallRules = append(routesFirewallRules, rule) @@ -1375,65 +1396,6 @@ func (a *Account) GetResourcePoliciesMap() map[string][]*Policy { return resourcePolicies } -// GetNetworkResourcesRoutesToSync returns network routes for syncing with a specific peer and its ACL peers. -func (a *Account) GetNetworkResourcesRoutesToSync(ctx context.Context, peerID string, resourcePolicies map[string][]*Policy, routers map[string]map[string]*routerTypes.NetworkRouter) (bool, []*route.Route, map[string]struct{}) { - var isRoutingPeer bool - var routes []*route.Route - allSourcePeers := make(map[string]struct{}, len(a.Peers)) - - for _, resource := range a.NetworkResources { - if !resource.Enabled { - continue - } - - var addSourcePeers bool - - networkRoutingPeers, exists := routers[resource.NetworkID] - if exists { - if router, ok := networkRoutingPeers[peerID]; ok { - isRoutingPeer, addSourcePeers = true, true - routes = append(routes, a.getNetworkResourcesRoutes(resource, peerID, router, resourcePolicies)...) - } - } - - addedResourceRoute := false - for _, policy := range resourcePolicies[resource.ID] { - var peers []string - if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" { - peers = []string{policy.Rules[0].SourceResource.ID} - } else { - peers = a.getUniquePeerIDsFromGroupsIDs(ctx, policy.SourceGroups()) - } - if addSourcePeers { - for _, pID := range a.getPostureValidPeers(peers, policy.SourcePostureChecks) { - allSourcePeers[pID] = struct{}{} - } - } else if slices.Contains(peers, peerID) && a.validatePostureChecksOnPeer(ctx, policy.SourcePostureChecks, peerID) { - // add routes for the resource if the peer is in the distribution group - for peerId, router := range networkRoutingPeers { - routes = append(routes, a.getNetworkResourcesRoutes(resource, peerId, router, resourcePolicies)...) - } - addedResourceRoute = true - } - if addedResourceRoute { - break - } - } - } - - return isRoutingPeer, routes, allSourcePeers -} - -func (a *Account) getPostureValidPeers(inputPeers []string, postureChecksIDs []string) []string { - var dest []string - for _, peerID := range inputPeers { - if a.validatePostureChecksOnPeer(context.Background(), postureChecksIDs, peerID) { - dest = append(dest, peerID) - } - } - return dest -} - func (a *Account) getUniquePeerIDsFromGroupsIDs(ctx context.Context, groups []string) []string { peerIDs := make(map[string]struct{}, len(groups)) // we expect at least one peer per group as initial capacity for _, groupID := range groups { @@ -1480,12 +1442,12 @@ func (a *Account) GetPoliciesForNetworkResource(resourceId string) []*Policy { networkResourceGroups := a.getNetworkResourceGroups(resourceId) for _, policy := range a.Policies { - if !policy.Enabled { + if policy == nil || !policy.Enabled { continue } for _, rule := range policy.Rules { - if !rule.Enabled { + if rule == nil || !rule.Enabled { continue } @@ -1525,22 +1487,6 @@ func (a *Account) GetPoliciesAppliedInNetwork(networkID string) []string { return result } -// getNetworkResourcesRoutes convert the network resources list to routes list. -func (a *Account) getNetworkResourcesRoutes(resource *resourceTypes.NetworkResource, peerId string, router *routerTypes.NetworkRouter, resourcePolicies map[string][]*Policy) []*route.Route { - resourceAppliedPolicies := resourcePolicies[resource.ID] - - var routes []*route.Route - // distribute the resource routes only if there is policy applied to it - if len(resourceAppliedPolicies) > 0 { - peer := a.GetPeer(peerId) - if peer != nil { - routes = append(routes, resource.ToRoute(peer, router)) - } - } - - return routes -} - func (a *Account) GetResourceRoutersMap() map[string]map[string]*routerTypes.NetworkRouter { routers := make(map[string]map[string]*routerTypes.NetworkRouter) @@ -1587,6 +1533,10 @@ func getPoliciesSourcePeers(policies []*Policy, groups map[string]*Group) map[st sourcePeers[peer] = struct{}{} } } + + if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { + sourcePeers[rule.SourceResource.ID] = struct{}{} + } } } @@ -1637,69 +1587,278 @@ func (a *Account) AddAllGroup(disableDefaultPolicy bool) error { return nil } -// expandPortsAndRanges expands Ports and PortRanges of a rule into individual firewall rules -func expandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule { - var expanded []*FirewallRule - - if len(rule.Ports) > 0 { - for _, port := range rule.Ports { - fr := base - fr.Port = port - expanded = append(expanded, &fr) - } - return expanded +func (a *Account) GetActiveGroupUsers() map[string][]string { + allGroupID := "" + group, err := a.GetGroupAll() + if err != nil { + log.Errorf("failed to get group all: %v", err) + } else { + allGroupID = group.ID } - - supportPortRanges := peerSupportsPortRanges(peer.Meta.WtVersion) - for _, portRange := range rule.PortRanges { - fr := base - - if supportPortRanges { - fr.PortRange = portRange - } else { - // Peer doesn't support port ranges, only allow single-port ranges - if portRange.Start != portRange.End { - continue + groups := make(map[string][]string, len(a.GroupsG)) + for _, user := range a.Users { + if !user.IsBlocked() && !user.IsServiceUser { + for _, groupID := range user.AutoGroups { + groups[groupID] = append(groups[groupID], user.Id) } - fr.Port = strconv.FormatUint(uint64(portRange.Start), 10) + groups[allGroupID] = append(groups[allGroupID], user.Id) } - expanded = append(expanded, &fr) } - - return expanded + return groups } -// peerSupportsPortRanges checks if the peer version supports port ranges. -func peerSupportsPortRanges(peerVer string) bool { - if strings.Contains(peerVer, "dev") { - return true +func (a *Account) GetProxyPeers() map[string][]*nbpeer.Peer { + proxyPeers := make(map[string][]*nbpeer.Peer) + for _, peer := range a.Peers { + if peer.ProxyMeta.Embedded { + proxyPeers[peer.ProxyMeta.Cluster] = append(proxyPeers[peer.ProxyMeta.Cluster], peer) + } + } + return proxyPeers +} + +func (a *Account) InjectProxyPolicies(ctx context.Context) { + if len(a.Services) == 0 { + return } - meetMinVer, err := posture.MeetsMinVersion(firewallRuleMinPortRangesVer, peerVer) - return err == nil && meetMinVer + proxyPeersByCluster := a.GetProxyPeers() + if len(proxyPeersByCluster) == 0 { + return + } + + for _, service := range a.Services { + if !service.Enabled { + continue + } + a.injectServiceProxyPolicies(ctx, service, proxyPeersByCluster) + } + +} + +func (a *Account) injectServiceProxyPolicies(ctx context.Context, service *service.Service, proxyPeersByCluster map[string][]*nbpeer.Peer) { + proxyPeers := proxyPeersByCluster[service.ProxyCluster] + for _, target := range service.Targets { + if !target.Enabled { + continue + } + a.injectTargetProxyPolicies(ctx, service, target, proxyPeers) + } + + a.injectPrivateServicePolicies(service, proxyPeers) +} + +// injectPrivateServicePolicies synthesises an in-memory ACL: AccessGroups → cluster proxy peers on TCP 80/443. +func (a *Account) injectPrivateServicePolicies(svc *service.Service, proxyPeers []*nbpeer.Peer) { + if !svc.Private { + return + } + if len(svc.AccessGroups) == 0 { + return + } + if len(proxyPeers) == 0 { + return + } + for _, proxyPeer := range proxyPeers { + a.Policies = append(a.Policies, a.createPrivateServicePolicy(svc, proxyPeer)) + } +} + +func (a *Account) createPrivateServicePolicy(svc *service.Service, proxyPeer *nbpeer.Peer) *Policy { + policyID := fmt.Sprintf("private-access-%s-%s", svc.ID, proxyPeer.ID) + sources := append([]string(nil), svc.AccessGroups...) + return &Policy{ + ID: policyID, + Name: fmt.Sprintf("Private Access to %s", svc.Name), + Enabled: true, + Rules: []*PolicyRule{ + { + ID: policyID, + PolicyID: policyID, + Name: fmt.Sprintf("Allow access groups to reach %s", svc.Name), + Enabled: true, + Sources: sources, + DestinationResource: Resource{ + ID: proxyPeer.ID, + Type: ResourceTypePeer, + }, + Bidirectional: false, + Protocol: PolicyRuleProtocolTCP, + Action: PolicyTrafficActionAccept, + PortRanges: []RulePortRange{ + {Start: 80, End: 80}, + {Start: 443, End: 443}, + }, + }, + }, + } +} + +func (a *Account) injectTargetProxyPolicies(ctx context.Context, service *service.Service, target *service.Target, proxyPeers []*nbpeer.Peer) { + port, ok := a.resolveTargetPort(ctx, target) + if !ok { + return + } + + path := "" + if target.Path != nil { + path = *target.Path + } + + for _, proxyPeer := range proxyPeers { + policy := a.createProxyPolicy(service, target, proxyPeer, port, path) + a.Policies = append(a.Policies, policy) + } +} + +func (a *Account) resolveTargetPort(ctx context.Context, target *service.Target) (uint16, bool) { + if target.Port != 0 { + return target.Port, true + } + + switch target.Protocol { + case "https", "tls": + return 443, true + case "http": + return 80, true + default: + log.WithContext(ctx).Warnf("unsupported protocol %s for proxy target %s, skipping policy injection", target.Protocol, target.TargetId) + return 0, false + } +} + +func (a *Account) createProxyPolicy(svc *service.Service, target *service.Target, proxyPeer *nbpeer.Peer, port uint16, path string) *Policy { + policyID := fmt.Sprintf("proxy-access-%s-%s-%s", svc.ID, proxyPeer.ID, path) + + protocol := PolicyRuleProtocolTCP + if svc.Mode == service.ModeUDP { + protocol = PolicyRuleProtocolUDP + } + + return &Policy{ + ID: policyID, + Name: fmt.Sprintf("Proxy Access to %s", svc.Name), + Enabled: true, + Rules: []*PolicyRule{ + { + ID: policyID, + PolicyID: policyID, + Name: fmt.Sprintf("Allow access to %s", svc.Name), + Enabled: true, + SourceResource: Resource{ + ID: proxyPeer.ID, + Type: ResourceTypePeer, + }, + DestinationResource: Resource{ + ID: target.TargetId, + Type: ResourceType(target.TargetType), + }, + Bidirectional: false, + Protocol: protocol, + Action: PolicyTrafficActionAccept, + PortRanges: []RulePortRange{ + { + Start: port, + End: port, + }, + }, + }, + }, + } } // filterZoneRecordsForPeers filters DNS records to only include peers to connect. +// AAAA records are excluded when the requesting peer lacks IPv6 capability. func filterZoneRecordsForPeers(peer *nbpeer.Peer, customZone nbdns.CustomZone, peersToConnect, expiredPeers []*nbpeer.Peer) []nbdns.SimpleRecord { filteredRecords := make([]nbdns.SimpleRecord, 0, len(customZone.Records)) - peerIPs := make(map[string]struct{}) + peerIPs := make(map[netip.Addr]struct{}, len(peersToConnect)+len(expiredPeers)+2) + includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid() - // Add peer's own IP to include its own DNS records - peerIPs[peer.IP.String()] = struct{}{} - - for _, peerToConnect := range peersToConnect { - peerIPs[peerToConnect.IP.String()] = struct{}{} + addPeerIPs := func(p *nbpeer.Peer) { + peerIPs[p.IP] = struct{}{} + if includeIPv6 && p.IPv6.IsValid() { + peerIPs[p.IPv6] = struct{}{} + } } - for _, expiredPeer := range expiredPeers { - peerIPs[expiredPeer.IP.String()] = struct{}{} + addPeerIPs(peer) + for _, p := range peersToConnect { + addPeerIPs(p) + } + for _, p := range expiredPeers { + addPeerIPs(p) } for _, record := range customZone.Records { - if _, exists := peerIPs[record.RData]; exists { - filteredRecords = append(filteredRecords, record) + if addr, err := netip.ParseAddr(record.RData); err == nil { + if _, exists := peerIPs[addr.Unmap()]; exists { + filteredRecords = append(filteredRecords, record) + } } } return filteredRecords } + +// filterPeerAppliedZones filters account zones based on the peer's group membership +func filterPeerAppliedZones(ctx context.Context, accountZones []*zones.Zone, peerGroups LookupMap) []nbdns.CustomZone { + var customZones []nbdns.CustomZone + + if len(peerGroups) == 0 { + return customZones + } + + for _, zone := range accountZones { + if !zone.Enabled || len(zone.Records) == 0 { + continue + } + + hasAccess := false + for _, distGroupID := range zone.DistributionGroups { + if _, found := peerGroups[distGroupID]; found { + hasAccess = true + break + } + } + + if !hasAccess { + continue + } + + simpleRecords := make([]nbdns.SimpleRecord, 0, len(zone.Records)) + for _, record := range zone.Records { + var recordType int + rData := record.Content + + switch record.Type { + case records.RecordTypeA: + recordType = int(dns.TypeA) + case records.RecordTypeAAAA: + recordType = int(dns.TypeAAAA) + case records.RecordTypeCNAME: + recordType = int(dns.TypeCNAME) + rData = dns.Fqdn(record.Content) + default: + log.WithContext(ctx).Warnf("unknown DNS record type %s for record %s", record.Type, record.ID) + continue + } + + simpleRecords = append(simpleRecords, nbdns.SimpleRecord{ + Name: dns.Fqdn(record.Name), + Type: recordType, + Class: nbdns.DefaultClass, + TTL: record.TTL, + RData: rData, + }) + } + + customZones = append(customZones, nbdns.CustomZone{ + Domain: dns.Fqdn(zone.Domain), + Records: simpleRecords, + SearchDomainDisabled: !zone.EnableSearchDomain, + NonAuthoritative: true, + }) + } + + return customZones +} diff --git a/management/server/types/account_components.go b/management/server/types/account_components.go new file mode 100644 index 000000000..af27788d8 --- /dev/null +++ b/management/server/types/account_components.go @@ -0,0 +1,724 @@ +package types + +import ( + "context" + "slices" + "time" + + log "github.com/sirupsen/logrus" + + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/modules/zones" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/netbirdio/netbird/route" +) + +// GetPeerNetworkMapResult dispatches to either the legacy-NetworkMap path or +// the components path based on the peer's capability and the kill switch. +// Capable peers (PeerCapabilityComponentNetworkMap) get the raw components +// shape — the server skips Calculate() entirely for them, saving CPU +// proportional to the number of capable peers in the account. Legacy peers +// (or any peer when componentsDisabled is true) get the fully-expanded +// NetworkMap as before. +func (a *Account) GetPeerNetworkMapResult( + ctx context.Context, + peerID string, + componentsDisabled bool, + peersCustomZone nbdns.CustomZone, + accountZones []*zones.Zone, + validatedPeersMap map[string]struct{}, + resourcePolicies map[string][]*Policy, + routers map[string]map[string]*routerTypes.NetworkRouter, + metrics *telemetry.AccountManagerMetrics, + groupIDToUserIDs map[string][]string, +) PeerNetworkMapResult { + peer := a.Peers[peerID] + if !componentsDisabled && peer != nil && peer.SupportsComponentNetworkMap() { + components := a.GetPeerNetworkMapComponents( + ctx, peerID, peersCustomZone, accountZones, validatedPeersMap, resourcePolicies, routers, groupIDToUserIDs, + ) + return PeerNetworkMapResult{Components: components} + } + return PeerNetworkMapResult{ + NetworkMap: a.GetPeerNetworkMapFromComponents( + ctx, peerID, peersCustomZone, accountZones, validatedPeersMap, resourcePolicies, routers, metrics, groupIDToUserIDs, + ), + } +} + +func (a *Account) GetPeerNetworkMapFromComponents( + ctx context.Context, + peerID string, + peersCustomZone nbdns.CustomZone, + accountZones []*zones.Zone, + validatedPeersMap map[string]struct{}, + resourcePolicies map[string][]*Policy, + routers map[string]map[string]*routerTypes.NetworkRouter, + metrics *telemetry.AccountManagerMetrics, + groupIDToUserIDs map[string][]string, +) *NetworkMap { + start := time.Now() + + components := a.GetPeerNetworkMapComponents( + ctx, + peerID, + peersCustomZone, + accountZones, + validatedPeersMap, + resourcePolicies, + routers, + groupIDToUserIDs, + ) + + if components.IsEmpty() { + return &NetworkMap{Network: components.Network} + } + + nm := CalculateNetworkMapFromComponents(ctx, components) + + if metrics != nil { + objectCount := int64(len(nm.Peers) + len(nm.OfflinePeers) + len(nm.Routes) + len(nm.FirewallRules) + len(nm.RoutesFirewallRules)) + metrics.CountNetworkMapObjects(objectCount) + metrics.CountGetPeerNetworkMapDuration(time.Since(start)) + + if objectCount > 5000 { + log.WithContext(ctx).Tracef("account: %s has a total resource count of %d objects from components, "+ + "peers: %d, offline peers: %d, routes: %d, firewall rules: %d, route firewall rules: %d", + a.Id, objectCount, len(nm.Peers), len(nm.OfflinePeers), len(nm.Routes), len(nm.FirewallRules), len(nm.RoutesFirewallRules)) + } + } + + return nm +} + +func (a *Account) GetPeerNetworkMapComponents( + ctx context.Context, + peerID string, + peersCustomZone nbdns.CustomZone, + accountZones []*zones.Zone, + validatedPeersMap map[string]struct{}, + resourcePolicies map[string][]*Policy, + routers map[string]map[string]*routerTypes.NetworkRouter, + groupIDToUserIDs map[string][]string, +) *NetworkMapComponents { + peer := a.Peers[peerID] + // this can never happen, things are very wrong if it did + // TODO (dmitri) maybe consider using invariants? + if peer == nil { + log.WithField("peer id", peerID).Error("NetworkMapComponents are computed for a peer missing from the account") + return EmptyNetworkMapComponents(&NetworkMapComponents{ + PeerID: peerID, + Network: a.Network.Copy(), + // must include the target peer as it's required on the client + Peers: map[string]*ComponentPeer{peerID: peer.ToComponent()}, + }) + } + + if _, ok := validatedPeersMap[peerID]; !ok { + // Mirror legacy graceful-degrade: GetPeerNetworkMapFromComponents + // returns &NetworkMap{Network: a.Network.Copy()} when components is + // nil. Match that floor so the receiving client always sees the + // account Network identifier, not a fully-empty envelope. + return EmptyNetworkMapComponents(&NetworkMapComponents{ + PeerID: peerID, + Network: a.Network.Copy(), + // must include the target peer as it's required on the client + Peers: map[string]*ComponentPeer{peerID: peer.ToComponent()}, + }) + } + + components := &NetworkMapComponents{ + PeerID: peerID, + Network: a.Network.Copy(), + NameServerGroups: make([]*nbdns.NameServerGroup, 0), + CustomZoneDomain: peersCustomZone.Domain, + ResourcePoliciesMap: make(map[string][]*Policy), + RoutersMap: make(map[string]map[string]*ComponentRouter), + NetworkResources: make([]*ComponentResource, 0), + PostureFailedPeers: make(map[string]map[string]struct{}, len(a.PostureChecks)), + RouterPeers: make(map[string]*ComponentPeer), + NetworkXIDToPublicID: make(map[string]string, len(a.Networks)), + PostureCheckXIDToPublicID: make(map[string]string, len(a.PostureChecks)), + } + for _, n := range a.Networks { + if n != nil { + components.NetworkXIDToPublicID[n.ID] = n.PublicID + } + } + for _, pc := range a.PostureChecks { + if pc != nil { + components.PostureCheckXIDToPublicID[pc.ID] = pc.PublicID + } + } + + components.AccountSettings = &AccountSettingsInfo{ + PeerLoginExpirationEnabled: a.Settings.PeerLoginExpirationEnabled, + PeerLoginExpiration: a.Settings.PeerLoginExpiration, + PeerInactivityExpirationEnabled: a.Settings.PeerInactivityExpirationEnabled, + PeerInactivityExpiration: a.Settings.PeerInactivityExpiration, + } + + components.DNSSettings = &a.DNSSettings + + // relevantPeers always contains the target peer (peerID) + relevantPeers, relevantGroups, relevantPolicies, relevantRoutes, sshReqs := a.getPeersGroupsPoliciesRoutes(ctx, peerID, peer.SSHEnabled, validatedPeersMap, &components.PostureFailedPeers) + + if len(sshReqs.neededGroupIDs) > 0 { + components.GroupIDToUserIDs = filterGroupIDToUserIDs(groupIDToUserIDs, sshReqs.neededGroupIDs) + } + if sshReqs.needAllowedUserIDs { + components.AllowedUserIDs = a.getAllowedUserIDs() + } + + components.Peers = relevantPeers + components.Groups = GroupsToComponent(relevantGroups) + components.Policies = relevantPolicies + components.Routes = relevantRoutes + components.AllDNSRecords = filterDNSRecordsByPeers(peersCustomZone.Records, relevantPeers, peer.SupportsIPv6() && peer.IPv6.IsValid()) + + peerGroups := a.GetPeerGroups(peerID) + components.AccountZones = filterPeerAppliedZones(ctx, accountZones, peerGroups) + components.AccountZones = append(components.AccountZones, a.SynthesizePrivateServiceZones(peerID)...) + + for _, nsGroup := range a.NameServerGroups { + if nsGroup.Enabled { + for _, gID := range nsGroup.Groups { + if _, found := relevantGroups[gID]; found { + components.NameServerGroups = append(components.NameServerGroups, nsGroup) + break + } + } + } + } + + for _, resource := range a.NetworkResources { + if !resource.Enabled { + continue + } + + policies, exists := resourcePolicies[resource.ID] + if !exists { + continue + } + + addSourcePeers := false + + networkRoutingPeers, routerExists := routers[resource.NetworkID] + if routerExists { + if _, ok := networkRoutingPeers[peerID]; ok { + addSourcePeers = true + } + } + + for _, policy := range policies { + if addSourcePeers { + var peers []string + if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" { + peers = []string{policy.Rules[0].SourceResource.ID} + } else { + peers = a.getUniquePeerIDsFromGroupsIDs(ctx, policy.SourceGroups()) + } + for _, pID := range a.getPostureValidPeersSaveFailed(peers, policy.SourcePostureChecks, validatedPeersMap, &components.PostureFailedPeers) { + if _, exists := components.Peers[pID]; !exists { + components.Peers[pID] = a.GetPeer(pID).ToComponent() + } + } + } else { + peerInSources := false + if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" { + peerInSources = policy.Rules[0].SourceResource.ID == peerID + } else { + for _, groupID := range policy.SourceGroups() { + if group := a.GetGroup(groupID); group != nil && slices.Contains(group.Peers, peerID) { + peerInSources = true + break + } + } + } + if !peerInSources { + continue + } + isValid, pname := a.validatePostureChecksOnPeerGetFailed(ctx, policy.SourcePostureChecks, peerID) + if !isValid && len(pname) > 0 { + if _, ok := components.PostureFailedPeers[pname]; !ok { + components.PostureFailedPeers[pname] = make(map[string]struct{}) + } + components.PostureFailedPeers[pname][peer.ID] = struct{}{} + continue + } + addSourcePeers = true + } + + for _, rule := range policy.Rules { + for _, srcGroupID := range rule.Sources { + if g := a.Groups[srcGroupID]; g != nil { + if _, exists := components.Groups[srcGroupID]; !exists { + components.Groups[srcGroupID] = g.ToComponent() + } + } + } + for _, dstGroupID := range rule.Destinations { + if g := a.Groups[dstGroupID]; g != nil { + if _, exists := components.Groups[dstGroupID]; !exists { + components.Groups[dstGroupID] = g.ToComponent() + } + } + } + } + components.ResourcePoliciesMap[resource.ID] = policies + } + + // Only expose router peers and the per-network routers_map when this + // target peer actually has access to the resource (either as a router + // itself or via a policy that includes it as a source). Without this + // gate, every peer's envelope was leaking router peers of every + // network in the account — accounts with many tenants/networks + // shipped tens of unrelated peers in `peers[]` and `routers_map`. + if addSourcePeers { + components.RoutersMap[resource.NetworkID] = routerTypes.ToComponentMap(networkRoutingPeers) + for peerIDKey := range networkRoutingPeers { + if p := a.Peers[peerIDKey]; p != nil { + cp := components.RouterPeers[peerIDKey] + if cp == nil { + cp = p.ToComponent() + components.RouterPeers[peerIDKey] = cp + } + if _, exists := components.Peers[peerIDKey]; !exists { + if _, validated := validatedPeersMap[peerIDKey]; validated { + components.Peers[peerIDKey] = cp + } + } + } + } + components.NetworkResources = append(components.NetworkResources, resource.ToComponent()) + } + } + + filterGroupPeers(&components.Groups, components.Peers) + filterPostureFailedPeers(&components.PostureFailedPeers, components.Policies, components.ResourcePoliciesMap, components.Peers) + + return components +} + +type sshRequirements struct { + neededGroupIDs map[string]struct{} + needAllowedUserIDs bool +} + +func (a *Account) getPeersGroupsPoliciesRoutes( + ctx context.Context, + peerID string, + peerSSHEnabled bool, + validatedPeersMap map[string]struct{}, + postureFailedPeers *map[string]map[string]struct{}, +) (map[string]*ComponentPeer, map[string]*Group, []*Policy, []*route.Route, sshRequirements) { + relevantPeerIDs := make(map[string]*ComponentPeer, len(a.Peers)/4) + relevantGroupIDs := make(map[string]*Group, len(a.Groups)/4) + relevantPolicies := make([]*Policy, 0, len(a.Policies)) + relevantRoutes := make([]*route.Route, 0, len(a.Routes)) + sshReqs := sshRequirements{neededGroupIDs: make(map[string]struct{})} + + relevantPeerIDs[peerID] = a.GetPeer(peerID).ToComponent() + + peerGroupSet := make(map[string]struct{}, 8) + for groupID, group := range a.Groups { + if slices.Contains(group.Peers, peerID) { + relevantGroupIDs[groupID] = a.GetGroup(groupID) + peerGroupSet[groupID] = struct{}{} + } + } + + routeAccessControlGroups := make(map[string]struct{}) + for _, r := range a.Routes { + if r == nil { + continue + } + relevant := r.Peer == peerID + if !relevant { + for _, groupID := range r.PeerGroups { + if _, ok := peerGroupSet[groupID]; ok { + relevant = true + break + } + } + } + if !relevant && r.Enabled { + for _, groupID := range r.Groups { + if _, ok := peerGroupSet[groupID]; ok { + relevant = true + break + } + } + } + if !relevant { + continue + } + + for _, groupID := range r.PeerGroups { + relevantGroupIDs[groupID] = a.GetGroup(groupID) + } + for _, groupID := range r.Groups { + relevantGroupIDs[groupID] = a.GetGroup(groupID) + } + if r.Enabled { + for _, groupID := range r.AccessControlGroups { + relevantGroupIDs[groupID] = a.GetGroup(groupID) + routeAccessControlGroups[groupID] = struct{}{} + } + } + + // Include route advertisers in relevantPeerIDs. The envelope + // encoder writes route.peer_index by looking up r.Peer in the + // shipped peers list; if the advertiser is policy-isolated from + // the target peer (no rule edge between them), it would otherwise + // be omitted and the decoder would fail to resolve r.Peer, leaving + // the client without a WG tunnel target for this route. Legacy + // NetworkMap.Routes shipped the WG public key inline, so the + // equivalence path doesn't surface this — but the dependency is + // real once a client actually tries to use the route. + // Gate by validatedPeersMap so non-validated advertisers stay out + // (matches the network-resource router behaviour at the bottom of + // this loop, and the legacy invariant that only validated peers + // reach a client's view). + if r.Peer != "" { + if _, ok := validatedPeersMap[r.Peer]; ok { + if p := a.GetPeer(r.Peer); p != nil { + relevantPeerIDs[r.Peer] = p.ToComponent() + } + } + } + for _, groupID := range r.PeerGroups { + g := a.GetGroup(groupID) + if g == nil { + continue + } + for _, pid := range g.Peers { + if _, exists := relevantPeerIDs[pid]; exists { + continue + } + if _, ok := validatedPeersMap[pid]; !ok { + continue + } + if p := a.GetPeer(pid); p != nil { + relevantPeerIDs[pid] = p.ToComponent() + } + } + } + relevantRoutes = append(relevantRoutes, r) + } + + for _, policy := range a.Policies { + if !policy.Enabled { + continue + } + + policyRelevant := false + for _, rule := range policy.Rules { + if !rule.Enabled { + continue + } + + if len(routeAccessControlGroups) > 0 { + for _, destGroupID := range rule.Destinations { + if _, needed := routeAccessControlGroups[destGroupID]; needed { + policyRelevant = true + for _, srcGroupID := range rule.Sources { + relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID) + } + for _, dstGroupID := range rule.Destinations { + relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID) + } + break + } + } + } + + var sourcePeers, destinationPeers []string + var peerInSources, peerInDestinations bool + + if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { + sourcePeers = []string{rule.SourceResource.ID} + if rule.SourceResource.ID == peerID { + peerInSources = true + } + } else { + sourcePeers, peerInSources = a.getPeersFromGroups(ctx, rule.Sources, peerID, policy.SourcePostureChecks, validatedPeersMap, postureFailedPeers) + } + + if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" { + destinationPeers = []string{rule.DestinationResource.ID} + if rule.DestinationResource.ID == peerID { + peerInDestinations = true + } + } else { + destinationPeers, peerInDestinations = a.getPeersFromGroups(ctx, rule.Destinations, peerID, nil, validatedPeersMap, postureFailedPeers) + } + + if peerInSources { + policyRelevant = true + for _, pid := range destinationPeers { + if _, exists := relevantPeerIDs[pid]; !exists { + relevantPeerIDs[pid] = a.GetPeer(pid).ToComponent() + } + } + for _, dstGroupID := range rule.Destinations { + relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID) + } + } + + if peerInDestinations { + policyRelevant = true + for _, pid := range sourcePeers { + if _, exists := relevantPeerIDs[pid]; !exists { + relevantPeerIDs[pid] = a.GetPeer(pid).ToComponent() + } + } + for _, srcGroupID := range rule.Sources { + relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID) + } + + if rule.Protocol == PolicyRuleProtocolNetbirdSSH { + switch { + case len(rule.AuthorizedGroups) > 0: + for groupID := range rule.AuthorizedGroups { + sshReqs.neededGroupIDs[groupID] = struct{}{} + } + case rule.AuthorizedUser != "": + default: + sshReqs.needAllowedUserIDs = true + } + } else if PolicyRuleImpliesLegacySSH(rule) && peerSSHEnabled { + sshReqs.needAllowedUserIDs = true + } + } + } + if policyRelevant { + relevantPolicies = append(relevantPolicies, policy) + } + } + + return relevantPeerIDs, relevantGroupIDs, relevantPolicies, relevantRoutes, sshReqs +} + +func (a *Account) getPeersFromGroups(ctx context.Context, groups []string, peerID string, sourcePostureChecksIDs []string, + validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) ([]string, bool) { + peerInGroups := false + filteredPeerIDs := make([]string, 0, len(groups)) + seenPeerIds := make(map[string]struct{}, len(groups)) + + for _, gid := range groups { + group := a.GetGroup(gid) + if group == nil { + continue + } + + if group.IsGroupAll() || len(groups) == 1 { + filteredPeerIDs = make([]string, 0, len(group.Peers)) + peerInGroups = false + for _, pid := range group.Peers { + peer, ok := a.Peers[pid] + if !ok || peer == nil { + continue + } + + if _, ok := validatedPeersMap[peer.ID]; !ok { + continue + } + + isValid, pname := a.validatePostureChecksOnPeerGetFailed(ctx, sourcePostureChecksIDs, peer.ID) + if !isValid && len(pname) > 0 { + if _, ok := (*postureFailedPeers)[pname]; !ok { + (*postureFailedPeers)[pname] = make(map[string]struct{}) + } + (*postureFailedPeers)[pname][peer.ID] = struct{}{} + continue + } + + if peer.ID == peerID { + peerInGroups = true + continue + } + + filteredPeerIDs = append(filteredPeerIDs, peer.ID) + } + return filteredPeerIDs, peerInGroups + } + + for _, pid := range group.Peers { + if _, seen := seenPeerIds[pid]; seen { + continue + } + seenPeerIds[pid] = struct{}{} + peer, ok := a.Peers[pid] + if !ok || peer == nil { + continue + } + + if _, ok := validatedPeersMap[peer.ID]; !ok { + continue + } + + isValid, pname := a.validatePostureChecksOnPeerGetFailed(ctx, sourcePostureChecksIDs, peer.ID) + if !isValid && len(pname) > 0 { + if _, ok := (*postureFailedPeers)[pname]; !ok { + (*postureFailedPeers)[pname] = make(map[string]struct{}) + } + (*postureFailedPeers)[pname][peer.ID] = struct{}{} + continue + } + + if peer.ID == peerID { + peerInGroups = true + continue + } + + filteredPeerIDs = append(filteredPeerIDs, peer.ID) + } + } + + return filteredPeerIDs, peerInGroups +} + +func (a *Account) validatePostureChecksOnPeerGetFailed(ctx context.Context, sourcePostureChecksID []string, peerID string) (bool, string) { + peer, ok := a.Peers[peerID] + if !ok || peer == nil { + return false, "" + } + + for _, postureChecksID := range sourcePostureChecksID { + postureChecks := a.GetPostureChecks(postureChecksID) + if postureChecks == nil { + continue + } + + for _, check := range postureChecks.GetChecks() { + isValid, _ := check.Check(ctx, *peer) + if !isValid { + return false, postureChecksID + } + } + } + return true, "" +} + +func (a *Account) getPostureValidPeersSaveFailed(inputPeers []string, postureChecksIDs []string, validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) []string { + var dest []string + for _, peerID := range inputPeers { + if _, validated := validatedPeersMap[peerID]; !validated { + continue + } + valid, pname := a.validatePostureChecksOnPeerGetFailed(context.Background(), postureChecksIDs, peerID) + if valid { + dest = append(dest, peerID) + continue + } + if _, ok := (*postureFailedPeers)[pname]; !ok { + (*postureFailedPeers)[pname] = make(map[string]struct{}) + } + (*postureFailedPeers)[pname][peerID] = struct{}{} + } + return dest +} + +// filterGroupPeers trims each group's Peers slice to only those peers that +// also appear in `peers`. Groups whose filtered list is empty are NOT +// deleted from the map — they're kept so the components wire encoder can +// still resolve seq references from routes/policies/access-control groups +// that name them. Calculate() tolerates groups with empty Peers (the inner +// loops simply iterate zero times), so retaining them is behaviourally a +// no-op for the legacy path that consumes the same NetworkMapComponents. +func filterGroupPeers(groups *map[string]*ComponentGroup, peers map[string]*ComponentPeer) { + for groupID, groupInfo := range *groups { + filteredPeers := make([]string, 0, len(groupInfo.Peers)) + for _, pid := range groupInfo.Peers { + if _, exists := peers[pid]; exists { + filteredPeers = append(filteredPeers, pid) + } + } + + if len(filteredPeers) != len(groupInfo.Peers) { + ng := *groupInfo + ng.Peers = filteredPeers + (*groups)[groupID] = &ng + } + } +} + +func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*Policy, resourcePoliciesMap map[string][]*Policy, peers map[string]*ComponentPeer) { + if len(*postureFailedPeers) == 0 { + return + } + + referencedPostureChecks := make(map[string]struct{}) + for _, policy := range policies { + for _, checkID := range policy.SourcePostureChecks { + referencedPostureChecks[checkID] = struct{}{} + } + } + for _, resPolicies := range resourcePoliciesMap { + for _, policy := range resPolicies { + for _, checkID := range policy.SourcePostureChecks { + referencedPostureChecks[checkID] = struct{}{} + } + } + } + + for checkID, failedPeers := range *postureFailedPeers { + if _, referenced := referencedPostureChecks[checkID]; !referenced { + delete(*postureFailedPeers, checkID) + continue + } + for peerID := range failedPeers { + if _, exists := peers[peerID]; !exists { + delete(failedPeers, peerID) + } + } + if len(failedPeers) == 0 { + delete(*postureFailedPeers, checkID) + } + } +} + +func filterDNSRecordsByPeers(records []nbdns.SimpleRecord, peers map[string]*ComponentPeer, includeIPv6 bool) []nbdns.SimpleRecord { + if len(records) == 0 || len(peers) == 0 { + return nil + } + + // Include both v4 and v6 addresses so AAAA records (whose RData is an IPv6 + // address) are not filtered out when peers have IPv6 assigned. When the + // requesting peer doesn't have IPv6, omit v6 IPs so AAAA records get dropped. + peerIPs := make(map[string]struct{}, len(peers)*2) + for _, peer := range peers { + if peer == nil { + continue + } + peerIPs[peer.IP.String()] = struct{}{} + if includeIPv6 && peer.IPv6.IsValid() { + peerIPs[peer.IPv6.String()] = struct{}{} + } + } + + filteredRecords := make([]nbdns.SimpleRecord, 0, len(records)) + for _, record := range records { + if _, exists := peerIPs[record.RData]; exists { + filteredRecords = append(filteredRecords, record) + } + } + + return filteredRecords +} + +func filterGroupIDToUserIDs(fullMap map[string][]string, neededGroupIDs map[string]struct{}) map[string][]string { + if len(neededGroupIDs) == 0 { + return nil + } + + filtered := make(map[string][]string, len(neededGroupIDs)) + for groupID := range neededGroupIDs { + if users, ok := fullMap[groupID]; ok { + filtered[groupID] = users + } + } + return filtered +} diff --git a/management/server/types/account_private_netmap_test.go b/management/server/types/account_private_netmap_test.go new file mode 100644 index 000000000..11b3d985a --- /dev/null +++ b/management/server/types/account_private_netmap_test.go @@ -0,0 +1,84 @@ +package types + +import ( + "context" + "testing" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbdns "github.com/netbirdio/netbird/dns" +) + +func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) { + account := privateZoneTestAccount(t) + account.Peers["user-peer"].Meta.WtVersion = "0.50.0" + account.Peers["proxy-peer"].Meta.WtVersion = "0.50.0" + + ctx := context.Background() + account.InjectProxyPolicies(ctx) + + validated := map[string]struct{}{ + "user-peer": {}, + "proxy-peer": {}, + } + + t.Run("user-peer update", func(t *testing.T) { + nm := account.GetPeerNetworkMapFromComponents(ctx, "user-peer", nbdns.CustomZone{}, nil, validated, nil, nil, nil, nil) + require.NotNil(t, nm) + + zone, ok := findCustomZone(nm.DNSConfig.CustomZones, "eu.proxy.netbird.io") + require.True(t, ok) + require.Len(t, zone.Records, 1) + assert.Equal(t, "myapp.eu.proxy.netbird.io.", zone.Records[0].Name) + assert.Equal(t, int(dns.TypeA), zone.Records[0].Type) + assert.Equal(t, "100.64.0.99", zone.Records[0].RData) + + assert.Contains(t, netmapPeerIDs(nm.Peers), "proxy-peer") + assertPrivateServiceFirewallRules(t, nm.FirewallRules, "100.64.0.99", FirewallRuleDirectionOUT) + }) + + t.Run("proxy-peer update", func(t *testing.T) { + nm := account.GetPeerNetworkMapFromComponents(ctx, "proxy-peer", nbdns.CustomZone{}, nil, validated, nil, nil, nil, nil) + require.NotNil(t, nm) + + assert.Contains(t, netmapPeerIDs(nm.Peers), "user-peer") + assertPrivateServiceFirewallRules(t, nm.FirewallRules, "100.64.0.10", FirewallRuleDirectionIN) + }) +} + +func netmapPeerIDs(peers []*ComponentPeer) []string { + ids := make([]string, 0, len(peers)) + for _, p := range peers { + if p == nil { + continue + } + ids = append(ids, p.ID) + } + return ids +} + +func assertPrivateServiceFirewallRules(t *testing.T, rules []*FirewallRule, peerIP string, direction int) { + t.Helper() + wantPorts := map[uint16]bool{80: false, 443: false} + for _, r := range rules { + if r == nil || r.PeerIP != peerIP || r.Direction != direction { + continue + } + if r.Protocol != string(PolicyRuleProtocolTCP) || r.Action != string(PolicyTrafficActionAccept) { + continue + } + switch { + case r.PortRange.Start == r.PortRange.End && r.PortRange.Start != 0: + wantPorts[r.PortRange.Start] = true + case r.Port == "80": + wantPorts[80] = true + case r.Port == "443": + wantPorts[443] = true + } + } + for port, found := range wantPorts { + assert.Truef(t, found, "missing TCP accept rule on port %d for peer %s direction %d", port, peerIP, direction) + } +} diff --git a/management/server/types/account_private_zones_test.go b/management/server/types/account_private_zones_test.go new file mode 100644 index 000000000..efbbbffaf --- /dev/null +++ b/management/server/types/account_private_zones_test.go @@ -0,0 +1,433 @@ +package types + +import ( + "context" + "net" + "net/netip" + "testing" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbdns "github.com/netbirdio/netbird/dns" + proxydomain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + nbpeer "github.com/netbirdio/netbird/management/server/peer" +) + +func privateZoneTestAccount(t *testing.T) *Account { + t.Helper() + return &Account{ + Id: "acct-1", + Settings: &Settings{}, + Network: &Network{ + Identifier: "net-1", + Net: net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)}, + }, + Peers: map[string]*nbpeer.Peer{ + "user-peer": { + ID: "user-peer", + AccountID: "acct-1", + Key: "user-peer-key", + IP: netip.MustParseAddr("100.64.0.10"), + Status: &nbpeer.PeerStatus{Connected: true}, + }, + "proxy-peer": { + ID: "proxy-peer", + AccountID: "acct-1", + Key: "proxy-peer-key", + IP: netip.MustParseAddr("100.64.0.99"), + Status: &nbpeer.PeerStatus{Connected: true}, + ProxyMeta: nbpeer.ProxyMeta{ + Embedded: true, + Cluster: "eu.proxy.netbird.io", + }, + }, + }, + Groups: map[string]*Group{ + "grp-admins": { + ID: "grp-admins", + Name: "admins", + Peers: []string{"user-peer"}, + }, + }, + Services: []*service.Service{ + { + ID: "svc-1", + AccountID: "acct-1", + Name: "myapp", + Domain: "myapp.eu.proxy.netbird.io", + ProxyCluster: "eu.proxy.netbird.io", + Enabled: true, + Private: true, + Mode: service.ModeHTTP, + AccessGroups: []string{"grp-admins"}, + }, + }, + } +} + +func TestSynthesizePrivateServiceZones_PeerInGroup_GetsRecord(t *testing.T) { + account := privateZoneTestAccount(t) + + zones := account.SynthesizePrivateServiceZones("user-peer") + require.Len(t, zones, 1, "one cluster should produce one zone") + zone := zones[0] + assert.Equal(t, "eu.proxy.netbird.io.", zone.Domain, "zone apex must be the cluster FQDN") + assert.True(t, zone.NonAuthoritative, "synth zone must be match-only so unrelated sibling names fall through to the upstream resolver") + require.Len(t, zone.Records, 1, "one private service yields one A record") + rec := zone.Records[0] + assert.Equal(t, "myapp.eu.proxy.netbird.io.", rec.Name, "record name is the service FQDN") + assert.Equal(t, int(dns.TypeA), rec.Type, "record type must be A") + assert.Equal(t, "100.64.0.99", rec.RData, "record points at the embedded proxy peer's tunnel IP") + assert.Equal(t, privateServiceDNSRecordTTL, rec.TTL, "TTL must match the synth-records constant") + assert.Equal(t, nbdns.DefaultClass, rec.Class, "record class must be the package default") +} + +func TestSynthesizePrivateServiceZones_PeerNotInGroup_NoRecord(t *testing.T) { + account := privateZoneTestAccount(t) + account.Groups["grp-admins"].Peers = nil + + zones := account.SynthesizePrivateServiceZones("user-peer") + assert.Empty(t, zones, "peer outside distribution_groups must not see private-service records") +} + +func TestSynthesizePrivateServiceZones_NotPrivate_NoRecord(t *testing.T) { + account := privateZoneTestAccount(t) + account.Services[0].Private = false + + zones := account.SynthesizePrivateServiceZones("user-peer") + assert.Empty(t, zones, "non-private service must not produce DNS records") +} + +func TestSynthesizePrivateServiceZones_NoAccessGroups_NoRecord(t *testing.T) { + account := privateZoneTestAccount(t) + account.Services[0].AccessGroups = nil + + zones := account.SynthesizePrivateServiceZones("user-peer") + assert.Empty(t, zones, "private service without bearer auth must not produce DNS records") +} + +func TestSynthesizePrivateServiceZones_NoProxyPeers_NoRecord(t *testing.T) { + account := privateZoneTestAccount(t) + delete(account.Peers, "proxy-peer") + + zones := account.SynthesizePrivateServiceZones("user-peer") + assert.Empty(t, zones, "no embedded proxy peer in cluster means no record to emit") +} + +func TestSynthesizePrivateServiceZones_DisabledService_NoRecord(t *testing.T) { + account := privateZoneTestAccount(t) + account.Services[0].Enabled = false + + zones := account.SynthesizePrivateServiceZones("user-peer") + assert.Empty(t, zones, "disabled service must not produce DNS records") +} + +func TestSynthesizePrivateServiceZones_DisconnectedProxyPeer_NoRecord(t *testing.T) { + account := privateZoneTestAccount(t) + account.Peers["proxy-peer"].Status = &nbpeer.PeerStatus{Connected: false} + + zones := account.SynthesizePrivateServiceZones("user-peer") + assert.Empty(t, zones, "disconnected proxy peer must not produce a DNS record (would be a black hole)") +} + +func TestSynthesizePrivateServiceZones_PartiallyDisconnectedProxyPeers_OnlyConnectedSurface(t *testing.T) { + account := privateZoneTestAccount(t) + account.Peers["proxy-peer-2"] = &nbpeer.Peer{ + ID: "proxy-peer-2", + AccountID: "acct-1", + Key: "proxy-peer-2-key", + IP: netip.MustParseAddr("100.64.0.100"), + Status: &nbpeer.PeerStatus{Connected: false}, + ProxyMeta: nbpeer.ProxyMeta{Embedded: true, Cluster: "eu.proxy.netbird.io"}, + } + + zones := account.SynthesizePrivateServiceZones("user-peer") + require.Len(t, zones, 1) + require.Len(t, zones[0].Records, 1, "only the connected proxy peer must surface") + assert.Equal(t, "100.64.0.99", zones[0].Records[0].RData) +} + +func TestSynthesizePrivateServiceZones_MultipleProxyPeers_RoundRobin(t *testing.T) { + account := privateZoneTestAccount(t) + account.Peers["proxy-peer-2"] = &nbpeer.Peer{ + ID: "proxy-peer-2", + AccountID: "acct-1", + Key: "proxy-peer-2-key", + IP: netip.MustParseAddr("100.64.0.100"), + Status: &nbpeer.PeerStatus{Connected: true}, + ProxyMeta: nbpeer.ProxyMeta{Embedded: true, Cluster: "eu.proxy.netbird.io"}, + } + + zones := account.SynthesizePrivateServiceZones("user-peer") + require.Len(t, zones, 1, "still one cluster yields one zone") + require.Len(t, zones[0].Records, 2, "two proxy peers must produce two A records on the same name") + rdata := []string{zones[0].Records[0].RData, zones[0].Records[1].RData} + assert.ElementsMatch(t, []string{"100.64.0.99", "100.64.0.100"}, rdata, "both proxy peer IPs must surface") +} + +// findCustomZone returns the CustomZone whose Domain equals the FQDN +// of want, or a zero value when not found. Tests use it to assert +// that the synth zone reaches dnsUpdate.CustomZones end-to-end. +func findCustomZone(zones []nbdns.CustomZone, want string) (nbdns.CustomZone, bool) { + wantFqdn := dns.Fqdn(want) + for _, z := range zones { + if z.Domain == wantFqdn { + return z, true + } + } + return nbdns.CustomZone{}, false +} + +// TestPrivateZone_GetPeerNetworkMapFromComponents_ShipsSynthZone +// covers the components-based builder path. The components builder +// appends SynthesizePrivateServiceZones to AccountZones; the +// CalculateNetworkMapFromComponents step then merges AccountZones +// into dnsUpdate.CustomZones. +func TestPrivateZone_GetPeerNetworkMapFromComponents_ShipsSynthZone(t *testing.T) { + account := privateZoneTestAccount(t) + ctx := context.Background() + validated := map[string]struct{}{ + "user-peer": {}, + "proxy-peer": {}, + } + + nm := account.GetPeerNetworkMapFromComponents(ctx, "user-peer", nbdns.CustomZone{}, nil, validated, nil, nil, nil, nil) + require.NotNil(t, nm, "network map must be produced for an in-account peer") + + zone, ok := findCustomZone(nm.DNSConfig.CustomZones, "eu.proxy.netbird.io") + require.True(t, ok, "shipped CustomZones must include the synth zone for the cluster") + require.Len(t, zone.Records, 1, "exactly one record per private service per connected proxy peer") + rec := zone.Records[0] + assert.Equal(t, "myapp.eu.proxy.netbird.io.", rec.Name, "record name is the service FQDN") + assert.Equal(t, "100.64.0.99", rec.RData, "record points at the embedded proxy peer's tunnel IP") +} + +// TestPrivateZone_GetPeerNetworkMap_PeerOutsideGroups_OmitsSynthZone +// confirms the negative case the user encountered: a peer whose +// groups don't overlap the policy's distribution_groups gets a +// network map with no synth zone (and the wildcard / peer zones still +// flow through). This is the test mirror of the runtime confusion +// where the user looked at a non-distribution-group peer and assumed +// the synth path was broken. +func TestPrivateZone_GetPeerNetworkMap_PeerOutsideGroups_OmitsSynthZone(t *testing.T) { + account := privateZoneTestAccount(t) + account.Peers["outsider"] = &nbpeer.Peer{ + ID: "outsider", + AccountID: "acct-1", + Key: "outsider-key", + IP: netip.MustParseAddr("100.64.0.20"), + Status: &nbpeer.PeerStatus{Connected: true}, + } + ctx := context.Background() + validated := map[string]struct{}{ + "user-peer": {}, + "proxy-peer": {}, + "outsider": {}, + } + + nm := account.GetPeerNetworkMapFromComponents(ctx, "outsider", nbdns.CustomZone{}, nil, validated, nil, nil, nil, nil) + require.NotNil(t, nm) + + _, ok := findCustomZone(nm.DNSConfig.CustomZones, "eu.proxy.netbird.io") + assert.False(t, ok, "peer outside the distribution_groups must not see the synth zone") +} + +func TestSynthesizePrivateServiceZones_CustomDomain_ZoneApexIsRegisteredDomain(t *testing.T) { + account := privateZoneTestAccount(t) + // A custom-domain service: Domain is the custom FQDN, ProxyCluster + // is the cluster serving it, and account.Domains holds the registered + // custom domain. The synth zone apex must be the registered domain, + // not the cluster, or the client's match-only zone never intercepts + // the query. + account.Services[0].Domain = "app.example.com" + account.Domains = []*proxydomain.Domain{ + {Domain: "example.com", AccountID: "acct-1", TargetCluster: "eu.proxy.netbird.io", Validated: true}, + } + + zones := account.SynthesizePrivateServiceZones("user-peer") + require.Len(t, zones, 1, "custom-domain service must still produce one zone") + zone := zones[0] + assert.Equal(t, "example.com.", zone.Domain, "zone apex must be the registered custom domain, not the cluster or the service FQDN") + assert.True(t, zone.NonAuthoritative, "synth zone must remain match-only") + require.Len(t, zone.Records, 1, "custom-domain service yields one A record") + rec := zone.Records[0] + assert.Equal(t, "app.example.com.", rec.Name, "record name is the custom service FQDN") + assert.Equal(t, "100.64.0.99", rec.RData, "record points at the embedded proxy peer's tunnel IP") +} + +func TestSynthesizePrivateServiceZones_CustomAndFreeDomain_SeparateZones(t *testing.T) { + account := privateZoneTestAccount(t) + account.Domains = []*proxydomain.Domain{ + {Domain: "example.com", AccountID: "acct-1", TargetCluster: "eu.proxy.netbird.io", Validated: true}, + } + account.Services = append(account.Services, &service.Service{ + ID: "svc-2", + AccountID: "acct-1", + Name: "custom", + Domain: "app.example.com", + ProxyCluster: "eu.proxy.netbird.io", + Enabled: true, + Private: true, + Mode: service.ModeHTTP, + AccessGroups: []string{"grp-admins"}, + }) + + zones := account.SynthesizePrivateServiceZones("user-peer") + require.Len(t, zones, 2, "a free-domain and a custom-domain service must not collapse into one zone") + + free, ok := findCustomZone(zones, "eu.proxy.netbird.io") + require.True(t, ok, "free-domain service keeps the shared cluster-apex zone") + require.Len(t, free.Records, 1, "cluster zone carries only the free-domain record") + assert.Equal(t, "myapp.eu.proxy.netbird.io.", free.Records[0].Name, "cluster zone record is the free-domain FQDN") + + custom, ok := findCustomZone(zones, "example.com") + require.True(t, ok, "custom-domain service gets its own zone at the registered custom domain apex") + require.Len(t, custom.Records, 1, "custom zone carries only the custom-domain record") + assert.Equal(t, "app.example.com.", custom.Records[0].Name, "custom zone record is the custom-domain FQDN") +} + +func TestSynthesizePrivateServiceZones_TwoServicesSameCustomDomain_OneZone(t *testing.T) { + account := privateZoneTestAccount(t) + account.Domains = []*proxydomain.Domain{ + {Domain: "example.com", AccountID: "acct-1", TargetCluster: "eu.proxy.netbird.io", Validated: true}, + } + account.Services[0].Domain = "a.example.com" + account.Services = append(account.Services, &service.Service{ + ID: "svc-2", + AccountID: "acct-1", + Name: "bapp", + Domain: "b.example.com", + ProxyCluster: "eu.proxy.netbird.io", + Enabled: true, + Private: true, + Mode: service.ModeHTTP, + AccessGroups: []string{"grp-admins"}, + }) + + zones := account.SynthesizePrivateServiceZones("user-peer") + require.Len(t, zones, 1, "two services under the same registered custom domain must share one zone") + assert.Equal(t, "example.com.", zones[0].Domain, "shared zone apex is the registered custom domain") + require.Len(t, zones[0].Records, 2, "both services surface as records in the shared custom-domain zone") + names := []string{zones[0].Records[0].Name, zones[0].Records[1].Name} + assert.ElementsMatch(t, []string{"a.example.com.", "b.example.com."}, names, "both custom-domain service FQDNs must surface") +} + +func TestSynthesizePrivateServiceZones_CustomDomainNotRegistered_NoZone(t *testing.T) { + account := privateZoneTestAccount(t) + // Service domain is outside the cluster and no account.Domains entry + // covers it: there is no apex that would intercept the query, so the + // service must be skipped rather than emit an unmatchable record. + account.Services[0].Domain = "app.example.com" + + zones := account.SynthesizePrivateServiceZones("user-peer") + assert.Empty(t, zones, "a custom-domain service with no registered domain apex must not produce a zone") +} + +func TestSynthesizePrivateServiceZones_CustomDomainClusterMismatch_NoZone(t *testing.T) { + account := privateZoneTestAccount(t) + // The registered custom domain matches the service FQDN by suffix but + // targets a different cluster than the service's ProxyCluster. It must + // be ignored, leaving no apex to intercept the query — otherwise the + // zone would point at this cluster's proxy peers under a domain owned + // by a different cluster. + account.Services[0].Domain = "app.example.com" + account.Domains = []*proxydomain.Domain{ + {Domain: "example.com", AccountID: "acct-1", TargetCluster: "us.proxy.netbird.io", Validated: true}, + } + + zones := account.SynthesizePrivateServiceZones("user-peer") + assert.Empty(t, zones, "a custom domain targeting a different cluster must not anchor the service zone") +} + +func TestSynthesizePrivateServiceZones_TwoServicesSameCluster_OneZone(t *testing.T) { + account := privateZoneTestAccount(t) + account.Services = append(account.Services, &service.Service{ + ID: "svc-2", + AccountID: "acct-1", + Name: "anotherapp", + Domain: "anotherapp.eu.proxy.netbird.io", + ProxyCluster: "eu.proxy.netbird.io", + Enabled: true, + Private: true, + Mode: service.ModeHTTP, + AccessGroups: []string{"grp-admins"}, + }) + + zones := account.SynthesizePrivateServiceZones("user-peer") + require.Len(t, zones, 1, "two services on the same cluster must collapse into one zone") + require.Len(t, zones[0].Records, 2, "two services yield two A records") + names := []string{zones[0].Records[0].Name, zones[0].Records[1].Name} + assert.ElementsMatch(t, []string{"myapp.eu.proxy.netbird.io.", "anotherapp.eu.proxy.netbird.io."}, names, "both service domains must surface") +} + +func TestSynthesizePrivateServiceZones_MixedClusterCustomAndPublic(t *testing.T) { + account := privateZoneTestAccount(t) + account.Domains = []*proxydomain.Domain{ + {Domain: "example.com", AccountID: "acct-1", TargetCluster: "eu.proxy.netbird.io", Validated: true}, + } + + privateService := func(id, domain string) *service.Service { + return &service.Service{ + ID: id, + AccountID: "acct-1", + Name: id, + Domain: domain, + ProxyCluster: "eu.proxy.netbird.io", + Enabled: true, + Private: true, + Mode: service.ModeHTTP, + AccessGroups: []string{"grp-admins"}, + } + } + publicService := func(id, domain string) *service.Service { + s := privateService(id, domain) + s.Private = false + return s + } + + account.Services = []*service.Service{ + // 3 private services under the cluster suffix. + privateService("cluster-1", "cluster1.eu.proxy.netbird.io"), + privateService("cluster-2", "cluster2.eu.proxy.netbird.io"), + privateService("cluster-3", "cluster3.eu.proxy.netbird.io"), + // 4 private services under the custom domain suffix. + privateService("custom-1", "custom1.example.com"), + privateService("custom-2", "custom2.example.com"), + privateService("custom-3", "custom3.example.com"), + privateService("custom-4", "custom4.example.com"), + // 2 public services, one per suffix, must not surface. + publicService("public-cluster", "public.eu.proxy.netbird.io"), + publicService("public-custom", "public.example.com"), + } + + zones := account.SynthesizePrivateServiceZones("user-peer") + require.Len(t, zones, 2, "one zone per apex: the cluster apex and the custom domain apex") + + cluster, ok := findCustomZone(zones, "eu.proxy.netbird.io") + require.True(t, ok, "cluster-suffix services collapse into the cluster-apex zone") + clusterNames := recordNames(cluster) + assert.ElementsMatch(t, + []string{"cluster1.eu.proxy.netbird.io.", "cluster2.eu.proxy.netbird.io.", "cluster3.eu.proxy.netbird.io."}, + clusterNames, + "only the 3 private cluster services surface in the cluster zone (public one excluded)") + + custom, ok := findCustomZone(zones, "example.com") + require.True(t, ok, "custom-suffix services collapse into the custom-domain-apex zone") + customNames := recordNames(custom) + assert.ElementsMatch(t, + []string{"custom1.example.com.", "custom2.example.com.", "custom3.example.com.", "custom4.example.com."}, + customNames, + "only the 4 private custom services surface in the custom zone (public one excluded)") +} + +// recordNames returns the record names of a zone for order-independent assertions. +func recordNames(zone nbdns.CustomZone) []string { + names := make([]string, 0, len(zone.Records)) + for _, r := range zone.Records { + names = append(names, r.Name) + } + return names +} diff --git a/management/server/types/account_test.go b/management/server/types/account_test.go index 32538933a..67d9e1c6f 100644 --- a/management/server/types/account_test.go +++ b/management/server/types/account_test.go @@ -5,7 +5,6 @@ import ( "fmt" "net" "net/netip" - "slices" "testing" "github.com/miekg/dns" @@ -13,11 +12,13 @@ import ( "github.com/stretchr/testify/require" nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/internals/modules/zones" + "github.com/netbirdio/netbird/management/internals/modules/zones/records" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" networkTypes "github.com/netbirdio/netbird/management/server/networks/types" nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/route" ) @@ -82,6 +83,12 @@ func setupTestAccount() *Account { }, }, Groups: map[string]*Group{ + "groupAll": { + ID: "groupAll", + Name: "All", + Peers: []string{"peer1", "peer2", "peer3", "peer11", "peer12", "peer21", "peer31", "peer32", "peer41", "peer51", "peer61"}, + Issued: GroupIssuedAPI, + }, "group1": { ID: "group1", Peers: []string{"peer11", "peer12"}, @@ -443,400 +450,423 @@ func Test_AddNetworksRoutingPeersHandlesNoMissingPeers(t *testing.T) { require.Len(t, result, 0) } -const ( - accID = "accountID" - network1ID = "network1ID" - group1ID = "group1" - accNetResourcePeer1ID = "peer1" - accNetResourcePeer2ID = "peer2" - accNetResourceRouter1ID = "router1" - accNetResource1ID = "resource1ID" - accNetResourceRestrictPostureCheckID = "restrictPostureCheck" - accNetResourceRelaxedPostureCheckID = "relaxedPostureCheck" - accNetResourceLockedPostureCheckID = "lockedPostureCheck" - accNetResourceLinuxPostureCheckID = "linuxPostureCheck" -) - -var ( - accNetResourcePeer1IP = net.IP{192, 168, 1, 1} - accNetResourcePeer2IP = net.IP{192, 168, 1, 2} - accNetResourceRouter1IP = net.IP{192, 168, 1, 3} - accNetResourceValidPeers = map[string]struct{}{accNetResourcePeer1ID: {}, accNetResourcePeer2ID: {}} -) - -func getBasicAccountsWithResource() *Account { - return &Account{ - Id: accID, - Peers: map[string]*nbpeer.Peer{ - accNetResourcePeer1ID: { - ID: accNetResourcePeer1ID, - AccountID: accID, - Key: "peer1Key", - IP: accNetResourcePeer1IP, +func Test_ExpandPortsAndRanges_SSHRuleExpansion(t *testing.T) { + tests := []struct { + name string + peer *nbpeer.Peer + rule *PolicyRule + base FirewallRule + expectedPorts []string + }{ + { + name: "adds port 22022 when SSH enabled on modern peer with port 22", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, Meta: nbpeer.PeerSystemMeta{ - GoOS: "linux", - WtVersion: "0.35.1", - KernelVersion: "4.4.0", + WtVersion: "0.60.0", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, }, }, - accNetResourcePeer2ID: { - ID: accNetResourcePeer2ID, - AccountID: accID, - Key: "peer2Key", - IP: accNetResourcePeer2IP, - Meta: nbpeer.PeerSystemMeta{ - GoOS: "windows", - WtVersion: "0.34.1", - KernelVersion: "4.4.0", - }, - }, - accNetResourceRouter1ID: { - ID: accNetResourceRouter1ID, - AccountID: accID, - Key: "router1Key", - IP: accNetResourceRouter1IP, - Meta: nbpeer.PeerSystemMeta{ - GoOS: "linux", - WtVersion: "0.35.1", - KernelVersion: "4.4.0", - }, - }, - }, - Groups: map[string]*Group{ - group1ID: { - ID: group1ID, - Peers: []string{accNetResourcePeer1ID, accNetResourcePeer2ID}, - }, - }, - Networks: []*networkTypes.Network{ - { - ID: network1ID, - AccountID: accID, - Name: "network1", - }, - }, - NetworkRouters: []*routerTypes.NetworkRouter{ - { - ID: accNetResourceRouter1ID, - NetworkID: network1ID, - AccountID: accID, - Peer: accNetResourceRouter1ID, - PeerGroups: []string{}, - Masquerade: false, - Metric: 100, - Enabled: true, - }, - }, - NetworkResources: []*resourceTypes.NetworkResource{ - { - ID: accNetResource1ID, - AccountID: accID, - NetworkID: network1ID, - Address: "10.10.10.0/24", - Prefix: netip.MustParsePrefix("10.10.10.0/24"), - Type: resourceTypes.NetworkResourceType("subnet"), - Enabled: true, - }, - }, - Policies: []*Policy{ - { - ID: "policy1ID", - AccountID: accID, - Enabled: true, - Rules: []*PolicyRule{ - { - ID: "rule1ID", - Enabled: true, - Sources: []string{group1ID}, - DestinationResource: Resource{ - ID: accNetResource1ID, - Type: "Host", - }, - Protocol: PolicyRuleProtocolTCP, - Ports: []string{"80"}, - Action: PolicyTrafficActionAccept, - }, - }, - SourcePostureChecks: nil, - }, - }, - PostureChecks: []*posture.Checks{ - { - ID: accNetResourceRestrictPostureCheckID, - Name: accNetResourceRestrictPostureCheckID, - Checks: posture.ChecksDefinition{ - NBVersionCheck: &posture.NBVersionCheck{ - MinVersion: "0.35.0", - }, - }, - }, - { - ID: accNetResourceRelaxedPostureCheckID, - Name: accNetResourceRelaxedPostureCheckID, - Checks: posture.ChecksDefinition{ - NBVersionCheck: &posture.NBVersionCheck{ - MinVersion: "0.0.1", - }, - }, - }, - { - ID: accNetResourceLockedPostureCheckID, - Name: accNetResourceLockedPostureCheckID, - Checks: posture.ChecksDefinition{ - NBVersionCheck: &posture.NBVersionCheck{ - MinVersion: "7.7.7", - }, - }, - }, - { - ID: accNetResourceLinuxPostureCheckID, - Name: accNetResourceLinuxPostureCheckID, - Checks: posture.ChecksDefinition{ - OSVersionCheck: &posture.OSVersionCheck{ - Linux: &posture.MinKernelVersionCheck{ - MinKernelVersion: "0.0.0"}, - }, - }, - }, - }, - } -} - -func Test_NetworksNetMapGenWithNoPostureChecks(t *testing.T) { - account := getBasicAccountsWithResource() - - // all peers should match the policy - - // validate for peer1 - isRouter, networkResourcesRoutes, sourcePeers := account.GetNetworkResourcesRoutesToSync(context.Background(), accNetResourcePeer1ID, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap()) - assert.False(t, isRouter, "expected router status") - assert.Len(t, networkResourcesRoutes, 1, "expected network resource route don't match") - assert.Len(t, sourcePeers, 0, "expected source peers don't match") - - // validate for peer2 - isRouter, networkResourcesRoutes, sourcePeers = account.GetNetworkResourcesRoutesToSync(context.Background(), accNetResourcePeer2ID, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap()) - assert.False(t, isRouter, "expected router status") - assert.Len(t, networkResourcesRoutes, 1, "expected network resource route don't match") - assert.Len(t, sourcePeers, 0, "expected source peers don't match") - - // validate routes for router1 - isRouter, networkResourcesRoutes, sourcePeers = account.GetNetworkResourcesRoutesToSync(context.Background(), accNetResourceRouter1ID, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap()) - assert.True(t, isRouter, "should be router") - assert.Len(t, networkResourcesRoutes, 1, "expected network resource route don't match") - assert.Len(t, sourcePeers, 2, "expected source peers don't match") - assert.NotNil(t, sourcePeers[accNetResourcePeer1ID], "expected source peers don't match") - assert.NotNil(t, sourcePeers[accNetResourcePeer2ID], "expected source peers don't match") - - // validate rules for router1 - rules := account.GetPeerNetworkResourceFirewallRules(context.Background(), account.Peers[accNetResourceRouter1ID], accNetResourceValidPeers, networkResourcesRoutes, account.GetResourcePoliciesMap()) - assert.Len(t, rules, 1, "expected rules count don't match") - assert.Equal(t, uint16(80), rules[0].Port, "should have port 80") - assert.Equal(t, "tcp", rules[0].Protocol, "should have protocol tcp") - if !slices.Contains(rules[0].SourceRanges, accNetResourcePeer1IP.String()+"/32") { - t.Errorf("%s should have source range of peer1 %s", rules[0].SourceRanges, accNetResourcePeer1IP.String()) - } - if !slices.Contains(rules[0].SourceRanges, accNetResourcePeer2IP.String()+"/32") { - t.Errorf("%s should have source range of peer2 %s", rules[0].SourceRanges, accNetResourcePeer2IP.String()) - } -} - -func Test_NetworksNetMapGenWithPostureChecks(t *testing.T) { - account := getBasicAccountsWithResource() - - // should allow peer1 to match the policy - policy := account.Policies[0] - policy.SourcePostureChecks = []string{accNetResourceRestrictPostureCheckID} - - // validate for peer1 - isRouter, networkResourcesRoutes, sourcePeers := account.GetNetworkResourcesRoutesToSync(context.Background(), accNetResourcePeer1ID, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap()) - assert.False(t, isRouter, "expected router status") - assert.Len(t, networkResourcesRoutes, 1, "expected network resource route don't match") - assert.Len(t, sourcePeers, 0, "expected source peers don't match") - - // validate for peer2 - isRouter, networkResourcesRoutes, sourcePeers = account.GetNetworkResourcesRoutesToSync(context.Background(), accNetResourcePeer2ID, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap()) - assert.False(t, isRouter, "expected router status") - assert.Len(t, networkResourcesRoutes, 0, "expected network resource route don't match") - assert.Len(t, sourcePeers, 0, "expected source peers don't match") - - // validate routes for router1 - isRouter, networkResourcesRoutes, sourcePeers = account.GetNetworkResourcesRoutesToSync(context.Background(), accNetResourceRouter1ID, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap()) - assert.True(t, isRouter, "should be router") - assert.Len(t, networkResourcesRoutes, 1, "expected network resource route don't match") - assert.Len(t, sourcePeers, 1, "expected source peers don't match") - assert.NotNil(t, sourcePeers[accNetResourcePeer1ID], "expected source peers don't match") - - // validate rules for router1 - rules := account.GetPeerNetworkResourceFirewallRules(context.Background(), account.Peers[accNetResourceRouter1ID], accNetResourceValidPeers, networkResourcesRoutes, account.GetResourcePoliciesMap()) - assert.Len(t, rules, 1, "expected rules count don't match") - assert.Equal(t, uint16(80), rules[0].Port, "should have port 80") - assert.Equal(t, "tcp", rules[0].Protocol, "should have protocol tcp") - if !slices.Contains(rules[0].SourceRanges, accNetResourcePeer1IP.String()+"/32") { - t.Errorf("%s should have source range of peer1 %s", rules[0].SourceRanges, accNetResourcePeer1IP.String()) - } - if slices.Contains(rules[0].SourceRanges, accNetResourcePeer2IP.String()+"/32") { - t.Errorf("%s should not have source range of peer2 %s", rules[0].SourceRanges, accNetResourcePeer2IP.String()) - } -} - -func Test_NetworksNetMapGenWithNoMatchedPostureChecks(t *testing.T) { - account := getBasicAccountsWithResource() - - // should not match any peer - policy := account.Policies[0] - policy.SourcePostureChecks = []string{accNetResourceLockedPostureCheckID} - - // validate for peer1 - isRouter, networkResourcesRoutes, sourcePeers := account.GetNetworkResourcesRoutesToSync(context.Background(), accNetResourcePeer1ID, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap()) - assert.False(t, isRouter, "expected router status") - assert.Len(t, networkResourcesRoutes, 0, "expected network resource route don't match") - assert.Len(t, sourcePeers, 0, "expected source peers don't match") - - // validate for peer2 - isRouter, networkResourcesRoutes, sourcePeers = account.GetNetworkResourcesRoutesToSync(context.Background(), accNetResourcePeer2ID, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap()) - assert.False(t, isRouter, "expected router status") - assert.Len(t, networkResourcesRoutes, 0, "expected network resource route don't match") - assert.Len(t, sourcePeers, 0, "expected source peers don't match") - - // validate routes for router1 - isRouter, networkResourcesRoutes, sourcePeers = account.GetNetworkResourcesRoutesToSync(context.Background(), accNetResourceRouter1ID, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap()) - assert.True(t, isRouter, "should be router") - assert.Len(t, networkResourcesRoutes, 1, "expected network resource route don't match") - assert.Len(t, sourcePeers, 0, "expected source peers don't match") - - // validate rules for router1 - rules := account.GetPeerNetworkResourceFirewallRules(context.Background(), account.Peers[accNetResourceRouter1ID], accNetResourceValidPeers, networkResourcesRoutes, account.GetResourcePoliciesMap()) - assert.Len(t, rules, 0, "expected rules count don't match") -} - -func Test_NetworksNetMapGenWithTwoPoliciesAndPostureChecks(t *testing.T) { - account := getBasicAccountsWithResource() - - // should allow peer1 to match the policy - policy := account.Policies[0] - policy.SourcePostureChecks = []string{accNetResourceRestrictPostureCheckID} - - // should allow peer1 and peer2 to match the policy - newPolicy := &Policy{ - ID: "policy2ID", - AccountID: accID, - Enabled: true, - Rules: []*PolicyRule{ - { - ID: "policy2ID", - Enabled: true, - Sources: []string{group1ID}, - DestinationResource: Resource{ - ID: accNetResource1ID, - Type: "Host", - }, + rule: &PolicyRule{ Protocol: PolicyRuleProtocolTCP, Ports: []string{"22"}, - Action: PolicyTrafficActionAccept, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"22", "22022"}, + }, + { + name: "adds port 22022 once when port 22 is duplicated within policy", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "0.60.0", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolTCP, + Ports: []string{"22", "80", "22"}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"22", "80", "22", "22022"}, + }, + { + name: "does not add 22022 for peer with old version", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "0.59.0", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolTCP, + Ports: []string{"22"}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"22"}, + }, + { + name: "does not add 22022 when SSHEnabled is false", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: false, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "0.60.0", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolTCP, + Ports: []string{"22"}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"22"}, + }, + { + name: "does not add 22022 when ServerSSHAllowed is false", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "0.60.0", + Flags: nbpeer.Flags{ServerSSHAllowed: false}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolTCP, + Ports: []string{"22"}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"22"}, + }, + { + name: "does not add 22022 for UDP protocol", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "0.60.0", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolUDP, + Ports: []string{"22"}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "udp"}, + expectedPorts: []string{"22"}, + }, + { + name: "does not add 22022 when port 22 not in rule", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "0.60.0", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolTCP, + Ports: []string{"80", "443"}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"80", "443"}, + }, + { + name: "does not duplicate 22022 when already present", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "0.60.0", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolTCP, + Ports: []string{"22", "22022"}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"22", "22022"}, + }, + { + name: "does not duplicate 22022 when already within a port range", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "0.60.0", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolTCP, + PortRanges: []RulePortRange{{Start: 20, End: 32000}}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"20-32000"}, + }, + { + name: "adds 22022 when port 22 in port range", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "0.60.0", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolTCP, + PortRanges: []RulePortRange{{Start: 20, End: 25}}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"20-25", "22022"}, + }, + { + name: "adds single 22022 once when port 22 in multiple port ranges", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "0.60.0", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolTCP, + PortRanges: []RulePortRange{{Start: 20, End: 25}, {Start: 10, End: 100}}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"20-25", "10-100", "22022"}, + }, + { + name: "development version supports all features", + peer: &nbpeer.Peer{ + ID: "peer1", + SSHEnabled: true, + Meta: nbpeer.PeerSystemMeta{ + WtVersion: "development", + Flags: nbpeer.Flags{ServerSSHAllowed: true}, + }, + }, + rule: &PolicyRule{ + Protocol: PolicyRuleProtocolTCP, + Ports: []string{"22"}, + }, + base: FirewallRule{PeerIP: "10.0.0.1", Direction: 0, Action: "accept", Protocol: "tcp"}, + expectedPorts: []string{"22", "22022"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExpandPortsAndRanges(tt.base, tt.rule, tt.peer.ToComponent()) + + var ports []string + for _, fr := range result { + if fr.Port != "" { + ports = append(ports, fr.Port) + } else if fr.PortRange.Start > 0 { + ports = append(ports, fmt.Sprintf("%d-%d", fr.PortRange.Start, fr.PortRange.End)) + } + } + + assert.Equal(t, tt.expectedPorts, ports, "expanded ports should match expected") + }) + } +} + +func Test_GetActiveGroupUsers(t *testing.T) { + tests := []struct { + name string + account *Account + expected map[string][]string + }{ + { + name: "all users are active", + account: &Account{ + Users: map[string]*User{ + "user1": { + Id: "user1", + AutoGroups: []string{"group1", "group2"}, + Blocked: false, + }, + "user2": { + Id: "user2", + AutoGroups: []string{"group2", "group3"}, + Blocked: false, + }, + "user3": { + Id: "user3", + AutoGroups: []string{"group1"}, + Blocked: false, + }, + }, + }, + expected: map[string][]string{ + "group1": {"user1", "user3"}, + "group2": {"user1", "user2"}, + "group3": {"user2"}, + "": {"user1", "user2", "user3"}, + }, + }, + { + name: "some users are blocked", + account: &Account{ + Users: map[string]*User{ + "user1": { + Id: "user1", + AutoGroups: []string{"group1", "group2"}, + Blocked: false, + }, + "user2": { + Id: "user2", + AutoGroups: []string{"group2", "group3"}, + Blocked: true, + }, + "user3": { + Id: "user3", + AutoGroups: []string{"group1", "group3"}, + Blocked: false, + }, + }, + }, + expected: map[string][]string{ + "group1": {"user1", "user3"}, + "group2": {"user1"}, + "group3": {"user3"}, + "": {"user1", "user3"}, + }, + }, + { + name: "all users are blocked", + account: &Account{ + Users: map[string]*User{ + "user1": { + Id: "user1", + AutoGroups: []string{"group1"}, + Blocked: true, + }, + "user2": { + Id: "user2", + AutoGroups: []string{"group2"}, + Blocked: true, + }, + }, + }, + expected: map[string][]string{}, + }, + { + name: "user with no auto groups", + account: &Account{ + Users: map[string]*User{ + "user1": { + Id: "user1", + AutoGroups: []string{}, + Blocked: false, + }, + "user2": { + Id: "user2", + AutoGroups: []string{"group1"}, + Blocked: false, + }, + }, + }, + expected: map[string][]string{ + "group1": {"user2"}, + "": {"user1", "user2"}, + }, + }, + { + name: "empty account", + account: &Account{ + Users: map[string]*User{}, + }, + expected: map[string][]string{}, + }, + { + name: "multiple users in same group", + account: &Account{ + Users: map[string]*User{ + "user1": { + Id: "user1", + AutoGroups: []string{"group1"}, + Blocked: false, + }, + "user2": { + Id: "user2", + AutoGroups: []string{"group1"}, + Blocked: false, + }, + "user3": { + Id: "user3", + AutoGroups: []string{"group1"}, + Blocked: false, + }, + }, + }, + expected: map[string][]string{ + "group1": {"user1", "user2", "user3"}, + "": {"user1", "user2", "user3"}, + }, + }, + { + name: "user in multiple groups with blocked users", + account: &Account{ + Users: map[string]*User{ + "user1": { + Id: "user1", + AutoGroups: []string{"group1", "group2", "group3"}, + Blocked: false, + }, + "user2": { + Id: "user2", + AutoGroups: []string{"group1", "group2"}, + Blocked: true, + }, + "user3": { + Id: "user3", + AutoGroups: []string{"group3"}, + Blocked: false, + }, + }, + }, + expected: map[string][]string{ + "group1": {"user1"}, + "group2": {"user1"}, + "group3": {"user1", "user3"}, + "": {"user1", "user3"}, }, }, - SourcePostureChecks: []string{accNetResourceRelaxedPostureCheckID}, } - account.Policies = append(account.Policies, newPolicy) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.account.GetActiveGroupUsers() - // validate for peer1 - isRouter, networkResourcesRoutes, sourcePeers := account.GetNetworkResourcesRoutesToSync(context.Background(), accNetResourcePeer1ID, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap()) - assert.False(t, isRouter, "expected router status") - assert.Len(t, networkResourcesRoutes, 1, "expected network resource route don't match") - assert.Len(t, sourcePeers, 0, "expected source peers don't match") + // Check that the number of groups matches + assert.Equal(t, len(tt.expected), len(result), "number of groups should match") - // validate for peer2 - isRouter, networkResourcesRoutes, sourcePeers = account.GetNetworkResourcesRoutesToSync(context.Background(), accNetResourcePeer2ID, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap()) - assert.False(t, isRouter, "expected router status") - assert.Len(t, networkResourcesRoutes, 1, "expected network resource route don't match") - assert.Len(t, sourcePeers, 0, "expected source peers don't match") + // Check each group's users + for groupID, expectedUsers := range tt.expected { + actualUsers, exists := result[groupID] + assert.True(t, exists, "group %s should exist in result", groupID) + assert.ElementsMatch(t, expectedUsers, actualUsers, "users in group %s should match", groupID) + } - // validate routes for router1 - isRouter, networkResourcesRoutes, sourcePeers = account.GetNetworkResourcesRoutesToSync(context.Background(), accNetResourceRouter1ID, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap()) - assert.True(t, isRouter, "should be router") - assert.Len(t, networkResourcesRoutes, 1, "expected network resource route don't match") - assert.Len(t, sourcePeers, 2, "expected source peers don't match") - assert.NotNil(t, sourcePeers[accNetResourcePeer1ID], "expected source peers don't match") - assert.NotNil(t, sourcePeers[accNetResourcePeer2ID], "expected source peers don't match") - - // validate rules for router1 - rules := account.GetPeerNetworkResourceFirewallRules(context.Background(), account.Peers[accNetResourceRouter1ID], accNetResourceValidPeers, networkResourcesRoutes, account.GetResourcePoliciesMap()) - assert.Len(t, rules, 2, "expected rules count don't match") - assert.Equal(t, uint16(80), rules[0].Port, "should have port 80") - assert.Equal(t, "tcp", rules[0].Protocol, "should have protocol tcp") - if !slices.Contains(rules[0].SourceRanges, accNetResourcePeer1IP.String()+"/32") { - t.Errorf("%s should have source range of peer1 %s", rules[0].SourceRanges, accNetResourcePeer1IP.String()) + // Ensure no extra groups in result + for groupID := range result { + _, exists := tt.expected[groupID] + assert.True(t, exists, "unexpected group %s in result", groupID) + } + }) } - if slices.Contains(rules[0].SourceRanges, accNetResourcePeer2IP.String()+"/32") { - t.Errorf("%s should not have source range of peer2 %s", rules[0].SourceRanges, accNetResourcePeer2IP.String()) - } - - assert.Equal(t, uint16(22), rules[1].Port, "should have port 22") - assert.Equal(t, "tcp", rules[1].Protocol, "should have protocol tcp") - if !slices.Contains(rules[1].SourceRanges, accNetResourcePeer1IP.String()+"/32") { - t.Errorf("%s should have source range of peer1 %s", rules[1].SourceRanges, accNetResourcePeer1IP.String()) - } - if !slices.Contains(rules[1].SourceRanges, accNetResourcePeer2IP.String()+"/32") { - t.Errorf("%s should have source range of peer2 %s", rules[1].SourceRanges, accNetResourcePeer2IP.String()) - } -} - -func Test_NetworksNetMapGenWithTwoPostureChecks(t *testing.T) { - account := getBasicAccountsWithResource() - - // two posture checks should match only the peers that match both checks - policy := account.Policies[0] - policy.SourcePostureChecks = []string{accNetResourceRelaxedPostureCheckID, accNetResourceLinuxPostureCheckID} - - // validate for peer1 - isRouter, networkResourcesRoutes, sourcePeers := account.GetNetworkResourcesRoutesToSync(context.Background(), accNetResourcePeer1ID, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap()) - assert.False(t, isRouter, "expected router status") - assert.Len(t, networkResourcesRoutes, 1, "expected network resource route don't match") - assert.Len(t, sourcePeers, 0, "expected source peers don't match") - - // validate for peer2 - isRouter, networkResourcesRoutes, sourcePeers = account.GetNetworkResourcesRoutesToSync(context.Background(), accNetResourcePeer2ID, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap()) - assert.False(t, isRouter, "expected router status") - assert.Len(t, networkResourcesRoutes, 0, "expected network resource route don't match") - assert.Len(t, sourcePeers, 0, "expected source peers don't match") - - // validate routes for router1 - isRouter, networkResourcesRoutes, sourcePeers = account.GetNetworkResourcesRoutesToSync(context.Background(), accNetResourceRouter1ID, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap()) - assert.True(t, isRouter, "should be router") - assert.Len(t, networkResourcesRoutes, 1, "expected network resource route don't match") - assert.Len(t, sourcePeers, 1, "expected source peers don't match") - assert.NotNil(t, sourcePeers[accNetResourcePeer1ID], "expected source peers don't match") - - // validate rules for router1 - rules := account.GetPeerNetworkResourceFirewallRules(context.Background(), account.Peers[accNetResourceRouter1ID], accNetResourceValidPeers, networkResourcesRoutes, account.GetResourcePoliciesMap()) - assert.Len(t, rules, 1, "expected rules count don't match") - assert.Equal(t, uint16(80), rules[0].Port, "should have port 80") - assert.Equal(t, "tcp", rules[0].Protocol, "should have protocol tcp") - if !slices.Contains(rules[0].SourceRanges, accNetResourcePeer1IP.String()+"/32") { - t.Errorf("%s should have source range of peer1 %s", rules[0].SourceRanges, accNetResourcePeer1IP.String()) - } - if slices.Contains(rules[0].SourceRanges, accNetResourcePeer2IP.String()+"/32") { - t.Errorf("%s should not have source range of peer2 %s", rules[0].SourceRanges, accNetResourcePeer2IP.String()) - } -} - -func Test_NetworksNetMapGenShouldExcludeOtherRouters(t *testing.T) { - account := getBasicAccountsWithResource() - - account.Peers["router2Id"] = &nbpeer.Peer{Key: "router2Key", ID: "router2Id", AccountID: accID, IP: net.IP{192, 168, 1, 4}} - account.NetworkRouters = append(account.NetworkRouters, &routerTypes.NetworkRouter{ - ID: "router2Id", - NetworkID: network1ID, - AccountID: accID, - Peer: "router2Id", - }) - - // validate routes for router1 - isRouter, networkResourcesRoutes, sourcePeers := account.GetNetworkResourcesRoutesToSync(context.Background(), accNetResourceRouter1ID, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap()) - assert.True(t, isRouter, "should be router") - assert.Len(t, networkResourcesRoutes, 1, "expected network resource route don't match") - assert.Len(t, sourcePeers, 2, "expected source peers don't match") } func Test_FilterZoneRecordsForPeers(t *testing.T) { @@ -859,7 +889,11 @@ func Test_FilterZoneRecordsForPeers(t *testing.T) { }, peersToConnect: []*nbpeer.Peer{}, expiredPeers: []*nbpeer.Peer{}, - peer: &nbpeer.Peer{ID: "router", IP: net.ParseIP("10.0.0.100")}, + peer: &nbpeer.Peer{ + ID: "router", + IP: netip.MustParseAddr("10.0.0.100"), + IPv6: netip.MustParseAddr("fd00::a00:64"), + }, expectedRecords: []nbdns.SimpleRecord{ {Name: "router.netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.100"}, }, @@ -886,14 +920,19 @@ func Test_FilterZoneRecordsForPeers(t *testing.T) { var peers []*nbpeer.Peer for _, i := range []int{1, 5, 10, 25, 50, 75, 100} { peers = append(peers, &nbpeer.Peer{ - ID: fmt.Sprintf("peer%d", i), - IP: net.ParseIP(fmt.Sprintf("10.0.%d.%d", i/256, i%256)), + ID: fmt.Sprintf("peer%d", i), + IP: netip.MustParseAddr(fmt.Sprintf("10.0.%d.%d", i/256, i%256)), + IPv6: netip.MustParseAddr(fmt.Sprintf("fd00::%d", i)), }) } return peers }(), expiredPeers: []*nbpeer.Peer{}, - peer: &nbpeer.Peer{ID: "router", IP: net.ParseIP("10.0.0.100")}, + peer: &nbpeer.Peer{ + ID: "router", + IP: netip.MustParseAddr("10.0.0.100"), + IPv6: netip.MustParseAddr("fd00::a00:64"), + }, expectedRecords: func() []nbdns.SimpleRecord { var records []nbdns.SimpleRecord for _, i := range []int{1, 5, 10, 25, 50, 75, 100} { @@ -924,11 +963,27 @@ func Test_FilterZoneRecordsForPeers(t *testing.T) { }, }, peersToConnect: []*nbpeer.Peer{ - {ID: "peer1", IP: net.ParseIP("10.0.0.1"), DNSLabel: "peer1", ExtraDNSLabels: []string{"peer1-alt", "peer1-backup"}}, - {ID: "peer2", IP: net.ParseIP("10.0.0.2"), DNSLabel: "peer2", ExtraDNSLabels: []string{"peer2-service"}}, + { + ID: "peer1", + IP: netip.MustParseAddr("10.0.0.1"), + IPv6: netip.MustParseAddr("fd00::a00:1"), + DNSLabel: "peer1", + ExtraDNSLabels: []string{"peer1-alt", "peer1-backup"}, + }, + { + ID: "peer2", + IP: netip.MustParseAddr("10.0.0.2"), + IPv6: netip.MustParseAddr("fd00::a00:2"), + DNSLabel: "peer2", + ExtraDNSLabels: []string{"peer2-service"}, + }, }, expiredPeers: []*nbpeer.Peer{}, - peer: &nbpeer.Peer{ID: "router", IP: net.ParseIP("10.0.0.100")}, + peer: &nbpeer.Peer{ + ID: "router", + IP: netip.MustParseAddr("10.0.0.100"), + IPv6: netip.MustParseAddr("fd00::a00:64"), + }, expectedRecords: []nbdns.SimpleRecord{ {Name: "peer1.netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, {Name: "peer1-alt.netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, @@ -950,12 +1005,24 @@ func Test_FilterZoneRecordsForPeers(t *testing.T) { }, }, peersToConnect: []*nbpeer.Peer{ - {ID: "peer1", IP: net.ParseIP("10.0.0.1")}, + { + ID: "peer1", + IP: netip.MustParseAddr("10.0.0.1"), + IPv6: netip.MustParseAddr("fd00::a00:1"), + }, }, expiredPeers: []*nbpeer.Peer{ - {ID: "expired-peer", IP: net.ParseIP("10.0.0.99")}, + { + ID: "expired-peer", + IP: netip.MustParseAddr("10.0.0.99"), + IPv6: netip.MustParseAddr("fd00::a00:63"), + }, + }, + peer: &nbpeer.Peer{ + ID: "router", + IP: netip.MustParseAddr("10.0.0.100"), + IPv6: netip.MustParseAddr("fd00::a00:64"), }, - peer: &nbpeer.Peer{ID: "router", IP: net.ParseIP("10.0.0.100")}, expectedRecords: []nbdns.SimpleRecord{ {Name: "peer1.netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}, {Name: "expired-peer.netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.99"}, @@ -972,3 +1039,715 @@ func Test_FilterZoneRecordsForPeers(t *testing.T) { }) } } + +func Test_filterPeerAppliedZones(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + accountZones []*zones.Zone + peerGroups LookupMap + expected []nbdns.CustomZone + }{ + { + name: "empty peer groups returns empty custom zones", + accountZones: []*zones.Zone{}, + peerGroups: LookupMap{}, + expected: []nbdns.CustomZone{}, + }, + { + name: "peer has access to zone with A record", + accountZones: []*zones.Zone{ + { + ID: "zone1", + Domain: "example.com", + Enabled: true, + EnableSearchDomain: false, + DistributionGroups: []string{"group1"}, + Records: []*records.Record{ + { + ID: "record1", + Name: "www.example.com", + Type: records.RecordTypeA, + Content: "192.168.1.1", + TTL: 300, + }, + }, + }, + }, + peerGroups: LookupMap{"group1": struct{}{}}, + expected: []nbdns.CustomZone{ + { + Domain: "example.com.", + Records: []nbdns.SimpleRecord{ + { + Name: "www.example.com.", + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: 300, + RData: "192.168.1.1", + }, + }, + SearchDomainDisabled: true, + }, + }, + }, + { + name: "peer has access to zone with search domain enabled", + accountZones: []*zones.Zone{ + { + ID: "zone1", + Domain: "internal.local", + Enabled: true, + EnableSearchDomain: true, + DistributionGroups: []string{"group1"}, + Records: []*records.Record{ + { + ID: "record1", + Name: "api.internal.local", + Type: records.RecordTypeA, + Content: "10.0.0.1", + TTL: 600, + }, + }, + }, + }, + peerGroups: LookupMap{"group1": struct{}{}}, + expected: []nbdns.CustomZone{ + { + Domain: "internal.local.", + Records: []nbdns.SimpleRecord{ + { + Name: "api.internal.local.", + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: 600, + RData: "10.0.0.1", + }, + }, + SearchDomainDisabled: false, + }, + }, + }, + { + name: "peer has no access to zone", + accountZones: []*zones.Zone{ + { + ID: "zone1", + Domain: "private.com", + Enabled: true, + EnableSearchDomain: false, + DistributionGroups: []string{"group2"}, + Records: []*records.Record{ + { + ID: "record1", + Name: "secret.private.com", + Type: records.RecordTypeA, + Content: "192.168.1.1", + TTL: 300, + }, + }, + }, + }, + peerGroups: LookupMap{"group1": struct{}{}}, + expected: []nbdns.CustomZone{}, + }, + { + name: "disabled zone is filtered out", + accountZones: []*zones.Zone{ + { + ID: "zone1", + Domain: "disabled.com", + Enabled: false, + EnableSearchDomain: false, + DistributionGroups: []string{"group1"}, + Records: []*records.Record{ + { + ID: "record1", + Name: "www.disabled.com", + Type: records.RecordTypeA, + Content: "192.168.1.1", + TTL: 300, + }, + }, + }, + }, + peerGroups: LookupMap{"group1": struct{}{}}, + expected: []nbdns.CustomZone{}, + }, + { + name: "zone with no records is filtered out", + accountZones: []*zones.Zone{ + { + ID: "zone1", + Domain: "empty.com", + Enabled: true, + EnableSearchDomain: false, + DistributionGroups: []string{"group1"}, + Records: []*records.Record{}, + }, + }, + peerGroups: LookupMap{"group1": struct{}{}}, + expected: []nbdns.CustomZone{}, + }, + { + name: "peer has access via multiple groups", + accountZones: []*zones.Zone{ + { + ID: "zone1", + Domain: "multi.com", + Enabled: true, + EnableSearchDomain: false, + DistributionGroups: []string{"group1", "group2", "group3"}, + Records: []*records.Record{ + { + ID: "record1", + Name: "www.multi.com", + Type: records.RecordTypeA, + Content: "192.168.1.1", + TTL: 300, + }, + }, + }, + }, + peerGroups: LookupMap{"group2": struct{}{}}, + expected: []nbdns.CustomZone{ + { + Domain: "multi.com.", + Records: []nbdns.SimpleRecord{ + { + Name: "www.multi.com.", + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: 300, + RData: "192.168.1.1", + }, + }, + SearchDomainDisabled: true, + }, + }, + }, + { + name: "multiple zones with mixed access", + accountZones: []*zones.Zone{ + { + ID: "zone1", + Domain: "allowed.com", + Enabled: true, + EnableSearchDomain: false, + DistributionGroups: []string{"group1"}, + Records: []*records.Record{ + { + ID: "record1", + Name: "www.allowed.com", + Type: records.RecordTypeA, + Content: "192.168.1.1", + TTL: 300, + }, + }, + }, + { + ID: "zone2", + Domain: "denied.com", + Enabled: true, + EnableSearchDomain: false, + DistributionGroups: []string{"group2"}, + Records: []*records.Record{ + { + ID: "record2", + Name: "www.denied.com", + Type: records.RecordTypeA, + Content: "192.168.1.2", + TTL: 300, + }, + }, + }, + }, + peerGroups: LookupMap{"group1": struct{}{}}, + expected: []nbdns.CustomZone{ + { + Domain: "allowed.com.", + Records: []nbdns.SimpleRecord{ + { + Name: "www.allowed.com.", + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: 300, + RData: "192.168.1.1", + }, + }, + SearchDomainDisabled: true, + }, + }, + }, + { + name: "zone with multiple record types", + accountZones: []*zones.Zone{ + { + ID: "zone1", + Domain: "mixed.com", + Enabled: true, + EnableSearchDomain: false, + DistributionGroups: []string{"group1"}, + Records: []*records.Record{ + { + ID: "record1", + Name: "www.mixed.com", + Type: records.RecordTypeA, + Content: "192.168.1.1", + TTL: 300, + }, + { + ID: "record2", + Name: "ipv6.mixed.com", + Type: records.RecordTypeAAAA, + Content: "2001:db8::1", + TTL: 600, + }, + { + ID: "record3", + Name: "alias.mixed.com", + Type: records.RecordTypeCNAME, + Content: "www.mixed.com", + TTL: 900, + }, + }, + }, + }, + peerGroups: LookupMap{"group1": struct{}{}}, + expected: []nbdns.CustomZone{ + { + Domain: "mixed.com.", + Records: []nbdns.SimpleRecord{ + { + Name: "www.mixed.com.", + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: 300, + RData: "192.168.1.1", + }, + { + Name: "ipv6.mixed.com.", + Type: int(dns.TypeAAAA), + Class: nbdns.DefaultClass, + TTL: 600, + RData: "2001:db8::1", + }, + { + Name: "alias.mixed.com.", + Type: int(dns.TypeCNAME), + Class: nbdns.DefaultClass, + TTL: 900, + RData: "www.mixed.com.", + }, + }, + SearchDomainDisabled: true, + }, + }, + }, + { + name: "multiple zones both accessible", + accountZones: []*zones.Zone{ + { + ID: "zone1", + Domain: "first.com", + Enabled: true, + EnableSearchDomain: true, + DistributionGroups: []string{"group1"}, + Records: []*records.Record{ + { + ID: "record1", + Name: "www.first.com", + Type: records.RecordTypeA, + Content: "192.168.1.1", + TTL: 300, + }, + }, + }, + { + ID: "zone2", + Domain: "second.com", + Enabled: true, + EnableSearchDomain: false, + DistributionGroups: []string{"group1"}, + Records: []*records.Record{ + { + ID: "record2", + Name: "www.second.com", + Type: records.RecordTypeA, + Content: "192.168.1.2", + TTL: 600, + }, + }, + }, + }, + peerGroups: LookupMap{"group1": struct{}{}}, + expected: []nbdns.CustomZone{ + { + Domain: "first.com.", + Records: []nbdns.SimpleRecord{ + { + Name: "www.first.com.", + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: 300, + RData: "192.168.1.1", + }, + }, + SearchDomainDisabled: false, + }, + { + Domain: "second.com.", + Records: []nbdns.SimpleRecord{ + { + Name: "www.second.com.", + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: 600, + RData: "192.168.1.2", + }, + }, + SearchDomainDisabled: true, + }, + }, + }, + { + name: "zone with multiple records of same type", + accountZones: []*zones.Zone{ + { + ID: "zone1", + Domain: "multi-a.com", + Enabled: true, + EnableSearchDomain: false, + DistributionGroups: []string{"group1"}, + Records: []*records.Record{ + { + ID: "record1", + Name: "www.multi-a.com", + Type: records.RecordTypeA, + Content: "192.168.1.1", + TTL: 300, + }, + { + ID: "record2", + Name: "www.multi-a.com", + Type: records.RecordTypeA, + Content: "192.168.1.2", + TTL: 300, + }, + }, + }, + }, + peerGroups: LookupMap{"group1": struct{}{}}, + expected: []nbdns.CustomZone{ + { + Domain: "multi-a.com.", + Records: []nbdns.SimpleRecord{ + { + Name: "www.multi-a.com.", + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: 300, + RData: "192.168.1.1", + }, + { + Name: "www.multi-a.com.", + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: 300, + RData: "192.168.1.2", + }, + }, + SearchDomainDisabled: true, + }, + }, + }, + { + name: "peer in multiple groups accessing different zones", + accountZones: []*zones.Zone{ + { + ID: "zone1", + Domain: "zone1.com", + Enabled: true, + EnableSearchDomain: false, + DistributionGroups: []string{"group1"}, + Records: []*records.Record{ + { + ID: "record1", + Name: "www.zone1.com", + Type: records.RecordTypeA, + Content: "192.168.1.1", + TTL: 300, + }, + }, + }, + { + ID: "zone2", + Domain: "zone2.com", + Enabled: true, + EnableSearchDomain: false, + DistributionGroups: []string{"group2"}, + Records: []*records.Record{ + { + ID: "record2", + Name: "www.zone2.com", + Type: records.RecordTypeA, + Content: "192.168.1.2", + TTL: 300, + }, + }, + }, + }, + peerGroups: LookupMap{"group1": struct{}{}, "group2": struct{}{}}, + expected: []nbdns.CustomZone{ + { + Domain: "zone1.com.", + Records: []nbdns.SimpleRecord{ + { + Name: "www.zone1.com.", + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: 300, + RData: "192.168.1.1", + }, + }, + SearchDomainDisabled: true, + }, + { + Domain: "zone2.com.", + Records: []nbdns.SimpleRecord{ + { + Name: "www.zone2.com.", + Type: int(dns.TypeA), + Class: nbdns.DefaultClass, + TTL: 300, + RData: "192.168.1.2", + }, + }, + SearchDomainDisabled: true, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := filterPeerAppliedZones(ctx, tt.accountZones, tt.peerGroups) + require.Equal(t, len(tt.expected), len(result), "number of custom zones should match") + + for i, expectedZone := range tt.expected { + assert.Equal(t, expectedZone.Domain, result[i].Domain, "domain should match") + assert.Equal(t, expectedZone.SearchDomainDisabled, result[i].SearchDomainDisabled, "search domain disabled flag should match") + assert.Equal(t, len(expectedZone.Records), len(result[i].Records), "number of records should match") + + for j, expectedRecord := range expectedZone.Records { + assert.Equal(t, expectedRecord.Name, result[i].Records[j].Name, "record name should match") + assert.Equal(t, expectedRecord.Type, result[i].Records[j].Type, "record type should match") + assert.Equal(t, expectedRecord.Class, result[i].Records[j].Class, "record class should match") + assert.Equal(t, expectedRecord.TTL, result[i].Records[j].TTL, "record TTL should match") + assert.Equal(t, expectedRecord.RData, result[i].Records[j].RData, "record RData should match") + } + } + }) + } +} + +func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) { + ctx := context.Background() + + userPeerIP := netip.MustParseAddr("100.64.0.10") + proxyPeerIP := netip.MustParseAddr("100.64.0.99") + + account := &Account{ + Id: "acct-1", + Network: &Network{ + Identifier: "net-1", + Net: net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)}, + }, + Peers: map[string]*nbpeer.Peer{ + "user-peer": { + ID: "user-peer", + AccountID: "acct-1", + Key: "user-peer-key", + IP: userPeerIP, + }, + "proxy-peer": { + ID: "proxy-peer", + AccountID: "acct-1", + Key: "proxy-peer-key", + IP: proxyPeerIP, + ProxyMeta: nbpeer.ProxyMeta{ + Embedded: true, + Cluster: "eu.proxy.netbird.io", + }, + }, + }, + Groups: map[string]*Group{ + "grp-admins": { + ID: "grp-admins", + Name: "admins", + Peers: []string{"user-peer"}, + }, + }, + Services: []*service.Service{ + { + ID: "svc-1", + AccountID: "acct-1", + Name: "myapp", + Domain: "myapp.eu.proxy.netbird.io", + ProxyCluster: "eu.proxy.netbird.io", + Enabled: true, + Private: true, + Mode: service.ModeHTTP, + AccessGroups: []string{"grp-admins"}, + Targets: []*service.Target{ + { + TargetId: "eu.proxy.netbird.io", + TargetType: service.TargetTypeCluster, + Protocol: "http", + Host: "127.0.0.1", + Port: 8080, + Enabled: true, + }, + }, + }, + }, + } + + account.InjectProxyPolicies(ctx) + + var found *Policy + for _, p := range account.Policies { + if p != nil && p.ID == "private-access-svc-1-proxy-peer" { + found = p + break + } + } + require.NotNil(t, found, "expected synthesised private-access policy in account.Policies") + require.Len(t, found.Rules, 1, "policy should have exactly one rule") + rule := found.Rules[0] + assert.Equal(t, []string{"grp-admins"}, rule.Sources, "sources should be group IDs verbatim") + assert.Equal(t, "proxy-peer", rule.DestinationResource.ID, "destination resource should be the proxy peer ID") + assert.Equal(t, ResourceTypePeer, rule.DestinationResource.Type, "destination resource type should be peer") + + validatedPeersMap := map[string]struct{}{ + "user-peer": {}, + "proxy-peer": {}, + } + + proxyPeer := account.Peers["proxy-peer"] + aclPeers, firewallRules, _, _ := account.GetPeerConnectionResources(ctx, proxyPeer, validatedPeersMap, nil) + + var sawUserAsAclPeer bool + for _, p := range aclPeers { + if p.ID == "user-peer" { + sawUserAsAclPeer = true + break + } + } + assert.True(t, sawUserAsAclPeer, "proxy peer should see the user peer as an ACL peer") + + var inboundRules []*FirewallRule + for _, r := range firewallRules { + if r.Direction == FirewallRuleDirectionIN && r.PeerIP == userPeerIP.String() { + inboundRules = append(inboundRules, r) + } + } + assert.NotEmpty(t, inboundRules, "proxy peer should have inbound firewall rules from the user peer") +} + +func TestInjectPrivateServicePolicies_NotPrivate_NoPolicy(t *testing.T) { + ctx := context.Background() + account := privateServiceTestAccount(t) + account.Services[0].Private = false + + account.InjectProxyPolicies(ctx) + assert.False(t, hasPrivateAccessPolicy(account, "svc-1"), "non-private service must not synthesise an access policy") +} + +func TestInjectPrivateServicePolicies_EmptyAccessGroups_NoPolicy(t *testing.T) { + ctx := context.Background() + account := privateServiceTestAccount(t) + account.Services[0].AccessGroups = nil + + account.InjectProxyPolicies(ctx) + assert.False(t, hasPrivateAccessPolicy(account, "svc-1"), "private service with no access groups must not synthesise a policy") +} + +func TestInjectPrivateServicePolicies_NoProxyPeers_NoPolicy(t *testing.T) { + ctx := context.Background() + account := privateServiceTestAccount(t) + delete(account.Peers, "proxy-peer") + + account.InjectProxyPolicies(ctx) + assert.False(t, hasPrivateAccessPolicy(account, "svc-1"), "policy must not synthesise when the cluster has no proxy peers") +} + +func privateServiceTestAccount(t *testing.T) *Account { + t.Helper() + return &Account{ + Id: "acct-1", + Network: &Network{ + Identifier: "net-1", + Net: net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.CIDRMask(10, 32)}, + }, + Peers: map[string]*nbpeer.Peer{ + "user-peer": { + ID: "user-peer", + AccountID: "acct-1", + Key: "user-peer-key", + IP: netip.MustParseAddr("100.64.0.10"), + }, + "proxy-peer": { + ID: "proxy-peer", + AccountID: "acct-1", + Key: "proxy-peer-key", + IP: netip.MustParseAddr("100.64.0.99"), + ProxyMeta: nbpeer.ProxyMeta{ + Embedded: true, + Cluster: "eu.proxy.netbird.io", + }, + }, + }, + Groups: map[string]*Group{ + "grp-admins": { + ID: "grp-admins", + Name: "admins", + Peers: []string{"user-peer"}, + }, + }, + Services: []*service.Service{ + { + ID: "svc-1", + AccountID: "acct-1", + Name: "myapp", + Domain: "myapp.eu.proxy.netbird.io", + ProxyCluster: "eu.proxy.netbird.io", + Enabled: true, + Private: true, + Mode: service.ModeHTTP, + AccessGroups: []string{"grp-admins"}, + Targets: []*service.Target{ + { + TargetId: "eu.proxy.netbird.io", + TargetType: service.TargetTypeCluster, + Protocol: "http", + Host: "127.0.0.1", + Port: 8080, + Enabled: true, + }, + }, + }, + }, + } +} + +func hasPrivateAccessPolicy(account *Account, serviceID string) bool { + prefix := "private-access-" + serviceID + "-" + for _, p := range account.Policies { + if p != nil && len(p.ID) > len(prefix) && p.ID[:len(prefix)] == prefix { + return true + } + } + return false +} diff --git a/management/server/types/aliases.go b/management/server/types/aliases.go new file mode 100644 index 000000000..9324cfa1e --- /dev/null +++ b/management/server/types/aliases.go @@ -0,0 +1,148 @@ +package types + +import ( + "context" + "math/rand" + "net" + "net/netip" + + nbroute "github.com/netbirdio/netbird/route" + sharedtypes "github.com/netbirdio/netbird/shared/management/types" +) + +// Type aliases for types relocated to shared/management/types so that the +// client-side compute path can depend on them + +type DNSSettings = sharedtypes.DNSSettings + +type FirewallRule = sharedtypes.FirewallRule + +type Network = sharedtypes.Network +type NetworkMap = sharedtypes.NetworkMap +type ForwardingRule = sharedtypes.ForwardingRule + +type Policy = sharedtypes.Policy +type PolicyUpdateOperation = sharedtypes.PolicyUpdateOperation + +type PolicyRule = sharedtypes.PolicyRule +type PolicyUpdateOperationType = sharedtypes.PolicyUpdateOperationType +type PolicyTrafficActionType = sharedtypes.PolicyTrafficActionType +type PolicyRuleProtocolType = sharedtypes.PolicyRuleProtocolType +type PolicyRuleDirection = sharedtypes.PolicyRuleDirection +type RulePortRange = sharedtypes.RulePortRange + +type Resource = sharedtypes.Resource +type ResourceType = sharedtypes.ResourceType + +type RouteFirewallRule = sharedtypes.RouteFirewallRule + +type NetworkMapComponents = sharedtypes.NetworkMapComponents + +type ComponentPeer = sharedtypes.ComponentPeer +type ComponentGroup = sharedtypes.ComponentGroup +type ComponentRouter = sharedtypes.ComponentRouter +type ComponentResource = sharedtypes.ComponentResource +type ComponentResourceType = sharedtypes.ComponentResourceType + +const ( + ComponentResourceHost = sharedtypes.ComponentResourceHost + ComponentResourceSubnet = sharedtypes.ComponentResourceSubnet + ComponentResourceDomain = sharedtypes.ComponentResourceDomain +) + +var EmptyNetworkMapComponents = sharedtypes.EmptyNetworkMapComponents + +type AccountSettingsInfo = sharedtypes.AccountSettingsInfo + +type GroupCompact = sharedtypes.GroupCompact +type NetworkMapComponentsCompact = sharedtypes.NetworkMapComponentsCompact + +type LookupMap = sharedtypes.LookupMap +type FirewallRuleContext = sharedtypes.FirewallRuleContext + +const GroupAllName = sharedtypes.GroupAllName + +// Function forwarders preserve types.X(...) call sites that previously +// resolved to package-local funcs. Plain forwarders (not var aliases) keep +// the symbol immutable and allow the inliner to flatten the call. + +func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool { + return sharedtypes.PolicyRuleImpliesLegacySSH(rule) +} + +func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPeer) []*FirewallRule { + return sharedtypes.ExpandPortsAndRanges(base, rule, peer) +} + +func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *ComponentPeer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule { + return sharedtypes.AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, rc) +} + +func CalculateNetworkMapFromComponents(ctx context.Context, components *NetworkMapComponents) *NetworkMap { + return sharedtypes.CalculateNetworkMapFromComponents(ctx, components) +} + +func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*ComponentPeer, direction int, includeIPv6 bool) []*RouteFirewallRule { + return sharedtypes.GenerateRouteFirewallRules(ctx, route, rule, groupPeers, direction, includeIPv6) +} + +func AllocateIPv6Subnet(r *rand.Rand) net.IPNet { + return sharedtypes.AllocateIPv6Subnet(r) +} + +func NewNetwork() *Network { + return sharedtypes.NewNetwork() +} + +func AllocatePeerIP(prefix netip.Prefix, takenIps []netip.Addr) (netip.Addr, error) { + return sharedtypes.AllocatePeerIP(prefix, takenIps) +} + +func AllocateRandomPeerIP(prefix netip.Prefix) (netip.Addr, error) { + return sharedtypes.AllocateRandomPeerIP(prefix) +} + +func AllocateRandomPeerIPv6(prefix netip.Prefix) (netip.Addr, error) { + return sharedtypes.AllocateRandomPeerIPv6(prefix) +} + +func ParseRuleString(rule string) (PolicyRuleProtocolType, RulePortRange, error) { + return sharedtypes.ParseRuleString(rule) +} + +const ( + FirewallRuleDirectionIN = sharedtypes.FirewallRuleDirectionIN + FirewallRuleDirectionOUT = sharedtypes.FirewallRuleDirectionOUT +) + +const ( + ResourceTypePeer = sharedtypes.ResourceTypePeer + ResourceTypeDomain = sharedtypes.ResourceTypeDomain + ResourceTypeHost = sharedtypes.ResourceTypeHost + ResourceTypeSubnet = sharedtypes.ResourceTypeSubnet +) + +const ( + PolicyTrafficActionAccept = sharedtypes.PolicyTrafficActionAccept + PolicyTrafficActionDrop = sharedtypes.PolicyTrafficActionDrop +) + +const ( + PolicyRuleProtocolALL = sharedtypes.PolicyRuleProtocolALL + PolicyRuleProtocolTCP = sharedtypes.PolicyRuleProtocolTCP + PolicyRuleProtocolUDP = sharedtypes.PolicyRuleProtocolUDP + PolicyRuleProtocolICMP = sharedtypes.PolicyRuleProtocolICMP + PolicyRuleProtocolNetbirdSSH = sharedtypes.PolicyRuleProtocolNetbirdSSH +) + +const ( + PolicyRuleFlowDirect = sharedtypes.PolicyRuleFlowDirect + PolicyRuleFlowBidirect = sharedtypes.PolicyRuleFlowBidirect +) + +const ( + DefaultRuleName = sharedtypes.DefaultRuleName + DefaultRuleDescription = sharedtypes.DefaultRuleDescription + DefaultPolicyName = sharedtypes.DefaultPolicyName + DefaultPolicyDescription = sharedtypes.DefaultPolicyDescription +) diff --git a/management/server/types/group.go b/management/server/types/group.go index 00fdf7a69..a5e196997 100644 --- a/management/server/types/group.go +++ b/management/server/types/group.go @@ -2,7 +2,6 @@ package types import ( "github.com/netbirdio/netbird/management/server/integration_reference" - "github.com/netbirdio/netbird/management/server/networks/resources/types" ) const ( @@ -19,6 +18,8 @@ type Group struct { // AccountID is a reference to Account that this object belongs AccountID string `json:"-" gorm:"index"` + PublicID string `json:"-"` + // Name visible in the UI Name string @@ -66,14 +67,11 @@ func (g *Group) EventMeta() map[string]any { return map[string]any{"name": g.Name} } -func (g *Group) EventMetaResource(resource *types.NetworkResource) map[string]any { - return map[string]any{"name": g.Name, "id": g.ID, "resource_name": resource.Name, "resource_id": resource.ID, "resource_type": resource.Type} -} - func (g *Group) Copy() *Group { group := &Group{ ID: g.ID, AccountID: g.AccountID, + PublicID: g.PublicID, Name: g.Name, Issued: g.Issued, Peers: make([]string, len(g.Peers)), @@ -94,7 +92,35 @@ func (g *Group) HasPeers() bool { // IsGroupAll checks if the group is a default "All" group. func (g *Group) IsGroupAll() bool { - return g.Name == "All" + return g.Name == GroupAllName +} + +// ToComponent converts the group to its self-contained components +// representation. The Peers slice is shared, not copied — components are +// treated as immutable snapshots. Returns nil for a nil group. +func (g *Group) ToComponent() *ComponentGroup { + if g == nil { + return nil + } + return &ComponentGroup{ + ID: g.ID, + PublicID: g.PublicID, + Name: g.Name, + Peers: g.Peers, + } +} + +// GroupsToComponent converts an id-keyed group map to its components +// representation, preserving nil entries. +func GroupsToComponent(groups map[string]*Group) map[string]*ComponentGroup { + if groups == nil { + return nil + } + out := make(map[string]*ComponentGroup, len(groups)) + for id, g := range groups { + out[id] = g.ToComponent() + } + return out } // AddPeer adds peerID to Peers if not present, returning true if added. diff --git a/management/server/types/holder.go b/management/server/types/holder.go deleted file mode 100644 index 3996db2b6..000000000 --- a/management/server/types/holder.go +++ /dev/null @@ -1,43 +0,0 @@ -package types - -import ( - "context" - "sync" -) - -type Holder struct { - mu sync.RWMutex - accounts map[string]*Account -} - -func NewHolder() *Holder { - return &Holder{ - accounts: make(map[string]*Account), - } -} - -func (h *Holder) GetAccount(id string) *Account { - h.mu.RLock() - defer h.mu.RUnlock() - return h.accounts[id] -} - -func (h *Holder) AddAccount(account *Account) { - h.mu.Lock() - defer h.mu.Unlock() - h.accounts[account.Id] = account -} - -func (h *Holder) LoadOrStoreFunc(id string, accGetter func(context.Context, string) (*Account, error)) (*Account, error) { - h.mu.Lock() - defer h.mu.Unlock() - if acc, ok := h.accounts[id]; ok { - return acc, nil - } - account, err := accGetter(context.Background(), id) - if err != nil { - return nil, err - } - h.accounts[id] = account - return account, nil -} diff --git a/management/server/types/identity_provider.go b/management/server/types/identity_provider.go new file mode 100644 index 000000000..0c1f9509c --- /dev/null +++ b/management/server/types/identity_provider.go @@ -0,0 +1,127 @@ +package types + +import ( + "errors" + "net/url" +) + +// Identity provider validation errors +var ( + ErrIdentityProviderNameRequired = errors.New("identity provider name is required") + ErrIdentityProviderTypeRequired = errors.New("identity provider type is required") + ErrIdentityProviderTypeUnsupported = errors.New("unsupported identity provider type") + ErrIdentityProviderIssuerRequired = errors.New("identity provider issuer is required") + ErrIdentityProviderIssuerInvalid = errors.New("identity provider issuer must be a valid URL") + ErrIdentityProviderIssuerUnreachable = errors.New("identity provider issuer is unreachable") + ErrIdentityProviderIssuerMismatch = errors.New("identity provider issuer does not match the issuer returned by the provider") + ErrIdentityProviderClientIDRequired = errors.New("identity provider client ID is required") +) + +// IdentityProviderType is the type of identity provider +type IdentityProviderType string + +const ( + // IdentityProviderTypeOIDC is a generic OIDC identity provider + IdentityProviderTypeOIDC IdentityProviderType = "oidc" + // IdentityProviderTypeZitadel is the Zitadel identity provider + IdentityProviderTypeZitadel IdentityProviderType = "zitadel" + // IdentityProviderTypeEntra is the Microsoft Entra (Azure AD) identity provider + IdentityProviderTypeEntra IdentityProviderType = "entra" + // IdentityProviderTypeGoogle is the Google identity provider + IdentityProviderTypeGoogle IdentityProviderType = "google" + // IdentityProviderTypeOkta is the Okta identity provider + IdentityProviderTypeOkta IdentityProviderType = "okta" + // IdentityProviderTypePocketID is the PocketID identity provider + IdentityProviderTypePocketID IdentityProviderType = "pocketid" + // IdentityProviderTypeMicrosoft is the Microsoft identity provider + IdentityProviderTypeMicrosoft IdentityProviderType = "microsoft" + // IdentityProviderTypeAuthentik is the Authentik identity provider + IdentityProviderTypeAuthentik IdentityProviderType = "authentik" + // IdentityProviderTypeKeycloak is the Keycloak identity provider + IdentityProviderTypeKeycloak IdentityProviderType = "keycloak" + // IdentityProviderTypeADFS is the Microsoft AD FS identity provider + IdentityProviderTypeADFS IdentityProviderType = "adfs" +) + +// IdentityProvider represents an identity provider configuration +type IdentityProvider struct { + // ID is the unique identifier of the identity provider + ID string `gorm:"primaryKey"` + // AccountID is a reference to Account that this object belongs + AccountID string `json:"-" gorm:"index"` + // Type is the type of identity provider + Type IdentityProviderType + // Name is a human-readable name for the identity provider + Name string + // Issuer is the OIDC issuer URL + Issuer string + // ClientID is the OAuth2 client ID + ClientID string + // ClientSecret is the OAuth2 client secret + ClientSecret string +} + +// Copy returns a copy of the IdentityProvider +func (idp *IdentityProvider) Copy() *IdentityProvider { + return &IdentityProvider{ + ID: idp.ID, + AccountID: idp.AccountID, + Type: idp.Type, + Name: idp.Name, + Issuer: idp.Issuer, + ClientID: idp.ClientID, + ClientSecret: idp.ClientSecret, + } +} + +// EventMeta returns a map of metadata for activity events +func (idp *IdentityProvider) EventMeta() map[string]any { + return map[string]any{ + "name": idp.Name, + "type": string(idp.Type), + "issuer": idp.Issuer, + } +} + +// Validate validates the identity provider configuration +func (idp *IdentityProvider) Validate() error { + if idp.Name == "" { + return ErrIdentityProviderNameRequired + } + if idp.Type == "" { + return ErrIdentityProviderTypeRequired + } + if !idp.Type.IsValid() { + return ErrIdentityProviderTypeUnsupported + } + if !idp.Type.HasBuiltInIssuer() && idp.Issuer == "" { + return ErrIdentityProviderIssuerRequired + } + if idp.Issuer != "" { + parsedURL, err := url.Parse(idp.Issuer) + if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { + return ErrIdentityProviderIssuerInvalid + } + } + if idp.ClientID == "" { + return ErrIdentityProviderClientIDRequired + } + return nil +} + +// IsValid checks if the given type is a supported identity provider type +func (t IdentityProviderType) IsValid() bool { + switch t { + case IdentityProviderTypeOIDC, IdentityProviderTypeZitadel, IdentityProviderTypeEntra, + IdentityProviderTypeGoogle, IdentityProviderTypeOkta, IdentityProviderTypePocketID, + IdentityProviderTypeMicrosoft, IdentityProviderTypeAuthentik, IdentityProviderTypeKeycloak, + IdentityProviderTypeADFS: + return true + } + return false +} + +// HasBuiltInIssuer returns true for types that don't require an issuer URL +func (t IdentityProviderType) HasBuiltInIssuer() bool { + return t == IdentityProviderTypeGoogle || t == IdentityProviderTypeMicrosoft +} diff --git a/management/server/types/identity_provider_test.go b/management/server/types/identity_provider_test.go new file mode 100644 index 000000000..6ddc563f2 --- /dev/null +++ b/management/server/types/identity_provider_test.go @@ -0,0 +1,137 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIdentityProvider_Validate(t *testing.T) { + tests := []struct { + name string + idp *IdentityProvider + expectedErr error + }{ + { + name: "valid OIDC provider", + idp: &IdentityProvider{ + Name: "Test Provider", + Type: IdentityProviderTypeOIDC, + Issuer: "https://example.com", + ClientID: "client-id", + }, + expectedErr: nil, + }, + { + name: "valid OIDC provider with path", + idp: &IdentityProvider{ + Name: "Test Provider", + Type: IdentityProviderTypeOIDC, + Issuer: "https://example.com/oauth2/issuer", + ClientID: "client-id", + }, + expectedErr: nil, + }, + { + name: "missing name", + idp: &IdentityProvider{ + Type: IdentityProviderTypeOIDC, + Issuer: "https://example.com", + ClientID: "client-id", + }, + expectedErr: ErrIdentityProviderNameRequired, + }, + { + name: "missing type", + idp: &IdentityProvider{ + Name: "Test Provider", + Issuer: "https://example.com", + ClientID: "client-id", + }, + expectedErr: ErrIdentityProviderTypeRequired, + }, + { + name: "invalid type", + idp: &IdentityProvider{ + Name: "Test Provider", + Type: "invalid", + Issuer: "https://example.com", + ClientID: "client-id", + }, + expectedErr: ErrIdentityProviderTypeUnsupported, + }, + { + name: "missing issuer for OIDC", + idp: &IdentityProvider{ + Name: "Test Provider", + Type: IdentityProviderTypeOIDC, + ClientID: "client-id", + }, + expectedErr: ErrIdentityProviderIssuerRequired, + }, + { + name: "invalid issuer URL - no scheme", + idp: &IdentityProvider{ + Name: "Test Provider", + Type: IdentityProviderTypeOIDC, + Issuer: "example.com", + ClientID: "client-id", + }, + expectedErr: ErrIdentityProviderIssuerInvalid, + }, + { + name: "invalid issuer URL - no host", + idp: &IdentityProvider{ + Name: "Test Provider", + Type: IdentityProviderTypeOIDC, + Issuer: "https://", + ClientID: "client-id", + }, + expectedErr: ErrIdentityProviderIssuerInvalid, + }, + { + name: "invalid issuer URL - just path", + idp: &IdentityProvider{ + Name: "Test Provider", + Type: IdentityProviderTypeOIDC, + Issuer: "/oauth2/issuer", + ClientID: "client-id", + }, + expectedErr: ErrIdentityProviderIssuerInvalid, + }, + { + name: "missing client ID", + idp: &IdentityProvider{ + Name: "Test Provider", + Type: IdentityProviderTypeOIDC, + Issuer: "https://example.com", + }, + expectedErr: ErrIdentityProviderClientIDRequired, + }, + { + name: "Google provider without issuer is valid", + idp: &IdentityProvider{ + Name: "Google SSO", + Type: IdentityProviderTypeGoogle, + ClientID: "client-id", + }, + expectedErr: nil, + }, + { + name: "Microsoft provider without issuer is valid", + idp: &IdentityProvider{ + Name: "Microsoft SSO", + Type: IdentityProviderTypeMicrosoft, + ClientID: "client-id", + }, + expectedErr: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.idp.Validate() + assert.Equal(t, tt.expectedErr, err) + }) + } +} diff --git a/management/server/types/ipv6_endtoend_test.go b/management/server/types/ipv6_endtoend_test.go new file mode 100644 index 000000000..d83603abe --- /dev/null +++ b/management/server/types/ipv6_endtoend_test.go @@ -0,0 +1,157 @@ +package types_test + +import ( + "net/netip" + "testing" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" +) + +func TestNetworkMapComponents_IPv6EndToEnd(t *testing.T) { + account := createComponentTestAccount() + + // Make all peers IPv6-capable and assign IPv6 addrs. + v6Caps := []int32{nbpeer.PeerCapabilityIPv6Overlay, nbpeer.PeerCapabilitySourcePrefixes} + account.Peers["peer-src-1"].Meta.Capabilities = v6Caps + account.Peers["peer-src-1"].IPv6 = netip.MustParseAddr("fd00::1") + account.Peers["peer-src-2"].Meta.Capabilities = v6Caps + account.Peers["peer-src-2"].IPv6 = netip.MustParseAddr("fd00::2") + account.Peers["peer-dst-1"].Meta.Capabilities = v6Caps + account.Peers["peer-dst-1"].IPv6 = netip.MustParseAddr("fd00::3") + + // Mark group-src and group-dst as IPv6-enabled. + account.Settings.IPv6EnabledGroups = []string{"group-src", "group-dst"} + + validated := allPeersValidated(account) + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + + require.NotNil(t, nm) + + t.Run("v6 AAAA records emitted", func(t *testing.T) { + require.NotEmpty(t, nm.DNSConfig.CustomZones, "expected at least one custom zone") + var hasAAAA bool + var hasA bool + for _, z := range nm.DNSConfig.CustomZones { + for _, r := range z.Records { + if r.Type == int(dns.TypeAAAA) { + hasAAAA = true + } + if r.Type == int(dns.TypeA) { + hasA = true + } + } + } + assert.True(t, hasA, "expected A records") + assert.True(t, hasAAAA, "expected AAAA records for IPv6-enabled peers") + }) + + t.Run("v6 AllowedIPs would be advertised", func(t *testing.T) { + // nm.Peers contains *nbpeer.Peer; IPv6 should be set on those peers + var foundV6 bool + for _, p := range nm.Peers { + if p.IPv6.IsValid() { + foundV6 = true + } + } + assert.True(t, foundV6, "remote peers should have IPv6 set so AllowedIPs gets v6") + }) + + t.Run("v6 firewall rules emitted", func(t *testing.T) { + require.NotEmpty(t, nm.FirewallRules, "expected firewall rules") + var hasV4 bool + var hasV6 bool + for _, r := range nm.FirewallRules { + addr, err := netip.ParseAddr(r.PeerIP) + if err != nil { + continue + } + if addr.Is4() { + hasV4 = true + } + if addr.Is6() { + hasV6 = true + } + } + assert.True(t, hasV4, "expected at least one v4 firewall rule (peer IP)") + assert.True(t, hasV6, "expected at least one v6 firewall rule (peer IPv6)") + }) +} + +// TestNetworkMapComponents_RemotePeerWithoutCapability checks the asymmetric +// case where the target peer is IPv6-capable but a remote peer has an IPv6 +// address assigned in the DB without yet reporting the capability flag. +// In that case the remote peer's v6 still appears in AllowedIPs (gated on +// the target peer's capability) but its AAAA record does not (gated on the +// remote peer's own capability). +func TestNetworkMapComponents_RemotePeerWithoutCapability(t *testing.T) { + account := createComponentTestAccount() + + v6Caps := []int32{nbpeer.PeerCapabilityIPv6Overlay, nbpeer.PeerCapabilitySourcePrefixes} + // Target is fully capable. + account.Peers["peer-src-1"].Meta.Capabilities = v6Caps + account.Peers["peer-src-1"].IPv6 = netip.MustParseAddr("fd00::1") + // Remote peer has v6 assigned but no capability flag yet (e.g. old client). + account.Peers["peer-dst-1"].IPv6 = netip.MustParseAddr("fd00::3") + + account.Settings.IPv6EnabledGroups = []string{"group-src", "group-dst"} + + validated := allPeersValidated(account) + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + require.NotNil(t, nm) + + t.Run("AllowedIPs include remote v6", func(t *testing.T) { + var dst *types.ComponentPeer + for _, p := range nm.Peers { + if p.ID == "peer-dst-1" { + dst = p + } + } + require.NotNil(t, dst) + assert.True(t, dst.IPv6.IsValid(), "remote peer's v6 should still be present so AllowedIPs gets v6/128 (gated on target peer cap)") + }) + + t.Run("no AAAA for non-capable remote peer", func(t *testing.T) { + for _, z := range nm.DNSConfig.CustomZones { + for _, r := range z.Records { + if r.Type == int(dns.TypeAAAA) && r.RData == "fd00::3" { + t.Errorf("AAAA record for non-capable remote peer should NOT be emitted, got %+v", r) + } + } + } + }) +} + +// TestNetworkMapComponents_IPv6Disabled_NoV6Output asserts that a peer that +// does not support IPv6 (e.g. older client without the capability flag) gets +// no v6 firewall rules and no AAAA records, even if other peers have IPv6. +func TestNetworkMapComponents_IPv6Disabled_NoV6Output(t *testing.T) { + account := createComponentTestAccount() + + v6Caps := []int32{nbpeer.PeerCapabilityIPv6Overlay} + account.Peers["peer-src-2"].Meta.Capabilities = v6Caps + account.Peers["peer-src-2"].IPv6 = netip.MustParseAddr("fd00::2") + account.Peers["peer-dst-1"].Meta.Capabilities = v6Caps + account.Peers["peer-dst-1"].IPv6 = netip.MustParseAddr("fd00::3") + // peer-src-1 (target) intentionally has no capability and no IPv6. + + account.Settings.IPv6EnabledGroups = []string{"group-src", "group-dst"} + + validated := allPeersValidated(account) + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + require.NotNil(t, nm) + + t.Run("no v6 firewall rules", func(t *testing.T) { + for _, r := range nm.FirewallRules { + addr, err := netip.ParseAddr(r.PeerIP) + if err != nil { + continue + } + assert.False(t, addr.Is6(), "v6 firewall rules should not be emitted for non-IPv6 peer (got %s)", r.PeerIP) + } + }) +} diff --git a/management/server/types/ipv6_groups_test.go b/management/server/types/ipv6_groups_test.go new file mode 100644 index 000000000..766a9c92c --- /dev/null +++ b/management/server/types/ipv6_groups_test.go @@ -0,0 +1,264 @@ +package types + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" +) + +func TestPeerIPv6Allowed(t *testing.T) { + account := &Account{ + Groups: map[string]*Group{ + "group-all": {ID: "group-all", Name: "All", Peers: []string{"peer1", "peer2", "peer3"}}, + "group-devs": {ID: "group-devs", Name: "Devs", Peers: []string{"peer1", "peer2"}}, + "group-infra": {ID: "group-infra", Name: "Infra", Peers: []string{"peer2", "peer3"}}, + "group-empty": {ID: "group-empty", Name: "Empty", Peers: []string{}}, + }, + Settings: &Settings{}, + } + + tests := []struct { + name string + enabledGroups []string + peerID string + expected bool + }{ + { + name: "empty groups list disables IPv6 for all", + enabledGroups: []string{}, + peerID: "peer1", + expected: false, + }, + { + name: "All group enables IPv6 for everyone", + enabledGroups: []string{"group-all"}, + peerID: "peer1", + expected: true, + }, + { + name: "peer in enabled group gets IPv6", + enabledGroups: []string{"group-devs"}, + peerID: "peer1", + expected: true, + }, + { + name: "peer not in any enabled group denied IPv6", + enabledGroups: []string{"group-devs"}, + peerID: "peer3", + expected: false, + }, + { + name: "peer in multiple groups, one enabled", + enabledGroups: []string{"group-infra"}, + peerID: "peer2", + expected: true, + }, + { + name: "peer in multiple groups, other one enabled", + enabledGroups: []string{"group-devs"}, + peerID: "peer2", + expected: true, + }, + { + name: "multiple enabled groups, peer in one", + enabledGroups: []string{"group-devs", "group-infra"}, + peerID: "peer1", + expected: true, + }, + { + name: "multiple enabled groups, peer in both", + enabledGroups: []string{"group-devs", "group-infra"}, + peerID: "peer2", + expected: true, + }, + { + name: "nonexistent group ID in enabled list", + enabledGroups: []string{"group-deleted"}, + peerID: "peer1", + expected: false, + }, + { + name: "empty group in enabled list", + enabledGroups: []string{"group-empty"}, + peerID: "peer1", + expected: false, + }, + { + name: "unknown peer ID", + enabledGroups: []string{"group-all"}, + peerID: "peer-unknown", + expected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + account.Settings.IPv6EnabledGroups = tc.enabledGroups + result := account.PeerIPv6Allowed(tc.peerID) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestIPv6RecalculationOnGroupChange(t *testing.T) { + peerWithV6 := func(id string, v6 string) *nbpeer.Peer { + p := &nbpeer.Peer{ + ID: id, + IP: netip.MustParseAddr("100.64.0.1"), + } + if v6 != "" { + p.IPv6 = netip.MustParseAddr(v6) + } + return p + } + + t.Run("peer loses IPv6 when removed from enabled groups", func(t *testing.T) { + peer := peerWithV6("peer1", "fd00::1") + + account := &Account{ + Peers: map[string]*nbpeer.Peer{"peer1": peer}, + Groups: map[string]*Group{ + "group-a": {ID: "group-a", Peers: []string{"peer1"}}, + "group-b": {ID: "group-b", Peers: []string{}}, + }, + Settings: &Settings{ + IPv6EnabledGroups: []string{"group-a"}, + }, + } + + assert.True(t, account.PeerIPv6Allowed("peer1"), "peer should be allowed before change") + + // Move peer out of enabled group + account.Groups["group-a"].Peers = []string{} + account.Groups["group-b"].Peers = []string{"peer1"} + + assert.False(t, account.PeerIPv6Allowed("peer1"), "peer should be denied after group change") + }) + + t.Run("peer gains IPv6 when added to enabled group", func(t *testing.T) { + peer := peerWithV6("peer1", "") + + account := &Account{ + Peers: map[string]*nbpeer.Peer{"peer1": peer}, + Groups: map[string]*Group{ + "group-a": {ID: "group-a", Peers: []string{}}, + "group-b": {ID: "group-b", Peers: []string{"peer1"}}, + }, + Settings: &Settings{ + IPv6EnabledGroups: []string{"group-a"}, + }, + } + + assert.False(t, account.PeerIPv6Allowed("peer1"), "peer should be denied before change") + + // Add peer to enabled group + account.Groups["group-a"].Peers = []string{"peer1"} + + assert.True(t, account.PeerIPv6Allowed("peer1"), "peer should be allowed after joining enabled group") + }) + + t.Run("peer in two groups, one leaves enabled list", func(t *testing.T) { + peer := peerWithV6("peer1", "fd00::1") + + account := &Account{ + Peers: map[string]*nbpeer.Peer{"peer1": peer}, + Groups: map[string]*Group{ + "group-a": {ID: "group-a", Peers: []string{"peer1"}}, + "group-b": {ID: "group-b", Peers: []string{"peer1"}}, + }, + Settings: &Settings{ + IPv6EnabledGroups: []string{"group-a", "group-b"}, + }, + } + + assert.True(t, account.PeerIPv6Allowed("peer1")) + + // Remove group-a from enabled list, peer still in group-b + account.Settings.IPv6EnabledGroups = []string{"group-b"} + + assert.True(t, account.PeerIPv6Allowed("peer1"), "peer should still be allowed via group-b") + }) + + t.Run("peer in two groups, both leave enabled list", func(t *testing.T) { + peer := peerWithV6("peer1", "fd00::1") + + account := &Account{ + Peers: map[string]*nbpeer.Peer{"peer1": peer}, + Groups: map[string]*Group{ + "group-a": {ID: "group-a", Peers: []string{"peer1"}}, + "group-b": {ID: "group-b", Peers: []string{"peer1"}}, + }, + Settings: &Settings{ + IPv6EnabledGroups: []string{"group-a", "group-b"}, + }, + } + + assert.True(t, account.PeerIPv6Allowed("peer1")) + + // Clear all enabled groups + account.Settings.IPv6EnabledGroups = []string{} + + assert.False(t, account.PeerIPv6Allowed("peer1"), "peer should be denied when no groups enabled") + }) + + t.Run("enabling a group gives only its peers IPv6", func(t *testing.T) { + account := &Account{ + Peers: map[string]*nbpeer.Peer{ + "peer1": peerWithV6("peer1", ""), + "peer2": peerWithV6("peer2", ""), + "peer3": peerWithV6("peer3", ""), + }, + Groups: map[string]*Group{ + "group-devs": {ID: "group-devs", Peers: []string{"peer1", "peer2"}}, + "group-infra": {ID: "group-infra", Peers: []string{"peer2", "peer3"}}, + }, + Settings: &Settings{ + IPv6EnabledGroups: []string{"group-devs"}, + }, + } + + assert.True(t, account.PeerIPv6Allowed("peer1"), "peer1 in devs") + assert.True(t, account.PeerIPv6Allowed("peer2"), "peer2 in devs") + assert.False(t, account.PeerIPv6Allowed("peer3"), "peer3 not in devs") + + // Add infra group + account.Settings.IPv6EnabledGroups = []string{"group-devs", "group-infra"} + + assert.True(t, account.PeerIPv6Allowed("peer1"), "peer1 still in devs") + assert.True(t, account.PeerIPv6Allowed("peer2"), "peer2 in both") + assert.True(t, account.PeerIPv6Allowed("peer3"), "peer3 now in infra") + }) +} + +func TestPeerIPv6AllowedEmbeddedProxy(t *testing.T) { + account := &Account{ + Peers: map[string]*nbpeer.Peer{ + "peer1": {ID: "peer1"}, + "proxy": {ID: "proxy", ProxyMeta: nbpeer.ProxyMeta{Embedded: true, Cluster: "netbird.test"}}, + }, + Groups: map[string]*Group{ + "group-devs": {ID: "group-devs", Peers: []string{"peer1"}}, + }, + Settings: &Settings{}, + } + + t.Run("embedded proxy allowed when any v6 group exists, without group membership", func(t *testing.T) { + account.Settings.IPv6EnabledGroups = []string{"group-devs"} + assert.True(t, account.PeerIPv6Allowed("proxy"), "embedded proxy participates in v6 overlay") + assert.True(t, account.PeerIPv6Allowed("peer1"), "regular peer in enabled group still allowed") + }) + + t.Run("embedded proxy denied when no v6 group enabled", func(t *testing.T) { + account.Settings.IPv6EnabledGroups = nil + assert.False(t, account.PeerIPv6Allowed("proxy"), "v6 disabled account-wide denies embedded proxies too") + }) + + t.Run("non-embedded peer outside any enabled group is not pulled in", func(t *testing.T) { + account.Settings.IPv6EnabledGroups = []string{"group-devs"} + account.Peers["lonely"] = &nbpeer.Peer{ID: "lonely"} + assert.False(t, account.PeerIPv6Allowed("lonely"), "embedded-proxy bypass must not leak to regular peers") + }) +} diff --git a/management/server/types/job.go b/management/server/types/job.go new file mode 100644 index 000000000..bad8f00ba --- /dev/null +++ b/management/server/types/job.go @@ -0,0 +1,228 @@ +package types + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/management/status" +) + +type JobStatus string + +const ( + JobStatusPending JobStatus = "pending" + JobStatusSucceeded JobStatus = "succeeded" + JobStatusFailed JobStatus = "failed" +) + +type JobType string + +const ( + JobTypeBundle JobType = "bundle" +) + +const ( + // MaxJobReasonLength is the maximum length allowed for job failure reasons + MaxJobReasonLength = 4096 +) + +type Job struct { + // ID is the primary identifier + ID string `gorm:"primaryKey"` + + // CreatedAt when job was created (UTC) + CreatedAt time.Time `gorm:"autoCreateTime"` + + // CompletedAt when job finished, null if still running + CompletedAt *time.Time + + // TriggeredBy user that triggered this job + TriggeredBy string `gorm:"index"` + + PeerID string `gorm:"index"` + + AccountID string `gorm:"index"` + + // Status of the job: pending, succeeded, failed + Status JobStatus `gorm:"index;type:varchar(50)"` + + // FailedReason describes why the job failed (if failed) + FailedReason string + + Workload Workload `gorm:"embedded;embeddedPrefix:workload_"` +} + +type Workload struct { + Type JobType `gorm:"column:workload_type;index;type:varchar(50)"` + Parameters json.RawMessage `gorm:"type:json"` + Result json.RawMessage `gorm:"type:json"` +} + +// NewJob creates a new job with default fields and validation +func NewJob(triggeredBy, accountID, peerID string, req *api.JobRequest) (*Job, error) { + if req == nil { + return nil, status.Errorf(status.BadRequest, "job request cannot be nil") + } + + // Determine job type + jobTypeStr, err := req.Workload.Discriminator() + if err != nil { + return nil, status.Errorf(status.BadRequest, "could not determine job type: %v", err) + } + jobType := JobType(jobTypeStr) + + if jobType == "" { + return nil, status.Errorf(status.BadRequest, "job type is required") + } + + var workload Workload + + switch jobType { + case JobTypeBundle: + if err := validateAndBuildBundleParams(req.Workload, &workload); err != nil { + return nil, status.Errorf(status.BadRequest, "%v", err) + } + default: + return nil, status.Errorf(status.BadRequest, "unsupported job type: %s", jobType) + } + + return &Job{ + ID: uuid.New().String(), + TriggeredBy: triggeredBy, + PeerID: peerID, + AccountID: accountID, + Status: JobStatusPending, + CreatedAt: time.Now().UTC(), + Workload: workload, + }, nil +} + +func (j *Job) BuildWorkloadResponse() (*api.WorkloadResponse, error) { + var wl api.WorkloadResponse + + switch j.Workload.Type { + case JobTypeBundle: + if err := j.buildBundleResponse(&wl); err != nil { + return nil, status.Errorf(status.Internal, "failed to process job: %v", err.Error()) + } + return &wl, nil + + default: + return nil, status.Errorf(status.InvalidArgument, "unknown job type: %v", j.Workload.Type) + } +} + +func (j *Job) buildBundleResponse(wl *api.WorkloadResponse) error { + var p api.BundleParameters + if err := json.Unmarshal(j.Workload.Parameters, &p); err != nil { + return fmt.Errorf("invalid parameters for bundle job: %w", err) + } + var r api.BundleResult + if err := json.Unmarshal(j.Workload.Result, &r); err != nil { + return fmt.Errorf("invalid result for bundle job: %w", err) + } + + if err := wl.FromBundleWorkloadResponse(api.BundleWorkloadResponse{ + Type: api.WorkloadTypeBundle, + Parameters: p, + Result: r, + }); err != nil { + return fmt.Errorf("unknown job parameters: %v", err) + } + return nil +} + +func validateAndBuildBundleParams(req api.WorkloadRequest, workload *Workload) error { + bundle, err := req.AsBundleWorkloadRequest() + if err != nil { + return fmt.Errorf("invalid parameters for bundle job") + } + // validate bundle_for_time <= 5 minutes if BundleFor is enabled + if bundle.Parameters.BundleFor && (bundle.Parameters.BundleForTime < 1 || bundle.Parameters.BundleForTime > 5) { + return fmt.Errorf("bundle_for_time must be between 1 and 5, got %d", bundle.Parameters.BundleForTime) + } + // validate log-file-count ≥ 1 and ≤ 1000 + if bundle.Parameters.LogFileCount < 1 || bundle.Parameters.LogFileCount > 1000 { + return fmt.Errorf("log-file-count must be between 1 and 1000, got %d", bundle.Parameters.LogFileCount) + } + + workload.Parameters, err = json.Marshal(bundle.Parameters) + if err != nil { + return fmt.Errorf("failed to marshal workload parameters: %w", err) + } + workload.Result = []byte("{}") + workload.Type = JobType(api.WorkloadTypeBundle) + + return nil +} + +// ApplyResponse validates and maps a proto.JobResponse into the Job fields. +func (j *Job) ApplyResponse(resp *proto.JobResponse) error { + if resp == nil { + return nil + } + + j.ID = string(resp.ID) + now := time.Now().UTC() + j.CompletedAt = &now + switch resp.Status { + case proto.JobStatus_succeeded: + j.Status = JobStatusSucceeded + case proto.JobStatus_failed: + j.Status = JobStatusFailed + if len(resp.Reason) > 0 { + reason := string(resp.Reason) + if len(resp.Reason) > MaxJobReasonLength { + reason = string(resp.Reason[:MaxJobReasonLength]) + "... (truncated)" + } + j.FailedReason = fmt.Sprintf("Client error: '%s'", reason) + } + return nil + default: + return fmt.Errorf("unexpected job status: %v", resp.Status) + } + + // Handle workload results (oneof) + var err error + switch r := resp.WorkloadResults.(type) { + case *proto.JobResponse_Bundle: + if j.Workload.Result, err = json.Marshal(r.Bundle); err != nil { + return fmt.Errorf("failed to marshal workload results: %w", err) + } + default: + return fmt.Errorf("unsupported workload response type: %T", r) + } + return nil +} + +func (j *Job) ToStreamJobRequest() (*proto.JobRequest, error) { + switch j.Workload.Type { + case JobTypeBundle: + return j.buildStreamBundleResponse() + default: + return nil, status.Errorf(status.InvalidArgument, "unknown job type: %v", j.Workload.Type) + } +} + +func (j *Job) buildStreamBundleResponse() (*proto.JobRequest, error) { + var p api.BundleParameters + if err := json.Unmarshal(j.Workload.Parameters, &p); err != nil { + return nil, fmt.Errorf("invalid parameters for bundle job: %w", err) + } + return &proto.JobRequest{ + ID: []byte(j.ID), + WorkloadParameters: &proto.JobRequest_Bundle{ + Bundle: &proto.BundleParameters{ + BundleFor: p.BundleFor, + BundleForTime: int64(p.BundleForTime), + LogFileCount: int32(p.LogFileCount), + Anonymize: p.Anonymize, + }, + }, + }, nil +} diff --git a/management/server/types/network_test.go b/management/server/types/network_test.go deleted file mode 100644 index 4c1459ce5..000000000 --- a/management/server/types/network_test.go +++ /dev/null @@ -1,153 +0,0 @@ -package types - -import ( - "net" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNewNetwork(t *testing.T) { - network := NewNetwork() - - // generated net should be a subnet of a larger 100.64.0.0/10 net - ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 192, 0, 0}} - assert.Equal(t, ipNet.Contains(network.Net.IP), true) -} - -func TestAllocatePeerIP(t *testing.T) { - ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 255, 255, 0}} - var ips []net.IP - for i := 0; i < 252; i++ { - ip, err := AllocatePeerIP(ipNet, ips) - if err != nil { - t.Fatal(err) - } - ips = append(ips, ip) - } - - assert.Len(t, ips, 252) - - uniq := make(map[string]struct{}) - for _, ip := range ips { - if _, ok := uniq[ip.String()]; !ok { - uniq[ip.String()] = struct{}{} - } else { - t.Errorf("found duplicate IP %s", ip.String()) - } - } -} - -func TestAllocatePeerIPSmallSubnet(t *testing.T) { - // Test /27 network (10.0.0.0/27) - should only have 30 usable IPs (10.0.0.1 to 10.0.0.30) - ipNet := net.IPNet{IP: net.ParseIP("10.0.0.0"), Mask: net.IPMask{255, 255, 255, 224}} - var ips []net.IP - - // Allocate all available IPs in the /27 network - for i := 0; i < 30; i++ { - ip, err := AllocatePeerIP(ipNet, ips) - if err != nil { - t.Fatal(err) - } - - // Verify IP is within the correct range - if !ipNet.Contains(ip) { - t.Errorf("allocated IP %s is not within network %s", ip.String(), ipNet.String()) - } - - ips = append(ips, ip) - } - - assert.Len(t, ips, 30) - - // Verify all IPs are unique - uniq := make(map[string]struct{}) - for _, ip := range ips { - if _, ok := uniq[ip.String()]; !ok { - uniq[ip.String()] = struct{}{} - } else { - t.Errorf("found duplicate IP %s", ip.String()) - } - } - - // Try to allocate one more IP - should fail as network is full - _, err := AllocatePeerIP(ipNet, ips) - if err == nil { - t.Error("expected error when network is full, but got none") - } -} - -func TestAllocatePeerIPVariousCIDRs(t *testing.T) { - testCases := []struct { - name string - cidr string - expectedUsable int - }{ - {"/30 network", "192.168.1.0/30", 2}, // 4 total - 2 reserved = 2 usable - {"/29 network", "192.168.1.0/29", 6}, // 8 total - 2 reserved = 6 usable - {"/28 network", "192.168.1.0/28", 14}, // 16 total - 2 reserved = 14 usable - {"/27 network", "192.168.1.0/27", 30}, // 32 total - 2 reserved = 30 usable - {"/26 network", "192.168.1.0/26", 62}, // 64 total - 2 reserved = 62 usable - {"/25 network", "192.168.1.0/25", 126}, // 128 total - 2 reserved = 126 usable - {"/16 network", "10.0.0.0/16", 65534}, // 65536 total - 2 reserved = 65534 usable - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - _, ipNet, err := net.ParseCIDR(tc.cidr) - require.NoError(t, err) - - var ips []net.IP - - // For larger networks, test only a subset to avoid long test runs - testCount := tc.expectedUsable - if testCount > 1000 { - testCount = 1000 - } - - // Allocate IPs and verify they're within the correct range - for i := 0; i < testCount; i++ { - ip, err := AllocatePeerIP(*ipNet, ips) - require.NoError(t, err, "failed to allocate IP %d", i) - - // Verify IP is within the correct range - assert.True(t, ipNet.Contains(ip), "allocated IP %s is not within network %s", ip.String(), ipNet.String()) - - // Verify IP is not network or broadcast address - networkIP := ipNet.IP.Mask(ipNet.Mask) - ones, bits := ipNet.Mask.Size() - hostBits := bits - ones - broadcastInt := uint32(ipToUint32(networkIP)) + (1 << hostBits) - 1 - broadcastIP := uint32ToIP(broadcastInt) - - assert.False(t, ip.Equal(networkIP), "allocated network address %s", ip.String()) - assert.False(t, ip.Equal(broadcastIP), "allocated broadcast address %s", ip.String()) - - ips = append(ips, ip) - } - - assert.Len(t, ips, testCount) - - // Verify all IPs are unique - uniq := make(map[string]struct{}) - for _, ip := range ips { - ipStr := ip.String() - assert.NotContains(t, uniq, ipStr, "found duplicate IP %s", ipStr) - uniq[ipStr] = struct{}{} - } - }) - } -} - -func TestGenerateIPs(t *testing.T) { - ipNet := net.IPNet{IP: net.ParseIP("100.64.0.0"), Mask: net.IPMask{255, 255, 255, 0}} - ips, ipsLen := generateIPs(&ipNet, map[string]struct{}{"100.64.0.0": {}}) - if ipsLen != 252 { - t.Errorf("expected 252 ips, got %d", len(ips)) - return - } - if ips[len(ips)-1].String() != "100.64.0.253" { - t.Errorf("expected last ip to be: 100.64.0.253, got %s", ips[len(ips)-1].String()) - } -} diff --git a/management/server/types/networkmap.go b/management/server/types/networkmap.go deleted file mode 100644 index c1099726f..000000000 --- a/management/server/types/networkmap.go +++ /dev/null @@ -1,58 +0,0 @@ -package types - -import ( - "context" - - nbdns "github.com/netbirdio/netbird/dns" - nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/management/server/telemetry" -) - -func (a *Account) initNetworkMapBuilder(validatedPeers map[string]struct{}) { - if a.NetworkMapCache != nil { - return - } - a.nmapInitOnce.Do(func() { - a.NetworkMapCache = NewNetworkMapBuilder(a, validatedPeers) - }) -} - -func (a *Account) InitNetworkMapBuilderIfNeeded(validatedPeers map[string]struct{}) { - a.initNetworkMapBuilder(validatedPeers) -} - -func (a *Account) GetPeerNetworkMapExp( - ctx context.Context, - peerID string, - peersCustomZone nbdns.CustomZone, - validatedPeers map[string]struct{}, - metrics *telemetry.AccountManagerMetrics, -) *NetworkMap { - a.initNetworkMapBuilder(validatedPeers) - return a.NetworkMapCache.GetPeerNetworkMap(ctx, peerID, peersCustomZone, validatedPeers, metrics) -} - -func (a *Account) OnPeerAddedUpdNetworkMapCache(peerId string) error { - if a.NetworkMapCache == nil { - return nil - } - return a.NetworkMapCache.OnPeerAddedIncremental(peerId) -} - -func (a *Account) OnPeerDeletedUpdNetworkMapCache(peerId string) error { - if a.NetworkMapCache == nil { - return nil - } - return a.NetworkMapCache.OnPeerDeleted(peerId) -} - -func (a *Account) UpdatePeerInNetworkMapCache(peer *nbpeer.Peer) { - if a.NetworkMapCache == nil { - return - } - a.NetworkMapCache.UpdatePeer(peer) -} - -func (a *Account) RecalculateNetworkMapCache(validatedPeers map[string]struct{}) { - a.initNetworkMapBuilder(validatedPeers) -} diff --git a/management/server/types/networkmap_benchmark_test.go b/management/server/types/networkmap_benchmark_test.go new file mode 100644 index 000000000..38272e7b0 --- /dev/null +++ b/management/server/types/networkmap_benchmark_test.go @@ -0,0 +1,217 @@ +package types_test + +import ( + "context" + "fmt" + "os" + "testing" + + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/server/types" +) + +type benchmarkScale struct { + name string + peers int + groups int +} + +var defaultScales = []benchmarkScale{ + {"100peers_5groups", 100, 5}, + {"500peers_20groups", 500, 20}, + {"1000peers_50groups", 1000, 50}, + {"5000peers_100groups", 5000, 100}, + {"10000peers_200groups", 10000, 200}, + {"20000peers_200groups", 20000, 200}, + {"30000peers_300groups", 30000, 300}, +} + +func skipCIBenchmark(b *testing.B) { + if os.Getenv("CI") == "true" { + b.Skip("Skipping benchmark in CI") + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Single Peer Network Map Generation +// ────────────────────────────────────────────────────────────────────────────── + +// BenchmarkNetworkMapGeneration_Components benchmarks the components-based approach for a single peer. +func BenchmarkNetworkMapGeneration_Components(b *testing.B) { + skipCIBenchmark(b) + for _, scale := range defaultScales { + b.Run(scale.name, func(b *testing.B) { + account, validatedPeers := scalableTestAccount(scale.peers, scale.groups) + ctx := context.Background() + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + b.ReportAllocs() + b.ResetTimer() + for range b.N { + _ = account.GetPeerNetworkMapFromComponents(ctx, "peer-0", nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, nil, groupIDToUserIDs) + } + }) + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// All Peers (UpdateAccountPeers hot path) +// ────────────────────────────────────────────────────────────────────────────── + +// BenchmarkNetworkMapGeneration_AllPeers benchmarks generating network maps for ALL peers. +func BenchmarkNetworkMapGeneration_AllPeers(b *testing.B) { + skipCIBenchmark(b) + scales := []benchmarkScale{ + {"100peers_5groups", 100, 5}, + {"500peers_20groups", 500, 20}, + {"1000peers_50groups", 1000, 50}, + {"5000peers_100groups", 5000, 100}, + } + + for _, scale := range scales { + account, validatedPeers := scalableTestAccount(scale.peers, scale.groups) + ctx := context.Background() + + peerIDs := make([]string, 0, len(account.Peers)) + for peerID := range account.Peers { + peerIDs = append(peerIDs, peerID) + } + + b.Run("components/"+scale.name, func(b *testing.B) { + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + b.ReportAllocs() + b.ResetTimer() + for range b.N { + for _, peerID := range peerIDs { + _ = account.GetPeerNetworkMapFromComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, nil, groupIDToUserIDs) + } + } + }) + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Sub-operations +// ────────────────────────────────────────────────────────────────────────────── + +// BenchmarkNetworkMapGeneration_ComponentsCreation benchmarks components extraction. +func BenchmarkNetworkMapGeneration_ComponentsCreation(b *testing.B) { + skipCIBenchmark(b) + for _, scale := range defaultScales { + b.Run(scale.name, func(b *testing.B) { + account, validatedPeers := scalableTestAccount(scale.peers, scale.groups) + ctx := context.Background() + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + b.ReportAllocs() + b.ResetTimer() + for range b.N { + _ = account.GetPeerNetworkMapComponents(ctx, "peer-0", nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, groupIDToUserIDs) + } + }) + } +} + +// BenchmarkNetworkMapGeneration_ComponentsCalculation benchmarks calculation from pre-built components. +func BenchmarkNetworkMapGeneration_ComponentsCalculation(b *testing.B) { + skipCIBenchmark(b) + for _, scale := range defaultScales { + b.Run(scale.name, func(b *testing.B) { + account, validatedPeers := scalableTestAccount(scale.peers, scale.groups) + ctx := context.Background() + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + components := account.GetPeerNetworkMapComponents(ctx, "peer-0", nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, groupIDToUserIDs) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + _ = types.CalculateNetworkMapFromComponents(ctx, components) + } + }) + } +} + +// BenchmarkNetworkMapGeneration_PrecomputeMaps benchmarks precomputed map costs. +func BenchmarkNetworkMapGeneration_PrecomputeMaps(b *testing.B) { + skipCIBenchmark(b) + for _, scale := range defaultScales { + b.Run("ResourcePoliciesMap/"+scale.name, func(b *testing.B) { + account, _ := scalableTestAccount(scale.peers, scale.groups) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + _ = account.GetResourcePoliciesMap() + } + }) + b.Run("ResourceRoutersMap/"+scale.name, func(b *testing.B) { + account, _ := scalableTestAccount(scale.peers, scale.groups) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + _ = account.GetResourceRoutersMap() + } + }) + b.Run("ActiveGroupUsers/"+scale.name, func(b *testing.B) { + account, _ := scalableTestAccount(scale.peers, scale.groups) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + _ = account.GetActiveGroupUsers() + } + }) + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Scaling Analysis +// ────────────────────────────────────────────────────────────────────────────── + +// BenchmarkNetworkMapGeneration_GroupScaling tests group count impact on performance. +func BenchmarkNetworkMapGeneration_GroupScaling(b *testing.B) { + skipCIBenchmark(b) + groupCounts := []int{1, 5, 20, 50, 100, 200, 500} + for _, numGroups := range groupCounts { + b.Run(fmt.Sprintf("components_%dgroups", numGroups), func(b *testing.B) { + account, validatedPeers := scalableTestAccount(1000, numGroups) + ctx := context.Background() + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + b.ReportAllocs() + b.ResetTimer() + for range b.N { + _ = account.GetPeerNetworkMapFromComponents(ctx, "peer-0", nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, nil, groupIDToUserIDs) + } + }) + } +} + +// BenchmarkNetworkMapGeneration_PeerScaling tests peer count impact on performance. +func BenchmarkNetworkMapGeneration_PeerScaling(b *testing.B) { + skipCIBenchmark(b) + peerCounts := []int{50, 100, 500, 1000, 2000, 5000, 10000, 20000, 30000} + for _, numPeers := range peerCounts { + numGroups := numPeers / 20 + if numGroups < 1 { + numGroups = 1 + } + b.Run(fmt.Sprintf("components_%dpeers", numPeers), func(b *testing.B) { + account, validatedPeers := scalableTestAccount(numPeers, numGroups) + ctx := context.Background() + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + b.ReportAllocs() + b.ResetTimer() + for range b.N { + _ = account.GetPeerNetworkMapFromComponents(ctx, "peer-0", nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, nil, groupIDToUserIDs) + } + }) + } +} diff --git a/management/server/types/networkmap_components_correctness_test.go b/management/server/types/networkmap_components_correctness_test.go new file mode 100644 index 000000000..825d51d4e --- /dev/null +++ b/management/server/types/networkmap_components_correctness_test.go @@ -0,0 +1,1331 @@ +package types_test + +import ( + "context" + "fmt" + "net" + "net/netip" + "slices" + "testing" + "time" + + "github.com/rs/xid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbdns "github.com/netbirdio/netbird/dns" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + networkTypes "github.com/netbirdio/netbird/management/server/networks/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/posture" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/route" +) + +// scalableTestAccountWithoutDefaultPolicy creates an account without the blanket "Allow All" policy. +// Use this for tests that need to verify feature-specific connectivity in isolation. +func scalableTestAccountWithoutDefaultPolicy(numPeers, numGroups int) (*types.Account, map[string]struct{}) { + return buildScalableTestAccount(numPeers, numGroups, false) +} + +// scalableTestAccount creates a realistic account with a blanket "Allow All" policy +// plus per-group policies, routes, network resources, posture checks, and DNS settings. +func scalableTestAccount(numPeers, numGroups int) (*types.Account, map[string]struct{}) { + return buildScalableTestAccount(numPeers, numGroups, true) +} + +// buildScalableTestAccount is the core builder. When withDefaultPolicy is true it adds +// a blanket group-all <-> group-all allow rule; when false the only policies are the +// per-group ones, so tests can verify feature-specific connectivity in isolation. +func buildScalableTestAccount(numPeers, numGroups int, withDefaultPolicy bool) (*types.Account, map[string]struct{}) { + peers := make(map[string]*nbpeer.Peer, numPeers) + allGroupPeers := make([]string, 0, numPeers) + + for i := range numPeers { + peerID := fmt.Sprintf("peer-%d", i) + ip := netip.AddrFrom4([4]byte{100, byte(64 + i/65536), byte((i / 256) % 256), byte(i % 256)}) + wtVersion := "0.25.0" + if i%2 == 0 { + wtVersion = "0.40.0" + } + + p := &nbpeer.Peer{ + ID: peerID, + IP: ip, + Key: fmt.Sprintf("key-%s", peerID), + DNSLabel: fmt.Sprintf("peer%d", i), + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, + UserID: "user-admin", + Meta: nbpeer.PeerSystemMeta{WtVersion: wtVersion, GoOS: "linux"}, + } + + if i == numPeers-2 { + p.LoginExpirationEnabled = true + pastTimestamp := time.Now().Add(-2 * time.Hour) + p.LastLogin = &pastTimestamp + } + + peers[peerID] = p + allGroupPeers = append(allGroupPeers, peerID) + } + + groups := make(map[string]*types.Group, numGroups+1) + groups["group-all"] = &types.Group{ID: "group-all", Name: "All", Peers: allGroupPeers} + + peersPerGroup := numPeers / numGroups + if peersPerGroup < 1 { + peersPerGroup = 1 + } + + for g := range numGroups { + groupID := fmt.Sprintf("group-%d", g) + groupPeers := make([]string, 0, peersPerGroup) + start := g * peersPerGroup + end := start + peersPerGroup + if end > numPeers { + end = numPeers + } + for i := start; i < end; i++ { + groupPeers = append(groupPeers, fmt.Sprintf("peer-%d", i)) + } + groups[groupID] = &types.Group{ID: groupID, PublicID: xid.New().String(), Name: fmt.Sprintf("Group %d", g), Peers: groupPeers} + } + + policies := make([]*types.Policy, 0, numGroups+2) + if withDefaultPolicy { + policies = append(policies, &types.Policy{ + ID: "policy-all", PublicID: xid.New().String(), Name: "Default-Allow", Enabled: true, + Rules: []*types.PolicyRule{{ + ID: "rule-all", Name: "Allow All", Enabled: true, Action: types.PolicyTrafficActionAccept, + Protocol: types.PolicyRuleProtocolALL, Bidirectional: true, + Sources: []string{"group-all"}, Destinations: []string{"group-all"}, + }}, + }) + } + + for g := range numGroups { + groupID := fmt.Sprintf("group-%d", g) + dstGroup := fmt.Sprintf("group-%d", (g+1)%numGroups) + policies = append(policies, &types.Policy{ + ID: fmt.Sprintf("policy-%d", g), PublicID: xid.New().String(), Name: fmt.Sprintf("Policy %d", g), Enabled: true, + Rules: []*types.PolicyRule{{ + ID: fmt.Sprintf("rule-%d", g), Name: fmt.Sprintf("Rule %d", g), Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolTCP, + Bidirectional: true, + Ports: []string{"8080"}, + Sources: []string{groupID}, Destinations: []string{dstGroup}, + }}, + }) + } + + if numGroups >= 2 { + policies = append(policies, &types.Policy{ + ID: "policy-drop", PublicID: xid.New().String(), Name: "Drop DB traffic", Enabled: true, + Rules: []*types.PolicyRule{{ + ID: "rule-drop", Name: "Drop DB", Enabled: true, Action: types.PolicyTrafficActionDrop, + Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"5432"}, Bidirectional: true, + Sources: []string{"group-0"}, Destinations: []string{"group-1"}, + }}, + }) + } + + numRoutes := numGroups + if numRoutes > 20 { + numRoutes = 20 + } + routes := make(map[route.ID]*route.Route, numRoutes) + for r := range numRoutes { + routeID := route.ID(fmt.Sprintf("route-%d", r)) + peerIdx := (numPeers / 2) + r + if peerIdx >= numPeers { + peerIdx = numPeers - 1 + } + routePeerID := fmt.Sprintf("peer-%d", peerIdx) + groupID := fmt.Sprintf("group-%d", r%numGroups) + routes[routeID] = &route.Route{ + ID: routeID, + PublicID: xid.New().String(), + Network: netip.MustParsePrefix(fmt.Sprintf("10.%d.0.0/16", r)), + Peer: peers[routePeerID].Key, + PeerID: routePeerID, + Description: fmt.Sprintf("Route %d", r), + Enabled: true, + PeerGroups: []string{groupID}, + Groups: []string{"group-all"}, + AccessControlGroups: []string{groupID}, + AccountID: "test-account", + } + } + + numResources := numGroups / 2 + if numResources < 1 { + numResources = 1 + } + if numResources > 50 { + numResources = 50 + } + + networkResources := make([]*resourceTypes.NetworkResource, 0, numResources) + networksList := make([]*networkTypes.Network, 0, numResources) + networkRouters := make([]*routerTypes.NetworkRouter, 0, numResources) + + routingPeerStart := numPeers * 3 / 4 + for nr := range numResources { + netID := fmt.Sprintf("net-%d", nr) + resID := fmt.Sprintf("res-%d", nr) + routerPeerIdx := routingPeerStart + nr + if routerPeerIdx >= numPeers { + routerPeerIdx = numPeers - 1 + } + routerPeerID := fmt.Sprintf("peer-%d", routerPeerIdx) + + networksList = append(networksList, &networkTypes.Network{ID: netID, PublicID: xid.New().String(), Name: fmt.Sprintf("Network %d", nr), AccountID: "test-account"}) + networkResources = append(networkResources, &resourceTypes.NetworkResource{ + ID: resID, PublicID: xid.New().String(), NetworkID: netID, AccountID: "test-account", Enabled: true, + Address: fmt.Sprintf("svc-%d.netbird.cloud", nr), + }) + networkRouters = append(networkRouters, &routerTypes.NetworkRouter{ + ID: fmt.Sprintf("router-%d", nr), PublicID: xid.New().String(), NetworkID: netID, Peer: routerPeerID, + Enabled: true, AccountID: "test-account", + }) + + policies = append(policies, &types.Policy{ + ID: fmt.Sprintf("policy-res-%d", nr), PublicID: xid.New().String(), Name: fmt.Sprintf("Resource Policy %d", nr), Enabled: true, + SourcePostureChecks: []string{"posture-check-ver"}, + Rules: []*types.PolicyRule{{ + ID: fmt.Sprintf("rule-res-%d", nr), Name: fmt.Sprintf("Allow Resource %d", nr), Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolALL, Bidirectional: true, + Sources: []string{fmt.Sprintf("group-%d", nr%numGroups)}, + DestinationResource: types.Resource{ID: resID}, + }}, + }) + } + + account := &types.Account{ + Id: "test-account", + Peers: peers, + Groups: groups, + Policies: policies, + Routes: routes, + Users: map[string]*types.User{ + "user-admin": {Id: "user-admin", Role: types.UserRoleAdmin, IsServiceUser: false, AccountID: "test-account"}, + }, + Network: &types.Network{ + Identifier: "net-test", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}, Serial: 1, + }, + DNSSettings: types.DNSSettings{DisabledManagementGroups: []string{}}, + NameServerGroups: map[string]*nbdns.NameServerGroup{ + "ns-group-main": { + ID: "ns-group-main", PublicID: xid.New().String(), Name: "Main NS", Enabled: true, Groups: []string{"group-all"}, + NameServers: []nbdns.NameServer{{IP: netip.MustParseAddr("8.8.8.8"), NSType: nbdns.UDPNameServerType, Port: 53}}, + }, + }, + PostureChecks: []*posture.Checks{ + {ID: "posture-check-ver", PublicID: xid.New().String(), Name: "Check version", Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.26.0"}, + }}, + }, + NetworkResources: networkResources, + Networks: networksList, + NetworkRouters: networkRouters, + Settings: &types.Settings{PeerLoginExpirationEnabled: true, PeerLoginExpiration: 1 * time.Hour}, + } + + for _, p := range account.Policies { + p.AccountID = account.Id + } + for _, r := range account.Routes { + r.AccountID = account.Id + } + + validatedPeers := make(map[string]struct{}, numPeers) + for i := range numPeers { + peerID := fmt.Sprintf("peer-%d", i) + if i != numPeers-1 { + validatedPeers[peerID] = struct{}{} + } + } + + return account, validatedPeers +} + +// componentsNetworkMap is a convenience wrapper for GetPeerNetworkMapFromComponents. +func componentsNetworkMap(account *types.Account, peerID string, validatedPeers map[string]struct{}) *types.NetworkMap { + return account.GetPeerNetworkMapFromComponents( + context.Background(), peerID, nbdns.CustomZone{}, nil, + validatedPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), + nil, account.GetActiveGroupUsers(), + ) +} + +// ────────────────────────────────────────────────────────────────────────────── +// 1. PEER VISIBILITY & GROUPS +// ────────────────────────────────────────────────────────────────────────────── + +func TestComponents_PeerVisibility(t *testing.T) { + account, validatedPeers := scalableTestAccount(100, 5) + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + assert.Equal(t, len(validatedPeers)-1-len(nm.OfflinePeers), len(nm.Peers), "peer should see all other validated non-expired peers") +} + +func TestComponents_PeerDoesNotSeeItself(t *testing.T) { + account, validatedPeers := scalableTestAccount(50, 5) + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + for _, p := range nm.Peers { + assert.NotEqual(t, "peer-0", p.ID, "peer should not see itself") + } +} + +func TestComponents_IntraGroupConnectivity(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + + peerIDs := make(map[string]bool, len(nm.Peers)) + for _, p := range nm.Peers { + peerIDs[p.ID] = true + } + assert.True(t, peerIDs["peer-5"], "peer-0 should see peer-5 from same group") +} + +func TestComponents_CrossGroupConnectivity(t *testing.T) { + // Without default policy, only per-group policies provide connectivity + account, validatedPeers := scalableTestAccountWithoutDefaultPolicy(20, 2) + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + + peerIDs := make(map[string]bool, len(nm.Peers)) + for _, p := range nm.Peers { + peerIDs[p.ID] = true + } + assert.True(t, peerIDs["peer-10"], "peer-0 should see peer-10 from cross-group policy") +} + +func TestComponents_BidirectionalPolicy(t *testing.T) { + // Without default policy so bidirectional visibility comes only from per-group policies + account, validatedPeers := scalableTestAccountWithoutDefaultPolicy(100, 5) + nm0 := componentsNetworkMap(account, "peer-0", validatedPeers) + nm20 := componentsNetworkMap(account, "peer-20", validatedPeers) + require.NotNil(t, nm0) + require.NotNil(t, nm20) + + peer0SeesPeer20 := false + for _, p := range nm0.Peers { + if p.ID == "peer-20" { + peer0SeesPeer20 = true + } + } + peer20SeesPeer0 := false + for _, p := range nm20.Peers { + if p.ID == "peer-0" { + peer20SeesPeer0 = true + } + } + assert.True(t, peer0SeesPeer20, "peer-0 should see peer-20 via bidirectional policy") + assert.True(t, peer20SeesPeer0, "peer-20 should see peer-0 via bidirectional policy") +} + +// ────────────────────────────────────────────────────────────────────────────── +// 2. PEER EXPIRATION & ACCOUNT SETTINGS +// ────────────────────────────────────────────────────────────────────────────── + +func TestComponents_ExpiredPeerInOfflineList(t *testing.T) { + account, validatedPeers := scalableTestAccount(100, 5) + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + + offlineIDs := make(map[string]bool, len(nm.OfflinePeers)) + for _, p := range nm.OfflinePeers { + offlineIDs[p.ID] = true + } + assert.True(t, offlineIDs["peer-98"], "expired peer should be in OfflinePeers") + for _, p := range nm.Peers { + assert.NotEqual(t, "peer-98", p.ID, "expired peer should not be in active Peers") + } +} + +func TestComponents_ExpirationDisabledSetting(t *testing.T) { + account, validatedPeers := scalableTestAccount(100, 5) + account.Settings.PeerLoginExpirationEnabled = false + + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + + peerIDs := make(map[string]bool, len(nm.Peers)) + for _, p := range nm.Peers { + peerIDs[p.ID] = true + } + assert.True(t, peerIDs["peer-98"], "with expiration disabled, peer-98 should be in active Peers") +} + +func TestComponents_LoginExpiration_PeerLevel(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + account.Settings.PeerLoginExpirationEnabled = true + account.Settings.PeerLoginExpiration = 1 * time.Hour + + pastLogin := time.Now().Add(-2 * time.Hour) + account.Peers["peer-5"].LastLogin = &pastLogin + account.Peers["peer-5"].LoginExpirationEnabled = true + + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + + offlineIDs := make(map[string]bool, len(nm.OfflinePeers)) + for _, p := range nm.OfflinePeers { + offlineIDs[p.ID] = true + } + assert.True(t, offlineIDs["peer-5"], "login-expired peer should be in OfflinePeers") + for _, p := range nm.Peers { + assert.NotEqual(t, "peer-5", p.ID, "login-expired peer should not be in active Peers") + } +} + +func TestComponents_NetworkSerial(t *testing.T) { + account, validatedPeers := scalableTestAccount(50, 5) + account.Network.Serial = 42 + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + assert.Equal(t, uint64(42), nm.Network.Serial, "network serial should match") +} + +// ────────────────────────────────────────────────────────────────────────────── +// 3. NON-VALIDATED PEERS +// ────────────────────────────────────────────────────────────────────────────── + +func TestComponents_NonValidatedPeerExcluded(t *testing.T) { + account, validatedPeers := scalableTestAccount(100, 5) + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + for _, p := range nm.Peers { + assert.NotEqual(t, "peer-99", p.ID, "non-validated peer should not appear in Peers") + } + for _, p := range nm.OfflinePeers { + assert.NotEqual(t, "peer-99", p.ID, "non-validated peer should not appear in OfflinePeers") + } +} + +func TestComponents_NonValidatedTargetPeerGetsEmptyMap(t *testing.T) { + account, validatedPeers := scalableTestAccount(100, 5) + nm := componentsNetworkMap(account, "peer-99", validatedPeers) + require.NotNil(t, nm) + assert.Empty(t, nm.Peers) + assert.Empty(t, nm.FirewallRules) +} + +func TestComponents_NonExistentPeerGetsEmptyMap(t *testing.T) { + account, validatedPeers := scalableTestAccount(100, 5) + nm := componentsNetworkMap(account, "peer-does-not-exist", validatedPeers) + require.NotNil(t, nm) + assert.Empty(t, nm.Peers) + assert.Empty(t, nm.FirewallRules) +} + +// ────────────────────────────────────────────────────────────────────────────── +// 4. POLICIES & FIREWALL RULES +// ────────────────────────────────────────────────────────────────────────────── + +func TestComponents_FirewallRulesGenerated(t *testing.T) { + account, validatedPeers := scalableTestAccount(100, 5) + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + assert.NotEmpty(t, nm.FirewallRules, "should have firewall rules from policies") +} + +func TestComponents_DropPolicyGeneratesDropRules(t *testing.T) { + account, validatedPeers := scalableTestAccount(100, 5) + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + + hasDropRule := false + for _, rule := range nm.FirewallRules { + if rule.Action == string(types.PolicyTrafficActionDrop) { + hasDropRule = true + break + } + } + assert.True(t, hasDropRule, "should have at least one drop firewall rule") +} + +func TestComponents_DisabledPolicyIgnored(t *testing.T) { + account, validatedPeers := scalableTestAccount(50, 2) + for _, p := range account.Policies { + p.Enabled = false + } + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + assert.Empty(t, nm.Peers, "disabled policies should yield no peers") + assert.Empty(t, nm.FirewallRules, "disabled policies should yield no firewall rules") +} + +func TestComponents_PortPolicy(t *testing.T) { + account, validatedPeers := scalableTestAccount(50, 2) + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + + has8080, has5432 := false, false + for _, rule := range nm.FirewallRules { + if rule.Port == "8080" { + has8080 = true + } + if rule.Port == "5432" { + has5432 = true + } + } + assert.True(t, has8080, "should have firewall rule for port 8080") + assert.True(t, has5432, "should have firewall rule for port 5432 (drop policy)") +} + +func TestComponents_PortRangePolicy(t *testing.T) { + account, validatedPeers := scalableTestAccount(50, 2) + account.Peers["peer-0"].Meta.WtVersion = "0.50.0" + + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-port-range", Name: "Port Range", Enabled: true, AccountID: "test-account", + Rules: []*types.PolicyRule{{ + ID: "rule-port-range", Name: "Port Range Rule", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolTCP, + Bidirectional: true, + PortRanges: []types.RulePortRange{{Start: 8000, End: 9000}}, + Sources: []string{"group-0"}, Destinations: []string{"group-1"}, + }}, + }) + + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + + hasPortRange := false + for _, rule := range nm.FirewallRules { + if rule.PortRange.Start == 8000 && rule.PortRange.End == 9000 { + hasPortRange = true + break + } + } + assert.True(t, hasPortRange, "should have firewall rule with port range 8000-9000") +} + +func TestComponents_FirewallRuleDirection(t *testing.T) { + account, validatedPeers := scalableTestAccount(50, 2) + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + + hasIn, hasOut := false, false + for _, rule := range nm.FirewallRules { + if rule.Direction == types.FirewallRuleDirectionIN { + hasIn = true + } + if rule.Direction == types.FirewallRuleDirectionOUT { + hasOut = true + } + } + assert.True(t, hasIn, "should have inbound firewall rules") + assert.True(t, hasOut, "should have outbound firewall rules") +} + +// ────────────────────────────────────────────────────────────────────────────── +// 5. ROUTES +// ────────────────────────────────────────────────────────────────────────────── + +func TestComponents_RoutesIncluded(t *testing.T) { + account, validatedPeers := scalableTestAccount(100, 5) + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + assert.NotEmpty(t, nm.Routes, "should have routes") +} + +func TestComponents_DisabledRouteExcluded(t *testing.T) { + account, validatedPeers := scalableTestAccount(50, 2) + for _, r := range account.Routes { + r.Enabled = false + } + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + for _, r := range nm.Routes { + assert.True(t, r.Enabled, "only enabled routes should appear") + } +} + +func TestComponents_RoutesFirewallRulesForACG(t *testing.T) { + account, validatedPeers := scalableTestAccount(100, 5) + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + assert.NotEmpty(t, nm.RoutesFirewallRules, "should have route firewall rules for access-controlled routes") +} + +func TestComponents_HARouteDeduplication(t *testing.T) { + account, validatedPeers := scalableTestAccount(50, 5) + + haNetwork := netip.MustParsePrefix("172.16.0.0/16") + account.Routes["route-ha-1"] = &route.Route{ + ID: "route-ha-1", Network: haNetwork, PeerID: "peer-10", + Peer: account.Peers["peer-10"].Key, Enabled: true, Metric: 100, + Groups: []string{"group-all"}, PeerGroups: []string{"group-0"}, AccountID: "test-account", + } + account.Routes["route-ha-2"] = &route.Route{ + ID: "route-ha-2", Network: haNetwork, PeerID: "peer-20", + Peer: account.Peers["peer-20"].Key, Enabled: true, Metric: 200, + Groups: []string{"group-all"}, PeerGroups: []string{"group-1"}, AccountID: "test-account", + } + + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + + haRoutes := 0 + for _, r := range nm.Routes { + if r.Network == haNetwork { + haRoutes++ + } + } + // Components deduplicates HA routes with the same HA unique ID, returning one entry per HA group + assert.Equal(t, 1, haRoutes, "HA routes with same network should be deduplicated into one entry") +} + +// ────────────────────────────────────────────────────────────────────────────── +// 6. NETWORK RESOURCES & ROUTERS +// ────────────────────────────────────────────────────────────────────────────── + +func TestComponents_NetworkResourceRoutes_RouterPeer(t *testing.T) { + account, validatedPeers := scalableTestAccount(100, 5) + + var routerPeerID string + for _, nr := range account.NetworkRouters { + routerPeerID = nr.Peer + break + } + require.NotEmpty(t, routerPeerID) + + nm := componentsNetworkMap(account, routerPeerID, validatedPeers) + require.NotNil(t, nm) + assert.NotEmpty(t, nm.Peers, "router peer should see source peers") +} + +func TestComponents_NetworkResourceRoutes_SourcePeerSeesRouterPeer(t *testing.T) { + account, validatedPeers := scalableTestAccount(100, 5) + + var routerPeerID string + for _, nr := range account.NetworkRouters { + routerPeerID = nr.Peer + break + } + require.NotEmpty(t, routerPeerID) + + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + + peerIDs := make(map[string]bool, len(nm.Peers)) + for _, p := range nm.Peers { + peerIDs[p.ID] = true + } + assert.True(t, peerIDs[routerPeerID], "source peer should see router peer for network resource") +} + +func TestComponents_DisabledNetworkResourceIgnored(t *testing.T) { + account, validatedPeers := scalableTestAccount(50, 5) + for _, nr := range account.NetworkResources { + nr.Enabled = false + } + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + assert.NotNil(t, nm.Network) +} + +// ────────────────────────────────────────────────────────────────────────────── +// 7. POSTURE CHECKS +// ────────────────────────────────────────────────────────────────────────────── + +func TestComponents_PostureCheckFiltering_PassingPeer(t *testing.T) { + account, validatedPeers := scalableTestAccount(100, 5) + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + assert.NotEmpty(t, nm.Routes, "passing peer should have routes including resource routes") +} + +func TestComponents_PostureCheckFiltering_FailingPeer(t *testing.T) { + // peer-0 has version 0.40.0 (passes posture check >= 0.26.0) + // peer-1 has version 0.25.0 (fails posture check >= 0.26.0) + // Resource policies require posture-check-ver, so the failing peer + // should not see the router peer for those resources. + account, validatedPeers := scalableTestAccountWithoutDefaultPolicy(100, 5) + + nm0 := componentsNetworkMap(account, "peer-0", validatedPeers) + nm1 := componentsNetworkMap(account, "peer-1", validatedPeers) + require.NotNil(t, nm0) + require.NotNil(t, nm1) + + // The passing peer should have more peers visible (including resource router peers) + // than the failing peer, because the failing peer is excluded from resource policies. + assert.Greater(t, len(nm0.Peers), len(nm1.Peers), + "passing peer (0.40.0) should see more peers than failing peer (0.25.0) due to posture-gated resource policies") +} + +func TestComponents_MultiplePostureChecks(t *testing.T) { + account, validatedPeers := scalableTestAccountWithoutDefaultPolicy(50, 2) + + // Keep only the posture-gated policy — remove per-group policies so connectivity is isolated + account.Policies = []*types.Policy{} + + // Set kernel version on peers so the OS posture check can evaluate + for _, p := range account.Peers { + p.Meta.KernelVersion = "5.15.0" + } + + account.PostureChecks = append(account.PostureChecks, &posture.Checks{ + ID: "posture-check-os", Name: "Check OS", + Checks: posture.ChecksDefinition{ + OSVersionCheck: &posture.OSVersionCheck{Linux: &posture.MinKernelVersionCheck{MinKernelVersion: "0.0.1"}}, + }, + }) + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-multi-posture", Name: "Multi Posture", Enabled: true, AccountID: "test-account", + SourcePostureChecks: []string{"posture-check-ver", "posture-check-os"}, + Rules: []*types.PolicyRule{{ + ID: "rule-multi-posture", Name: "Multi Check Rule", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolALL, + Bidirectional: true, + Sources: []string{"group-0"}, Destinations: []string{"group-1"}, + }}, + }) + + // peer-0 (0.40.0, kernel 5.15.0) passes both checks, should see group-1 peers + nm0 := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm0) + assert.NotEmpty(t, nm0.Peers, "peer passing both posture checks should see destination peers") + + // peer-1 (0.25.0, kernel 5.15.0) fails version check, should NOT see group-1 peers + nm1 := componentsNetworkMap(account, "peer-1", validatedPeers) + require.NotNil(t, nm1) + assert.Empty(t, nm1.Peers, + "peer failing posture check should see no peers when posture-gated policy is the only connectivity") +} + +// ────────────────────────────────────────────────────────────────────────────── +// 8. DNS +// ────────────────────────────────────────────────────────────────────────────── + +func TestComponents_DNSConfigEnabled(t *testing.T) { + account, validatedPeers := scalableTestAccount(100, 5) + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + assert.True(t, nm.DNSConfig.ServiceEnable, "DNS should be enabled") + assert.NotEmpty(t, nm.DNSConfig.NameServerGroups, "should have nameserver groups") +} + +func TestComponents_DNSDisabledByManagementGroup(t *testing.T) { + account, validatedPeers := scalableTestAccount(100, 5) + account.DNSSettings.DisabledManagementGroups = []string{"group-all"} + + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + assert.False(t, nm.DNSConfig.ServiceEnable, "DNS should be disabled for peer in disabled group") +} + +func TestComponents_DNSNameServerGroupDistribution(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + account.NameServerGroups["ns-group-0"] = &nbdns.NameServerGroup{ + ID: "ns-group-0", Name: "Group 0 NS", Enabled: true, Groups: []string{"group-0"}, + NameServers: []nbdns.NameServer{{IP: netip.MustParseAddr("1.1.1.1"), NSType: nbdns.UDPNameServerType, Port: 53}}, + } + + nm0 := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm0) + hasGroup0NS := false + for _, ns := range nm0.DNSConfig.NameServerGroups { + if ns.ID == "ns-group-0" { + hasGroup0NS = true + } + } + assert.True(t, hasGroup0NS, "peer-0 in group-0 should receive ns-group-0") + + nm10 := componentsNetworkMap(account, "peer-10", validatedPeers) + require.NotNil(t, nm10) + hasGroup0NSForPeer10 := false + for _, ns := range nm10.DNSConfig.NameServerGroups { + if ns.ID == "ns-group-0" { + hasGroup0NSForPeer10 = true + } + } + assert.False(t, hasGroup0NSForPeer10, "peer-10 in group-1 should NOT receive ns-group-0") +} + +func TestComponents_DNSCustomZone(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + customZone := nbdns.CustomZone{ + Domain: "netbird.cloud.", + Records: []nbdns.SimpleRecord{ + {Name: "peer0.netbird.cloud.", Type: 1, Class: "IN", TTL: 300, RData: account.Peers["peer-0"].IP.String()}, + {Name: "peer1.netbird.cloud.", Type: 1, Class: "IN", TTL: 300, RData: account.Peers["peer-1"].IP.String()}, + }, + } + + nm := account.GetPeerNetworkMapFromComponents( + context.Background(), "peer-0", customZone, nil, + validatedPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), + nil, account.GetActiveGroupUsers(), + ) + require.NotNil(t, nm) + assert.True(t, nm.DNSConfig.ServiceEnable) +} + +// ────────────────────────────────────────────────────────────────────────────── +// 9. SSH +// ────────────────────────────────────────────────────────────────────────────── + +func TestComponents_SSHPolicy(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + account.Groups["ssh-users"] = &types.Group{ID: "ssh-users", Name: "SSH Users", Peers: []string{}} + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-ssh", Name: "SSH Access", Enabled: true, AccountID: "test-account", + Rules: []*types.PolicyRule{{ + ID: "rule-ssh", Name: "Allow SSH", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Bidirectional: false, + Sources: []string{"group-0"}, Destinations: []string{"group-1"}, + AuthorizedGroups: map[string][]string{"ssh-users": {"root"}}, + }}, + }) + + nm := componentsNetworkMap(account, "peer-10", validatedPeers) + require.NotNil(t, nm) + assert.True(t, nm.EnableSSH, "SSH should be enabled for destination peer of SSH policy") +} + +func TestComponents_SSHNotEnabledWithoutPolicy(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + assert.False(t, nm.EnableSSH, "SSH should not be enabled without SSH policy") +} + +// ────────────────────────────────────────────────────────────────────────────── +// 10. CROSS-PEER CONSISTENCY +// ────────────────────────────────────────────────────────────────────────────── + +// TestComponents_AllPeersGetValidMaps verifies that every validated peer gets a +// non-nil map with a consistent network serial and non-empty peer list. +func TestComponents_AllPeersGetValidMaps(t *testing.T) { + account, validatedPeers := scalableTestAccount(50, 5) + for peerID := range account.Peers { + if _, validated := validatedPeers[peerID]; !validated { + continue + } + nm := componentsNetworkMap(account, peerID, validatedPeers) + require.NotNil(t, nm, "network map should not be nil for %s", peerID) + assert.Equal(t, account.Network.Serial, nm.Network.Serial, "serial mismatch for %s", peerID) + assert.NotEmpty(t, nm.Peers, "validated peer %s should see other peers", peerID) + } +} + +// TestComponents_LargeScaleMapGeneration verifies that components can generate maps +// at larger scales without errors and with consistent output. +func TestComponents_LargeScaleMapGeneration(t *testing.T) { + scales := []struct{ peers, groups int }{ + {500, 20}, + {1000, 50}, + } + for _, s := range scales { + t.Run(fmt.Sprintf("%dpeers_%dgroups", s.peers, s.groups), func(t *testing.T) { + account, validatedPeers := scalableTestAccount(s.peers, s.groups) + testPeers := []string{"peer-0", fmt.Sprintf("peer-%d", s.peers/4), fmt.Sprintf("peer-%d", s.peers/2)} + for _, peerID := range testPeers { + nm := componentsNetworkMap(account, peerID, validatedPeers) + require.NotNil(t, nm, "network map should not be nil for %s", peerID) + assert.NotEmpty(t, nm.Peers, "peer %s should see other peers at scale", peerID) + assert.NotEmpty(t, nm.Routes, "peer %s should have routes at scale", peerID) + assert.Equal(t, account.Network.Serial, nm.Network.Serial, "serial mismatch for %s", peerID) + } + }) + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// 11. PEER-AS-RESOURCE POLICIES +// ────────────────────────────────────────────────────────────────────────────── + +// TestComponents_PeerAsSourceResource verifies that a policy with SourceResource.Type=Peer +// targets only that specific peer as the source. +func TestComponents_PeerAsSourceResource(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-peer-src", Name: "Peer Source Resource", Enabled: true, AccountID: "test-account", + Rules: []*types.PolicyRule{{ + ID: "rule-peer-src", Name: "Peer Source Rule", Enabled: true, + Action: types.PolicyTrafficActionAccept, + Protocol: types.PolicyRuleProtocolTCP, + Bidirectional: true, + Ports: []string{"443"}, + SourceResource: types.Resource{ID: "peer-0", Type: types.ResourceTypePeer}, + Destinations: []string{"group-1"}, + }}, + }) + + // peer-0 is the source resource, should see group-1 peers + nm0 := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm0) + + has443 := false + for _, rule := range nm0.FirewallRules { + if rule.Port == "443" { + has443 = true + break + } + } + assert.True(t, has443, "peer-0 as source resource should have port 443 rule") +} + +// TestComponents_PeerAsDestinationResource verifies that a policy with DestinationResource.Type=Peer +// targets only that specific peer as the destination. +func TestComponents_PeerAsDestinationResource(t *testing.T) { + account, validatedPeers := scalableTestAccountWithoutDefaultPolicy(20, 2) + + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-peer-dst", Name: "Peer Dest Resource", Enabled: true, AccountID: "test-account", + Rules: []*types.PolicyRule{{ + ID: "rule-peer-dst", Name: "Peer Dest Rule", Enabled: true, + Action: types.PolicyTrafficActionAccept, + Protocol: types.PolicyRuleProtocolTCP, + Bidirectional: true, + Ports: []string{"443"}, + Sources: []string{"group-0"}, + DestinationResource: types.Resource{ID: "peer-15", Type: types.ResourceTypePeer}, + }}, + }) + + // peer-0 is in group-0 (source), should see peer-15 as destination + nm0 := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm0) + + peerIDs := make(map[string]bool, len(nm0.Peers)) + for _, p := range nm0.Peers { + peerIDs[p.ID] = true + } + assert.True(t, peerIDs["peer-15"], "peer-0 should see peer-15 via peer-as-destination-resource policy") +} + +// ────────────────────────────────────────────────────────────────────────────── +// 12. MULTIPLE RULES PER POLICY +// ────────────────────────────────────────────────────────────────────────────── + +// TestComponents_MultipleRulesPerPolicy verifies a policy with multiple rules generates +// firewall rules for each. +func TestComponents_MultipleRulesPerPolicy(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-multi-rule", Name: "Multi Rule Policy", Enabled: true, AccountID: "test-account", + Rules: []*types.PolicyRule{ + { + ID: "rule-http", Name: "Allow HTTP", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolTCP, + Bidirectional: true, Ports: []string{"80"}, + Sources: []string{"group-0"}, Destinations: []string{"group-1"}, + }, + { + ID: "rule-https", Name: "Allow HTTPS", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolTCP, + Bidirectional: true, Ports: []string{"443"}, + Sources: []string{"group-0"}, Destinations: []string{"group-1"}, + }, + }, + }) + + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + + has80, has443 := false, false + for _, rule := range nm.FirewallRules { + if rule.Port == "80" { + has80 = true + } + if rule.Port == "443" { + has443 = true + } + } + assert.True(t, has80, "should have firewall rule for port 80 from first rule") + assert.True(t, has443, "should have firewall rule for port 443 from second rule") +} + +// ────────────────────────────────────────────────────────────────────────────── +// 13. SSH AUTHORIZED USERS CONTENT +// ────────────────────────────────────────────────────────────────────────────── + +// TestComponents_SSHAuthorizedUsersContent verifies that SSH policies populate +// the AuthorizedUsers map with the correct users and machine mappings. +func TestComponents_SSHAuthorizedUsersContent(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + + account.Users["user-dev"] = &types.User{Id: "user-dev", Role: types.UserRoleUser, AccountID: "test-account", AutoGroups: []string{"ssh-users"}} + account.Groups["ssh-users"] = &types.Group{ID: "ssh-users", Name: "SSH Users", Peers: []string{}} + + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-ssh", Name: "SSH Access", Enabled: true, AccountID: "test-account", + Rules: []*types.PolicyRule{{ + ID: "rule-ssh", Name: "Allow SSH", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Bidirectional: false, + Sources: []string{"group-0"}, Destinations: []string{"group-1"}, + AuthorizedGroups: map[string][]string{"ssh-users": {"root", "admin"}}, + }}, + }) + + // peer-10 is in group-1 (destination) + nm := componentsNetworkMap(account, "peer-10", validatedPeers) + require.NotNil(t, nm) + assert.True(t, nm.EnableSSH, "SSH should be enabled") + assert.NotNil(t, nm.AuthorizedUsers, "AuthorizedUsers should not be nil") + assert.NotEmpty(t, nm.AuthorizedUsers, "AuthorizedUsers should have entries") + + // Check that "root" machine user mapping exists + _, hasRoot := nm.AuthorizedUsers["root"] + _, hasAdmin := nm.AuthorizedUsers["admin"] + assert.True(t, hasRoot || hasAdmin, "AuthorizedUsers should contain 'root' or 'admin' machine user mapping") +} + +// TestComponents_SSHLegacyImpliedSSH verifies that a non-SSH ALL protocol policy with +// SSHEnabled peer implies legacy SSH access. +func TestComponents_SSHLegacyImpliedSSH(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + + // Enable SSH on the destination peer + account.Peers["peer-10"].SSHEnabled = true + + // The default "Allow All" policy with Protocol=ALL + SSHEnabled peer should imply SSH + nm := componentsNetworkMap(account, "peer-10", validatedPeers) + require.NotNil(t, nm) + assert.True(t, nm.EnableSSH, "SSH should be implied by ALL protocol policy with SSHEnabled peer") +} + +// ────────────────────────────────────────────────────────────────────────────── +// 14. ROUTE DEFAULT PERMIT (no AccessControlGroups) +// ────────────────────────────────────────────────────────────────────────────── + +// TestComponents_RouteDefaultPermit verifies that a route without AccessControlGroups +// generates default permit firewall rules (0.0.0.0/0 source). +func TestComponents_RouteDefaultPermit(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + + // Add a route without ACGs — this peer is the routing peer + routingPeerID := "peer-5" + account.Routes["route-no-acg"] = &route.Route{ + ID: "route-no-acg", Network: netip.MustParsePrefix("192.168.99.0/24"), + PeerID: routingPeerID, Peer: account.Peers[routingPeerID].Key, + Enabled: true, Groups: []string{"group-all"}, PeerGroups: []string{"group-0"}, + AccessControlGroups: []string{}, + AccountID: "test-account", + } + + // The routing peer should get default permit route firewall rules + nm := componentsNetworkMap(account, routingPeerID, validatedPeers) + require.NotNil(t, nm) + + hasDefaultPermit := false + for _, rfr := range nm.RoutesFirewallRules { + for _, src := range rfr.SourceRanges { + if src == "0.0.0.0/0" || src == "::/0" { + hasDefaultPermit = true + break + } + } + } + assert.True(t, hasDefaultPermit, "route without ACG should have default permit rule with 0.0.0.0/0 source") +} + +// TestComponents_ExitNodeDefaultPermitIPv6 verifies that a default exit node route +// (0.0.0.0/0) without AccessControlGroups also emits an IPv6 default permit rule +// (::/0 source and destination) for peers that support IPv6, mirroring the route +// the client installs. Without it, IPv6 traffic is routed to the exit node but +// dropped at the forward chain. +func TestComponents_ExitNodeDefaultPermitIPv6(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + + routingPeerID := "peer-5" + routingPeer := account.Peers[routingPeerID] + routingPeer.IPv6 = netip.MustParseAddr("fd00::5") + routingPeer.Meta.Capabilities = append(routingPeer.Meta.Capabilities, nbpeer.PeerCapabilityIPv6Overlay) + + account.Routes["route-exit"] = &route.Route{ + ID: "route-exit", Network: netip.MustParsePrefix("0.0.0.0/0"), + PeerID: routingPeerID, Peer: routingPeer.Key, + Enabled: true, Groups: []string{"group-all"}, PeerGroups: []string{"group-0"}, + AccessControlGroups: []string{}, + AccountID: "test-account", + } + + nm := componentsNetworkMap(account, routingPeerID, validatedPeers) + require.NotNil(t, nm) + + hasV4 := false + hasV6 := false + for _, rfr := range nm.RoutesFirewallRules { + switch rfr.Destination { + case "0.0.0.0/0": + if slices.Contains(rfr.SourceRanges, "0.0.0.0/0") { + hasV4 = true + } + case "::/0": + if slices.Contains(rfr.SourceRanges, "::/0") { + hasV6 = true + } + } + } + assert.True(t, hasV4, "exit node route should have an IPv4 default permit rule (0.0.0.0/0)") + assert.True(t, hasV6, "exit node route should have an IPv6 default permit rule (::/0)") +} + +// ────────────────────────────────────────────────────────────────────────────── +// 15. MULTIPLE ROUTERS PER NETWORK +// ────────────────────────────────────────────────────────────────────────────── + +// TestComponents_MultipleRoutersPerNetwork verifies that a network resource +// with multiple routers provides routes through all available routers. +func TestComponents_MultipleRoutersPerNetwork(t *testing.T) { + account, validatedPeers := scalableTestAccountWithoutDefaultPolicy(20, 2) + + netID := "net-multi-router" + resID := "res-multi-router" + account.Networks = append(account.Networks, &networkTypes.Network{ID: netID, Name: "Multi Router Network", AccountID: "test-account"}) + account.NetworkResources = append(account.NetworkResources, &resourceTypes.NetworkResource{ + ID: resID, NetworkID: netID, AccountID: "test-account", Enabled: true, + Address: "multi-svc.netbird.cloud", + }) + account.NetworkRouters = append(account.NetworkRouters, + &routerTypes.NetworkRouter{ID: "router-a", NetworkID: netID, Peer: "peer-5", Enabled: true, AccountID: "test-account", Metric: 100}, + &routerTypes.NetworkRouter{ID: "router-b", NetworkID: netID, Peer: "peer-15", Enabled: true, AccountID: "test-account", Metric: 200}, + ) + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-multi-router-res", Name: "Multi Router Resource", Enabled: true, AccountID: "test-account", + Rules: []*types.PolicyRule{{ + ID: "rule-multi-router-res", Name: "Allow Multi Router", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolALL, Bidirectional: true, + Sources: []string{"group-0"}, DestinationResource: types.Resource{ID: resID}, + }}, + }) + + // peer-0 is in group-0 (source), should see both router peers + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + + peerIDs := make(map[string]bool, len(nm.Peers)) + for _, p := range nm.Peers { + peerIDs[p.ID] = true + } + assert.True(t, peerIDs["peer-5"], "source peer should see router-a (peer-5)") + assert.True(t, peerIDs["peer-15"], "source peer should see router-b (peer-15)") +} + +// ────────────────────────────────────────────────────────────────────────────── +// 16. PEER-AS-NAMESERVER EXCLUSION +// ────────────────────────────────────────────────────────────────────────────── + +// TestComponents_PeerIsNameserverExcludedFromNSGroup verifies that a peer serving +// as a nameserver does not receive its own NS group in DNS config. +func TestComponents_PeerIsNameserverExcludedFromNSGroup(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + + // peer-0 has IP 100.64.0.0 — make it a nameserver + nsIP := account.Peers["peer-0"].IP + account.NameServerGroups["ns-self"] = &nbdns.NameServerGroup{ + ID: "ns-self", Name: "Self NS", Enabled: true, Groups: []string{"group-all"}, + NameServers: []nbdns.NameServer{{IP: nsIP, NSType: nbdns.UDPNameServerType, Port: 53}}, + } + + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + + hasSelfNS := false + for _, ns := range nm.DNSConfig.NameServerGroups { + if ns.ID == "ns-self" { + hasSelfNS = true + } + } + assert.False(t, hasSelfNS, "peer serving as nameserver should NOT receive its own NS group") + + // peer-10 is NOT the nameserver, should receive the NS group + nm10 := componentsNetworkMap(account, "peer-10", validatedPeers) + require.NotNil(t, nm10) + hasNSForPeer10 := false + for _, ns := range nm10.DNSConfig.NameServerGroups { + if ns.ID == "ns-self" { + hasNSForPeer10 = true + } + } + assert.True(t, hasNSForPeer10, "non-nameserver peer should receive the NS group") +} + +// ────────────────────────────────────────────────────────────────────────────── +// 17. DOMAIN NETWORK RESOURCES +// ────────────────────────────────────────────────────────────────────────────── + +// TestComponents_DomainNetworkResource verifies that domain-based network resources +// produce routes with the correct domain configuration. +func TestComponents_DomainNetworkResource(t *testing.T) { + account, validatedPeers := scalableTestAccountWithoutDefaultPolicy(20, 2) + + netID := "net-domain" + resID := "res-domain" + account.Networks = append(account.Networks, &networkTypes.Network{ID: netID, Name: "Domain Network", AccountID: "test-account"}) + account.NetworkResources = append(account.NetworkResources, &resourceTypes.NetworkResource{ + ID: resID, NetworkID: netID, AccountID: "test-account", Enabled: true, + Address: "api.example.com", Type: "domain", + }) + account.NetworkRouters = append(account.NetworkRouters, &routerTypes.NetworkRouter{ + ID: "router-domain", NetworkID: netID, Peer: "peer-5", Enabled: true, AccountID: "test-account", + }) + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-domain-res", Name: "Domain Resource Policy", Enabled: true, AccountID: "test-account", + Rules: []*types.PolicyRule{{ + ID: "rule-domain-res", Name: "Allow Domain", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolALL, Bidirectional: true, + Sources: []string{"group-0"}, DestinationResource: types.Resource{ID: resID}, + }}, + }) + + // peer-0 is source, should get route to the domain resource via peer-5 + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + + peerIDs := make(map[string]bool, len(nm.Peers)) + for _, p := range nm.Peers { + peerIDs[p.ID] = true + } + assert.True(t, peerIDs["peer-5"], "source peer should see domain resource router peer") +} + +// ────────────────────────────────────────────────────────────────────────────── +// 18. DISABLED RULE WITHIN ENABLED POLICY +// ────────────────────────────────────────────────────────────────────────────── + +// TestComponents_DisabledRuleInEnabledPolicy verifies that a disabled rule within +// an enabled policy does not generate firewall rules. +func TestComponents_DisabledRuleInEnabledPolicy(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-mixed-rules", Name: "Mixed Rules", Enabled: true, AccountID: "test-account", + Rules: []*types.PolicyRule{ + { + ID: "rule-enabled", Name: "Enabled Rule", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolTCP, + Bidirectional: true, Ports: []string{"3000"}, + Sources: []string{"group-0"}, Destinations: []string{"group-1"}, + }, + { + ID: "rule-disabled", Name: "Disabled Rule", Enabled: false, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolTCP, + Bidirectional: true, Ports: []string{"3001"}, + Sources: []string{"group-0"}, Destinations: []string{"group-1"}, + }, + }, + }) + + nm := componentsNetworkMap(account, "peer-0", validatedPeers) + require.NotNil(t, nm) + + has3000, has3001 := false, false + for _, rule := range nm.FirewallRules { + if rule.Port == "3000" { + has3000 = true + } + if rule.Port == "3001" { + has3001 = true + } + } + assert.True(t, has3000, "enabled rule should generate firewall rule for port 3000") + assert.False(t, has3001, "disabled rule should NOT generate firewall rule for port 3001") +} + +func peerGroupIDSet(account *types.Account, peerID string) map[string]struct{} { + return account.GetPeerGroups(peerID) +} + +func assertSSHEquivalence(t *testing.T, account *types.Account, peerID string, validatedPeers map[string]struct{}) { + t.Helper() + nm := componentsNetworkMap(account, peerID, validatedPeers) + require.NotNil(t, nm) + + got := types.PeerSSHEnabledFromPolicies(account.Policies, peerID, peerGroupIDSet(account, peerID), account.Peers[peerID].SSHEnabled) + assert.Equalf(t, nm.EnableSSH, got, "PeerSSHEnabledFromPolicies mismatch for %s", peerID) +} + +func TestPeerSSHEnabledFromPolicies_MatchesMap_NetbirdSSHProtocol(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + account.Groups["ssh-users"] = &types.Group{ID: "ssh-users", Name: "SSH Users", Peers: []string{}} + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-ssh", Name: "SSH Access", Enabled: true, AccountID: "test-account", + Rules: []*types.PolicyRule{{ + ID: "rule-ssh", Name: "Allow SSH", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Bidirectional: false, + Sources: []string{"group-0"}, Destinations: []string{"group-1"}, + AuthorizedGroups: map[string][]string{"ssh-users": {"root"}}, + }}, + }) + + assertSSHEquivalence(t, account, "peer-10", validatedPeers) + assertSSHEquivalence(t, account, "peer-0", validatedPeers) +} + +func TestPeerSSHEnabledFromPolicies_MatchesMap_NoSSHPolicy(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + assertSSHEquivalence(t, account, "peer-0", validatedPeers) +} + +func TestPeerSSHEnabledFromPolicies_MatchesMap_LegacyImpliedSSH(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + account.Peers["peer-10"].SSHEnabled = true + assertSSHEquivalence(t, account, "peer-10", validatedPeers) + assertSSHEquivalence(t, account, "peer-11", validatedPeers) +} + +func TestPeerSSHEnabledFromPolicies_MatchesMap_PeerAsDestinationResource(t *testing.T) { + account, validatedPeers := scalableTestAccountWithoutDefaultPolicy(20, 2) + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-ssh-res", Name: "SSH to peer", Enabled: true, AccountID: "test-account", + Rules: []*types.PolicyRule{{ + ID: "rule-ssh-res", Name: "SSH to peer-5", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Sources: []string{"group-0"}, + DestinationResource: types.Resource{ID: "peer-5", Type: types.ResourceTypePeer}, + }}, + }) + + assertSSHEquivalence(t, account, "peer-5", validatedPeers) + assertSSHEquivalence(t, account, "peer-6", validatedPeers) +} + +func TestPeerSSHEnabledFromPolicies_MatchesMap_DisabledSSHPolicy(t *testing.T) { + account, validatedPeers := scalableTestAccountWithoutDefaultPolicy(20, 2) + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-ssh-off", Name: "SSH disabled", Enabled: false, AccountID: "test-account", + Rules: []*types.PolicyRule{{ + ID: "rule-ssh-off", Name: "Allow SSH", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Sources: []string{"group-0"}, Destinations: []string{"group-1"}, + }}, + }) + assertSSHEquivalence(t, account, "peer-10", validatedPeers) +} + +func TestPeerSSHEnabledFromPolicies_MatchesMap_Sweep(t *testing.T) { + account, validatedPeers := scalableTestAccount(60, 6) + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-ssh-sweep", Name: "SSH sweep", Enabled: true, AccountID: "test-account", + Rules: []*types.PolicyRule{{ + ID: "rule-ssh-sweep", Name: "Allow SSH", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolNetbirdSSH, + Sources: []string{"group-0"}, Destinations: []string{"group-2"}, + }}, + }) + for peerID := range account.Peers { + account.Peers[peerID].SSHEnabled = len(peerID)%2 == 0 + } + + for peerID := range account.Peers { + if _, ok := validatedPeers[peerID]; !ok { + continue + } + assertSSHEquivalence(t, account, peerID, validatedPeers) + } +} diff --git a/management/server/types/networkmap_components_test.go b/management/server/types/networkmap_components_test.go new file mode 100644 index 000000000..3f2288f88 --- /dev/null +++ b/management/server/types/networkmap_components_test.go @@ -0,0 +1,787 @@ +package types_test + +import ( + "context" + "net" + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbdns "github.com/netbirdio/netbird/dns" + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + networkTypes "github.com/netbirdio/netbird/management/server/networks/types" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/posture" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/route" +) + +func networkMapFromComponents(t *testing.T, account *types.Account, peerID string, validatedPeers map[string]struct{}) *types.NetworkMap { + t.Helper() + return account.GetPeerNetworkMapFromComponents( + context.Background(), + peerID, + account.GetPeersCustomZone(context.Background(), "netbird.io"), + nil, + validatedPeers, + account.GetResourcePoliciesMap(), + account.GetResourceRoutersMap(), + nil, + account.GetActiveGroupUsers(), + ) +} + +func allPeersValidated(account *types.Account, excludePeerIDs ...string) map[string]struct{} { + excludeSet := make(map[string]struct{}, len(excludePeerIDs)) + for _, id := range excludePeerIDs { + excludeSet[id] = struct{}{} + } + validated := make(map[string]struct{}, len(account.Peers)) + for id := range account.Peers { + if _, excluded := excludeSet[id]; !excluded { + validated[id] = struct{}{} + } + } + return validated +} + +func peerIDs(peers []*types.ComponentPeer) []string { + ids := make([]string, len(peers)) + for i, p := range peers { + ids[i] = p.ID + } + return ids +} + +func TestNetworkMapComponents_RegularPeerConnectivity(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + + assert.NotNil(t, nm) + assert.Contains(t, peerIDs(nm.Peers), "peer-dst-1", "should see peer from destination group via bidirectional policy") + assert.Contains(t, peerIDs(nm.Peers), "peer-router-1", "should see router peer via resource policy") + assert.NotContains(t, peerIDs(nm.Peers), "peer-src-1", "should not see itself") + assert.Empty(t, nm.OfflinePeers, "no expired peers expected") +} + +func TestNetworkMapComponents_IntraGroupConnectivity(t *testing.T) { + account := createComponentTestAccount() + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-intra-src", Name: "Intra-source connectivity", Enabled: true, AccountID: account.Id, + Rules: []*types.PolicyRule{{ + ID: "rule-intra-src", Name: "src <-> src", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolALL, + Bidirectional: true, + Sources: []string{"group-src"}, Destinations: []string{"group-src"}, + }}, + }) + validated := allPeersValidated(account) + + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + assert.Contains(t, peerIDs(nm.Peers), "peer-src-2", "should see peer from same group with intra-group policy") +} + +func TestNetworkMapComponents_FirewallRules(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + + require.NotEmpty(t, nm.FirewallRules, "firewall rules should be generated") + + var hasAcceptAll bool + for _, rule := range nm.FirewallRules { + if rule.Protocol == string(types.PolicyRuleProtocolALL) && rule.Action == string(types.PolicyTrafficActionAccept) { + hasAcceptAll = true + } + } + assert.True(t, hasAcceptAll, "should have an accept-all firewall rule from the base policy") +} + +func TestNetworkMapComponents_LoginExpiration(t *testing.T) { + account := createComponentTestAccount() + account.Settings.PeerLoginExpirationEnabled = true + account.Settings.PeerLoginExpiration = 1 * time.Hour + + expiredTime := time.Now().Add(-2 * time.Hour) + account.Peers["peer-dst-1"].LoginExpirationEnabled = true + account.Peers["peer-dst-1"].LastLogin = &expiredTime + + validated := allPeersValidated(account) + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + + assert.Contains(t, peerIDs(nm.OfflinePeers), "peer-dst-1", "expired peer should be in OfflinePeers") + assert.NotContains(t, peerIDs(nm.Peers), "peer-dst-1", "expired peer should NOT be in active Peers") +} + +func TestNetworkMapComponents_InvalidatedPeerExcluded(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account, "peer-dst-1") + + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + + assert.NotContains(t, peerIDs(nm.Peers), "peer-dst-1", "non-validated peer should be excluded") + assert.NotContains(t, peerIDs(nm.OfflinePeers), "peer-dst-1", "non-validated peer should not be in offline peers either") +} + +func TestNetworkMapComponents_NonValidatedTargetPeer(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account, "peer-src-1") + + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + + assert.Empty(t, nm.Peers, "non-validated target peer should get empty network map") + assert.Empty(t, nm.FirewallRules) +} + +func TestNetworkMapComponents_NetworkResourceRoutes_SourcePeer(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + + var hasResourceRoute bool + for _, r := range nm.Routes { + if r.Network.String() == "10.200.0.1/32" { + hasResourceRoute = true + break + } + } + assert.True(t, hasResourceRoute, "source peer should receive route to network resource via router") + assert.Contains(t, peerIDs(nm.Peers), "peer-router-1", "source peer should see the routing peer") +} + +func TestNetworkMapComponents_NetworkResourceRoutes_RouterPeer(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + nm := networkMapFromComponents(t, account, "peer-router-1", validated) + + var hasResourceRoute bool + for _, r := range nm.Routes { + if r.Network.String() == "10.200.0.1/32" { + hasResourceRoute = true + break + } + } + assert.True(t, hasResourceRoute, "router peer should receive network resource route") + assert.NotEmpty(t, nm.RoutesFirewallRules, "router peer should have route firewall rules for the resource") +} + +func TestNetworkMapComponents_NetworkResourceRoutes_UnrelatedPeer(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + nm := networkMapFromComponents(t, account, "peer-dst-1", validated) + + for _, r := range nm.Routes { + assert.NotEqual(t, "10.200.0.1/32", r.Network.String(), "unrelated peer should not receive network resource route") + } +} + +func TestNetworkMapComponents_NetworkResource_WithPostureCheck(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + account.PostureChecks = []*posture.Checks{ + {ID: "pc-version", Name: "Version check", Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.30.0"}, + }}, + } + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-posture-resource", Name: "Posture resource access", Enabled: true, AccountID: account.Id, + SourcePostureChecks: []string{"pc-version"}, + Rules: []*types.PolicyRule{{ + ID: "rule-posture-resource", Name: "Posture -> Resource", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolALL, + Sources: []string{"group-src"}, + DestinationResource: types.Resource{ID: "resource-guarded"}, + }}, + }) + + account.NetworkResources = append(account.NetworkResources, &resourceTypes.NetworkResource{ + ID: "resource-guarded", NetworkID: "net-guarded", AccountID: account.Id, Enabled: true, + Type: resourceTypes.Host, Prefix: netip.MustParsePrefix("10.200.1.1/32"), Address: "10.200.1.1/32", + }) + account.Networks = append(account.Networks, &networkTypes.Network{ + ID: "net-guarded", Name: "Guarded Net", AccountID: account.Id, + }) + account.NetworkRouters = append(account.NetworkRouters, &routerTypes.NetworkRouter{ + ID: "router-guarded", NetworkID: "net-guarded", Peer: "peer-router-1", Enabled: true, AccountID: account.Id, + }) + + t.Run("peer passes posture check", func(t *testing.T) { + account.Peers["peer-src-1"].Meta.WtVersion = "0.35.0" + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + + var hasGuardedRoute bool + for _, r := range nm.Routes { + if r.Network.String() == "10.200.1.1/32" { + hasGuardedRoute = true + } + } + assert.True(t, hasGuardedRoute, "peer passing posture check should get guarded resource route") + }) + + t.Run("peer fails posture check", func(t *testing.T) { + account.Peers["peer-src-1"].Meta.WtVersion = "0.20.0" + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + + for _, r := range nm.Routes { + assert.NotEqual(t, "10.200.1.1/32", r.Network.String(), "peer failing posture check should NOT get guarded resource route") + } + }) +} + +func TestNetworkMapComponents_NetworkResource_MultiplePostureChecks(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + account.PostureChecks = []*posture.Checks{ + {ID: "pc-version", Name: "Version", Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.30.0"}, + }}, + {ID: "pc-os", Name: "OS check", Checks: posture.ChecksDefinition{ + OSVersionCheck: &posture.OSVersionCheck{Linux: &posture.MinKernelVersionCheck{MinKernelVersion: "5.0"}}, + }}, + } + + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-multi-posture", Name: "Multi posture", Enabled: true, AccountID: account.Id, + SourcePostureChecks: []string{"pc-version", "pc-os"}, + Rules: []*types.PolicyRule{{ + ID: "rule-multi-posture", Name: "Multi posture rule", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolALL, + Sources: []string{"group-src"}, + DestinationResource: types.Resource{ID: "resource-strict"}, + }}, + }) + + account.NetworkResources = append(account.NetworkResources, &resourceTypes.NetworkResource{ + ID: "resource-strict", NetworkID: "net-strict", AccountID: account.Id, Enabled: true, + Type: resourceTypes.Host, Prefix: netip.MustParsePrefix("10.200.2.1/32"), Address: "10.200.2.1/32", + }) + account.Networks = append(account.Networks, &networkTypes.Network{ + ID: "net-strict", Name: "Strict Net", AccountID: account.Id, + }) + account.NetworkRouters = append(account.NetworkRouters, &routerTypes.NetworkRouter{ + ID: "router-strict", NetworkID: "net-strict", Peer: "peer-router-1", Enabled: true, AccountID: account.Id, + }) + + t.Run("passes both posture checks", func(t *testing.T) { + account.Peers["peer-src-1"].Meta.WtVersion = "0.35.0" + account.Peers["peer-src-1"].Meta.GoOS = "linux" + account.Peers["peer-src-1"].Meta.KernelVersion = "6.1.0" + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + + var found bool + for _, r := range nm.Routes { + if r.Network.String() == "10.200.2.1/32" { + found = true + } + } + assert.True(t, found, "peer passing both checks should get resource route") + }) + + t.Run("fails version posture check", func(t *testing.T) { + account.Peers["peer-src-1"].Meta.WtVersion = "0.20.0" + account.Peers["peer-src-1"].Meta.KernelVersion = "6.1.0" + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + + for _, r := range nm.Routes { + assert.NotEqual(t, "10.200.2.1/32", r.Network.String(), "peer failing version check should NOT get resource route") + } + }) + + t.Run("fails OS posture check", func(t *testing.T) { + account.Peers["peer-src-1"].Meta.WtVersion = "0.35.0" + account.Peers["peer-src-1"].Meta.KernelVersion = "4.0.0" + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + + for _, r := range nm.Routes { + assert.NotEqual(t, "10.200.2.1/32", r.Network.String(), "peer failing OS check should NOT get resource route") + } + }) +} + +func TestNetworkMapComponents_RouterPeerFirewallRules(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + nm := networkMapFromComponents(t, account, "peer-router-1", validated) + + var resourceFWRules []*types.RouteFirewallRule + for _, rule := range nm.RoutesFirewallRules { + if rule.Destination == "10.200.0.1/32" { + resourceFWRules = append(resourceFWRules, rule) + } + } + assert.NotEmpty(t, resourceFWRules, "router should have firewall rules for the network resource") + + var hasSourcePeerIP bool + for _, rule := range resourceFWRules { + for _, sr := range rule.SourceRanges { + if sr == account.Peers["peer-src-1"].IP.String()+"/32" || sr == account.Peers["peer-src-2"].IP.String()+"/32" { + hasSourcePeerIP = true + } + } + } + assert.True(t, hasSourcePeerIP, "resource firewall rules should include source peer IPs") +} + +func TestNetworkMapComponents_DNSManagement(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + t.Run("peer in DNS-enabled group", func(t *testing.T) { + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + assert.True(t, nm.DNSConfig.ServiceEnable, "peer in non-disabled group should have DNS enabled") + }) + + t.Run("peer in DNS-disabled group", func(t *testing.T) { + nm := networkMapFromComponents(t, account, "peer-dst-1", validated) + assert.False(t, nm.DNSConfig.ServiceEnable, "peer in DNS-disabled group should have DNS disabled") + }) +} + +func TestNetworkMapComponents_NameServerGroups(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + assert.True(t, nm.DNSConfig.ServiceEnable) + + var hasNSGroup bool + for _, ns := range nm.DNSConfig.NameServerGroups { + if ns.ID == "ns-main" { + hasNSGroup = true + } + } + assert.True(t, hasNSGroup, "peer in NS group should receive nameserver configuration") +} + +func TestNetworkMapComponents_RoutesWithHADeduplication(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + account.Routes["route-ha-1"] = &route.Route{ + ID: "route-ha-1", Network: netip.MustParsePrefix("172.16.0.0/16"), + Peer: account.Peers["peer-dst-1"].Key, PeerID: "peer-dst-1", + Enabled: true, Metric: 100, AccountID: account.Id, + Groups: []string{"group-src", "group-dst"}, PeerGroups: []string{"group-dst"}, + } + account.Routes["route-ha-2"] = &route.Route{ + ID: "route-ha-2", Network: netip.MustParsePrefix("172.16.0.0/16"), + Peer: account.Peers["peer-src-1"].Key, PeerID: "peer-src-1", + Enabled: true, Metric: 200, AccountID: account.Id, + Groups: []string{"group-src", "group-dst"}, PeerGroups: []string{"group-src"}, + } + + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + + haCount := 0 + for _, r := range nm.Routes { + if r.Network.String() == "172.16.0.0/16" { + haCount++ + } + } + assert.Equal(t, 1, haCount, "peer should only receive one route from HA group (not both, since it's a member of one)") +} + +func TestNetworkMapComponents_RoutesFirewallRulesForAccessControl(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + account.Routes["route-acl"] = &route.Route{ + ID: "route-acl", Network: netip.MustParsePrefix("192.168.100.0/24"), + Peer: account.Peers["peer-src-1"].Key, PeerID: "peer-src-1", + Enabled: true, Metric: 100, AccountID: account.Id, + Groups: []string{"group-dst"}, + PeerGroups: []string{"group-src"}, + AccessControlGroups: []string{"group-dst"}, + } + + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + + var hasFWRule bool + for _, rule := range nm.RoutesFirewallRules { + if rule.Destination == "192.168.100.0/24" { + hasFWRule = true + } + } + assert.True(t, hasFWRule, "routing peer should have firewall rules for route with access control groups") +} + +func TestNetworkMapComponents_RoutesDefaultPermit(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + account.Routes["route-open"] = &route.Route{ + ID: "route-open", Network: netip.MustParsePrefix("10.99.0.0/16"), + Peer: account.Peers["peer-src-1"].Key, PeerID: "peer-src-1", + Enabled: true, Metric: 100, AccountID: account.Id, + Groups: []string{"group-src"}, + PeerGroups: []string{"group-src"}, + } + + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + + var hasFWRule bool + for _, rule := range nm.RoutesFirewallRules { + if rule.Destination == "10.99.0.0/16" { + hasFWRule = true + } + } + assert.True(t, hasFWRule, "route without access control groups should have default permit firewall rules") +} + +func TestNetworkMapComponents_SSHAuthorizedUsers(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + account.Peers["peer-dst-1"].SSHEnabled = true + + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-ssh", Name: "SSH Access", Enabled: true, AccountID: account.Id, + Rules: []*types.PolicyRule{{ + ID: "rule-ssh", Name: "SSH to dst", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolALL, + Bidirectional: true, + Sources: []string{"group-src"}, Destinations: []string{"group-dst"}, + }}, + }) + + nm := networkMapFromComponents(t, account, "peer-dst-1", validated) + assert.True(t, nm.EnableSSH, "SSH-enabled peer with matching policy should have EnableSSH") +} + +func TestNetworkMapComponents_DisabledPolicyIgnored(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + for _, p := range account.Policies { + p.Enabled = false + } + + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + assert.Empty(t, nm.Peers, "with all policies disabled, peer should see no other peers") + assert.Empty(t, nm.FirewallRules) +} + +func TestNetworkMapComponents_DisabledRouteIgnored(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + for _, r := range account.Routes { + r.Enabled = false + } + for _, r := range account.NetworkResources { + r.Enabled = false + } + + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + assert.Empty(t, nm.Routes, "disabled routes should not appear in network map") +} + +func TestNetworkMapComponents_DisabledNetworkResourceIgnored(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + for _, r := range account.NetworkResources { + r.Enabled = false + } + + nm := networkMapFromComponents(t, account, "peer-router-1", validated) + + for _, r := range nm.Routes { + assert.NotEqual(t, "10.200.0.1/32", r.Network.String(), "disabled resource should not generate routes") + } +} + +func TestNetworkMapComponents_BidirectionalPolicy(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + nmSrc := networkMapFromComponents(t, account, "peer-src-1", validated) + nmDst := networkMapFromComponents(t, account, "peer-dst-1", validated) + + assert.Contains(t, peerIDs(nmSrc.Peers), "peer-dst-1", "src should see dst via bidirectional policy") + assert.Contains(t, peerIDs(nmDst.Peers), "peer-src-1", "dst should see src via bidirectional policy") +} + +func TestNetworkMapComponents_DropPolicy(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-drop", Name: "Drop traffic", Enabled: true, AccountID: account.Id, + Rules: []*types.PolicyRule{{ + ID: "rule-drop", Name: "Drop src->dst", Enabled: true, + Action: types.PolicyTrafficActionDrop, Protocol: types.PolicyRuleProtocolTCP, + Ports: []string{"5432"}, + Sources: []string{"group-src"}, Destinations: []string{"group-dst"}, + }}, + }) + + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + + var hasDropRule bool + for _, rule := range nm.FirewallRules { + if rule.Action == string(types.PolicyTrafficActionDrop) && rule.Port == "5432" { + hasDropRule = true + } + } + assert.True(t, hasDropRule, "drop policy should generate drop firewall rule") +} + +func TestNetworkMapComponents_PortRangePolicy(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + account.Peers["peer-src-1"].Meta.WtVersion = "0.50.0" + + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-range", Name: "Port range", Enabled: true, AccountID: account.Id, + Rules: []*types.PolicyRule{{ + ID: "rule-range", Name: "Range rule", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolTCP, + PortRanges: []types.RulePortRange{{Start: 8080, End: 8090}}, + Sources: []string{"group-src"}, Destinations: []string{"group-dst"}, + }}, + }) + + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + + var hasRangeRule bool + for _, rule := range nm.FirewallRules { + if rule.PortRange.Start == 8080 && rule.PortRange.End == 8090 { + hasRangeRule = true + } + } + assert.True(t, hasRangeRule, "port range policy should generate corresponding firewall rule") +} + +func TestNetworkMapComponents_MultipleNetworkResources(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + account.NetworkResources = append(account.NetworkResources, &resourceTypes.NetworkResource{ + ID: "resource-2", NetworkID: "net-1", AccountID: account.Id, Enabled: true, + Type: resourceTypes.Host, Prefix: netip.MustParsePrefix("10.200.0.2/32"), Address: "10.200.0.2/32", + }) + account.Groups["group-res2"] = &types.Group{ID: "group-res2", Name: "Resource 2 Group", Peers: []string{"peer-src-1", "peer-src-2"}, + Resources: []types.Resource{{ID: "resource-2"}}, + } + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-res2", Name: "Resource 2 Policy", Enabled: true, AccountID: account.Id, + Rules: []*types.PolicyRule{{ + ID: "rule-res2", Name: "Access Resource 2", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolALL, + Sources: []string{"group-src"}, + DestinationResource: types.Resource{ID: "resource-2"}, + }}, + }) + + nm := networkMapFromComponents(t, account, "peer-router-1", validated) + + resourceRouteCount := 0 + for _, r := range nm.Routes { + if r.Network.String() == "10.200.0.1/32" || r.Network.String() == "10.200.0.2/32" { + resourceRouteCount++ + } + } + assert.Equal(t, 2, resourceRouteCount, "router should have routes for both network resources") +} + +func TestNetworkMapComponents_DomainNetworkResource(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + account.NetworkResources = append(account.NetworkResources, &resourceTypes.NetworkResource{ + ID: "resource-domain", NetworkID: "net-1", AccountID: account.Id, Enabled: true, + Type: resourceTypes.Domain, Domain: "api.example.com", Address: "api.example.com", + }) + account.Groups["group-res-domain"] = &types.Group{ + ID: "group-res-domain", Name: "Domain Resource Group", + Resources: []types.Resource{{ID: "resource-domain"}}, + } + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-domain", Name: "Domain resource policy", Enabled: true, AccountID: account.Id, + Rules: []*types.PolicyRule{{ + ID: "rule-domain", Name: "Access domain resource", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolALL, + Sources: []string{"group-src"}, + DestinationResource: types.Resource{ID: "resource-domain"}, + }}, + }) + + nm := networkMapFromComponents(t, account, "peer-src-1", validated) + + var hasDomainRoute bool + for _, r := range nm.Routes { + if r.NetworkType == route.DomainNetwork && len(r.Domains) > 0 && r.Domains[0].SafeString() == "api.example.com" { + hasDomainRoute = true + } + } + assert.True(t, hasDomainRoute, "source peer should receive domain route for domain network resource") +} + +func TestNetworkMapComponents_NetworkEmpty(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + nm := networkMapFromComponents(t, account, "nonexistent-peer", validated) + + assert.NotNil(t, nm) + assert.Empty(t, nm.Peers) + assert.Empty(t, nm.FirewallRules) + assert.NotNil(t, nm.Network) +} + +func TestNetworkMapComponents_RouterExcludesOtherNetworkRoutes(t *testing.T) { + account := createComponentTestAccount() + validated := allPeersValidated(account) + + account.NetworkResources = append(account.NetworkResources, &resourceTypes.NetworkResource{ + ID: "resource-other", NetworkID: "net-other", AccountID: account.Id, Enabled: true, + Type: resourceTypes.Host, Prefix: netip.MustParsePrefix("10.200.99.1/32"), Address: "10.200.99.1/32", + }) + account.Networks = append(account.Networks, &networkTypes.Network{ + ID: "net-other", Name: "Other Net", AccountID: account.Id, + }) + account.NetworkRouters = append(account.NetworkRouters, &routerTypes.NetworkRouter{ + ID: "router-other", NetworkID: "net-other", Peer: "peer-dst-1", Enabled: true, AccountID: account.Id, + }) + account.Groups["group-res-other"] = &types.Group{ID: "group-res-other", Name: "Other resource group", + Resources: []types.Resource{{ID: "resource-other"}}, + } + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-other-resource", Name: "Other resource policy", Enabled: true, AccountID: account.Id, + Rules: []*types.PolicyRule{{ + ID: "rule-other", Name: "Other resource access", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolALL, + Sources: []string{"group-src"}, + DestinationResource: types.Resource{ID: "resource-other"}, + }}, + }) + + nm := networkMapFromComponents(t, account, "peer-router-1", validated) + + for _, r := range nm.Routes { + assert.NotEqual(t, "10.200.99.1/32", r.Network.String(), "router-1 should NOT get routes for other network's resources") + } +} + +func createComponentTestAccount() *types.Account { + peers := map[string]*nbpeer.Peer{ + "peer-src-1": { + ID: "peer-src-1", IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), Key: "key-src-1", DNSLabel: "src1", + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, UserID: "user-1", + Meta: nbpeer.PeerSystemMeta{WtVersion: "0.35.0", GoOS: "linux"}, + }, + "peer-src-2": { + ID: "peer-src-2", IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), Key: "key-src-2", DNSLabel: "src2", + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, UserID: "user-1", + Meta: nbpeer.PeerSystemMeta{WtVersion: "0.35.0", GoOS: "linux"}, + }, + "peer-dst-1": { + ID: "peer-dst-1", IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}), Key: "key-dst-1", DNSLabel: "dst1", + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, UserID: "user-2", + Meta: nbpeer.PeerSystemMeta{WtVersion: "0.35.0", GoOS: "linux"}, + }, + "peer-router-1": { + ID: "peer-router-1", IP: netip.AddrFrom4([4]byte{100, 64, 0, 10}), Key: "key-router-1", DNSLabel: "router1", + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, UserID: "user-1", + Meta: nbpeer.PeerSystemMeta{WtVersion: "0.35.0", GoOS: "linux"}, + }, + } + + groups := map[string]*types.Group{ + "group-src": {ID: "group-src", Name: "Sources", Peers: []string{"peer-src-1", "peer-src-2"}}, + "group-dst": {ID: "group-dst", Name: "Destinations", Peers: []string{"peer-dst-1"}}, + "group-all": {ID: "group-all", Name: "All", Peers: []string{"peer-src-1", "peer-src-2", "peer-dst-1", "peer-router-1"}}, + "group-res": { + ID: "group-res", Name: "Resource Group", + Resources: []types.Resource{{ID: "resource-1"}}, + }, + } + + policies := []*types.Policy{ + { + ID: "policy-base", Name: "Base connectivity", Enabled: true, + Rules: []*types.PolicyRule{{ + ID: "rule-base", Name: "Allow src <-> dst", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolALL, + Bidirectional: true, + Sources: []string{"group-src"}, Destinations: []string{"group-dst"}, + }}, + }, + { + ID: "policy-resource", Name: "Network resource access", Enabled: true, + Rules: []*types.PolicyRule{{ + ID: "rule-resource", Name: "Source -> Resource", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolALL, + Sources: []string{"group-src"}, + DestinationResource: types.Resource{ID: "resource-1"}, + }}, + }, + } + + routes := map[route.ID]*route.Route{ + "route-main": { + ID: "route-main", Network: netip.MustParsePrefix("192.168.10.0/24"), + Peer: peers["peer-dst-1"].Key, PeerID: "peer-dst-1", + Enabled: true, Metric: 100, + Groups: []string{"group-src", "group-dst"}, PeerGroups: []string{"group-dst"}, + }, + } + + users := map[string]*types.User{ + "user-1": {Id: "user-1", Role: types.UserRoleAdmin, IsServiceUser: false, AutoGroups: []string{"group-all"}}, + "user-2": {Id: "user-2", Role: types.UserRoleUser, IsServiceUser: false, AutoGroups: []string{"group-all"}}, + } + + account := &types.Account{ + Id: "account-components-test", Peers: peers, Groups: groups, Policies: policies, Routes: routes, + Users: users, + Network: &types.Network{ + Identifier: "net-test", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(16, 32)}, Serial: 1, + }, + DNSSettings: types.DNSSettings{DisabledManagementGroups: []string{"group-dst"}}, + NameServerGroups: map[string]*nbdns.NameServerGroup{ + "ns-main": { + ID: "ns-main", Name: "Main NS", Enabled: true, Groups: []string{"group-src"}, + NameServers: []nbdns.NameServer{{IP: netip.MustParseAddr("8.8.8.8"), NSType: nbdns.UDPNameServerType, Port: 53}}, + }, + }, + PostureChecks: []*posture.Checks{}, + NetworkResources: []*resourceTypes.NetworkResource{ + { + ID: "resource-1", NetworkID: "net-1", AccountID: "account-components-test", Enabled: true, + Type: resourceTypes.Host, Prefix: netip.MustParsePrefix("10.200.0.1/32"), Address: "10.200.0.1/32", + }, + }, + Networks: []*networkTypes.Network{ + {ID: "net-1", Name: "Resource Net", AccountID: "account-components-test"}, + }, + NetworkRouters: []*routerTypes.NetworkRouter{ + {ID: "router-1", NetworkID: "net-1", Peer: "peer-router-1", Enabled: true, AccountID: "account-components-test"}, + }, + Settings: &types.Settings{PeerLoginExpirationEnabled: false, PeerLoginExpiration: 24 * time.Hour}, + } + + for _, p := range account.Policies { + p.AccountID = account.Id + } + for _, r := range account.Routes { + r.AccountID = account.Id + } + + return account +} diff --git a/management/server/types/networkmap_golden_test.go b/management/server/types/networkmap_golden_test.go deleted file mode 100644 index d85aaabb2..000000000 --- a/management/server/types/networkmap_golden_test.go +++ /dev/null @@ -1,1069 +0,0 @@ -package types_test - -import ( - "context" - "encoding/json" - "fmt" - "net" - "net/netip" - "os" - "path/filepath" - "slices" - "sort" - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/netbirdio/netbird/dns" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" - routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - networkTypes "github.com/netbirdio/netbird/management/server/networks/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/management/server/posture" - "github.com/netbirdio/netbird/management/server/types" - "github.com/netbirdio/netbird/route" -) - -// update flag is used to update the golden file. -// example: go test ./... -v -update -// var update = flag.Bool("update", false, "update golden files") - -const ( - numPeers = 100 - devGroupID = "group-dev" - opsGroupID = "group-ops" - allGroupID = "group-all" - routeID = route.ID("route-main") - routeHA1ID = route.ID("route-ha-1") - routeHA2ID = route.ID("route-ha-2") - policyIDDevOps = "policy-dev-ops" - policyIDAll = "policy-all" - policyIDPosture = "policy-posture" - policyIDDrop = "policy-drop" - postureCheckID = "posture-check-ver" - networkResourceID = "res-database" - networkID = "net-database" - networkRouterID = "router-database" - nameserverGroupID = "ns-group-main" - testingPeerID = "peer-60" // A peer from the "dev" group, should receive the most detailed map. - expiredPeerID = "peer-98" // This peer will be online but with an expired session. - offlinePeerID = "peer-99" // This peer will be completely offline. - routingPeerID = "peer-95" // This peer is used for routing, it has a route to the network. - testAccountID = "account-golden-test" -) - -func TestGetPeerNetworkMap_Golden(t *testing.T) { - account := createTestAccountWithEntities() - - ctx := context.Background() - validatedPeersMap := make(map[string]struct{}) - for i := range numPeers { - peerID := fmt.Sprintf("peer-%d", i) - if peerID == offlinePeerID { - continue - } - validatedPeersMap[peerID] = struct{}{} - } - - resourcePolicies := account.GetResourcePoliciesMap() - routers := account.GetResourceRoutersMap() - - networkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, resourcePolicies, routers, nil) - - normalizeAndSortNetworkMap(networkMap) - - jsonData, err := json.MarshalIndent(networkMap, "", " ") - require.NoError(t, err, "error marshaling network map to JSON") - - goldenFilePath := filepath.Join("testdata", "networkmap_golden.json") - - t.Log("Update golden file...") - err = os.MkdirAll(filepath.Dir(goldenFilePath), 0755) - require.NoError(t, err) - err = os.WriteFile(goldenFilePath, jsonData, 0644) - require.NoError(t, err) - - expectedJSON, err := os.ReadFile(goldenFilePath) - require.NoError(t, err, "error reading golden file") - - require.JSONEq(t, string(expectedJSON), string(jsonData), "resulted network map from OLD method does not match golden file") -} - -func TestGetPeerNetworkMap_Golden_New(t *testing.T) { - account := createTestAccountWithEntities() - - ctx := context.Background() - validatedPeersMap := make(map[string]struct{}) - for i := range numPeers { - peerID := fmt.Sprintf("peer-%d", i) - - if peerID == offlinePeerID { - continue - } - validatedPeersMap[peerID] = struct{}{} - } - - builder := types.NewNetworkMapBuilder(account, validatedPeersMap) - networkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil) - - normalizeAndSortNetworkMap(networkMap) - - jsonData, err := json.MarshalIndent(networkMap, "", " ") - require.NoError(t, err, "error marshaling network map to JSON") - - goldenFilePath := filepath.Join("testdata", "networkmap_golden_new.json") - - t.Log("Update golden file...") - err = os.MkdirAll(filepath.Dir(goldenFilePath), 0755) - require.NoError(t, err) - err = os.WriteFile(goldenFilePath, jsonData, 0644) - require.NoError(t, err) - - expectedJSON, err := os.ReadFile(goldenFilePath) - require.NoError(t, err, "error reading golden file") - - require.JSONEq(t, string(expectedJSON), string(jsonData), "resulted network map from NEW builder does not match golden file") -} - -func BenchmarkGetPeerNetworkMap(b *testing.B) { - account := createTestAccountWithEntities() - ctx := context.Background() - validatedPeersMap := make(map[string]struct{}) - var peerIDs []string - for i := range numPeers { - peerID := fmt.Sprintf("peer-%d", i) - validatedPeersMap[peerID] = struct{}{} - peerIDs = append(peerIDs, peerID) - } - - b.ResetTimer() - b.Run("old builder", func(b *testing.B) { - for range b.N { - for _, peerID := range peerIDs { - _ = account.GetPeerNetworkMap(ctx, peerID, dns.CustomZone{}, validatedPeersMap, nil, nil, nil) - } - } - }) - b.ResetTimer() - b.Run("new builder", func(b *testing.B) { - for range b.N { - builder := types.NewNetworkMapBuilder(account, validatedPeersMap) - for _, peerID := range peerIDs { - _ = builder.GetPeerNetworkMap(ctx, peerID, dns.CustomZone{}, validatedPeersMap, nil) - } - } - }) -} - -func TestGetPeerNetworkMap_Golden_WithNewPeer(t *testing.T) { - account := createTestAccountWithEntities() - - ctx := context.Background() - validatedPeersMap := make(map[string]struct{}) - for i := range numPeers { - peerID := fmt.Sprintf("peer-%d", i) - if peerID == offlinePeerID { - continue - } - validatedPeersMap[peerID] = struct{}{} - } - - newPeerID := "peer-new-101" - newPeerIP := net.IP{100, 64, 1, 1} - newPeer := &nbpeer.Peer{ - ID: newPeerID, - IP: newPeerIP, - Key: fmt.Sprintf("key-%s", newPeerID), - DNSLabel: "peernew101", - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, - UserID: "user-admin", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.26.0", GoOS: "linux"}, - LastLogin: func() *time.Time { t := time.Now(); return &t }(), - } - - account.Peers[newPeerID] = newPeer - - if devGroup, exists := account.Groups[devGroupID]; exists { - devGroup.Peers = append(devGroup.Peers, newPeerID) - } - - if allGroup, exists := account.Groups[allGroupID]; exists { - allGroup.Peers = append(allGroup.Peers, newPeerID) - } - - validatedPeersMap[newPeerID] = struct{}{} - - if account.Network != nil { - account.Network.Serial++ - } - - resourcePolicies := account.GetResourcePoliciesMap() - routers := account.GetResourceRoutersMap() - - networkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, resourcePolicies, routers, nil) - - normalizeAndSortNetworkMap(networkMap) - - jsonData, err := json.MarshalIndent(networkMap, "", " ") - require.NoError(t, err, "error marshaling network map to JSON") - - goldenFilePath := filepath.Join("testdata", "networkmap_golden_with_new_peer.json") - - t.Log("Update golden file with new peer...") - err = os.MkdirAll(filepath.Dir(goldenFilePath), 0755) - require.NoError(t, err) - err = os.WriteFile(goldenFilePath, jsonData, 0644) - require.NoError(t, err) - - expectedJSON, err := os.ReadFile(goldenFilePath) - require.NoError(t, err, "error reading golden file") - - require.JSONEq(t, string(expectedJSON), string(jsonData), "network map from OLD method with new peer does not match golden file") -} - -func TestGetPeerNetworkMap_Golden_New_WithOnPeerAdded(t *testing.T) { - account := createTestAccountWithEntities() - - ctx := context.Background() - validatedPeersMap := make(map[string]struct{}) - for i := range numPeers { - peerID := fmt.Sprintf("peer-%d", i) - if peerID == offlinePeerID { - continue - } - validatedPeersMap[peerID] = struct{}{} - } - - builder := types.NewNetworkMapBuilder(account, validatedPeersMap) - - newPeerID := "peer-new-101" - newPeerIP := net.IP{100, 64, 1, 1} - newPeer := &nbpeer.Peer{ - ID: newPeerID, - IP: newPeerIP, - Key: fmt.Sprintf("key-%s", newPeerID), - DNSLabel: "peernew101", - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, - UserID: "user-admin", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.26.0", GoOS: "linux"}, - LastLogin: func() *time.Time { t := time.Now(); return &t }(), - } - - account.Peers[newPeerID] = newPeer - - if devGroup, exists := account.Groups[devGroupID]; exists { - devGroup.Peers = append(devGroup.Peers, newPeerID) - } - - if allGroup, exists := account.Groups[allGroupID]; exists { - allGroup.Peers = append(allGroup.Peers, newPeerID) - } - - validatedPeersMap[newPeerID] = struct{}{} - - if account.Network != nil { - account.Network.Serial++ - } - - err := builder.OnPeerAddedIncremental(newPeerID) - require.NoError(t, err, "error adding peer to cache") - - networkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil) - - normalizeAndSortNetworkMap(networkMap) - - jsonData, err := json.MarshalIndent(networkMap, "", " ") - require.NoError(t, err, "error marshaling network map to JSON") - - goldenFilePath := filepath.Join("testdata", "networkmap_golden_new_with_onpeeradded.json") - t.Log("Update golden file with OnPeerAdded...") - err = os.MkdirAll(filepath.Dir(goldenFilePath), 0755) - require.NoError(t, err) - err = os.WriteFile(goldenFilePath, jsonData, 0644) - require.NoError(t, err) - - expectedJSON, err := os.ReadFile(goldenFilePath) - require.NoError(t, err, "error reading golden file") - - require.JSONEq(t, string(expectedJSON), string(jsonData), "network map from NEW builder with OnPeerAdded does not match golden file") -} - -func BenchmarkGetPeerNetworkMap_AfterPeerAdded(b *testing.B) { - account := createTestAccountWithEntities() - ctx := context.Background() - validatedPeersMap := make(map[string]struct{}) - var peerIDs []string - for i := range numPeers { - peerID := fmt.Sprintf("peer-%d", i) - validatedPeersMap[peerID] = struct{}{} - peerIDs = append(peerIDs, peerID) - } - builder := types.NewNetworkMapBuilder(account, validatedPeersMap) - newPeerID := "peer-new-101" - newPeer := &nbpeer.Peer{ - ID: newPeerID, - IP: net.IP{100, 64, 1, 1}, - Key: fmt.Sprintf("key-%s", newPeerID), - DNSLabel: "peernew101", - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, - UserID: "user-admin", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.26.0", GoOS: "linux"}, - } - - account.Peers[newPeerID] = newPeer - account.Groups[devGroupID].Peers = append(account.Groups[devGroupID].Peers, newPeerID) - account.Groups[allGroupID].Peers = append(account.Groups[allGroupID].Peers, newPeerID) - validatedPeersMap[newPeerID] = struct{}{} - - b.ResetTimer() - b.Run("old builder after add", func(b *testing.B) { - for i := 0; i < b.N; i++ { - for _, testingPeerID := range peerIDs { - _ = account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil, nil, nil) - } - } - }) - - b.ResetTimer() - b.Run("new builder after add", func(b *testing.B) { - for i := 0; i < b.N; i++ { - _ = builder.OnPeerAddedIncremental(newPeerID) - for _, testingPeerID := range peerIDs { - _ = builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil) - } - } - }) -} - -func TestGetPeerNetworkMap_Golden_WithNewRoutingPeer(t *testing.T) { - account := createTestAccountWithEntities() - - ctx := context.Background() - validatedPeersMap := make(map[string]struct{}) - for i := range numPeers { - peerID := fmt.Sprintf("peer-%d", i) - if peerID == offlinePeerID { - continue - } - validatedPeersMap[peerID] = struct{}{} - } - - newRouterID := "peer-new-router-102" - newRouterIP := net.IP{100, 64, 1, 2} - newRouter := &nbpeer.Peer{ - ID: newRouterID, - IP: newRouterIP, - Key: fmt.Sprintf("key-%s", newRouterID), - DNSLabel: "newrouter102", - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, - UserID: "user-admin", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.26.0", GoOS: "linux"}, - LastLogin: func() *time.Time { t := time.Now(); return &t }(), - } - - account.Peers[newRouterID] = newRouter - - if opsGroup, exists := account.Groups[opsGroupID]; exists { - opsGroup.Peers = append(opsGroup.Peers, newRouterID) - } - - if allGroup, exists := account.Groups[allGroupID]; exists { - allGroup.Peers = append(allGroup.Peers, newRouterID) - } - - newRoute := &route.Route{ - ID: route.ID("route-new-router"), - Network: netip.MustParsePrefix("172.16.0.0/24"), - Peer: newRouter.Key, - PeerID: newRouterID, - Description: "Route from new router", - Enabled: true, - PeerGroups: []string{opsGroupID}, - Groups: []string{devGroupID, opsGroupID}, - AccessControlGroups: []string{devGroupID}, - AccountID: account.Id, - } - account.Routes[newRoute.ID] = newRoute - - validatedPeersMap[newRouterID] = struct{}{} - - if account.Network != nil { - account.Network.Serial++ - } - - resourcePolicies := account.GetResourcePoliciesMap() - routers := account.GetResourceRoutersMap() - - networkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, resourcePolicies, routers, nil) - - normalizeAndSortNetworkMap(networkMap) - - jsonData, err := json.MarshalIndent(networkMap, "", " ") - require.NoError(t, err, "error marshaling network map to JSON") - - goldenFilePath := filepath.Join("testdata", "networkmap_golden_with_new_router.json") - - t.Log("Update golden file with new router...") - err = os.MkdirAll(filepath.Dir(goldenFilePath), 0755) - require.NoError(t, err) - err = os.WriteFile(goldenFilePath, jsonData, 0644) - require.NoError(t, err) - - expectedJSON, err := os.ReadFile(goldenFilePath) - require.NoError(t, err, "error reading golden file") - - require.JSONEq(t, string(expectedJSON), string(jsonData), "network map from OLD method with new router does not match golden file") -} - -func TestGetPeerNetworkMap_Golden_New_WithOnPeerAddedRouter(t *testing.T) { - account := createTestAccountWithEntities() - - ctx := context.Background() - validatedPeersMap := make(map[string]struct{}) - for i := range numPeers { - peerID := fmt.Sprintf("peer-%d", i) - if peerID == offlinePeerID { - continue - } - validatedPeersMap[peerID] = struct{}{} - } - - builder := types.NewNetworkMapBuilder(account, validatedPeersMap) - - newRouterID := "peer-new-router-102" - newRouterIP := net.IP{100, 64, 1, 2} - newRouter := &nbpeer.Peer{ - ID: newRouterID, - IP: newRouterIP, - Key: fmt.Sprintf("key-%s", newRouterID), - DNSLabel: "newrouter102", - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, - UserID: "user-admin", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.26.0", GoOS: "linux"}, - LastLogin: func() *time.Time { t := time.Now(); return &t }(), - } - - account.Peers[newRouterID] = newRouter - - if opsGroup, exists := account.Groups[opsGroupID]; exists { - opsGroup.Peers = append(opsGroup.Peers, newRouterID) - } - if allGroup, exists := account.Groups[allGroupID]; exists { - allGroup.Peers = append(allGroup.Peers, newRouterID) - } - - newRoute := &route.Route{ - ID: route.ID("route-new-router"), - Network: netip.MustParsePrefix("172.16.0.0/24"), - Peer: newRouter.Key, - PeerID: newRouterID, - Description: "Route from new router", - Enabled: true, - PeerGroups: []string{opsGroupID}, - Groups: []string{devGroupID, opsGroupID}, - AccessControlGroups: []string{devGroupID}, - AccountID: account.Id, - } - account.Routes[newRoute.ID] = newRoute - - validatedPeersMap[newRouterID] = struct{}{} - - if account.Network != nil { - account.Network.Serial++ - } - - err := builder.OnPeerAddedIncremental(newRouterID) - require.NoError(t, err, "error adding router to cache") - - networkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil) - - normalizeAndSortNetworkMap(networkMap) - - jsonData, err := json.MarshalIndent(networkMap, "", " ") - require.NoError(t, err, "error marshaling network map to JSON") - - goldenFilePath := filepath.Join("testdata", "networkmap_golden_new_with_onpeeradded_router.json") - - t.Log("Update golden file with OnPeerAdded router...") - err = os.MkdirAll(filepath.Dir(goldenFilePath), 0755) - require.NoError(t, err) - err = os.WriteFile(goldenFilePath, jsonData, 0644) - require.NoError(t, err) - - expectedJSON, err := os.ReadFile(goldenFilePath) - require.NoError(t, err, "error reading golden file") - - require.JSONEq(t, string(expectedJSON), string(jsonData), "network map from NEW builder with OnPeerAdded router does not match golden file") -} - -func BenchmarkGetPeerNetworkMap_AfterRouterPeerAdded(b *testing.B) { - account := createTestAccountWithEntities() - ctx := context.Background() - validatedPeersMap := make(map[string]struct{}) - var peerIDs []string - for i := range numPeers { - peerID := fmt.Sprintf("peer-%d", i) - validatedPeersMap[peerID] = struct{}{} - peerIDs = append(peerIDs, peerID) - } - builder := types.NewNetworkMapBuilder(account, validatedPeersMap) - newRouterID := "peer-new-router-102" - newRouterIP := net.IP{100, 64, 1, 2} - newRouter := &nbpeer.Peer{ - ID: newRouterID, - IP: newRouterIP, - Key: fmt.Sprintf("key-%s", newRouterID), - DNSLabel: "newrouter102", - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, - UserID: "user-admin", - Meta: nbpeer.PeerSystemMeta{WtVersion: "0.26.0", GoOS: "linux"}, - LastLogin: func() *time.Time { t := time.Now(); return &t }(), - } - - account.Peers[newRouterID] = newRouter - - if opsGroup, exists := account.Groups[opsGroupID]; exists { - opsGroup.Peers = append(opsGroup.Peers, newRouterID) - } - if allGroup, exists := account.Groups[allGroupID]; exists { - allGroup.Peers = append(allGroup.Peers, newRouterID) - } - - newRoute := &route.Route{ - ID: route.ID("route-new-router"), - Network: netip.MustParsePrefix("172.16.0.0/24"), - Peer: newRouter.Key, - PeerID: newRouterID, - Description: "Route from new router", - Enabled: true, - PeerGroups: []string{opsGroupID}, - Groups: []string{devGroupID, opsGroupID}, - AccessControlGroups: []string{devGroupID}, - AccountID: account.Id, - } - account.Routes[newRoute.ID] = newRoute - - validatedPeersMap[newRouterID] = struct{}{} - - b.ResetTimer() - b.Run("old builder after add", func(b *testing.B) { - for i := 0; i < b.N; i++ { - for _, testingPeerID := range peerIDs { - _ = account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil, nil, nil) - } - } - }) - - b.ResetTimer() - b.Run("new builder after add", func(b *testing.B) { - for i := 0; i < b.N; i++ { - _ = builder.OnPeerAddedIncremental(newRouterID) - for _, testingPeerID := range peerIDs { - _ = builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil) - } - } - }) -} - -func TestGetPeerNetworkMap_Golden_WithDeletedPeer(t *testing.T) { - account := createTestAccountWithEntities() - - ctx := context.Background() - validatedPeersMap := make(map[string]struct{}) - for i := range numPeers { - peerID := fmt.Sprintf("peer-%d", i) - if peerID == offlinePeerID { - continue - } - validatedPeersMap[peerID] = struct{}{} - } - - deletedPeerID := "peer-25" // peer from devs group - - delete(account.Peers, deletedPeerID) - - if devGroup, exists := account.Groups[devGroupID]; exists { - devGroup.Peers = slices.DeleteFunc(devGroup.Peers, func(id string) bool { - return id == deletedPeerID - }) - } - - if allGroup, exists := account.Groups[allGroupID]; exists { - allGroup.Peers = slices.DeleteFunc(allGroup.Peers, func(id string) bool { - return id == deletedPeerID - }) - } - - delete(validatedPeersMap, deletedPeerID) - - if account.Network != nil { - account.Network.Serial++ - } - - resourcePolicies := account.GetResourcePoliciesMap() - routers := account.GetResourceRoutersMap() - - networkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, resourcePolicies, routers, nil) - - normalizeAndSortNetworkMap(networkMap) - - jsonData, err := json.MarshalIndent(networkMap, "", " ") - require.NoError(t, err, "error marshaling network map to JSON") - - goldenFilePath := filepath.Join("testdata", "networkmap_golden_with_deleted_peer.json") - - t.Log("Update golden file with deleted peer...") - err = os.MkdirAll(filepath.Dir(goldenFilePath), 0755) - require.NoError(t, err) - err = os.WriteFile(goldenFilePath, jsonData, 0644) - require.NoError(t, err) - - expectedJSON, err := os.ReadFile(goldenFilePath) - require.NoError(t, err, "error reading golden file") - - require.JSONEq(t, string(expectedJSON), string(jsonData), "network map from OLD method with deleted peer does not match golden file") -} - -func TestGetPeerNetworkMap_Golden_New_WithOnPeerDeleted(t *testing.T) { - account := createTestAccountWithEntities() - - ctx := context.Background() - validatedPeersMap := make(map[string]struct{}) - for i := range numPeers { - peerID := fmt.Sprintf("peer-%d", i) - if peerID == offlinePeerID { - continue - } - validatedPeersMap[peerID] = struct{}{} - } - - builder := types.NewNetworkMapBuilder(account, validatedPeersMap) - - deletedPeerID := "peer-25" // devs group peer - - delete(account.Peers, deletedPeerID) - - if devGroup, exists := account.Groups[devGroupID]; exists { - devGroup.Peers = slices.DeleteFunc(devGroup.Peers, func(id string) bool { - return id == deletedPeerID - }) - } - - if allGroup, exists := account.Groups[allGroupID]; exists { - allGroup.Peers = slices.DeleteFunc(allGroup.Peers, func(id string) bool { - return id == deletedPeerID - }) - } - - delete(validatedPeersMap, deletedPeerID) - - if account.Network != nil { - account.Network.Serial++ - } - - err := builder.OnPeerDeleted(deletedPeerID) - require.NoError(t, err, "error deleting peer from cache") - - networkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil) - - normalizeAndSortNetworkMap(networkMap) - - jsonData, err := json.MarshalIndent(networkMap, "", " ") - require.NoError(t, err, "error marshaling network map to JSON") - - goldenFilePath := filepath.Join("testdata", "networkmap_golden_new_with_onpeerdeleted.json") - t.Log("Update golden file with OnPeerDeleted...") - err = os.MkdirAll(filepath.Dir(goldenFilePath), 0755) - require.NoError(t, err) - err = os.WriteFile(goldenFilePath, jsonData, 0644) - require.NoError(t, err) - - expectedJSON, err := os.ReadFile(goldenFilePath) - require.NoError(t, err, "error reading golden file") - - require.JSONEq(t, string(expectedJSON), string(jsonData), "network map from NEW builder with OnPeerDeleted does not match golden file") -} - -func TestGetPeerNetworkMap_Golden_WithDeletedRouterPeer(t *testing.T) { - account := createTestAccountWithEntities() - - ctx := context.Background() - validatedPeersMap := make(map[string]struct{}) - for i := range numPeers { - peerID := fmt.Sprintf("peer-%d", i) - if peerID == offlinePeerID { - continue - } - validatedPeersMap[peerID] = struct{}{} - } - - deletedRouterID := "peer-75" // router peer - - var affectedRoute *route.Route - for _, r := range account.Routes { - if r.PeerID == deletedRouterID { - affectedRoute = r - break - } - } - require.NotNil(t, affectedRoute, "Router peer should have a route") - - for _, group := range account.Groups { - group.Peers = slices.DeleteFunc(group.Peers, func(id string) bool { - return id == deletedRouterID - }) - } - - for routeID, r := range account.Routes { - if r.Peer == account.Peers[deletedRouterID].Key || r.PeerID == deletedRouterID { - delete(account.Routes, routeID) - } - } - delete(account.Peers, deletedRouterID) - delete(validatedPeersMap, deletedRouterID) - - if account.Network != nil { - account.Network.Serial++ - } - - resourcePolicies := account.GetResourcePoliciesMap() - routers := account.GetResourceRoutersMap() - - networkMap := account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, resourcePolicies, routers, nil) - - normalizeAndSortNetworkMap(networkMap) - - jsonData, err := json.MarshalIndent(networkMap, "", " ") - require.NoError(t, err, "error marshaling network map to JSON") - - goldenFilePath := filepath.Join("testdata", "networkmap_golden_with_deleted_router_peer.json") - - t.Log("Update golden file with deleted peer...") - err = os.MkdirAll(filepath.Dir(goldenFilePath), 0755) - require.NoError(t, err) - err = os.WriteFile(goldenFilePath, jsonData, 0644) - require.NoError(t, err) - - expectedJSON, err := os.ReadFile(goldenFilePath) - require.NoError(t, err, "error reading golden file") - - require.JSONEq(t, string(expectedJSON), string(jsonData), "network map from OLD method with deleted peer does not match golden file") -} - -func TestGetPeerNetworkMap_Golden_New_WithDeletedRouterPeer(t *testing.T) { - account := createTestAccountWithEntities() - - ctx := context.Background() - validatedPeersMap := make(map[string]struct{}) - for i := range numPeers { - peerID := fmt.Sprintf("peer-%d", i) - if peerID == offlinePeerID { - continue - } - validatedPeersMap[peerID] = struct{}{} - } - - builder := types.NewNetworkMapBuilder(account, validatedPeersMap) - - deletedRouterID := "peer-75" // router peer - - var affectedRoute *route.Route - for _, r := range account.Routes { - if r.PeerID == deletedRouterID { - affectedRoute = r - break - } - } - require.NotNil(t, affectedRoute, "Router peer should have a route") - - for _, group := range account.Groups { - group.Peers = slices.DeleteFunc(group.Peers, func(id string) bool { - return id == deletedRouterID - }) - } - for routeID, r := range account.Routes { - if r.Peer == account.Peers[deletedRouterID].Key || r.PeerID == deletedRouterID { - delete(account.Routes, routeID) - } - } - delete(account.Peers, deletedRouterID) - delete(validatedPeersMap, deletedRouterID) - - if account.Network != nil { - account.Network.Serial++ - } - - err := builder.OnPeerDeleted(deletedRouterID) - require.NoError(t, err, "error deleting routing peer from cache") - - networkMap := builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil) - - normalizeAndSortNetworkMap(networkMap) - - jsonData, err := json.MarshalIndent(networkMap, "", " ") - require.NoError(t, err) - - goldenFilePath := filepath.Join("testdata", "networkmap_golden_new_with_deleted_router.json") - - t.Log("Update golden file with deleted router...") - err = os.MkdirAll(filepath.Dir(goldenFilePath), 0755) - require.NoError(t, err) - err = os.WriteFile(goldenFilePath, jsonData, 0644) - require.NoError(t, err) - - expectedJSON, err := os.ReadFile(goldenFilePath) - require.NoError(t, err) - - require.JSONEq(t, string(expectedJSON), string(jsonData), - "network map after deleting router does not match golden file") -} - -func BenchmarkGetPeerNetworkMap_AfterPeerDeleted(b *testing.B) { - account := createTestAccountWithEntities() - ctx := context.Background() - validatedPeersMap := make(map[string]struct{}) - var peerIDs []string - for i := range numPeers { - peerID := fmt.Sprintf("peer-%d", i) - validatedPeersMap[peerID] = struct{}{} - peerIDs = append(peerIDs, peerID) - } - - deletedPeerID := "peer-25" - - delete(account.Peers, deletedPeerID) - account.Groups[devGroupID].Peers = slices.DeleteFunc(account.Groups[devGroupID].Peers, func(id string) bool { - return id == deletedPeerID - }) - account.Groups[allGroupID].Peers = slices.DeleteFunc(account.Groups[allGroupID].Peers, func(id string) bool { - return id == deletedPeerID - }) - delete(validatedPeersMap, deletedPeerID) - - builder := types.NewNetworkMapBuilder(account, validatedPeersMap) - - b.ResetTimer() - b.Run("old builder after delete", func(b *testing.B) { - for i := 0; i < b.N; i++ { - for _, testingPeerID := range peerIDs { - _ = account.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil, nil, nil) - } - } - }) - - b.ResetTimer() - b.Run("new builder after delete", func(b *testing.B) { - for i := 0; i < b.N; i++ { - _ = builder.OnPeerDeleted(deletedPeerID) - for _, testingPeerID := range peerIDs { - _ = builder.GetPeerNetworkMap(ctx, testingPeerID, dns.CustomZone{}, validatedPeersMap, nil) - } - } - }) -} - -func normalizeAndSortNetworkMap(networkMap *types.NetworkMap) { - for _, peer := range networkMap.Peers { - if peer.Status != nil { - peer.Status.LastSeen = time.Time{} - } - peer.LastLogin = &time.Time{} - } - for _, peer := range networkMap.OfflinePeers { - if peer.Status != nil { - peer.Status.LastSeen = time.Time{} - } - peer.LastLogin = &time.Time{} - } - - sort.Slice(networkMap.Peers, func(i, j int) bool { return networkMap.Peers[i].ID < networkMap.Peers[j].ID }) - sort.Slice(networkMap.OfflinePeers, func(i, j int) bool { return networkMap.OfflinePeers[i].ID < networkMap.OfflinePeers[j].ID }) - sort.Slice(networkMap.Routes, func(i, j int) bool { return networkMap.Routes[i].ID < networkMap.Routes[j].ID }) - - sort.Slice(networkMap.FirewallRules, func(i, j int) bool { - r1, r2 := networkMap.FirewallRules[i], networkMap.FirewallRules[j] - if r1.PeerIP != r2.PeerIP { - return r1.PeerIP < r2.PeerIP - } - if r1.Protocol != r2.Protocol { - return r1.Protocol < r2.Protocol - } - if r1.Direction != r2.Direction { - return r1.Direction < r2.Direction - } - if r1.Action != r2.Action { - return r1.Action < r2.Action - } - return r1.Port < r2.Port - }) - - sort.Slice(networkMap.RoutesFirewallRules, func(i, j int) bool { - r1, r2 := networkMap.RoutesFirewallRules[i], networkMap.RoutesFirewallRules[j] - if r1.RouteID != r2.RouteID { - return r1.RouteID < r2.RouteID - } - if r1.Action != r2.Action { - return r1.Action < r2.Action - } - if r1.Destination != r2.Destination { - return r1.Destination < r2.Destination - } - if len(r1.SourceRanges) > 0 && len(r2.SourceRanges) > 0 { - if r1.SourceRanges[0] != r2.SourceRanges[0] { - return r1.SourceRanges[0] < r2.SourceRanges[0] - } - } - return r1.Port < r2.Port - }) - - for _, ranges := range networkMap.RoutesFirewallRules { - sort.Slice(ranges.SourceRanges, func(i, j int) bool { - return ranges.SourceRanges[i] < ranges.SourceRanges[j] - }) - } -} - -func createTestAccountWithEntities() *types.Account { - peers := make(map[string]*nbpeer.Peer) - devGroupPeers, opsGroupPeers, allGroupPeers := []string{}, []string{}, []string{} - - for i := range numPeers { - peerID := fmt.Sprintf("peer-%d", i) - ip := net.IP{100, 64, 0, byte(i + 1)} - wtVersion := "0.25.0" - if i%2 == 0 { - wtVersion = "0.40.0" - } - - p := &nbpeer.Peer{ - ID: peerID, IP: ip, Key: fmt.Sprintf("key-%s", peerID), DNSLabel: fmt.Sprintf("peer%d", i+1), - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now()}, - UserID: "user-admin", Meta: nbpeer.PeerSystemMeta{WtVersion: wtVersion, GoOS: "linux"}, - } - - if peerID == expiredPeerID { - p.LoginExpirationEnabled = true - pastTimestamp := time.Now().Add(-2 * time.Hour) - p.LastLogin = &pastTimestamp - } - - peers[peerID] = p - allGroupPeers = append(allGroupPeers, peerID) - if i < numPeers/2 { - devGroupPeers = append(devGroupPeers, peerID) - } else { - opsGroupPeers = append(opsGroupPeers, peerID) - } - - } - - groups := map[string]*types.Group{ - allGroupID: {ID: allGroupID, Name: "All", Peers: allGroupPeers}, - devGroupID: {ID: devGroupID, Name: "Developers", Peers: devGroupPeers}, - opsGroupID: {ID: opsGroupID, Name: "Operations", Peers: opsGroupPeers}, - } - - policies := []*types.Policy{ - { - ID: policyIDAll, Name: "Default-Allow", Enabled: true, - Rules: []*types.PolicyRule{{ - ID: policyIDAll, Name: "Allow All", Enabled: true, Action: types.PolicyTrafficActionAccept, - Protocol: types.PolicyRuleProtocolALL, Bidirectional: true, - Sources: []string{allGroupID}, Destinations: []string{allGroupID}, - }}, - }, - { - ID: policyIDDevOps, Name: "Dev to Ops Web Access", Enabled: true, - Rules: []*types.PolicyRule{{ - ID: policyIDDevOps, Name: "Dev -> Ops (HTTP Range)", Enabled: true, Action: types.PolicyTrafficActionAccept, - Protocol: types.PolicyRuleProtocolTCP, Bidirectional: false, - PortRanges: []types.RulePortRange{{Start: 8080, End: 8090}}, - Sources: []string{devGroupID}, Destinations: []string{opsGroupID}, - }}, - }, - { - ID: policyIDDrop, Name: "Drop DB traffic", Enabled: true, - Rules: []*types.PolicyRule{{ - ID: policyIDDrop, Name: "Drop DB", Enabled: true, Action: types.PolicyTrafficActionDrop, - Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"5432"}, Bidirectional: true, - Sources: []string{devGroupID}, Destinations: []string{opsGroupID}, - }}, - }, - { - ID: policyIDPosture, Name: "Posture Check for DB Resource", Enabled: true, - SourcePostureChecks: []string{postureCheckID}, - Rules: []*types.PolicyRule{{ - ID: policyIDPosture, Name: "Allow DB Access", Enabled: true, Action: types.PolicyTrafficActionAccept, - Protocol: types.PolicyRuleProtocolALL, Bidirectional: true, - Sources: []string{opsGroupID}, DestinationResource: types.Resource{ID: networkResourceID}, - }}, - }, - } - - routes := map[route.ID]*route.Route{ - routeID: { - ID: routeID, Network: netip.MustParsePrefix("192.168.10.0/24"), - Peer: peers["peer-75"].Key, - PeerID: "peer-75", - Description: "Route to internal resource", Enabled: true, - PeerGroups: []string{devGroupID, opsGroupID}, - Groups: []string{devGroupID, opsGroupID}, - AccessControlGroups: []string{devGroupID}, - }, - routeHA1ID: { - ID: routeHA1ID, Network: netip.MustParsePrefix("10.10.0.0/16"), - Peer: peers["peer-80"].Key, - PeerID: "peer-80", - Description: "HA Route 1", Enabled: true, Metric: 1000, - PeerGroups: []string{allGroupID}, - Groups: []string{allGroupID}, - AccessControlGroups: []string{allGroupID}, - }, - routeHA2ID: { - ID: routeHA2ID, Network: netip.MustParsePrefix("10.10.0.0/16"), - Peer: peers["peer-90"].Key, - PeerID: "peer-90", - Description: "HA Route 2", Enabled: true, Metric: 900, - PeerGroups: []string{devGroupID, opsGroupID}, - Groups: []string{devGroupID, opsGroupID}, - AccessControlGroups: []string{allGroupID}, - }, - } - - account := &types.Account{ - Id: testAccountID, Peers: peers, Groups: groups, Policies: policies, Routes: routes, - Network: &types.Network{ - Identifier: "net-golden-test", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(16, 32)}, Serial: 1, - }, - DNSSettings: types.DNSSettings{DisabledManagementGroups: []string{opsGroupID}}, - NameServerGroups: map[string]*dns.NameServerGroup{ - nameserverGroupID: { - ID: nameserverGroupID, Name: "Main NS", Enabled: true, Groups: []string{devGroupID}, - NameServers: []dns.NameServer{{IP: netip.MustParseAddr("8.8.8.8"), NSType: dns.UDPNameServerType, Port: 53}}, - }, - }, - PostureChecks: []*posture.Checks{ - {ID: postureCheckID, Name: "Check version", Checks: posture.ChecksDefinition{ - NBVersionCheck: &posture.NBVersionCheck{MinVersion: "0.26.0"}, - }}, - }, - NetworkResources: []*resourceTypes.NetworkResource{ - {ID: networkResourceID, NetworkID: networkID, AccountID: testAccountID, Enabled: true, Address: "db.netbird.cloud"}, - }, - Networks: []*networkTypes.Network{{ID: networkID, Name: "DB Network", AccountID: testAccountID}}, - NetworkRouters: []*routerTypes.NetworkRouter{ - {ID: networkRouterID, NetworkID: networkID, Peer: routingPeerID, Enabled: true, AccountID: testAccountID}, - }, - Settings: &types.Settings{PeerLoginExpirationEnabled: true, PeerLoginExpiration: 1 * time.Hour}, - } - - for _, p := range account.Policies { - p.AccountID = account.Id - } - for _, r := range account.Routes { - r.AccountID = account.Id - } - - return account -} diff --git a/management/server/types/networkmap_wire_benchmark_test.go b/management/server/types/networkmap_wire_benchmark_test.go new file mode 100644 index 000000000..ee9839a3f --- /dev/null +++ b/management/server/types/networkmap_wire_benchmark_test.go @@ -0,0 +1,163 @@ +package types_test + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "testing" + + goproto "google.golang.org/protobuf/proto" + + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache" + mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/server/types" +) + +// wireBenchScales — trimmed scale set for wire-size measurements. Encoding +// and marshalling are linear, so the largest extremes don't add signal. +var wireBenchScales = []benchmarkScale{ + {"100peers_5groups", 100, 5}, + {"500peers_20groups", 500, 20}, + {"1000peers_50groups", 1000, 50}, + {"5000peers_100groups", 5000, 100}, +} + +// assignValidWgKeys overwrites every peer's Key with a valid base64-encoded +// 32-byte string. The default scalableTestAccount uses unparsable strings +// like "key-peer-0", which makes the components encoder emit a nil WgPubKey +// and the legacy encoder ship 10-char placeholders — both shrink the wire +// size in unrealistic ways. Production peers always have valid 44-char base64 +// keys, so any benchmark/breakdown that wants honest numbers must call this. +func assignValidWgKeys(account *types.Account) { + for _, p := range account.Peers { + var raw [32]byte + _, _ = rand.Read(raw[:]) + p.Key = base64.StdEncoding.EncodeToString(raw[:]) + } +} + +// BenchmarkNetworkMapWireEncode reports per-call ns and the marshaled wire +// size for both encoding paths. Run with: +// +// go test -run=^$ -bench=BenchmarkNetworkMapWireEncode -benchmem ./management/server/types/ +func BenchmarkNetworkMapWireEncode(b *testing.B) { + skipCIBenchmark(b) + + for _, scale := range wireBenchScales { + account, validatedPeers := scalableTestAccount(scale.peers, scale.groups) + // populateAccountSeqIDs(account) + assignValidWgKeys(account) + + ctx := context.Background() + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + peerID := "peer-0" + peer := account.Peers[peerID] + + networkMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, nil, groupIDToUserIDs) + components := account.GetPeerNetworkMapComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, groupIDToUserIDs) + + dnsCache := &cache.DNSConfigCache{} + settings := &types.Settings{} + + // Pre-encode once so the size metric is identical for every run inside + // the same scale; the b.Loop call only re-runs encode + Marshal. + legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0) + legacyBytes, err := goproto.Marshal(legacyResp.NetworkMap) + if err != nil { + b.Fatalf("marshal legacy networkmap: %v", err) + } + + envelopeInput := mgmtgrpc.ComponentsEnvelopeInput{ + Components: components, + PeerConfig: legacyResp.NetworkMap.PeerConfig, + DNSDomain: "netbird.cloud", + } + envelope := mgmtgrpc.EncodeNetworkMapEnvelope(envelopeInput) + envelopeBytes, err := goproto.Marshal(envelope) + if err != nil { + b.Fatalf("marshal envelope: %v", err) + } + + b.Run(fmt.Sprintf("legacy/%s", scale.name), func(b *testing.B) { + b.ReportAllocs() + b.ReportMetric(float64(len(legacyBytes)), "bytes/msg") + b.ResetTimer() + for range b.N { + resp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0) + if _, err := goproto.Marshal(resp.NetworkMap); err != nil { + b.Fatal(err) + } + } + }) + + b.Run(fmt.Sprintf("components/%s", scale.name), func(b *testing.B) { + b.ReportAllocs() + b.ReportMetric(float64(len(envelopeBytes)), "bytes/msg") + b.ResetTimer() + for range b.N { + env := mgmtgrpc.EncodeNetworkMapEnvelope(envelopeInput) + if _, err := goproto.Marshal(env); err != nil { + b.Fatal(err) + } + } + }) + } +} + +// BenchmarkNetworkMapWireSize is a fast snapshot of the wire size by scale +// without a tight encode loop. Run with -bench to see one ns/op + bytes per +// scale (treat the timing as informational; the sample is one Marshal per +// scale, not the full b.N loop). +func BenchmarkNetworkMapWireSize(b *testing.B) { + skipCIBenchmark(b) + + for _, scale := range wireBenchScales { + account, validatedPeers := scalableTestAccount(scale.peers, scale.groups) + // populateAccountSeqIDs(account) + assignValidWgKeys(account) + + ctx := context.Background() + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + peerID := "peer-0" + peer := account.Peers[peerID] + + networkMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, nil, groupIDToUserIDs) + components := account.GetPeerNetworkMapComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, groupIDToUserIDs) + + dnsCache := &cache.DNSConfigCache{} + settings := &types.Settings{} + + legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0) + legacyBytes, err := goproto.Marshal(legacyResp.NetworkMap) + if err != nil { + b.Fatalf("marshal legacy networkmap: %v", err) + } + + env := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{ + Components: components, + PeerConfig: legacyResp.NetworkMap.PeerConfig, + DNSDomain: "netbird.cloud", + }) + envBytes, err := goproto.Marshal(env) + if err != nil { + b.Fatalf("marshal envelope: %v", err) + } + + b.Run(fmt.Sprintf("size/%s", scale.name), func(b *testing.B) { + b.ReportMetric(float64(len(legacyBytes)), "legacy_bytes") + b.ReportMetric(float64(len(envBytes)), "components_bytes") + ratio := float64(len(envBytes)) / float64(len(legacyBytes)) + b.ReportMetric(ratio, "components/legacy") + for range b.N { + } + }) + } +} diff --git a/management/server/types/networkmap_wire_breakdown_test.go b/management/server/types/networkmap_wire_breakdown_test.go new file mode 100644 index 000000000..ac2855fa3 --- /dev/null +++ b/management/server/types/networkmap_wire_breakdown_test.go @@ -0,0 +1,149 @@ +package types_test + +import ( + "context" + "fmt" + "os" + "testing" + + goproto "google.golang.org/protobuf/proto" + + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache" + mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// TestNetworkMapWireBreakdown is a one-shot diagnostic: it computes the wire +// size attributable to each top-level field of both the legacy NetworkMap and +// the components NetworkMapEnvelope at the 5000-peer scale, so the migration +// docs can attribute the size reduction to each optimization. Runs only on +// demand via -run TestNetworkMapWireBreakdown. +func TestNetworkMapWireBreakdown(t *testing.T) { + if testing.Short() { + t.Skip("size diagnostic, skipped with -short") + } + if os.Getenv("NB_RUN_WIRE_BREAKDOWN") != "1" { + t.Skip("set NB_RUN_WIRE_BREAKDOWN=1 to run wire breakdown diagnostic") + } + + const peerCount, groupCount = 5000, 100 + account, validatedPeers := scalableTestAccount(peerCount, groupCount) + assignValidWgKeys(account) + + ctx := context.Background() + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + peerID := "peer-0" + peer := account.Peers[peerID] + networkMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, nil, groupIDToUserIDs) + components := account.GetPeerNetworkMapComponents(ctx, peerID, nbdns.CustomZone{}, nil, validatedPeers, resourcePolicies, routers, groupIDToUserIDs) + + dnsCache := &cache.DNSConfigCache{} + settings := &types.Settings{} + + legacyResp := mgmtgrpc.ToSyncResponse(ctx, nil, nil, nil, peer, nil, nil, networkMap, "netbird.cloud", nil, dnsCache, settings, nil, nil, 0) + legacyTotal := mustMarshalSize(t, legacyResp.NetworkMap) + + envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{ + Components: components, + PeerConfig: legacyResp.NetworkMap.PeerConfig, + DNSDomain: "netbird.cloud", + }) + componentsTotal := mustMarshalSize(t, envelope) + + t.Logf("\n=== LEGACY NetworkMap (%d peers, %d groups) ===", peerCount, groupCount) + t.Logf(" Total: %d bytes\n", legacyTotal) + + legacyBreakdown := []struct { + name string + nm *proto.NetworkMap + }{ + {"RemotePeers", &proto.NetworkMap{RemotePeers: legacyResp.NetworkMap.RemotePeers}}, + {"OfflinePeers", &proto.NetworkMap{OfflinePeers: legacyResp.NetworkMap.OfflinePeers}}, + {"FirewallRules", &proto.NetworkMap{FirewallRules: legacyResp.NetworkMap.FirewallRules}}, + {"Routes", &proto.NetworkMap{Routes: legacyResp.NetworkMap.Routes}}, + {"RoutesFirewallRules", &proto.NetworkMap{RoutesFirewallRules: legacyResp.NetworkMap.RoutesFirewallRules}}, + {"DNSConfig", &proto.NetworkMap{DNSConfig: legacyResp.NetworkMap.DNSConfig}}, + {"PeerConfig", &proto.NetworkMap{PeerConfig: legacyResp.NetworkMap.PeerConfig}}, + {"SshAuth", &proto.NetworkMap{SshAuth: legacyResp.NetworkMap.SshAuth}}, + } + for _, e := range legacyBreakdown { + size := mustMarshalSize(t, e.nm) + t.Logf(" %-22s %8d bytes %5.1f%%", e.name, size, pct(size, legacyTotal)) + } + + full := envelope.GetFull() + if full == nil { + t.Fatalf("expected full network map envelope payload, got nil") + } + t.Logf("\n=== COMPONENTS NetworkMapEnvelope (%d peers, %d groups) ===", peerCount, groupCount) + t.Logf(" Total: %d bytes (%.1f%% of legacy)\n", componentsTotal, pct(componentsTotal, legacyTotal)) + + componentsBreakdown := []struct { + name string + nm *proto.NetworkMapComponentsFull + }{ + {"Peers", &proto.NetworkMapComponentsFull{Peers: full.Peers}}, + {"Policies", &proto.NetworkMapComponentsFull{Policies: full.Policies}}, + {"Groups", &proto.NetworkMapComponentsFull{Groups: full.Groups}}, + {"Routes (raw)", &proto.NetworkMapComponentsFull{Routes: full.Routes}}, + {"NameServerGroups", &proto.NetworkMapComponentsFull{NameserverGroups: full.NameserverGroups}}, + {"AllDNSRecords", &proto.NetworkMapComponentsFull{AllDnsRecords: full.AllDnsRecords}}, + {"AccountZones", &proto.NetworkMapComponentsFull{AccountZones: full.AccountZones}}, + {"NetworkResources", &proto.NetworkMapComponentsFull{NetworkResources: full.NetworkResources}}, + {"RoutersMap", &proto.NetworkMapComponentsFull{RoutersMap: full.RoutersMap}}, + {"ResourcePoliciesMap", &proto.NetworkMapComponentsFull{ResourcePoliciesMap: full.ResourcePoliciesMap}}, + {"GroupIDToUserIDs", &proto.NetworkMapComponentsFull{GroupIdToUserIds: full.GroupIdToUserIds}}, + {"AllowedUserIDs", &proto.NetworkMapComponentsFull{AllowedUserIds: full.AllowedUserIds}}, + {"PostureFailedPeers", &proto.NetworkMapComponentsFull{PostureFailedPeers: full.PostureFailedPeers}}, + {"DNSSettings", &proto.NetworkMapComponentsFull{DnsSettings: full.DnsSettings}}, + {"PeerConfig", &proto.NetworkMapComponentsFull{PeerConfig: full.PeerConfig}}, + {"AgentVersions", &proto.NetworkMapComponentsFull{AgentVersions: full.AgentVersions}}, + } + for _, e := range componentsBreakdown { + size := mustMarshalSize(t, e.nm) + t.Logf(" %-22s %8d bytes %5.1f%%", e.name, size, pct(size, componentsTotal)) + } + + t.Logf("\n=== Per-PeerCompact average ===") + if len(full.Peers) > 0 { + t.Logf(" PeerCompact avg: %d bytes/peer", mustMarshalSize(t, &proto.NetworkMapComponentsFull{Peers: full.Peers})/len(full.Peers)) + } + if len(legacyResp.NetworkMap.RemotePeers) > 0 { + t.Logf(" RemotePeer avg: %d bytes/peer", + mustMarshalSize(t, &proto.NetworkMap{RemotePeers: legacyResp.NetworkMap.RemotePeers})/len(legacyResp.NetworkMap.RemotePeers)) + } + + t.Logf("\n=== FirewallRule expansion footprint ===") + t.Logf(" legacy FirewallRules count: %d", len(legacyResp.NetworkMap.FirewallRules)) + t.Logf(" components Policies count: %d", len(full.Policies)) + t.Logf(" components Groups count: %d", len(full.Groups)) + + totalGroupPeerIdxs := 0 + for _, g := range full.Groups { + totalGroupPeerIdxs += len(g.PeerIndexes) + } + t.Logf(" components peer-index refs across all groups: %d", totalGroupPeerIdxs) +} + +func mustMarshalSize(t *testing.T, m goproto.Message) int { + b, err := goproto.Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return len(b) +} + +func pct(part, total int) float64 { + if total == 0 { + return 0 + } + return 100 * float64(part) / float64(total) +} + +// Stops fmt being unused if the breakdown loop above is later commented out. +var _ = fmt.Sprintf diff --git a/management/server/types/networkmapbuilder.go b/management/server/types/networkmapbuilder.go deleted file mode 100644 index 58f1bfa30..000000000 --- a/management/server/types/networkmapbuilder.go +++ /dev/null @@ -1,1932 +0,0 @@ -package types - -import ( - "context" - "fmt" - "slices" - "strconv" - "strings" - "sync" - "sync/atomic" - "time" - - log "github.com/sirupsen/logrus" - "golang.org/x/exp/maps" - - nbdns "github.com/netbirdio/netbird/dns" - resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" - routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" - nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/management/server/telemetry" - "github.com/netbirdio/netbird/route" -) - -const ( - allPeers = "0.0.0.0" - fw = "fw:" - rfw = "route-fw:" - nr = "network-resource-" -) - -type NetworkMapCache struct { - globalRoutes map[route.ID]*route.Route - globalRules map[string]*FirewallRule //ruleId - globalRouteRules map[string]*RouteFirewallRule //ruleId - globalPeers map[string]*nbpeer.Peer - - groupToPeers map[string][]string - peerToGroups map[string][]string - policyToRules map[string][]*PolicyRule //policyId - groupToPolicies map[string][]*Policy - groupToRoutes map[string][]*route.Route - peerToRoutes map[string][]*route.Route - - peerACLs map[string]*PeerACLView - peerRoutes map[string]*PeerRoutesView - peerDNS map[string]*nbdns.Config - - resourceRouters map[string]map[string]*routerTypes.NetworkRouter - resourcePolicies map[string][]*Policy - - globalResources map[string]*resourceTypes.NetworkResource // resourceId - - acgToRoutes map[string]map[route.ID]*RouteOwnerInfo // routeID -> owner info - noACGRoutes map[route.ID]*RouteOwnerInfo - - mu sync.RWMutex -} - -type RouteOwnerInfo struct { - PeerID string - RouteID route.ID -} - -type PeerACLView struct { - ConnectedPeerIDs []string - FirewallRuleIDs []string -} - -type PeerRoutesView struct { - OwnRouteIDs []route.ID - NetworkResourceIDs []route.ID - InheritedRouteIDs []route.ID - RouteFirewallRuleIDs []string -} - -type NetworkMapBuilder struct { - account atomic.Pointer[Account] - cache *NetworkMapCache - validatedPeers map[string]struct{} -} - -func NewNetworkMapBuilder(account *Account, validatedPeers map[string]struct{}) *NetworkMapBuilder { - builder := &NetworkMapBuilder{ - cache: &NetworkMapCache{ - globalRoutes: make(map[route.ID]*route.Route), - globalRules: make(map[string]*FirewallRule), - globalRouteRules: make(map[string]*RouteFirewallRule), - globalPeers: make(map[string]*nbpeer.Peer), - groupToPeers: make(map[string][]string), - peerToGroups: make(map[string][]string), - policyToRules: make(map[string][]*PolicyRule), - groupToPolicies: make(map[string][]*Policy), - groupToRoutes: make(map[string][]*route.Route), - peerToRoutes: make(map[string][]*route.Route), - peerACLs: make(map[string]*PeerACLView), - peerRoutes: make(map[string]*PeerRoutesView), - peerDNS: make(map[string]*nbdns.Config), - globalResources: make(map[string]*resourceTypes.NetworkResource), - acgToRoutes: make(map[string]map[route.ID]*RouteOwnerInfo), - noACGRoutes: make(map[route.ID]*RouteOwnerInfo), - }, - validatedPeers: make(map[string]struct{}), - } - builder.account.Store(account) - maps.Copy(builder.validatedPeers, validatedPeers) - - builder.initialBuild(account) - - return builder -} - -func (b *NetworkMapBuilder) initialBuild(account *Account) { - b.cache.mu.Lock() - defer b.cache.mu.Unlock() - - start := time.Now() - - b.buildGlobalIndexes(account) - - resourceRouters := account.GetResourceRoutersMap() - resourcePolicies := account.GetResourcePoliciesMap() - b.cache.resourceRouters = resourceRouters - b.cache.resourcePolicies = resourcePolicies - - for peerID := range account.Peers { - b.buildPeerACLView(account, peerID) - b.buildPeerRoutesView(account, peerID) - b.buildPeerDNSView(account, peerID) - } - - log.Debugf("NetworkMapBuilder: Initial build completed in %v for account %s", time.Since(start), account.Id) -} - -func (b *NetworkMapBuilder) buildGlobalIndexes(account *Account) { - clear(b.cache.globalPeers) - clear(b.cache.groupToPeers) - clear(b.cache.peerToGroups) - clear(b.cache.policyToRules) - clear(b.cache.groupToPolicies) - clear(b.cache.globalRoutes) - clear(b.cache.globalRules) - clear(b.cache.globalRouteRules) - clear(b.cache.globalResources) - clear(b.cache.groupToRoutes) - clear(b.cache.peerToRoutes) - clear(b.cache.acgToRoutes) - clear(b.cache.noACGRoutes) - - maps.Copy(b.cache.globalPeers, account.Peers) - - for groupID, group := range account.Groups { - peersCopy := make([]string, len(group.Peers)) - copy(peersCopy, group.Peers) - b.cache.groupToPeers[groupID] = peersCopy - - for _, peerID := range group.Peers { - b.cache.peerToGroups[peerID] = append(b.cache.peerToGroups[peerID], groupID) - } - } - - for _, policy := range account.Policies { - if !policy.Enabled { - continue - } - - b.cache.policyToRules[policy.ID] = policy.Rules - - affectedGroups := make(map[string]struct{}) - for _, rule := range policy.Rules { - if !rule.Enabled { - continue - } - - for _, groupID := range rule.Sources { - affectedGroups[groupID] = struct{}{} - } - for _, groupID := range rule.Destinations { - affectedGroups[groupID] = struct{}{} - } - if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { - groupId := rule.SourceResource.ID - affectedGroups[groupId] = struct{}{} - b.cache.peerToGroups[rule.SourceResource.ID] = append(b.cache.peerToGroups[rule.SourceResource.ID], groupId) - } - if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" { - groupId := rule.DestinationResource.ID - affectedGroups[groupId] = struct{}{} - b.cache.peerToGroups[rule.DestinationResource.ID] = append(b.cache.peerToGroups[rule.DestinationResource.ID], groupId) - } - } - - for groupID := range affectedGroups { - b.cache.groupToPolicies[groupID] = append(b.cache.groupToPolicies[groupID], policy) - } - } - - for _, resource := range account.NetworkResources { - if !resource.Enabled { - continue - } - b.cache.globalResources[resource.ID] = resource - } - - for _, r := range account.Routes { - if !r.Enabled { - continue - } - for _, groupID := range r.PeerGroups { - b.cache.groupToRoutes[groupID] = append(b.cache.groupToRoutes[groupID], r) - } - if r.Peer != "" { - if peer, ok := b.cache.globalPeers[r.Peer]; ok { - b.cache.peerToRoutes[peer.ID] = append(b.cache.peerToRoutes[peer.ID], r) - } - } - } -} - -func (b *NetworkMapBuilder) buildPeerACLView(account *Account, peerID string) { - peer := account.GetPeer(peerID) - if peer == nil { - return - } - - allPotentialPeers, firewallRules := b.getPeerConnectionResources(account, peer, b.validatedPeers) - - isRouter, networkResourcesRoutes, sourcePeers := b.getNetworkResourcesForPeer(account, peer) - - var emptyExpiredPeers []*nbpeer.Peer - finalAllPeers := b.addNetworksRoutingPeers( - networkResourcesRoutes, - peer, - allPotentialPeers, - emptyExpiredPeers, - isRouter, - sourcePeers, - ) - - view := &PeerACLView{ - ConnectedPeerIDs: make([]string, 0, len(finalAllPeers)), - FirewallRuleIDs: make([]string, 0, len(firewallRules)), - } - - for _, p := range finalAllPeers { - view.ConnectedPeerIDs = append(view.ConnectedPeerIDs, p.ID) - } - - for _, rule := range firewallRules { - ruleID := b.generateFirewallRuleID(rule) - view.FirewallRuleIDs = append(view.FirewallRuleIDs, ruleID) - b.cache.globalRules[ruleID] = rule - } - - b.cache.peerACLs[peerID] = view -} - -func (b *NetworkMapBuilder) getPeerConnectionResources(account *Account, peer *nbpeer.Peer, - validatedPeersMap map[string]struct{}, -) ([]*nbpeer.Peer, []*FirewallRule) { - ctx := context.Background() - - peerID := peer.ID - - peerGroups := b.cache.peerToGroups[peerID] - peerGroupsMap := make(map[string]struct{}, len(peerGroups)) - for _, groupID := range peerGroups { - peerGroupsMap[groupID] = struct{}{} - } - - rulesExists := make(map[string]struct{}) - peersExists := make(map[string]struct{}) - fwRules := make([]*FirewallRule, 0) - peers := make([]*nbpeer.Peer, 0) - - for _, group := range peerGroups { - policies := b.cache.groupToPolicies[group] - for _, policy := range policies { - if isValid := account.validatePostureChecksOnPeer(ctx, policy.SourcePostureChecks, peerID); !isValid { - continue - } - rules := b.cache.policyToRules[policy.ID] - for _, rule := range rules { - var sourcePeers, destinationPeers []*nbpeer.Peer - var peerInSources, peerInDestinations bool - - if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { - peerInSources = rule.SourceResource.ID == peerID - } else { - peerInSources = b.isPeerInGroupscached(rule.Sources, peerGroupsMap) - } - - if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" { - peerInDestinations = rule.DestinationResource.ID == peerID - } else { - peerInDestinations = b.isPeerInGroupscached(rule.Destinations, peerGroupsMap) - } - - if !peerInSources && !peerInDestinations { - continue - } - - if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" { - peer := account.GetPeer(rule.SourceResource.ID) - if peer != nil { - sourcePeers = []*nbpeer.Peer{peer} - } - } else { - sourcePeers = b.getPeersFromGroupscached(account, rule.Sources, peerID, policy.SourcePostureChecks, validatedPeersMap) - } - - if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" { - peer := account.GetPeer(rule.DestinationResource.ID) - if peer != nil { - destinationPeers = []*nbpeer.Peer{peer} - } - } else { - destinationPeers = b.getPeersFromGroupscached(account, rule.Destinations, peerID, nil, validatedPeersMap) - } - - if rule.Bidirectional { - if peerInSources { - b.generateResourcescached( - account, rule, destinationPeers, FirewallRuleDirectionIN, - peer, &peers, &fwRules, peersExists, rulesExists, - ) - } - if peerInDestinations { - b.generateResourcescached( - account, rule, sourcePeers, FirewallRuleDirectionOUT, - peer, &peers, &fwRules, peersExists, rulesExists, - ) - } - } - - if peerInSources { - b.generateResourcescached( - account, rule, destinationPeers, FirewallRuleDirectionOUT, - peer, &peers, &fwRules, peersExists, rulesExists, - ) - } - - if peerInDestinations { - b.generateResourcescached( - account, rule, sourcePeers, FirewallRuleDirectionIN, - peer, &peers, &fwRules, peersExists, rulesExists, - ) - } - } - } - } - - return peers, fwRules -} - -func (b *NetworkMapBuilder) isPeerInGroupscached(groupIDs []string, peerGroupsMap map[string]struct{}) bool { - for _, groupID := range groupIDs { - if _, exists := peerGroupsMap[groupID]; exists { - return true - } - } - return false -} - -func (b *NetworkMapBuilder) getPeersFromGroupscached(account *Account, groupIDs []string, - excludePeerID string, postureChecksIDs []string, validatedPeersMap map[string]struct{}, -) []*nbpeer.Peer { - ctx := context.Background() - uniquePeers := make(map[string]*nbpeer.Peer) - - for _, groupID := range groupIDs { - peerIDs := b.cache.groupToPeers[groupID] - for _, peerID := range peerIDs { - if peerID == excludePeerID { - continue - } - - if _, ok := validatedPeersMap[peerID]; !ok { - continue - } - - peer := b.cache.globalPeers[peerID] - if peer == nil { - continue - } - - if len(postureChecksIDs) > 0 { - if !account.validatePostureChecksOnPeer(ctx, postureChecksIDs, peerID) { - continue - } - } - - uniquePeers[peerID] = peer - } - } - - result := make([]*nbpeer.Peer, 0, len(uniquePeers)) - for _, peer := range uniquePeers { - result = append(result, peer) - } - - return result -} - -func (b *NetworkMapBuilder) generateResourcescached( - account *Account, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int, targetPeer *nbpeer.Peer, - peers *[]*nbpeer.Peer, rules *[]*FirewallRule, peersExists map[string]struct{}, rulesExists map[string]struct{}, -) { - isAll := false - if allGroup, err := account.GetGroupAll(); err == nil { - isAll = (len(allGroup.Peers) - 1) == len(groupPeers) - } - - for _, peer := range groupPeers { - if peer == nil { - continue - } - if _, ok := peersExists[peer.ID]; !ok { - *peers = append(*peers, peer) - peersExists[peer.ID] = struct{}{} - } - - fr := FirewallRule{ - PolicyID: rule.ID, - PeerIP: peer.IP.String(), - Direction: direction, - Action: string(rule.Action), - Protocol: string(rule.Protocol), - } - - if isAll { - fr.PeerIP = allPeers - } - - var s strings.Builder - s.WriteString(rule.ID) - s.WriteString(fr.PeerIP) - s.WriteString(strconv.Itoa(direction)) - s.WriteString(fr.Protocol) - s.WriteString(fr.Action) - s.WriteString(strings.Join(rule.Ports, ",")) - - ruleID := s.String() - - if _, ok := rulesExists[ruleID]; ok { - continue - } - rulesExists[ruleID] = struct{}{} - - if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 { - *rules = append(*rules, &fr) - continue - } - - *rules = append(*rules, expandPortsAndRanges(fr, rule, targetPeer)...) - } -} - -func (b *NetworkMapBuilder) getNetworkResourcesForPeer(account *Account, peer *nbpeer.Peer) (bool, []*route.Route, map[string]struct{}) { - ctx := context.Background() - peerID := peer.ID - - var isRoutingPeer bool - var routes []*route.Route - allSourcePeers := make(map[string]struct{}) - - peerGroups := b.cache.peerToGroups[peerID] - peerGroupsMap := make(map[string]struct{}, len(peerGroups)) - for _, groupID := range peerGroups { - peerGroupsMap[groupID] = struct{}{} - } - - for _, resource := range b.cache.globalResources { - - networkRoutingPeers := b.cache.resourceRouters[resource.NetworkID] - resourcePolicies := b.cache.resourcePolicies[resource.ID] - if len(resourcePolicies) == 0 { - continue - } - - isRouterForThisResource := false - - if networkRoutingPeers != nil { - if router, ok := networkRoutingPeers[peerID]; ok && router.Enabled { - isRoutingPeer = true - isRouterForThisResource = true - if rt := b.createNetworkResourceRoutes(resource, peerID, router, resourcePolicies); rt != nil { - routes = append(routes, rt) - } - } - } - - hasAccessAsClient := false - if !isRouterForThisResource { - for _, policy := range resourcePolicies { - if b.isPeerInGroupscached(policy.SourceGroups(), peerGroupsMap) { - if account.validatePostureChecksOnPeer(ctx, policy.SourcePostureChecks, peerID) { - hasAccessAsClient = true - break - } - } - } - } - - if hasAccessAsClient && networkRoutingPeers != nil { - for routerPeerID, router := range networkRoutingPeers { - if router.Enabled { - if rt := b.createNetworkResourceRoutes(resource, routerPeerID, router, resourcePolicies); rt != nil { - routes = append(routes, rt) - } - } - } - } - - if isRouterForThisResource { - for _, policy := range resourcePolicies { - var peersWithAccess []*nbpeer.Peer - if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" { - peersWithAccess = []*nbpeer.Peer{peer} - } else { - peersWithAccess = b.getPeersFromGroupscached(account, policy.SourceGroups(), "", policy.SourcePostureChecks, b.validatedPeers) - } - for _, p := range peersWithAccess { - allSourcePeers[p.ID] = struct{}{} - } - } - } - } - - return isRoutingPeer, routes, allSourcePeers -} - -func (b *NetworkMapBuilder) createNetworkResourceRoutes( - resource *resourceTypes.NetworkResource, routerPeerID string, - router *routerTypes.NetworkRouter, resourcePolicies []*Policy, -) *route.Route { - if len(resourcePolicies) > 0 { - peer := b.cache.globalPeers[routerPeerID] - if peer != nil { - return resource.ToRoute(peer, router) - } - } - return nil -} - -func (b *NetworkMapBuilder) addNetworksRoutingPeers( - networkResourcesRoutes []*route.Route, peer *nbpeer.Peer, peersToConnect []*nbpeer.Peer, - expiredPeers []*nbpeer.Peer, isRouter bool, sourcePeers map[string]struct{}, -) []*nbpeer.Peer { - - networkRoutesPeers := make(map[string]struct{}, len(networkResourcesRoutes)) - for _, r := range networkResourcesRoutes { - networkRoutesPeers[r.PeerID] = struct{}{} - } - - delete(sourcePeers, peer.ID) - delete(networkRoutesPeers, peer.ID) - - for _, existingPeer := range peersToConnect { - delete(sourcePeers, existingPeer.ID) - delete(networkRoutesPeers, existingPeer.ID) - } - for _, expPeer := range expiredPeers { - delete(sourcePeers, expPeer.ID) - delete(networkRoutesPeers, expPeer.ID) - } - - missingPeers := make(map[string]struct{}, len(sourcePeers)+len(networkRoutesPeers)) - if isRouter { - for p := range sourcePeers { - missingPeers[p] = struct{}{} - } - } - for p := range networkRoutesPeers { - missingPeers[p] = struct{}{} - } - - for p := range missingPeers { - if missingPeer := b.cache.globalPeers[p]; missingPeer != nil { - peersToConnect = append(peersToConnect, missingPeer) - } - } - - return peersToConnect -} - -func (b *NetworkMapBuilder) buildPeerRoutesView(account *Account, peerID string) { - ctx := context.Background() - peer := account.GetPeer(peerID) - if peer == nil { - return - } - resourcePolicies := b.cache.resourcePolicies - - view := &PeerRoutesView{ - OwnRouteIDs: make([]route.ID, 0), - NetworkResourceIDs: make([]route.ID, 0), - RouteFirewallRuleIDs: make([]string, 0), - } - - enabledRoutes, disabledRoutes := b.getRoutingPeerRoutes(peerID) - for _, rt := range enabledRoutes { - if rt.PeerID != "" && rt.PeerID != peerID { - if b.cache.globalPeers[rt.PeerID] == nil { - continue - } - } - - view.OwnRouteIDs = append(view.OwnRouteIDs, rt.ID) - b.cache.globalRoutes[rt.ID] = rt - } - - aclView := b.cache.peerACLs[peerID] - if aclView != nil { - peerRoutesMembership := make(LookupMap) - for _, r := range append(enabledRoutes, disabledRoutes...) { - peerRoutesMembership[string(r.GetHAUniqueID())] = struct{}{} - } - - peerGroups := b.cache.peerToGroups[peerID] - peerGroupsMap := make(LookupMap) - for _, groupID := range peerGroups { - peerGroupsMap[groupID] = struct{}{} - } - - for _, aclPeerID := range aclView.ConnectedPeerIDs { - if aclPeerID == peerID { - continue - } - activeRoutes, _ := b.getRoutingPeerRoutes(aclPeerID) - groupFilteredRoutes := account.filterRoutesByGroups(activeRoutes, peerGroupsMap) - haFilteredRoutes := account.filterRoutesFromPeersOfSameHAGroup(groupFilteredRoutes, peerRoutesMembership) - - for _, inheritedRoute := range haFilteredRoutes { - view.InheritedRouteIDs = append(view.InheritedRouteIDs, inheritedRoute.ID) - b.cache.globalRoutes[inheritedRoute.ID] = inheritedRoute - } - } - } - - _, networkResourcesRoutes, _ := b.getNetworkResourcesForPeer(account, peer) - - for _, rt := range networkResourcesRoutes { - view.NetworkResourceIDs = append(view.NetworkResourceIDs, rt.ID) - b.cache.globalRoutes[rt.ID] = rt - } - - allRoutes := slices.Concat(enabledRoutes, networkResourcesRoutes) - b.updateACGIndexForPeer(peerID, allRoutes) - - routeFirewallRules := b.getPeerRoutesFirewallRules(account, peerID, b.validatedPeers) - for _, rule := range routeFirewallRules { - ruleID := b.generateRouteFirewallRuleID(rule) - view.RouteFirewallRuleIDs = append(view.RouteFirewallRuleIDs, ruleID) - b.cache.globalRouteRules[ruleID] = rule - } - - if len(networkResourcesRoutes) > 0 { - networkResourceFirewallRules := account.GetPeerNetworkResourceFirewallRules(ctx, peer, b.validatedPeers, networkResourcesRoutes, resourcePolicies) - for _, rule := range networkResourceFirewallRules { - ruleID := b.generateRouteFirewallRuleID(rule) - view.RouteFirewallRuleIDs = append(view.RouteFirewallRuleIDs, ruleID) - b.cache.globalRouteRules[ruleID] = rule - } - } - - b.cache.peerRoutes[peerID] = view -} - -func (b *NetworkMapBuilder) updateACGIndexForPeer(peerID string, routes []*route.Route) { - for acg, routeMap := range b.cache.acgToRoutes { - for routeID, info := range routeMap { - if info.PeerID == peerID { - delete(routeMap, routeID) - } - } - if len(routeMap) == 0 { - delete(b.cache.acgToRoutes, acg) - } - } - - for routeID, info := range b.cache.noACGRoutes { - if info.PeerID == peerID { - delete(b.cache.noACGRoutes, routeID) - } - } - - for _, rt := range routes { - if !rt.Enabled { - continue - } - - if len(rt.AccessControlGroups) == 0 { - b.cache.noACGRoutes[rt.ID] = &RouteOwnerInfo{ - PeerID: peerID, - RouteID: rt.ID, - } - } else { - for _, acg := range rt.AccessControlGroups { - if b.cache.acgToRoutes[acg] == nil { - b.cache.acgToRoutes[acg] = make(map[route.ID]*RouteOwnerInfo) - } - - b.cache.acgToRoutes[acg][rt.ID] = &RouteOwnerInfo{ - PeerID: peerID, - RouteID: rt.ID, - } - } - } - } -} - -func (b *NetworkMapBuilder) getRoutingPeerRoutes(peerID string) (enabledRoutes []*route.Route, disabledRoutes []*route.Route) { - peer := b.cache.globalPeers[peerID] - if peer == nil { - return enabledRoutes, disabledRoutes - } - - seenRoute := make(map[route.ID]struct{}) - - takeRoute := func(r *route.Route, id string) { - if _, ok := seenRoute[r.ID]; ok { - return - } - seenRoute[r.ID] = struct{}{} - - if r.Enabled { - // maybe here is some mess - here we store peer key (see comment below) - r.Peer = peer.Key - enabledRoutes = append(enabledRoutes, r) - return - } - disabledRoutes = append(disabledRoutes, r) - } - - peerGroups := b.cache.peerToGroups[peerID] - for _, groupID := range peerGroups { - groupRoutes := b.cache.groupToRoutes[groupID] - for _, r := range groupRoutes { - newPeerRoute := r.Copy() - // and here we store peer ID - this logic is taken from original account.getRoutingPeerRoutes - newPeerRoute.Peer = peerID - newPeerRoute.PeerGroups = nil - newPeerRoute.ID = route.ID(string(r.ID) + ":" + peerID) - takeRoute(newPeerRoute, peerID) - } - } - for _, r := range b.cache.peerToRoutes[peerID] { - takeRoute(r.Copy(), peerID) - } - return enabledRoutes, disabledRoutes -} - -func (b *NetworkMapBuilder) getPeerRoutesFirewallRules(account *Account, peerID string, validatedPeersMap map[string]struct{}) []*RouteFirewallRule { - routesFirewallRules := make([]*RouteFirewallRule, 0) - - enabledRoutes, _ := b.getRoutingPeerRoutes(peerID) - for _, route := range enabledRoutes { - if len(route.AccessControlGroups) == 0 { - defaultPermit := getDefaultPermit(route) - routesFirewallRules = append(routesFirewallRules, defaultPermit...) - continue - } - - distributionPeers := b.getDistributionGroupsPeers(route) - - for _, accessGroup := range route.AccessControlGroups { - policies := b.getAllRoutePoliciesFromGroups([]string{accessGroup}) - - rules := b.getRouteFirewallRules(peerID, policies, route, validatedPeersMap, distributionPeers, account) - routesFirewallRules = append(routesFirewallRules, rules...) - } - } - - return routesFirewallRules -} - -func (b *NetworkMapBuilder) getDistributionGroupsPeers(route *route.Route) map[string]struct{} { - distPeers := make(map[string]struct{}) - for _, id := range route.Groups { - groupPeers := b.cache.groupToPeers[id] - if groupPeers == nil { - continue - } - - for _, pID := range groupPeers { - distPeers[pID] = struct{}{} - } - } - return distPeers -} - -func (b *NetworkMapBuilder) getAllRoutePoliciesFromGroups(accessControlGroups []string) []*Policy { - routePolicies := make(map[string]*Policy) - - for _, groupID := range accessControlGroups { - candidatePolicies := b.cache.groupToPolicies[groupID] - - for _, policy := range candidatePolicies { - if _, found := routePolicies[policy.ID]; found { - continue - } - policyRules := b.cache.policyToRules[policy.ID] - for _, rule := range policyRules { - if slices.Contains(rule.Destinations, groupID) { - routePolicies[policy.ID] = policy - break - } - } - } - } - - return maps.Values(routePolicies) -} - -func (b *NetworkMapBuilder) getRouteFirewallRules( - peerID string, policies []*Policy, route *route.Route, validatedPeersMap map[string]struct{}, - distributionPeers map[string]struct{}, account *Account, -) []*RouteFirewallRule { - ctx := context.Background() - var fwRules []*RouteFirewallRule - for _, policy := range policies { - if !policy.Enabled { - continue - } - - for _, rule := range policy.Rules { - if !rule.Enabled { - continue - } - - rulePeers := b.getRulePeers(rule, policy.SourcePostureChecks, peerID, distributionPeers, validatedPeersMap, account) - - rules := generateRouteFirewallRules(ctx, route, rule, rulePeers, FirewallRuleDirectionIN) - fwRules = append(fwRules, rules...) - } - } - return fwRules -} - -func (b *NetworkMapBuilder) getRulePeers( - rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}, - validatedPeersMap map[string]struct{}, account *Account, -) []*nbpeer.Peer { - distPeersWithPolicy := make(map[string]struct{}) - - for _, id := range rule.Sources { - groupPeers := b.cache.groupToPeers[id] - if groupPeers == nil { - continue - } - - for _, pID := range groupPeers { - if pID == peerID { - continue - } - _, distPeer := distributionPeers[pID] - _, valid := validatedPeersMap[pID] - - if distPeer && valid && account.validatePostureChecksOnPeer(context.Background(), postureChecks, pID) { - distPeersWithPolicy[pID] = struct{}{} - } - } - } - - distributionGroupPeers := make([]*nbpeer.Peer, 0, len(distPeersWithPolicy)) - for pID := range distPeersWithPolicy { - peer := b.cache.globalPeers[pID] - if peer == nil { - continue - } - distributionGroupPeers = append(distributionGroupPeers, peer) - } - return distributionGroupPeers -} - -func (b *NetworkMapBuilder) buildPeerDNSView(account *Account, peerID string) { - peerGroups := b.cache.peerToGroups[peerID] - checkGroups := make(map[string]struct{}, len(peerGroups)) - for _, groupID := range peerGroups { - checkGroups[groupID] = struct{}{} - } - - dnsManagementStatus := b.getPeerDNSManagementStatus(account, checkGroups) - dnsConfig := &nbdns.Config{ - ServiceEnable: dnsManagementStatus, - } - - if dnsManagementStatus { - dnsConfig.NameServerGroups = b.getPeerNSGroups(account, peerID, checkGroups) - } - - b.cache.peerDNS[peerID] = dnsConfig -} - -func (b *NetworkMapBuilder) getPeerDNSManagementStatus(account *Account, checkGroups map[string]struct{}) bool { - - enabled := true - for _, groupID := range account.DNSSettings.DisabledManagementGroups { - _, found := checkGroups[groupID] - if found { - enabled = false - break - } - } - return enabled -} - -func (b *NetworkMapBuilder) getPeerNSGroups(account *Account, peerID string, checkGroups map[string]struct{}) []*nbdns.NameServerGroup { - var peerNSGroups []*nbdns.NameServerGroup - - for _, nsGroup := range account.NameServerGroups { - if !nsGroup.Enabled { - continue - } - for _, gID := range nsGroup.Groups { - _, found := checkGroups[gID] - if found { - peer := b.cache.globalPeers[peerID] - if !peerIsNameserver(peer, nsGroup) { - peerNSGroups = append(peerNSGroups, nsGroup.Copy()) - break - } - } - } - } - - return peerNSGroups -} - -func (b *NetworkMapBuilder) UpdateAccountPointer(account *Account) { - b.account.Store(account) -} - -func (b *NetworkMapBuilder) GetPeerNetworkMap( - ctx context.Context, peerID string, peersCustomZone nbdns.CustomZone, - validatedPeers map[string]struct{}, metrics *telemetry.AccountManagerMetrics, -) *NetworkMap { - start := time.Now() - account := b.account.Load() - - peer := account.GetPeer(peerID) - if peer == nil { - return &NetworkMap{Network: account.Network.Copy()} - } - - b.cache.mu.RLock() - defer b.cache.mu.RUnlock() - - aclView := b.cache.peerACLs[peerID] - routesView := b.cache.peerRoutes[peerID] - dnsConfig := b.cache.peerDNS[peerID] - - if aclView == nil || routesView == nil || dnsConfig == nil { - return &NetworkMap{Network: account.Network.Copy()} - } - - nm := b.assembleNetworkMap(account, peer, aclView, routesView, dnsConfig, peersCustomZone, validatedPeers) - - if metrics != nil { - objectCount := int64(len(nm.Peers) + len(nm.OfflinePeers) + len(nm.Routes) + len(nm.FirewallRules) + len(nm.RoutesFirewallRules)) - metrics.CountNetworkMapObjects(objectCount) - metrics.CountGetPeerNetworkMapDuration(time.Since(start)) - - if objectCount > 5000 { - log.WithContext(ctx).Tracef("account: %s has a total resource count of %d objects from cache", - account.Id, objectCount) - } - } - - return nm -} - -func (b *NetworkMapBuilder) assembleNetworkMap( - account *Account, peer *nbpeer.Peer, aclView *PeerACLView, routesView *PeerRoutesView, - dnsConfig *nbdns.Config, customZone nbdns.CustomZone, validatedPeers map[string]struct{}, -) *NetworkMap { - - var peersToConnect []*nbpeer.Peer - var expiredPeers []*nbpeer.Peer - - for _, peerID := range aclView.ConnectedPeerIDs { - if _, ok := validatedPeers[peerID]; !ok { - continue - } - - peer := b.cache.globalPeers[peerID] - if peer == nil { - continue - } - - expired, _ := peer.LoginExpired(account.Settings.PeerLoginExpiration) - if account.Settings.PeerLoginExpirationEnabled && expired { - expiredPeers = append(expiredPeers, peer) - } else { - peersToConnect = append(peersToConnect, peer) - } - } - - var routes []*route.Route - allRouteIDs := slices.Concat(routesView.OwnRouteIDs, routesView.NetworkResourceIDs, routesView.InheritedRouteIDs) - - for _, routeID := range allRouteIDs { - if route := b.cache.globalRoutes[routeID]; route != nil { - routes = append(routes, route) - } - } - - var firewallRules []*FirewallRule - for _, ruleID := range aclView.FirewallRuleIDs { - if rule := b.cache.globalRules[ruleID]; rule != nil { - firewallRules = append(firewallRules, rule) - } - } - - var routesFirewallRules []*RouteFirewallRule - for _, ruleID := range routesView.RouteFirewallRuleIDs { - if rule := b.cache.globalRouteRules[ruleID]; rule != nil { - routesFirewallRules = append(routesFirewallRules, rule) - } - } - - finalDNSConfig := *dnsConfig - if finalDNSConfig.ServiceEnable && customZone.Domain != "" { - var zones []nbdns.CustomZone - records := filterZoneRecordsForPeers(peer, customZone, peersToConnect, expiredPeers) - zones = append(zones, nbdns.CustomZone{ - Domain: customZone.Domain, - Records: records, - }) - finalDNSConfig.CustomZones = zones - } - - return &NetworkMap{ - Peers: peersToConnect, - Network: account.Network.Copy(), - Routes: routes, - DNSConfig: finalDNSConfig, - OfflinePeers: expiredPeers, - FirewallRules: firewallRules, - RoutesFirewallRules: routesFirewallRules, - } -} - -func (b *NetworkMapBuilder) generateFirewallRuleID(rule *FirewallRule) string { - var s strings.Builder - s.WriteString(fw) - s.WriteString(rule.PolicyID) - s.WriteRune(':') - s.WriteString(rule.PeerIP) - s.WriteRune(':') - s.WriteString(strconv.Itoa(rule.Direction)) - s.WriteRune(':') - s.WriteString(rule.Protocol) - s.WriteRune(':') - s.WriteString(rule.Action) - s.WriteRune(':') - s.WriteString(rule.Port) - s.WriteRune(':') - s.WriteString(strconv.Itoa(int(rule.PortRange.Start))) - s.WriteRune('-') - s.WriteString(strconv.Itoa(int(rule.PortRange.End))) - return s.String() -} - -func (b *NetworkMapBuilder) generateRouteFirewallRuleID(rule *RouteFirewallRule) string { - var s strings.Builder - s.WriteString(rfw) - s.WriteString(string(rule.RouteID)) - s.WriteRune(':') - s.WriteString(rule.Destination) - s.WriteRune(':') - s.WriteString(rule.Action) - s.WriteRune(':') - s.WriteString(strings.Join(rule.SourceRanges, ",")) - s.WriteRune(':') - s.WriteString(rule.Protocol) - s.WriteRune(':') - s.WriteString(strconv.Itoa(int(rule.Port))) - return s.String() -} - -func (b *NetworkMapBuilder) isPeerInGroups(groupIDs []string, peerGroups []string) bool { - for _, groupID := range groupIDs { - if slices.Contains(peerGroups, groupID) { - return true - } - } - return false -} - -func (b *NetworkMapBuilder) isPeerRouter(account *Account, peerID string) bool { - for _, r := range account.Routes { - if !r.Enabled { - continue - } - - if r.PeerID == peerID { - return true - } - - if peer := b.cache.globalPeers[peerID]; peer != nil { - if r.Peer == peer.Key && r.PeerID == "" { - return true - } - } - } - - routers := account.GetResourceRoutersMap() - for _, networkRouters := range routers { - if router, exists := networkRouters[peerID]; exists && router.Enabled { - return true - } - } - - return false -} - -type ViewDelta struct { - AddedPeerIDs []string - RemovedPeerIDs []string - AddedRuleIDs []string - RemovedRuleIDs []string -} - -func (b *NetworkMapBuilder) OnPeerAddedIncremental(peerID string) error { - tt := time.Now() - account := b.account.Load() - peer := account.GetPeer(peerID) - if peer == nil { - return fmt.Errorf("peer %s not found in account", peerID) - } - - b.cache.mu.Lock() - defer b.cache.mu.Unlock() - - log.Debugf("NetworkMapBuilder: Adding peer %s (IP: %s) to cache", peerID, peer.IP.String()) - - b.validatedPeers[peerID] = struct{}{} - - b.cache.globalPeers[peerID] = peer - - peerGroups := b.updateIndexesForNewPeer(account, peerID) - - b.buildPeerACLView(account, peerID) - b.buildPeerRoutesView(account, peerID) - b.buildPeerDNSView(account, peerID) - - log.Debugf("NetworkMapBuilder: Adding peer %s to cache, views took %s", peerID, time.Since(tt)) - - b.incrementalUpdateAffectedPeers(account, peerID, peerGroups) - - log.Debugf("NetworkMapBuilder: Added peer %s to cache, took %s", peerID, time.Since(tt)) - - return nil -} - -func (b *NetworkMapBuilder) updateIndexesForNewPeer(account *Account, peerID string) []string { - peerGroups := make([]string, 0) - - for groupID, group := range account.Groups { - if slices.Contains(group.Peers, peerID) { - if !slices.Contains(b.cache.groupToPeers[groupID], peerID) { - b.cache.groupToPeers[groupID] = append(b.cache.groupToPeers[groupID], peerID) - } - peerGroups = append(peerGroups, groupID) - } - } - - b.cache.peerToGroups[peerID] = peerGroups - - for _, r := range account.Routes { - if !r.Enabled || b.cache.globalRoutes[r.ID] != nil { - continue - } - for _, groupID := range r.PeerGroups { - if !slices.Contains(b.cache.groupToRoutes[groupID], r) { - b.cache.groupToRoutes[groupID] = append(b.cache.groupToRoutes[groupID], r) - } - } - if r.Peer != "" { - if peer, ok := b.cache.globalPeers[r.Peer]; ok { - if !slices.Contains(b.cache.peerToRoutes[peer.ID], r) { - b.cache.peerToRoutes[peer.ID] = append(b.cache.peerToRoutes[peer.ID], r) - } - } - } - b.cache.globalRoutes[r.ID] = r - } - - return peerGroups -} - -func (b *NetworkMapBuilder) incrementalUpdateAffectedPeers(account *Account, newPeerID string, peerGroups []string) { - updates := b.calculateIncrementalUpdates(account, newPeerID, peerGroups) - - if b.isPeerRouter(account, newPeerID) { - affectedByRoutes := b.findPeersAffectedByNewRouter(account, newPeerID, peerGroups) - for affectedPeerID := range affectedByRoutes { - if affectedPeerID == newPeerID { - continue - } - if _, exists := updates[affectedPeerID]; !exists { - updates[affectedPeerID] = &PeerUpdateDelta{ - PeerID: affectedPeerID, - RebuildRoutesView: true, - } - } else { - updates[affectedPeerID].RebuildRoutesView = true - } - } - } - - for affectedPeerID, delta := range updates { - b.applyDeltaToPeer(account, affectedPeerID, delta) - } -} - -func (b *NetworkMapBuilder) findPeersAffectedByNewRouter(account *Account, newRouterID string, routerGroups []string) map[string]struct{} { - affected := make(map[string]struct{}) - enabledRoutes, _ := b.getRoutingPeerRoutes(newRouterID) - - for _, route := range enabledRoutes { - for _, distGroupID := range route.Groups { - if peers := b.cache.groupToPeers[distGroupID]; peers != nil { - for _, peerID := range peers { - if peerID != newRouterID { - affected[peerID] = struct{}{} - } - } - } - } - - for _, peerGroupID := range route.PeerGroups { - if peers := b.cache.groupToPeers[peerGroupID]; peers != nil { - for _, peerID := range peers { - if peerID != newRouterID { - affected[peerID] = struct{}{} - } - } - } - } - } - - for _, route := range account.Routes { - if !route.Enabled { - continue - } - - routerInPeerGroups := false - for _, peerGroupID := range route.PeerGroups { - if slices.Contains(routerGroups, peerGroupID) { - routerInPeerGroups = true - break - } - } - - if routerInPeerGroups { - for _, distGroupID := range route.Groups { - if peers := b.cache.groupToPeers[distGroupID]; peers != nil { - for _, peerID := range peers { - affected[peerID] = struct{}{} - } - } - } - } - } - - return affected -} - -func (b *NetworkMapBuilder) calculateIncrementalUpdates(account *Account, newPeerID string, peerGroups []string) map[string]*PeerUpdateDelta { - updates := make(map[string]*PeerUpdateDelta) - ctx := context.Background() - - groupAllLn := 0 - if allGroup, err := account.GetGroupAll(); err == nil { - groupAllLn = len(allGroup.Peers) - 1 - } - - newPeer := b.cache.globalPeers[newPeerID] - if newPeer == nil { - return updates - } - - for _, policy := range account.Policies { - if !policy.Enabled { - continue - } - - for _, rule := range policy.Rules { - if !rule.Enabled { - continue - } - - peerInSources := b.isPeerInGroups(rule.Sources, peerGroups) - peerInDestinations := b.isPeerInGroups(rule.Destinations, peerGroups) - - if peerInSources { - b.addUpdateForPeersInGroups(updates, rule.Destinations, newPeerID, rule, FirewallRuleDirectionIN, groupAllLn) - } - - if peerInDestinations { - b.addUpdateForPeersInGroups(updates, rule.Sources, newPeerID, rule, FirewallRuleDirectionOUT, groupAllLn) - } - - if rule.Bidirectional { - if peerInSources { - b.addUpdateForPeersInGroups(updates, rule.Destinations, newPeerID, rule, FirewallRuleDirectionOUT, groupAllLn) - } - if peerInDestinations { - b.addUpdateForPeersInGroups(updates, rule.Sources, newPeerID, rule, FirewallRuleDirectionIN, groupAllLn) - } - } - } - } - - b.calculateRouteFirewallUpdates(newPeerID, newPeer, peerGroups, updates) - - b.calculateNetworkResourceFirewallUpdates(ctx, account, newPeerID, newPeer, peerGroups, updates) - - b.calculateNewRouterNetworkResourceUpdates(ctx, account, newPeerID, updates) - - return updates -} - -func (b *NetworkMapBuilder) calculateNewRouterNetworkResourceUpdates( - ctx context.Context, account *Account, newPeerID string, - updates map[string]*PeerUpdateDelta, -) { - resourceRouters := b.cache.resourceRouters - - for networkID, routers := range resourceRouters { - router, isRouter := routers[newPeerID] - if !isRouter || !router.Enabled { - continue - } - - for _, resource := range b.cache.globalResources { - if resource.NetworkID != networkID { - continue - } - - policies := b.cache.resourcePolicies[resource.ID] - if len(policies) == 0 { - continue - } - - peersWithAccess := make(map[string]struct{}) - - for _, policy := range policies { - if !policy.Enabled { - continue - } - - sourceGroups := policy.SourceGroups() - for _, sourceGroup := range sourceGroups { - groupPeers := b.cache.groupToPeers[sourceGroup] - for _, peerID := range groupPeers { - if peerID == newPeerID { - continue - } - - if account.validatePostureChecksOnPeer(ctx, policy.SourcePostureChecks, peerID) { - peersWithAccess[peerID] = struct{}{} - } - } - } - } - - for peerID := range peersWithAccess { - delta := updates[peerID] - if delta == nil { - delta = &PeerUpdateDelta{ - PeerID: peerID, - } - updates[peerID] = delta - } - - if delta.AddConnectedPeer == "" { - delta.AddConnectedPeer = newPeerID - } - - delta.RebuildRoutesView = true - } - } - } -} - -func (b *NetworkMapBuilder) calculateRouteFirewallUpdates( - newPeerID string, newPeer *nbpeer.Peer, - peerGroups []string, updates map[string]*PeerUpdateDelta, -) { - processedPeerRoutes := make(map[string]map[route.ID]struct{}) - - for routeID, info := range b.cache.noACGRoutes { - if info.PeerID == newPeerID { - continue - } - - b.addRouteFirewallUpdate(updates, info.PeerID, string(routeID), newPeer.IP.String()) - - if processedPeerRoutes[info.PeerID] == nil { - processedPeerRoutes[info.PeerID] = make(map[route.ID]struct{}) - } - processedPeerRoutes[info.PeerID][routeID] = struct{}{} - } - - for _, acg := range peerGroups { - routeInfos := b.cache.acgToRoutes[acg] - if routeInfos == nil { - continue - } - - for routeID, info := range routeInfos { - if info.PeerID == newPeerID { - continue - } - - if processedRoutes, exists := processedPeerRoutes[info.PeerID]; exists { - if _, processed := processedRoutes[routeID]; processed { - continue - } - } - - b.addRouteFirewallUpdate(updates, info.PeerID, string(routeID), newPeer.IP.String()) - - if processedPeerRoutes[info.PeerID] == nil { - processedPeerRoutes[info.PeerID] = make(map[route.ID]struct{}) - } - processedPeerRoutes[info.PeerID][routeID] = struct{}{} - } - } -} - -func (b *NetworkMapBuilder) addRouteFirewallUpdate( - updates map[string]*PeerUpdateDelta, peerID string, - routeID string, sourceIP string, -) { - delta := updates[peerID] - if delta == nil { - delta = &PeerUpdateDelta{ - PeerID: peerID, - UpdateRouteFirewallRules: make([]*RouteFirewallRuleUpdate, 0), - } - updates[peerID] = delta - } - - for _, existing := range delta.UpdateRouteFirewallRules { - if existing.RuleID == routeID && existing.AddSourceIP == sourceIP { - return - } - } - - delta.UpdateRouteFirewallRules = append(delta.UpdateRouteFirewallRules, &RouteFirewallRuleUpdate{ - RuleID: routeID, - AddSourceIP: sourceIP, - }) -} - -func (b *NetworkMapBuilder) calculateNetworkResourceFirewallUpdates( - ctx context.Context, account *Account, newPeerID string, - newPeer *nbpeer.Peer, peerGroups []string, updates map[string]*PeerUpdateDelta, -) { - for _, resource := range b.cache.globalResources { - resourcePolicies := b.cache.resourcePolicies - resourceRouters := b.cache.resourceRouters - - policies := resourcePolicies[resource.ID] - peerHasAccess := false - - for _, policy := range policies { - if !policy.Enabled { - continue - } - - sourceGroups := policy.SourceGroups() - for _, sourceGroup := range sourceGroups { - if slices.Contains(peerGroups, sourceGroup) { - if account.validatePostureChecksOnPeer(ctx, policy.SourcePostureChecks, newPeerID) { - peerHasAccess = true - break - } - } - } - - if peerHasAccess { - break - } - } - - if !peerHasAccess { - continue - } - - networkRouters := resourceRouters[resource.NetworkID] - for routerPeerID, router := range networkRouters { - if !router.Enabled || routerPeerID == newPeerID { - continue - } - - delta := updates[routerPeerID] - if delta == nil { - delta = &PeerUpdateDelta{ - PeerID: routerPeerID, - } - updates[routerPeerID] = delta - } - - if delta.AddConnectedPeer == "" { - delta.AddConnectedPeer = newPeerID - } - - delta.RebuildRoutesView = true - } - } -} - -type PeerUpdateDelta struct { - PeerID string - AddConnectedPeer string - AddFirewallRules []*FirewallRuleDelta - AddRoutes []route.ID - UpdateRouteFirewallRules []*RouteFirewallRuleUpdate - UpdateDNS bool - RebuildRoutesView bool -} -type FirewallRuleDelta struct { - Rule *FirewallRule - RuleID string - Direction int -} - -type RouteFirewallRuleUpdate struct { - RuleID string - AddSourceIP string -} - -func (b *NetworkMapBuilder) addUpdateForPeersInGroups( - updates map[string]*PeerUpdateDelta, groupIDs []string, newPeerID string, - rule *PolicyRule, direction int, allGroupLn int, -) { - for _, groupID := range groupIDs { - peers := b.cache.groupToPeers[groupID] - cnt := 0 - for _, peerID := range peers { - if peerID == newPeerID { - continue - } - if _, ok := b.validatedPeers[peerID]; !ok { - continue - } - cnt++ - } - all := false - if allGroupLn > 0 && cnt == allGroupLn { - all = true - } - newPeer := b.cache.globalPeers[newPeerID] - fr := &FirewallRule{ - PolicyID: rule.ID, - PeerIP: newPeer.IP.String(), - Direction: direction, - Action: string(rule.Action), - Protocol: string(rule.Protocol), - } - for _, peerID := range peers { - if peerID == newPeerID { - continue - } - if _, ok := b.validatedPeers[peerID]; !ok { - continue - } - delta := updates[peerID] - if delta == nil { - delta = &PeerUpdateDelta{ - PeerID: peerID, - AddConnectedPeer: newPeerID, - AddFirewallRules: make([]*FirewallRuleDelta, 0), - } - updates[peerID] = delta - } - - if all { - fr.PeerIP = allPeers - } - - if len(rule.Ports) > 0 || len(rule.PortRanges) > 0 { - expandedRules := expandPortsAndRanges(*fr, rule, b.cache.globalPeers[peerID]) - for _, expandedRule := range expandedRules { - ruleID := b.generateFirewallRuleID(expandedRule) - delta.AddFirewallRules = append(delta.AddFirewallRules, &FirewallRuleDelta{ - Rule: expandedRule, - RuleID: ruleID, - Direction: direction, - }) - } - } else { - ruleID := b.generateFirewallRuleID(fr) - delta.AddFirewallRules = append(delta.AddFirewallRules, &FirewallRuleDelta{ - Rule: fr, - RuleID: ruleID, - Direction: direction, - }) - } - } - } -} - -func (b *NetworkMapBuilder) applyDeltaToPeer(account *Account, peerID string, delta *PeerUpdateDelta) { - if delta.AddConnectedPeer != "" || len(delta.AddFirewallRules) > 0 { - if aclView := b.cache.peerACLs[peerID]; aclView != nil { - if delta.AddConnectedPeer != "" && !slices.Contains(aclView.ConnectedPeerIDs, delta.AddConnectedPeer) { - aclView.ConnectedPeerIDs = append(aclView.ConnectedPeerIDs, delta.AddConnectedPeer) - } - - for _, ruleDelta := range delta.AddFirewallRules { - b.cache.globalRules[ruleDelta.RuleID] = ruleDelta.Rule - - if !slices.Contains(aclView.FirewallRuleIDs, ruleDelta.RuleID) { - aclView.FirewallRuleIDs = append(aclView.FirewallRuleIDs, ruleDelta.RuleID) - } - } - } - } - - if delta.RebuildRoutesView { - b.buildPeerRoutesView(account, peerID) - } else if len(delta.UpdateRouteFirewallRules) > 0 { - if routesView := b.cache.peerRoutes[peerID]; routesView != nil { - b.updateRouteFirewallRules(routesView, delta.UpdateRouteFirewallRules) - } - } - - if delta.UpdateDNS { - b.buildPeerDNSView(account, peerID) - } -} - -func (b *NetworkMapBuilder) updateRouteFirewallRules(routesView *PeerRoutesView, updates []*RouteFirewallRuleUpdate) { - for _, update := range updates { - for _, ruleID := range routesView.RouteFirewallRuleIDs { - rule := b.cache.globalRouteRules[ruleID] - if rule == nil { - continue - } - - if string(rule.RouteID) == update.RuleID { - sourceIP := update.AddSourceIP - - if strings.Contains(sourceIP, ":") { - sourceIP += "/128" // IPv6 - } else { - sourceIP += "/32" // IPv4 - } - - if !slices.Contains(rule.SourceRanges, sourceIP) { - rule.SourceRanges = append(rule.SourceRanges, sourceIP) - } - break - } - } - } -} - -func (b *NetworkMapBuilder) OnPeerDeleted(peerID string) error { - b.cache.mu.Lock() - defer b.cache.mu.Unlock() - - account := b.account.Load() - - deletedPeer := b.cache.globalPeers[peerID] - if deletedPeer == nil { - return fmt.Errorf("peer %s not found in cache", peerID) - } - - deletedPeerKey := deletedPeer.Key - peerGroups := b.cache.peerToGroups[peerID] - peerIP := deletedPeer.IP.String() - - log.Debugf("NetworkMapBuilder: Deleting peer %s (IP: %s) from cache", peerID, peerIP) - - delete(b.validatedPeers, peerID) - - routesToDelete := []route.ID{} - - for routeID, r := range account.Routes { - if r.Peer != deletedPeerKey && r.PeerID != peerID { - continue - } - if len(r.PeerGroups) == 0 { - routesToDelete = append(routesToDelete, routeID) - continue - } - newPeerAssigned := false - for _, groupID := range r.PeerGroups { - candidatePeerIDs := b.cache.groupToPeers[groupID] - for _, candidatePeerID := range candidatePeerIDs { - if candidatePeerID == peerID { - continue - } - if candidatePeer := b.cache.globalPeers[candidatePeerID]; candidatePeer != nil { - r.Peer = candidatePeer.Key - r.PeerID = candidatePeerID - newPeerAssigned = true - break - } - } - if newPeerAssigned { - break - } - } - - if !newPeerAssigned { - routesToDelete = append(routesToDelete, routeID) - } - } - - for _, routeID := range routesToDelete { - delete(account.Routes, routeID) - } - - delete(b.cache.peerACLs, peerID) - delete(b.cache.peerRoutes, peerID) - delete(b.cache.peerDNS, peerID) - - delete(b.cache.globalPeers, peerID) - - for acg, routeMap := range b.cache.acgToRoutes { - for routeID, info := range routeMap { - if info.PeerID == peerID { - delete(routeMap, routeID) - } - } - if len(routeMap) == 0 { - delete(b.cache.acgToRoutes, acg) - } - } - - for _, groupID := range peerGroups { - if peers := b.cache.groupToPeers[groupID]; peers != nil { - b.cache.groupToPeers[groupID] = slices.DeleteFunc(peers, func(id string) bool { - return id == peerID - }) - } - } - delete(b.cache.peerToGroups, peerID) - - affectedPeers := make(map[string]struct{}) - - for _, r := range account.Routes { - for _, groupID := range r.Groups { - if peers := b.cache.groupToPeers[groupID]; peers != nil { - for _, p := range peers { - affectedPeers[p] = struct{}{} - } - } - } - - for _, groupID := range r.PeerGroups { - if peers := b.cache.groupToPeers[groupID]; peers != nil { - for _, p := range peers { - affectedPeers[p] = struct{}{} - } - } - } - } - - for affectedPeerID := range affectedPeers { - if affectedPeerID == peerID { - continue - } - b.buildPeerRoutesView(account, affectedPeerID) - } - - peerDeletionUpdates := b.findPeersAffectedByDeletedPeerACL(peerID, peerIP) - for affectedPeerID, updates := range peerDeletionUpdates { - b.applyDeletionUpdates(affectedPeerID, updates) - } - - b.cleanupUnusedRules() - - log.Debugf("NetworkMapBuilder: Deleted peer %s, affected %d other peers", peerID, len(affectedPeers)) - - return nil -} - -func (b *NetworkMapBuilder) findPeersAffectedByDeletedPeerACL( - deletedPeerID string, - peerIP string, -) map[string]*PeerDeletionUpdate { - - affected := make(map[string]*PeerDeletionUpdate) - - for peerID, aclView := range b.cache.peerACLs { - if peerID == deletedPeerID { - continue - } - - if !slices.Contains(aclView.ConnectedPeerIDs, deletedPeerID) { - continue - } - if affected[peerID] == nil { - affected[peerID] = &PeerDeletionUpdate{ - RemovePeerID: deletedPeerID, - PeerIP: peerIP, - } - } - - for _, ruleID := range aclView.FirewallRuleIDs { - if rule := b.cache.globalRules[ruleID]; rule != nil && rule.PeerIP == peerIP { - affected[peerID].RemoveFirewallRuleIDs = append( - affected[peerID].RemoveFirewallRuleIDs, - ruleID, - ) - } - } - } - - return affected -} - -type PeerDeletionUpdate struct { - RemovePeerID string - RemoveFirewallRuleIDs []string - RemoveRouteIDs []route.ID - RemoveFromSourceRanges bool - PeerIP string -} - -func (b *NetworkMapBuilder) applyDeletionUpdates(peerID string, updates *PeerDeletionUpdate) { - if aclView := b.cache.peerACLs[peerID]; aclView != nil { - aclView.ConnectedPeerIDs = slices.DeleteFunc(aclView.ConnectedPeerIDs, func(id string) bool { - return id == updates.RemovePeerID - }) - - if len(updates.RemoveFirewallRuleIDs) > 0 { - aclView.FirewallRuleIDs = slices.DeleteFunc(aclView.FirewallRuleIDs, func(ruleID string) bool { - return slices.Contains(updates.RemoveFirewallRuleIDs, ruleID) - }) - } - } - - if routesView := b.cache.peerRoutes[peerID]; routesView != nil { - if len(updates.RemoveRouteIDs) > 0 { - routesView.NetworkResourceIDs = slices.DeleteFunc(routesView.NetworkResourceIDs, func(routeID route.ID) bool { - return slices.Contains(updates.RemoveRouteIDs, routeID) - }) - } - - if updates.RemoveFromSourceRanges { - b.removeIPFromRouteFirewallRules(routesView, updates.PeerIP) - } - } -} - -func (b *NetworkMapBuilder) removeIPFromRouteFirewallRules(routesView *PeerRoutesView, peerIP string) { - sourceIPv4 := peerIP + "/32" - sourceIPv6 := peerIP + "/128" - - rulesToRemove := []string{} - - for _, ruleID := range routesView.RouteFirewallRuleIDs { - if rule := b.cache.globalRouteRules[ruleID]; rule != nil { - rule.SourceRanges = slices.DeleteFunc(rule.SourceRanges, func(source string) bool { - return source == sourceIPv4 || source == sourceIPv6 || source == peerIP - }) - - if len(rule.SourceRanges) == 0 { - rulesToRemove = append(rulesToRemove, ruleID) - } - } - } - - if len(rulesToRemove) > 0 { - routesView.RouteFirewallRuleIDs = slices.DeleteFunc(routesView.RouteFirewallRuleIDs, func(ruleID string) bool { - return slices.Contains(rulesToRemove, ruleID) - }) - } -} - -func (b *NetworkMapBuilder) cleanupUnusedRules() { - usedFirewallRules := make(map[string]struct{}) - usedRouteRules := make(map[string]struct{}) - usedRoutes := make(map[route.ID]struct{}) - - for _, aclView := range b.cache.peerACLs { - for _, ruleID := range aclView.FirewallRuleIDs { - usedFirewallRules[ruleID] = struct{}{} - } - } - - for _, routesView := range b.cache.peerRoutes { - for _, ruleID := range routesView.RouteFirewallRuleIDs { - usedRouteRules[ruleID] = struct{}{} - } - - for _, routeID := range routesView.OwnRouteIDs { - usedRoutes[routeID] = struct{}{} - } - for _, routeID := range routesView.NetworkResourceIDs { - usedRoutes[routeID] = struct{}{} - } - } - - for ruleID := range b.cache.globalRules { - if _, used := usedFirewallRules[ruleID]; !used { - delete(b.cache.globalRules, ruleID) - } - } - - for ruleID := range b.cache.globalRouteRules { - if _, used := usedRouteRules[ruleID]; !used { - delete(b.cache.globalRouteRules, ruleID) - } - } - - for routeID := range b.cache.globalRoutes { - if _, used := usedRoutes[routeID]; !used { - delete(b.cache.globalRoutes, routeID) - } - } -} - -func (b *NetworkMapBuilder) UpdatePeer(peer *nbpeer.Peer) { - b.cache.mu.Lock() - defer b.cache.mu.Unlock() - peerStored, ok := b.cache.globalPeers[peer.ID] - if !ok { - return - } - *peerStored = *peer -} diff --git a/management/server/types/peer.go b/management/server/types/peer.go index 15d343793..885d67bba 100644 --- a/management/server/types/peer.go +++ b/management/server/types/peer.go @@ -12,6 +12,9 @@ type PeerSync struct { WireGuardPubKey string // Meta is the system information passed by peer, must be always present Meta nbpeer.PeerSystemMeta + // RealIP is the peer's connection IP, used to refresh its geo location. + // May be nil when the request has no associated connection IP. + RealIP net.IP // UpdateAccountPeers indicate updating account peers, // which occurs when the peer's metadata is updated UpdateAccountPeers bool diff --git a/management/server/types/peer_networkmap_result.go b/management/server/types/peer_networkmap_result.go new file mode 100644 index 000000000..fadbeb599 --- /dev/null +++ b/management/server/types/peer_networkmap_result.go @@ -0,0 +1,25 @@ +package types + +// PeerNetworkMapResult is what the network_map controller produces for a +// single peer. Exactly one of NetworkMap or Components is populated depending +// on the peer's capability: +// +// - Components-capable peers (PeerCapabilityComponentNetworkMap) get +// Components: the raw types.NetworkMapComponents the client decodes and +// runs Calculate() on locally. NetworkMap stays nil — the server skips +// the expansion entirely. +// - Legacy peers (or any peer when the kill switch is set) get NetworkMap: +// the fully-expanded view the legacy gRPC path consumes. +// +// The gRPC layer (ToSyncResponseForPeer) dispatches by which field is +// non-nil; callers must not rely on both being set. +type PeerNetworkMapResult struct { + NetworkMap *NetworkMap + Components *NetworkMapComponents +} + +// IsComponents reports whether the result carries the components shape. +// Use this in preference to direct nil checks on the fields. +func (r PeerNetworkMapResult) IsComponents() bool { + return r.Components != nil +} diff --git a/management/server/types/peer_networkmap_result_test.go b/management/server/types/peer_networkmap_result_test.go new file mode 100644 index 000000000..908581a08 --- /dev/null +++ b/management/server/types/peer_networkmap_result_test.go @@ -0,0 +1,104 @@ +package types_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbdns "github.com/netbirdio/netbird/dns" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" +) + +// helper: marks the given peer as components-capable. +func markCapable(p *nbpeer.Peer) { + p.Meta.Capabilities = append(p.Meta.Capabilities, nbpeer.PeerCapabilityComponentNetworkMap) +} + +func TestGetPeerNetworkMapResult_CapablePeerGetsComponents(t *testing.T) { + account, validatedPeers := scalableTestAccount(10, 2) + markCapable(account.Peers["peer-0"]) + + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + result := account.GetPeerNetworkMapResult( + context.Background(), + "peer-0", + false, // componentsDisabled + nbdns.CustomZone{}, + nil, + validatedPeers, + resourcePolicies, + routers, + nil, + groupIDToUserIDs, + ) + + require.True(t, result.IsComponents(), "capable peer must get the components shape") + assert.Nil(t, result.NetworkMap) + require.NotNil(t, result.Components) + assert.Equal(t, "peer-0", result.Components.PeerID) +} + +func TestGetPeerNetworkMapResult_LegacyPeerGetsNetworkMap(t *testing.T) { + account, validatedPeers := scalableTestAccount(10, 2) + // peer-0 left without the component capability + + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + result := account.GetPeerNetworkMapResult( + context.Background(), + "peer-0", + false, + nbdns.CustomZone{}, + nil, + validatedPeers, + resourcePolicies, + routers, + nil, + groupIDToUserIDs, + ) + + assert.False(t, result.IsComponents()) + assert.Nil(t, result.Components) + require.NotNil(t, result.NetworkMap, "legacy peer must get a NetworkMap") +} + +func TestGetPeerNetworkMapResult_KillSwitchOverridesCapability(t *testing.T) { + // Capable peer + componentsDisabled=true → falls back to legacy. + account, validatedPeers := scalableTestAccount(10, 2) + markCapable(account.Peers["peer-0"]) + + resourcePolicies := account.GetResourcePoliciesMap() + routers := account.GetResourceRoutersMap() + groupIDToUserIDs := account.GetActiveGroupUsers() + + result := account.GetPeerNetworkMapResult( + context.Background(), + "peer-0", + true, // componentsDisabled = true (kill switch) + nbdns.CustomZone{}, + nil, + validatedPeers, + resourcePolicies, + routers, + nil, + groupIDToUserIDs, + ) + + assert.False(t, result.IsComponents(), "kill switch must force legacy NetworkMap path") + assert.Nil(t, result.Components) + require.NotNil(t, result.NetworkMap) +} + +func TestPeerNetworkMapResult_IsComponents(t *testing.T) { + assert.True(t, types.PeerNetworkMapResult{Components: &types.NetworkMapComponents{}}.IsComponents()) + assert.False(t, types.PeerNetworkMapResult{NetworkMap: &types.NetworkMap{}}.IsComponents()) + assert.False(t, types.PeerNetworkMapResult{}.IsComponents()) +} diff --git a/management/server/types/policy_test.go b/management/server/types/policy_test.go new file mode 100644 index 000000000..b1d7aabc2 --- /dev/null +++ b/management/server/types/policy_test.go @@ -0,0 +1,193 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPolicyEqual_SameRulesDifferentOrder(t *testing.T) { + a := &Policy{ + ID: "pol1", + AccountID: "acc1", + Name: "test", + Enabled: true, + Rules: []*PolicyRule{ + {ID: "r1", PolicyID: "pol1", Ports: []string{"80"}}, + {ID: "r2", PolicyID: "pol1", Ports: []string{"443"}}, + }, + } + b := &Policy{ + ID: "pol1", + AccountID: "acc1", + Name: "test", + Enabled: true, + Rules: []*PolicyRule{ + {ID: "r2", PolicyID: "pol1", Ports: []string{"443"}}, + {ID: "r1", PolicyID: "pol1", Ports: []string{"80"}}, + }, + } + assert.True(t, a.Equal(b)) +} + +func TestPolicyEqual_DifferentRules(t *testing.T) { + a := &Policy{ + ID: "pol1", + Enabled: true, + Rules: []*PolicyRule{ + {ID: "r1", PolicyID: "pol1", Ports: []string{"80"}}, + }, + } + b := &Policy{ + ID: "pol1", + Enabled: true, + Rules: []*PolicyRule{ + {ID: "r1", PolicyID: "pol1", Ports: []string{"443"}}, + }, + } + assert.False(t, a.Equal(b)) +} + +func TestPolicyEqual_DifferentRuleCount(t *testing.T) { + a := &Policy{ + ID: "pol1", + Rules: []*PolicyRule{ + {ID: "r1", PolicyID: "pol1"}, + }, + } + b := &Policy{ + ID: "pol1", + Rules: []*PolicyRule{ + {ID: "r1", PolicyID: "pol1"}, + {ID: "r2", PolicyID: "pol1"}, + }, + } + assert.False(t, a.Equal(b)) +} + +func TestPolicyEqual_PostureChecksDifferentOrder(t *testing.T) { + a := &Policy{ + ID: "pol1", + SourcePostureChecks: []string{"pc3", "pc1", "pc2"}, + } + b := &Policy{ + ID: "pol1", + SourcePostureChecks: []string{"pc1", "pc2", "pc3"}, + } + assert.True(t, a.Equal(b)) +} + +func TestPolicyEqual_DifferentPostureChecks(t *testing.T) { + a := &Policy{ + ID: "pol1", + SourcePostureChecks: []string{"pc1", "pc2"}, + } + b := &Policy{ + ID: "pol1", + SourcePostureChecks: []string{"pc1", "pc3"}, + } + assert.False(t, a.Equal(b)) +} + +func TestPolicyEqual_DifferentScalarFields(t *testing.T) { + base := Policy{ + ID: "pol1", + AccountID: "acc1", + Name: "test", + Description: "desc", + Enabled: true, + } + + other := base + other.Name = "changed" + assert.False(t, base.Equal(&other)) + + other = base + other.Enabled = false + assert.False(t, base.Equal(&other)) + + other = base + other.Description = "changed" + assert.False(t, base.Equal(&other)) +} + +func TestPolicyEqual_NilCases(t *testing.T) { + var a *Policy + var b *Policy + assert.True(t, a.Equal(b)) + + a = &Policy{ID: "pol1"} + assert.False(t, a.Equal(nil)) +} + +func TestPolicyEqual_RulesMismatchByID(t *testing.T) { + a := &Policy{ + ID: "pol1", + Rules: []*PolicyRule{ + {ID: "r1", PolicyID: "pol1"}, + }, + } + b := &Policy{ + ID: "pol1", + Rules: []*PolicyRule{ + {ID: "r2", PolicyID: "pol1"}, + }, + } + assert.False(t, a.Equal(b)) +} + +func TestPolicyEqual_FullScenario(t *testing.T) { + a := &Policy{ + ID: "pol1", + AccountID: "acc1", + Name: "Web Access", + Description: "Allow web access", + Enabled: true, + SourcePostureChecks: []string{"pc2", "pc1"}, + Rules: []*PolicyRule{ + { + ID: "r1", + PolicyID: "pol1", + Name: "HTTP", + Enabled: true, + Action: PolicyTrafficActionAccept, + Protocol: PolicyRuleProtocolTCP, + Bidirectional: true, + Sources: []string{"g2", "g1"}, + Destinations: []string{"g4", "g3"}, + Ports: []string{"443", "80", "8080"}, + PortRanges: []RulePortRange{ + {Start: 8000, End: 9000}, + {Start: 80, End: 80}, + }, + }, + }, + } + b := &Policy{ + ID: "pol1", + AccountID: "acc1", + Name: "Web Access", + Description: "Allow web access", + Enabled: true, + SourcePostureChecks: []string{"pc1", "pc2"}, + Rules: []*PolicyRule{ + { + ID: "r1", + PolicyID: "pol1", + Name: "HTTP", + Enabled: true, + Action: PolicyTrafficActionAccept, + Protocol: PolicyRuleProtocolTCP, + Bidirectional: true, + Sources: []string{"g1", "g2"}, + Destinations: []string{"g3", "g4"}, + Ports: []string{"80", "8080", "443"}, + PortRanges: []RulePortRange{ + {Start: 80, End: 80}, + {Start: 8000, End: 9000}, + }, + }, + }, + } + assert.True(t, a.Equal(b)) +} diff --git a/management/server/types/policyrule_test.go b/management/server/types/policyrule_test.go new file mode 100644 index 000000000..b8f6a98b1 --- /dev/null +++ b/management/server/types/policyrule_test.go @@ -0,0 +1,193 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPolicyRuleEqual_SamePortsDifferentOrder(t *testing.T) { + a := &PolicyRule{ + ID: "rule1", + PolicyID: "pol1", + Ports: []string{"443", "80", "22"}, + } + b := &PolicyRule{ + ID: "rule1", + PolicyID: "pol1", + Ports: []string{"22", "443", "80"}, + } + assert.True(t, a.Equal(b)) +} + +func TestPolicyRuleEqual_DifferentPorts(t *testing.T) { + a := &PolicyRule{ + ID: "rule1", + PolicyID: "pol1", + Ports: []string{"443", "80"}, + } + b := &PolicyRule{ + ID: "rule1", + PolicyID: "pol1", + Ports: []string{"443", "22"}, + } + assert.False(t, a.Equal(b)) +} + +func TestPolicyRuleEqual_SourcesDestinationsDifferentOrder(t *testing.T) { + a := &PolicyRule{ + ID: "rule1", + PolicyID: "pol1", + Sources: []string{"g1", "g2", "g3"}, + Destinations: []string{"g4", "g5"}, + } + b := &PolicyRule{ + ID: "rule1", + PolicyID: "pol1", + Sources: []string{"g3", "g1", "g2"}, + Destinations: []string{"g5", "g4"}, + } + assert.True(t, a.Equal(b)) +} + +func TestPolicyRuleEqual_DifferentSources(t *testing.T) { + a := &PolicyRule{ + ID: "rule1", + PolicyID: "pol1", + Sources: []string{"g1", "g2"}, + } + b := &PolicyRule{ + ID: "rule1", + PolicyID: "pol1", + Sources: []string{"g1", "g3"}, + } + assert.False(t, a.Equal(b)) +} + +func TestPolicyRuleEqual_PortRangesDifferentOrder(t *testing.T) { + a := &PolicyRule{ + ID: "rule1", + PolicyID: "pol1", + PortRanges: []RulePortRange{ + {Start: 8000, End: 9000}, + {Start: 80, End: 80}, + }, + } + b := &PolicyRule{ + ID: "rule1", + PolicyID: "pol1", + PortRanges: []RulePortRange{ + {Start: 80, End: 80}, + {Start: 8000, End: 9000}, + }, + } + assert.True(t, a.Equal(b)) +} + +func TestPolicyRuleEqual_DifferentPortRanges(t *testing.T) { + a := &PolicyRule{ + ID: "rule1", + PolicyID: "pol1", + PortRanges: []RulePortRange{ + {Start: 80, End: 80}, + }, + } + b := &PolicyRule{ + ID: "rule1", + PolicyID: "pol1", + PortRanges: []RulePortRange{ + {Start: 80, End: 443}, + }, + } + assert.False(t, a.Equal(b)) +} + +func TestPolicyRuleEqual_AuthorizedGroupsDifferentValueOrder(t *testing.T) { + a := &PolicyRule{ + ID: "rule1", + PolicyID: "pol1", + AuthorizedGroups: map[string][]string{ + "g1": {"u1", "u2", "u3"}, + }, + } + b := &PolicyRule{ + ID: "rule1", + PolicyID: "pol1", + AuthorizedGroups: map[string][]string{ + "g1": {"u3", "u1", "u2"}, + }, + } + assert.True(t, a.Equal(b)) +} + +func TestPolicyRuleEqual_DifferentAuthorizedGroups(t *testing.T) { + a := &PolicyRule{ + ID: "rule1", + PolicyID: "pol1", + AuthorizedGroups: map[string][]string{ + "g1": {"u1"}, + }, + } + b := &PolicyRule{ + ID: "rule1", + PolicyID: "pol1", + AuthorizedGroups: map[string][]string{ + "g2": {"u1"}, + }, + } + assert.False(t, a.Equal(b)) +} + +func TestPolicyRuleEqual_DifferentScalarFields(t *testing.T) { + base := PolicyRule{ + ID: "rule1", + PolicyID: "pol1", + Name: "test", + Description: "desc", + Enabled: true, + Action: PolicyTrafficActionAccept, + Bidirectional: true, + Protocol: PolicyRuleProtocolTCP, + } + + other := base + other.Name = "changed" + assert.False(t, base.Equal(&other)) + + other = base + other.Enabled = false + assert.False(t, base.Equal(&other)) + + other = base + other.Action = PolicyTrafficActionDrop + assert.False(t, base.Equal(&other)) + + other = base + other.Protocol = PolicyRuleProtocolUDP + assert.False(t, base.Equal(&other)) +} + +func TestPolicyRuleEqual_NilCases(t *testing.T) { + var a *PolicyRule + var b *PolicyRule + assert.True(t, a.Equal(b)) + + a = &PolicyRule{ID: "rule1"} + assert.False(t, a.Equal(nil)) +} + +func TestPolicyRuleEqual_EmptySlices(t *testing.T) { + a := &PolicyRule{ + ID: "rule1", + PolicyID: "pol1", + Ports: []string{}, + Sources: nil, + } + b := &PolicyRule{ + ID: "rule1", + PolicyID: "pol1", + Ports: nil, + Sources: []string{}, + } + assert.True(t, a.Equal(b)) +} diff --git a/management/server/types/proxy.go b/management/server/types/proxy.go new file mode 100644 index 000000000..1b80e80d1 --- /dev/null +++ b/management/server/types/proxy.go @@ -0,0 +1,7 @@ +package types + +// ProxyCallbackEndpoint holds the proxy callback endpoint +const ProxyCallbackEndpoint = "/reverse-proxy/callback" + +// ProxyCallbackEndpointFull holds the proxy callback endpoint with api suffix +const ProxyCallbackEndpointFull = "/api" + ProxyCallbackEndpoint diff --git a/management/server/types/proxy_access_token.go b/management/server/types/proxy_access_token.go new file mode 100644 index 000000000..b20b83bc1 --- /dev/null +++ b/management/server/types/proxy_access_token.go @@ -0,0 +1,137 @@ +package types + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "hash/crc32" + "strings" + "time" + + b "github.com/hashicorp/go-secure-stdlib/base62" + "github.com/rs/xid" + + "github.com/netbirdio/netbird/base62" + "github.com/netbirdio/netbird/management/server/util" +) + +const ( + // ProxyTokenPrefix is the globally used prefix for proxy access tokens + ProxyTokenPrefix = "nbx_" + // ProxyTokenSecretLength is the number of characters used for the secret + ProxyTokenSecretLength = 30 + // ProxyTokenChecksumLength is the number of characters used for the encoded checksum + ProxyTokenChecksumLength = 6 + // ProxyTokenLength is the total number of characters used for the token + ProxyTokenLength = 40 +) + +// HashedProxyToken is a SHA-256 hash of a plain proxy token, base64-encoded. +type HashedProxyToken string + +// PlainProxyToken is the raw token string displayed once at creation time. +type PlainProxyToken string + +// ProxyAccessToken holds information about a proxy access token including a hashed version for verification +type ProxyAccessToken struct { + ID string `gorm:"primaryKey"` + Name string + HashedToken HashedProxyToken `gorm:"type:varchar(255);uniqueIndex"` + // AccountID is nil for management-wide tokens, set for account-scoped tokens + AccountID *string `gorm:"index"` + ExpiresAt *time.Time + CreatedBy string + CreatedAt time.Time + LastUsed *time.Time + Revoked bool +} + +// IsExpired returns true if the token has expired +func (t *ProxyAccessToken) IsExpired() bool { + if t.ExpiresAt == nil { + return false + } + return time.Now().After(*t.ExpiresAt) +} + +// IsValid returns true if the token is not revoked and not expired +func (t *ProxyAccessToken) IsValid() bool { + return !t.Revoked && !t.IsExpired() +} + +// ProxyAccessTokenGenerated holds the new token and the plain text version +type ProxyAccessTokenGenerated struct { + PlainToken PlainProxyToken + ProxyAccessToken +} + +// CreateNewProxyAccessToken generates a new proxy access token. +// Returns the token with hashed value stored and plain token for one-time display. +func CreateNewProxyAccessToken(name string, expiresIn time.Duration, accountID *string, createdBy string) (*ProxyAccessTokenGenerated, error) { + hashedToken, plainToken, err := generateProxyToken() + if err != nil { + return nil, err + } + + currentTime := time.Now().UTC() + var expiresAt *time.Time + if expiresIn > 0 { + expiresAt = util.ToPtr(currentTime.Add(expiresIn)) + } + + return &ProxyAccessTokenGenerated{ + ProxyAccessToken: ProxyAccessToken{ + ID: xid.New().String(), + Name: name, + HashedToken: hashedToken, + AccountID: accountID, + ExpiresAt: expiresAt, + CreatedBy: createdBy, + CreatedAt: currentTime, + Revoked: false, + }, + PlainToken: plainToken, + }, nil +} + +func generateProxyToken() (HashedProxyToken, PlainProxyToken, error) { + secret, err := b.Random(ProxyTokenSecretLength) + if err != nil { + return "", "", err + } + + checksum := crc32.ChecksumIEEE([]byte(secret)) + encodedChecksum := base62.Encode(checksum) + paddedChecksum := fmt.Sprintf("%06s", encodedChecksum) + plainToken := PlainProxyToken(ProxyTokenPrefix + secret + paddedChecksum) + return plainToken.Hash(), plainToken, nil +} + +// Hash returns the SHA-256 hash of the plain token, base64-encoded. +func (t PlainProxyToken) Hash() HashedProxyToken { + h := sha256.Sum256([]byte(t)) + return HashedProxyToken(base64.StdEncoding.EncodeToString(h[:])) +} + +// Validate checks the format of a proxy token without checking the database. +func (t PlainProxyToken) Validate() error { + if !strings.HasPrefix(string(t), ProxyTokenPrefix) { + return fmt.Errorf("invalid token prefix") + } + + if len(t) != ProxyTokenLength { + return fmt.Errorf("invalid token length") + } + + secret := t[len(ProxyTokenPrefix) : len(t)-ProxyTokenChecksumLength] + checksumStr := t[len(t)-ProxyTokenChecksumLength:] + + expectedChecksum := crc32.ChecksumIEEE([]byte(secret)) + expectedChecksumStr := fmt.Sprintf("%06s", base62.Encode(expectedChecksum)) + + if string(checksumStr) != expectedChecksumStr { + return fmt.Errorf("invalid token checksum") + } + + return nil +} diff --git a/management/server/types/proxy_access_token_test.go b/management/server/types/proxy_access_token_test.go new file mode 100644 index 000000000..aa1a4d2dd --- /dev/null +++ b/management/server/types/proxy_access_token_test.go @@ -0,0 +1,155 @@ +package types + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPlainProxyToken_Validate(t *testing.T) { + tests := []struct { + name string + token PlainProxyToken + wantErr bool + errMsg string + }{ + { + name: "valid token", + token: "", // will be generated + wantErr: false, + }, + { + name: "wrong prefix", + token: "xyz_8FbPkxioCFmlvCTJbD1RafygfVmS9z15lyNM", + wantErr: true, + errMsg: "invalid token prefix", + }, + { + name: "too short", + token: "nbx_short", + wantErr: true, + errMsg: "invalid token length", + }, + { + name: "too long", + token: "nbx_8FbPkxioCFmlvCTJbD1RafygfVmS9z15lyNMextra", + wantErr: true, + errMsg: "invalid token length", + }, + { + name: "correct length but invalid checksum", + token: "nbx_invalidtoken123456789012345678901234", // exactly 40 chars, invalid checksum + wantErr: true, + errMsg: "invalid token checksum", + }, + { + name: "empty token", + token: "", + wantErr: true, + errMsg: "invalid token prefix", + }, + { + name: "only prefix", + token: "nbx_", + wantErr: true, + errMsg: "invalid token length", + }, + } + + // Generate a valid token for the first test + generated, err := CreateNewProxyAccessToken("test", 0, nil, "test") + require.NoError(t, err) + tests[0].token = generated.PlainToken + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.token.Validate() + if tt.wantErr { + assert.Error(t, err) + if tt.errMsg != "" { + assert.Contains(t, err.Error(), tt.errMsg) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestPlainProxyToken_Hash(t *testing.T) { + token1 := PlainProxyToken("nbx_8FbPkxioCFmlvCTJbD1RafygfVmS9z15lyNM") + token2 := PlainProxyToken("nbx_8FbPkxioCFmlvCTJbD1RafygfVmS9z15lyNM") + token3 := PlainProxyToken("nbx_differenttoken1234567890123456789X") + + hash1 := token1.Hash() + hash2 := token2.Hash() + hash3 := token3.Hash() + + assert.Equal(t, hash1, hash2, "same token should produce same hash") + assert.NotEqual(t, hash1, hash3, "different tokens should produce different hashes") + assert.NotEmpty(t, hash1) +} + +func TestCreateNewProxyAccessToken(t *testing.T) { + t.Run("creates valid token", func(t *testing.T) { + generated, err := CreateNewProxyAccessToken("test-token", 0, nil, "test-user") + require.NoError(t, err) + + assert.NotEmpty(t, generated.ID) + assert.Equal(t, "test-token", generated.Name) + assert.Equal(t, "test-user", generated.CreatedBy) + assert.NotEmpty(t, generated.HashedToken) + assert.NotEmpty(t, generated.PlainToken) + assert.Nil(t, generated.ExpiresAt) + assert.False(t, generated.Revoked) + + assert.NoError(t, generated.PlainToken.Validate()) + assert.Equal(t, ProxyTokenLength, len(generated.PlainToken)) + assert.Equal(t, ProxyTokenPrefix, string(generated.PlainToken[:len(ProxyTokenPrefix)])) + }) + + t.Run("tokens are unique", func(t *testing.T) { + gen1, err := CreateNewProxyAccessToken("test1", 0, nil, "user") + require.NoError(t, err) + + gen2, err := CreateNewProxyAccessToken("test2", 0, nil, "user") + require.NoError(t, err) + + assert.NotEqual(t, gen1.PlainToken, gen2.PlainToken) + assert.NotEqual(t, gen1.HashedToken, gen2.HashedToken) + assert.NotEqual(t, gen1.ID, gen2.ID) + }) +} + +func TestProxyAccessToken_IsExpired(t *testing.T) { + past := time.Now().Add(-1 * time.Hour) + future := time.Now().Add(1 * time.Hour) + + t.Run("expired token", func(t *testing.T) { + token := &ProxyAccessToken{ExpiresAt: &past} + assert.True(t, token.IsExpired()) + }) + + t.Run("not expired token", func(t *testing.T) { + token := &ProxyAccessToken{ExpiresAt: &future} + assert.False(t, token.IsExpired()) + }) + + t.Run("no expiration", func(t *testing.T) { + token := &ProxyAccessToken{ExpiresAt: nil} + assert.False(t, token.IsExpired()) + }) +} + +func TestProxyAccessToken_IsValid(t *testing.T) { + token := &ProxyAccessToken{ + Revoked: false, + } + + assert.True(t, token.IsValid()) + + token.Revoked = true + assert.False(t, token.IsValid()) +} diff --git a/management/server/types/settings.go b/management/server/types/settings.go index b4afb2f5e..815c93ce6 100644 --- a/management/server/types/settings.go +++ b/management/server/types/settings.go @@ -46,12 +46,56 @@ type Settings struct { // NetworkRange is the custom network range for that account NetworkRange netip.Prefix `gorm:"serializer:json"` + // NetworkRangeV6 is the custom IPv6 network range for that account + NetworkRangeV6 netip.Prefix `gorm:"serializer:json"` + + // PeerExposeEnabled enables or disables peer-initiated service expose + PeerExposeEnabled bool + // PeerExposeGroups list of peer group IDs allowed to expose services + PeerExposeGroups []string `gorm:"serializer:json"` // Extra is a dictionary of Account settings Extra *ExtraSettings `gorm:"embedded;embeddedPrefix:extra_"` // LazyConnectionEnabled indicates if the experimental feature is enabled or disabled LazyConnectionEnabled bool `gorm:"default:false"` + + // AutoUpdateVersion client auto-update version + AutoUpdateVersion string `gorm:"default:'disabled'"` + + // AutoUpdateAlways when true, updates are installed automatically in the background; + // when false, updates require user interaction from the UI + AutoUpdateAlways bool `gorm:"default:false"` + + // IPv6EnabledGroups is the list of group IDs whose peers receive IPv6 overlay addresses. + // Peers not in any of these groups will not be allocated an IPv6 address. + // Empty list means IPv6 is disabled for the account. + // For new accounts this defaults to the All group. + IPv6EnabledGroups []string `gorm:"serializer:json"` + + // MetricsPushEnabled globally enables or disables client metrics push for the account + MetricsPushEnabled bool `gorm:"default:false"` + + // AgentNetworkOnly limits the dashboard to the Agent Network surface for this account. + // Set for accounts created via netbird.ai signups; users can disable it later. + AgentNetworkOnly bool `gorm:"default:false"` + + // DashboardFeatures holds per-account dashboard section visibility overrides. + // It serializes to a single JSON column so new sections can be added without + // a schema change. + DashboardFeatures *DashboardFeatures `gorm:"serializer:json"` + + // EmbeddedIdpEnabled indicates if the embedded identity provider is enabled. + // This is a runtime-only field, not stored in the database. + EmbeddedIdpEnabled bool `gorm:"-"` + + // LocalAuthDisabled indicates if local (email/password) authentication is disabled. + // This is a runtime-only field, not stored in the database. + LocalAuthDisabled bool `gorm:"-"` + + // LocalMfaEnabled indicates if TOTP MFA is enabled for local users. + // Only applicable when the embedded IDP is enabled. + LocalMfaEnabled bool } // Copy copies the Settings struct @@ -69,16 +113,49 @@ func (s *Settings) Copy() *Settings { PeerInactivityExpiration: s.PeerInactivityExpiration, RoutingPeerDNSResolutionEnabled: s.RoutingPeerDNSResolutionEnabled, + PeerExposeEnabled: s.PeerExposeEnabled, + PeerExposeGroups: slices.Clone(s.PeerExposeGroups), LazyConnectionEnabled: s.LazyConnectionEnabled, DNSDomain: s.DNSDomain, NetworkRange: s.NetworkRange, + NetworkRangeV6: s.NetworkRangeV6, + AutoUpdateVersion: s.AutoUpdateVersion, + AutoUpdateAlways: s.AutoUpdateAlways, + IPv6EnabledGroups: slices.Clone(s.IPv6EnabledGroups), + MetricsPushEnabled: s.MetricsPushEnabled, + AgentNetworkOnly: s.AgentNetworkOnly, + EmbeddedIdpEnabled: s.EmbeddedIdpEnabled, + LocalAuthDisabled: s.LocalAuthDisabled, + LocalMfaEnabled: s.LocalMfaEnabled, } if s.Extra != nil { settings.Extra = s.Extra.Copy() } + if s.DashboardFeatures != nil { + settings.DashboardFeatures = s.DashboardFeatures.Copy() + } return settings } +// DashboardFeatures holds per-account dashboard section visibility overrides. +// Nil fields are unset and follow the default dashboard behavior; an explicit +// value forces that section shown or hidden for the account. +type DashboardFeatures struct { + // AgentNetwork, when set, forces the Agent Network menu shown (true) or + // hidden (false) regardless of the deployment feature flag. + AgentNetwork *bool `json:"agent_network,omitempty"` +} + +// Copy returns a deep copy of the DashboardFeatures struct. +func (d *DashboardFeatures) Copy() *DashboardFeatures { + c := &DashboardFeatures{} + if d.AgentNetwork != nil { + v := *d.AgentNetwork + c.AgentNetwork = &v + } + return c +} + type ExtraSettings struct { // PeerApprovalEnabled enables or disables the need for peers bo be approved by an administrator PeerApprovalEnabled bool diff --git a/management/server/types/update_reason.go b/management/server/types/update_reason.go new file mode 100644 index 000000000..9d752da9a --- /dev/null +++ b/management/server/types/update_reason.go @@ -0,0 +1,37 @@ +package types + +// UpdateReason describes why an account peers update was triggered. +type UpdateReason struct { + Resource UpdateResource + Operation UpdateOperation +} + +// UpdateResource represents the kind of resource that triggered an account peers update. +type UpdateResource string + +const ( + UpdateResourceAccountSettings UpdateResource = "account_settings" + UpdateResourceDNSSettings UpdateResource = "dns_settings" + UpdateResourceGroup UpdateResource = "group" + UpdateResourceNameServerGroup UpdateResource = "nameserver_group" + UpdateResourcePolicy UpdateResource = "policy" + UpdateResourcePostureCheck UpdateResource = "posture_check" + UpdateResourceRoute UpdateResource = "route" + UpdateResourceUser UpdateResource = "user" + UpdateResourcePeer UpdateResource = "peer" + UpdateResourceNetwork UpdateResource = "network" + UpdateResourceNetworkResource UpdateResource = "network_resource" + UpdateResourceNetworkRouter UpdateResource = "network_router" + UpdateResourceService UpdateResource = "service" + UpdateResourceZone UpdateResource = "zone" + UpdateResourceZoneRecord UpdateResource = "zone_record" +) + +// UpdateOperation represents the kind of change that triggered the update. +type UpdateOperation string + +const ( + UpdateOperationCreate UpdateOperation = "create" + UpdateOperationUpdate UpdateOperation = "update" + UpdateOperationDelete UpdateOperation = "delete" +) diff --git a/management/server/types/user.go b/management/server/types/user.go index beb3586df..dc601e15b 100644 --- a/management/server/types/user.go +++ b/management/server/types/user.go @@ -7,6 +7,7 @@ import ( "github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/netbird/management/server/integration_reference" + "github.com/netbirdio/netbird/util/crypt" ) const ( @@ -65,7 +66,11 @@ type UserInfo struct { LastLogin time.Time `json:"last_login"` Issued string `json:"issued"` PendingApproval bool `json:"pending_approval"` + Password string `json:"password"` IntegrationReference integration_reference.IntegrationReference `json:"-"` + // IdPID is the identity provider ID (connector ID) extracted from the Dex-encoded user ID. + // This field is only populated when the user ID can be decoded from Dex's format. + IdPID string `json:"idp_id,omitempty"` } // User represents a user of the system @@ -96,6 +101,9 @@ type User struct { Issued string `gorm:"default:api"` IntegrationReference integration_reference.IntegrationReference `gorm:"embedded;embeddedPrefix:integration_ref_"` + + Name string `gorm:"default:''"` + Email string `gorm:"default:''"` } // IsBlocked returns true if the user is blocked, false otherwise @@ -143,10 +151,16 @@ func (u *User) ToUserInfo(userData *idp.UserData) (*UserInfo, error) { } if userData == nil { + + name := u.Name + if u.IsServiceUser { + name = u.ServiceUserName + } + return &UserInfo{ ID: u.Id, - Email: "", - Name: u.ServiceUserName, + Email: u.Email, + Name: name, Role: string(u.Role), AutoGroups: u.AutoGroups, Status: string(UserStatusActive), @@ -178,6 +192,7 @@ func (u *User) ToUserInfo(userData *idp.UserData) (*UserInfo, error) { LastLogin: u.GetLastLogin(), Issued: u.Issued, PendingApproval: u.PendingApproval, + Password: userData.Password, }, nil } @@ -204,11 +219,13 @@ func (u *User) Copy() *User { CreatedAt: u.CreatedAt, Issued: u.Issued, IntegrationReference: u.IntegrationReference, + Email: u.Email, + Name: u.Name, } } // NewUser creates a new user -func NewUser(id string, role UserRole, isServiceUser bool, nonDeletable bool, serviceUserName string, autoGroups []string, issued string) *User { +func NewUser(id string, role UserRole, isServiceUser bool, nonDeletable bool, serviceUserName string, autoGroups []string, issued string, email string, name string) *User { return &User{ Id: id, Role: role, @@ -218,20 +235,70 @@ func NewUser(id string, role UserRole, isServiceUser bool, nonDeletable bool, se AutoGroups: autoGroups, Issued: issued, CreatedAt: time.Now().UTC(), + Name: name, + Email: email, } } // NewRegularUser creates a new user with role UserRoleUser -func NewRegularUser(id string) *User { - return NewUser(id, UserRoleUser, false, false, "", []string{}, UserIssuedAPI) +func NewRegularUser(id, email, name string) *User { + return NewUser(id, UserRoleUser, false, false, "", []string{}, UserIssuedAPI, email, name) } // NewAdminUser creates a new user with role UserRoleAdmin func NewAdminUser(id string) *User { - return NewUser(id, UserRoleAdmin, false, false, "", []string{}, UserIssuedAPI) + return NewUser(id, UserRoleAdmin, false, false, "", []string{}, UserIssuedAPI, "", "") } // NewOwnerUser creates a new user with role UserRoleOwner -func NewOwnerUser(id string) *User { - return NewUser(id, UserRoleOwner, false, false, "", []string{}, UserIssuedAPI) +func NewOwnerUser(id string, email string, name string) *User { + return NewUser(id, UserRoleOwner, false, false, "", []string{}, UserIssuedAPI, email, name) +} + +// EncryptSensitiveData encrypts the user's sensitive fields (Email and Name) in place. +func (u *User) EncryptSensitiveData(enc *crypt.FieldEncrypt) error { + if enc == nil { + return nil + } + + var err error + if u.Email != "" { + u.Email, err = enc.Encrypt(u.Email) + if err != nil { + return fmt.Errorf("encrypt email: %w", err) + } + } + + if u.Name != "" { + u.Name, err = enc.Encrypt(u.Name) + if err != nil { + return fmt.Errorf("encrypt name: %w", err) + } + } + + return nil +} + +// DecryptSensitiveData decrypts the user's sensitive fields (Email and Name) in place. +func (u *User) DecryptSensitiveData(enc *crypt.FieldEncrypt) error { + if enc == nil { + return nil + } + + var err error + if u.Email != "" { + u.Email, err = enc.Decrypt(u.Email) + if err != nil { + return fmt.Errorf("decrypt email: %w", err) + } + } + + if u.Name != "" { + u.Name, err = enc.Decrypt(u.Name) + if err != nil { + return fmt.Errorf("decrypt name: %w", err) + } + } + + return nil } diff --git a/management/server/types/user_invite.go b/management/server/types/user_invite.go new file mode 100644 index 000000000..1544b0ff3 --- /dev/null +++ b/management/server/types/user_invite.go @@ -0,0 +1,201 @@ +package types + +import ( + "crypto/sha256" + b64 "encoding/base64" + "fmt" + "hash/crc32" + "strings" + "time" + + b "github.com/hashicorp/go-secure-stdlib/base62" + "github.com/rs/xid" + + "github.com/netbirdio/netbird/base62" + "github.com/netbirdio/netbird/util/crypt" +) + +const ( + // InviteTokenPrefix is the prefix for invite tokens + InviteTokenPrefix = "nbi_" + // InviteTokenSecretLength is the length of the random secret part + InviteTokenSecretLength = 30 + // InviteTokenChecksumLength is the length of the encoded checksum + InviteTokenChecksumLength = 6 + // InviteTokenLength is the total length of the token (4 + 30 + 6 = 40) + InviteTokenLength = 40 + // DefaultInviteExpirationSeconds is the default expiration time for invites (72 hours) + DefaultInviteExpirationSeconds = 259200 + // MinInviteExpirationSeconds is the minimum expiration time for invites (1 hour) + MinInviteExpirationSeconds = 3600 +) + +// UserInviteRecord represents an invitation for a user to set up their account (database model) +type UserInviteRecord struct { + ID string `gorm:"primaryKey"` + AccountID string `gorm:"index;not null"` + Email string `gorm:"index;not null"` + Name string `gorm:"not null"` + Role string `gorm:"not null"` + AutoGroups []string `gorm:"serializer:json"` + HashedToken string `gorm:"index;not null"` // SHA-256 hash of the token (base64 encoded) + ExpiresAt time.Time `gorm:"not null"` + CreatedAt time.Time `gorm:"not null"` + CreatedBy string `gorm:"not null"` +} + +// TableName returns the table name for GORM +func (UserInviteRecord) TableName() string { + return "user_invites" +} + +// GenerateInviteToken creates a new invite token with the format: nbi_ +// Returns the hashed token (for storage) and the plain token (to give to the user) +func GenerateInviteToken() (hashedToken string, plainToken string, err error) { + secret, err := b.Random(InviteTokenSecretLength) + if err != nil { + return "", "", fmt.Errorf("failed to generate random secret: %w", err) + } + + checksum := crc32.ChecksumIEEE([]byte(secret)) + encodedChecksum := base62.Encode(checksum) + // Left-pad with '0' to ensure exactly 6 characters (fmt.Sprintf %s pads with spaces which breaks base62.Decode) + paddedChecksum := encodedChecksum + if len(paddedChecksum) < InviteTokenChecksumLength { + paddedChecksum = strings.Repeat("0", InviteTokenChecksumLength-len(paddedChecksum)) + paddedChecksum + } + + plainToken = InviteTokenPrefix + secret + paddedChecksum + hash := sha256.Sum256([]byte(plainToken)) + hashedToken = b64.StdEncoding.EncodeToString(hash[:]) + + return hashedToken, plainToken, nil +} + +// HashInviteToken creates a SHA-256 hash of the token (base64 encoded) +func HashInviteToken(token string) string { + hash := sha256.Sum256([]byte(token)) + return b64.StdEncoding.EncodeToString(hash[:]) +} + +// ValidateInviteToken validates the token format and checksum. +// Returns an error if the token is invalid. +func ValidateInviteToken(token string) error { + if len(token) != InviteTokenLength { + return fmt.Errorf("invalid token length") + } + + prefix := token[:len(InviteTokenPrefix)] + if prefix != InviteTokenPrefix { + return fmt.Errorf("invalid token prefix") + } + + secret := token[len(InviteTokenPrefix) : len(InviteTokenPrefix)+InviteTokenSecretLength] + encodedChecksum := token[len(InviteTokenPrefix)+InviteTokenSecretLength:] + + verificationChecksum, err := base62.Decode(encodedChecksum) + if err != nil { + return fmt.Errorf("checksum decoding failed: %w", err) + } + + secretChecksum := crc32.ChecksumIEEE([]byte(secret)) + if secretChecksum != verificationChecksum { + return fmt.Errorf("checksum does not match") + } + + return nil +} + +// IsExpired checks if the invite has expired +func (i *UserInviteRecord) IsExpired() bool { + return time.Now().After(i.ExpiresAt) +} + +// UserInvite contains the result of creating or regenerating an invite +type UserInvite struct { + UserInfo *UserInfo + InviteToken string + InviteExpiresAt time.Time + InviteCreatedAt time.Time +} + +// UserInviteInfo contains public information about an invite (for unauthenticated endpoint) +type UserInviteInfo struct { + Email string `json:"email"` + Name string `json:"name"` + ExpiresAt time.Time `json:"expires_at"` + Valid bool `json:"valid"` + InvitedBy string `json:"invited_by"` +} + +// NewInviteID generates a new invite ID using xid +func NewInviteID() string { + return xid.New().String() +} + +// EncryptSensitiveData encrypts the invite's sensitive fields (Email and Name) in place. +func (i *UserInviteRecord) EncryptSensitiveData(enc *crypt.FieldEncrypt) error { + if enc == nil { + return nil + } + + var err error + if i.Email != "" { + i.Email, err = enc.Encrypt(i.Email) + if err != nil { + return fmt.Errorf("encrypt email: %w", err) + } + } + + if i.Name != "" { + i.Name, err = enc.Encrypt(i.Name) + if err != nil { + return fmt.Errorf("encrypt name: %w", err) + } + } + + return nil +} + +// DecryptSensitiveData decrypts the invite's sensitive fields (Email and Name) in place. +func (i *UserInviteRecord) DecryptSensitiveData(enc *crypt.FieldEncrypt) error { + if enc == nil { + return nil + } + + var err error + if i.Email != "" { + i.Email, err = enc.Decrypt(i.Email) + if err != nil { + return fmt.Errorf("decrypt email: %w", err) + } + } + + if i.Name != "" { + i.Name, err = enc.Decrypt(i.Name) + if err != nil { + return fmt.Errorf("decrypt name: %w", err) + } + } + + return nil +} + +// Copy creates a deep copy of the UserInviteRecord +func (i *UserInviteRecord) Copy() *UserInviteRecord { + autoGroups := make([]string, len(i.AutoGroups)) + copy(autoGroups, i.AutoGroups) + + return &UserInviteRecord{ + ID: i.ID, + AccountID: i.AccountID, + Email: i.Email, + Name: i.Name, + Role: i.Role, + AutoGroups: autoGroups, + HashedToken: i.HashedToken, + ExpiresAt: i.ExpiresAt, + CreatedAt: i.CreatedAt, + CreatedBy: i.CreatedBy, + } +} diff --git a/management/server/types/user_invite_test.go b/management/server/types/user_invite_test.go new file mode 100644 index 000000000..c77fb89e2 --- /dev/null +++ b/management/server/types/user_invite_test.go @@ -0,0 +1,358 @@ +package types + +import ( + "crypto/sha256" + b64 "encoding/base64" + "hash/crc32" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/base62" + "github.com/netbirdio/netbird/util/crypt" +) + +func TestUserInviteRecord_TableName(t *testing.T) { + invite := UserInviteRecord{} + assert.Equal(t, "user_invites", invite.TableName()) +} + +func TestGenerateInviteToken_Success(t *testing.T) { + hashedToken, plainToken, err := GenerateInviteToken() + require.NoError(t, err) + assert.NotEmpty(t, hashedToken) + assert.NotEmpty(t, plainToken) +} + +func TestGenerateInviteToken_Length(t *testing.T) { + _, plainToken, err := GenerateInviteToken() + require.NoError(t, err) + assert.Len(t, plainToken, InviteTokenLength) +} + +func TestGenerateInviteToken_Prefix(t *testing.T) { + _, plainToken, err := GenerateInviteToken() + require.NoError(t, err) + assert.True(t, strings.HasPrefix(plainToken, InviteTokenPrefix)) +} + +func TestGenerateInviteToken_Hashing(t *testing.T) { + hashedToken, plainToken, err := GenerateInviteToken() + require.NoError(t, err) + + expectedHash := sha256.Sum256([]byte(plainToken)) + expectedHashedToken := b64.StdEncoding.EncodeToString(expectedHash[:]) + assert.Equal(t, expectedHashedToken, hashedToken) +} + +func TestGenerateInviteToken_Checksum(t *testing.T) { + _, plainToken, err := GenerateInviteToken() + require.NoError(t, err) + + // Extract parts + secret := plainToken[len(InviteTokenPrefix) : len(InviteTokenPrefix)+InviteTokenSecretLength] + checksumStr := plainToken[len(InviteTokenPrefix)+InviteTokenSecretLength:] + + // Verify checksum + expectedChecksum := crc32.ChecksumIEEE([]byte(secret)) + actualChecksum, err := base62.Decode(checksumStr) + require.NoError(t, err) + assert.Equal(t, expectedChecksum, actualChecksum) +} + +func TestGenerateInviteToken_Uniqueness(t *testing.T) { + tokens := make(map[string]bool) + for i := 0; i < 100; i++ { + _, plainToken, err := GenerateInviteToken() + require.NoError(t, err) + assert.False(t, tokens[plainToken], "Token should be unique") + tokens[plainToken] = true + } +} + +func TestHashInviteToken(t *testing.T) { + token := "nbi_testtoken123456789012345678901234" + hashedToken := HashInviteToken(token) + + expectedHash := sha256.Sum256([]byte(token)) + expectedHashedToken := b64.StdEncoding.EncodeToString(expectedHash[:]) + assert.Equal(t, expectedHashedToken, hashedToken) +} + +func TestHashInviteToken_Consistency(t *testing.T) { + token := "nbi_testtoken123456789012345678901234" + hash1 := HashInviteToken(token) + hash2 := HashInviteToken(token) + assert.Equal(t, hash1, hash2) +} + +func TestHashInviteToken_DifferentTokens(t *testing.T) { + token1 := "nbi_testtoken123456789012345678901234" + token2 := "nbi_testtoken123456789012345678901235" + hash1 := HashInviteToken(token1) + hash2 := HashInviteToken(token2) + assert.NotEqual(t, hash1, hash2) +} + +func TestValidateInviteToken_Success(t *testing.T) { + _, plainToken, err := GenerateInviteToken() + require.NoError(t, err) + + err = ValidateInviteToken(plainToken) + assert.NoError(t, err) +} + +func TestValidateInviteToken_InvalidLength(t *testing.T) { + testCases := []struct { + name string + token string + }{ + {"empty", ""}, + {"too short", "nbi_abc"}, + {"too long", "nbi_" + strings.Repeat("a", 50)}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateInviteToken(tc.token) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid token length") + }) + } +} + +func TestValidateInviteToken_InvalidPrefix(t *testing.T) { + // Create a token with wrong prefix but correct length + token := "xyz_" + strings.Repeat("a", 30) + "000000" + err := ValidateInviteToken(token) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid token prefix") +} + +func TestValidateInviteToken_InvalidChecksum(t *testing.T) { + // Create a token with correct format but invalid checksum + token := InviteTokenPrefix + strings.Repeat("a", InviteTokenSecretLength) + "ZZZZZZ" + err := ValidateInviteToken(token) + require.Error(t, err) + assert.Contains(t, err.Error(), "checksum") +} + +func TestValidateInviteToken_ModifiedToken(t *testing.T) { + _, plainToken, err := GenerateInviteToken() + require.NoError(t, err) + + replacement := "X" + if plainToken[5] == 'X' { + replacement = "Y" + } + modifiedToken := plainToken[:5] + replacement + plainToken[6:] + err = ValidateInviteToken(modifiedToken) + require.Error(t, err) +} + +func TestUserInviteRecord_IsExpired(t *testing.T) { + t.Run("not expired", func(t *testing.T) { + invite := &UserInviteRecord{ + ExpiresAt: time.Now().Add(time.Hour), + } + assert.False(t, invite.IsExpired()) + }) + + t.Run("expired", func(t *testing.T) { + invite := &UserInviteRecord{ + ExpiresAt: time.Now().Add(-time.Hour), + } + assert.True(t, invite.IsExpired()) + }) + + t.Run("just expired", func(t *testing.T) { + invite := &UserInviteRecord{ + ExpiresAt: time.Now().Add(-time.Second), + } + assert.True(t, invite.IsExpired()) + }) +} + +func TestNewInviteID(t *testing.T) { + id := NewInviteID() + assert.NotEmpty(t, id) + assert.Len(t, id, 20) // xid generates 20 character IDs +} + +func TestNewInviteID_Uniqueness(t *testing.T) { + ids := make(map[string]bool) + for i := 0; i < 100; i++ { + id := NewInviteID() + assert.False(t, ids[id], "ID should be unique") + ids[id] = true + } +} + +func TestUserInviteRecord_EncryptDecryptSensitiveData(t *testing.T) { + key, err := crypt.GenerateKey() + require.NoError(t, err) + fieldEncrypt, err := crypt.NewFieldEncrypt(key) + require.NoError(t, err) + + t.Run("encrypt and decrypt", func(t *testing.T) { + invite := &UserInviteRecord{ + ID: "test-invite", + AccountID: "test-account", + Email: "test@example.com", + Name: "Test User", + Role: "user", + } + + // Encrypt + err := invite.EncryptSensitiveData(fieldEncrypt) + require.NoError(t, err) + + // Verify encrypted values are different from original + assert.NotEqual(t, "test@example.com", invite.Email) + assert.NotEqual(t, "Test User", invite.Name) + + // Decrypt + err = invite.DecryptSensitiveData(fieldEncrypt) + require.NoError(t, err) + + // Verify decrypted values match original + assert.Equal(t, "test@example.com", invite.Email) + assert.Equal(t, "Test User", invite.Name) + }) + + t.Run("encrypt empty fields", func(t *testing.T) { + invite := &UserInviteRecord{ + ID: "test-invite", + AccountID: "test-account", + Email: "", + Name: "", + Role: "user", + } + + err := invite.EncryptSensitiveData(fieldEncrypt) + require.NoError(t, err) + assert.Equal(t, "", invite.Email) + assert.Equal(t, "", invite.Name) + + err = invite.DecryptSensitiveData(fieldEncrypt) + require.NoError(t, err) + assert.Equal(t, "", invite.Email) + assert.Equal(t, "", invite.Name) + }) + + t.Run("nil encryptor", func(t *testing.T) { + invite := &UserInviteRecord{ + ID: "test-invite", + AccountID: "test-account", + Email: "test@example.com", + Name: "Test User", + Role: "user", + } + + err := invite.EncryptSensitiveData(nil) + require.NoError(t, err) + assert.Equal(t, "test@example.com", invite.Email) + assert.Equal(t, "Test User", invite.Name) + + err = invite.DecryptSensitiveData(nil) + require.NoError(t, err) + assert.Equal(t, "test@example.com", invite.Email) + assert.Equal(t, "Test User", invite.Name) + }) +} + +func TestUserInviteRecord_Copy(t *testing.T) { + now := time.Now() + expiresAt := now.Add(72 * time.Hour) + + original := &UserInviteRecord{ + ID: "invite-id", + AccountID: "account-id", + Email: "test@example.com", + Name: "Test User", + Role: "user", + AutoGroups: []string{"group1", "group2"}, + HashedToken: "hashed-token", + ExpiresAt: expiresAt, + CreatedAt: now, + CreatedBy: "creator-id", + } + + copied := original.Copy() + + // Verify all fields are copied + assert.Equal(t, original.ID, copied.ID) + assert.Equal(t, original.AccountID, copied.AccountID) + assert.Equal(t, original.Email, copied.Email) + assert.Equal(t, original.Name, copied.Name) + assert.Equal(t, original.Role, copied.Role) + assert.Equal(t, original.AutoGroups, copied.AutoGroups) + assert.Equal(t, original.HashedToken, copied.HashedToken) + assert.Equal(t, original.ExpiresAt, copied.ExpiresAt) + assert.Equal(t, original.CreatedAt, copied.CreatedAt) + assert.Equal(t, original.CreatedBy, copied.CreatedBy) + + // Verify deep copy of AutoGroups (modifying copy doesn't affect original) + copied.AutoGroups[0] = "modified" + assert.NotEqual(t, original.AutoGroups[0], copied.AutoGroups[0]) + assert.Equal(t, "group1", original.AutoGroups[0]) +} + +func TestUserInviteRecord_Copy_EmptyAutoGroups(t *testing.T) { + original := &UserInviteRecord{ + ID: "invite-id", + AccountID: "account-id", + AutoGroups: []string{}, + } + + copied := original.Copy() + assert.NotNil(t, copied.AutoGroups) + assert.Len(t, copied.AutoGroups, 0) +} + +func TestUserInviteRecord_Copy_NilAutoGroups(t *testing.T) { + original := &UserInviteRecord{ + ID: "invite-id", + AccountID: "account-id", + AutoGroups: nil, + } + + copied := original.Copy() + assert.NotNil(t, copied.AutoGroups) + assert.Len(t, copied.AutoGroups, 0) +} + +func TestInviteTokenConstants(t *testing.T) { + // Verify constants are consistent + expectedLength := len(InviteTokenPrefix) + InviteTokenSecretLength + InviteTokenChecksumLength + assert.Equal(t, InviteTokenLength, expectedLength) + assert.Equal(t, 4, len(InviteTokenPrefix)) + assert.Equal(t, 30, InviteTokenSecretLength) + assert.Equal(t, 6, InviteTokenChecksumLength) + assert.Equal(t, 40, InviteTokenLength) + assert.Equal(t, 259200, DefaultInviteExpirationSeconds) // 72 hours + assert.Equal(t, 3600, MinInviteExpirationSeconds) // 1 hour +} + +func TestGenerateInviteToken_ValidatesOwnOutput(t *testing.T) { + // Generate multiple tokens and ensure they all validate + for i := 0; i < 50; i++ { + _, plainToken, err := GenerateInviteToken() + require.NoError(t, err) + + err = ValidateInviteToken(plainToken) + assert.NoError(t, err, "Generated token should always be valid") + } +} + +func TestHashInviteToken_MatchesGeneratedHash(t *testing.T) { + hashedToken, plainToken, err := GenerateInviteToken() + require.NoError(t, err) + + // HashInviteToken should produce the same hash as GenerateInviteToken + rehashedToken := HashInviteToken(plainToken) + assert.Equal(t, hashedToken, rehashedToken) +} diff --git a/management/server/types/user_test.go b/management/server/types/user_test.go new file mode 100644 index 000000000..e11df96aa --- /dev/null +++ b/management/server/types/user_test.go @@ -0,0 +1,298 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/util/crypt" +) + +func TestUser_EncryptSensitiveData(t *testing.T) { + key, err := crypt.GenerateKey() + require.NoError(t, err) + + fieldEncrypt, err := crypt.NewFieldEncrypt(key) + require.NoError(t, err) + + t.Run("encrypt email and name", func(t *testing.T) { + user := &User{ + Id: "user-1", + Email: "test@example.com", + Name: "Test User", + } + + err := user.EncryptSensitiveData(fieldEncrypt) + require.NoError(t, err) + + assert.NotEqual(t, "test@example.com", user.Email, "email should be encrypted") + assert.NotEqual(t, "Test User", user.Name, "name should be encrypted") + assert.NotEmpty(t, user.Email, "encrypted email should not be empty") + assert.NotEmpty(t, user.Name, "encrypted name should not be empty") + }) + + t.Run("encrypt empty email and name", func(t *testing.T) { + user := &User{ + Id: "user-2", + Email: "", + Name: "", + } + + err := user.EncryptSensitiveData(fieldEncrypt) + require.NoError(t, err) + + assert.Equal(t, "", user.Email, "empty email should remain empty") + assert.Equal(t, "", user.Name, "empty name should remain empty") + }) + + t.Run("encrypt only email", func(t *testing.T) { + user := &User{ + Id: "user-3", + Email: "test@example.com", + Name: "", + } + + err := user.EncryptSensitiveData(fieldEncrypt) + require.NoError(t, err) + + assert.NotEqual(t, "test@example.com", user.Email, "email should be encrypted") + assert.NotEmpty(t, user.Email, "encrypted email should not be empty") + assert.Equal(t, "", user.Name, "empty name should remain empty") + }) + + t.Run("encrypt only name", func(t *testing.T) { + user := &User{ + Id: "user-4", + Email: "", + Name: "Test User", + } + + err := user.EncryptSensitiveData(fieldEncrypt) + require.NoError(t, err) + + assert.Equal(t, "", user.Email, "empty email should remain empty") + assert.NotEqual(t, "Test User", user.Name, "name should be encrypted") + assert.NotEmpty(t, user.Name, "encrypted name should not be empty") + }) + + t.Run("nil encryptor returns no error", func(t *testing.T) { + user := &User{ + Id: "user-5", + Email: "test@example.com", + Name: "Test User", + } + + err := user.EncryptSensitiveData(nil) + require.NoError(t, err) + + assert.Equal(t, "test@example.com", user.Email, "email should remain unchanged with nil encryptor") + assert.Equal(t, "Test User", user.Name, "name should remain unchanged with nil encryptor") + }) +} + +func TestUser_DecryptSensitiveData(t *testing.T) { + key, err := crypt.GenerateKey() + require.NoError(t, err) + + fieldEncrypt, err := crypt.NewFieldEncrypt(key) + require.NoError(t, err) + + t.Run("decrypt email and name", func(t *testing.T) { + originalEmail := "test@example.com" + originalName := "Test User" + + user := &User{ + Id: "user-1", + Email: originalEmail, + Name: originalName, + } + + err := user.EncryptSensitiveData(fieldEncrypt) + require.NoError(t, err) + + err = user.DecryptSensitiveData(fieldEncrypt) + require.NoError(t, err) + + assert.Equal(t, originalEmail, user.Email, "decrypted email should match original") + assert.Equal(t, originalName, user.Name, "decrypted name should match original") + }) + + t.Run("decrypt empty email and name", func(t *testing.T) { + user := &User{ + Id: "user-2", + Email: "", + Name: "", + } + + err := user.DecryptSensitiveData(fieldEncrypt) + require.NoError(t, err) + + assert.Equal(t, "", user.Email, "empty email should remain empty") + assert.Equal(t, "", user.Name, "empty name should remain empty") + }) + + t.Run("decrypt only email", func(t *testing.T) { + originalEmail := "test@example.com" + + user := &User{ + Id: "user-3", + Email: originalEmail, + Name: "", + } + + err := user.EncryptSensitiveData(fieldEncrypt) + require.NoError(t, err) + + err = user.DecryptSensitiveData(fieldEncrypt) + require.NoError(t, err) + + assert.Equal(t, originalEmail, user.Email, "decrypted email should match original") + assert.Equal(t, "", user.Name, "empty name should remain empty") + }) + + t.Run("decrypt only name", func(t *testing.T) { + originalName := "Test User" + + user := &User{ + Id: "user-4", + Email: "", + Name: originalName, + } + + err := user.EncryptSensitiveData(fieldEncrypt) + require.NoError(t, err) + + err = user.DecryptSensitiveData(fieldEncrypt) + require.NoError(t, err) + + assert.Equal(t, "", user.Email, "empty email should remain empty") + assert.Equal(t, originalName, user.Name, "decrypted name should match original") + }) + + t.Run("nil encryptor returns no error", func(t *testing.T) { + user := &User{ + Id: "user-5", + Email: "test@example.com", + Name: "Test User", + } + + err := user.DecryptSensitiveData(nil) + require.NoError(t, err) + + assert.Equal(t, "test@example.com", user.Email, "email should remain unchanged with nil encryptor") + assert.Equal(t, "Test User", user.Name, "name should remain unchanged with nil encryptor") + }) + + t.Run("decrypt with invalid ciphertext returns error", func(t *testing.T) { + user := &User{ + Id: "user-6", + Email: "not-valid-base64-ciphertext!!!", + Name: "Test User", + } + + err := user.DecryptSensitiveData(fieldEncrypt) + require.Error(t, err) + assert.Contains(t, err.Error(), "decrypt email") + }) + + t.Run("decrypt with wrong key returns error", func(t *testing.T) { + originalEmail := "test@example.com" + originalName := "Test User" + + user := &User{ + Id: "user-7", + Email: originalEmail, + Name: originalName, + } + + err := user.EncryptSensitiveData(fieldEncrypt) + require.NoError(t, err) + + differentKey, err := crypt.GenerateKey() + require.NoError(t, err) + + differentEncrypt, err := crypt.NewFieldEncrypt(differentKey) + require.NoError(t, err) + + err = user.DecryptSensitiveData(differentEncrypt) + require.Error(t, err) + assert.Contains(t, err.Error(), "decrypt email") + }) +} + +func TestUser_EncryptDecryptRoundTrip(t *testing.T) { + key, err := crypt.GenerateKey() + require.NoError(t, err) + + fieldEncrypt, err := crypt.NewFieldEncrypt(key) + require.NoError(t, err) + + testCases := []struct { + name string + email string + uname string + }{ + { + name: "standard email and name", + email: "user@example.com", + uname: "John Doe", + }, + { + name: "email with special characters", + email: "user+tag@sub.example.com", + uname: "O'Brien, Mary-Jane", + }, + { + name: "unicode characters", + email: "user@example.com", + uname: "Jean-Pierre Müller 日本語", + }, + { + name: "long values", + email: "very.long.email.address.that.is.quite.extended@subdomain.example.organization.com", + uname: "A Very Long Name That Contains Many Words And Is Quite Extended For Testing Purposes", + }, + { + name: "empty email only", + email: "", + uname: "Name Only", + }, + { + name: "empty name only", + email: "email@only.com", + uname: "", + }, + { + name: "both empty", + email: "", + uname: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + user := &User{ + Id: "test-user", + Email: tc.email, + Name: tc.uname, + } + + err := user.EncryptSensitiveData(fieldEncrypt) + require.NoError(t, err) + + if tc.email != "" { + assert.NotEqual(t, tc.email, user.Email, "email should be encrypted") + } + if tc.uname != "" { + assert.NotEqual(t, tc.uname, user.Name, "name should be encrypted") + } + + err = user.DecryptSensitiveData(fieldEncrypt) + require.NoError(t, err) + + assert.Equal(t, tc.email, user.Email, "decrypted email should match original") + assert.Equal(t, tc.uname, user.Name, "decrypted name should match original") + }) + } +} diff --git a/management/server/user.go b/management/server/user.go index be4e491a8..1de63c302 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -4,15 +4,21 @@ import ( "context" "errors" "fmt" + "slices" "strings" "time" + "unicode" + + nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/shared/auth" "github.com/google/uuid" log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/idp/dex" + "github.com/netbirdio/netbird/management/server/account" "github.com/netbirdio/netbird/management/server/activity" - nbContext "github.com/netbirdio/netbird/management/server/context" - nbcontext "github.com/netbirdio/netbird/management/server/context" + "github.com/netbirdio/netbird/management/server/affectedpeers" "github.com/netbirdio/netbird/management/server/idp" nbpeer "github.com/netbirdio/netbird/management/server/peer" "github.com/netbirdio/netbird/management/server/permissions/modules" @@ -26,7 +32,7 @@ import ( // createServiceUser creates a new service user under the given account. func (am *DefaultAccountManager) createServiceUser(ctx context.Context, accountID string, initiatorUserID string, role types.UserRole, serviceUserName string, nonDeletable bool, autoGroups []string) (*types.UserInfo, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -39,7 +45,7 @@ func (am *DefaultAccountManager) createServiceUser(ctx context.Context, accountI } newUserID := uuid.New().String() - newUser := types.NewUser(newUserID, role, true, nonDeletable, serviceUserName, autoGroups, types.UserIssuedAPI) + newUser := types.NewUser(newUserID, role, true, nonDeletable, serviceUserName, autoGroups, types.UserIssuedAPI, "", "") newUser.AccountID = accountID log.WithContext(ctx).Debugf("New User: %v", newUser) @@ -81,7 +87,7 @@ func (am *DefaultAccountManager) inviteNewUser(ctx context.Context, accountID, u return nil, err } - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Users, operations.Create) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Users, operations.Create) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -103,7 +109,12 @@ func (am *DefaultAccountManager) inviteNewUser(ctx context.Context, accountID, u inviterID = createdBy } - idpUser, err := am.createNewIdpUser(ctx, accountID, inviterID, invite) + var idpUser *idp.UserData + if IsEmbeddedIdp(am.idpManager) { + idpUser, err = am.createEmbeddedIdpUser(ctx, accountID, inviterID, invite) + } else { + idpUser, err = am.createNewIdpUser(ctx, accountID, inviterID, invite) + } if err != nil { return nil, err } @@ -116,18 +127,26 @@ func (am *DefaultAccountManager) inviteNewUser(ctx context.Context, accountID, u Issued: invite.Issued, IntegrationReference: invite.IntegrationReference, CreatedAt: time.Now().UTC(), + Email: invite.Email, + Name: invite.Name, } if err = am.Store.SaveUser(ctx, newUser); err != nil { return nil, err } - _, err = am.refreshCache(ctx, accountID) - if err != nil { - return nil, err + if !IsEmbeddedIdp(am.idpManager) { + _, err = am.refreshCache(ctx, accountID) + if err != nil { + return nil, err + } } - am.StoreEvent(ctx, userID, newUser.Id, accountID, activity.UserInvited, nil) + eventType := activity.UserInvited + if IsEmbeddedIdp(am.idpManager) { + eventType = activity.UserCreated + } + am.StoreEvent(ctx, userID, newUser.Id, accountID, eventType, nil) return newUser.ToUserInfo(idpUser) } @@ -171,13 +190,45 @@ func (am *DefaultAccountManager) createNewIdpUser(ctx context.Context, accountID return am.idpManager.CreateUser(ctx, invite.Email, invite.Name, accountID, inviterUser.Email) } +// createEmbeddedIdpUser validates the invite and creates a new user in the embedded IdP. +// Unlike createNewIdpUser, this method fetches user data directly from the database +// since the embedded IdP usage ensures the username and email are stored locally in the User table. +func (am *DefaultAccountManager) createEmbeddedIdpUser(ctx context.Context, accountID string, inviterID string, invite *types.UserInfo) (*idp.UserData, error) { + if IsLocalAuthDisabled(ctx, am.idpManager) { + return nil, status.Errorf(status.PreconditionFailed, "local user creation is disabled - use an external identity provider") + } + + inviter, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, inviterID) + if err != nil { + return nil, fmt.Errorf("failed to get inviter user: %w", err) + } + + if inviter == nil { + return nil, status.Errorf(status.NotFound, "inviter user with ID %s doesn't exist", inviterID) + } + + // check if the user is already registered with this email => reject + existingUsers, err := am.Store.GetAccountUsers(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, err + } + + for _, user := range existingUsers { + if strings.EqualFold(user.Email, invite.Email) { + return nil, status.Errorf(status.UserAlreadyExists, "can't invite a user with an existing NetBird account") + } + } + + return am.idpManager.CreateUser(ctx, invite.Email, invite.Name, accountID, inviter.Email) +} + func (am *DefaultAccountManager) GetUserByID(ctx context.Context, id string) (*types.User, error) { return am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, id) } -// GetUser looks up a user by provided nbContext.UserAuths. +// GetUser looks up a user by provided auth.UserAuths. // Expects account to have been created already. -func (am *DefaultAccountManager) GetUserFromUserAuth(ctx context.Context, userAuth nbContext.UserAuth) (*types.User, error) { +func (am *DefaultAccountManager) GetUserFromUserAuth(ctx context.Context, userAuth auth.UserAuth) (*types.User, error) { user, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userAuth.UserId) if err != nil { return nil, err @@ -206,6 +257,37 @@ func (am *DefaultAccountManager) ListUsers(ctx context.Context, accountID string return am.Store.GetAccountUsers(ctx, store.LockingStrengthNone, accountID) } +// UpdateUserPassword updates the password for a user in the embedded IdP. +// This is only available when the embedded IdP is enabled. +// Users can only change their own password. +func (am *DefaultAccountManager) UpdateUserPassword(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error { + if !IsEmbeddedIdp(am.idpManager) { + return status.Errorf(status.PreconditionFailed, "password change is only available with embedded identity provider") + } + + if oldPassword == "" { + return status.Errorf(status.InvalidArgument, "old password is required") + } + + if newPassword == "" { + return status.Errorf(status.InvalidArgument, "new password is required") + } + + embeddedIdp, ok := am.idpManager.(*idp.EmbeddedIdPManager) + if !ok { + return status.Errorf(status.Internal, "failed to get embedded IdP manager") + } + + err := embeddedIdp.UpdateUserPassword(ctx, currentUserID, targetUserID, oldPassword, newPassword) + if err != nil { + return status.Errorf(status.InvalidArgument, "failed to update password: %v", err) + } + + am.StoreEvent(ctx, currentUserID, targetUserID, accountID, activity.UserPasswordChanged, nil) + + return nil +} + func (am *DefaultAccountManager) deleteServiceUser(ctx context.Context, accountID string, initiatorUserID string, targetUser *types.User) error { if err := am.Store.DeleteUser(ctx, accountID, targetUser.Id); err != nil { return err @@ -226,7 +308,7 @@ func (am *DefaultAccountManager) DeleteUser(ctx context.Context, accountID, init return err } - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete) if err != nil { return status.NewPermissionValidationError(err) } @@ -262,15 +344,11 @@ func (am *DefaultAccountManager) DeleteUser(ctx context.Context, accountID, init return err } - updateAccountPeers, err := am.deleteRegularUser(ctx, accountID, initiatorUserID, userInfo) + _, err = am.deleteRegularUser(ctx, accountID, initiatorUserID, userInfo) if err != nil { return err } - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) - } - return nil } @@ -280,7 +358,7 @@ func (am *DefaultAccountManager) InviteUser(ctx context.Context, accountID strin return status.Errorf(status.PreconditionFailed, "IdP manager must be enabled to send user invites") } - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create) if err != nil { return status.NewPermissionValidationError(err) } @@ -320,11 +398,11 @@ func (am *DefaultAccountManager) CreatePAT(ctx context.Context, accountID string return nil, status.Errorf(status.InvalidArgument, "token name can't be empty") } - if expiresIn < 1 || expiresIn > 365 { - return nil, status.Errorf(status.InvalidArgument, "expiration has to be between 1 and 365") + if expiresIn < account.PATMinExpireDays || expiresIn > account.PATMaxExpireDays { + return nil, status.Errorf(status.InvalidArgument, "expiration has to be between %d and %d", account.PATMinExpireDays, account.PATMaxExpireDays) } - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Create) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Create) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -342,6 +420,10 @@ func (am *DefaultAccountManager) CreatePAT(ctx context.Context, accountID string return nil, err } + if targetUser.AccountID != accountID { + return nil, status.NewPermissionDeniedError() + } + // @note this is essential to prevent non admin users with Pats create permission frpm creating one for a service user if initiatorUserID != targetUserID && !(initiatorUser.HasAdminPower() && targetUser.IsServiceUser) { return nil, status.NewAdminPermissionError() @@ -364,7 +446,7 @@ func (am *DefaultAccountManager) CreatePAT(ctx context.Context, accountID string // DeletePAT deletes a specific PAT from a user func (am *DefaultAccountManager) DeletePAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) error { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Delete) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Delete) if err != nil { return status.NewPermissionValidationError(err) } @@ -382,6 +464,10 @@ func (am *DefaultAccountManager) DeletePAT(ctx context.Context, accountID string return err } + if targetUser.AccountID != accountID { + return status.NewPermissionDeniedError() + } + if initiatorUserID != targetUserID && !(initiatorUser.HasAdminPower() && targetUser.IsServiceUser) { return status.NewAdminPermissionError() } @@ -403,7 +489,7 @@ func (am *DefaultAccountManager) DeletePAT(ctx context.Context, accountID string // GetPAT returns a specific PAT from a user func (am *DefaultAccountManager) GetPAT(ctx context.Context, accountID string, initiatorUserID string, targetUserID string, tokenID string) (*types.PersonalAccessToken, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Read) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -421,6 +507,10 @@ func (am *DefaultAccountManager) GetPAT(ctx context.Context, accountID string, i return nil, err } + if targetUser.AccountID != accountID { + return nil, status.NewPermissionDeniedError() + } + if initiatorUserID != targetUserID && !(initiatorUser.HasAdminPower() && targetUser.IsServiceUser) { return nil, status.NewAdminPermissionError() } @@ -430,7 +520,7 @@ func (am *DefaultAccountManager) GetPAT(ctx context.Context, accountID string, i // GetAllPATs returns all PATs for a user func (am *DefaultAccountManager) GetAllPATs(ctx context.Context, accountID string, initiatorUserID string, targetUserID string) ([]*types.PersonalAccessToken, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Read) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Pats, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -448,6 +538,10 @@ func (am *DefaultAccountManager) GetAllPATs(ctx context.Context, accountID strin return nil, err } + if targetUser.AccountID != accountID { + return nil, status.NewPermissionDeniedError() + } + if initiatorUserID != targetUserID && !(initiatorUser.HasAdminPower() && targetUser.IsServiceUser) { return nil, status.NewAdminPermissionError() } @@ -483,7 +577,7 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID, return nil, nil //nolint:nilnil } - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create) // TODO: split by Create and Update + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create) // TODO: split by Create and Update if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -517,6 +611,11 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID, return nil, err } initiatorUser = result + role, ok := nbcontext.RoleFromContext(ctx) + if !ok { + return nil, status.Errorf(status.Internal, "failed to get user role from context") + } + initiatorUser.Role = types.UserRole(role) } var globalErr error @@ -526,16 +625,14 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID, } err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { - userHadPeers, updatedUser, userPeersToExpire, userEvents, err := am.processUserUpdate( + _, updatedUser, userPeersToExpire, userEvents, err := am.processUserUpdate( ctx, transaction, groupsMap, accountID, initiatorUserID, initiatorUser, update, addIfNotExists, settings, ) if err != nil { return fmt.Errorf("failed to process update for user %s: %w", update.Id, err) } - if userHadPeers { - updateAccountPeers = true - } + updateAccountPeers = true err = transaction.SaveUser(ctx, updatedUser) if err != nil { @@ -578,26 +675,30 @@ func (am *DefaultAccountManager) SaveOrAddUsers(ctx context.Context, accountID, } if len(peersToExpire) > 0 { - if err := am.expireAndUpdatePeers(ctx, accountID, peersToExpire); err != nil { + if err := am.expireAndUpdatePeers(ctx, accountID, peersToExpire, peerExpirationUserBlocked); err != nil { log.WithContext(ctx).Errorf("failed update expired peers: %s", err) return nil, err } - } - - if settings.GroupsPropagationEnabled && updateAccountPeers { + } else if updateAccountPeers { if err = am.Store.IncrementNetworkSerial(ctx, accountID); err != nil { return nil, fmt.Errorf("failed to increment network serial: %w", err) } - am.UpdateAccountPeers(ctx, accountID) + am.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceUser, Operation: types.UpdateOperationUpdate}) } return updatedUsersInfo, globalErr } // prepareUserUpdateEvents prepares a list user update events based on the changes between the old and new user data. -func (am *DefaultAccountManager) prepareUserUpdateEvents(ctx context.Context, accountID string, initiatorUserID string, oldUser, newUser *types.User, transferredOwnerRole bool, removedGroupIDs, addedGroupIDs []string, tx store.Store) []func() { +func (am *DefaultAccountManager) prepareUserUpdateEvents(ctx context.Context, accountID string, initiatorUserID string, oldUser, newUser *types.User, transferredOwnerRole bool, isNewUser bool, removedGroupIDs, addedGroupIDs []string, tx store.Store) []func() { var eventsToStore []func() + if isNewUser { + eventsToStore = append(eventsToStore, func() { + am.StoreEvent(ctx, initiatorUserID, newUser.Id, accountID, activity.UserCreated, nil) + }) + } + if oldUser.IsBlocked() != newUser.IsBlocked() { if newUser.IsBlocked() { eventsToStore = append(eventsToStore, func() { @@ -632,7 +733,7 @@ func (am *DefaultAccountManager) prepareUserUpdateEvents(ctx context.Context, ac "is_service_user": oldUser.IsServiceUser, "user_name": oldUser.ServiceUserName, } eventsToStore = append(eventsToStore, func() { - am.StoreEvent(ctx, oldUser.Id, oldUser.Id, accountID, activity.GroupAddedToUser, meta) + am.StoreEvent(ctx, initiatorUserID, oldUser.Id, accountID, activity.GroupAddedToUser, meta) }) } @@ -646,7 +747,7 @@ func (am *DefaultAccountManager) prepareUserUpdateEvents(ctx context.Context, ac "is_service_user": oldUser.IsServiceUser, "user_name": oldUser.ServiceUserName, } eventsToStore = append(eventsToStore, func() { - am.StoreEvent(ctx, oldUser.Id, oldUser.Id, accountID, activity.GroupRemovedFromUser, meta) + am.StoreEvent(ctx, initiatorUserID, oldUser.Id, accountID, activity.GroupRemovedFromUser, meta) }) } @@ -660,7 +761,7 @@ func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transact return false, nil, nil, nil, status.Errorf(status.InvalidArgument, "provided user update is nil") } - oldUser, err := getUserOrCreateIfNotExists(ctx, transaction, accountID, update, addIfNotExists) + oldUser, isNewUser, err := getUserOrCreateIfNotExists(ctx, transaction, accountID, update, addIfNotExists) if err != nil { return false, nil, nil, nil, err } @@ -674,9 +775,15 @@ func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transact updatedUser.Role = update.Role updatedUser.Blocked = update.Blocked updatedUser.AutoGroups = update.AutoGroups - // these two fields can't be set via API, only via direct call to the method + // these fields can't be set via API, only via direct call to the method updatedUser.Issued = update.Issued updatedUser.IntegrationReference = update.IntegrationReference + if update.Name != "" { + updatedUser.Name = update.Name + } + if update.Email != "" { + updatedUser.Email = update.Email + } var transferredOwnerRole bool result, err := handleOwnerRoleTransfer(ctx, transaction, initiatorUser, update) @@ -712,33 +819,38 @@ func (am *DefaultAccountManager) processUserUpdate(ctx context.Context, transact } } } + + allGroupChanges := slices.Concat(removedGroups, addedGroups) + if err := am.reconcileIPv6ForGroupChanges(ctx, transaction, accountID, allGroupChanges); err != nil { + return false, nil, nil, nil, fmt.Errorf("reconcile IPv6 for group changes: %w", err) + } } updateAccountPeers := len(userPeers) > 0 - userEventsToAdd := am.prepareUserUpdateEvents(ctx, updatedUser.AccountID, initiatorUserId, oldUser, updatedUser, transferredOwnerRole, removedGroups, addedGroups, transaction) + userEventsToAdd := am.prepareUserUpdateEvents(ctx, updatedUser.AccountID, initiatorUserId, oldUser, updatedUser, transferredOwnerRole, isNewUser, removedGroups, addedGroups, transaction) return updateAccountPeers, updatedUser, peersToExpire, userEventsToAdd, nil } // getUserOrCreateIfNotExists retrieves the existing user or creates a new one if it doesn't exist. -func getUserOrCreateIfNotExists(ctx context.Context, transaction store.Store, accountID string, update *types.User, addIfNotExists bool) (*types.User, error) { +func getUserOrCreateIfNotExists(ctx context.Context, transaction store.Store, accountID string, update *types.User, addIfNotExists bool) (*types.User, bool, error) { existingUser, err := transaction.GetUserByUserID(ctx, store.LockingStrengthNone, update.Id) if err != nil { if sErr, ok := status.FromError(err); ok && sErr.Type() == status.NotFound { if !addIfNotExists { - return nil, status.Errorf(status.NotFound, "user to update doesn't exist: %s", update.Id) + return nil, false, status.Errorf(status.NotFound, "user to update doesn't exist: %s", update.Id) } update.AccountID = accountID - return update, nil // use all fields from update if addIfNotExists is true + return update, true, nil // use all fields from update if addIfNotExists is true } - return nil, err + return nil, false, err } if existingUser.AccountID != accountID { - return nil, status.Errorf(status.InvalidArgument, "user account ID mismatch") + return nil, false, status.Errorf(status.InvalidArgument, "user account ID mismatch") } - return existingUser, nil + return existingUser, false, nil } func handleOwnerRoleTransfer(ctx context.Context, transaction store.Store, initiatorUser, update *types.User) (bool, error) { @@ -758,14 +870,27 @@ func handleOwnerRoleTransfer(ctx context.Context, transaction store.Store, initi // If the AccountManager has a non-nil idpManager and the User is not a service user, // it will attempt to look up the UserData from the cache. func (am *DefaultAccountManager) getUserInfo(ctx context.Context, user *types.User, accountID string) (*types.UserInfo, error) { - if !isNil(am.idpManager) && !user.IsServiceUser { + if !isNil(am.idpManager) && !user.IsServiceUser && !IsEmbeddedIdp(am.idpManager) { userData, err := am.lookupUserInCache(ctx, user.Id, accountID) if err != nil { return nil, err } return user.ToUserInfo(userData) } - return user.ToUserInfo(nil) + + userInfo, err := user.ToUserInfo(nil) + if err != nil { + return nil, err + } + + // For embedded IDP users, extract the IdPID (connector ID) from the encoded user ID + if IsEmbeddedIdp(am.idpManager) && !user.IsServiceUser { + if _, connectorID, decodeErr := dex.DecodeDexUserID(user.Id); decodeErr == nil && connectorID != "" { + userInfo.IdPID = connectorID + } + } + + return userInfo, nil } // validateUserUpdate validates the update operation for a user. @@ -774,20 +899,23 @@ func validateUserUpdate(groupsMap map[string]*types.Group, initiatorUser, oldUse return nil } - // @todo double check these + if !initiatorUser.HasAdminPower() { + return status.Errorf(status.PermissionDenied, "only admins and owners can update users") + } + if initiatorUser.HasAdminPower() && initiatorUser.Id == update.Id && oldUser.Blocked != update.Blocked { return status.Errorf(status.PermissionDenied, "admins can't block or unblock themselves") } if initiatorUser.HasAdminPower() && initiatorUser.Id == update.Id && update.Role != initiatorUser.Role { return status.Errorf(status.PermissionDenied, "admins can't change their role") } - if initiatorUser.Role == types.UserRoleAdmin && oldUser.Role == types.UserRoleOwner && update.Role != oldUser.Role { + if initiatorUser.Role != types.UserRoleOwner && oldUser.Role == types.UserRoleOwner && update.Role != oldUser.Role { return status.Errorf(status.PermissionDenied, "only owners can remove owner role from their user") } - if initiatorUser.Role == types.UserRoleAdmin && oldUser.Role == types.UserRoleOwner && update.IsBlocked() && !oldUser.IsBlocked() { + if oldUser.Role == types.UserRoleOwner && update.IsBlocked() && !oldUser.IsBlocked() { return status.Errorf(status.PermissionDenied, "unable to block owner user") } - if initiatorUser.Role == types.UserRoleAdmin && update.Role == types.UserRoleOwner && update.Role != oldUser.Role { + if initiatorUser.Role != types.UserRoleOwner && update.Role == types.UserRoleOwner && update.Role != oldUser.Role { return status.Errorf(status.PermissionDenied, "only owners can add owner role to other users") } if oldUser.IsServiceUser && update.Role == types.UserRoleOwner { @@ -809,7 +937,10 @@ func validateUserUpdate(groupsMap map[string]*types.Group, initiatorUser, oldUse } // GetOrCreateAccountByUser returns an existing account for a given user id or creates a new one if doesn't exist -func (am *DefaultAccountManager) GetOrCreateAccountByUser(ctx context.Context, userID, domain string) (*types.Account, error) { +func (am *DefaultAccountManager) GetOrCreateAccountByUser(ctx context.Context, userAuth auth.UserAuth) (*types.Account, error) { + userID := userAuth.UserId + domain := userAuth.Domain + start := time.Now() unlock := am.Store.AcquireGlobalLock(ctx) defer unlock() @@ -820,7 +951,7 @@ func (am *DefaultAccountManager) GetOrCreateAccountByUser(ctx context.Context, u account, err := am.Store.GetAccountByUser(ctx, userID) if err != nil { if s, ok := status.FromError(err); ok && s.Type() == status.NotFound { - account, err = am.newAccount(ctx, userID, lowerDomain) + account, err = am.newAccount(ctx, userID, lowerDomain, userAuth.Email, userAuth.Name) if err != nil { return nil, err } @@ -850,7 +981,7 @@ func (am *DefaultAccountManager) GetOrCreateAccountByUser(ctx context.Context, u // GetUsersFromAccount performs a batched request for users from IDP by account ID apply filter on what data to return // based on provided user role. func (am *DefaultAccountManager) GetUsersFromAccount(ctx context.Context, accountID, initiatorUserID string) (map[string]*types.UserInfo, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Read) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Read) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -867,10 +998,12 @@ func (am *DefaultAccountManager) GetUsersFromAccount(ctx context.Context, accoun accountUsers := []*types.User{} switch { case allowed: + start := time.Now() accountUsers, err = am.Store.GetAccountUsers(ctx, store.LockingStrengthNone, accountID) if err != nil { return nil, err } + log.WithContext(ctx).Tracef("Got %d users from account %s after %s", len(accountUsers), accountID, time.Since(start)) case user != nil && user.AccountID == accountID: accountUsers = append(accountUsers, user) default: @@ -885,31 +1018,49 @@ func (am *DefaultAccountManager) BuildUserInfosForAccount(ctx context.Context, a var queriedUsers []*idp.UserData var err error - if !isNil(am.idpManager) { + // embedded IdP ensures that we have user data (email and name) stored in the database. + if !isNil(am.idpManager) && !IsEmbeddedIdp(am.idpManager) { users := make(map[string]userLoggedInOnce, len(accountUsers)) usersFromIntegration := make([]*idp.UserData, 0) + filtered := make(map[string]*idp.UserData, len(accountUsers)) + log.WithContext(ctx).Tracef("Querying users from IDP for account %s", accountID) + start := time.Now() + + integrationKeys := make(map[string]struct{}) for _, user := range accountUsers { if user.Issued == types.UserIssuedIntegration { - key := user.IntegrationReference.CacheKey(accountID, user.Id) - info, err := am.externalCacheManager.Get(am.ctx, key) - if err != nil { - log.WithContext(ctx).Infof("Get ExternalCache for key: %s, error: %s", key, err) - users[user.Id] = true - continue - } - usersFromIntegration = append(usersFromIntegration, info) + integrationKeys[user.IntegrationReference.CacheKey(accountID)] = struct{}{} continue } if !user.IsServiceUser { users[user.Id] = userLoggedInOnce(!user.GetLastLogin().IsZero()) } } + + for key := range integrationKeys { + usersData, err := am.externalCacheManager.GetUsers(am.ctx, key) + if err != nil { + log.WithContext(ctx).Debugf("GetUsers from ExternalCache for key: %s, error: %s", key, err) + continue + } + for _, ud := range usersData { + filtered[ud.ID] = ud + } + } + + for _, ud := range filtered { + usersFromIntegration = append(usersFromIntegration, ud) + } + + log.WithContext(ctx).Tracef("Got user info from external cache after %s", time.Since(start)) + start = time.Now() queriedUsers, err = am.lookupCache(ctx, users, accountID) + log.WithContext(ctx).Tracef("Got user info from cache for %d users after %s", len(queriedUsers), time.Since(start)) if err != nil { return nil, err } - log.WithContext(ctx).Debugf("Got %d users from ExternalCache for account %s", len(usersFromIntegration), accountID) - log.WithContext(ctx).Debugf("Got %d users from InternalCache for account %s", len(queriedUsers), accountID) + log.WithContext(ctx).Tracef("Got %d users from ExternalCache for account %s", len(usersFromIntegration), accountID) + log.WithContext(ctx).Tracef("Got %d users from InternalCache for account %s", len(queriedUsers), accountID) queriedUsers = append(queriedUsers, usersFromIntegration...) } @@ -922,6 +1073,10 @@ func (am *DefaultAccountManager) BuildUserInfosForAccount(ctx context.Context, a if err != nil { return nil, err } + // Try to decode Dex user ID to extract the IdP ID (connector ID) + if _, connectorID, decodeErr := dex.DecodeDexUserID(accountUser.Id); decodeErr == nil && connectorID != "" { + info.IdPID = connectorID + } userInfosMap[accountUser.Id] = info } @@ -943,7 +1098,7 @@ func (am *DefaultAccountManager) BuildUserInfosForAccount(ctx context.Context, a info = &types.UserInfo{ ID: localUser.Id, - Email: "", + Email: localUser.Email, Name: name, Role: string(localUser.Role), AutoGroups: localUser.AutoGroups, @@ -952,6 +1107,10 @@ func (am *DefaultAccountManager) BuildUserInfosForAccount(ctx context.Context, a NonDeletable: localUser.NonDeletable, } } + // Try to decode Dex user ID to extract the IdP ID (connector ID) + if _, connectorID, decodeErr := dex.DecodeDexUserID(localUser.Id); decodeErr == nil && connectorID != "" { + info.IdPID = connectorID + } userInfosMap[info.ID] = info } @@ -959,7 +1118,7 @@ func (am *DefaultAccountManager) BuildUserInfosForAccount(ctx context.Context, a } // expireAndUpdatePeers expires all peers of the given user and updates them in the account -func (am *DefaultAccountManager) expireAndUpdatePeers(ctx context.Context, accountID string, peers []*nbpeer.Peer) error { +func (am *DefaultAccountManager) expireAndUpdatePeers(ctx context.Context, accountID string, peers []*nbpeer.Peer, reason peerExpirationReason) error { log.WithContext(ctx).Debugf("Expiring %d peers for account %s", len(peers), accountID) settings, err := am.Store.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) if err != nil { @@ -970,7 +1129,7 @@ func (am *DefaultAccountManager) expireAndUpdatePeers(ctx context.Context, accou var peerIDs []string for _, peer := range peers { // nolint:staticcheck - ctx = context.WithValue(ctx, nbContext.PeerIDKey, peer.Key) + ctx = context.WithValue(ctx, nbcontext.PeerIDKey, peer.Key) if peer.UserID == "" { // we do not want to expire peers that are added via setup key @@ -986,19 +1145,31 @@ func (am *DefaultAccountManager) expireAndUpdatePeers(ctx context.Context, accou if err := am.Store.SavePeerStatus(ctx, accountID, peer.ID, *peer.Status); err != nil { return err } + meta := peer.EventMeta(dnsDomain) + meta["reason"] = string(reason) am.StoreEvent( ctx, peer.UserID, peer.ID, accountID, - activity.PeerLoginExpired, peer.EventMeta(dnsDomain), + activity.PeerLoginExpired, meta, ) + } - am.networkMapController.OnPeerUpdated(accountID, peer) + if len(peerIDs) != 0 { + if err := am.Store.IncrementNetworkSerial(ctx, accountID); err != nil { + return err + } + } + + affectedPeerIDs := am.resolveAffectedPeersForPeerChanges(ctx, am.Store, accountID, peerIDs) + err = am.networkMapController.OnPeersUpdated(ctx, accountID, peerIDs, affectedPeerIDs) + if err != nil { + return fmt.Errorf("notify network map controller of peer update: %w", err) } if len(peerIDs) != 0 { // this will trigger peer disconnect from the management service log.Debugf("Expiring %d peers for account %s", len(peerIDs), accountID) - am.networkMapController.DisconnectPeers(ctx, peerIDs) + am.networkMapController.DisconnectPeers(ctx, accountID, peerIDs) } return nil } @@ -1030,7 +1201,7 @@ func (am *DefaultAccountManager) deleteUserFromIDP(ctx context.Context, targetUs // If an error occurs while deleting the user, the function skips it and continues deleting other users. // Errors are collected and returned at the end. func (am *DefaultAccountManager) DeleteRegularUsers(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string, userInfos map[string]*types.UserInfo) error { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete) if err != nil { return status.NewPermissionValidationError(err) } @@ -1044,7 +1215,6 @@ func (am *DefaultAccountManager) DeleteRegularUsers(ctx context.Context, account } var allErrors error - var updateAccountPeers bool for _, targetUserID := range targetUserIDs { if initiatorUserID == targetUserID { @@ -1075,19 +1245,11 @@ func (am *DefaultAccountManager) DeleteRegularUsers(ctx context.Context, account continue } - userHadPeers, err := am.deleteRegularUser(ctx, accountID, initiatorUserID, userInfo) + _, err = am.deleteRegularUser(ctx, accountID, initiatorUserID, userInfo) if err != nil { allErrors = errors.Join(allErrors, err) continue } - - if userHadPeers { - updateAccountPeers = true - } - } - - if updateAccountPeers { - am.UpdateAccountPeers(ctx, accountID) } return allErrors @@ -1114,6 +1276,9 @@ func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountI var updateAccountPeers bool var userPeers []*nbpeer.Peer var targetUser *types.User + var settings *types.Settings + var snap *affectedpeers.Snapshot + var change affectedpeers.Change var err error err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { @@ -1122,6 +1287,11 @@ func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountI return fmt.Errorf("failed to get user to delete: %w", err) } + settings, err = transaction.GetAccountSettings(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return fmt.Errorf("failed to get account settings: %w", err) + } + userPeers, err = transaction.GetUserPeers(ctx, store.LockingStrengthNone, accountID, targetUserInfo.ID) if err != nil { return fmt.Errorf("failed to get user peers: %w", err) @@ -1129,7 +1299,19 @@ func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountI if len(userPeers) > 0 { updateAccountPeers = true - addPeerRemovedEvents, err = deletePeers(ctx, am, transaction, accountID, targetUserInfo.ID, userPeers) + + var peerIDs []string + for _, peer := range userPeers { + peerIDs = append(peerIDs, peer.ID) + } + // Load before delete so the snapshot still has the peers' group + // memberships; the resolver derives them from the peer IDs during the walk. + change = affectedpeers.Change{ChangedPeerIDs: peerIDs} + if snap, err = affectedpeers.Load(ctx, transaction, accountID, change); err != nil { + return err + } + + addPeerRemovedEvents, err = deletePeers(ctx, am, transaction, accountID, targetUserInfo.ID, userPeers, settings) if err != nil { return fmt.Errorf("failed to delete user peers: %w", err) } @@ -1145,21 +1327,23 @@ func (am *DefaultAccountManager) deleteRegularUser(ctx context.Context, accountI return false, err } + var peerIDs []string for _, peer := range userPeers { - err = am.networkMapController.DeletePeer(ctx, accountID, peer.ID) - if err != nil { - log.WithContext(ctx).Errorf("failed to delete peer %s from network map: %v", peer.ID, err) - } - - if err := am.networkMapController.OnPeerDeleted(ctx, accountID, peer.ID); err != nil { - log.WithContext(ctx).Errorf("failed to update network map cache for peer %s: %v", peer.ID, err) + peerIDs = append(peerIDs, peer.ID) + if err = am.integratedPeerValidator.PeerDeleted(ctx, accountID, peer.ID, settings.Extra); err != nil { + log.WithContext(ctx).Errorf("failed to delete peer %s from integrated validator: %v", peer.ID, err) } } + affectedPeerIDs := snap.Expand(ctx, accountID, change) + if err := am.networkMapController.OnPeersDeleted(ctx, accountID, peerIDs, affectedPeerIDs); err != nil { + log.WithContext(ctx).Errorf("failed to delete peers %s from network map: %v", peerIDs, err) + } for _, addPeerRemovedEvent := range addPeerRemovedEvents { addPeerRemovedEvent() } - meta := map[string]any{"name": targetUserInfo.Name, "email": targetUserInfo.Email, "created_at": targetUser.CreatedAt} + + meta := map[string]any{"name": targetUserInfo.Name, "email": targetUserInfo.Email, "created_at": targetUser.CreatedAt, "issued": targetUser.Issued} am.StoreEvent(ctx, initiatorUserID, targetUser.Id, accountID, activity.UserDeleted, meta) return updateAccountPeers, nil @@ -1214,7 +1398,7 @@ func validateUserInvite(invite *types.UserInfo) error { } // GetCurrentUserInfo retrieves the account's current user info and permissions -func (am *DefaultAccountManager) GetCurrentUserInfo(ctx context.Context, userAuth nbcontext.UserAuth) (*users.UserInfoWithPermissions, error) { +func (am *DefaultAccountManager) GetCurrentUserInfo(ctx context.Context, userAuth auth.UserAuth) (*users.UserInfoWithPermissions, error) { accountID, userID := userAuth.AccountId, userAuth.UserId user, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) @@ -1230,7 +1414,8 @@ func (am *DefaultAccountManager) GetCurrentUserInfo(ctx context.Context, userAut return nil, status.NewPermissionDeniedError() } - if err := am.permissionsManager.ValidateAccountAccess(ctx, accountID, user, false); err != nil { + ctx, err = am.permissionsManager.ValidateAccountAccess(ctx, accountID, user, false) + if err != nil { return nil, err } @@ -1259,7 +1444,7 @@ func (am *DefaultAccountManager) GetCurrentUserInfo(ctx context.Context, userAut // ApproveUser approves a user that is pending approval func (am *DefaultAccountManager) ApproveUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) (*types.UserInfo, error) { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Update) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Update) if err != nil { return nil, status.NewPermissionValidationError(err) } @@ -1300,7 +1485,7 @@ func (am *DefaultAccountManager) ApproveUser(ctx context.Context, accountID, ini // RejectUser rejects a user that is pending approval by deleting them func (am *DefaultAccountManager) RejectUser(ctx context.Context, accountID, initiatorUserID, targetUserID string) error { - allowed, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete) + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete) if err != nil { return status.NewPermissionValidationError(err) } @@ -1330,3 +1515,381 @@ func (am *DefaultAccountManager) RejectUser(ctx context.Context, accountID, init return nil } + +// CreateUserInvite creates an invite link for a new user in the embedded IdP. +// The user is NOT created until the invite is accepted. +func (am *DefaultAccountManager) CreateUserInvite(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) { + if !IsEmbeddedIdp(am.idpManager) { + return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider") + } + + if IsLocalAuthDisabled(ctx, am.idpManager) { + return nil, status.Errorf(status.PreconditionFailed, "local user creation is disabled - use an external identity provider") + } + + if err := validateUserInvite(invite); err != nil { + return nil, err + } + + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Create) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !allowed { + return nil, status.NewPermissionDeniedError() + } + + // Check if user already exists in NetBird DB + existingUsers, err := am.Store.GetAccountUsers(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, err + } + for _, user := range existingUsers { + if strings.EqualFold(user.Email, invite.Email) { + return nil, status.Errorf(status.UserAlreadyExists, "user with this email already exists") + } + } + + // Check if invite already exists for this email + existingInvite, err := am.Store.GetUserInviteByEmail(ctx, store.LockingStrengthNone, accountID, invite.Email) + if err != nil { + if sErr, ok := status.FromError(err); !ok || sErr.Type() != status.NotFound { + return nil, fmt.Errorf("failed to check existing invites: %w", err) + } + } + if existingInvite != nil { + return nil, status.Errorf(status.AlreadyExists, "invite already exists for this email") + } + + // Calculate expiration time + if expiresIn <= 0 { + expiresIn = types.DefaultInviteExpirationSeconds + } + + if expiresIn < types.MinInviteExpirationSeconds { + return nil, status.Errorf(status.InvalidArgument, "invite expiration must be at least 1 hour") + } + expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second) + + // Generate invite token + inviteID := types.NewInviteID() + hashedToken, plainToken, err := types.GenerateInviteToken() + if err != nil { + return nil, fmt.Errorf("failed to generate invite token: %w", err) + } + + // Create the invite record (no user created yet) + userInvite := &types.UserInviteRecord{ + ID: inviteID, + AccountID: accountID, + Email: invite.Email, + Name: invite.Name, + Role: invite.Role, + AutoGroups: invite.AutoGroups, + HashedToken: hashedToken, + ExpiresAt: expiresAt, + CreatedAt: time.Now().UTC(), + CreatedBy: initiatorUserID, + } + + if err := am.Store.SaveUserInvite(ctx, userInvite); err != nil { + return nil, err + } + + am.StoreEvent(ctx, initiatorUserID, inviteID, accountID, activity.UserInviteLinkCreated, map[string]any{"email": invite.Email}) + + return &types.UserInvite{ + UserInfo: &types.UserInfo{ + ID: inviteID, + Email: invite.Email, + Name: invite.Name, + Role: invite.Role, + AutoGroups: invite.AutoGroups, + Status: string(types.UserStatusInvited), + Issued: types.UserIssuedAPI, + }, + InviteToken: plainToken, + InviteExpiresAt: expiresAt, + }, nil +} + +// GetUserInviteInfo retrieves invite information from a token (public endpoint). +func (am *DefaultAccountManager) GetUserInviteInfo(ctx context.Context, token string) (*types.UserInviteInfo, error) { + if err := types.ValidateInviteToken(token); err != nil { + return nil, status.Errorf(status.InvalidArgument, "invalid invite token: %v", err) + } + + hashedToken := types.HashInviteToken(token) + invite, err := am.Store.GetUserInviteByHashedToken(ctx, store.LockingStrengthNone, hashedToken) + if err != nil { + return nil, err + } + + // Get the inviter's name + invitedBy := "" + if invite.CreatedBy != "" { + inviter, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, invite.CreatedBy) + if err == nil && inviter != nil { + invitedBy = inviter.Name + } + } + + return &types.UserInviteInfo{ + Email: invite.Email, + Name: invite.Name, + ExpiresAt: invite.ExpiresAt, + Valid: !invite.IsExpired(), + InvitedBy: invitedBy, + }, nil +} + +// ListUserInvites returns all invites for an account. +func (am *DefaultAccountManager) ListUserInvites(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error) { + if !IsEmbeddedIdp(am.idpManager) { + return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider") + } + + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Read) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !allowed { + return nil, status.NewPermissionDeniedError() + } + + records, err := am.Store.GetAccountUserInvites(ctx, store.LockingStrengthNone, accountID) + if err != nil { + return nil, err + } + + invites := make([]*types.UserInvite, 0, len(records)) + for _, record := range records { + invites = append(invites, &types.UserInvite{ + UserInfo: &types.UserInfo{ + ID: record.ID, + Email: record.Email, + Name: record.Name, + Role: record.Role, + AutoGroups: record.AutoGroups, + }, + InviteExpiresAt: record.ExpiresAt, + InviteCreatedAt: record.CreatedAt, + }) + } + + return invites, nil +} + +// AcceptUserInvite accepts an invite and creates the user in both IdP and NetBird DB. +func (am *DefaultAccountManager) AcceptUserInvite(ctx context.Context, token, password string) error { + if !IsEmbeddedIdp(am.idpManager) { + return status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider") + } + + if IsLocalAuthDisabled(ctx, am.idpManager) { + return status.Errorf(status.PreconditionFailed, "local user creation is disabled - use an external identity provider") + } + + if password == "" { + return status.Errorf(status.InvalidArgument, "password is required") + } + + if err := validatePassword(password); err != nil { + return status.Errorf(status.InvalidArgument, "invalid password: %v", err) + } + + if err := types.ValidateInviteToken(token); err != nil { + return status.Errorf(status.InvalidArgument, "invalid invite token: %v", err) + } + + hashedToken := types.HashInviteToken(token) + invite, err := am.Store.GetUserInviteByHashedToken(ctx, store.LockingStrengthUpdate, hashedToken) + if err != nil { + return err + } + + if invite.IsExpired() { + return status.Errorf(status.InvalidArgument, "invite has expired") + } + + // Create user in Dex with the provided password + embeddedIdp, ok := am.idpManager.(*idp.EmbeddedIdPManager) + if !ok { + return status.Errorf(status.Internal, "failed to get embedded IdP manager") + } + + idpUser, err := embeddedIdp.CreateUserWithPassword(ctx, invite.Email, password, invite.Name) + if err != nil { + return fmt.Errorf("failed to create user in IdP: %w", err) + } + + // Create user in NetBird DB + newUser := &types.User{ + Id: idpUser.ID, + AccountID: invite.AccountID, + Role: types.StrRoleToUserRole(invite.Role), + AutoGroups: invite.AutoGroups, + Issued: types.UserIssuedAPI, + CreatedAt: time.Now().UTC(), + Email: invite.Email, + Name: invite.Name, + } + + err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + if err := transaction.SaveUser(ctx, newUser); err != nil { + return fmt.Errorf("failed to save user: %w", err) + } + if err := transaction.DeleteUserInvite(ctx, invite.ID); err != nil { + return fmt.Errorf("failed to delete invite: %w", err) + } + return nil + }) + if err != nil { + // Best-effort rollback: delete the IdP user to avoid orphaned records + if deleteErr := embeddedIdp.DeleteUser(ctx, idpUser.ID); deleteErr != nil { + log.WithContext(ctx).WithError(deleteErr).Errorf("failed to rollback IdP user %s after transaction failure", idpUser.ID) + } + return err + } + + am.StoreEvent(ctx, newUser.Id, newUser.Id, invite.AccountID, activity.UserInviteLinkAccepted, map[string]any{"email": invite.Email}) + + return nil +} + +// RegenerateUserInvite creates a new invite token for an existing invite, invalidating the previous one. +func (am *DefaultAccountManager) RegenerateUserInvite(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInvite, error) { + if !IsEmbeddedIdp(am.idpManager) { + return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider") + } + + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Update) + if err != nil { + return nil, status.NewPermissionValidationError(err) + } + if !allowed { + return nil, status.NewPermissionDeniedError() + } + + // Get existing invite + existingInvite, err := am.Store.GetUserInviteByID(ctx, store.LockingStrengthUpdate, accountID, inviteID) + if err != nil { + return nil, err + } + + // Calculate expiration time + if expiresIn <= 0 { + expiresIn = types.DefaultInviteExpirationSeconds + } + if expiresIn < types.MinInviteExpirationSeconds { + return nil, status.Errorf(status.InvalidArgument, "invite expiration must be at least 1 hour") + } + expiresAt := time.Now().UTC().Add(time.Duration(expiresIn) * time.Second) + + // Generate new invite token + hashedToken, plainToken, err := types.GenerateInviteToken() + if err != nil { + return nil, fmt.Errorf("failed to generate invite token: %w", err) + } + + // Update existing invite with new token and expiration + existingInvite.HashedToken = hashedToken + existingInvite.ExpiresAt = expiresAt + existingInvite.CreatedBy = initiatorUserID + + err = am.Store.SaveUserInvite(ctx, existingInvite) + if err != nil { + return nil, err + } + + am.StoreEvent(ctx, initiatorUserID, existingInvite.ID, accountID, activity.UserInviteLinkRegenerated, map[string]any{"email": existingInvite.Email}) + + return &types.UserInvite{ + UserInfo: &types.UserInfo{ + ID: existingInvite.ID, + Email: existingInvite.Email, + Name: existingInvite.Name, + Role: existingInvite.Role, + AutoGroups: existingInvite.AutoGroups, + Status: string(types.UserStatusInvited), + Issued: types.UserIssuedAPI, + }, + InviteToken: plainToken, + InviteExpiresAt: expiresAt, + }, nil +} + +// DeleteUserInvite deletes an existing invite by ID. +func (am *DefaultAccountManager) DeleteUserInvite(ctx context.Context, accountID, initiatorUserID, inviteID string) error { + if !IsEmbeddedIdp(am.idpManager) { + return status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider") + } + + allowed, ctx, err := am.permissionsManager.ValidateUserPermissions(ctx, accountID, initiatorUserID, modules.Users, operations.Delete) + if err != nil { + return status.NewPermissionValidationError(err) + } + if !allowed { + return status.NewPermissionDeniedError() + } + + invite, err := am.Store.GetUserInviteByID(ctx, store.LockingStrengthUpdate, accountID, inviteID) + if err != nil { + return err + } + + if err := am.Store.DeleteUserInvite(ctx, inviteID); err != nil { + return err + } + + am.StoreEvent(ctx, initiatorUserID, inviteID, accountID, activity.UserInviteLinkDeleted, map[string]any{"email": invite.Email}) + + return nil +} + +const minPasswordLength = 8 + +// validatePassword checks password strength requirements. +func validatePassword(password string) error { + return ValidatePassword(password) +} + +// ValidatePassword checks password strength requirements: +// - Minimum 8 characters +// - At least 1 digit +// - At least 1 uppercase letter +// - At least 1 special character +func ValidatePassword(password string) error { + if len(password) < minPasswordLength { + return errors.New("password must be at least 8 characters long") + } + + var hasDigit, hasUpper, hasSpecial bool + for _, c := range password { + switch { + case unicode.IsDigit(c): + hasDigit = true + case unicode.IsUpper(c): + hasUpper = true + case !unicode.IsLetter(c) && !unicode.IsDigit(c): + hasSpecial = true + } + } + + var missing []string + if !hasDigit { + missing = append(missing, "one digit") + } + if !hasUpper { + missing = append(missing, "one uppercase letter") + } + if !hasSpecial { + missing = append(missing, "one special character") + } + + if len(missing) > 0 { + return errors.New("password must contain at least " + strings.Join(missing, ", ")) + } + + return nil +} diff --git a/management/server/user_invite_test.go b/management/server/user_invite_test.go new file mode 100644 index 000000000..6256ed44a --- /dev/null +++ b/management/server/user_invite_test.go @@ -0,0 +1,1010 @@ +package server + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/activity" + "github.com/netbirdio/netbird/management/server/idp" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" + "github.com/netbirdio/netbird/util/crypt" +) + +const ( + testAccountID = "testAccountID" + testAdminUserID = "testAdminUserID" + testRegularUserID = "testRegularUserID" +) + +// setupInviteTestManagerWithEmbeddedIdP creates a test manager with a real embedded IdP +// and store encryption enabled. This is required for tests that need to pass the IsEmbeddedIdp check. +func setupInviteTestManagerWithEmbeddedIdP(t *testing.T) (*DefaultAccountManager, func()) { + t.Helper() + ctx := context.Background() + + tmpDir := t.TempDir() + dexDataDir := tmpDir + "/dex" + require.NoError(t, os.MkdirAll(dexDataDir, 0700)) + + // Create test store + s, cleanup, err := store.NewTestStoreFromSQL(ctx, "", tmpDir) + require.NoError(t, err, "Error when creating store") + + // Enable encryption + key, err := crypt.GenerateKey() + require.NoError(t, err) + fieldEncrypt, err := crypt.NewFieldEncrypt(key) + require.NoError(t, err) + s.SetFieldEncrypt(fieldEncrypt) + + // Create embedded IDP config + embeddedIdPConfig := &idp.EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + Storage: idp.EmbeddedStorageConfig{ + Type: "sqlite3", + Config: idp.EmbeddedStorageTypeConfig{ + File: dexDataDir + "/dex.db", + }, + }, + } + + // Create embedded IDP manager + embeddedIdp, err := idp.NewEmbeddedIdPManager(ctx, embeddedIdPConfig, nil) + require.NoError(t, err) + + account := newAccountWithId(ctx, testAccountID, testAdminUserID, "", "admin@test.com", "Admin User", false) + account.Users[testRegularUserID] = &types.User{ + Id: testRegularUserID, + AccountID: testAccountID, + Role: types.UserRoleUser, + Email: "regular@test.com", + Name: "Regular User", + } + + err = s.SaveAccount(ctx, account) + require.NoError(t, err, "Error when saving account") + + permissionsManager := permissions.NewManager(s) + + am := DefaultAccountManager{ + Store: s, + eventStore: &activity.InMemoryEventStore{}, + permissionsManager: permissionsManager, + idpManager: embeddedIdp, + } + + cleanupFunc := func() { + _ = embeddedIdp.Stop(ctx) + cleanup() + } + + return &am, cleanupFunc +} + +func TestCreateUserInvite_Success(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + invite := &types.UserInfo{ + Email: "newuser@test.com", + Name: "New User", + Role: "user", + AutoGroups: []string{}, + } + + result, err := am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, 0) + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, "newuser@test.com", result.UserInfo.Email) + assert.Equal(t, "New User", result.UserInfo.Name) + assert.Equal(t, "user", result.UserInfo.Role) + assert.Equal(t, string(types.UserStatusInvited), result.UserInfo.Status) + assert.NotEmpty(t, result.InviteToken) + assert.True(t, result.InviteExpiresAt.After(time.Now())) + + // Verify invite is stored in DB + invites, err := am.Store.GetAccountUserInvites(context.Background(), store.LockingStrengthNone, testAccountID) + require.NoError(t, err) + assert.Len(t, invites, 1) + assert.Equal(t, "newuser@test.com", invites[0].Email) +} + +func TestCreateUserInvite_DuplicateEmail(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + invite := &types.UserInfo{ + Email: "newuser@test.com", + Name: "New User", + Role: "user", + AutoGroups: []string{}, + } + + // Create first invite + _, err := am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, 0) + require.NoError(t, err) + + // Try to create duplicate invite + _, err = am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, 0) + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.AlreadyExists, sErr.Type()) +} + +func TestCreateUserInvite_ExistingUserEmail(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + // Try to invite with an email that already exists as a user + invite := &types.UserInfo{ + Email: "regular@test.com", // Already exists as a user + Name: "Duplicate User", + Role: "user", + AutoGroups: []string{}, + } + + _, err := am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, 0) + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.UserAlreadyExists, sErr.Type()) +} + +func TestCreateUserInvite_PermissionDenied(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + invite := &types.UserInfo{ + Email: "newuser@test.com", + Name: "New User", + Role: "user", + AutoGroups: []string{}, + } + + // Regular user should not be able to create invites + _, err := am.CreateUserInvite(context.Background(), testAccountID, testRegularUserID, invite, 0) + require.Error(t, err) +} + +func TestCreateUserInvite_InvalidEmail(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + invite := &types.UserInfo{ + Email: "", + Name: "New User", + Role: "user", + AutoGroups: []string{}, + } + + _, err := am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, 0) + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.InvalidArgument, sErr.Type()) +} + +func TestCreateUserInvite_InvalidName(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + invite := &types.UserInfo{ + Email: "newuser@test.com", + Name: "", + Role: "user", + AutoGroups: []string{}, + } + + _, err := am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, 0) + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.InvalidArgument, sErr.Type()) +} + +func TestCreateUserInvite_OwnerRole(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + invite := &types.UserInfo{ + Email: "newowner@test.com", + Name: "New Owner", + Role: "owner", + AutoGroups: []string{}, + } + + _, err := am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, 0) + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.InvalidArgument, sErr.Type()) +} + +func TestCreateUserInvite_ExpirationTooShort(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + invite := &types.UserInfo{ + Email: "newuser@test.com", + Name: "New User", + Role: "user", + AutoGroups: []string{}, + } + + // Try to create with expiration less than 1 hour + _, err := am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, 1800) // 30 minutes + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.InvalidArgument, sErr.Type()) + assert.Contains(t, err.Error(), "at least 1 hour") +} + +func TestCreateUserInvite_CustomExpiration(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + invite := &types.UserInfo{ + Email: "newuser@test.com", + Name: "New User", + Role: "user", + AutoGroups: []string{}, + } + + expiresIn := 7200 // 2 hours + result, err := am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, expiresIn) + require.NoError(t, err) + + // Verify expiration is approximately 2 hours from now + expectedExpiration := time.Now().Add(time.Duration(expiresIn) * time.Second) + assert.WithinDuration(t, expectedExpiration, result.InviteExpiresAt, time.Minute) +} + +func TestCreateUserInvite_WithAutoGroups(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + invite := &types.UserInfo{ + Email: "newuser@test.com", + Name: "New User", + Role: "user", + AutoGroups: []string{"group1", "group2"}, + } + + result, err := am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, 0) + require.NoError(t, err) + assert.Equal(t, []string{"group1", "group2"}, result.UserInfo.AutoGroups) + + // Verify invite in DB has auto groups + invites, err := am.Store.GetAccountUserInvites(context.Background(), store.LockingStrengthNone, testAccountID) + require.NoError(t, err) + require.Len(t, invites, 1) + assert.Equal(t, []string{"group1", "group2"}, invites[0].AutoGroups) +} + +func TestGetUserInviteInfo_Success(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + // Create an invite first + invite := &types.UserInfo{ + Email: "newuser@test.com", + Name: "New User", + Role: "user", + AutoGroups: []string{}, + } + + result, err := am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, 0) + require.NoError(t, err) + + // Get the invite info using the token + info, err := am.GetUserInviteInfo(context.Background(), result.InviteToken) + require.NoError(t, err) + require.NotNil(t, info) + + assert.Equal(t, "newuser@test.com", info.Email) + assert.Equal(t, "New User", info.Name) + assert.True(t, info.Valid) + assert.Equal(t, "Admin User", info.InvitedBy) +} + +func TestGetUserInviteInfo_InvalidToken(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + _, err := am.GetUserInviteInfo(context.Background(), "invalid_token") + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.InvalidArgument, sErr.Type()) +} + +func TestGetUserInviteInfo_TokenNotFound(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + // Generate a valid token format that doesn't exist in DB + _, validToken, err := types.GenerateInviteToken() + require.NoError(t, err) + + _, err = am.GetUserInviteInfo(context.Background(), validToken) + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.NotFound, sErr.Type()) +} + +func TestGetUserInviteInfo_ExpiredInvite(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + // Create an invite with valid expiration + invite := &types.UserInfo{ + Email: "newuser@test.com", + Name: "New User", + Role: "user", + AutoGroups: []string{}, + } + + result, err := am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, 0) + require.NoError(t, err) + + // Manually set the invite to expired by updating the store directly + inviteRecord, err := am.Store.GetUserInviteByID(context.Background(), store.LockingStrengthUpdate, testAccountID, result.UserInfo.ID) + require.NoError(t, err) + inviteRecord.ExpiresAt = time.Now().Add(-time.Hour) // Set to 1 hour ago + err = am.Store.SaveUserInvite(context.Background(), inviteRecord) + require.NoError(t, err) + + // Get the invite info - should still return info but Valid should be false + info, err := am.GetUserInviteInfo(context.Background(), result.InviteToken) + require.NoError(t, err) + assert.False(t, info.Valid) +} + +func TestListUserInvites_Success(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + // Create multiple invites + for i, email := range []string{"user1@test.com", "user2@test.com", "user3@test.com"} { + invite := &types.UserInfo{ + Email: email, + Name: "User " + string(rune('1'+i)), + Role: "user", + AutoGroups: []string{}, + } + _, err := am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, 0) + require.NoError(t, err) + } + + // List invites + invites, err := am.ListUserInvites(context.Background(), testAccountID, testAdminUserID) + require.NoError(t, err) + assert.Len(t, invites, 3) +} + +func TestListUserInvites_Empty(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + invites, err := am.ListUserInvites(context.Background(), testAccountID, testAdminUserID) + require.NoError(t, err) + assert.Len(t, invites, 0) +} + +func TestListUserInvites_PermissionDenied(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + _, err := am.ListUserInvites(context.Background(), testAccountID, testRegularUserID) + require.Error(t, err) +} + +func TestRegenerateUserInvite_Success(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + // Create an invite first + invite := &types.UserInfo{ + Email: "newuser@test.com", + Name: "New User", + Role: "user", + AutoGroups: []string{}, + } + + originalResult, err := am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, 0) + require.NoError(t, err) + + // Regenerate the invite + newResult, err := am.RegenerateUserInvite(context.Background(), testAccountID, testAdminUserID, originalResult.UserInfo.ID, 0) + require.NoError(t, err) + require.NotNil(t, newResult) + + // Verify invite ID remains the same (stable ID for clients) + assert.Equal(t, originalResult.UserInfo.ID, newResult.UserInfo.ID) + + // Verify new token is different + assert.NotEqual(t, originalResult.InviteToken, newResult.InviteToken) + assert.Equal(t, "newuser@test.com", newResult.UserInfo.Email) + assert.Equal(t, "New User", newResult.UserInfo.Name) + + // Verify old token no longer works + _, err = am.GetUserInviteInfo(context.Background(), originalResult.InviteToken) + require.Error(t, err) + + // Verify new token works + info, err := am.GetUserInviteInfo(context.Background(), newResult.InviteToken) + require.NoError(t, err) + assert.Equal(t, "newuser@test.com", info.Email) +} + +func TestRegenerateUserInvite_NotFound(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + _, err := am.RegenerateUserInvite(context.Background(), testAccountID, testAdminUserID, "nonexistent-id", 0) + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.NotFound, sErr.Type()) +} + +func TestRegenerateUserInvite_PermissionDenied(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + // Create an invite first + invite := &types.UserInfo{ + Email: "newuser@test.com", + Name: "New User", + Role: "user", + AutoGroups: []string{}, + } + + result, err := am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, 0) + require.NoError(t, err) + + // Regular user should not be able to regenerate + _, err = am.RegenerateUserInvite(context.Background(), testAccountID, testRegularUserID, result.UserInfo.ID, 0) + require.Error(t, err) +} + +func TestDeleteUserInvite_Success(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + // Create an invite first + invite := &types.UserInfo{ + Email: "newuser@test.com", + Name: "New User", + Role: "user", + AutoGroups: []string{}, + } + + result, err := am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, 0) + require.NoError(t, err) + + // Delete the invite + err = am.DeleteUserInvite(context.Background(), testAccountID, testAdminUserID, result.UserInfo.ID) + require.NoError(t, err) + + // Verify invite is deleted + invites, err := am.Store.GetAccountUserInvites(context.Background(), store.LockingStrengthNone, testAccountID) + require.NoError(t, err) + assert.Len(t, invites, 0) + + // Verify token no longer works + _, err = am.GetUserInviteInfo(context.Background(), result.InviteToken) + require.Error(t, err) +} + +func TestDeleteUserInvite_NotFound(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + err := am.DeleteUserInvite(context.Background(), testAccountID, testAdminUserID, "nonexistent-id") + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.NotFound, sErr.Type()) +} + +func TestDeleteUserInvite_PermissionDenied(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + // Create an invite first + invite := &types.UserInfo{ + Email: "newuser@test.com", + Name: "New User", + Role: "user", + AutoGroups: []string{}, + } + + result, err := am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, 0) + require.NoError(t, err) + + // Regular user should not be able to delete + err = am.DeleteUserInvite(context.Background(), testAccountID, testRegularUserID, result.UserInfo.ID) + require.Error(t, err) +} + +func TestDeleteUserInvite_WrongAccount(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + // Create an invite + invite := &types.UserInfo{ + Email: "newuser@test.com", + Name: "New User", + Role: "user", + AutoGroups: []string{}, + } + + result, err := am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, 0) + require.NoError(t, err) + + // Create another account + anotherAccountID := "anotherAccountID" + anotherAdminID := "anotherAdminID" + anotherAccount := newAccountWithId(context.Background(), anotherAccountID, anotherAdminID, "", "otheradmin@test.com", "Other Admin", false) + err = am.Store.SaveAccount(context.Background(), anotherAccount) + require.NoError(t, err) + + // Try to delete from wrong account + err = am.DeleteUserInvite(context.Background(), anotherAccountID, anotherAdminID, result.UserInfo.ID) + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.NotFound, sErr.Type()) +} + +func TestAcceptUserInvite_Success(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + // Create an invite + invite := &types.UserInfo{ + Email: "newuser@test.com", + Name: "New User", + Role: "user", + AutoGroups: []string{}, + } + + result, err := am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, 0) + require.NoError(t, err) + + // Accept the invite with a valid password + err = am.AcceptUserInvite(context.Background(), result.InviteToken, "Password1!") + require.NoError(t, err) + + // Verify user is created in DB + users, err := am.Store.GetAccountUsers(context.Background(), store.LockingStrengthNone, testAccountID) + require.NoError(t, err) + + var foundUser *types.User + for _, u := range users { + if u.Email == "newuser@test.com" { + foundUser = u + break + } + } + require.NotNil(t, foundUser, "User should be created in DB") + assert.Equal(t, "New User", foundUser.Name) + assert.Equal(t, types.UserRoleUser, foundUser.Role) + + // Verify invite is deleted + invites, err := am.Store.GetAccountUserInvites(context.Background(), store.LockingStrengthNone, testAccountID) + require.NoError(t, err) + assert.Len(t, invites, 0) +} + +func TestAcceptUserInvite_InvalidToken(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + err := am.AcceptUserInvite(context.Background(), "invalid_token", "Password1!") + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.InvalidArgument, sErr.Type()) +} + +func TestAcceptUserInvite_TokenNotFound(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + // Generate a valid token format that doesn't exist in DB + _, validToken, err := types.GenerateInviteToken() + require.NoError(t, err) + + err = am.AcceptUserInvite(context.Background(), validToken, "Password1!") + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.NotFound, sErr.Type()) +} + +func TestAcceptUserInvite_ExpiredToken(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + // Create an invite with valid expiration + invite := &types.UserInfo{ + Email: "newuser@test.com", + Name: "New User", + Role: "user", + AutoGroups: []string{}, + } + + result, err := am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, 0) + require.NoError(t, err) + + // Manually set the invite to expired by updating the store directly + inviteRecord, err := am.Store.GetUserInviteByID(context.Background(), store.LockingStrengthUpdate, testAccountID, result.UserInfo.ID) + require.NoError(t, err) + inviteRecord.ExpiresAt = time.Now().Add(-time.Hour) // Set to 1 hour ago + err = am.Store.SaveUserInvite(context.Background(), inviteRecord) + require.NoError(t, err) + + err = am.AcceptUserInvite(context.Background(), result.InviteToken, "Password1!") + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.InvalidArgument, sErr.Type()) + assert.Contains(t, err.Error(), "expired") +} + +func TestAcceptUserInvite_EmptyPassword(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + // Create an invite + invite := &types.UserInfo{ + Email: "newuser@test.com", + Name: "New User", + Role: "user", + AutoGroups: []string{}, + } + + result, err := am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, 0) + require.NoError(t, err) + + err = am.AcceptUserInvite(context.Background(), result.InviteToken, "") + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.InvalidArgument, sErr.Type()) + assert.Contains(t, err.Error(), "password is required") +} + +func TestAcceptUserInvite_WeakPassword(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + // Create an invite + invite := &types.UserInfo{ + Email: "newuser@test.com", + Name: "New User", + Role: "user", + AutoGroups: []string{}, + } + + result, err := am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, 0) + require.NoError(t, err) + + testCases := []struct { + name string + password string + expectedMsg string + }{ + {"too short", "Pass1!", "at least 8 characters"}, + {"no digit", "Password!", "one digit"}, + {"no uppercase", "password1!", "one uppercase"}, + {"no special", "Password1", "one special character"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := am.AcceptUserInvite(context.Background(), result.InviteToken, tc.password) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedMsg) + }) + } +} + +func TestValidatePassword(t *testing.T) { + testCases := []struct { + name string + password string + expectError bool + errorMsg string + }{ + {"valid password", "Password1!", false, ""}, + {"valid complex password", "MyP@ssw0rd#2024", false, ""}, + {"too short", "Pass1!", true, "at least 8 characters"}, + {"no digit", "Password!", true, "one digit"}, + {"no uppercase", "password1!", true, "one uppercase"}, + {"no special", "Password1", true, "one special character"}, + {"only lowercase", "password", true, "one digit"}, + {"no uppercase no special", "password1", true, "one uppercase"}, + {"all lowercase short", "pass", true, "at least 8 characters"}, + {"empty", "", true, "at least 8 characters"}, + {"spaces count as special", "Pass word1", false, ""}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := validatePassword(tc.password) + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.errorMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestInviteToken_GenerateAndValidate(t *testing.T) { + hashedToken, plainToken, err := types.GenerateInviteToken() + require.NoError(t, err) + require.NotEmpty(t, hashedToken) + require.NotEmpty(t, plainToken) + + // Validate token format + assert.Len(t, plainToken, types.InviteTokenLength) + assert.True(t, len(plainToken) > len(types.InviteTokenPrefix)) + assert.Equal(t, types.InviteTokenPrefix, plainToken[:len(types.InviteTokenPrefix)]) + + // Validate checksum + err = types.ValidateInviteToken(plainToken) + require.NoError(t, err) + + // Verify hashing is consistent + hashedAgain := types.HashInviteToken(plainToken) + assert.Equal(t, hashedToken, hashedAgain) +} + +func TestInviteToken_ValidateInvalid(t *testing.T) { + testCases := []struct { + name string + token string + }{ + {"empty", ""}, + {"too short", "nbi_abc"}, + {"wrong prefix", "xyz_123456789012345678901234567890"}, + {"invalid checksum", "nbi_123456789012345678901234567890abcdef"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := types.ValidateInviteToken(tc.token) + require.Error(t, err) + }) + } +} + +func TestUserInviteRecord_IsExpired(t *testing.T) { + // Not expired + invite := &types.UserInviteRecord{ + ExpiresAt: time.Now().Add(time.Hour), + } + assert.False(t, invite.IsExpired()) + + // Expired + invite = &types.UserInviteRecord{ + ExpiresAt: time.Now().Add(-time.Hour), + } + assert.True(t, invite.IsExpired()) +} + +func TestUserInviteRecord_Copy(t *testing.T) { + original := &types.UserInviteRecord{ + ID: "invite-id", + AccountID: "account-id", + Email: "test@example.com", + Name: "Test User", + Role: "user", + AutoGroups: []string{"group1", "group2"}, + HashedToken: "hashed-token", + ExpiresAt: time.Now().Add(time.Hour), + CreatedAt: time.Now(), + CreatedBy: "creator-id", + } + + copied := original.Copy() + + assert.Equal(t, original.ID, copied.ID) + assert.Equal(t, original.AccountID, copied.AccountID) + assert.Equal(t, original.Email, copied.Email) + assert.Equal(t, original.Name, copied.Name) + assert.Equal(t, original.Role, copied.Role) + assert.Equal(t, original.AutoGroups, copied.AutoGroups) + assert.Equal(t, original.HashedToken, copied.HashedToken) + assert.Equal(t, original.ExpiresAt, copied.ExpiresAt) + assert.Equal(t, original.CreatedAt, copied.CreatedAt) + assert.Equal(t, original.CreatedBy, copied.CreatedBy) + + // Verify deep copy of AutoGroups + copied.AutoGroups[0] = "modified" + assert.NotEqual(t, original.AutoGroups[0], copied.AutoGroups[0]) +} + +func TestCreateUserInvite_NonEmbeddedIdP(t *testing.T) { + s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + require.NoError(t, err) + defer cleanup() + + account := newAccountWithId(context.Background(), testAccountID, testAdminUserID, "", "admin@test.com", "Admin User", false) + err = s.SaveAccount(context.Background(), account) + require.NoError(t, err) + + permissionsManager := permissions.NewManager(s) + + // Use nil IDP manager (non-embedded) + am := DefaultAccountManager{ + Store: s, + eventStore: &activity.InMemoryEventStore{}, + permissionsManager: permissionsManager, + idpManager: nil, + } + + invite := &types.UserInfo{ + Email: "newuser@test.com", + Name: "New User", + Role: "user", + AutoGroups: []string{}, + } + + _, err = am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, 0) + require.Error(t, err) + + sErr, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, status.PreconditionFailed, sErr.Type()) + assert.Contains(t, err.Error(), "embedded identity provider") +} + +func TestAcceptUserInvite_WithAutoGroups(t *testing.T) { + am, cleanup := setupInviteTestManagerWithEmbeddedIdP(t) + defer cleanup() + + // Create an invite with auto groups + invite := &types.UserInfo{ + Email: "newuser@test.com", + Name: "New User", + Role: "admin", + AutoGroups: []string{"group1", "group2"}, + } + + result, err := am.CreateUserInvite(context.Background(), testAccountID, testAdminUserID, invite, 0) + require.NoError(t, err) + + // Accept the invite + err = am.AcceptUserInvite(context.Background(), result.InviteToken, "Password1!") + require.NoError(t, err) + + // Verify user has the auto groups and role + users, err := am.Store.GetAccountUsers(context.Background(), store.LockingStrengthNone, testAccountID) + require.NoError(t, err) + + var foundUser *types.User + for _, u := range users { + if u.Email == "newuser@test.com" { + foundUser = u + break + } + } + require.NotNil(t, foundUser) + assert.Equal(t, types.UserRoleAdmin, foundUser.Role) + assert.Equal(t, []string{"group1", "group2"}, foundUser.AutoGroups) +} + +func TestUserInvite_EncryptDecryptSensitiveData(t *testing.T) { + key, err := crypt.GenerateKey() + require.NoError(t, err) + fieldEncrypt, err := crypt.NewFieldEncrypt(key) + require.NoError(t, err) + + t.Run("encrypt and decrypt", func(t *testing.T) { + invite := &types.UserInviteRecord{ + ID: "test-invite", + AccountID: "test-account", + Email: "test@example.com", + Name: "Test User", + Role: "user", + } + + // Encrypt + err := invite.EncryptSensitiveData(fieldEncrypt) + require.NoError(t, err) + + // Verify encrypted values are different from original + assert.NotEqual(t, "test@example.com", invite.Email) + assert.NotEqual(t, "Test User", invite.Name) + + // Decrypt + err = invite.DecryptSensitiveData(fieldEncrypt) + require.NoError(t, err) + + // Verify decrypted values match original + assert.Equal(t, "test@example.com", invite.Email) + assert.Equal(t, "Test User", invite.Name) + }) + + t.Run("encrypt empty fields", func(t *testing.T) { + invite := &types.UserInviteRecord{ + ID: "test-invite", + AccountID: "test-account", + Email: "", + Name: "", + Role: "user", + } + + // Encrypt empty fields + err := invite.EncryptSensitiveData(fieldEncrypt) + require.NoError(t, err) + + // Empty strings should remain empty + assert.Equal(t, "", invite.Email) + assert.Equal(t, "", invite.Name) + + // Decrypt empty fields + err = invite.DecryptSensitiveData(fieldEncrypt) + require.NoError(t, err) + + // Should still be empty + assert.Equal(t, "", invite.Email) + assert.Equal(t, "", invite.Name) + }) + + t.Run("nil encryptor", func(t *testing.T) { + invite := &types.UserInviteRecord{ + ID: "test-invite", + AccountID: "test-account", + Email: "test@example.com", + Name: "Test User", + Role: "user", + } + + // Encrypt with nil encryptor should be no-op + err := invite.EncryptSensitiveData(nil) + require.NoError(t, err) + assert.Equal(t, "test@example.com", invite.Email) + assert.Equal(t, "Test User", invite.Name) + + // Decrypt with nil encryptor should be no-op + err = invite.DecryptSensitiveData(nil) + require.NoError(t, err) + assert.Equal(t, "test@example.com", invite.Email) + assert.Equal(t, "Test User", invite.Name) + }) +} diff --git a/management/server/user_test.go b/management/server/user_test.go index 69b8c85ee..f32a6b3a1 100644 --- a/management/server/user_test.go +++ b/management/server/user_test.go @@ -3,20 +3,23 @@ package server import ( "context" "fmt" + "os" "reflect" "testing" "time" "github.com/google/go-cmp/cmp" + "go.uber.org/mock/gomock" "golang.org/x/exp/maps" + "github.com/netbirdio/netbird/management/internals/controllers/network_map" nbcache "github.com/netbirdio/netbird/management/server/cache" - nbcontext "github.com/netbirdio/netbird/management/server/context" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions/modules" "github.com/netbirdio/netbird/management/server/permissions/roles" "github.com/netbirdio/netbird/management/server/users" "github.com/netbirdio/netbird/management/server/util" + "github.com/netbirdio/netbird/shared/auth" "github.com/netbirdio/netbird/shared/management/status" nbpeer "github.com/netbirdio/netbird/management/server/peer" @@ -27,6 +30,7 @@ import ( "github.com/stretchr/testify/require" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "github.com/netbirdio/netbird/idp/dex" "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/netbird/management/server/integration_reference" @@ -56,7 +60,7 @@ func TestUser_CreatePAT_ForSameUser(t *testing.T) { } t.Cleanup(cleanup) - account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false) + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) err = s.SaveAccount(context.Background(), account) if err != nil { @@ -103,7 +107,7 @@ func TestUser_CreatePAT_ForDifferentUser(t *testing.T) { } t.Cleanup(cleanup) - account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false) + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) account.Users[mockTargetUserId] = &types.User{ Id: mockTargetUserId, IsServiceUser: false, @@ -131,7 +135,7 @@ func TestUser_CreatePAT_ForServiceUser(t *testing.T) { } t.Cleanup(cleanup) - account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false) + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) account.Users[mockTargetUserId] = &types.User{ Id: mockTargetUserId, IsServiceUser: true, @@ -163,7 +167,7 @@ func TestUser_CreatePAT_WithWrongExpiration(t *testing.T) { } t.Cleanup(cleanup) - account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false) + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) err = store.SaveAccount(context.Background(), account) if err != nil { @@ -188,7 +192,7 @@ func TestUser_CreatePAT_WithEmptyName(t *testing.T) { } t.Cleanup(cleanup) - account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false) + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) err = store.SaveAccount(context.Background(), account) if err != nil { @@ -213,7 +217,7 @@ func TestUser_DeletePAT(t *testing.T) { } t.Cleanup(cleanup) - account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false) + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) account.Users[mockUserID] = &types.User{ Id: mockUserID, PATs: map[string]*types.PersonalAccessToken{ @@ -256,7 +260,7 @@ func TestUser_GetPAT(t *testing.T) { } t.Cleanup(cleanup) - account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false) + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) account.Users[mockUserID] = &types.User{ Id: mockUserID, AccountID: mockAccountID, @@ -296,7 +300,7 @@ func TestUser_GetAllPATs(t *testing.T) { } t.Cleanup(cleanup) - account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false) + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) account.Users[mockUserID] = &types.User{ Id: mockUserID, AccountID: mockAccountID, @@ -332,6 +336,104 @@ func TestUser_GetAllPATs(t *testing.T) { assert.Equal(t, 2, len(pats)) } +func TestUser_PAT_CrossAccountProtection(t *testing.T) { + const ( + accountAID = "accountA" + accountBID = "accountB" + userAID = "userA" + adminBID = "adminB" + serviceUserBID = "serviceUserB" + regularUserBID = "regularUserB" + tokenBID = "tokenB1" + hashedTokenB = "SoMeHaShEdToKeNB" + ) + + setupStore := func(t *testing.T) (*DefaultAccountManager, func()) { + t.Helper() + + s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + require.NoError(t, err, "creating store") + + accountA := newAccountWithId(context.Background(), accountAID, userAID, "", "", "", false) + require.NoError(t, s.SaveAccount(context.Background(), accountA)) + + accountB := newAccountWithId(context.Background(), accountBID, adminBID, "", "", "", false) + accountB.Users[serviceUserBID] = &types.User{ + Id: serviceUserBID, + AccountID: accountBID, + IsServiceUser: true, + ServiceUserName: "svcB", + Role: types.UserRoleAdmin, + PATs: map[string]*types.PersonalAccessToken{ + tokenBID: { + ID: tokenBID, + HashedToken: hashedTokenB, + }, + }, + } + accountB.Users[regularUserBID] = &types.User{ + Id: regularUserBID, + AccountID: accountBID, + Role: types.UserRoleUser, + } + require.NoError(t, s.SaveAccount(context.Background(), accountB)) + + pm := permissions.NewManager(s) + am := &DefaultAccountManager{ + Store: s, + eventStore: &activity.InMemoryEventStore{}, + permissionsManager: pm, + } + return am, cleanup + } + + t.Run("CreatePAT for user in different account is denied", func(t *testing.T) { + am, cleanup := setupStore(t) + t.Cleanup(cleanup) + + _, err := am.CreatePAT(context.Background(), accountAID, userAID, serviceUserBID, "xss-token", 7) + require.Error(t, err, "cross-account CreatePAT must fail") + + _, err = am.CreatePAT(context.Background(), accountAID, userAID, regularUserBID, "xss-token", 7) + require.Error(t, err, "cross-account CreatePAT for regular user must fail") + + _, err = am.CreatePAT(context.Background(), accountBID, adminBID, serviceUserBID, "legit-token", 7) + require.NoError(t, err, "same-account CreatePAT should succeed") + }) + + t.Run("DeletePAT for user in different account is denied", func(t *testing.T) { + am, cleanup := setupStore(t) + t.Cleanup(cleanup) + + err := am.DeletePAT(context.Background(), accountAID, userAID, serviceUserBID, tokenBID) + require.Error(t, err, "cross-account DeletePAT must fail") + }) + + t.Run("GetPAT for user in different account is denied", func(t *testing.T) { + am, cleanup := setupStore(t) + t.Cleanup(cleanup) + + _, err := am.GetPAT(context.Background(), accountAID, userAID, serviceUserBID, tokenBID) + require.Error(t, err, "cross-account GetPAT must fail") + }) + + t.Run("GetAllPATs for user in different account is denied", func(t *testing.T) { + am, cleanup := setupStore(t) + t.Cleanup(cleanup) + + _, err := am.GetAllPATs(context.Background(), accountAID, userAID, serviceUserBID) + require.Error(t, err, "cross-account GetAllPATs must fail") + }) + + t.Run("CreatePAT with forged accountID targeting foreign user is denied", func(t *testing.T) { + am, cleanup := setupStore(t) + t.Cleanup(cleanup) + + _, err := am.CreatePAT(context.Background(), accountAID, userAID, adminBID, "forged", 7) + require.Error(t, err, "forged accountID CreatePAT must fail") + }) +} + func TestUser_Copy(t *testing.T) { // this is an imaginary case which will never be in DB this way user := types.User{ @@ -360,6 +462,8 @@ func TestUser_Copy(t *testing.T) { ID: 0, IntegrationType: "test", }, + Email: "whatever@gmail.com", + Name: "John Doe", } err := validateStruct(user) @@ -406,7 +510,7 @@ func TestUser_CreateServiceUser(t *testing.T) { } t.Cleanup(cleanup) - account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false) + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) err = store.SaveAccount(context.Background(), account) if err != nil { @@ -453,7 +557,7 @@ func TestUser_CreateUser_ServiceUser(t *testing.T) { } t.Cleanup(cleanup) - account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false) + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) err = store.SaveAccount(context.Background(), account) if err != nil { @@ -501,7 +605,7 @@ func TestUser_CreateUser_RegularUser(t *testing.T) { } t.Cleanup(cleanup) - account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false) + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) err = store.SaveAccount(context.Background(), account) if err != nil { @@ -532,7 +636,7 @@ func TestUser_InviteNewUser(t *testing.T) { } t.Cleanup(cleanup) - account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false) + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) err = store.SaveAccount(context.Background(), account) if err != nil { @@ -547,7 +651,7 @@ func TestUser_InviteNewUser(t *testing.T) { permissionsManager: permissionsManager, } - cs, err := nbcache.NewStore(context.Background(), nbcache.DefaultIDPCacheExpirationMax, nbcache.DefaultIDPCacheCleanupInterval) + cs, err := nbcache.NewStore(context.Background(), nbcache.DefaultIDPCacheExpirationMax, nbcache.DefaultIDPCacheCleanupInterval, nbcache.DefaultIDPCacheOpenConn) require.NoError(t, err) am.cacheManager = nbcache.NewAccountUserDataCache(am.loadAccount, cs) @@ -639,7 +743,7 @@ func TestUser_DeleteUser_ServiceUser(t *testing.T) { } t.Cleanup(cleanup) - account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false) + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) account.Users[mockServiceUserID] = tt.serviceUser err = store.SaveAccount(context.Background(), account) @@ -678,7 +782,7 @@ func TestUser_DeleteUser_SelfDelete(t *testing.T) { } t.Cleanup(cleanup) - account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false) + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) err = store.SaveAccount(context.Background(), account) if err != nil { @@ -705,7 +809,7 @@ func TestUser_DeleteUser_regularUser(t *testing.T) { } t.Cleanup(cleanup) - account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false) + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) targetId := "user2" account.Users[targetId] = &types.User{ @@ -739,11 +843,18 @@ func TestUser_DeleteUser_regularUser(t *testing.T) { t.Fatalf("Error when saving account: %s", err) } + ctrl := gomock.NewController(t) + networkMapControllerMock := network_map.NewMockController(ctrl) + networkMapControllerMock.EXPECT(). + OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil) + permissionsManager := permissions.NewManager(store) am := DefaultAccountManager{ - Store: store, - eventStore: &activity.InMemoryEventStore{}, - permissionsManager: permissionsManager, + Store: store, + eventStore: &activity.InMemoryEventStore{}, + permissionsManager: permissionsManager, + networkMapController: networkMapControllerMock, } testCases := []struct { @@ -792,7 +903,7 @@ func TestUser_DeleteUser_RegularUsers(t *testing.T) { } t.Cleanup(cleanup) - account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false) + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) targetId := "user2" account.Users[targetId] = &types.User{ @@ -848,12 +959,20 @@ func TestUser_DeleteUser_RegularUsers(t *testing.T) { t.Fatalf("Error when saving account: %s", err) } + ctrl := gomock.NewController(t) + networkMapControllerMock := network_map.NewMockController(ctrl) + networkMapControllerMock.EXPECT(). + OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil). + AnyTimes() + permissionsManager := permissions.NewManager(store) am := DefaultAccountManager{ Store: store, eventStore: &activity.InMemoryEventStore{}, integratedPeerValidator: MockIntegratedValidator{}, permissionsManager: permissionsManager, + networkMapController: networkMapControllerMock, } testCases := []struct { @@ -952,7 +1071,7 @@ func TestDefaultAccountManager_GetUser(t *testing.T) { } t.Cleanup(cleanup) - account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false) + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) err = store.SaveAccount(context.Background(), account) if err != nil { @@ -966,7 +1085,7 @@ func TestDefaultAccountManager_GetUser(t *testing.T) { permissionsManager: permissionsManager, } - claims := nbcontext.UserAuth{ + claims := auth.UserAuth{ UserId: mockUserID, AccountId: mockAccountID, } @@ -988,9 +1107,9 @@ func TestDefaultAccountManager_ListUsers(t *testing.T) { } t.Cleanup(cleanup) - account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false) - account.Users["normal_user1"] = types.NewRegularUser("normal_user1") - account.Users["normal_user2"] = types.NewRegularUser("normal_user2") + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) + account.Users["normal_user1"] = types.NewRegularUser("normal_user1", "", "") + account.Users["normal_user2"] = types.NewRegularUser("normal_user2", "", "") err = store.SaveAccount(context.Background(), account) if err != nil { @@ -1030,7 +1149,7 @@ func TestDefaultAccountManager_ExternalCache(t *testing.T) { } t.Cleanup(cleanup) - account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false) + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) externalUser := &types.User{ Id: "externalUser", Role: types.UserRoleUser, @@ -1056,7 +1175,7 @@ func TestDefaultAccountManager_ExternalCache(t *testing.T) { permissionsManager: permissionsManager, } - cacheStore, err := nbcache.NewStore(context.Background(), nbcache.DefaultIDPCacheExpirationMax, nbcache.DefaultIDPCacheCleanupInterval) + cacheStore, err := nbcache.NewStore(context.Background(), nbcache.DefaultIDPCacheExpirationMax, nbcache.DefaultIDPCacheCleanupInterval, nbcache.DefaultIDPCacheOpenConn) assert.NoError(t, err) am.externalCacheManager = nbcache.NewUserDataCache(cacheStore) am.cacheManager = nbcache.NewAccountUserDataCache(am.loadAccount, cacheStore) @@ -1065,8 +1184,12 @@ func TestDefaultAccountManager_ExternalCache(t *testing.T) { assert.NoError(t, err) cacheManager := am.GetExternalCacheManager() - cacheKey := externalUser.IntegrationReference.CacheKey(mockAccountID, externalUser.Id) - err = cacheManager.Set(context.Background(), cacheKey, &idp.UserData{ID: externalUser.Id, Name: "Test User", Email: "user@example.com"}, time.Minute) + tud := &idp.UserData{ID: externalUser.Id, Name: "Test User", Email: "user@example.com"} + cacheKeyUser := externalUser.IntegrationReference.CacheKey(mockAccountID, externalUser.Id) + err = cacheManager.Set(context.Background(), cacheKeyUser, tud, time.Minute) + assert.NoError(t, err) + cacheKeyAccount := externalUser.IntegrationReference.CacheKey(mockAccountID) + err = cacheManager.SetUsers(context.Background(), cacheKeyAccount, []*idp.UserData{tud}, time.Minute) assert.NoError(t, err) infos, err := am.GetUsersFromAccount(context.Background(), mockAccountID, mockUserID) @@ -1087,7 +1210,7 @@ func TestUser_IsAdmin(t *testing.T) { user := types.NewAdminUser(mockUserID) assert.True(t, user.HasAdminPower()) - user = types.NewRegularUser(mockUserID) + user = types.NewRegularUser(mockUserID, "", "") assert.False(t, user.HasAdminPower()) } @@ -1098,7 +1221,7 @@ func TestUser_GetUsersFromAccount_ForAdmin(t *testing.T) { } t.Cleanup(cleanup) - account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false) + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) account.Users[mockServiceUserID] = &types.User{ Id: mockServiceUserID, Role: "user", @@ -1132,7 +1255,7 @@ func TestUser_GetUsersFromAccount_ForUser(t *testing.T) { } t.Cleanup(cleanup) - account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", false) + account := newAccountWithId(context.Background(), mockAccountID, mockUserID, "", "", "", false) account.Users[mockServiceUserID] = &types.User{ Id: mockServiceUserID, Role: "user", @@ -1303,13 +1426,13 @@ func TestDefaultAccountManager_SaveUser(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // create an account and an admin user - account, err := manager.GetOrCreateAccountByUser(context.Background(), ownerUserID, "netbird.io") + account, err := manager.GetOrCreateAccountByUser(context.Background(), auth.UserAuth{UserId: ownerUserID, Domain: "netbird.io"}) if err != nil { t.Fatal(err) } // create other users - account.Users[regularUserID] = types.NewRegularUser(regularUserID) + account.Users[regularUserID] = types.NewRegularUser(regularUserID, "", "") account.Users[adminUserID] = types.NewAdminUser(adminUserID) account.Users[serviceUserID] = &types.User{IsServiceUser: true, Id: serviceUserID, Role: types.UserRoleAdmin, ServiceUserName: "service"} err = manager.Store.SaveAccount(context.Background(), account) @@ -1362,11 +1485,11 @@ func TestUserAccountPeersUpdate(t *testing.T) { updateManager.CloseChannel(context.Background(), peer1.ID) }) - // Creating a new regular user should not update account peers and not send peer update + // Creating a new regular user should send peer update (as users are not filtered yet) t.Run("creating new regular user with no groups", func(t *testing.T) { done := make(chan struct{}) go func() { - peerShouldNotReceiveUpdate(t, updMsg) + peerShouldReceiveUpdate(t, updMsg) close(done) }() @@ -1385,11 +1508,11 @@ func TestUserAccountPeersUpdate(t *testing.T) { } }) - // updating user with no linked peers should not update account peers and not send peer update + // updating user with no linked peers should update account peers and send peer update (as users are not filtered yet) t.Run("updating user with no linked peers", func(t *testing.T) { done := make(chan struct{}) go func() { - peerShouldNotReceiveUpdate(t, updMsg) + peerShouldReceiveUpdate(t, updMsg) close(done) }() @@ -1408,6 +1531,9 @@ func TestUserAccountPeersUpdate(t *testing.T) { } }) + // drain any buffered updates from previous subtests + drainPeerUpdates(updMsg) + // deleting user with no linked peers should not update account peers and not send peer update t.Run("deleting user with no linked peers", func(t *testing.T) { done := make(chan struct{}) @@ -1439,7 +1565,7 @@ func TestUserAccountPeersUpdate(t *testing.T) { require.NoError(t, err) expectedPeerKey := key.PublicKey().String() - peer4, _, _, err := manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ + peer4, _, _, _, err := manager.AddPeer(context.Background(), "", "", "regularUser2", &nbpeer.Peer{ Key: expectedPeerKey, Meta: nbpeer.PeerSystemMeta{Hostname: expectedPeerKey}, }, false) @@ -1463,7 +1589,7 @@ func TestUserAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -1486,7 +1612,7 @@ func TestUserAccountPeersUpdate(t *testing.T) { select { case <-done: - case <-time.After(time.Second): + case <-time.After(peerUpdateTimeout): t.Error("timeout waiting for peerShouldReceiveUpdate") } }) @@ -1499,7 +1625,7 @@ func TestSaveOrAddUser_PreventAccountSwitch(t *testing.T) { } t.Cleanup(cleanup) - account1 := newAccountWithId(context.Background(), "account1", "ownerAccount1", "", false) + account1 := newAccountWithId(context.Background(), "account1", "ownerAccount1", "", "", "", false) targetId := "user2" account1.Users[targetId] = &types.User{ Id: targetId, @@ -1508,7 +1634,7 @@ func TestSaveOrAddUser_PreventAccountSwitch(t *testing.T) { } require.NoError(t, s.SaveAccount(context.Background(), account1)) - account2 := newAccountWithId(context.Background(), "account2", "ownerAccount2", "", false) + account2 := newAccountWithId(context.Background(), "account2", "ownerAccount2", "", "", "", false) require.NoError(t, s.SaveAccount(context.Background(), account2)) permissionsManager := permissions.NewManager(s) @@ -1535,7 +1661,7 @@ func TestDefaultAccountManager_GetCurrentUserInfo(t *testing.T) { } t.Cleanup(cleanup) - account1 := newAccountWithId(context.Background(), "account1", "account1Owner", "", false) + account1 := newAccountWithId(context.Background(), "account1", "account1Owner", "", "", "", false) account1.Settings.RegularUsersViewBlocked = false account1.Users["blocked-user"] = &types.User{ Id: "blocked-user", @@ -1557,7 +1683,7 @@ func TestDefaultAccountManager_GetCurrentUserInfo(t *testing.T) { } require.NoError(t, store.SaveAccount(context.Background(), account1)) - account2 := newAccountWithId(context.Background(), "account2", "account2Owner", "", false) + account2 := newAccountWithId(context.Background(), "account2", "account2Owner", "", "", "", false) account2.Users["settings-blocked-user"] = &types.User{ Id: "settings-blocked-user", Role: types.UserRoleUser, @@ -1573,33 +1699,33 @@ func TestDefaultAccountManager_GetCurrentUserInfo(t *testing.T) { tt := []struct { name string - userAuth nbcontext.UserAuth + userAuth auth.UserAuth expectedErr error expectedResult *users.UserInfoWithPermissions }{ { name: "not found", - userAuth: nbcontext.UserAuth{AccountId: account1.Id, UserId: "not-found"}, + userAuth: auth.UserAuth{AccountId: account1.Id, UserId: "not-found"}, expectedErr: status.NewUserNotFoundError("not-found"), }, { name: "not part of account", - userAuth: nbcontext.UserAuth{AccountId: account1.Id, UserId: "account2Owner"}, + userAuth: auth.UserAuth{AccountId: account1.Id, UserId: "account2Owner"}, expectedErr: status.NewUserNotPartOfAccountError(), }, { name: "blocked", - userAuth: nbcontext.UserAuth{AccountId: account1.Id, UserId: "blocked-user"}, + userAuth: auth.UserAuth{AccountId: account1.Id, UserId: "blocked-user"}, expectedErr: status.NewUserBlockedError(), }, { name: "service user", - userAuth: nbcontext.UserAuth{AccountId: account1.Id, UserId: "service-user"}, + userAuth: auth.UserAuth{AccountId: account1.Id, UserId: "service-user"}, expectedErr: status.NewPermissionDeniedError(), }, { name: "owner user", - userAuth: nbcontext.UserAuth{AccountId: account1.Id, UserId: "account1Owner"}, + userAuth: auth.UserAuth{AccountId: account1.Id, UserId: "account1Owner"}, expectedResult: &users.UserInfoWithPermissions{ UserInfo: &types.UserInfo{ ID: "account1Owner", @@ -1619,7 +1745,7 @@ func TestDefaultAccountManager_GetCurrentUserInfo(t *testing.T) { }, { name: "regular user", - userAuth: nbcontext.UserAuth{AccountId: account1.Id, UserId: "regular-user"}, + userAuth: auth.UserAuth{AccountId: account1.Id, UserId: "regular-user"}, expectedResult: &users.UserInfoWithPermissions{ UserInfo: &types.UserInfo{ ID: "regular-user", @@ -1638,7 +1764,7 @@ func TestDefaultAccountManager_GetCurrentUserInfo(t *testing.T) { }, { name: "admin user", - userAuth: nbcontext.UserAuth{AccountId: account1.Id, UserId: "admin-user"}, + userAuth: auth.UserAuth{AccountId: account1.Id, UserId: "admin-user"}, expectedResult: &users.UserInfoWithPermissions{ UserInfo: &types.UserInfo{ ID: "admin-user", @@ -1657,7 +1783,7 @@ func TestDefaultAccountManager_GetCurrentUserInfo(t *testing.T) { }, { name: "settings blocked regular user", - userAuth: nbcontext.UserAuth{AccountId: account2.Id, UserId: "settings-blocked-user"}, + userAuth: auth.UserAuth{AccountId: account2.Id, UserId: "settings-blocked-user"}, expectedResult: &users.UserInfoWithPermissions{ UserInfo: &types.UserInfo{ ID: "settings-blocked-user", @@ -1678,7 +1804,7 @@ func TestDefaultAccountManager_GetCurrentUserInfo(t *testing.T) { { name: "settings blocked regular user child account", - userAuth: nbcontext.UserAuth{AccountId: account2.Id, UserId: "settings-blocked-user", IsChild: true}, + userAuth: auth.UserAuth{AccountId: account2.Id, UserId: "settings-blocked-user", IsChild: true}, expectedResult: &users.UserInfoWithPermissions{ UserInfo: &types.UserInfo{ ID: "settings-blocked-user", @@ -1698,7 +1824,7 @@ func TestDefaultAccountManager_GetCurrentUserInfo(t *testing.T) { }, { name: "settings blocked owner user", - userAuth: nbcontext.UserAuth{AccountId: account2.Id, UserId: "account2Owner"}, + userAuth: auth.UserAuth{AccountId: account2.Id, UserId: "account2Owner"}, expectedResult: &users.UserInfoWithPermissions{ UserInfo: &types.UserInfo{ ID: "account2Owner", @@ -1754,7 +1880,7 @@ func TestApproveUser(t *testing.T) { } // Create account with admin and pending approval user - account := newAccountWithId(context.Background(), "account-1", "admin-user", "example.com", false) + account := newAccountWithId(context.Background(), "account-1", "admin-user", "example.com", "", "", false) err = manager.Store.SaveAccount(context.Background(), account) require.NoError(t, err) @@ -1765,7 +1891,7 @@ func TestApproveUser(t *testing.T) { require.NoError(t, err) // Create user pending approval - pendingUser := types.NewRegularUser("pending-user") + pendingUser := types.NewRegularUser("pending-user", "", "") pendingUser.AccountID = account.Id pendingUser.Blocked = true pendingUser.PendingApproval = true @@ -1790,12 +1916,12 @@ func TestApproveUser(t *testing.T) { assert.Contains(t, err.Error(), "not pending approval") // Test approval by non-admin should fail - regularUser := types.NewRegularUser("regular-user") + regularUser := types.NewRegularUser("regular-user", "", "") regularUser.AccountID = account.Id err = manager.Store.SaveUser(context.Background(), regularUser) require.NoError(t, err) - pendingUser2 := types.NewRegularUser("pending-user-2") + pendingUser2 := types.NewRegularUser("pending-user-2", "", "") pendingUser2.AccountID = account.Id pendingUser2.Blocked = true pendingUser2.PendingApproval = true @@ -1813,7 +1939,7 @@ func TestRejectUser(t *testing.T) { } // Create account with admin and pending approval user - account := newAccountWithId(context.Background(), "account-1", "admin-user", "example.com", false) + account := newAccountWithId(context.Background(), "account-1", "admin-user", "example.com", "", "", false) err = manager.Store.SaveAccount(context.Background(), account) require.NoError(t, err) @@ -1824,7 +1950,7 @@ func TestRejectUser(t *testing.T) { require.NoError(t, err) // Create user pending approval - pendingUser := types.NewRegularUser("pending-user") + pendingUser := types.NewRegularUser("pending-user", "", "") pendingUser.AccountID = account.Id pendingUser.Blocked = true pendingUser.PendingApproval = true @@ -1840,7 +1966,7 @@ func TestRejectUser(t *testing.T) { require.Error(t, err) // Test rejection of non-pending user should fail - regularUser := types.NewRegularUser("regular-user") + regularUser := types.NewRegularUser("regular-user", "", "") regularUser.AccountID = account.Id err = manager.Store.SaveUser(context.Background(), regularUser) require.NoError(t, err) @@ -1850,7 +1976,7 @@ func TestRejectUser(t *testing.T) { assert.Contains(t, err.Error(), "not pending approval") // Test rejection by non-admin should fail - pendingUser2 := types.NewRegularUser("pending-user-2") + pendingUser2 := types.NewRegularUser("pending-user-2", "", "") pendingUser2.AccountID = account.Id pendingUser2.Blocked = true pendingUser2.PendingApproval = true @@ -1860,3 +1986,149 @@ func TestRejectUser(t *testing.T) { err = manager.RejectUser(context.Background(), account.Id, regularUser.Id, pendingUser2.Id) require.Error(t, err) } + +func TestUser_Operations_WithEmbeddedIDP(t *testing.T) { + ctx := context.Background() + + // Create temporary directory for Dex + tmpDir := t.TempDir() + dexDataDir := tmpDir + "/dex" + require.NoError(t, os.MkdirAll(dexDataDir, 0700)) + + // Create embedded IDP config + embeddedIdPConfig := &idp.EmbeddedIdPConfig{ + Enabled: true, + Issuer: "http://localhost:5556/dex", + Storage: idp.EmbeddedStorageConfig{ + Type: "sqlite3", + Config: idp.EmbeddedStorageTypeConfig{ + File: dexDataDir + "/dex.db", + }, + }, + } + + // Create embedded IDP manager + embeddedIdp, err := idp.NewEmbeddedIdPManager(ctx, embeddedIdPConfig, nil) + require.NoError(t, err) + defer func() { _ = embeddedIdp.Stop(ctx) }() + + // Create test store + testStore, cleanup, err := store.NewTestStoreFromSQL(ctx, "", tmpDir) + require.NoError(t, err) + defer cleanup() + + // Create account with owner user + account := newAccountWithId(ctx, mockAccountID, mockUserID, "", "owner@test.com", "Owner User", false) + require.NoError(t, testStore.SaveAccount(ctx, account)) + + // Create mock network map controller + ctrl := gomock.NewController(t) + networkMapControllerMock := network_map.NewMockController(ctrl) + networkMapControllerMock.EXPECT(). + OnPeersDeleted(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil). + AnyTimes() + + // Create account manager with embedded IDP + permissionsManager := permissions.NewManager(testStore) + am := DefaultAccountManager{ + Store: testStore, + eventStore: &activity.InMemoryEventStore{}, + permissionsManager: permissionsManager, + idpManager: embeddedIdp, + cacheLoading: map[string]chan struct{}{}, + networkMapController: networkMapControllerMock, + } + + // Initialize cache manager + cacheStore, err := nbcache.NewStore(ctx, nbcache.DefaultIDPCacheExpirationMax, nbcache.DefaultIDPCacheCleanupInterval, nbcache.DefaultIDPCacheOpenConn) + require.NoError(t, err) + am.cacheManager = nbcache.NewAccountUserDataCache(am.loadAccount, cacheStore) + am.externalCacheManager = nbcache.NewUserDataCache(cacheStore) + + t.Run("create regular user returns password", func(t *testing.T) { + userInfo, err := am.CreateUser(ctx, mockAccountID, mockUserID, &types.UserInfo{ + Email: "newuser@test.com", + Name: "New User", + Role: "user", + AutoGroups: []string{}, + IsServiceUser: false, + }) + require.NoError(t, err) + require.NotNil(t, userInfo) + + // Verify user data + assert.Equal(t, "newuser@test.com", userInfo.Email) + assert.Equal(t, "New User", userInfo.Name) + assert.Equal(t, "user", userInfo.Role) + assert.NotEmpty(t, userInfo.ID) + + // IMPORTANT: Password should be returned for embedded IDP + assert.NotEmpty(t, userInfo.Password, "Password should be returned for embedded IDP user") + t.Logf("Created user: ID=%s, Email=%s, Password=%s", userInfo.ID, userInfo.Email, userInfo.Password) + + // Verify user ID is in Dex encoded format + rawUserID, connectorID, err := dex.DecodeDexUserID(userInfo.ID) + require.NoError(t, err) + assert.NotEmpty(t, rawUserID) + assert.Equal(t, "local", connectorID) + t.Logf("Decoded user ID: rawUserID=%s, connectorID=%s", rawUserID, connectorID) + + // Verify user exists in database with correct data + dbUser, err := testStore.GetUserByUserID(ctx, store.LockingStrengthNone, userInfo.ID) + require.NoError(t, err) + assert.Equal(t, "newuser@test.com", dbUser.Email) + assert.Equal(t, "New User", dbUser.Name) + + // Store user ID for delete test + createdUserID := userInfo.ID + + t.Run("delete user works", func(t *testing.T) { + err := am.DeleteUser(ctx, mockAccountID, mockUserID, createdUserID) + require.NoError(t, err) + + // Verify user is deleted from database + _, err = testStore.GetUserByUserID(ctx, store.LockingStrengthNone, createdUserID) + assert.Error(t, err, "User should be deleted from database") + }) + }) + + t.Run("create service user does not return password", func(t *testing.T) { + userInfo, err := am.CreateUser(ctx, mockAccountID, mockUserID, &types.UserInfo{ + Name: "Service User", + Role: "user", + AutoGroups: []string{}, + IsServiceUser: true, + }) + require.NoError(t, err) + require.NotNil(t, userInfo) + + assert.True(t, userInfo.IsServiceUser) + assert.Equal(t, "Service User", userInfo.Name) + // Service users don't have passwords + assert.Empty(t, userInfo.Password, "Service users should not have passwords") + }) + + t.Run("duplicate email fails", func(t *testing.T) { + // Create first user + _, err := am.CreateUser(ctx, mockAccountID, mockUserID, &types.UserInfo{ + Email: "duplicate@test.com", + Name: "First User", + Role: "user", + AutoGroups: []string{}, + IsServiceUser: false, + }) + require.NoError(t, err) + + // Try to create second user with same email + _, err = am.CreateUser(ctx, mockAccountID, mockUserID, &types.UserInfo{ + Email: "duplicate@test.com", + Name: "Second User", + Role: "user", + AutoGroups: []string{}, + IsServiceUser: false, + }) + assert.Error(t, err, "Creating user with duplicate email should fail") + t.Logf("Duplicate email error: %v", err) + }) +} diff --git a/management/server/users/manager.go b/management/server/users/manager.go index e07f28706..1a05b1a7c 100644 --- a/management/server/users/manager.go +++ b/management/server/users/manager.go @@ -10,6 +10,7 @@ import ( type Manager interface { GetUser(ctx context.Context, userID string) (*types.User, error) + GetUserWithGroups(ctx context.Context, userID string) (*types.User, []*types.Group, error) } type managerImpl struct { @@ -29,6 +30,31 @@ func (m *managerImpl) GetUser(ctx context.Context, userID string) (*types.User, return m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) } +// GetUserWithGroups returns the user and the *types.Group records for the user's AutoGroups, in the same order as +// AutoGroups. Group ids that don't resolve to a stored group are skipped from the returned slice (the parallel id list is +// derivable from the returned User). Wraps two store calls today; can be optimised to a single JOIN later if needed. +// Any store error returns (nil, nil, err) so callers never receive a valid user alongside a non-nil error. +func (m *managerImpl) GetUserWithGroups(ctx context.Context, userID string) (*types.User, []*types.Group, error) { + user, err := m.store.GetUserByUserID(ctx, store.LockingStrengthNone, userID) + if err != nil { + return nil, nil, err + } + if len(user.AutoGroups) == 0 { + return user, nil, nil + } + groupsMap, err := m.store.GetGroupsByIDs(ctx, store.LockingStrengthNone, user.AccountID, user.AutoGroups) + if err != nil { + return nil, nil, err + } + groups := make([]*types.Group, 0, len(user.AutoGroups)) + for _, id := range user.AutoGroups { + if g, ok := groupsMap[id]; ok && g != nil { + groups = append(groups, g) + } + } + return user, groups, nil +} + func NewManagerMock() Manager { return &managerMock{} } @@ -47,3 +73,11 @@ func (m *managerMock) GetUser(ctx context.Context, userID string) (*types.User, return nil, errors.New("user not found") } } + +func (m *managerMock) GetUserWithGroups(ctx context.Context, userID string) (*types.User, []*types.Group, error) { + user, err := m.GetUser(ctx, userID) + if err != nil { + return nil, nil, err + } + return user, nil, nil +} diff --git a/management/server/util/util.go b/management/server/util/util.go index 617484274..d85b55f02 100644 --- a/management/server/util/util.go +++ b/management/server/util/util.go @@ -19,34 +19,3 @@ func Difference(a, b []string) []string { func ToPtr[T any](value T) *T { return &value } - -type comparableObject[T any] interface { - Equal(other T) bool -} - -func MergeUnique[T comparableObject[T]](arr1, arr2 []T) []T { - var result []T - - for _, item := range arr1 { - if !contains(result, item) { - result = append(result, item) - } - } - - for _, item := range arr2 { - if !contains(result, item) { - result = append(result, item) - } - } - - return result -} - -func contains[T comparableObject[T]](slice []T, element T) bool { - for _, item := range slice { - if item.Equal(element) { - return true - } - } - return false -} diff --git a/proxy/Dockerfile b/proxy/Dockerfile new file mode 100644 index 000000000..22c4cbfaa --- /dev/null +++ b/proxy/Dockerfile @@ -0,0 +1,20 @@ +FROM golang:1.25-alpine AS builder +WORKDIR /app + +RUN echo "netbird:x:1000:1000:netbird:/var/lib/netbird:/sbin/nologin" > /tmp/passwd && \ + echo "netbird:x:1000:netbird" > /tmp/group && \ + mkdir -p /tmp/var/lib/netbird && \ + mkdir -p /tmp/certs + +FROM gcr.io/distroless/base:debug +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/netbird-proxy /go/bin/netbird-proxy +COPY --from=builder /tmp/passwd /etc/passwd +COPY --from=builder /tmp/group /etc/group +COPY --from=builder --chown=1000:1000 /tmp/var/lib/netbird /var/lib/netbird +COPY --from=builder --chown=1000:1000 --chmod=755 /tmp/certs /certs +USER netbird:netbird +ENV HOME=/var/lib/netbird +ENV NB_PROXY_ADDRESS=":8443" +EXPOSE 8443 +ENTRYPOINT ["/go/bin/netbird-proxy"] diff --git a/proxy/Dockerfile.multistage b/proxy/Dockerfile.multistage new file mode 100644 index 000000000..4f360a811 --- /dev/null +++ b/proxy/Dockerfile.multistage @@ -0,0 +1,39 @@ +FROM golang:1.25-alpine AS builder +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY client ./client +COPY dns ./dns +COPY encryption ./encryption +COPY flow ./flow +COPY formatter ./formatter +COPY monotime ./monotime +COPY management ./management +COPY proxy ./proxy +COPY route ./route +COPY shared ./shared +COPY sharedsock ./sharedsock +COPY trustedproxy ./trustedproxy +COPY upload-server ./upload-server +COPY util ./util +COPY version ./version +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o netbird-proxy ./proxy/cmd/proxy + +RUN echo "netbird:x:1000:1000:netbird:/var/lib/netbird:/sbin/nologin" > /tmp/passwd && \ + echo "netbird:x:1000:netbird" > /tmp/group && \ + mkdir -p /tmp/var/lib/netbird && \ + mkdir -p /tmp/certs + +FROM gcr.io/distroless/base:debug +COPY --from=builder /app/netbird-proxy /usr/bin/netbird-proxy +COPY --from=builder /tmp/passwd /etc/passwd +COPY --from=builder /tmp/group /etc/group +COPY --from=builder --chown=1000:1000 /tmp/var/lib/netbird /var/lib/netbird +COPY --from=builder --chown=1000:1000 --chmod=755 /tmp/certs /certs +USER netbird:netbird +ENV HOME=/var/lib/netbird +ENV NB_PROXY_ADDRESS=":8443" +EXPOSE 8443 +ENTRYPOINT ["/usr/bin/netbird-proxy"] diff --git a/proxy/LICENSE b/proxy/LICENSE new file mode 100644 index 000000000..be3f7b28e --- /dev/null +++ b/proxy/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/proxy/README.md b/proxy/README.md new file mode 100644 index 000000000..6af7cadd2 --- /dev/null +++ b/proxy/README.md @@ -0,0 +1,80 @@ +# Netbird Reverse Proxy + +The NetBird Reverse Proxy is a separate service that can act as a public entrypoint to certain resources within a NetBird network. +At a high level, the way that it operates is: +- Configured routes are communicated from the Management server to the proxy. +- For each route the proxy creates a NetBird connection to the NetBird Peer that hosts the resource. +- When traffic hits the proxy at the address and path configured for the proxied resource, the NetBird Proxy brings up a relevant authentication method for that resource. +- On successful authentication the proxy will forward traffic onwards to the NetBird Peer. + +Proxy Authentication methods supported are: +- No authentication +- Oauth2/OIDC +- Emailed Magic Link +- Simple PIN +- HTTP Basic Auth Username and Password + +## Management Connection and Authentication + +The Proxy communicates with the Management server over a gRPC connection. +Proxies act as clients to the Management server, the following RPCs are used: +- Server-side streaming for proxied service updates. +- Client-side streaming for proxy logs. + +To authenticate with the Management server, the proxy server uses Machine-to-Machine OAuth2. +If you are using the embedded IdP //TODO: explain how to get credentials. +Otherwise, create a new machine-to-machine profile in your IdP for proxy servers and set the relevant settings in the proxy's environment or flags (see below). + +## User Authentication + +When a request hits the Proxy, it looks up the permitted authentication methods for the Host domain. +If no authentication methods are registered for the Host domain, then no authentication will be applied (for fully public resources). +If any authentication methods are registered for the Host domain, then the Proxy will first serve an authentication page allowing the user to select an authentication method (from the permitted methods) and enter the required information for that authentication method. +If the user is successfully authenticated, their request will be forwarded through to the Proxy to be proxied to the relevant Peer. +Successful authentication does not guarantee a successful forwarding of the request as there may be failures behind the Proxy, such as with Peer connectivity or the underlying resource. + +## TLS + +Due to the authentication provided, the Proxy uses HTTPS for its endpoint, even if the underlying service is HTTP. +Certificate generation can either be via ACME (by default, using Let's Encrypt, but alternative ACME providers can be used) or through certificate files. +When not using ACME, the proxy server attempts to load a certificate and key from the files `tls.crt` and `tls.key` in a specified certificate directory. +When using ACME, the proxy server will store generated certificates in the specified certificate directory. + + +## Auth UI + +The authentication UI is a Vite + React application located in the `web/` directory. It is embedded into the Go binary at build time. + +To build the UI: +```bash +cd web +npm install +npm run build +``` + +For UI development with hot reload (served at http://localhost:3031): +```bash +npm run dev +``` + +The built assets in `web/dist/` are embedded via `//go:embed` and served by the `web.ServeHTTP` handler. + +## Configuration + +NetBird Proxy deployment configuration is via flags or environment variables, with flags taking precedence over the environment. +The following deployment configuration is available: + +| Flag | Env | Purpose | Default | +|------------------|----------------------------------|------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------| +| `-debug` | `NB_PROXY_DEBUG_LOGS` | Enable debug logging | `false` | +| `-mgmt` | `NB_PROXY_MANAGEMENT_ADDRESS` | The address of the management server for the proxy to get configuration from. | `"https://api.netbird.io:443"` | +| `-addr` | `NB_PROXY_ADDRESS` | The address that the reverse proxy will listen on. | `":443` | +| `-url` | `NB_PROXY_URL` | The URL that the proxy will be reached at (where endpoints will be CNAMEd to). If unset, this will fall back to the proxy address. | `"proxy.netbird.io"` | +| `-cert-dir` | `NB_PROXY_CERTIFICATE_DIRECTORY` | The location that certificates are stored in. | `"./certs"` | +| `-acme-certs` | `NB_PROXY_ACME_CERTIFICATES` | Whether to use ACME to generate certificates. | `false` | +| `-acme-addr` | `NB_PROXY_ACME_ADDRESS` | The HTTP address the proxy will listen on to respond to HTTP-01 ACME challenges | `":80"` | +| `-acme-dir` | `NB_PROXY_ACME_DIRECTORY` | The directory URL of the ACME server to be used | `"https://acme-v02.api.letsencrypt.org/directory"` | +| `-oidc-id` | `NB_PROXY_OIDC_CLIENT_ID` | The OAuth2 Client ID for OIDC User Authentication | `"netbird-proxy"` | +| `-oidc-secret` | `NB_PROXY_OIDC_CLIENT_SECRET` | The OAuth2 Client Secret for OIDC User Authentication | `""` | +| `-oidc-endpoint` | `NB_PROXY_OIDC_ENDPOINT` | The OAuth2 provider endpoint for OIDC User Authentication | `"https://api.netbird.io/oauth2"` | +| `-oidc-scopes` | `NB_PROXY_OIDC_SCOPES` | The OAuth2 scopes for OIDC User Authentication, comma separated | `"openid,profile,email"` | diff --git a/proxy/auth/auth.go b/proxy/auth/auth.go new file mode 100644 index 000000000..78f0097d5 --- /dev/null +++ b/proxy/auth/auth.go @@ -0,0 +1,107 @@ +// Package auth contains exported proxy auth values. +// These are used to ensure coherent usage across management and proxy implementations. +package auth + +import ( + "crypto/ed25519" + "crypto/tls" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Method string + +const ( + MethodPassword Method = "password" + MethodPIN Method = "pin" + MethodOIDC Method = "oidc" + MethodHeader Method = "header" +) + +func (m Method) String() string { + return string(m) +} + +const ( + SessionCookieName = "nb_session" + DefaultSessionExpiry = 24 * time.Hour + SessionJWTIssuer = "netbird-management" +) + +// ResolveProto determines the protocol scheme based on the forwarded proto +// configuration. When set to "http" or "https" the value is used directly. +// Otherwise TLS state is used: if conn is non-nil "https" is returned, else "http". +func ResolveProto(forwardedProto string, conn *tls.ConnectionState) string { + switch forwardedProto { + case "http", "https": + return forwardedProto + default: + if conn != nil { + return "https" + } + return "http" + } +} + +// ValidateSessionJWT validates a session JWT and returns the user ID, the +// user's email (when carried), the authentication method, any embedded +// group memberships, and the parallel group display names. email, +// groups, and groupNames may be empty for tokens minted before those +// claims were introduced. groupNames pairs positionally with groups. +func ValidateSessionJWT(tokenString, domain string, publicKey ed25519.PublicKey) (userID, email, method string, groups, groupNames []string, err error) { + if publicKey == nil { + return "", "", "", nil, nil, fmt.Errorf("no public key configured for domain") + } + + token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodEd25519); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return publicKey, nil + }, jwt.WithAudience(domain), jwt.WithIssuer(SessionJWTIssuer)) + if err != nil { + return "", "", "", nil, nil, fmt.Errorf("parse token: %w", err) + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || !token.Valid { + return "", "", "", nil, nil, fmt.Errorf("invalid token claims") + } + + sub, _ := claims.GetSubject() + if sub == "" { + return "", "", "", nil, nil, fmt.Errorf("missing subject claim") + } + + methodClaim, _ := claims["method"].(string) + emailClaim, _ := claims["email"].(string) + groups = extractGroupsClaim(claims["groups"]) + groupNames = extractGroupsClaim(claims["group_names"]) + + return sub, emailClaim, methodClaim, groups, groupNames, nil +} + +// extractGroupsClaim decodes the "groups" claim into a string slice. The JWT +// library decodes JSON arrays as []interface{}, so we coerce element-wise +// and skip non-string entries silently. +func extractGroupsClaim(claim interface{}) []string { + raw, ok := claim.([]interface{}) + if !ok { + return nil + } + if len(raw) == 0 { + return nil + } + groups := make([]string, 0, len(raw)) + for _, v := range raw { + if s, ok := v.(string); ok && s != "" { + groups = append(groups, s) + } + } + if len(groups) == 0 { + return nil + } + return groups +} diff --git a/proxy/cmd/proxy/cmd/debug.go b/proxy/cmd/proxy/cmd/debug.go new file mode 100644 index 000000000..360c7a516 --- /dev/null +++ b/proxy/cmd/proxy/cmd/debug.go @@ -0,0 +1,332 @@ +package cmd + +import ( + "fmt" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/proxy/internal/debug" +) + +var ( + debugAddr string + jsonOutput bool + + // status filters + statusFilterByIPs []string + statusFilterByNames []string + statusFilterByStatus string + statusFilterByConnectionType string +) + +var debugCmd = &cobra.Command{ + Use: "debug", + Short: "Debug commands for inspecting proxy state", + Long: "Debug commands for inspecting the reverse proxy state via the debug HTTP endpoint.", +} + +var debugHealthCmd = &cobra.Command{ + Use: "health", + Short: "Show proxy health status", + RunE: runDebugHealth, + SilenceUsage: true, +} + +var debugClientsCmd = &cobra.Command{ + Use: "clients", + Aliases: []string{"list"}, + Short: "List all connected clients", + RunE: runDebugClients, + SilenceUsage: true, +} + +var debugStatusCmd = &cobra.Command{ + Use: "status ", + Short: "Show client status", + Args: cobra.ExactArgs(1), + RunE: runDebugStatus, + SilenceUsage: true, +} + +var debugSyncCmd = &cobra.Command{ + Use: "sync-response ", + Short: "Show client sync response", + Args: cobra.ExactArgs(1), + RunE: runDebugSync, + SilenceUsage: true, +} + +var ( + pingTimeout time.Duration + pingIPv4 bool + pingIPv6 bool +) + +var debugPingCmd = &cobra.Command{ + Use: "ping [port]", + Short: "TCP ping through a client", + Long: "Perform a TCP ping through a client's network to test connectivity.\nPort defaults to 80 if not specified.", + Args: cobra.RangeArgs(2, 3), + RunE: runDebugPing, + SilenceUsage: true, +} + +var debugLogCmd = &cobra.Command{ + Use: "log", + Short: "Manage client logging", + Long: "Commands to manage logging settings for a client connected through the proxy.", +} + +var debugLogLevelCmd = &cobra.Command{ + Use: "level ", + Short: "Set client log level", + Long: "Set the log level for a client (trace, debug, info, warn, error).", + Args: cobra.ExactArgs(2), + RunE: runDebugLogLevel, + SilenceUsage: true, +} + +var debugStartCmd = &cobra.Command{ + Use: "start ", + Short: "Start a client", + Args: cobra.ExactArgs(1), + RunE: runDebugStart, + SilenceUsage: true, +} + +var debugStopCmd = &cobra.Command{ + Use: "stop ", + Short: "Stop a client", + Args: cobra.ExactArgs(1), + RunE: runDebugStop, + SilenceUsage: true, +} + +var debugPerfCmd = &cobra.Command{ + Use: "perf ", + Short: "Live-retune the tunnel buffer pool cap on all running clients", + Args: cobra.ExactArgs(1), + RunE: runDebugPerfSet, + SilenceUsage: true, +} + +var debugRuntimeCmd = &cobra.Command{ + Use: "runtime", + Short: "Show runtime stats (heap, goroutines, RSS)", + Args: cobra.NoArgs, + RunE: runDebugRuntime, + SilenceUsage: true, +} + +var debugCaptureCmd = &cobra.Command{ + Use: "capture [filter expression]", + Short: "Capture packets on a client's WireGuard interface", + Long: `Captures decrypted packets flowing through a client's WireGuard interface. + +Default output is human-readable text. Use --pcap or --output for pcap binary. +Filter arguments after the account ID use BPF-like syntax. + +Examples: + netbird-proxy debug capture + netbird-proxy debug capture --duration 1m host 10.0.0.1 + netbird-proxy debug capture host 10.0.0.1 and tcp port 443 + netbird-proxy debug capture not port 22 + netbird-proxy debug capture -o capture.pcap + netbird-proxy debug capture --pcap | tcpdump -r - -n + netbird-proxy debug capture --pcap | tshark -r -`, + Args: cobra.MinimumNArgs(1), + RunE: runDebugCapture, + SilenceUsage: true, +} + +func init() { + debugCmd.PersistentFlags().StringVar(&debugAddr, "addr", envStringOrDefault("NB_PROXY_DEBUG_ADDRESS", "localhost:8444"), "Debug endpoint address") + debugCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output JSON instead of pretty format") + + debugStatusCmd.Flags().StringSliceVar(&statusFilterByIPs, "filter-by-ips", nil, "Filter by peer IPs (comma-separated)") + debugStatusCmd.Flags().StringSliceVar(&statusFilterByNames, "filter-by-names", nil, "Filter by peer names (comma-separated)") + debugStatusCmd.Flags().StringVar(&statusFilterByStatus, "filter-by-status", "", "Filter by status (idle|connecting|connected)") + debugStatusCmd.Flags().StringVar(&statusFilterByConnectionType, "filter-by-connection-type", "", "Filter by connection type (P2P|Relayed)") + + debugPingCmd.Flags().DurationVar(&pingTimeout, "timeout", 0, "Ping timeout (e.g., 10s)") + debugPingCmd.Flags().BoolVarP(&pingIPv4, "ipv4", "4", false, "Force IPv4") + debugPingCmd.Flags().BoolVarP(&pingIPv6, "ipv6", "6", false, "Force IPv6") + debugPingCmd.MarkFlagsMutuallyExclusive("ipv4", "ipv6") + + debugCaptureCmd.Flags().DurationP("duration", "d", 0, "Capture duration (0 = server default)") + debugCaptureCmd.Flags().Bool("pcap", false, "Force pcap binary output (default when --output is set)") + debugCaptureCmd.Flags().BoolP("verbose", "v", false, "Show seq/ack, TTL, window, total length (text mode)") + debugCaptureCmd.Flags().Bool("ascii", false, "Print payload as ASCII after each packet (text mode)") + debugCaptureCmd.Flags().StringP("output", "o", "", "Write pcap to file instead of stdout") + + debugCmd.AddCommand(debugHealthCmd) + debugCmd.AddCommand(debugClientsCmd) + debugCmd.AddCommand(debugStatusCmd) + debugCmd.AddCommand(debugSyncCmd) + debugCmd.AddCommand(debugPingCmd) + debugLogCmd.AddCommand(debugLogLevelCmd) + debugCmd.AddCommand(debugLogCmd) + debugCmd.AddCommand(debugStartCmd) + debugCmd.AddCommand(debugStopCmd) + debugCmd.AddCommand(debugPerfCmd) + debugCmd.AddCommand(debugRuntimeCmd) + debugCmd.AddCommand(debugCaptureCmd) + + rootCmd.AddCommand(debugCmd) +} + +func getDebugClient(cmd *cobra.Command) *debug.Client { + return debug.NewClient(debugAddr, jsonOutput, cmd.OutOrStdout()) +} + +func runDebugHealth(cmd *cobra.Command, _ []string) error { + return getDebugClient(cmd).Health(cmd.Context()) +} + +func runDebugClients(cmd *cobra.Command, _ []string) error { + return getDebugClient(cmd).ListClients(cmd.Context()) +} + +func runDebugStatus(cmd *cobra.Command, args []string) error { + return getDebugClient(cmd).ClientStatus(cmd.Context(), args[0], debug.StatusFilters{ + IPs: statusFilterByIPs, + Names: statusFilterByNames, + Status: statusFilterByStatus, + ConnectionType: statusFilterByConnectionType, + }) +} + +func runDebugSync(cmd *cobra.Command, args []string) error { + return getDebugClient(cmd).ClientSyncResponse(cmd.Context(), args[0]) +} + +func runDebugPing(cmd *cobra.Command, args []string) error { + port := 80 + if len(args) > 2 { + p, err := strconv.Atoi(args[2]) + if err != nil { + return fmt.Errorf("invalid port: %w", err) + } + port = p + } + var ipVersion string + switch { + case pingIPv4: + ipVersion = "4" + case pingIPv6: + ipVersion = "6" + } + return getDebugClient(cmd).PingTCP(cmd.Context(), args[0], args[1], port, pingTimeout, ipVersion) +} + +func runDebugLogLevel(cmd *cobra.Command, args []string) error { + return getDebugClient(cmd).SetLogLevel(cmd.Context(), args[0], args[1]) +} + +func runDebugStart(cmd *cobra.Command, args []string) error { + return getDebugClient(cmd).StartClient(cmd.Context(), args[0]) +} + +func runDebugStop(cmd *cobra.Command, args []string) error { + return getDebugClient(cmd).StopClient(cmd.Context(), args[0]) +} + +func runDebugPerfSet(cmd *cobra.Command, args []string) error { + n, err := strconv.ParseUint(args[0], 10, 32) + if err != nil { + return fmt.Errorf("invalid value %q: %w", args[0], err) + } + return getDebugClient(cmd).PerfSet(cmd.Context(), uint32(n)) +} + +func runDebugRuntime(cmd *cobra.Command, _ []string) error { + return getDebugClient(cmd).Runtime(cmd.Context()) +} + +func runDebugCapture(cmd *cobra.Command, args []string) error { + duration, _ := cmd.Flags().GetDuration("duration") + forcePcap, _ := cmd.Flags().GetBool("pcap") + verbose, _ := cmd.Flags().GetBool("verbose") + ascii, _ := cmd.Flags().GetBool("ascii") + outPath, _ := cmd.Flags().GetString("output") + + // Default to text. Use pcap when --pcap is set or --output is given. + wantText := !forcePcap && outPath == "" + + var filterExpr string + if len(args) > 1 { + filterExpr = strings.Join(args[1:], " ") + } + + ctx, cancel := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + out, cleanup, err := captureOutputWriter(cmd, outPath) + if err != nil { + return err + } + defer cleanup() + + if wantText { + cmd.PrintErrln("Capturing packets... Press Ctrl+C to stop.") + } else { + cmd.PrintErrln("Capturing packets (pcap)... Press Ctrl+C to stop.") + } + + var durationStr string + if duration > 0 { + durationStr = duration.String() + } + + err = getDebugClient(cmd).Capture(ctx, debug.CaptureOptions{ + AccountID: args[0], + Duration: durationStr, + FilterExpr: filterExpr, + Text: wantText, + Verbose: verbose, + ASCII: ascii, + Output: out, + }) + if err != nil { + return err + } + + cmd.PrintErrln("\nCapture finished.") + return nil +} + +// captureOutputWriter returns the writer and cleanup function for capture output. +func captureOutputWriter(cmd *cobra.Command, outPath string) (out *os.File, cleanup func(), err error) { + if outPath != "" { + 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() { + if err := f.Close(); err != nil { + cmd.PrintErrf("close output file: %v\n", err) + } + if fi, err := os.Stat(tmpPath); err == nil && fi.Size() > 0 { + if err := os.Rename(tmpPath, outPath); err != nil { + cmd.PrintErrf("rename output file: %v\n", err) + } else { + cmd.PrintErrf("Wrote %s\n", outPath) + } + } else { + os.Remove(tmpPath) + } + }, nil + } + + return os.Stdout, func() { + // no cleanup needed for stdout + }, nil +} diff --git a/proxy/cmd/proxy/cmd/root.go b/proxy/cmd/proxy/cmd/root.go new file mode 100644 index 000000000..9b180a5c4 --- /dev/null +++ b/proxy/cmd/proxy/cmd/root.go @@ -0,0 +1,307 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "golang.org/x/crypto/acme" + + "github.com/netbirdio/netbird/shared/management/domain" + + "github.com/netbirdio/netbird/client/embed" + "github.com/netbirdio/netbird/proxy" + nbacme "github.com/netbirdio/netbird/proxy/internal/acme" + "github.com/netbirdio/netbird/trustedproxy" + "github.com/netbirdio/netbird/util" +) + +const ( + // envPreallocatedBuffers caps the per-tunnel buffer pool. Zero (unset) + // keeps the upstream uncapped default. + envPreallocatedBuffers = "NB_PROXY_PREALLOCATED_BUFFERS" + // envMaxBatchSize overrides the per-tunnel batch size, which controls + // how many buffers each receive/TUN worker eagerly allocates. Zero + // (unset) keeps the platform default. + envMaxBatchSize = "NB_PROXY_MAX_BATCH_SIZE" +) + +const DefaultManagementURL = "https://api.netbird.io:443" + +// envProxyToken is the environment variable name for the proxy access token. +// +//nolint:gosec +const envProxyToken = "NB_PROXY_TOKEN" + +var ( + Version = "dev" + Commit = "unknown" + BuildDate = "unknown" + GoVersion = "unknown" +) + +var ( + logLevel string + debugLogs bool + mgmtAddr string + addr string + proxyDomain string + maxDialTimeout time.Duration + maxSessionIdleTimeout time.Duration + certDir string + acmeCerts bool + acmeAddr string + acmeDir string + acmeEABKID string + acmeEABHMACKey string + acmeChallengeType string + debugEndpoint bool + debugEndpointAddr string + healthAddr string + forwardedProto string + trustedProxies string + certFile string + certKeyFile string + certLockMethod string + wildcardCertDir string + wgPort uint16 + proxyProtocol bool + preSharedKey string + supportsCustomPorts bool + requireSubdomain bool + private bool + geoDataDir string + crowdsecAPIURL string + crowdsecAPIKey string +) + +var rootCmd = &cobra.Command{ + Use: "proxy", + Short: "NetBird reverse proxy server", + Long: "NetBird reverse proxy server for proxying traffic to NetBird networks.", + Version: Version, + SilenceUsage: true, + RunE: runServer, +} + +func init() { + rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", envStringOrDefault("NB_PROXY_LOG_LEVEL", "info"), "Log level: panic, fatal, error, warn, info, debug, trace") + rootCmd.PersistentFlags().BoolVar(&debugLogs, "debug", envBoolOrDefault("NB_PROXY_DEBUG_LOGS", false), "Enable debug logs") + _ = rootCmd.PersistentFlags().MarkDeprecated("debug", "use --log-level instead") + rootCmd.Flags().StringVar(&mgmtAddr, "mgmt", envStringOrDefault("NB_PROXY_MANAGEMENT_ADDRESS", DefaultManagementURL), "Management address to connect to") + rootCmd.Flags().StringVar(&addr, "addr", envStringOrDefault("NB_PROXY_ADDRESS", ":443"), "Reverse proxy address to listen on") + rootCmd.Flags().StringVar(&proxyDomain, "domain", envStringOrDefault("NB_PROXY_DOMAIN", ""), "The Domain at which this proxy will be reached. e.g., netbird.example.com") + rootCmd.Flags().StringVar(&certDir, "cert-dir", envStringOrDefault("NB_PROXY_CERTIFICATE_DIRECTORY", "./certs"), "Directory to store certificates") + rootCmd.Flags().BoolVar(&acmeCerts, "acme-certs", envBoolOrDefault("NB_PROXY_ACME_CERTIFICATES", false), "Generate ACME certificates automatically") + rootCmd.Flags().StringVar(&acmeAddr, "acme-addr", envStringOrDefault("NB_PROXY_ACME_ADDRESS", ":80"), "HTTP address for ACME HTTP-01 challenges (only used when acme-challenge-type is http-01)") + rootCmd.Flags().StringVar(&acmeDir, "acme-dir", envStringOrDefault("NB_PROXY_ACME_DIRECTORY", acme.LetsEncryptURL), "URL of ACME challenge directory") + rootCmd.Flags().StringVar(&acmeEABKID, "acme-eab-kid", envStringOrDefault("NB_PROXY_ACME_EAB_KID", ""), "ACME EAB KID for account registration") + rootCmd.Flags().StringVar(&acmeEABHMACKey, "acme-eab-hmac-key", envStringOrDefault("NB_PROXY_ACME_EAB_HMAC_KEY", ""), "ACME EAB HMAC key for account registration") + rootCmd.Flags().StringVar(&acmeChallengeType, "acme-challenge-type", envStringOrDefault("NB_PROXY_ACME_CHALLENGE_TYPE", "tls-alpn-01"), "ACME challenge type: tls-alpn-01 (default, port 443 only) or http-01 (requires port 80)") + rootCmd.Flags().BoolVar(&debugEndpoint, "debug-endpoint", envBoolOrDefault("NB_PROXY_DEBUG_ENDPOINT", false), "Enable debug HTTP endpoint") + rootCmd.Flags().StringVar(&debugEndpointAddr, "debug-endpoint-addr", envStringOrDefault("NB_PROXY_DEBUG_ENDPOINT_ADDRESS", "localhost:8444"), "Address for the debug HTTP endpoint") + rootCmd.Flags().StringVar(&healthAddr, "health-addr", envStringOrDefault("NB_PROXY_HEALTH_ADDRESS", "localhost:8080"), "Address for the health probe endpoint (liveness/readiness/startup)") + rootCmd.Flags().StringVar(&forwardedProto, "forwarded-proto", envStringOrDefault("NB_PROXY_FORWARDED_PROTO", "auto"), "X-Forwarded-Proto value for backends: auto, http, or https") + rootCmd.Flags().StringVar(&trustedProxies, "trusted-proxies", envStringOrDefault("NB_PROXY_TRUSTED_PROXIES", ""), "Comma-separated list of trusted upstream proxy CIDR ranges (e.g. '10.0.0.0/8,192.168.1.1')") + rootCmd.Flags().StringVar(&certFile, "cert-file", envStringOrDefault("NB_PROXY_CERTIFICATE_FILE", "tls.crt"), "TLS certificate filename within the certificate directory") + rootCmd.Flags().StringVar(&certKeyFile, "cert-key-file", envStringOrDefault("NB_PROXY_CERTIFICATE_KEY_FILE", "tls.key"), "TLS certificate key filename within the certificate directory") + rootCmd.Flags().StringVar(&certLockMethod, "cert-lock-method", envStringOrDefault("NB_PROXY_CERT_LOCK_METHOD", "auto"), "Certificate lock method for cross-replica coordination: auto, flock, or k8s-lease") + rootCmd.Flags().StringVar(&wildcardCertDir, "wildcard-cert-dir", envStringOrDefault("NB_PROXY_WILDCARD_CERT_DIR", ""), "Directory containing wildcard certificate pairs (.crt/.key). Wildcard patterns are extracted from SANs automatically") + rootCmd.Flags().Uint16Var(&wgPort, "wg-port", envUint16OrDefault("NB_PROXY_WG_PORT", 0), "WireGuard listen port (0 = random). Fixed port only works with single-account deployments") + rootCmd.Flags().BoolVar(&proxyProtocol, "proxy-protocol", envBoolOrDefault("NB_PROXY_PROXY_PROTOCOL", false), "Enable PROXY protocol on TCP listeners to preserve client IPs behind L4 proxies") + rootCmd.Flags().StringVar(&preSharedKey, "preshared-key", envStringOrDefault("NB_PROXY_PRESHARED_KEY", ""), "Define a pre-shared key for the tunnel between proxy and peers") + rootCmd.Flags().BoolVar(&supportsCustomPorts, "supports-custom-ports", envBoolOrDefault("NB_PROXY_SUPPORTS_CUSTOM_PORTS", true), "Whether the proxy can bind arbitrary ports for UDP/TCP passthrough") + rootCmd.Flags().BoolVar(&requireSubdomain, "require-subdomain", envBoolOrDefault("NB_PROXY_REQUIRE_SUBDOMAIN", false), "Require a subdomain label in front of the cluster domain") + rootCmd.Flags().BoolVar(&private, "private", envBoolOrDefault("NB_PROXY_PRIVATE", false), "Enable private services accessible with NetBird-Only authentication mode.") + _ = rootCmd.Flags().MarkHidden("private") + rootCmd.Flags().DurationVar(&maxDialTimeout, "max-dial-timeout", envDurationOrDefault("NB_PROXY_MAX_DIAL_TIMEOUT", 0), "Cap per-service backend dial timeout (0 = no cap)") + rootCmd.Flags().DurationVar(&maxSessionIdleTimeout, "max-session-idle-timeout", envDurationOrDefault("NB_PROXY_MAX_SESSION_IDLE_TIMEOUT", 0), "Cap per-service session idle timeout (0 = no cap)") + rootCmd.Flags().StringVar(&geoDataDir, "geo-data-dir", envStringOrDefault("NB_PROXY_GEO_DATA_DIR", "/var/lib/netbird/geolocation"), "Directory for the GeoLite2 MMDB file (auto-downloaded if missing)") + rootCmd.Flags().StringVar(&crowdsecAPIURL, "crowdsec-api-url", envStringOrDefault("NB_PROXY_CROWDSEC_API_URL", ""), "CrowdSec LAPI URL for IP reputation checks") + rootCmd.Flags().StringVar(&crowdsecAPIKey, "crowdsec-api-key", envStringOrDefault("NB_PROXY_CROWDSEC_API_KEY", ""), "CrowdSec bouncer API key") +} + +// Execute runs the root command. +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +// SetVersionInfo sets version information for the CLI. +func SetVersionInfo(version, commit, buildDate, goVersion string) { + Version = version + Commit = commit + BuildDate = buildDate + GoVersion = goVersion + rootCmd.Version = version + rootCmd.SetVersionTemplate("Version: {{.Version}}, Commit: " + Commit + ", BuildDate: " + BuildDate + ", Go: " + GoVersion + "\n") +} + +func runServer(cmd *cobra.Command, args []string) error { + proxyToken := os.Getenv(envProxyToken) + if proxyToken == "" { + return fmt.Errorf("proxy token is required: set %s environment variable", envProxyToken) + } + + level := logLevel + if debugLogs { + level = "debug" + } + logger := log.New() + + _ = util.InitLogger(logger, level, util.LogConsole) + + logger.Infof("configured log level: %s", level) + + var wgPool, wgBatch uint64 + var perf embed.Performance + if raw := os.Getenv(envPreallocatedBuffers); raw != "" { + n, err := strconv.ParseUint(raw, 10, 32) + if err != nil { + return fmt.Errorf("invalid %s %q: %w", envPreallocatedBuffers, raw, err) + } + wgPool = n + v := uint32(n) + perf.PreallocatedBuffersPerPool = &v + logger.Infof("tunnel preallocated buffers per pool: %d", n) + } + if raw := os.Getenv(envMaxBatchSize); raw != "" { + n, err := strconv.ParseUint(raw, 10, 32) + if err != nil { + return fmt.Errorf("invalid %s %q: %w", envMaxBatchSize, raw, err) + } + wgBatch = n + v := uint32(n) + perf.MaxBatchSize = &v + logger.Infof("tunnel max batch size override: %d", n) + } + if wgPool > 0 { + // Each bind recv goroutine (IPv4 + IPv6 + ICE relay) plus + // RoutineReadFromTUN eagerly reserves `batch` message buffers for + // the lifetime of the Device. A pool cap below that floor blocks + // the receive pipeline at startup. + batch := wgBatch + if batch == 0 { + batch = 128 + } + const recvGoroutines = 4 + floor := batch * recvGoroutines + if wgPool < floor { + logger.Warnf("%s=%d is below the eager-allocation floor (~%d for batch=%d); startup may deadlock", + envPreallocatedBuffers, wgPool, floor, batch) + } + } + + switch forwardedProto { + case "auto", "http", "https": + default: + return fmt.Errorf("invalid --forwarded-proto value %q: must be auto, http, or https", forwardedProto) + } + + _, err := domain.ValidateDomains([]string{proxyDomain}) + if err != nil { + return fmt.Errorf("invalid domain value %q: %w", proxyDomain, err) + } + + parsedTrustedProxies, err := trustedproxy.Parse(trustedProxies) + if err != nil { + return fmt.Errorf("invalid --trusted-proxies: %w", err) + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) + defer stop() + + srv := proxy.New(ctx, proxy.Config{ + ListenAddr: addr, + Logger: logger, + Version: Version, + ManagementAddress: mgmtAddr, + ProxyURL: proxyDomain, + ProxyToken: proxyToken, + CertificateDirectory: certDir, + CertificateFile: certFile, + CertificateKeyFile: certKeyFile, + GenerateACMECertificates: acmeCerts, + ACMEChallengeAddress: acmeAddr, + ACMEDirectory: acmeDir, + ACMEEABKID: acmeEABKID, + ACMEEABHMACKey: acmeEABHMACKey, + ACMEChallengeType: acmeChallengeType, + DebugEndpointEnabled: debugEndpoint, + DebugEndpointAddress: debugEndpointAddr, + HealthAddr: healthAddr, + ForwardedProto: forwardedProto, + TrustedProxies: parsedTrustedProxies, + CertLockMethod: nbacme.CertLockMethod(certLockMethod), + WildcardCertDir: wildcardCertDir, + WireguardPort: wgPort, + Performance: perf, + ProxyProtocol: proxyProtocol, + PreSharedKey: preSharedKey, + SupportsCustomPorts: supportsCustomPorts, + RequireSubdomain: requireSubdomain, + Private: private, + MaxDialTimeout: maxDialTimeout, + MaxSessionIdleTimeout: maxSessionIdleTimeout, + MappingBatchWatchdog: envDurationOrDefault("NB_PROXY_MAPPING_BATCH_WATCHDOG", 0), + GeoDataDir: geoDataDir, + CrowdSecAPIURL: crowdsecAPIURL, + CrowdSecAPIKey: crowdsecAPIKey, + }) + + return srv.ListenAndServe(ctx, addr) +} + +func envBoolOrDefault(key string, def bool) bool { + v, exists := os.LookupEnv(key) + if !exists { + return def + } + parsed, err := strconv.ParseBool(v) + if err != nil { + log.Warnf("parse %s=%q: %v, using default %v", key, v, err, def) + return def + } + return parsed +} + +func envStringOrDefault(key string, def string) string { + v, exists := os.LookupEnv(key) + if !exists { + return def + } + return v +} + +func envUint16OrDefault(key string, def uint16) uint16 { + v, exists := os.LookupEnv(key) + if !exists { + return def + } + parsed, err := strconv.ParseUint(v, 10, 16) + if err != nil { + log.Warnf("parse %s=%q: %v, using default %d", key, v, err, def) + return def + } + return uint16(parsed) +} + +func envDurationOrDefault(key string, def time.Duration) time.Duration { + v, exists := os.LookupEnv(key) + if !exists { + return def + } + parsed, err := time.ParseDuration(v) + if err != nil { + log.Warnf("parse %s=%q: %v, using default %s", key, v, err, def) + return def + } + return parsed +} diff --git a/proxy/cmd/proxy/main.go b/proxy/cmd/proxy/main.go new file mode 100644 index 000000000..16e7e8ac2 --- /dev/null +++ b/proxy/cmd/proxy/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "net/http" + // nolint:gosec + _ "net/http/pprof" + "runtime" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/cmd/proxy/cmd" +) + +var ( + // Version is the application version (set via ldflags during build) + Version = "dev" + + // Commit is the git commit hash (set via ldflags during build) + Commit = "unknown" + + // BuildDate is the build date (set via ldflags during build) + BuildDate = "unknown" + + // GoVersion is the Go version used to build the binary + GoVersion = runtime.Version() +) + +func main() { + go func() { + log.Println(http.ListenAndServe("localhost:6060", nil)) + }() + cmd.SetVersionInfo(Version, Commit, BuildDate, GoVersion) + cmd.Execute() +} diff --git a/proxy/handle_mapping_stream_test.go b/proxy/handle_mapping_stream_test.go new file mode 100644 index 000000000..67c399e44 --- /dev/null +++ b/proxy/handle_mapping_stream_test.go @@ -0,0 +1,104 @@ +package proxy + +import ( + "context" + "io" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + + "github.com/netbirdio/netbird/proxy/internal/health" + "github.com/netbirdio/netbird/shared/management/proto" +) + +type mockMappingStream struct { + grpc.ClientStream + messages []*proto.GetMappingUpdateResponse + idx int +} + +func (m *mockMappingStream) Recv() (*proto.GetMappingUpdateResponse, error) { + if m.idx >= len(m.messages) { + return nil, io.EOF + } + msg := m.messages[m.idx] + m.idx++ + return msg, nil +} + +func (m *mockMappingStream) Header() (metadata.MD, error) { + return nil, nil //nolint:nilnil +} +func (m *mockMappingStream) Trailer() metadata.MD { return nil } +func (m *mockMappingStream) CloseSend() error { return nil } +func (m *mockMappingStream) Context() context.Context { return context.Background() } +func (m *mockMappingStream) SendMsg(any) error { return nil } +func (m *mockMappingStream) RecvMsg(any) error { return nil } + +func closedChan() chan struct{} { + ch := make(chan struct{}) + close(ch) + return ch +} + +func TestHandleMappingStream_SyncCompleteFlag(t *testing.T) { + checker := health.NewChecker(nil, nil) + s := &Server{ + Logger: log.StandardLogger(), + healthChecker: checker, + routerReady: closedChan(), + } + + stream := &mockMappingStream{ + messages: []*proto.GetMappingUpdateResponse{ + {InitialSyncComplete: true}, + }, + } + + syncDone := false + err := s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{}) + assert.NoError(t, err) + assert.True(t, syncDone, "initial sync should be marked done when flag is set") +} + +func TestHandleMappingStream_NoSyncFlagDoesNotMarkDone(t *testing.T) { + checker := health.NewChecker(nil, nil) + s := &Server{ + Logger: log.StandardLogger(), + healthChecker: checker, + routerReady: closedChan(), + } + + stream := &mockMappingStream{ + messages: []*proto.GetMappingUpdateResponse{ + {}, // no sync flag + }, + } + + syncDone := false + err := s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{}) + assert.NoError(t, err) + assert.False(t, syncDone, "initial sync should not be marked done without flag") +} + +func TestHandleMappingStream_NilHealthChecker(t *testing.T) { + s := &Server{ + Logger: log.StandardLogger(), + routerReady: closedChan(), + } + + stream := &mockMappingStream{ + messages: []*proto.GetMappingUpdateResponse{ + {InitialSyncComplete: true}, + }, + } + + syncDone := false + err := s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{}) + assert.NoError(t, err) + assert.True(t, syncDone, "sync done flag should be set even without health checker") +} diff --git a/proxy/inbound.go b/proxy/inbound.go new file mode 100644 index 000000000..e8f93fbe2 --- /dev/null +++ b/proxy/inbound.go @@ -0,0 +1,572 @@ +package proxy + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + stdlog "log" + "net" + "net/http" + "net/netip" + "strconv" + "sync" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/embed" + "github.com/netbirdio/netbird/proxy/internal/auth" + "github.com/netbirdio/netbird/proxy/internal/debug" + nbtcp "github.com/netbirdio/netbird/proxy/internal/tcp" + "github.com/netbirdio/netbird/proxy/internal/types" +) + +// httpInboundReadHeaderTimeout matches the host-listener read header timeout +// so per-account http.Servers don't leak idle connections. +const httpInboundReadHeaderTimeout = 30 * time.Second + +// httpInboundIdleTimeout caps idle keep-alive on per-account inbound HTTP +// servers; matches the host listener. +const httpInboundIdleTimeout = 90 * time.Second + +// inboundShutdownTimeout caps how long a per-account http.Server gets to +// drain in-flight requests during teardown. +const inboundShutdownTimeout = 5 * time.Second + +// privateInboundPortHTTPS is the WG-side TLS port. Each account's +// embedded netstack binds independently, so a fixed port is fine. +const privateInboundPortHTTPS = 443 + +// privateInboundPortHTTP is the WG-side plain-HTTP port. +const privateInboundPortHTTP = 80 + +// inboundManager wires per-account inbound listeners into the proxy +// pipeline when --private is enabled. When disabled the manager +// is nil and every method on *Server that touches it short-circuits. +type inboundManager struct { + logger *log.Logger + handler http.Handler + tlsConfig *tls.Config + // muxLock guards entries and pendingRoutes. + muxLock sync.Mutex + entries map[types.AccountID]*inboundEntry + pendingRoutes map[types.AccountID][]pendingInboundRoute +} + +// inboundEntry owns the listeners, router and HTTP servers for a single +// account's embedded netstack. errorLogWriters retain the logrus pipe +// writers backing each http.Server's ErrorLog so tearDown can close +// them — otherwise the pipe + its scanner goroutine leak per account. +type inboundEntry struct { + router *nbtcp.Router + tlsListener net.Listener + plainListener net.Listener + httpsServer *http.Server + httpServer *http.Server + errorLogWriters []*io.PipeWriter + cancel context.CancelFunc + wg sync.WaitGroup +} + +// pendingInboundRoute holds a route that arrived before the account's +// listener finished starting. +type pendingInboundRoute struct { + host nbtcp.SNIHost + route nbtcp.Route +} + +// newInboundManager constructs a manager bound to the proxy's HTTP +// handler chain and TLS config. +func newInboundManager(logger *log.Logger, handler http.Handler, tlsConfig *tls.Config) *inboundManager { + return &inboundManager{ + logger: logger, + handler: handler, + tlsConfig: tlsConfig, + entries: make(map[types.AccountID]*inboundEntry), + pendingRoutes: make(map[types.AccountID][]pendingInboundRoute), + } +} + +// onClientReady is registered with NetBird.SetClientLifecycle so the +// listener pair comes up exactly when the embedded client reports ready. +// The returned value is opaque to the roundtrip package; it is handed +// back verbatim to onClientStop on teardown. +func (m *inboundManager) onClientReady(ctx context.Context, accountID types.AccountID, client *embed.Client) any { + if m == nil { + return nil + } + entry, err := m.bringUp(ctx, accountID, client) + if err != nil { + m.logger.WithField("account_id", accountID).WithError(err).Warn("failed to start per-account inbound listener; continuing without inbound") + return nil + } + + m.flushPending(accountID, entry) + + m.logger.WithFields(log.Fields{ + "account_id": accountID, + "https": entry.tlsListener.Addr().String(), + "http": entry.plainListener.Addr().String(), + }).Info("per-account inbound listeners up") + return entry +} + +// onClientStop tears down a per-account listener bundle. State is the +// opaque value previously returned by onClientReady. +func (m *inboundManager) onClientStop(accountID types.AccountID, state any) { + if m == nil { + return + } + entry, ok := state.(*inboundEntry) + if !ok || entry == nil { + return + } + m.tearDown(accountID, entry) +} + +// bringUp opens both listeners on the account's netstack, builds the +// router, and starts the parallel HTTP servers. +func (m *inboundManager) bringUp(ctx context.Context, accountID types.AccountID, client *embed.Client) (*inboundEntry, error) { + tlsListener, err := client.ListenTCP(fmt.Sprintf(":%d", privateInboundPortHTTPS)) + if err != nil { + return nil, fmt.Errorf("listen tls on netstack: %w", err) + } + plainListener, err := client.ListenTCP(fmt.Sprintf(":%d", privateInboundPortHTTP)) + if err != nil { + _ = tlsListener.Close() + return nil, fmt.Errorf("listen plain on netstack: %w", err) + } + + router := nbtcp.NewRouter(m.logger, accountDialResolver(accountID, client), tlsListener.Addr(), nbtcp.WithPlainHTTP(plainListener.Addr())) + + scopedHandler := withTunnelLookup(m.handler, accountTunnelLookup(client)) + + // markOverlayOrigin stamps every connection accepted by an inbound + // listener with a context value middlewares can read to skip + // geo/CrowdSec checks (the source address is always inside the + // NetBird CGNAT range and won't match either dataset). + markOverlayOrigin := func(ctx context.Context, _ net.Conn) context.Context { + return types.WithOverlayOrigin(ctx) + } + + httpsErrLog, httpsErrW := newInboundErrorLog(m.logger, "https", accountID) + httpErrLog, httpErrW := newInboundErrorLog(m.logger, "http", accountID) + + httpsServer := &http.Server{ + Handler: scopedHandler, + TLSConfig: m.tlsConfig, + ReadHeaderTimeout: httpInboundReadHeaderTimeout, + IdleTimeout: httpInboundIdleTimeout, + ErrorLog: httpsErrLog, + ConnContext: markOverlayOrigin, + } + httpServer := &http.Server{ + Handler: scopedHandler, + ReadHeaderTimeout: httpInboundReadHeaderTimeout, + IdleTimeout: httpInboundIdleTimeout, + ErrorLog: httpErrLog, + ConnContext: markOverlayOrigin, + } + + runCtx, cancel := context.WithCancel(ctx) + entry := &inboundEntry{ + router: router, + tlsListener: tlsListener, + plainListener: plainListener, + httpsServer: httpsServer, + httpServer: httpServer, + errorLogWriters: []*io.PipeWriter{httpsErrW, httpErrW}, + cancel: cancel, + } + + entry.wg.Add(1) + go func() { + defer entry.wg.Done() + if err := router.Serve(runCtx, tlsListener); err != nil { + m.logger.WithField("account_id", accountID).Debugf("per-account router stopped: %v", err) + } + }() + + entry.wg.Add(1) + go func() { + defer entry.wg.Done() + if err := httpsServer.ServeTLS(router.HTTPListener(), "", ""); err != nil && !errors.Is(err, http.ErrServerClosed) { + m.logger.WithField("account_id", accountID).Debugf("per-account https server stopped: %v", err) + } + }() + + entry.wg.Add(1) + go func() { + defer entry.wg.Done() + if err := httpServer.Serve(router.HTTPListenerPlain()); err != nil && !errors.Is(err, http.ErrServerClosed) { + m.logger.WithField("account_id", accountID).Debugf("per-account http server stopped: %v", err) + } + }() + + entry.wg.Add(1) + go func() { + defer entry.wg.Done() + feedRouterFromListener(runCtx, plainListener, router, m.logger, accountID) + }() + + m.muxLock.Lock() + m.entries[accountID] = entry + m.muxLock.Unlock() + + return entry, nil +} + +// tearDown shuts every goroutine down and closes the netstack listeners. +func (m *inboundManager) tearDown(accountID types.AccountID, entry *inboundEntry) { + m.muxLock.Lock() + if m.entries[accountID] == entry { + delete(m.entries, accountID) + delete(m.pendingRoutes, accountID) + } + m.muxLock.Unlock() + + entry.cancel() + + shutdownCtx, cancel := context.WithTimeout(context.Background(), inboundShutdownTimeout) + defer cancel() + + if err := entry.httpsServer.Shutdown(shutdownCtx); err != nil { + m.logger.Debugf("per-account https shutdown: %v", err) + } + if err := entry.httpServer.Shutdown(shutdownCtx); err != nil { + m.logger.Debugf("per-account http shutdown: %v", err) + } + if err := entry.tlsListener.Close(); err != nil { + m.logger.Debugf("close per-account tls listener: %v", err) + } + if err := entry.plainListener.Close(); err != nil { + m.logger.Debugf("close per-account plain listener: %v", err) + } + entry.wg.Wait() + // Close the ErrorLog pipes only after the http.Servers have fully + // stopped so any straggling stdlib write doesn't race with the + // close. Each writer also tears down the logrus scanner goroutine. + for _, w := range entry.errorLogWriters { + if err := w.Close(); err != nil { + m.logger.Debugf("close per-account inbound error log writer: %v", err) + } + } +} + +// AddRoute records an SNI/host route on the account's per-account router. +// Routes registered before the listener is up are queued and replayed +// once startup completes. +func (m *inboundManager) AddRoute(accountID types.AccountID, host nbtcp.SNIHost, route nbtcp.Route) { + if m == nil { + return + } + m.muxLock.Lock() + entry, ok := m.entries[accountID] + if !ok { + m.queuePendingLocked(accountID, host, route) + m.muxLock.Unlock() + return + } + router := entry.router + m.muxLock.Unlock() + + router.AddRoute(host, route) +} + +// RemoveRoute drops a previously registered route. Safe to call when the +// listener is not yet up; queued copies are pruned in that case. +func (m *inboundManager) RemoveRoute(accountID types.AccountID, host nbtcp.SNIHost, svcID types.ServiceID) { + if m == nil { + return + } + m.muxLock.Lock() + m.dropPendingLocked(accountID, host, svcID) + entry, ok := m.entries[accountID] + if !ok { + m.muxLock.Unlock() + return + } + router := entry.router + m.muxLock.Unlock() + + router.RemoveRoute(host, svcID) +} + +// queuePendingLocked stores or upserts a pending route. Caller holds muxLock. +func (m *inboundManager) queuePendingLocked(accountID types.AccountID, host nbtcp.SNIHost, route nbtcp.Route) { + queued := m.pendingRoutes[accountID] + for i, pr := range queued { + if pr.host == host && pr.route.ServiceID == route.ServiceID { + queued[i] = pendingInboundRoute{host: host, route: route} + m.pendingRoutes[accountID] = queued + return + } + } + m.pendingRoutes[accountID] = append(queued, pendingInboundRoute{host: host, route: route}) +} + +// dropPendingLocked removes any queued route matching host/svcID. +// Caller holds muxLock. +func (m *inboundManager) dropPendingLocked(accountID types.AccountID, host nbtcp.SNIHost, svcID types.ServiceID) { + queued, ok := m.pendingRoutes[accountID] + if !ok { + return + } + filtered := queued[:0] + for _, pr := range queued { + if pr.host == host && pr.route.ServiceID == svcID { + continue + } + filtered = append(filtered, pr) + } + if len(filtered) == 0 { + delete(m.pendingRoutes, accountID) + return + } + m.pendingRoutes[accountID] = filtered +} + +// flushPending applies all queued routes to a freshly-up router. +func (m *inboundManager) flushPending(accountID types.AccountID, entry *inboundEntry) { + m.muxLock.Lock() + queued := m.pendingRoutes[accountID] + delete(m.pendingRoutes, accountID) + m.muxLock.Unlock() + + for _, pr := range queued { + entry.router.AddRoute(pr.host, pr.route) + } +} + +// HasInbound reports whether the manager has a live listener for the account. +// Used by tests. +func (m *inboundManager) HasInbound(accountID types.AccountID) bool { + if m == nil { + return false + } + m.muxLock.Lock() + defer m.muxLock.Unlock() + _, ok := m.entries[accountID] + return ok +} + +// PendingRouteCount reports the number of queued routes for the account. +// Used by tests. +func (m *inboundManager) PendingRouteCount(accountID types.AccountID) int { + if m == nil { + return 0 + } + m.muxLock.Lock() + defer m.muxLock.Unlock() + return len(m.pendingRoutes[accountID]) +} + +// InboundListenerInfo describes the bound addresses of a single +// per-account inbound listener. Both addresses live on the embedded +// netstack of the account's WireGuard client and share the same tunnel IP. +type InboundListenerInfo struct { + TunnelIP string + HTTPSPort uint16 + HTTPPort uint16 +} + +// ListenerInfo returns the inbound listener addresses for the given +// account, or ok=false when the account has no live listener. Used by +// the status-update RPC and the debug HTTP handler to surface inbound +// reachability to operators. +func (m *inboundManager) ListenerInfo(accountID types.AccountID) (InboundListenerInfo, bool) { + if m == nil { + return InboundListenerInfo{}, false + } + m.muxLock.Lock() + defer m.muxLock.Unlock() + entry, ok := m.entries[accountID] + if !ok || entry == nil { + return InboundListenerInfo{}, false + } + return listenerInfoFromEntry(entry), true +} + +// Snapshot returns the inbound listener state for every account that has +// a live listener at call time. Empty when --private is off or +// no accounts have come up yet. +func (m *inboundManager) Snapshot() map[types.AccountID]InboundListenerInfo { + if m == nil { + return nil + } + m.muxLock.Lock() + defer m.muxLock.Unlock() + if len(m.entries) == 0 { + return nil + } + out := make(map[types.AccountID]InboundListenerInfo, len(m.entries)) + for id, entry := range m.entries { + if entry == nil { + continue + } + out[id] = listenerInfoFromEntry(entry) + } + return out +} + +// listenerInfoFromEntry extracts the tunnel IP and ports from a live +// per-account entry. Both listeners are bound on the same netstack so +// their host components match; we still pull the TLS host as the +// authoritative source. +func listenerInfoFromEntry(entry *inboundEntry) InboundListenerInfo { + info := InboundListenerInfo{HTTPSPort: privateInboundPortHTTPS, HTTPPort: privateInboundPortHTTP} + if entry.tlsListener != nil { + host, port := splitHostPort(entry.tlsListener.Addr()) + info.TunnelIP = host + if port != 0 { + info.HTTPSPort = port + } + } + if entry.plainListener != nil { + host, port := splitHostPort(entry.plainListener.Addr()) + if info.TunnelIP == "" { + info.TunnelIP = host + } + if port != 0 { + info.HTTPPort = port + } + } + return info +} + +// splitHostPort extracts host and port from a net.Addr, returning the +// zero values when the address is missing or malformed. +func splitHostPort(addr net.Addr) (string, uint16) { + if addr == nil { + return "", 0 + } + host, portStr, err := net.SplitHostPort(addr.String()) + if err != nil { + return "", 0 + } + if portStr == "" { + return host, 0 + } + port, err := strconv.ParseUint(portStr, 10, 16) + if err != nil { + return host, 0 + } + return host, uint16(port) +} + +// feedRouterFromListener accepts on the plain-HTTP netstack listener and +// hands every connection to the account's router. The router peeks the +// first byte and dispatches to the plain-HTTP channel for non-TLS +// streams or the TLS channel for ClientHellos that arrive on :80. +func feedRouterFromListener(ctx context.Context, ln net.Listener, router *nbtcp.Router, logger *log.Logger, accountID types.AccountID) { + go func() { + <-ctx.Done() + _ = ln.Close() + }() + + var backoff nbtcp.AcceptBackoff + for { + conn, err := ln.Accept() + if err != nil { + if ctx.Err() != nil || nbtcp.IsClosedListenerErr(err) { + return + } + logger.WithField("account_id", accountID).Debugf("plain inbound accept: %v; backing off", err) + if !backoff.Backoff(ctx) { + return + } + continue + } + backoff.Reset() + router.HandleConn(ctx, conn) + } +} + +// accountDialResolver returns a DialResolver bound to a single account's +// embedded client. The router only ever serves traffic for that account +// so the supplied accountID is ignored at dial time. +func accountDialResolver(_ types.AccountID, client *embed.Client) nbtcp.DialResolver { + return func(_ types.AccountID) (types.DialContextFunc, error) { + return client.DialContext, nil + } +} + +// accountTunnelLookup returns a TunnelLookupFunc backed by the embedded +// client's peerstore for a single account. Phase 3 uses the result to +// short-circuit ValidateTunnelPeer when the source IP is not in the +// account's roster and to seed the cached identity for known peers. +func accountTunnelLookup(client *embed.Client) auth.TunnelLookupFunc { + if client == nil { + return nil + } + return func(ip netip.Addr) (auth.PeerIdentity, bool) { + pubKey, fqdn, ok := client.IdentityForIP(ip) + if !ok { + return auth.PeerIdentity{}, false + } + return auth.PeerIdentity{ + PubKey: pubKey, + TunnelIP: ip, + FQDN: fqdn, + }, true + } +} + +// withTunnelLookup returns an http.Handler that attaches the per-account +// peerstore lookup to every request's context before delegating to next. +// Calling on the host-level listener is a no-op because that path never +// installs this wrapper, so the existing behaviour stays byte-for-byte +// identical when --private is off or the request didn't arrive +// on a per-account listener. +func withTunnelLookup(next http.Handler, lookup auth.TunnelLookupFunc) http.Handler { + if lookup == nil { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := auth.WithTunnelLookup(r.Context(), lookup) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// inboundDebugAdapter adapts *inboundManager to the debug.InboundProvider +// interface so the debug HTTP handler can render per-account inbound +// listener state without importing the proxy package. +type inboundDebugAdapter struct { + mgr *inboundManager +} + +// InboundListeners returns a snapshot of the live per-account inbound +// listeners formatted for the debug surface. +func (a inboundDebugAdapter) InboundListeners() map[types.AccountID]debug.InboundListenerInfo { + if a.mgr == nil { + return nil + } + snap := a.mgr.Snapshot() + if len(snap) == 0 { + return nil + } + out := make(map[types.AccountID]debug.InboundListenerInfo, len(snap)) + for id, info := range snap { + out[id] = debug.InboundListenerInfo{ + TunnelIP: info.TunnelIP, + HTTPSPort: info.HTTPSPort, + HTTPPort: info.HTTPPort, + } + } + return out +} + +// newInboundErrorLog routes a per-account http.Server's stdlib error +// stream through logrus at warn level. The returned PipeWriter must be +// closed by the caller (tearDown) once the http.Server has shut down — +// otherwise the pipe and its scanner goroutine leak per account, see +// logrus.Entry.WriterLevel. +func newInboundErrorLog(logger *log.Logger, scheme string, accountID types.AccountID) (*stdlog.Logger, *io.PipeWriter) { + w := logger.WithFields(log.Fields{ + "inbound-http": scheme, + "account_id": accountID, + }).WriterLevel(log.WarnLevel) + return stdlog.New(w, "", 0), w +} diff --git a/proxy/inbound_test.go b/proxy/inbound_test.go new file mode 100644 index 000000000..0e6081802 --- /dev/null +++ b/proxy/inbound_test.go @@ -0,0 +1,657 @@ +package proxy + +import ( + "bufio" + "context" + "crypto/tls" + "io" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "sync" + "sync/atomic" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/proxy/internal/auth" + "github.com/netbirdio/netbird/proxy/internal/roundtrip" + nbtcp "github.com/netbirdio/netbird/proxy/internal/tcp" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// bufioReader wraps the connection in a buffered reader so http.ReadResponse +// can parse the response line + headers off the wire. +func bufioReader(conn net.Conn) *bufio.Reader { + return bufio.NewReader(conn) +} + +// quietLogger returns a logger that emits nothing — keeps test output tidy. +func quietLogger() *log.Logger { + logger := log.New() + logger.SetLevel(log.PanicLevel) + return logger +} + +func TestInboundManager_RouteScopedToAccount(t *testing.T) { + mgr := newInboundManager(quietLogger(), http.NotFoundHandler(), nil) + + accountA := types.AccountID("acct-a") + accountB := types.AccountID("acct-b") + + mgr.AddRoute(accountA, "shared.example", nbtcp.Route{Type: nbtcp.RouteHTTP, AccountID: accountA, ServiceID: "svc-a", Domain: "shared.example"}) + mgr.AddRoute(accountB, "other.example", nbtcp.Route{Type: nbtcp.RouteHTTP, AccountID: accountB, ServiceID: "svc-b", Domain: "other.example"}) + + require.Equal(t, 1, mgr.PendingRouteCount(accountA), "account A should have one queued route") + require.Equal(t, 1, mgr.PendingRouteCount(accountB), "account B should have one queued route") + + mgr.RemoveRoute(accountA, "shared.example", "svc-a") + mgr.RemoveRoute(accountB, "other.example", "svc-b") + + assert.Equal(t, 0, mgr.PendingRouteCount(accountA), "queue should drain on remove") + assert.Equal(t, 0, mgr.PendingRouteCount(accountB), "queue should drain on remove") +} + +func TestInboundManager_PendingThenFlush(t *testing.T) { + mgr := newInboundManager(quietLogger(), http.NotFoundHandler(), nil) + + accountID := types.AccountID("acct-1") + host := nbtcp.SNIHost("example.test") + route := nbtcp.Route{Type: nbtcp.RouteHTTP, AccountID: accountID, ServiceID: "svc-1", Domain: "example.test"} + + mgr.AddRoute(accountID, host, route) + require.Equal(t, 1, mgr.PendingRouteCount(accountID), "pending count before listener is up") + + // Simulate listener up by registering a fake entry, then flushing. + router := nbtcp.NewRouter(quietLogger(), nil, &fakeAddr{addr: "127.0.0.1:0"}) + entry := &inboundEntry{router: router} + mgr.muxLock.Lock() + mgr.entries[accountID] = entry + mgr.muxLock.Unlock() + + mgr.flushPending(accountID, entry) + assert.Equal(t, 0, mgr.PendingRouteCount(accountID), "queue should be empty after flush") +} + +// fakeAddr is a stub net.Addr for tests that don't actually bind sockets. +type fakeAddr struct { + addr string +} + +func (a *fakeAddr) Network() string { return "tcp" } +func (a *fakeAddr) String() string { return a.addr } + +// fakeMgmtClient implements roundtrip.managementClient for tests. +type fakeMgmtClient struct{} + +func (fakeMgmtClient) CreateProxyPeer(_ context.Context, _ *proto.CreateProxyPeerRequest, _ ...grpc.CallOption) (*proto.CreateProxyPeerResponse, error) { + return &proto.CreateProxyPeerResponse{Success: true}, nil +} + +// TestServer_PrivateInbound_NotEnabled_NoManager confirms that with +// --private off the inbound manager is nil and the standalone proxy +// keeps its zero-overhead default path. +func TestServer_PrivateInbound_NotEnabled_NoManager(t *testing.T) { + s := &Server{Logger: quietLogger(), Private: false} + s.initPrivateInbound(http.NotFoundHandler(), nil) + assert.Nil(t, s.inbound, "manager should remain nil when --private is off") +} + +// TestServer_PrivateInbound_Enabled_WiresLifecycle confirms that +// --private alone wires the manager into the NetBird transport, so +// AddPeer / RemovePeer drive the lifecycle. +func TestServer_PrivateInbound_Enabled_WiresLifecycle(t *testing.T) { + s := &Server{Logger: quietLogger(), Private: true} + // Construct a NetBird transport. We can't actually start the embedded + // client here (that needs a real management server), but we can + // confirm that the lifecycle callbacks are registered. + s.netbird = roundtrip.NewNetBird(t.Context(), "test", "test", roundtrip.ClientConfig{ + MgmtAddr: "http://invalid.test", + }, quietLogger(), nil, fakeMgmtClient{}) + + s.initPrivateInbound(http.NotFoundHandler(), &tls.Config{}) //nolint:gosec + require.NotNil(t, s.inbound, "manager should be set when --private is on") + assert.NotNil(t, s.inbound.handler, "handler should be set on manager") + assert.NotNil(t, s.inbound.tlsConfig, "tls config should be set on manager") +} + +// TestInboundManager_AddRouteAfterReady_RegistersDirectly verifies that +// when the listener is already up, AddRoute writes straight to the +// router without queueing. +func TestInboundManager_AddRouteAfterReady_RegistersDirectly(t *testing.T) { + mgr := newInboundManager(quietLogger(), http.NotFoundHandler(), nil) + accountID := types.AccountID("acct-1") + router := nbtcp.NewRouter(quietLogger(), nil, &fakeAddr{addr: "127.0.0.1:0"}) + + mgr.muxLock.Lock() + mgr.entries[accountID] = &inboundEntry{router: router} + mgr.muxLock.Unlock() + + host := nbtcp.SNIHost("ready.example") + mgr.AddRoute(accountID, host, nbtcp.Route{Type: nbtcp.RouteHTTP, AccountID: accountID, ServiceID: "svc-ready", Domain: string(host)}) + assert.Equal(t, 0, mgr.PendingRouteCount(accountID), "no pending entries when listener is up") +} + +// TestPrivateCapability_DerivedFromPrivateOnly tests that the capability +// bit reported upstream tracks --private exclusively. The previous +// --private flag has been folded into --private. +func TestPrivateCapability_DerivedFromPrivateOnly(t *testing.T) { + tests := []struct { + name string + private bool + expected bool + }{ + {"off", false, false}, + {"on", true, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Server{Private: tt.private} + assert.Equal(t, tt.expected, s.Private, "private capability bit should match --private") + }) + } +} + +// TestInboundManager_RouteScopedToAccountB_DoesNotMatchA verifies that a +// service registered for account B is invisible to a router serving +// account A. We exercise the path through real per-account routers. +func TestInboundManager_RouteScopedToAccountB_DoesNotMatchA(t *testing.T) { + mgr := newInboundManager(quietLogger(), http.NotFoundHandler(), nil) + + accountA := types.AccountID("acct-a") + accountB := types.AccountID("acct-b") + routerA := nbtcp.NewRouter(quietLogger(), nil, &fakeAddr{addr: "127.0.0.1:0"}) + routerB := nbtcp.NewRouter(quietLogger(), nil, &fakeAddr{addr: "127.0.0.1:0"}) + + mgr.muxLock.Lock() + mgr.entries[accountA] = &inboundEntry{router: routerA} + mgr.entries[accountB] = &inboundEntry{router: routerB} + mgr.muxLock.Unlock() + + host := nbtcp.SNIHost("shared.example") + mgr.AddRoute(accountB, host, nbtcp.Route{Type: nbtcp.RouteHTTP, AccountID: accountB, ServiceID: "svc-b", Domain: string(host)}) + + // Account A's router should have no routes; account B's should have one. + // We check via IsEmpty — true means no routes and no fallback. + assert.True(t, routerA.IsEmpty(), "account A router must not see account B's mappings") + assert.False(t, routerB.IsEmpty(), "account B router should hold its own mapping") +} + +// TestInboundEntry_ShutdownIdempotent ensures that tearDown can run twice +// without panicking — callers may invoke it from RemovePeer + StopAll. +func TestInboundEntry_ShutdownIdempotent(t *testing.T) { + t.Skip("teardown requires real netstack listeners; covered by integration tests") +} + +// TestRouter_PlainHTTP_ForwardedProtoIsHTTP exercises the full per-account +// router pipeline against a loopback listener (proxy of a netstack +// listener for test purposes): a plain HTTP request lands on the plain +// http.Server and the inner handler observes a nil r.TLS, which is what +// auth.ResolveProto translates to "http" in the real pipeline. +func TestRouter_PlainHTTP_ForwardedProtoIsHTTP(t *testing.T) { + logger := quietLogger() + + var captured atomic.Value + captured.Store("") + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.TLS == nil { + captured.Store("http") + } else { + captured.Store("https") + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + + hostListener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err, "loopback listener bind must succeed") + defer hostListener.Close() + + router := nbtcp.NewRouter(logger, nil, hostListener.Addr(), nbtcp.WithPlainHTTP(hostListener.Addr())) + httpServer := &http.Server{Handler: handler, ReadHeaderTimeout: time.Second} + defer func() { _ = httpServer.Close() }() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = httpServer.Serve(router.HTTPListenerPlain()) }() + go func() { _ = router.Serve(ctx, hostListener) }() + + conn, err := net.DialTimeout("tcp", hostListener.Addr().String(), 2*time.Second) + require.NoError(t, err, "plain HTTP dial must succeed") + defer conn.Close() + + _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n")) + require.NoError(t, err, "write must succeed") + + resp, err := http.ReadResponse(bufioReader(conn), nil) + require.NoError(t, err, "must read response") + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "http", captured.Load(), "ForwardedProto must be http on plain path") +} + +// TestWithTunnelLookup_AttachesLookupToContext verifies that requests +// flowing through the per-account handler wrapper carry the peerstore +// lookup function. Phase 3's local-first deny path depends on this. +func TestWithTunnelLookup_AttachesLookupToContext(t *testing.T) { + expected := auth.PeerIdentity{TunnelIP: netip.MustParseAddr("100.64.0.10"), FQDN: "peer.netbird"} + lookup := auth.TunnelLookupFunc(func(_ netip.Addr) (auth.PeerIdentity, bool) { + return expected, true + }) + + var observed auth.TunnelLookupFunc + inner := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + observed = auth.TunnelLookupFromContext(r.Context()) + }) + + handler := withTunnelLookup(inner, lookup) + r := httptest.NewRequest(http.MethodGet, "https://svc.example/", nil) + handler.ServeHTTP(httptest.NewRecorder(), r) + + require.NotNil(t, observed, "wrapper must inject the lookup into the request context") + got, ok := observed(netip.MustParseAddr("100.64.0.10")) + assert.True(t, ok, "lookup must round-trip through context") + assert.Equal(t, expected.FQDN, got.FQDN, "lookup must return the same identity it was constructed with") +} + +// TestWithTunnelLookup_NilLookupIsNoop confirms the wrapper is a pure +// pass-through when no lookup is provided. Required for the host-level +// listener path to keep its byte-for-byte previous behaviour. +func TestWithTunnelLookup_NilLookupIsNoop(t *testing.T) { + var called bool + inner := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + called = true + assert.Nil(t, auth.TunnelLookupFromContext(r.Context()), "host-level path must not see a lookup function") + }) + + handler := withTunnelLookup(inner, nil) + r := httptest.NewRequest(http.MethodGet, "https://svc.example/", nil) + handler.ServeHTTP(httptest.NewRecorder(), r) + assert.True(t, called, "wrapper without lookup must still invoke next") +} + +// fakeListener satisfies net.Listener for snapshot tests without binding +// a real socket on the netstack. +type fakeListener struct { + addr net.Addr +} + +func (f *fakeListener) Accept() (net.Conn, error) { return nil, net.ErrClosed } +func (f *fakeListener) Close() error { return nil } +func (f *fakeListener) Addr() net.Addr { return f.addr } + +// TestInboundManager_ListenerInfo confirms ListenerInfo and Snapshot +// surface the bound tunnel-IP and ports for live entries. +func TestInboundManager_ListenerInfo(t *testing.T) { + mgr := newInboundManager(quietLogger(), http.NotFoundHandler(), nil) + accountID := types.AccountID("acct-info") + + tlsAddr := &net.TCPAddr{IP: net.ParseIP("100.64.0.5"), Port: privateInboundPortHTTPS} + plainAddr := &net.TCPAddr{IP: net.ParseIP("100.64.0.5"), Port: privateInboundPortHTTP} + mgr.muxLock.Lock() + mgr.entries[accountID] = &inboundEntry{ + tlsListener: &fakeListener{addr: tlsAddr}, + plainListener: &fakeListener{addr: plainAddr}, + } + mgr.muxLock.Unlock() + + info, ok := mgr.ListenerInfo(accountID) + require.True(t, ok, "ListenerInfo must report ok for live entry") + assert.Equal(t, "100.64.0.5", info.TunnelIP, "tunnel IP must come from listener address") + assert.Equal(t, uint16(privateInboundPortHTTPS), info.HTTPSPort, "TLS port must match bound port") + assert.Equal(t, uint16(privateInboundPortHTTP), info.HTTPPort, "HTTP port must match bound port") + + snap := mgr.Snapshot() + require.Len(t, snap, 1, "snapshot must contain exactly one entry") + assert.Equal(t, info, snap[accountID], "snapshot entry must equal direct lookup") + + _, ok = mgr.ListenerInfo(types.AccountID("missing")) + assert.False(t, ok, "ListenerInfo must report ok=false for unknown accounts") +} + +// TestInboundManager_NilManagerSafe ensures the observability accessors +// are safe to call when --private is off (nil manager). +func TestInboundManager_NilManagerSafe(t *testing.T) { + var mgr *inboundManager + _, ok := mgr.ListenerInfo("anything") + assert.False(t, ok, "nil manager must return ok=false") + assert.Nil(t, mgr.Snapshot(), "nil manager must return nil snapshot") +} + +// TestInboundManager_ConcurrentAddRemove pounds AddRoute / RemoveRoute +// from multiple goroutines to expose any locking gaps. +func TestInboundManager_ConcurrentAddRemove(t *testing.T) { + mgr := newInboundManager(quietLogger(), http.NotFoundHandler(), nil) + accountID := types.AccountID("acct-1") + const workers = 32 + const iterations = 50 + + var wg sync.WaitGroup + wg.Add(workers) + for i := 0; i < workers; i++ { + go func(idx int) { + defer wg.Done() + host := nbtcp.SNIHost("example.test") + svc := types.ServiceID("svc") + route := nbtcp.Route{Type: nbtcp.RouteHTTP, AccountID: accountID, ServiceID: svc, Domain: "example.test"} + for j := 0; j < iterations; j++ { + mgr.AddRoute(accountID, host, route) + mgr.RemoveRoute(accountID, host, svc) + } + }(i) + } + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("concurrent add/remove timed out") + } +} + +// TestFeedRouterFromListener_DeliversConnectionToHandler validates the +// per-account inbound chain end-to-end with a loopback listener +// substituted for the embedded netstack: a TCP connection arriving at +// the plain listener flows through feedRouterFromListener, the router's +// peek-and-dispatch, the wrapped HTTP server, and reaches the user +// handler. If the embedded netstack is delivering connections at all, +// this is the path they take. Failures localise to wiring bugs in the +// proxy, not the netstack. +func TestFeedRouterFromListener_DeliversConnectionToHandler(t *testing.T) { + logger := quietLogger() + + hits := make(chan string, 1) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits <- r.Host + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("served")) + }) + + plainLn, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err, "plain loopback bind must succeed") + t.Cleanup(func() { _ = plainLn.Close() }) + + router := nbtcp.NewRouter(logger, nil, &fakeAddr{addr: "127.0.0.1:0"}, nbtcp.WithPlainHTTP(plainLn.Addr())) + + httpServer := &http.Server{Handler: handler, ReadHeaderTimeout: time.Second} + t.Cleanup(func() { _ = httpServer.Close() }) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + go func() { _ = httpServer.Serve(router.HTTPListenerPlain()) }() + go feedRouterFromListener(ctx, plainLn, router, logger, types.AccountID("acct-1")) + + conn, err := net.DialTimeout("tcp", plainLn.Addr().String(), 2*time.Second) + require.NoError(t, err, "must connect to the plain listener") + t.Cleanup(func() { _ = conn.Close() }) + + _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: app.example\r\nConnection: close\r\n\r\n")) + require.NoError(t, err, "request write must succeed") + + resp, err := http.ReadResponse(bufioReader(conn), nil) + require.NoError(t, err, "must read response from server") + t.Cleanup(func() { _ = resp.Body.Close() }) + + assert.Equal(t, http.StatusOK, resp.StatusCode, "handler must be reached") + + select { + case host := <-hits: + assert.Equal(t, "app.example", host, "handler must observe the request Host") + case <-time.After(2 * time.Second): + t.Fatal("handler was not invoked — connection did not flow through router → http server") + } +} + +// TestFeedRouterFromListener_DispatchesTLSToTLSChannel verifies that a +// TLS ClientHello arriving on the plain listener is detected by the +// router peek and re-dispatched to the TLS channel — the cross-channel +// fallback the inbound stack relies on for HTTPS-on-:80 testing. +func TestFeedRouterFromListener_DispatchesTLSToTLSChannel(t *testing.T) { + logger := quietLogger() + + hits := make(chan string, 1) + tlsHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits <- r.Host + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("served-tls")) + }) + + plainLn, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err, "plain loopback bind must succeed") + t.Cleanup(func() { _ = plainLn.Close() }) + + tlsLn, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err, "tls loopback bind must succeed") + t.Cleanup(func() { _ = tlsLn.Close() }) + + router := nbtcp.NewRouter(logger, nil, tlsLn.Addr(), nbtcp.WithPlainHTTP(plainLn.Addr())) + + tlsConfig := selfSignedTLSConfig(t) + httpsServer := &http.Server{ + Handler: tlsHandler, + TLSConfig: tlsConfig, + ReadHeaderTimeout: time.Second, + } + t.Cleanup(func() { _ = httpsServer.Close() }) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + go func() { _ = httpsServer.ServeTLS(router.HTTPListener(), "", "") }() + go feedRouterFromListener(ctx, plainLn, router, logger, types.AccountID("acct-tls")) + + tlsConn, err := tls.Dial("tcp", plainLn.Addr().String(), &tls.Config{InsecureSkipVerify: true}) //nolint:gosec + require.NoError(t, err, "TLS dial against the plain listener must succeed (cross-channel)") + t.Cleanup(func() { _ = tlsConn.Close() }) + + req, err := http.NewRequest(http.MethodGet, "https://app.example/", nil) + require.NoError(t, err) + require.NoError(t, req.Write(tlsConn), "TLS request write must succeed") + + resp, err := http.ReadResponse(bufioReader(tlsConn), req) + require.NoError(t, err, "must read TLS response") + t.Cleanup(func() { _ = resp.Body.Close() }) + + assert.Equal(t, http.StatusOK, resp.StatusCode, "TLS handler must be reached") + + select { + case host := <-hits: + assert.Equal(t, "app.example", host, "TLS handler must observe the request Host") + case <-time.After(2 * time.Second): + t.Fatal("TLS handler was not invoked — peek/dispatch path is broken") + } +} + +func selfSignedTLSConfig(t *testing.T) *tls.Config { + t.Helper() + cert, err := tls.X509KeyPair(testCertPEM, testKeyPEM) + require.NoError(t, err, "load static self-signed cert") + return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12} //nolint:gosec +} + +// TestNewInboundErrorLog_WriterIsCloseable guards the close path on the +// logrus PipeWriter that backs each per-account http.Server's ErrorLog. +// logrus.Entry.WriterLevel returns an *io.PipeWriter that owns a pipe + +// scanner goroutine; the caller must Close() it on teardown or the +// resources leak per account. The contract is verified two ways: +// +// - the constructor returns a non-nil writer the caller can keep, +// - writing to the writer after Close() fails with io.ErrClosedPipe, +// which is the only externally observable sign that Close was wired. +// +// A leaking refactor (forgetting to thread the writer to tearDown, or +// dropping the Close call) would still pass this test individually but +// fail an integration goleak check; this unit test is the cheap first +// line of defence. +func TestNewInboundErrorLog_WriterIsCloseable(t *testing.T) { + logger := quietLogger() + stdLog, writer := newInboundErrorLog(logger, "https", types.AccountID("acct-1")) + + require.NotNil(t, stdLog, "newInboundErrorLog must return a non-nil *log.Logger") + require.NotNil(t, writer, "newInboundErrorLog must return the underlying PipeWriter so tearDown can Close it") + + // First Close succeeds. + require.NoError(t, writer.Close(), "PipeWriter.Close should succeed the first time") + + // After Close, the writer must refuse new writes — that's the only + // behavioural signal that the pipe (and its scanner goroutine) has + // shut down. + _, err := writer.Write([]byte("post-close write\n")) + require.ErrorIs(t, err, io.ErrClosedPipe, + "writes after Close must surface io.ErrClosedPipe so callers know the writer is gone") +} + +// testCertPEM / testKeyPEM are a minimal RSA self-signed cert for +// 127.0.0.1 — only used by tests that need a working TLS handshake. +var testCertPEM = []byte(`-----BEGIN CERTIFICATE----- +MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw +DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow +EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d +7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B +5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr +BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1 +NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l +Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc +6MF9+Yw1Yy0t +-----END CERTIFICATE-----`) +var testKeyPEM = []byte(`-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIIrYSSNQFaA2Hwf1duRSxKtLYX5CB04fSeQ6tF1aY/PuoAoGCCqGSM49 +AwEHoUQDQgAEPR3tU2Fta9ktY+6P9G0cWO+0kETA6SFs38GecTyudlHz6xvCdz8q +EKTcWGekdmdDPsHloRNtsiCa697B2O9IFA== +-----END EC PRIVATE KEY-----`) + +// scriptedAcceptListener returns pre-scripted errors from Accept(). Used +// to drive the feedRouterFromListener tests without binding a real +// socket — the production code path is a netstack-backed listener that +// returns gVisor's "endpoint is in invalid state" forever after its +// endpoint is destroyed. +type scriptedAcceptListener struct { + errs chan error + closed chan struct{} +} + +func newScriptedAcceptListener(errs ...error) *scriptedAcceptListener { + s := &scriptedAcceptListener{ + errs: make(chan error, len(errs)+1), + closed: make(chan struct{}), + } + for _, e := range errs { + s.errs <- e + } + return s +} + +func (s *scriptedAcceptListener) Accept() (net.Conn, error) { + select { + case <-s.closed: + return nil, net.ErrClosed + case err := <-s.errs: + return nil, err + } +} + +func (s *scriptedAcceptListener) Close() error { + select { + case <-s.closed: + default: + close(s.closed) + } + return nil +} + +func (s *scriptedAcceptListener) Addr() net.Addr { + return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0} +} + +// errSentinel carries a literal error message so tests can synthesise +// the exact gVisor text without importing the netstack package. +type errSentinel string + +func (e errSentinel) Error() string { return string(e) } + +// TestFeedRouterFromListener_ExitsOnGVisorInvalidEndpoint is the +// regression guard for the inbound side of the tight-loop bug. The +// per-account plain-HTTP feeder must recognise gVisor's "endpoint is in +// invalid state" and exit, otherwise it pegs a CPU core and floods the +// account-scoped log with the same accept error every iteration. +func TestFeedRouterFromListener_ExitsOnGVisorInvalidEndpoint(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 80} + router := nbtcp.NewRouter(logger, nil, addr) + + gvisorErr := &net.OpError{ + Op: "accept", + Net: "tcp", + Addr: addr, + Err: errSentinel("endpoint is in invalid state"), + } + ln := newScriptedAcceptListener(gvisorErr) + defer ln.Close() + + done := make(chan struct{}) + go func() { + defer close(done) + feedRouterFromListener(context.Background(), ln, router, logger, "acct-1") + }() + + select { + case <-done: + // Expected: loop recognised the gVisor error and returned. + case <-time.After(2 * time.Second): + t.Fatal("feedRouterFromListener did not exit on gVisor 'endpoint is in invalid state' — accept loop is spinning") + } +} + +// TestFeedRouterFromListener_BacksOffOnTransientError asserts the +// defence-in-depth path: an unknown sticky Accept error must NOT cause +// CPU spin. The loop backs off and exits cleanly when ctx is cancelled. +func TestFeedRouterFromListener_BacksOffOnTransientError(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 80} + router := nbtcp.NewRouter(logger, nil, addr) + + const transientCount = 5 + errs := make([]error, transientCount) + for i := range errs { + errs[i] = errSentinel("transient: temporary network error") + } + ln := newScriptedAcceptListener(errs...) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + start := time.Now() + done := make(chan struct{}) + go func() { + defer close(done) + feedRouterFromListener(ctx, ln, router, logger, "acct-1") + }() + time.AfterFunc(150*time.Millisecond, cancel) + + select { + case <-done: + // Expected. + case <-time.After(2 * time.Second): + t.Fatal("feedRouterFromListener did not exit on ctx cancellation — backoff or exit path broken") + } + + // Without backoff the 5 scripted errors would burn in microseconds. + // With backoff the first delay alone is 5ms, so the loop must take + // at least that long even though ctx fires at 150ms. + elapsed := time.Since(start) + assert.GreaterOrEqual(t, elapsed, 5*time.Millisecond, + "loop ran without backing off — would burn CPU in production") +} diff --git a/proxy/internal/accesslog/logger.go b/proxy/internal/accesslog/logger.go new file mode 100644 index 000000000..a438f42d2 --- /dev/null +++ b/proxy/internal/accesslog/logger.go @@ -0,0 +1,442 @@ +package accesslog + +import ( + "context" + "maps" + "net/netip" + "sync" + "sync/atomic" + "time" + + "github.com/rs/xid" + log "github.com/sirupsen/logrus" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/trustedproxy" +) + +const ( + requestThreshold = 10000 // Log every 10k requests + bytesThreshold = 1024 * 1024 * 1024 // Log every 1GB + usageCleanupPeriod = 1 * time.Hour // Clean up stale counters every hour + usageInactiveWindow = 24 * time.Hour // Consider domain inactive if no traffic for 24 hours + logSendTimeout = 10 * time.Second + + // denyCooldown is the min interval between deny log entries per service+reason + // to prevent flooding from denied connections (e.g. UDP packets from blocked IPs). + denyCooldown = 10 * time.Second + + // maxDenyBuckets caps tracked deny rate-limit entries to bound memory under DDoS. + maxDenyBuckets = 10000 + + // maxLogWorkers caps concurrent gRPC send goroutines. + maxLogWorkers = 4096 +) + +type domainUsage struct { + requestCount int64 + requestStartTime time.Time + + bytesTransferred int64 + bytesStartTime time.Time + + lastActivity time.Time // Track last activity for cleanup +} + +type gRPCClient interface { + SendAccessLog(ctx context.Context, in *proto.SendAccessLogRequest, opts ...grpc.CallOption) (*proto.SendAccessLogResponse, error) +} + +// denyBucketKey identifies a rate-limited deny log stream. +type denyBucketKey struct { + ServiceID types.ServiceID + Reason string +} + +// denyBucket tracks rate-limited deny log entries. +type denyBucket struct { + lastLogged time.Time + suppressed int64 +} + +// Logger sends access log entries to the management server via gRPC. +type Logger struct { + client gRPCClient + logger *log.Logger + trustedProxies *trustedproxy.List + + usageMux sync.Mutex + domainUsage map[string]*domainUsage + + denyMu sync.Mutex + denyBuckets map[denyBucketKey]*denyBucket + + logSem chan struct{} + cleanupCancel context.CancelFunc + dropped atomic.Int64 +} + +// NewLogger creates a new access log Logger. The trustedProxies parameter +// configures which upstream proxy IP ranges are trusted for extracting +// the real client IP from X-Forwarded-For headers. +func NewLogger(client gRPCClient, logger *log.Logger, trustedProxies *trustedproxy.List) *Logger { + if logger == nil { + logger = log.StandardLogger() + } + + ctx, cancel := context.WithCancel(context.Background()) + l := &Logger{ + client: client, + logger: logger, + trustedProxies: trustedProxies, + domainUsage: make(map[string]*domainUsage), + denyBuckets: make(map[denyBucketKey]*denyBucket), + logSem: make(chan struct{}, maxLogWorkers), + cleanupCancel: cancel, + } + + // Start background cleanup routine + go l.cleanupStaleUsage(ctx) + + return l +} + +// Close stops the cleanup routine. Should be called during graceful shutdown. +func (l *Logger) Close() { + if l.cleanupCancel != nil { + l.cleanupCancel() + } +} + +type logEntry struct { + ID string + AccountID types.AccountID + ServiceID types.ServiceID + Host string + Path string + DurationMs int64 + Method string + ResponseCode int32 + SourceIP netip.Addr + AuthMechanism string + UserID string + AuthSuccess bool + BytesUpload int64 + BytesDownload int64 + Protocol Protocol + Metadata map[string]string + AgentNetwork bool +} + +// Protocol identifies the transport protocol of an access log entry. +type Protocol string + +const ( + ProtocolHTTP Protocol = "http" + ProtocolTCP Protocol = "tcp" + ProtocolUDP Protocol = "udp" + ProtocolTLS Protocol = "tls" +) + +// L4Entry holds the data for a layer-4 (TCP/UDP) access log entry. +type L4Entry struct { + AccountID types.AccountID + ServiceID types.ServiceID + Protocol Protocol + Host string // SNI hostname or listen address + SourceIP netip.Addr + DurationMs int64 + BytesUpload int64 + BytesDownload int64 + // DenyReason, when non-empty, indicates the connection was denied. + // Values match the HTTP auth mechanism strings: "ip_restricted", + // "country_restricted", "geo_unavailable", "crowdsec_ban", etc. + DenyReason string + // Metadata carries extra context about the connection (e.g. CrowdSec verdict). + Metadata map[string]string +} + +// LogL4 sends an access log entry for a layer-4 connection (TCP or UDP). +// The call is non-blocking: the gRPC send happens in a background goroutine. +func (l *Logger) LogL4(entry L4Entry) { + le := logEntry{ + ID: xid.New().String(), + AccountID: entry.AccountID, + ServiceID: entry.ServiceID, + Protocol: entry.Protocol, + Host: entry.Host, + SourceIP: entry.SourceIP, + DurationMs: entry.DurationMs, + BytesUpload: entry.BytesUpload, + BytesDownload: entry.BytesDownload, + Metadata: maps.Clone(entry.Metadata), + } + if entry.DenyReason != "" { + if !l.allowDenyLog(entry.ServiceID, entry.DenyReason) { + return + } + le.AuthMechanism = entry.DenyReason + le.AuthSuccess = false + } + l.log(le) + l.trackUsage(entry.Host, entry.BytesUpload+entry.BytesDownload) +} + +// allowDenyLog rate-limits deny log entries per service+reason combination. +func (l *Logger) allowDenyLog(serviceID types.ServiceID, reason string) bool { + key := denyBucketKey{ServiceID: serviceID, Reason: reason} + now := time.Now() + + l.denyMu.Lock() + defer l.denyMu.Unlock() + + b, ok := l.denyBuckets[key] + if !ok { + if len(l.denyBuckets) >= maxDenyBuckets { + return false + } + l.denyBuckets[key] = &denyBucket{lastLogged: now} + return true + } + + if now.Sub(b.lastLogged) >= denyCooldown { + if b.suppressed > 0 { + l.logger.Debugf("access restriction: suppressed %d deny log entries for %s (%s)", b.suppressed, serviceID, reason) + } + b.lastLogged = now + b.suppressed = 0 + return true + } + + b.suppressed++ + return false +} + +// usageMetadataKeys is the allowlist of metadata retained on a stripped, +// usage-only agent-network entry. Mirrors the llm.* / cost.* keys in +// proxy/internal/middleware/keys.go — only the dimensions management needs to +// record a usage row (provider / model / tokens / cost / groups). +var usageMetadataKeys = map[string]struct{}{ + "llm.provider": {}, + "llm.model": {}, + "llm.resolved_provider_id": {}, + "llm.input_tokens": {}, + "llm.output_tokens": {}, + "llm.total_tokens": {}, + "llm.cached_input_tokens": {}, + "llm.cache_creation_tokens": {}, + "cost.usd_input": {}, + "cost.usd_cached_input": {}, + "cost.usd_cache_creation": {}, + "cost.usd_output": {}, + "cost.usd_total": {}, + "cost.usd_cache": {}, + "llm.authorising_groups": {}, +} + +// stripAgentNetworkEntryForUsage returns the entry reduced to what's needed to +// record usage/cost: it drops request detail (host / path / source IP) and any +// prompt capture, keeping the LLM usage metadata plus the caller identity +// (user / auth mechanism) needed for attribution. Shipped when an +// agent-network account has log collection disabled but usage must still be +// collected. logEntry is passed by value, so mutating it here is safe; Metadata +// is replaced with a fresh map rather than mutated in place. +func stripAgentNetworkEntryForUsage(entry logEntry) logEntry { + entry.Host = "" + entry.Path = "" + entry.SourceIP = netip.Addr{} + // Drop the rest of the per-request telemetry too — a usage-only entry + // must carry the LLM usage metadata and caller identity, nothing that + // describes the individual request. + entry.Method = "" + entry.ResponseCode = 0 + entry.DurationMs = 0 + entry.BytesUpload = 0 + entry.BytesDownload = 0 + entry.Protocol = "" + + if len(entry.Metadata) > 0 { + stripped := make(map[string]string, len(usageMetadataKeys)) + for k := range usageMetadataKeys { + if v, ok := entry.Metadata[k]; ok { + stripped[k] = v + } + } + entry.Metadata = stripped + } + return entry +} + +func (l *Logger) log(entry logEntry) { + // Fire off the log request in a separate routine. + // This increases the possibility of losing a log message + // (although it should still get logged in the event of an error), + // but it will reduce latency returning the request in the + // middleware. + // There is also a chance that log messages will arrive at + // the server out of order; however, the timestamp should + // allow for resolving that on the server. + now := timestamppb.Now() + select { + case l.logSem <- struct{}{}: + default: + total := l.dropped.Add(1) + l.logger.Debugf("access log send dropped: worker limit reached (total dropped: %d)", total) + return + } + go func() { + defer func() { <-l.logSem }() + logCtx, cancel := context.WithTimeout(context.Background(), logSendTimeout) + defer cancel() + // Only OIDC sessions have a meaningful user identity. + if entry.AuthMechanism != auth.MethodOIDC.String() { + entry.UserID = "" + } + + var sourceIP string + if entry.SourceIP.IsValid() { + sourceIP = entry.SourceIP.String() + } + + if _, err := l.client.SendAccessLog(logCtx, &proto.SendAccessLogRequest{ + Log: &proto.AccessLog{ + LogId: entry.ID, + AccountId: string(entry.AccountID), + Timestamp: now, + ServiceId: string(entry.ServiceID), + Host: entry.Host, + Path: entry.Path, + DurationMs: entry.DurationMs, + Method: entry.Method, + ResponseCode: entry.ResponseCode, + SourceIp: sourceIP, + AuthMechanism: entry.AuthMechanism, + UserId: entry.UserID, + AuthSuccess: entry.AuthSuccess, + BytesUpload: entry.BytesUpload, + BytesDownload: entry.BytesDownload, + Protocol: string(entry.Protocol), + Metadata: entry.Metadata, + AgentNetwork: entry.AgentNetwork, + }, + }); err != nil { + l.logger.WithFields(log.Fields{ + "service_id": entry.ServiceID, + "host": entry.Host, + "path": entry.Path, + "duration": entry.DurationMs, + "method": entry.Method, + "response_code": entry.ResponseCode, + "source_ip": sourceIP, + "auth_mechanism": entry.AuthMechanism, + "user_id": entry.UserID, + "auth_success": entry.AuthSuccess, + "error": err, + }).Error("Error sending access log on gRPC connection") + } + }() +} + +// trackUsage records request and byte counts per domain, logging when thresholds are hit. +func (l *Logger) trackUsage(domain string, bytesTransferred int64) { + if domain == "" { + return + } + + l.usageMux.Lock() + defer l.usageMux.Unlock() + + now := time.Now() + usage, exists := l.domainUsage[domain] + if !exists { + usage = &domainUsage{ + requestStartTime: now, + bytesStartTime: now, + lastActivity: now, + } + l.domainUsage[domain] = usage + } + + usage.lastActivity = now + + usage.requestCount++ + if usage.requestCount >= requestThreshold { + elapsed := time.Since(usage.requestStartTime) + l.logger.WithFields(log.Fields{ + "domain": domain, + "requests": usage.requestCount, + "duration": elapsed.String(), + }).Infof("domain %s had %d requests over %s", domain, usage.requestCount, elapsed) + + usage.requestCount = 0 + usage.requestStartTime = now + } + + usage.bytesTransferred += bytesTransferred + if usage.bytesTransferred >= bytesThreshold { + elapsed := time.Since(usage.bytesStartTime) + bytesInGB := float64(usage.bytesTransferred) / (1024 * 1024 * 1024) + l.logger.WithFields(log.Fields{ + "domain": domain, + "bytes": usage.bytesTransferred, + "bytes_gb": bytesInGB, + "duration": elapsed.String(), + }).Infof("domain %s transferred %.2f GB over %s", domain, bytesInGB, elapsed) + + usage.bytesTransferred = 0 + usage.bytesStartTime = now + } +} + +// cleanupStaleUsage removes usage and deny-rate-limit entries that have been inactive. +func (l *Logger) cleanupStaleUsage(ctx context.Context) { + ticker := time.NewTicker(usageCleanupPeriod) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + now := time.Now() + l.cleanupDomainUsage(now) + l.cleanupDenyBuckets(now) + } + } +} + +func (l *Logger) cleanupDomainUsage(now time.Time) { + l.usageMux.Lock() + defer l.usageMux.Unlock() + + removed := 0 + for domain, usage := range l.domainUsage { + if now.Sub(usage.lastActivity) > usageInactiveWindow { + delete(l.domainUsage, domain) + removed++ + } + } + if removed > 0 { + l.logger.Debugf("cleaned up %d stale domain usage entries", removed) + } +} + +func (l *Logger) cleanupDenyBuckets(now time.Time) { + l.denyMu.Lock() + defer l.denyMu.Unlock() + + removed := 0 + for key, bucket := range l.denyBuckets { + if now.Sub(bucket.lastLogged) > usageInactiveWindow { + delete(l.denyBuckets, key) + removed++ + } + } + if removed > 0 { + l.logger.Debugf("cleaned up %d stale deny rate-limit entries", removed) + } +} diff --git a/proxy/internal/accesslog/middleware.go b/proxy/internal/accesslog/middleware.go new file mode 100644 index 000000000..9c644418e --- /dev/null +++ b/proxy/internal/accesslog/middleware.go @@ -0,0 +1,107 @@ +package accesslog + +import ( + "net" + "net/http" + "strings" + "time" + + "github.com/rs/xid" + + "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/proxy/internal/responsewriter" + "github.com/netbirdio/netbird/proxy/web" +) + +// Middleware wraps an HTTP handler to log access entries and resolve client IPs. +func (l *Logger) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Skip logging for internal proxy assets (CSS, JS, etc.) + if strings.HasPrefix(r.URL.Path, web.PathPrefix+"/") { + next.ServeHTTP(w, r) + return + } + + // Generate request ID early so it can be used by error pages and log correlation. + requestID := xid.New().String() + + l.logger.Debugf("request: request_id=%s method=%s host=%s path=%s", requestID, r.Method, r.Host, r.URL.Path) + + // Use a response writer wrapper so we can access the status code later. + sw := &statusWriter{ + PassthroughWriter: responsewriter.New(w), + status: http.StatusOK, + } + + var bytesRead int64 + if r.Body != nil { + r.Body = &bodyCounter{ + ReadCloser: r.Body, + bytesRead: &bytesRead, + } + } + + // Resolve the source IP using trusted proxy configuration before passing + // the request on, as the proxy will modify forwarding headers. + sourceIp := extractSourceIP(r, l.trustedProxies) + + // Create a mutable struct to capture data from downstream handlers. + // We pass a pointer in the context - the pointer itself flows down immutably, + // but the struct it points to can be mutated by inner handlers. + capturedData := proxy.NewCapturedData(requestID) + capturedData.SetClientIP(sourceIp) + + ctx := proxy.WithCapturedData(r.Context(), capturedData) + + start := time.Now() + next.ServeHTTP(sw, r.WithContext(ctx)) + duration := time.Since(start) + + host, _, err := net.SplitHostPort(r.Host) + if err != nil { + // Fallback to just using the full host value. + host = r.Host + } + + bytesUpload := bytesRead + bytesDownload := sw.bytesWritten + + entry := logEntry{ + ID: requestID, + ServiceID: capturedData.GetServiceID(), + AccountID: capturedData.GetAccountID(), + Host: host, + Path: r.URL.Path, + DurationMs: duration.Milliseconds(), + Method: r.Method, + ResponseCode: int32(sw.status), + SourceIP: sourceIp, + AuthMechanism: capturedData.GetAuthMethod(), + UserID: capturedData.GetUserID(), + AuthSuccess: sw.status != http.StatusUnauthorized && sw.status != http.StatusForbidden, + BytesUpload: bytesUpload, + BytesDownload: bytesDownload, + Protocol: ProtocolHTTP, + Metadata: capturedData.GetMetadata(), + AgentNetwork: capturedData.GetAgentNetwork(), + } + l.logger.Debugf("response: request_id=%s method=%s host=%s path=%s status=%d duration=%dms source=%s origin=%s service=%s account=%s", + requestID, r.Method, host, r.URL.Path, sw.status, duration.Milliseconds(), sourceIp, capturedData.GetOrigin(), capturedData.GetServiceID(), capturedData.GetAccountID()) + + // Emit the access log unless the matched target opted out + // (agent-network synth targets do this when the account's + // EnableLogCollection toggle is off). For agent-network entries we + // still ship a stripped, usage-only record even when suppressed, so + // usage/cost is collected regardless of the log-collection toggle; + // request detail and prompt capture are dropped before sending. + switch { + case !capturedData.GetSuppressAccessLog(): + l.log(entry) + case entry.AgentNetwork: + l.log(stripAgentNetworkEntryForUsage(entry)) + } + + // Track usage for cost monitoring (upload + download) by domain + l.trackUsage(host, bytesUpload+bytesDownload) + }) +} diff --git a/proxy/internal/accesslog/middleware_test.go b/proxy/internal/accesslog/middleware_test.go new file mode 100644 index 000000000..cf91957a8 --- /dev/null +++ b/proxy/internal/accesslog/middleware_test.go @@ -0,0 +1,185 @@ +package accesslog + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// recorderClient is a minimal stub for the access-log gRPCClient interface. It +// counts SendAccessLog invocations and signals on every call so tests can +// deterministically wait for the goroutine inside Logger.log without sleeping. +type recorderClient struct { + mu sync.Mutex + calls int64 + lastEntry *proto.AccessLog + called chan struct{} +} + +func newRecorderClient() *recorderClient { + return &recorderClient{called: make(chan struct{}, 16)} +} + +func (r *recorderClient) SendAccessLog(_ context.Context, in *proto.SendAccessLogRequest, _ ...grpc.CallOption) (*proto.SendAccessLogResponse, error) { + r.mu.Lock() + r.calls++ + r.lastEntry = in.GetLog() + r.mu.Unlock() + select { + case r.called <- struct{}{}: + default: + } + return &proto.SendAccessLogResponse{}, nil +} + +func (r *recorderClient) callCount() int64 { + r.mu.Lock() + defer r.mu.Unlock() + return r.calls +} + +// newTestLogger builds a Logger backed by the supplied recorderClient. It is +// the same constructor production uses, just with a stub gRPC client — no +// mocks, no interface re-implementations. +func newTestLogger(t *testing.T, client *recorderClient) *Logger { + t.Helper() + logger := NewLogger(client, nil, nil) + t.Cleanup(logger.Close) + return logger +} + +// TestMiddleware_SuppressAccessLog_SkipsLogSink asserts the suppression gate. +// When the inner handler stamps SuppressAccessLog=true on CapturedData (mirrors +// what reverseproxy does when the matched target's DisableAccessLog flag is +// set), the middleware must NOT invoke the access-log sink. Bandwidth telemetry +// (trackUsage) keeps running — it's the call to SendAccessLog that we gate. +func TestMiddleware_SuppressAccessLog_SkipsLogSink(t *testing.T) { + client := newRecorderClient() + l := newTestLogger(t, client) + + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cd := proxy.CapturedDataFromContext(r.Context()) + require.NotNil(t, cd, "middleware must inject CapturedData into the request context") + cd.SetSuppressAccessLog(true) + w.WriteHeader(http.StatusOK) + }) + + srv := httptest.NewServer(l.Middleware(inner)) + t.Cleanup(srv.Close) + + resp, err := http.Get(srv.URL + "/agent-network/v1/chat/completions") + require.NoError(t, err, "GET against suppressed target must succeed") + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusOK, resp.StatusCode, "inner handler must run normally") + + // Give the goroutine fence a beat (Logger.log dispatches in a goroutine). + // The negative assertion needs a small window: if a send is going to + // happen, it happens promptly. + select { + case <-client.called: + t.Fatalf("access-log sink must not be invoked when SuppressAccessLog=true (got %d call(s))", client.callCount()) + case <-time.After(150 * time.Millisecond): + } + + assert.Equal(t, int64(0), client.callCount(), + "SendAccessLog must not be called for suppressed requests") +} + +// TestMiddleware_SuppressAccessLog_DefaultEmitsLog is the regression sanity: +// when nothing sets SuppressAccessLog (the universal default for every +// non-agent-network target), the middleware MUST still emit the access-log +// entry. This is the guarantee that wires-through to the EnableLogCollection +// gate without breaking anyone who isn't opted in. +func TestMiddleware_SuppressAccessLog_DefaultEmitsLog(t *testing.T) { + client := newRecorderClient() + l := newTestLogger(t, client) + + var innerRan atomic.Bool + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + innerRan.Store(true) + // Intentionally DO NOT touch SuppressAccessLog — mirrors every + // non-agent-network target. + w.WriteHeader(http.StatusOK) + }) + + srv := httptest.NewServer(l.Middleware(inner)) + t.Cleanup(srv.Close) + + resp, err := http.Get(srv.URL + "/service/healthz") + require.NoError(t, err, "GET against default target must succeed") + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusOK, resp.StatusCode, "inner handler must run normally") + require.True(t, innerRan.Load(), "inner handler must have run") + + select { + case <-client.called: + case <-time.After(2 * time.Second): + t.Fatalf("SendAccessLog must be invoked for non-suppressed requests, none observed (calls=%d)", client.callCount()) + } + + assert.Equal(t, int64(1), client.callCount(), + "non-suppressed request must produce exactly one access-log send") +} + +// TestMiddleware_SuppressAccessLog_PreservesUsageTracking proves the gate is +// surgical: with SuppressAccessLog=true the access-log send is skipped, but +// the per-domain usage tracker still records the bytes transferred. This is +// the cost-monitoring guarantee called out in the gate's comment. +func TestMiddleware_SuppressAccessLog_PreservesUsageTracking(t *testing.T) { + client := newRecorderClient() + l := newTestLogger(t, client) + + payload := []byte("ok") + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cd := proxy.CapturedDataFromContext(r.Context()) + require.NotNil(t, cd, "middleware must inject CapturedData") + cd.SetSuppressAccessLog(true) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(payload) + }) + + srv := httptest.NewServer(l.Middleware(inner)) + t.Cleanup(srv.Close) + + resp, err := http.Get(srv.URL + "/agent-network/v1/chat/completions") + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + // Allow trackUsage to land — it runs synchronously after l.log(entry) is + // (would have been) called. + time.Sleep(50 * time.Millisecond) + + l.usageMux.Lock() + usage, present := l.domainUsage[hostNoPort(srv.URL)] + l.usageMux.Unlock() + require.True(t, present, "domain usage must be tracked even when the access-log is suppressed") + assert.Greater(t, usage.bytesTransferred, int64(0), "bytesTransferred must include the response payload") + assert.Equal(t, int64(0), client.callCount(), + "SendAccessLog must remain suppressed across the response write") +} + +// hostNoPort extracts the host name from an httptest server URL. The +// middleware strips the port before keying domain usage, so the test mirrors +// that to look the entry up. +func hostNoPort(url string) string { + // httptest URLs are always "http://127.0.0.1:PORT". + const prefix = "http://" + host := url[len(prefix):] + for i := 0; i < len(host); i++ { + if host[i] == ':' || host[i] == '/' { + return host[:i] + } + } + return host +} diff --git a/proxy/internal/accesslog/requestip.go b/proxy/internal/accesslog/requestip.go new file mode 100644 index 000000000..71cea85d0 --- /dev/null +++ b/proxy/internal/accesslog/requestip.go @@ -0,0 +1,16 @@ +package accesslog + +import ( + "net/http" + "net/netip" + + "github.com/netbirdio/netbird/trustedproxy" +) + +// extractSourceIP resolves the real client IP from the request using trusted +// proxy configuration. When trustedProxies is non-empty and the direct +// connection is from a trusted source, it walks X-Forwarded-For right-to-left +// skipping trusted IPs. Otherwise it returns RemoteAddr directly. +func extractSourceIP(r *http.Request, trustedProxies *trustedproxy.List) netip.Addr { + return trustedProxies.ResolveClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For")) +} diff --git a/proxy/internal/accesslog/statuswriter.go b/proxy/internal/accesslog/statuswriter.go new file mode 100644 index 000000000..24f7b35e9 --- /dev/null +++ b/proxy/internal/accesslog/statuswriter.go @@ -0,0 +1,39 @@ +package accesslog + +import ( + "io" + + "github.com/netbirdio/netbird/proxy/internal/responsewriter" +) + +// statusWriter captures the HTTP status code and bytes written from responses. +// It embeds responsewriter.PassthroughWriter which handles all the optional +// interfaces (Hijacker, Flusher, Pusher) automatically. +type statusWriter struct { + *responsewriter.PassthroughWriter + status int + bytesWritten int64 +} + +func (w *statusWriter) WriteHeader(status int) { + w.status = status + w.PassthroughWriter.WriteHeader(status) +} + +func (w *statusWriter) Write(b []byte) (int, error) { + n, err := w.PassthroughWriter.Write(b) + w.bytesWritten += int64(n) + return n, err +} + +// bodyCounter wraps an io.ReadCloser and counts bytes read from the request body. +type bodyCounter struct { + io.ReadCloser + bytesRead *int64 +} + +func (bc *bodyCounter) Read(p []byte) (int, error) { + n, err := bc.ReadCloser.Read(p) + *bc.bytesRead += int64(n) + return n, err +} diff --git a/proxy/internal/acme/locker.go b/proxy/internal/acme/locker.go new file mode 100644 index 000000000..2f0f18885 --- /dev/null +++ b/proxy/internal/acme/locker.go @@ -0,0 +1,102 @@ +package acme + +import ( + "context" + "path/filepath" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/internal/flock" + "github.com/netbirdio/netbird/proxy/internal/k8s" +) + +// certLocker provides distributed mutual exclusion for certificate operations. +// Implementations must be safe for concurrent use from multiple goroutines. +type certLocker interface { + // Lock acquires an exclusive lock for the given domain. + // It blocks until the lock is acquired, the context is cancelled, or an + // unrecoverable error occurs. The returned function releases the lock; + // callers must call it exactly once when the critical section is complete. + Lock(ctx context.Context, domain string) (unlock func(), err error) +} + +// CertLockMethod controls how ACME certificate locks are coordinated. +type CertLockMethod string + +const ( + // CertLockAuto detects the environment and selects k8s-lease if running + // in a Kubernetes pod, otherwise flock. + CertLockAuto CertLockMethod = "auto" + // CertLockFlock uses advisory file locks via flock(2). + CertLockFlock CertLockMethod = "flock" + // CertLockK8sLease uses Kubernetes coordination Leases. + CertLockK8sLease CertLockMethod = "k8s-lease" +) + +func newCertLocker(method CertLockMethod, certDir string, logger *log.Logger) certLocker { + if logger == nil { + logger = log.StandardLogger() + } + + if method == "" || method == CertLockAuto { + if k8s.InCluster() { + method = CertLockK8sLease + } else { + method = CertLockFlock + } + logger.Infof("auto-detected cert lock method: %s", method) + } + + switch method { + case CertLockK8sLease: + locker, err := newK8sLeaseLocker(logger) + if err != nil { + logger.Warnf("create k8s lease locker, falling back to flock: %v", err) + return newFlockLocker(certDir, logger) + } + logger.Infof("using k8s lease locker in namespace %s", locker.client.Namespace()) + return locker + default: + logger.Infof("using flock cert locker in %s", certDir) + return newFlockLocker(certDir, logger) + } +} + +type flockLocker struct { + certDir string + logger *log.Logger +} + +func newFlockLocker(certDir string, logger *log.Logger) *flockLocker { + if logger == nil { + logger = log.StandardLogger() + } + return &flockLocker{certDir: certDir, logger: logger} +} + +// Lock acquires an advisory file lock for the given domain. +func (l *flockLocker) Lock(ctx context.Context, domain string) (func(), error) { + lockPath := filepath.Join(l.certDir, domain+".lock") + lockFile, err := flock.Lock(ctx, lockPath) + if err != nil { + return nil, err + } + + // nil lockFile means locking is not supported (non-unix). + if lockFile == nil { + return func() { /* no-op: locking unsupported on this platform */ }, nil + } + + return func() { + if err := flock.Unlock(lockFile); err != nil { + l.logger.Debugf("release cert lock for domain %q: %v", domain, err) + } + }, nil +} + +type noopLocker struct{} + +// Lock is a no-op that always succeeds immediately. +func (noopLocker) Lock(context.Context, string) (func(), error) { + return func() { /* no-op: locker disabled */ }, nil +} diff --git a/proxy/internal/acme/locker_k8s.go b/proxy/internal/acme/locker_k8s.go new file mode 100644 index 000000000..a3f8043e6 --- /dev/null +++ b/proxy/internal/acme/locker_k8s.go @@ -0,0 +1,197 @@ +package acme + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/internal/k8s" +) + +const ( + // leaseDurationSec is the Kubernetes Lease TTL. If the holder crashes without + // releasing the lock, other replicas must wait this long before taking over. + // This is intentionally generous: in the worst case two replicas may both + // issue an ACME request for the same domain, which is harmless (the CA + // deduplicates and the cache converges). + leaseDurationSec = 300 + retryBaseBackoff = 500 * time.Millisecond + retryMaxBackoff = 10 * time.Second +) + +type k8sLeaseLocker struct { + client *k8s.LeaseClient + identity string + logger *log.Logger +} + +func newK8sLeaseLocker(logger *log.Logger) (*k8sLeaseLocker, error) { + client, err := k8s.NewLeaseClient() + if err != nil { + return nil, fmt.Errorf("create k8s lease client: %w", err) + } + + identity, err := os.Hostname() + if err != nil { + return nil, fmt.Errorf("get hostname: %w", err) + } + + return &k8sLeaseLocker{ + client: client, + identity: identity, + logger: logger, + }, nil +} + +// Lock acquires a Kubernetes Lease for the given domain using optimistic +// concurrency. It retries with exponential backoff until the lease is +// acquired or the context is cancelled. +func (l *k8sLeaseLocker) Lock(ctx context.Context, domain string) (func(), error) { + leaseName := k8s.LeaseNameForDomain(domain) + backoff := retryBaseBackoff + + for { + acquired, err := l.tryAcquire(ctx, leaseName, domain) + if err != nil { + return nil, fmt.Errorf("acquire lease %s for %q: %w", leaseName, domain, err) + } + if acquired { + l.logger.Debugf("k8s lease %s acquired for domain %q", leaseName, domain) + return l.unlockFunc(leaseName, domain), nil + } + + l.logger.Debugf("k8s lease %s held by another replica, retrying in %s", leaseName, backoff) + + timer := time.NewTimer(backoff) + select { + case <-ctx.Done(): + timer.Stop() + return nil, ctx.Err() + case <-timer.C: + } + + backoff *= 2 + if backoff > retryMaxBackoff { + backoff = retryMaxBackoff + } + } +} + +// tryAcquire attempts to create or take over a Lease. Returns (true, nil) +// on success, (false, nil) if the lease is held and not stale, or an error. +func (l *k8sLeaseLocker) tryAcquire(ctx context.Context, name, domain string) (bool, error) { + existing, err := l.client.Get(ctx, name) + if err != nil { + return false, err + } + + now := k8s.MicroTime{Time: time.Now().UTC()} + dur := int32(leaseDurationSec) + + if existing == nil { + lease := &k8s.Lease{ + Metadata: k8s.LeaseMetadata{ + Name: name, + Annotations: map[string]string{ + "netbird.io/domain": domain, + }, + }, + Spec: k8s.LeaseSpec{ + HolderIdentity: &l.identity, + LeaseDurationSeconds: &dur, + AcquireTime: &now, + RenewTime: &now, + }, + } + + if _, err := l.client.Create(ctx, lease); errors.Is(err, k8s.ErrConflict) { + return false, nil + } else if err != nil { + return false, err + } + return true, nil + } + + if !l.canTakeover(existing) { + return false, nil + } + + existing.Spec.HolderIdentity = &l.identity + existing.Spec.LeaseDurationSeconds = &dur + existing.Spec.AcquireTime = &now + existing.Spec.RenewTime = &now + + if _, err := l.client.Update(ctx, existing); errors.Is(err, k8s.ErrConflict) { + return false, nil + } else if err != nil { + return false, err + } + return true, nil +} + +// canTakeover returns true if the lease is free (no holder) or stale +// (renewTime + leaseDuration has passed). +func (l *k8sLeaseLocker) canTakeover(lease *k8s.Lease) bool { + holder := lease.Spec.HolderIdentity + if holder == nil || *holder == "" { + return true + } + + // We already hold it (e.g. from a previous crashed attempt). + if *holder == l.identity { + return true + } + + if lease.Spec.RenewTime == nil || lease.Spec.LeaseDurationSeconds == nil { + return true + } + + expiry := lease.Spec.RenewTime.Add(time.Duration(*lease.Spec.LeaseDurationSeconds) * time.Second) + if time.Now().After(expiry) { + l.logger.Infof("k8s lease %s held by %q is stale (expired %s ago), taking over", + lease.Metadata.Name, *holder, time.Since(expiry).Round(time.Second)) + return true + } + + return false +} + +// unlockFunc returns a closure that releases the lease by clearing the holder. +func (l *k8sLeaseLocker) unlockFunc(name, domain string) func() { + return func() { + // Use a fresh context: the parent may already be cancelled. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Re-GET to get current resourceVersion (ours may be stale if + // the lock was held for a long time and something updated it). + current, err := l.client.Get(ctx, name) + if err != nil { + l.logger.Debugf("release k8s lease %s for %q: get: %v", name, domain, err) + return + } + if current == nil { + return + } + + // Only clear if we're still the holder. + if current.Spec.HolderIdentity == nil || *current.Spec.HolderIdentity != l.identity { + l.logger.Debugf("k8s lease %s for %q: holder changed to %v, skip release", + name, domain, current.Spec.HolderIdentity) + return + } + + empty := "" + current.Spec.HolderIdentity = &empty + current.Spec.AcquireTime = nil + current.Spec.RenewTime = nil + + if _, err := l.client.Update(ctx, current); err != nil { + l.logger.Debugf("release k8s lease %s for %q: update: %v", name, domain, err) + } + } +} diff --git a/proxy/internal/acme/locker_test.go b/proxy/internal/acme/locker_test.go new file mode 100644 index 000000000..39245df0c --- /dev/null +++ b/proxy/internal/acme/locker_test.go @@ -0,0 +1,65 @@ +package acme + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFlockLockerRoundTrip(t *testing.T) { + dir := t.TempDir() + locker := newFlockLocker(dir, nil) + + unlock, err := locker.Lock(context.Background(), "example.com") + require.NoError(t, err) + require.NotNil(t, unlock) + + // Lock file should exist. + assert.FileExists(t, filepath.Join(dir, "example.com.lock")) + + unlock() +} + +func TestNoopLocker(t *testing.T) { + locker := noopLocker{} + unlock, err := locker.Lock(context.Background(), "example.com") + require.NoError(t, err) + require.NotNil(t, unlock) + unlock() +} + +func TestNewCertLockerDefaultsToFlock(t *testing.T) { + dir := t.TempDir() + + // t.Setenv registers cleanup to restore the original value. + // os.Unsetenv is needed because the production code uses LookupEnv, + // which distinguishes "empty" from "not set". + t.Setenv("KUBERNETES_SERVICE_HOST", "") + os.Unsetenv("KUBERNETES_SERVICE_HOST") + locker := newCertLocker(CertLockAuto, dir, nil) + + _, ok := locker.(*flockLocker) + assert.True(t, ok, "auto without k8s env should select flockLocker") +} + +func TestNewCertLockerExplicitFlock(t *testing.T) { + dir := t.TempDir() + locker := newCertLocker(CertLockFlock, dir, nil) + + _, ok := locker.(*flockLocker) + assert.True(t, ok, "explicit flock should select flockLocker") +} + +func TestNewCertLockerK8sFallsBackToFlock(t *testing.T) { + dir := t.TempDir() + + // k8s-lease without SA files should fall back to flock. + locker := newCertLocker(CertLockK8sLease, dir, nil) + + _, ok := locker.(*flockLocker) + assert.True(t, ok, "k8s-lease without SA should fall back to flockLocker") +} diff --git a/proxy/internal/acme/manager.go b/proxy/internal/acme/manager.go new file mode 100644 index 000000000..a4a220ed7 --- /dev/null +++ b/proxy/internal/acme/manager.go @@ -0,0 +1,643 @@ +package acme + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/asn1" + "encoding/base64" + "encoding/binary" + "encoding/pem" + "fmt" + "math/rand/v2" + "net" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/acme" + "golang.org/x/crypto/acme/autocert" + + "github.com/netbirdio/netbird/proxy/internal/certwatch" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/domain" +) + +// OID for the SCT list extension (1.3.6.1.4.1.11129.2.4.2) +var oidSCTList = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 4, 2} + +type certificateNotifier interface { + NotifyCertificateIssued(ctx context.Context, accountID types.AccountID, serviceID types.ServiceID, domain string) error +} + +type domainState int + +const ( + domainPending domainState = iota + domainReady + domainFailed +) + +type domainInfo struct { + accountID types.AccountID + serviceID types.ServiceID + state domainState + err string +} + +type metricsRecorder interface { + RecordCertificateIssuance(duration time.Duration) +} + +// wildcardEntry maps a domain suffix (e.g. ".example.com") to a certwatch +// watcher that hot-reloads the corresponding wildcard certificate from disk. +type wildcardEntry struct { + suffix string // e.g. ".example.com" + pattern string // e.g. "*.example.com" + watcher *certwatch.Watcher +} + +// ManagerConfig holds the configuration values for the ACME certificate manager. +type ManagerConfig struct { + // CertDir is the directory used for caching ACME certificates. + CertDir string + // ACMEURL is the ACME directory URL (e.g. Let's Encrypt). + ACMEURL string + // EABKID and EABHMACKey are optional External Account Binding credentials + // required by some CAs (e.g. ZeroSSL). EABHMACKey is the base64 + // URL-encoded string provided by the CA. + EABKID string + EABHMACKey string + // LockMethod controls the cross-replica coordination strategy. + LockMethod CertLockMethod + // WildcardDir is an optional path to a directory containing wildcard + // certificate pairs (.crt / .key). Wildcard patterns are + // extracted from the certificates' SAN lists. Domains matching a + // wildcard are served from disk; all others go through ACME. + WildcardDir string +} + +// Manager wraps autocert.Manager with domain tracking and cross-replica +// coordination via a pluggable locking strategy. The locker prevents +// duplicate ACME requests when multiple replicas share a certificate cache. +type Manager struct { + *autocert.Manager + + certDir string + locker certLocker + mu sync.RWMutex + domains map[domain.Domain]*domainInfo + + // wildcards holds all loaded wildcard certificates, keyed by suffix. + wildcards []wildcardEntry + + certNotifier certificateNotifier + logger *log.Logger + metrics metricsRecorder +} + +// NewManager creates a new ACME certificate manager. +func NewManager(cfg ManagerConfig, notifier certificateNotifier, logger *log.Logger, metrics metricsRecorder) (*Manager, error) { + if logger == nil { + logger = log.StandardLogger() + } + mgr := &Manager{ + certDir: cfg.CertDir, + locker: newCertLocker(cfg.LockMethod, cfg.CertDir, logger), + domains: make(map[domain.Domain]*domainInfo), + certNotifier: notifier, + logger: logger, + metrics: metrics, + } + + if cfg.WildcardDir != "" { + entries, err := loadWildcardDir(cfg.WildcardDir, logger) + if err != nil { + return nil, fmt.Errorf("load wildcard certificates from %q: %w", cfg.WildcardDir, err) + } + mgr.wildcards = entries + } + + var eab *acme.ExternalAccountBinding + if cfg.EABKID != "" && cfg.EABHMACKey != "" { + decodedKey, err := base64.RawURLEncoding.DecodeString(cfg.EABHMACKey) + if err != nil { + logger.Errorf("failed to decode EAB HMAC key: %v", err) + } else { + eab = &acme.ExternalAccountBinding{ + KID: cfg.EABKID, + Key: decodedKey, + } + logger.Infof("configured External Account Binding with KID: %s", cfg.EABKID) + } + } + + mgr.Manager = &autocert.Manager{ + Prompt: autocert.AcceptTOS, + HostPolicy: mgr.hostPolicy, + Cache: autocert.DirCache(cfg.CertDir), + ExternalAccountBinding: eab, + Client: &acme.Client{ + DirectoryURL: cfg.ACMEURL, + }, + } + return mgr, nil +} + +// WatchWildcards starts watching all wildcard certificate files for changes. +// It blocks until ctx is cancelled. It is a no-op if no wildcards are loaded. +func (mgr *Manager) WatchWildcards(ctx context.Context) { + if len(mgr.wildcards) == 0 { + return + } + seen := make(map[*certwatch.Watcher]struct{}) + var wg sync.WaitGroup + for i := range mgr.wildcards { + w := mgr.wildcards[i].watcher + if _, ok := seen[w]; ok { + continue + } + seen[w] = struct{}{} + wg.Add(1) + go func() { + defer wg.Done() + w.Watch(ctx) + }() + } + wg.Wait() +} + +// loadWildcardDir scans dir for .crt files, pairs each with a matching .key +// file, loads them, and extracts wildcard SANs (*.example.com) to build +// the suffix lookup entries. +func loadWildcardDir(dir string, logger *log.Logger) ([]wildcardEntry, error) { + crtFiles, err := filepath.Glob(filepath.Join(dir, "*.crt")) + if err != nil { + return nil, fmt.Errorf("glob certificate files: %w", err) + } + + if len(crtFiles) == 0 { + return nil, fmt.Errorf("no .crt files found in %s", dir) + } + + var entries []wildcardEntry + + for _, crtPath := range crtFiles { + base := strings.TrimSuffix(filepath.Base(crtPath), ".crt") + keyPath := filepath.Join(dir, base+".key") + if _, err := os.Stat(keyPath); err != nil { + logger.Warnf("skipping %s: no matching key file %s", crtPath, keyPath) + continue + } + + watcher, err := certwatch.NewWatcher(crtPath, keyPath, logger) + if err != nil { + logger.Warnf("skipping %s: %v", crtPath, err) + continue + } + + leaf := watcher.Leaf() + if leaf == nil { + logger.Warnf("skipping %s: no parsed leaf certificate", crtPath) + continue + } + + for _, san := range leaf.DNSNames { + suffix, ok := parseWildcard(san) + if !ok { + continue + } + entries = append(entries, wildcardEntry{ + suffix: suffix, + pattern: san, + watcher: watcher, + }) + logger.Infof("wildcard certificate loaded: %s (from %s)", san, filepath.Base(crtPath)) + } + } + + if len(entries) == 0 { + return nil, fmt.Errorf("no wildcard SANs (*.example.com) found in certificates in %s", dir) + } + + return entries, nil +} + +// parseWildcard validates a wildcard domain pattern like "*.example.com" +// and returns the suffix ".example.com" for matching. +func parseWildcard(pattern string) (suffix string, ok bool) { + if !strings.HasPrefix(pattern, "*.") { + return "", false + } + parent := pattern[1:] // ".example.com" + if strings.Count(parent, ".") < 1 { + return "", false + } + return strings.ToLower(parent), true +} + +// findWildcardEntry returns the wildcard entry that covers host, or nil. +func (mgr *Manager) findWildcardEntry(host string) *wildcardEntry { + if len(mgr.wildcards) == 0 { + return nil + } + host = strings.ToLower(host) + for i := range mgr.wildcards { + e := &mgr.wildcards[i] + if !strings.HasSuffix(host, e.suffix) { + continue + } + // Single-level match: prefix before suffix must have no dots. + prefix := strings.TrimSuffix(host, e.suffix) + if len(prefix) > 0 && !strings.Contains(prefix, ".") { + return e + } + } + return nil +} + +// WildcardPatterns returns the wildcard patterns that are currently loaded. +func (mgr *Manager) WildcardPatterns() []string { + patterns := make([]string, len(mgr.wildcards)) + for i, e := range mgr.wildcards { + patterns[i] = e.pattern + } + slices.Sort(patterns) + return patterns +} + +func (mgr *Manager) hostPolicy(_ context.Context, host string) error { + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + mgr.mu.RLock() + _, exists := mgr.domains[domain.Domain(host)] + mgr.mu.RUnlock() + if !exists { + return fmt.Errorf("unknown domain %q", host) + } + return nil +} + +// GetCertificate returns the TLS certificate for the given ClientHello. +// If the requested domain matches a loaded wildcard, the static wildcard +// certificate is returned. Otherwise, the ACME autocert manager handles +// the request. +func (mgr *Manager) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + if e := mgr.findWildcardEntry(hello.ServerName); e != nil { + return e.watcher.GetCertificate(hello) + } + return mgr.Manager.GetCertificate(hello) +} + +// AddDomain registers a domain for certificate management. Domains that +// match a loaded wildcard are marked ready immediately (they use the +// static wildcard certificate) and the method returns true. All other +// domains go through ACME prefetch and the method returns false. +// +// When AddDomain returns true the caller is responsible for sending any +// certificate-ready notifications after the surrounding operation (e.g. +// mapping update) has committed successfully. +func (mgr *Manager) AddDomain(d domain.Domain, accountID types.AccountID, serviceID types.ServiceID) (wildcardHit bool) { + name := d.PunycodeString() + if e := mgr.findWildcardEntry(name); e != nil { + mgr.mu.Lock() + mgr.domains[d] = &domainInfo{ + accountID: accountID, + serviceID: serviceID, + state: domainReady, + } + mgr.mu.Unlock() + mgr.logger.Debugf("domain %q matches wildcard %q, using static certificate", name, e.pattern) + return true + } + + mgr.mu.Lock() + mgr.domains[d] = &domainInfo{ + accountID: accountID, + serviceID: serviceID, + state: domainPending, + } + mgr.mu.Unlock() + + go mgr.prefetchCertificate(d) + return false +} + +// prefetchCertificate proactively triggers certificate generation for a domain. +// It acquires a distributed lock to prevent multiple replicas from issuing +// duplicate ACME requests. The second replica will block until the first +// finishes, then find the certificate in the cache. +// ACME and periodic disk reads race; whichever produces a valid certificate +// first wins. This handles cases where locking is unreliable and another +// replica already wrote the cert to the shared cache. +func (mgr *Manager) prefetchCertificate(d domain.Domain) { + time.Sleep(time.Duration(rand.IntN(200)) * time.Millisecond) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + name := d.PunycodeString() + + mgr.logger.Infof("acquiring cert lock for domain %q", name) + lockStart := time.Now() + unlock, err := mgr.locker.Lock(ctx, name) + if err != nil { + mgr.logger.Warnf("acquire cert lock for domain %q, proceeding without lock: %v", name, err) + } else { + mgr.logger.Infof("acquired cert lock for domain %q in %s", name, time.Since(lockStart)) + defer unlock() + } + + if cert, err := mgr.readCertFromDisk(ctx, name); err == nil { + mgr.logger.Infof("certificate for domain %q already on disk, skipping ACME", name) + mgr.recordAndNotify(ctx, d, name, cert, 0) + return + } + + // Run ACME in a goroutine so we can race it against periodic disk reads. + // autocert uses its own internal context and cannot be cancelled externally. + type acmeResult struct { + cert *tls.Certificate + err error + } + acmeCh := make(chan acmeResult, 1) + hello := &tls.ClientHelloInfo{ServerName: name, Conn: &dummyConn{ctx: ctx}} + go func() { + cert, err := mgr.GetCertificate(hello) + acmeCh <- acmeResult{cert, err} + }() + + start := time.Now() + diskTicker := time.NewTicker(5 * time.Second) + defer diskTicker.Stop() + + for { + select { + case res := <-acmeCh: + elapsed := time.Since(start) + if res.err != nil { + mgr.logger.Warnf("prefetch certificate for domain %q in %s: %v", name, elapsed.String(), res.err) + mgr.setDomainState(d, domainFailed, res.err.Error()) + return + } + mgr.recordAndNotify(ctx, d, name, res.cert, elapsed) + return + + case <-diskTicker.C: + cert, err := mgr.readCertFromDisk(context.Background(), name) + if err != nil { + continue + } + mgr.logger.Infof("certificate for domain %q appeared on disk after %s", name, time.Since(start).Round(time.Millisecond)) + // Drain the ACME goroutine before marking ready — autocert holds + // an internal write lock on certState while ACME is in flight. + go func() { + select { + case <-acmeCh: + default: + } + mgr.recordAndNotify(context.Background(), d, name, cert, 0) + }() + return + + case <-ctx.Done(): + mgr.logger.Warnf("prefetch certificate for domain %q timed out", name) + mgr.setDomainState(d, domainFailed, ctx.Err().Error()) + return + } + } +} + +// readCertFromDisk reads and parses a certificate directly from the autocert +// DirCache, bypassing autocert's internal certState mutex. Safe to call +// concurrently with an in-flight ACME request for the same domain. +func (mgr *Manager) readCertFromDisk(ctx context.Context, name string) (*tls.Certificate, error) { + if mgr.Cache == nil { + return nil, fmt.Errorf("no cache configured") + } + data, err := mgr.Cache.Get(ctx, name) + if err != nil { + return nil, err + } + privBlock, certsPEM := pem.Decode(data) + if privBlock == nil || !strings.Contains(privBlock.Type, "PRIVATE") { + return nil, fmt.Errorf("no private key in cache for %q", name) + } + cert, err := tls.X509KeyPair(certsPEM, pem.EncodeToMemory(privBlock)) + if err != nil { + return nil, fmt.Errorf("parse cached certificate for %q: %w", name, err) + } + if len(cert.Certificate) > 0 { + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return nil, fmt.Errorf("parse leaf for %q: %w", name, err) + } + if time.Now().After(leaf.NotAfter) { + return nil, fmt.Errorf("cached certificate for %q expired at %s", name, leaf.NotAfter) + } + cert.Leaf = leaf + } + return &cert, nil +} + +// recordAndNotify records metrics, marks the domain ready, logs cert details, +// and notifies the cert notifier. +func (mgr *Manager) recordAndNotify(ctx context.Context, d domain.Domain, name string, cert *tls.Certificate, elapsed time.Duration) { + if elapsed > 0 && mgr.metrics != nil { + mgr.metrics.RecordCertificateIssuance(elapsed) + } + mgr.setDomainState(d, domainReady, "") + now := time.Now() + if cert != nil && cert.Leaf != nil { + leaf := cert.Leaf + mgr.logger.Infof("certificate for domain %q ready in %s: serial=%s SANs=%v notBefore=%s, notAfter=%s, now=%s", + name, elapsed.Round(time.Millisecond), + leaf.SerialNumber.Text(16), + leaf.DNSNames, + leaf.NotBefore.UTC().Format(time.RFC3339), + leaf.NotAfter.UTC().Format(time.RFC3339), + now.UTC().Format(time.RFC3339), + ) + mgr.logCertificateDetails(name, leaf, now) + } else { + mgr.logger.Infof("certificate for domain %q ready in %s", name, elapsed.Round(time.Millisecond)) + } + mgr.mu.RLock() + info := mgr.domains[d] + mgr.mu.RUnlock() + if info != nil && mgr.certNotifier != nil { + if err := mgr.certNotifier.NotifyCertificateIssued(ctx, info.accountID, info.serviceID, name); err != nil { + mgr.logger.Warnf("notify certificate ready for domain %q: %v", name, err) + } + } +} + +func (mgr *Manager) setDomainState(d domain.Domain, state domainState, errMsg string) { + mgr.mu.Lock() + defer mgr.mu.Unlock() + if info, ok := mgr.domains[d]; ok { + info.state = state + info.err = errMsg + } +} + +// logCertificateDetails logs certificate validity and SCT timestamps. +func (mgr *Manager) logCertificateDetails(domain string, cert *x509.Certificate, now time.Time) { + if cert.NotBefore.After(now) { + mgr.logger.Warnf("certificate for %q NotBefore is in the future by %v", domain, cert.NotBefore.Sub(now)) + } + + sctTimestamps := mgr.parseSCTTimestamps(cert) + if len(sctTimestamps) == 0 { + return + } + + for i, sctTime := range sctTimestamps { + if sctTime.After(now) { + mgr.logger.Warnf("certificate for %q SCT[%d] timestamp is in the future: %v (by %v)", + domain, i, sctTime.UTC(), sctTime.Sub(now)) + } else { + mgr.logger.Debugf("certificate for %q SCT[%d] timestamp: %v (%v in the past)", + domain, i, sctTime.UTC(), now.Sub(sctTime)) + } + } +} + +// parseSCTTimestamps extracts SCT timestamps from a certificate. +func (mgr *Manager) parseSCTTimestamps(cert *x509.Certificate) []time.Time { + var timestamps []time.Time + + for _, ext := range cert.Extensions { + if !ext.Id.Equal(oidSCTList) { + continue + } + + // The extension value is an OCTET STRING containing the SCT list + var sctListBytes []byte + if _, err := asn1.Unmarshal(ext.Value, &sctListBytes); err != nil { + mgr.logger.Debugf("failed to unmarshal SCT list outer wrapper: %v", err) + continue + } + + // SCT list format: 2-byte length prefix, then concatenated SCTs + if len(sctListBytes) < 2 { + continue + } + + listLen := int(binary.BigEndian.Uint16(sctListBytes[:2])) + data := sctListBytes[2:] + if len(data) < listLen { + continue + } + + // Parse individual SCTs + offset := 0 + for offset < listLen { + if offset+2 > len(data) { + break + } + sctLen := int(binary.BigEndian.Uint16(data[offset : offset+2])) + offset += 2 + + if offset+sctLen > len(data) { + break + } + sctData := data[offset : offset+sctLen] + offset += sctLen + + // SCT format: version (1) + log_id (32) + timestamp (8) + ... + if len(sctData) < 41 { + continue + } + + // Timestamp is at offset 33 (after version + log_id), 8 bytes, milliseconds since epoch + tsMillis := binary.BigEndian.Uint64(sctData[33:41]) + ts := time.UnixMilli(int64(tsMillis)) + timestamps = append(timestamps, ts) + } + } + + return timestamps +} + +// dummyConn implements net.Conn to provide context for certificate fetching. +type dummyConn struct { + ctx context.Context +} + +func (c *dummyConn) Read(b []byte) (n int, err error) { return 0, nil } +func (c *dummyConn) Write(b []byte) (n int, err error) { return len(b), nil } +func (c *dummyConn) Close() error { return nil } +func (c *dummyConn) LocalAddr() net.Addr { return nil } +func (c *dummyConn) RemoteAddr() net.Addr { return nil } +func (c *dummyConn) SetDeadline(t time.Time) error { return nil } +func (c *dummyConn) SetReadDeadline(t time.Time) error { return nil } +func (c *dummyConn) SetWriteDeadline(t time.Time) error { return nil } + +// RemoveDomain removes a domain from tracking. +func (mgr *Manager) RemoveDomain(d domain.Domain) { + mgr.mu.Lock() + defer mgr.mu.Unlock() + delete(mgr.domains, d) +} + +// PendingCerts returns the number of certificates currently being prefetched. +func (mgr *Manager) PendingCerts() int { + mgr.mu.RLock() + defer mgr.mu.RUnlock() + var n int + for _, info := range mgr.domains { + if info.state == domainPending { + n++ + } + } + return n +} + +// TotalDomains returns the total number of registered domains. +func (mgr *Manager) TotalDomains() int { + mgr.mu.RLock() + defer mgr.mu.RUnlock() + return len(mgr.domains) +} + +// PendingDomains returns the domain names currently being prefetched. +func (mgr *Manager) PendingDomains() []string { + return mgr.domainsByState(domainPending) +} + +// ReadyDomains returns domain names that have successfully obtained certificates. +func (mgr *Manager) ReadyDomains() []string { + return mgr.domainsByState(domainReady) +} + +// FailedDomains returns domain names that failed certificate prefetch, mapped to their error. +func (mgr *Manager) FailedDomains() map[string]string { + mgr.mu.RLock() + defer mgr.mu.RUnlock() + result := make(map[string]string) + for d, info := range mgr.domains { + if info.state == domainFailed { + result[d.PunycodeString()] = info.err + } + } + return result +} + +func (mgr *Manager) domainsByState(state domainState) []string { + mgr.mu.RLock() + defer mgr.mu.RUnlock() + var domains []string + for d, info := range mgr.domains { + if info.state == state { + domains = append(domains, d.PunycodeString()) + } + } + slices.Sort(domains) + return domains +} diff --git a/proxy/internal/acme/manager_test.go b/proxy/internal/acme/manager_test.go new file mode 100644 index 000000000..ceb9ca13a --- /dev/null +++ b/proxy/internal/acme/manager_test.go @@ -0,0 +1,306 @@ +package acme + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/types" +) + +func TestHostPolicy(t *testing.T) { + mgr, err := NewManager(ManagerConfig{CertDir: t.TempDir(), ACMEURL: "https://acme.example.com/directory"}, nil, nil, nil) + require.NoError(t, err) + mgr.AddDomain("example.com", types.AccountID("acc1"), types.ServiceID("rp1")) + + // Wait for the background prefetch goroutine to finish so the temp dir + // can be cleaned up without a race. + t.Cleanup(func() { + assert.Eventually(t, func() bool { + return mgr.PendingCerts() == 0 + }, 30*time.Second, 50*time.Millisecond) + }) + + tests := []struct { + name string + host string + wantErr bool + }{ + { + name: "exact domain match", + host: "example.com", + }, + { + name: "domain with port", + host: "example.com:443", + }, + { + name: "unknown domain", + host: "unknown.com", + wantErr: true, + }, + { + name: "unknown domain with port", + host: "unknown.com:443", + wantErr: true, + }, + { + name: "empty host", + host: "", + wantErr: true, + }, + { + name: "port only", + host: ":443", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := mgr.hostPolicy(context.Background(), tc.host) + if tc.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown domain") + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestDomainStates(t *testing.T) { + mgr, err := NewManager(ManagerConfig{CertDir: t.TempDir(), ACMEURL: "https://acme.example.com/directory"}, nil, nil, nil) + require.NoError(t, err) + + assert.Equal(t, 0, mgr.PendingCerts(), "initially zero") + assert.Equal(t, 0, mgr.TotalDomains(), "initially zero domains") + assert.Empty(t, mgr.PendingDomains()) + assert.Empty(t, mgr.ReadyDomains()) + assert.Empty(t, mgr.FailedDomains()) + + // AddDomain starts as pending, then the prefetch goroutine will fail + // (no real ACME server) and transition to failed. + mgr.AddDomain("a.example.com", types.AccountID("acc1"), types.ServiceID("rp1")) + mgr.AddDomain("b.example.com", types.AccountID("acc1"), types.ServiceID("rp1")) + + assert.Equal(t, 2, mgr.TotalDomains(), "two domains registered") + + // Pending domains should eventually drain after prefetch goroutines finish. + assert.Eventually(t, func() bool { + return mgr.PendingCerts() == 0 + }, 30*time.Second, 100*time.Millisecond, "pending certs should return to zero after prefetch completes") + + assert.Empty(t, mgr.PendingDomains()) + assert.Equal(t, 2, mgr.TotalDomains(), "total domains unchanged") + + // With a fake ACME URL, both should have failed. + failed := mgr.FailedDomains() + assert.Len(t, failed, 2, "both domains should have failed") + assert.Contains(t, failed, "a.example.com") + assert.Contains(t, failed, "b.example.com") + assert.Empty(t, mgr.ReadyDomains()) +} + +func TestParseWildcard(t *testing.T) { + tests := []struct { + pattern string + wantSuffix string + wantOK bool + }{ + {"*.example.com", ".example.com", true}, + {"*.foo.example.com", ".foo.example.com", true}, + {"*.COM", ".com", true}, // single-label TLD + {"example.com", "", false}, // no wildcard prefix + {"*example.com", "", false}, // missing dot + {"**.example.com", "", false}, // double star + {"", "", false}, + } + + for _, tc := range tests { + t.Run(tc.pattern, func(t *testing.T) { + suffix, ok := parseWildcard(tc.pattern) + assert.Equal(t, tc.wantOK, ok) + if ok { + assert.Equal(t, tc.wantSuffix, suffix) + } + }) + } +} + +func TestMatchesWildcard(t *testing.T) { + wcDir := t.TempDir() + generateSelfSignedCert(t, wcDir, "example", "*.example.com") + + acmeDir := t.TempDir() + mgr, err := NewManager(ManagerConfig{CertDir: acmeDir, ACMEURL: "https://acme.example.com/directory", WildcardDir: wcDir}, nil, nil, nil) + require.NoError(t, err) + + tests := []struct { + host string + match bool + }{ + {"foo.example.com", true}, + {"bar.example.com", true}, + {"FOO.Example.COM", true}, // case insensitive + {"example.com", false}, // bare parent + {"sub.foo.example.com", false}, // multi-level + {"notexample.com", false}, + {"", false}, + } + + for _, tc := range tests { + t.Run(tc.host, func(t *testing.T) { + assert.Equal(t, tc.match, mgr.findWildcardEntry(tc.host) != nil) + }) + } +} + +// generateSelfSignedCert creates a temporary self-signed certificate and key +// for testing purposes. The baseName controls the output filenames: +// .crt and .key. +func generateSelfSignedCert(t *testing.T, dir, baseName string, dnsNames ...string) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: dnsNames[0]}, + DNSNames: dnsNames, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + certFile, err := os.Create(filepath.Join(dir, baseName+".crt")) + require.NoError(t, err) + require.NoError(t, pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER})) + require.NoError(t, certFile.Close()) + + keyDER, err := x509.MarshalECPrivateKey(key) + require.NoError(t, err) + keyFile, err := os.Create(filepath.Join(dir, baseName+".key")) + require.NoError(t, err) + require.NoError(t, pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})) + require.NoError(t, keyFile.Close()) +} + +func TestWildcardAddDomainSkipsACME(t *testing.T) { + wcDir := t.TempDir() + generateSelfSignedCert(t, wcDir, "example", "*.example.com") + + acmeDir := t.TempDir() + mgr, err := NewManager(ManagerConfig{CertDir: acmeDir, ACMEURL: "https://acme.example.com/directory", WildcardDir: wcDir}, nil, nil, nil) + require.NoError(t, err) + + // Add a wildcard-matching domain — should be immediately ready. + mgr.AddDomain("foo.example.com", types.AccountID("acc1"), types.ServiceID("svc1")) + assert.Equal(t, 0, mgr.PendingCerts(), "wildcard domain should not be pending") + assert.Equal(t, []string{"foo.example.com"}, mgr.ReadyDomains()) + + // Add a non-wildcard domain — should go through ACME (pending then failed). + mgr.AddDomain("other.net", types.AccountID("acc2"), types.ServiceID("svc2")) + assert.Equal(t, 2, mgr.TotalDomains()) + + // Wait for the ACME prefetch to fail. + assert.Eventually(t, func() bool { + return mgr.PendingCerts() == 0 + }, 30*time.Second, 100*time.Millisecond) + + assert.Equal(t, []string{"foo.example.com"}, mgr.ReadyDomains()) + assert.Contains(t, mgr.FailedDomains(), "other.net") +} + +func TestWildcardGetCertificate(t *testing.T) { + wcDir := t.TempDir() + generateSelfSignedCert(t, wcDir, "example", "*.example.com") + + acmeDir := t.TempDir() + mgr, err := NewManager(ManagerConfig{CertDir: acmeDir, ACMEURL: "https://acme.example.com/directory", WildcardDir: wcDir}, nil, nil, nil) + require.NoError(t, err) + + mgr.AddDomain("foo.example.com", types.AccountID("acc1"), types.ServiceID("svc1")) + + // GetCertificate for a wildcard-matching domain should return the static cert. + cert, err := mgr.GetCertificate(&tls.ClientHelloInfo{ServerName: "foo.example.com"}) + require.NoError(t, err) + require.NotNil(t, cert) + assert.Contains(t, cert.Leaf.DNSNames, "*.example.com") +} + +func TestMultipleWildcards(t *testing.T) { + wcDir := t.TempDir() + generateSelfSignedCert(t, wcDir, "example", "*.example.com") + generateSelfSignedCert(t, wcDir, "other", "*.other.org") + + acmeDir := t.TempDir() + mgr, err := NewManager(ManagerConfig{CertDir: acmeDir, ACMEURL: "https://acme.example.com/directory", WildcardDir: wcDir}, nil, nil, nil) + require.NoError(t, err) + + assert.ElementsMatch(t, []string{"*.example.com", "*.other.org"}, mgr.WildcardPatterns()) + + // Both wildcards should resolve. + mgr.AddDomain("foo.example.com", types.AccountID("acc1"), types.ServiceID("svc1")) + mgr.AddDomain("bar.other.org", types.AccountID("acc2"), types.ServiceID("svc2")) + + assert.Equal(t, 0, mgr.PendingCerts()) + assert.ElementsMatch(t, []string{"foo.example.com", "bar.other.org"}, mgr.ReadyDomains()) + + // GetCertificate routes to the correct cert. + cert1, err := mgr.GetCertificate(&tls.ClientHelloInfo{ServerName: "foo.example.com"}) + require.NoError(t, err) + assert.Contains(t, cert1.Leaf.DNSNames, "*.example.com") + + cert2, err := mgr.GetCertificate(&tls.ClientHelloInfo{ServerName: "bar.other.org"}) + require.NoError(t, err) + assert.Contains(t, cert2.Leaf.DNSNames, "*.other.org") + + // Non-matching domain falls through to ACME. + mgr.AddDomain("custom.net", types.AccountID("acc3"), types.ServiceID("svc3")) + assert.Eventually(t, func() bool { + return mgr.PendingCerts() == 0 + }, 30*time.Second, 100*time.Millisecond) + assert.Contains(t, mgr.FailedDomains(), "custom.net") +} + +func TestWildcardDirEmpty(t *testing.T) { + wcDir := t.TempDir() + // Empty directory — no .crt files. + _, err := NewManager(ManagerConfig{CertDir: t.TempDir(), ACMEURL: "https://acme.example.com/directory", WildcardDir: wcDir}, nil, nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no .crt files found") +} + +func TestWildcardDirNonWildcardCert(t *testing.T) { + wcDir := t.TempDir() + // Certificate without a wildcard SAN. + generateSelfSignedCert(t, wcDir, "plain", "plain.example.com") + + _, err := NewManager(ManagerConfig{CertDir: t.TempDir(), ACMEURL: "https://acme.example.com/directory", WildcardDir: wcDir}, nil, nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no wildcard SANs") +} + +func TestNoWildcardDir(t *testing.T) { + // Empty string means no wildcard dir — pure ACME mode. + mgr, err := NewManager(ManagerConfig{CertDir: t.TempDir(), ACMEURL: "https://acme.example.com/directory"}, nil, nil, nil) + require.NoError(t, err) + assert.Empty(t, mgr.WildcardPatterns()) +} diff --git a/proxy/internal/auth/auth.gohtml b/proxy/internal/auth/auth.gohtml new file mode 100644 index 000000000..9cd36b796 --- /dev/null +++ b/proxy/internal/auth/auth.gohtml @@ -0,0 +1,18 @@ + +{{ range $method, $value := .Methods }} +{{ if eq $method "pin" }} +
                                                + + + +
                                                +{{ else if eq $method "password" }} +
                                                + + + +
                                                +{{ else if eq $method "oidc" }} +Click here to log in with SSO +{{ end }} +{{ end }} diff --git a/proxy/internal/auth/header.go b/proxy/internal/auth/header.go new file mode 100644 index 000000000..194800a49 --- /dev/null +++ b/proxy/internal/auth/header.go @@ -0,0 +1,69 @@ +package auth + +import ( + "errors" + "fmt" + "net/http" + + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// ErrHeaderAuthFailed indicates that the header was present but the +// credential did not validate. Callers should return 401 instead of +// falling through to other auth schemes. +var ErrHeaderAuthFailed = errors.New("header authentication failed") + +// Header implements header-based authentication. The proxy checks for the +// configured header in each request and validates its value via gRPC. +type Header struct { + id types.ServiceID + accountId types.AccountID + headerName string + client authenticator +} + +// NewHeader creates a Header authentication scheme for the given header name. +func NewHeader(client authenticator, id types.ServiceID, accountId types.AccountID, headerName string) Header { + return Header{ + id: id, + accountId: accountId, + headerName: headerName, + client: client, + } +} + +// Type returns auth.MethodHeader. +func (Header) Type() auth.Method { + return auth.MethodHeader +} + +// Authenticate checks for the configured header in the request. If absent, +// returns empty (unauthenticated). If present, validates via gRPC. +func (h Header) Authenticate(r *http.Request) (string, string, error) { + value := r.Header.Get(h.headerName) + if value == "" { + return "", "", nil + } + + res, err := h.client.Authenticate(r.Context(), &proto.AuthenticateRequest{ + Id: string(h.id), + AccountId: string(h.accountId), + Request: &proto.AuthenticateRequest_HeaderAuth{ + HeaderAuth: &proto.HeaderAuthRequest{ + HeaderValue: value, + HeaderName: h.headerName, + }, + }, + }) + if err != nil { + return "", "", fmt.Errorf("authenticate header: %w", err) + } + + if res.GetSuccess() { + return res.GetSessionToken(), "", nil + } + + return "", "", ErrHeaderAuthFailed +} diff --git a/proxy/internal/auth/identity.go b/proxy/internal/auth/identity.go new file mode 100644 index 000000000..c49e0caa9 --- /dev/null +++ b/proxy/internal/auth/identity.go @@ -0,0 +1,47 @@ +package auth + +import ( + "context" + "net/netip" +) + +// PeerIdentity describes the locally-known facts about a peer reachable on +// the proxy's per-account WireGuard listener. Phase 3 fills PubKey, TunnelIP +// and FQDN from the embedded client's peerstore. UserID, Email and Groups +// stay zero in V1 — full identity still travels through ValidateTunnelPeer. +// Phase V2 will populate them once RemotePeerConfig carries user identity. +type PeerIdentity struct { + PubKey string + TunnelIP netip.Addr + FQDN string + + // V2 fields (zero in V1). + UserID string + Email string + Groups []string +} + +// TunnelLookupFunc resolves a tunnel IP to a peer identity using locally +// available peerstore data. ok=false means the IP is not in the calling +// account's roster. +type TunnelLookupFunc func(ip netip.Addr) (PeerIdentity, bool) + +type tunnelLookupContextKey struct{} + +// WithTunnelLookup attaches a per-account peerstore lookup function to +// the request context. The auth middleware calls this lookup before +// hitting management's ValidateTunnelPeer to short-circuit unknown IPs +// and to skip the RPC for already-cached identities. +func WithTunnelLookup(ctx context.Context, lookup TunnelLookupFunc) context.Context { + if lookup == nil { + return ctx + } + return context.WithValue(ctx, tunnelLookupContextKey{}, lookup) +} + +// TunnelLookupFromContext returns the peerstore lookup attached to ctx, +// or nil when the request did not arrive on a per-account listener. +func TunnelLookupFromContext(ctx context.Context) TunnelLookupFunc { + v, _ := ctx.Value(tunnelLookupContextKey{}).(TunnelLookupFunc) + return v +} diff --git a/proxy/internal/auth/middleware.go b/proxy/internal/auth/middleware.go new file mode 100644 index 000000000..72630b085 --- /dev/null +++ b/proxy/internal/auth/middleware.go @@ -0,0 +1,740 @@ +package auth + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "errors" + "fmt" + "html" + "net" + "net/http" + "net/netip" + "net/url" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/proxy/internal/restrict" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/proxy/web" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// errValidationUnavailable indicates that session validation failed due to +// an infrastructure error (e.g. gRPC unavailable), not an invalid token. +var errValidationUnavailable = errors.New("session validation unavailable") + +type authenticator interface { + Authenticate(ctx context.Context, in *proto.AuthenticateRequest, opts ...grpc.CallOption) (*proto.AuthenticateResponse, error) +} + +// SessionValidator validates session tokens and checks user access permissions. +type SessionValidator interface { + ValidateSession(ctx context.Context, in *proto.ValidateSessionRequest, opts ...grpc.CallOption) (*proto.ValidateSessionResponse, error) + ValidateTunnelPeer(ctx context.Context, in *proto.ValidateTunnelPeerRequest, opts ...grpc.CallOption) (*proto.ValidateTunnelPeerResponse, error) +} + +// Scheme defines an authentication mechanism for a domain. +type Scheme interface { + Type() auth.Method + // Authenticate checks the request and determines whether it represents + // an authenticated user. An empty token indicates an unauthenticated + // request; optionally, promptData may be returned for the login UI. + // An error indicates an infrastructure failure (e.g. gRPC unavailable). + Authenticate(*http.Request) (token string, promptData string, err error) +} + +// DomainConfig holds the authentication and restriction settings for a protected domain. +type DomainConfig struct { + Schemes []Scheme + SessionPublicKey ed25519.PublicKey + SessionExpiration time.Duration + AccountID types.AccountID + ServiceID types.ServiceID + IPRestrictions *restrict.Filter + // Private routes the domain through ValidateTunnelPeer; failure → 403. + Private bool +} + +type validationResult struct { + UserID string + UserEmail string + Valid bool + DeniedReason string + Groups []string + // GroupNames carries the human-readable display names for Groups, + // ordered identically (positional pairing). May be shorter than + // Groups for tokens minted before names were embedded; the consumer + // falls back to ids for missing positions. + GroupNames []string +} + +// Middleware applies per-domain authentication and IP restriction checks. +type Middleware struct { + domainsMux sync.RWMutex + domains map[string]DomainConfig + logger *log.Logger + sessionValidator SessionValidator + geo restrict.GeoResolver + tunnelCache *tunnelValidationCache +} + +// NewMiddleware creates a new authentication middleware. The sessionValidator is +// optional; if nil, OIDC session tokens are validated locally without group access checks. +func NewMiddleware(logger *log.Logger, sessionValidator SessionValidator, geo restrict.GeoResolver) *Middleware { + if logger == nil { + logger = log.StandardLogger() + } + return &Middleware{ + domains: make(map[string]DomainConfig), + logger: logger, + sessionValidator: sessionValidator, + geo: geo, + tunnelCache: newTunnelValidationCache(), + } +} + +// Protect wraps next with per-domain authentication and IP restriction checks. +// Requests whose Host is not registered pass through unchanged. +func (mw *Middleware) Protect(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host, _, err := net.SplitHostPort(r.Host) + if err != nil { + host = r.Host + } + + config, exists := mw.getDomainConfig(host) + mw.logger.Debugf("checking authentication for host: %s, exists: %t", host, exists) + + if !exists { + next.ServeHTTP(w, r) + return + } + + // Set account and service IDs in captured data for access logging. + setCapturedIDs(r, config) + + if !mw.checkIPRestrictions(w, r, config) { + return + } + + // Private services bypass operator schemes and gate on tunnel peer. + if config.Private { + if mw.forwardWithTunnelPeer(w, r, host, config, next) { + return + } + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + // Domains with no authentication schemes pass through after IP checks. + if len(config.Schemes) == 0 { + next.ServeHTTP(w, r) + return + } + + if mw.handleOAuthCallbackError(w, r) { + return + } + + if mw.forwardWithSessionCookie(w, r, host, config, next) { + return + } + + if mw.forwardWithHeaderAuth(w, r, host, config, next) { + return + } + + if mw.forwardWithTunnelPeer(w, r, host, config, next) { + return + } + + if mw.blockOIDCOnPlainHTTP(w, r, config) { + return + } + + mw.authenticateWithSchemes(w, r, host, config) + }) +} + +// requestIsPlainHTTP reports whether the request arrived without TLS. +// Used to gate cookie-on-plain warnings and the OIDC plain-HTTP block. +func requestIsPlainHTTP(r *http.Request) bool { + return r.TLS == nil +} + +// hasOIDCScheme reports whether any of the configured schemes requires +// TLS to round-trip safely with an external IdP. +func hasOIDCScheme(schemes []Scheme) bool { + for _, s := range schemes { + if s.Type() == auth.MethodOIDC { + return true + } + } + return false +} + +// blockOIDCOnPlainHTTP fails fast when an OIDC-configured domain is hit +// over plain HTTP. Most IdPs reject http:// redirect URIs, so surfacing +// the misconfiguration here yields a clearer error than the IdP's +// "invalid redirect_uri" round-trip. +func (mw *Middleware) blockOIDCOnPlainHTTP(w http.ResponseWriter, r *http.Request, config DomainConfig) bool { + if !requestIsPlainHTTP(r) { + return false + } + if !hasOIDCScheme(config.Schemes) { + return false + } + mw.logger.WithFields(log.Fields{ + "host": r.Host, + "remote": r.RemoteAddr, + }).Warn("OIDC scheme reached on plain HTTP path; rejecting with 400 — use port 443") + http.Error(w, "OIDC requires TLS — use port 443", http.StatusBadRequest) + return true +} + +func (mw *Middleware) getDomainConfig(host string) (DomainConfig, bool) { + mw.domainsMux.RLock() + defer mw.domainsMux.RUnlock() + config, exists := mw.domains[host] + return config, exists +} + +func setCapturedIDs(r *http.Request, config DomainConfig) { + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetAccountID(config.AccountID) + cd.SetServiceID(config.ServiceID) + } +} + +// checkIPRestrictions validates the client IP against the domain's IP restrictions. +// Uses the resolved client IP from CapturedData (which accounts for trusted proxies) +// rather than r.RemoteAddr directly. +func (mw *Middleware) checkIPRestrictions(w http.ResponseWriter, r *http.Request, config DomainConfig) bool { + if config.IPRestrictions == nil { + return true + } + + clientIP := mw.resolveClientIP(r) + if !clientIP.IsValid() { + mw.logger.Debugf("IP restriction: cannot resolve client address for %q, denying", r.RemoteAddr) + http.Error(w, "Forbidden", http.StatusForbidden) + return false + } + + var verdict restrict.Verdict + if types.IsOverlayOrigin(r.Context()) { + // Geo/CrowdSec checks don't apply over the WireGuard overlay: + // the source address is always inside the NetBird CGNAT range, + // which is never in a GeoIP database or a CrowdSec decision + // list. Enforcing them here would either no-op (best case) or + // fail-closed when the geo database is missing. + verdict = config.IPRestrictions.CheckCIDR(clientIP) + } else { + verdict = config.IPRestrictions.Check(clientIP, mw.geo) + } + if verdict == restrict.Allow { + return true + } + + if verdict.IsCrowdSec() { + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetMetadata("crowdsec_verdict", verdict.String()) + if config.IPRestrictions.IsObserveOnly(verdict) { + cd.SetMetadata("crowdsec_mode", "observe") + } + } + } + + if config.IPRestrictions.IsObserveOnly(verdict) { + mw.logger.Debugf("CrowdSec observe: would block %s for %s (%s)", clientIP, r.Host, verdict) + return true + } + + reason := verdict.String() + mw.blockIPRestriction(r, reason) + http.Error(w, "Forbidden", http.StatusForbidden) + return false +} + +// resolveClientIP extracts the real client IP from CapturedData, falling back to r.RemoteAddr. +func (mw *Middleware) resolveClientIP(r *http.Request) netip.Addr { + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + if ip := cd.GetClientIP(); ip.IsValid() { + return ip + } + } + + clientIPStr, _, _ := net.SplitHostPort(r.RemoteAddr) + if clientIPStr == "" { + clientIPStr = r.RemoteAddr + } + addr, err := netip.ParseAddr(clientIPStr) + if err != nil { + return netip.Addr{} + } + return addr.Unmap() +} + +// blockIPRestriction sets captured data fields for an IP-restriction block event. +func (mw *Middleware) blockIPRestriction(r *http.Request, reason string) { + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(proxy.OriginAuth) + cd.SetAuthMethod(reason) + } + mw.logger.Debugf("IP restriction: %s for %s", reason, r.RemoteAddr) +} + +// handleOAuthCallbackError checks for error query parameters from an OAuth +// callback and renders the access denied page if present. +func (mw *Middleware) handleOAuthCallbackError(w http.ResponseWriter, r *http.Request) bool { + errCode := r.URL.Query().Get("error") + if errCode == "" { + return false + } + + var requestID string + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(proxy.OriginAuth) + cd.SetAuthMethod(auth.MethodOIDC.String()) + requestID = cd.GetRequestID() + } + errDesc := r.URL.Query().Get("error_description") + if errDesc == "" { + errDesc = "An error occurred during authentication" + } else { + errDesc = html.EscapeString(errDesc) + } + web.ServeAccessDeniedPage(w, r, http.StatusForbidden, "Access Denied", errDesc, requestID) + return true +} + +// forwardWithSessionCookie checks for a valid session cookie and, if found, +// sets the user identity on the request context and forwards to the next handler. +func (mw *Middleware) forwardWithSessionCookie(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, next http.Handler) bool { + cookie, err := r.Cookie(auth.SessionCookieName) + if err != nil { + return false + } + userID, email, method, groups, groupNames, err := auth.ValidateSessionJWT(cookie.Value, host, config.SessionPublicKey) + if err != nil { + return false + } + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetUserID(userID) + cd.SetUserEmail(email) + cd.SetUserGroups(groups) + cd.SetUserGroupNames(groupNames) + cd.SetAuthMethod(method) + } + next.ServeHTTP(w, r) + return true +} + +// forwardWithTunnelPeer is the OIDC fast-path for requests originating on the +// netbird mesh. When the source IP belongs to a private/CGNAT range the proxy +// asks management to resolve it to a peer/user and to gate by the service's +// distribution_groups. On success the proxy installs the freshly minted JWT +// as a session cookie, sets UserID + Method=oidc on the captured data, and +// forwards directly — operators see the same access-log shape as if the user +// had completed an OIDC redirect. Any failure (private-range mismatch, +// management unreachable, peer unknown, user not in group) returns false so +// the caller falls back to the existing OIDC scheme dispatch. +// +// The fast-path is gated on TunnelLookupFromContext(r.Context()) being +// present — that context value is attached only by the per-account +// inbound (overlay) listener. The host listener never sets it, so a +// public client whose source IP happens to fall inside an RFC1918 / ULA +// / CGNAT range can't impersonate a mesh peer by colliding with a +// tunnel-IP. Once we know the request arrived over WireGuard the +// per-account peerstore lookup is consulted: a miss denies fast (no +// management round-trip), a hit gates the cached ValidateTunnelPeer RPC +// that mints the session JWT. +func (mw *Middleware) forwardWithTunnelPeer(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, next http.Handler) bool { + if mw.sessionValidator == nil { + return false + } + clientIP := mw.resolveClientIP(r) + if !clientIP.IsValid() { + return false + } + + // Anti-spoof: only honour the tunnel-peer fast-path on requests that + // were stamped by an overlay listener. Without that marker an + // attacker could send a request from a colliding RFC1918 / CGNAT + // source on the public listener and bypass operator auth. + lookup := TunnelLookupFromContext(r.Context()) + if lookup == nil { + return false + } + if !isTunnelSourceIP(clientIP) { + return false + } + if _, ok := lookup(clientIP); !ok { + mw.logger.WithFields(log.Fields{ + "host": host, + "remote": clientIP, + }).Debug("local peerstore: tunnel IP not in account roster; denying without RPC") + return false + } + + resp, _, err := mw.tunnelCache.fetch(r.Context(), tunnelCacheKey{ + accountID: config.AccountID, + tunnelIP: clientIP, + domain: host, + }, mw.validateTunnelPeer) + if err != nil { + mw.logger.WithError(err).Debug("ValidateTunnelPeer failed; falling back to OIDC") + return false + } + if !resp.GetValid() || resp.GetSessionToken() == "" { + return false + } + + setSessionCookie(w, resp.GetSessionToken(), config.SessionExpiration) + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(proxy.OriginAuth) + cd.SetUserID(resp.GetUserId()) + cd.SetUserEmail(resp.GetUserEmail()) + cd.SetUserGroups(resp.GetPeerGroupIds()) + cd.SetUserGroupNames(resp.GetPeerGroupNames()) + cd.SetAuthMethod(auth.MethodOIDC.String()) + } + next.ServeHTTP(w, r) + return true +} + +// validateTunnelPeer adapts the SessionValidator interface to the cache's +// validateTunnelPeerFn signature. +func (mw *Middleware) validateTunnelPeer(ctx context.Context, req *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) { + return mw.sessionValidator.ValidateTunnelPeer(ctx, req) +} + +// cgnatPrefix covers RFC 6598 100.64.0.0/10, the CGNAT block NetBird +// allocates tunnel addresses from by default. IsPrivate() doesn't include +// it, so we check it explicitly. +var cgnatPrefix = netip.MustParsePrefix("100.64.0.0/10") + +// isTunnelSourceIP reports whether ip falls within an address range typical +// of NetBird tunnels: RFC1918 private space, IPv6 ULA, or CGNAT 100.64/10 +// (NetBird's default range). Loopback and link-local are excluded — the +// fast-path is meant for peer-to-peer mesh traffic, not localhost. +func isTunnelSourceIP(ip netip.Addr) bool { + if !ip.IsValid() || ip.IsLoopback() || ip.IsLinkLocalUnicast() { + return false + } + if ip.IsPrivate() { + return true + } + return cgnatPrefix.Contains(ip) +} + +// forwardWithHeaderAuth checks for a Header auth scheme. If the header validates, +// the request is forwarded directly (no redirect), which is important for API clients. +func (mw *Middleware) forwardWithHeaderAuth(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, next http.Handler) bool { + for _, scheme := range config.Schemes { + hdr, ok := scheme.(Header) + if !ok { + continue + } + + handled := mw.tryHeaderScheme(w, r, host, config, hdr, next) + if handled { + return true + } + } + return false +} + +func (mw *Middleware) tryHeaderScheme(w http.ResponseWriter, r *http.Request, host string, config DomainConfig, hdr Header, next http.Handler) bool { + token, _, err := hdr.Authenticate(r) + if err != nil { + return mw.handleHeaderAuthError(w, r, err) + } + if token == "" { + return false + } + + result, err := mw.validateSessionToken(r.Context(), host, token, config.SessionPublicKey, auth.MethodHeader) + if err != nil { + setHeaderCapturedData(r.Context(), "", "", nil, nil) + status := http.StatusBadRequest + msg := "invalid session token" + if errors.Is(err, errValidationUnavailable) { + status = http.StatusBadGateway + msg = "authentication service unavailable" + } + http.Error(w, msg, status) + return true + } + + if !result.Valid { + setHeaderCapturedData(r.Context(), result.UserID, result.UserEmail, result.Groups, result.GroupNames) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return true + } + + setSessionCookie(w, token, config.SessionExpiration) + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetUserID(result.UserID) + cd.SetUserEmail(result.UserEmail) + cd.SetUserGroups(result.Groups) + cd.SetUserGroupNames(result.GroupNames) + cd.SetAuthMethod(auth.MethodHeader.String()) + } + + next.ServeHTTP(w, r) + return true +} + +func (mw *Middleware) handleHeaderAuthError(w http.ResponseWriter, r *http.Request, err error) bool { + if errors.Is(err, ErrHeaderAuthFailed) { + setHeaderCapturedData(r.Context(), "", "", nil, nil) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return true + } + mw.logger.WithField("scheme", "header").Warnf("header auth infrastructure error: %v", err) + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(proxy.OriginAuth) + } + http.Error(w, "authentication service unavailable", http.StatusBadGateway) + return true +} + +func setHeaderCapturedData(ctx context.Context, userID, userEmail string, groups, groupNames []string) { + cd := proxy.CapturedDataFromContext(ctx) + if cd == nil { + return + } + cd.SetOrigin(proxy.OriginAuth) + cd.SetAuthMethod(auth.MethodHeader.String()) + cd.SetUserID(userID) + cd.SetUserEmail(userEmail) + cd.SetUserGroups(groups) + cd.SetUserGroupNames(groupNames) +} + +// authenticateWithSchemes tries each configured auth scheme in order. +// On success it sets a session cookie and redirects; on failure it renders the login page. +func (mw *Middleware) authenticateWithSchemes(w http.ResponseWriter, r *http.Request, host string, config DomainConfig) { + methods := make(map[string]string) + var attemptedMethod string + + for _, scheme := range config.Schemes { + token, promptData, err := scheme.Authenticate(r) + if err != nil { + mw.logger.WithField("scheme", scheme.Type().String()).Warnf("authentication infrastructure error: %v", err) + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(proxy.OriginAuth) + } + http.Error(w, "authentication service unavailable", http.StatusBadGateway) + return + } + + // Track if credentials were submitted but auth failed + if token == "" && wasCredentialSubmitted(r, scheme.Type()) { + attemptedMethod = scheme.Type().String() + } + + if token != "" { + mw.handleAuthenticatedToken(w, r, host, token, config, scheme) + return + } + methods[scheme.Type().String()] = promptData + } + + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(proxy.OriginAuth) + if attemptedMethod != "" { + cd.SetAuthMethod(attemptedMethod) + } + } + + if oidcURL, ok := methods[auth.MethodOIDC.String()]; ok && len(methods) == 1 && oidcURL != "" { + http.Redirect(w, r, oidcURL, http.StatusFound) + return + } + + web.ServeHTTP(w, r, map[string]any{"methods": methods}, http.StatusUnauthorized) +} + +// handleAuthenticatedToken validates the token, handles denied access, and on +// success sets a session cookie and redirects to the original URL. +func (mw *Middleware) handleAuthenticatedToken(w http.ResponseWriter, r *http.Request, host, token string, config DomainConfig, scheme Scheme) { + result, err := mw.validateSessionToken(r.Context(), host, token, config.SessionPublicKey, scheme.Type()) + if err != nil { + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(proxy.OriginAuth) + cd.SetAuthMethod(scheme.Type().String()) + } + status := http.StatusBadRequest + msg := "invalid session token" + if errors.Is(err, errValidationUnavailable) { + status = http.StatusBadGateway + msg = "authentication service unavailable" + } + http.Error(w, msg, status) + return + } + + if !result.Valid { + var requestID string + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(proxy.OriginAuth) + cd.SetUserID(result.UserID) + cd.SetUserEmail(result.UserEmail) + cd.SetUserGroups(result.Groups) + cd.SetUserGroupNames(result.GroupNames) + cd.SetAuthMethod(scheme.Type().String()) + requestID = cd.GetRequestID() + } + web.ServeAccessDeniedPage(w, r, http.StatusForbidden, "Access Denied", "You are not authorized to access this service", requestID) + return + } + + setSessionCookie(w, token, config.SessionExpiration) + + // Redirect instead of forwarding the auth POST to the backend. + // The browser will follow with a GET carrying the new session cookie. + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(proxy.OriginAuth) + cd.SetUserID(result.UserID) + cd.SetUserEmail(result.UserEmail) + cd.SetUserGroups(result.Groups) + cd.SetUserGroupNames(result.GroupNames) + cd.SetAuthMethod(scheme.Type().String()) + } + redirectURL := stripSessionTokenParam(r.URL) + http.Redirect(w, r, redirectURL, http.StatusSeeOther) +} + +// setSessionCookie writes a session cookie with secure defaults. +func setSessionCookie(w http.ResponseWriter, token string, expiration time.Duration) { + if expiration == 0 { + expiration = auth.DefaultSessionExpiry + } + http.SetCookie(w, &http.Cookie{ + Name: auth.SessionCookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int(expiration.Seconds()), + }) +} + +// wasCredentialSubmitted checks if credentials were submitted for the given auth method. +func wasCredentialSubmitted(r *http.Request, method auth.Method) bool { + switch method { + case auth.MethodPIN: + return r.FormValue("pin") != "" + case auth.MethodPassword: + return r.FormValue("password") != "" + case auth.MethodOIDC: + return r.URL.Query().Get("session_token") != "" + } + return false +} + +// AddDomain registers authentication schemes for the given domain. With schemes a valid session public key is required. +// private=true forces ValidateTunnelPeer enforcement (403 on failure) regardless of the schemes list. +func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter, private bool) error { + if len(schemes) == 0 { + mw.domainsMux.Lock() + defer mw.domainsMux.Unlock() + mw.domains[domain] = DomainConfig{ + AccountID: accountID, + ServiceID: serviceID, + IPRestrictions: ipRestrictions, + Private: private, + } + return nil + } + + pubKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyB64) + if err != nil { + return fmt.Errorf("decode session public key for domain %s: %w", domain, err) + } + if len(pubKeyBytes) != ed25519.PublicKeySize { + return fmt.Errorf("invalid session public key size for domain %s: got %d, want %d", domain, len(pubKeyBytes), ed25519.PublicKeySize) + } + + mw.domainsMux.Lock() + defer mw.domainsMux.Unlock() + mw.domains[domain] = DomainConfig{ + Schemes: schemes, + SessionPublicKey: pubKeyBytes, + SessionExpiration: expiration, + AccountID: accountID, + ServiceID: serviceID, + IPRestrictions: ipRestrictions, + Private: private, + } + return nil +} + +// RemoveDomain unregisters authentication for the given domain. +func (mw *Middleware) RemoveDomain(domain string) { + mw.domainsMux.Lock() + defer mw.domainsMux.Unlock() + delete(mw.domains, domain) +} + +// validateSessionToken validates a session token. OIDC tokens with a configured +// validator go through gRPC for group access checks; other methods validate locally. +func (mw *Middleware) validateSessionToken(ctx context.Context, host, token string, publicKey ed25519.PublicKey, method auth.Method) (*validationResult, error) { + if method == auth.MethodOIDC && mw.sessionValidator != nil { + resp, err := mw.sessionValidator.ValidateSession(ctx, &proto.ValidateSessionRequest{ + Domain: host, + SessionToken: token, + }) + if err != nil { + return nil, fmt.Errorf("%w: %w", errValidationUnavailable, err) + } + if !resp.Valid { + mw.logger.WithFields(log.Fields{ + "domain": host, + "denied_reason": resp.DeniedReason, + "user_id": resp.UserId, + }).Debug("Session validation denied") + return &validationResult{ + UserID: resp.UserId, + UserEmail: resp.GetUserEmail(), + Valid: false, + DeniedReason: resp.DeniedReason, + }, nil + } + return &validationResult{ + UserID: resp.UserId, + UserEmail: resp.GetUserEmail(), + Valid: true, + Groups: resp.GetPeerGroupIds(), + GroupNames: resp.GetPeerGroupNames(), + }, nil + } + + userID, email, _, groups, groupNames, err := auth.ValidateSessionJWT(token, host, publicKey) + if err != nil { + return nil, err + } + return &validationResult{UserID: userID, UserEmail: email, Valid: true, Groups: groups, GroupNames: groupNames}, nil +} + +// stripSessionTokenParam returns the request URI with the session_token query +// parameter removed so it doesn't linger in the browser's address bar or history. +func stripSessionTokenParam(u *url.URL) string { + q := u.Query() + if !q.Has("session_token") { + return u.RequestURI() + } + q.Del("session_token") + clean := *u + clean.RawQuery = q.Encode() + return clean.RequestURI() +} diff --git a/proxy/internal/auth/middleware_test.go b/proxy/internal/auth/middleware_test.go new file mode 100644 index 000000000..6608c2b22 --- /dev/null +++ b/proxy/internal/auth/middleware_test.go @@ -0,0 +1,1406 @@ +package auth + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/tls" + "encoding/base64" + "errors" + "net/http" + "net/http/httptest" + "net/netip" + "net/url" + "strings" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey" + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/proxy/internal/restrict" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +func generateTestKeyPair(t *testing.T) *sessionkey.KeyPair { + t.Helper() + kp, err := sessionkey.GenerateKeyPair() + require.NoError(t, err) + return kp +} + +// stubScheme is a minimal Scheme implementation for testing. +type stubScheme struct { + method auth.Method + token string + promptID string + authFn func(*http.Request) (string, string, error) +} + +func (s *stubScheme) Type() auth.Method { return s.method } + +func (s *stubScheme) Authenticate(r *http.Request) (string, string, error) { + if s.authFn != nil { + return s.authFn(r) + } + return s.token, s.promptID, nil +} + +func newPassthroughHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("backend")) + }) +} + +func TestAddDomain_ValidKey(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + err := mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false) + require.NoError(t, err) + + mw.domainsMux.RLock() + config, exists := mw.domains["example.com"] + mw.domainsMux.RUnlock() + + assert.True(t, exists, "domain should be registered") + assert.Len(t, config.Schemes, 1) + assert.Equal(t, ed25519.PublicKeySize, len(config.SessionPublicKey)) + assert.Equal(t, time.Hour, config.SessionExpiration) +} + +func TestAddDomain_EmptyKey(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + err := mw.AddDomain("example.com", []Scheme{scheme}, "", time.Hour, "", "", nil, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid session public key size") + + mw.domainsMux.RLock() + _, exists := mw.domains["example.com"] + mw.domainsMux.RUnlock() + assert.False(t, exists, "domain must not be registered with an empty session key") +} + +func TestAddDomain_InvalidBase64(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + err := mw.AddDomain("example.com", []Scheme{scheme}, "not-valid-base64!!!", time.Hour, "", "", nil, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "decode session public key") + + mw.domainsMux.RLock() + _, exists := mw.domains["example.com"] + mw.domainsMux.RUnlock() + assert.False(t, exists, "domain must not be registered with invalid base64 key") +} + +func TestAddDomain_WrongKeySize(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + + shortKey := base64.StdEncoding.EncodeToString([]byte("tooshort")) + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + err := mw.AddDomain("example.com", []Scheme{scheme}, shortKey, time.Hour, "", "", nil, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid session public key size") + + mw.domainsMux.RLock() + _, exists := mw.domains["example.com"] + mw.domainsMux.RUnlock() + assert.False(t, exists, "domain must not be registered with a wrong-size key") +} + +func TestAddDomain_NoSchemes_NoKeyRequired(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + + err := mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false) + require.NoError(t, err, "domains with no auth schemes should not require a key") + + mw.domainsMux.RLock() + _, exists := mw.domains["example.com"] + mw.domainsMux.RUnlock() + assert.True(t, exists) +} + +func TestAddDomain_OverwritesPreviousConfig(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp1 := generateTestKeyPair(t) + kp2 := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false)) + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp2.PublicKey, 2*time.Hour, "", "", nil, false)) + + mw.domainsMux.RLock() + config := mw.domains["example.com"] + mw.domainsMux.RUnlock() + + pubKeyBytes, _ := base64.StdEncoding.DecodeString(kp2.PublicKey) + assert.Equal(t, ed25519.PublicKey(pubKeyBytes), config.SessionPublicKey, "should use the latest key") + assert.Equal(t, 2*time.Hour, config.SessionExpiration) +} + +func TestRemoveDomain(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + + mw.RemoveDomain("example.com") + + mw.domainsMux.RLock() + _, exists := mw.domains["example.com"] + mw.domainsMux.RUnlock() + assert.False(t, exists) +} + +func TestProtect_UnknownDomainPassesThrough(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + handler := mw.Protect(newPassthroughHandler()) + + req := httptest.NewRequest(http.MethodGet, "http://unknown.com/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "backend", rec.Body.String()) +} + +func TestProtect_DomainWithNoSchemesPassesThrough(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + require.NoError(t, mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false)) + + handler := mw.Protect(newPassthroughHandler()) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "backend", rec.Body.String()) +} + +func TestProtect_UnauthenticatedRequestIsBlocked(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + + var backendCalled bool + backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + backendCalled = true + w.WriteHeader(http.StatusOK) + }) + handler := mw.Protect(backend) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.False(t, backendCalled, "unauthenticated request should not reach backend") +} + +func TestProtect_HostWithPortIsMatched(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + + var backendCalled bool + backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + backendCalled = true + w.WriteHeader(http.StatusOK) + }) + handler := mw.Protect(backend) + + req := httptest.NewRequest(http.MethodGet, "http://example.com:8443/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.False(t, backendCalled, "host with port should still match the protected domain") +} + +func TestProtect_ValidSessionCookiePassesThrough(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + + token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour) + require.NoError(t, err) + + capturedData := proxy.NewCapturedData("") + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cd := proxy.CapturedDataFromContext(r.Context()) + require.NotNil(t, cd) + assert.Equal(t, "test-user", cd.GetUserID()) + assert.Equal(t, "pin", cd.GetAuthMethod()) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("authenticated")) + })) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req = req.WithContext(proxy.WithCapturedData(req.Context(), capturedData)) + req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: token}) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "authenticated", rec.Body.String()) +} + +// TestProtect_SessionCookieGroupsPropagate verifies the cookie path lifts the +// JWT's groups claim into CapturedData so policy-aware middlewares can +// authorise without an extra management round-trip. +func TestProtect_SessionCookieGroupsPropagate(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + + groups := []string{"engineering", "sre"} + token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, groups, nil, time.Hour) + require.NoError(t, err) + + capturedData := proxy.NewCapturedData("") + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cd := proxy.CapturedDataFromContext(r.Context()) + require.NotNil(t, cd, "captured data must be present in request context") + assert.Equal(t, "test-user", cd.GetUserID()) + assert.Equal(t, groups, cd.GetUserGroups(), "JWT groups claim must propagate to CapturedData") + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req = req.WithContext(proxy.WithCapturedData(req.Context(), capturedData)) + req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: token}) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code, "request with valid groups-bearing cookie must succeed") + assert.Equal(t, groups, capturedData.GetUserGroups(), "CapturedData groups must be retained after handler completes") +} + +// stubTunnelValidator implements SessionValidator for the tunnel-peer +// path. ValidateTunnelPeer returns a fixed response so tests can assert +// how the proxy maps it onto CapturedData, and records whether the +// fast-path actually reached management. +type stubTunnelValidator struct { + called bool + resp *proto.ValidateTunnelPeerResponse +} + +func (s *stubTunnelValidator) ValidateSession(context.Context, *proto.ValidateSessionRequest, ...grpc.CallOption) (*proto.ValidateSessionResponse, error) { + return nil, errors.New("not used in this test") +} + +func (s *stubTunnelValidator) ValidateTunnelPeer(context.Context, *proto.ValidateTunnelPeerRequest, ...grpc.CallOption) (*proto.ValidateTunnelPeerResponse, error) { + s.called = true + return s.resp, nil +} + +// TestProtect_PrivateService_TunnelPeerGroupsPropagate locks the agent-network +// auth path end-to-end at the proxy edge: a Private service must route through +// ValidateTunnelPeer and lift the returned peer_group_ids onto CapturedData so +// the llm_router group-authorisation pass can see them. Regression guard for +// the failure that surfaces downstream as llm_policy.no_authorised_provider — +// i.e. a synthesised service that reaches the proxy without private=true (so +// this path is skipped) leaves UserGroups empty and every request is denied. +func TestProtect_PrivateService_TunnelPeerGroupsPropagate(t *testing.T) { + groups := []string{"grp-admins", "grp-users"} + names := []string{"Admins", "Users"} + validator := &stubTunnelValidator{resp: &proto.ValidateTunnelPeerResponse{ + Valid: true, + UserId: "user-1", + UserEmail: "user@example.com", + SessionToken: "tunnel-session-token", + PeerGroupIds: groups, + PeerGroupNames: names, + }} + mw := NewMiddleware(log.StandardLogger(), validator, nil) + kp := generateTestKeyPair(t) + + // Private service: no operator schemes — auth gates solely on the tunnel peer. + require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true)) + + cd := proxy.NewCapturedData("") + cd.SetClientIP(netip.MustParseAddr("100.90.1.14")) // CGNAT tunnel source + + var seenGroups []string + var seenUser string + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c := proxy.CapturedDataFromContext(r.Context()) + require.NotNil(t, c, "captured data must be present in request context") + seenGroups = c.GetUserGroups() + seenUser = c.GetUserID() + w.WriteHeader(http.StatusOK) + })) + + lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) { + return PeerIdentity{}, true + }) + req := httptest.NewRequest(http.MethodPost, "http://agent.example.com/v1/chat/completions", nil) + req.RemoteAddr = "100.90.1.14:5000" + req = req.WithContext(WithTunnelLookup(proxy.WithCapturedData(req.Context(), cd), lookup)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, "private service must authorise a tunnel peer the validator accepts") + assert.Equal(t, groups, seenGroups, "ValidateTunnelPeer peer_group_ids must reach CapturedData.UserGroups for llm_router authorisation") + assert.Equal(t, "user-1", seenUser, "tunnel-peer principal must reach CapturedData") + assert.Equal(t, groups, cd.GetUserGroups(), "groups must persist on CapturedData after the handler returns") +} + +// TestProtect_PrivateService_TunnelPeerDenied verifies the deny path: when +// ValidateTunnelPeer rejects the peer, a Private service 403s and never reaches +// the upstream handler (no fall-through to unauthenticated pass-through). +func TestProtect_PrivateService_TunnelPeerDenied(t *testing.T) { + validator := &stubTunnelValidator{resp: &proto.ValidateTunnelPeerResponse{ + Valid: false, + DeniedReason: "not_in_group", + }} + mw := NewMiddleware(log.StandardLogger(), validator, nil) + kp := generateTestKeyPair(t) + require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true)) + + cd := proxy.NewCapturedData("") + cd.SetClientIP(netip.MustParseAddr("100.90.1.14")) + + reached := false + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reached = true + w.WriteHeader(http.StatusOK) + })) + lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) { + return PeerIdentity{}, true + }) + req := httptest.NewRequest(http.MethodPost, "http://agent.example.com/v1/chat/completions", nil) + req.RemoteAddr = "100.90.1.14:5000" + req = req.WithContext(WithTunnelLookup(proxy.WithCapturedData(req.Context(), cd), lookup)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusForbidden, rec.Code, "private service must 403 when the tunnel peer is rejected") + assert.False(t, reached, "denied private request must not reach the upstream handler") +} + +func TestProtect_ExpiredSessionCookieIsRejected(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + + // Sign a token that expired 1 second ago. + token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, -time.Second) + require.NoError(t, err) + + var backendCalled bool + backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + backendCalled = true + w.WriteHeader(http.StatusOK) + }) + handler := mw.Protect(backend) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: token}) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.False(t, backendCalled, "expired session should not reach the backend") +} + +func TestProtect_WrongDomainCookieIsRejected(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + + // Token signed for a different domain audience. + token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "other.com", auth.MethodPIN, nil, nil, time.Hour) + require.NoError(t, err) + + var backendCalled bool + backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + backendCalled = true + w.WriteHeader(http.StatusOK) + }) + handler := mw.Protect(backend) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: token}) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.False(t, backendCalled, "cookie for wrong domain should be rejected") +} + +func TestProtect_WrongKeyCookieIsRejected(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp1 := generateTestKeyPair(t) + kp2 := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false)) + + // Token signed with a different private key. + token, err := sessionkey.SignToken(kp2.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour) + require.NoError(t, err) + + var backendCalled bool + backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + backendCalled = true + w.WriteHeader(http.StatusOK) + }) + handler := mw.Protect(backend) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: token}) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.False(t, backendCalled, "cookie signed by wrong key should be rejected") +} + +func TestProtect_SchemeAuthRedirectsWithCookie(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + token, err := sessionkey.SignToken(kp.PrivateKey, "pin-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour) + require.NoError(t, err) + + scheme := &stubScheme{ + method: auth.MethodPIN, + authFn: func(r *http.Request) (string, string, error) { + if r.FormValue("pin") == "111111" { + return token, "", nil + } + return "", "pin", nil + }, + } + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + + var backendCalled bool + backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + backendCalled = true + w.WriteHeader(http.StatusOK) + }) + handler := mw.Protect(backend) + + // Submit the PIN via form POST. + form := url.Values{"pin": {"111111"}} + req := httptest.NewRequest(http.MethodPost, "http://example.com/somepath", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.False(t, backendCalled, "backend should not be called during auth, only a redirect should be returned") + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, "/somepath", rec.Header().Get("Location"), "redirect should point to the original request URI") + + cookies := rec.Result().Cookies() + var sessionCookie *http.Cookie + for _, c := range cookies { + if c.Name == auth.SessionCookieName { + sessionCookie = c + break + } + } + require.NotNil(t, sessionCookie, "session cookie should be set after successful auth") + assert.True(t, sessionCookie.HttpOnly) + assert.True(t, sessionCookie.Secure) + assert.Equal(t, http.SameSiteLaxMode, sessionCookie.SameSite) +} + +func TestSetSessionCookieHasRootPath(t *testing.T) { + w := httptest.NewRecorder() + setSessionCookie(w, "test-token", time.Hour) + + cookies := w.Result().Cookies() + require.Len(t, cookies, 1) + assert.Equal(t, "/", cookies[0].Path, "session cookie must be scoped to root so it applies to all paths") +} + +func TestProtect_FailedAuthDoesNotSetCookie(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{ + method: auth.MethodPIN, + authFn: func(_ *http.Request) (string, string, error) { + return "", "pin", nil + }, + } + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + + handler := mw.Protect(newPassthroughHandler()) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + for _, c := range rec.Result().Cookies() { + assert.NotEqual(t, auth.SessionCookieName, c.Name, "no session cookie should be set on failed auth") + } +} + +func TestProtect_MultipleSchemes(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + token, err := sessionkey.SignToken(kp.PrivateKey, "password-user", "", "example.com", auth.MethodPassword, nil, nil, time.Hour) + require.NoError(t, err) + + // First scheme (PIN) always fails, second scheme (password) succeeds. + pinScheme := &stubScheme{ + method: auth.MethodPIN, + authFn: func(_ *http.Request) (string, string, error) { + return "", "pin", nil + }, + } + passwordScheme := &stubScheme{ + method: auth.MethodPassword, + authFn: func(r *http.Request) (string, string, error) { + if r.FormValue("password") == "secret" { + return token, "", nil + } + return "", "password", nil + }, + } + require.NoError(t, mw.AddDomain("example.com", []Scheme{pinScheme, passwordScheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + + var backendCalled bool + backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + backendCalled = true + w.WriteHeader(http.StatusOK) + }) + handler := mw.Protect(backend) + + form := url.Values{"password": {"secret"}} + req := httptest.NewRequest(http.MethodPost, "http://example.com/", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.False(t, backendCalled, "backend should not be called during auth") + assert.Equal(t, http.StatusSeeOther, rec.Code) +} + +func TestProtect_InvalidTokenFromSchemeReturns400(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + // Return a garbage token that won't validate. + scheme := &stubScheme{ + method: auth.MethodPIN, + authFn: func(_ *http.Request) (string, string, error) { + return "invalid-jwt-token", "", nil + }, + } + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + + handler := mw.Protect(newPassthroughHandler()) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestAddDomain_RandomBytes32NotEd25519(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + + // 32 random bytes that happen to be valid base64 and correct size + // but are actually a valid ed25519 public key length-wise. + // This should succeed because ed25519 public keys are just 32 bytes. + randomBytes := make([]byte, ed25519.PublicKeySize) + _, err := rand.Read(randomBytes) + require.NoError(t, err) + + key := base64.StdEncoding.EncodeToString(randomBytes) + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + + err = mw.AddDomain("example.com", []Scheme{scheme}, key, time.Hour, "", "", nil, false) + require.NoError(t, err, "any 32-byte key should be accepted at registration time") +} + +func TestAddDomain_InvalidKeyDoesNotCorruptExistingConfig(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + + // Attempt to overwrite with an invalid key. + err := mw.AddDomain("example.com", []Scheme{scheme}, "bad", time.Hour, "", "", nil, false) + require.Error(t, err) + + // The original valid config should still be intact. + mw.domainsMux.RLock() + config, exists := mw.domains["example.com"] + mw.domainsMux.RUnlock() + + assert.True(t, exists, "original config should still exist") + assert.Len(t, config.Schemes, 1) + assert.Equal(t, time.Hour, config.SessionExpiration) +} + +func TestProtect_FailedPinAuthCapturesAuthMethod(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + // Scheme that always fails authentication (returns empty token) + scheme := &stubScheme{ + method: auth.MethodPIN, + authFn: func(_ *http.Request) (string, string, error) { + return "", "pin", nil + }, + } + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + + capturedData := proxy.NewCapturedData("") + handler := mw.Protect(newPassthroughHandler()) + + // Submit wrong PIN - should capture auth method + form := url.Values{"pin": {"wrong-pin"}} + req := httptest.NewRequest(http.MethodPost, "http://example.com/", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req = req.WithContext(proxy.WithCapturedData(req.Context(), capturedData)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, "pin", capturedData.GetAuthMethod(), "Auth method should be captured for failed PIN auth") +} + +func TestProtect_FailedPasswordAuthCapturesAuthMethod(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{ + method: auth.MethodPassword, + authFn: func(_ *http.Request) (string, string, error) { + return "", "password", nil + }, + } + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + + capturedData := proxy.NewCapturedData("") + handler := mw.Protect(newPassthroughHandler()) + + // Submit wrong password - should capture auth method + form := url.Values{"password": {"wrong-password"}} + req := httptest.NewRequest(http.MethodPost, "http://example.com/", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req = req.WithContext(proxy.WithCapturedData(req.Context(), capturedData)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, "password", capturedData.GetAuthMethod(), "Auth method should be captured for failed password auth") +} + +func TestProtect_NoCredentialsDoesNotCaptureAuthMethod(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{ + method: auth.MethodPIN, + authFn: func(_ *http.Request) (string, string, error) { + return "", "pin", nil + }, + } + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + + capturedData := proxy.NewCapturedData("") + handler := mw.Protect(newPassthroughHandler()) + + // No credentials submitted - should not capture auth method + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req = req.WithContext(proxy.WithCapturedData(req.Context(), capturedData)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Empty(t, capturedData.GetAuthMethod(), "Auth method should not be captured when no credentials submitted") +} + +func TestWasCredentialSubmitted(t *testing.T) { + tests := []struct { + name string + method auth.Method + formData url.Values + query url.Values + expected bool + }{ + { + name: "PIN submitted", + method: auth.MethodPIN, + formData: url.Values{"pin": {"123456"}}, + expected: true, + }, + { + name: "PIN not submitted", + method: auth.MethodPIN, + formData: url.Values{}, + expected: false, + }, + { + name: "Password submitted", + method: auth.MethodPassword, + formData: url.Values{"password": {"secret"}}, + expected: true, + }, + { + name: "Password not submitted", + method: auth.MethodPassword, + formData: url.Values{}, + expected: false, + }, + { + name: "OIDC token in query", + method: auth.MethodOIDC, + query: url.Values{"session_token": {"abc123"}}, + expected: true, + }, + { + name: "OIDC token not in query", + method: auth.MethodOIDC, + query: url.Values{}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reqURL := "http://example.com/" + if len(tt.query) > 0 { + reqURL += "?" + tt.query.Encode() + } + + var body *strings.Reader + if len(tt.formData) > 0 { + body = strings.NewReader(tt.formData.Encode()) + } else { + body = strings.NewReader("") + } + + req := httptest.NewRequest(http.MethodPost, reqURL, body) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + result := wasCredentialSubmitted(req, tt.method) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestCheckIPRestrictions_UnparseableAddress(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + + err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1", + restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}), false) + require.NoError(t, err) + + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + tests := []struct { + name string + remoteAddr string + wantCode int + }{ + {"unparsable address denies", "not-an-ip:1234", http.StatusForbidden}, + {"empty address denies", "", http.StatusForbidden}, + {"allowed address passes", "10.1.2.3:5678", http.StatusOK}, + {"denied address blocked", "192.168.1.1:5678", http.StatusForbidden}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.RemoteAddr = tt.remoteAddr + req.Host = "example.com" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + assert.Equal(t, tt.wantCode, rr.Code) + }) + } +} + +func TestCheckIPRestrictions_UsesCapturedDataClientIP(t *testing.T) { + // When CapturedData is set (by the access log middleware, which resolves + // trusted proxies), checkIPRestrictions should use that IP, not RemoteAddr. + mw := NewMiddleware(log.StandardLogger(), nil, nil) + + err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1", + restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"203.0.113.0/24"}}), false) + require.NoError(t, err) + + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + // RemoteAddr is a trusted proxy, but CapturedData has the real client IP. + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.RemoteAddr = "10.0.0.1:5000" + req.Host = "example.com" + + cd := proxy.NewCapturedData("") + cd.SetClientIP(netip.MustParseAddr("203.0.113.50")) + ctx := proxy.WithCapturedData(req.Context(), cd) + req = req.WithContext(ctx) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code, "should use CapturedData IP (203.0.113.50), not RemoteAddr (10.0.0.1)") + + // Same request but CapturedData has a blocked IP. + req2 := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req2.RemoteAddr = "203.0.113.50:5000" + req2.Host = "example.com" + + cd2 := proxy.NewCapturedData("") + cd2.SetClientIP(netip.MustParseAddr("10.0.0.1")) + ctx2 := proxy.WithCapturedData(req2.Context(), cd2) + req2 = req2.WithContext(ctx2) + + rr2 := httptest.NewRecorder() + handler.ServeHTTP(rr2, req2) + assert.Equal(t, http.StatusForbidden, rr2.Code, "should use CapturedData IP (10.0.0.1), not RemoteAddr (203.0.113.50)") +} + +func TestCheckIPRestrictions_NilGeoWithCountryRules(t *testing.T) { + // Geo is nil, country restrictions are configured: must deny (fail-close). + mw := NewMiddleware(log.StandardLogger(), nil, nil) + + err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1", + restrict.ParseFilter(restrict.FilterConfig{AllowedCountries: []string{"US"}}), false) + require.NoError(t, err) + + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.RemoteAddr = "1.2.3.4:5678" + req.Host = "example.com" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code, "country restrictions with nil geo must deny") +} + +// TestCheckIPRestrictions_OverlayOriginSkipsCountryRules covers the +// inbound (WG) listener path: requests stamped with WithOverlayOrigin +// must skip country lookups, even when no geo database is configured. +// Without this short-circuit the inbound flow would fail-closed for +// every overlay request whenever country rules are configured. +func TestCheckIPRestrictions_OverlayOriginSkipsCountryRules(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + + err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1", + restrict.ParseFilter(restrict.FilterConfig{ + AllowedCIDRs: []string{"100.64.0.0/10"}, + AllowedCountries: []string{"US"}, + }), false) + require.NoError(t, err) + + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.RemoteAddr = "100.64.5.6:5000" + req.Host = "example.com" + req = req.WithContext(types.WithOverlayOrigin(req.Context())) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code, + "overlay-origin requests must not be denied by country rules they would fail without geo data") + + // Sanity check: the same filter without the overlay flag denies (no geo, + // country allowlist active → DenyGeoUnavailable). + req2 := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req2.RemoteAddr = "100.64.5.6:5000" + req2.Host = "example.com" + rr2 := httptest.NewRecorder() + handler.ServeHTTP(rr2, req2) + assert.Equal(t, http.StatusForbidden, rr2.Code, + "WAN-origin requests must still hit the full Check path and be denied without geo data") +} + +// TestCheckIPRestrictions_OverlayOriginRespectsCIDR confirms CIDR +// rules still apply on the overlay path so operators retain a way to +// scope private services to specific peer subnets. +func TestCheckIPRestrictions_OverlayOriginRespectsCIDR(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + + err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1", + restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"100.64.0.0/16"}}), false) + require.NoError(t, err) + + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.RemoteAddr = "100.65.5.6:5000" // outside 100.64.0.0/16 + req.Host = "example.com" + req = req.WithContext(types.WithOverlayOrigin(req.Context())) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code, + "CIDR rules must still apply on the overlay path") +} + +func TestProtect_OIDCOnlyRedirectsDirectly(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + oidcURL := "https://idp.example.com/authorize?client_id=abc" + scheme := &stubScheme{ + method: auth.MethodOIDC, + authFn: func(_ *http.Request) (string, string, error) { + return "", oidcURL, nil + }, + } + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + + handler := mw.Protect(newPassthroughHandler()) + + req := httptest.NewRequest(http.MethodGet, "https://example.com/", nil) + req.TLS = &tls.ConnectionState{} + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusFound, rec.Code, "should redirect directly to IdP") + assert.Equal(t, oidcURL, rec.Header().Get("Location")) +} + +func TestProtect_OIDCWithOtherMethodShowsLoginPage(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + oidcScheme := &stubScheme{ + method: auth.MethodOIDC, + authFn: func(_ *http.Request) (string, string, error) { + return "", "https://idp.example.com/authorize", nil + }, + } + pinScheme := &stubScheme{ + method: auth.MethodPIN, + authFn: func(_ *http.Request) (string, string, error) { + return "", "pin", nil + }, + } + require.NoError(t, mw.AddDomain("example.com", []Scheme{oidcScheme, pinScheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + + handler := mw.Protect(newPassthroughHandler()) + + req := httptest.NewRequest(http.MethodGet, "https://example.com/", nil) + req.TLS = &tls.ConnectionState{} + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code, "should show login page when multiple methods exist") +} + +// mockAuthenticator is a minimal mock for the authenticator gRPC interface +// used by the Header scheme. +type mockAuthenticator struct { + fn func(ctx context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) +} + +func (m *mockAuthenticator) Authenticate(ctx context.Context, in *proto.AuthenticateRequest, _ ...grpc.CallOption) (*proto.AuthenticateResponse, error) { + return m.fn(ctx, in) +} + +// newHeaderSchemeWithToken creates a Header scheme backed by a mock that +// returns a signed session token when the expected header value is provided. +func newHeaderSchemeWithToken(t *testing.T, kp *sessionkey.KeyPair, headerName, expectedValue string) Header { + t.Helper() + token, err := sessionkey.SignToken(kp.PrivateKey, "header-user", "", "example.com", auth.MethodHeader, nil, nil, time.Hour) + require.NoError(t, err) + + mock := &mockAuthenticator{fn: func(_ context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) { + ha := req.GetHeaderAuth() + if ha != nil && ha.GetHeaderValue() == expectedValue { + return &proto.AuthenticateResponse{Success: true, SessionToken: token}, nil + } + return &proto.AuthenticateResponse{Success: false}, nil + }} + return NewHeader(mock, "svc1", "acc1", headerName) +} + +func TestProtect_HeaderAuth_ForwardsOnSuccess(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key") + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false)) + + var backendCalled bool + capturedData := proxy.NewCapturedData("") + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + backendCalled = true + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/path", nil) + req.Header.Set("X-API-Key", "secret-key") + req = req.WithContext(proxy.WithCapturedData(req.Context(), capturedData)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.True(t, backendCalled, "backend should be called directly for header auth (no redirect)") + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "ok", rec.Body.String()) + + // Session cookie should be set. + var sessionCookie *http.Cookie + for _, c := range rec.Result().Cookies() { + if c.Name == auth.SessionCookieName { + sessionCookie = c + break + } + } + require.NotNil(t, sessionCookie, "session cookie should be set after successful header auth") + assert.True(t, sessionCookie.HttpOnly) + assert.True(t, sessionCookie.Secure) + + assert.Equal(t, "header-user", capturedData.GetUserID()) + assert.Equal(t, "header", capturedData.GetAuthMethod()) +} + +func TestProtect_HeaderAuth_MissingHeaderFallsThrough(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key") + // Also add a PIN scheme so we can verify fallthrough behavior. + pinScheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr, pinScheme}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false)) + + handler := mw.Protect(newPassthroughHandler()) + + // No X-API-Key header: should fall through to PIN login page (401). + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code, "missing header should fall through to login page") +} + +func TestProtect_HeaderAuth_WrongValueReturns401(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + mock := &mockAuthenticator{fn: func(_ context.Context, _ *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) { + return &proto.AuthenticateResponse{Success: false}, nil + }} + hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key") + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false)) + + capturedData := proxy.NewCapturedData("") + handler := mw.Protect(newPassthroughHandler()) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.Header.Set("X-API-Key", "wrong-key") + req = req.WithContext(proxy.WithCapturedData(req.Context(), capturedData)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Equal(t, "header", capturedData.GetAuthMethod()) +} + +func TestProtect_HeaderAuth_InfraErrorReturns502(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + mock := &mockAuthenticator{fn: func(_ context.Context, _ *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) { + return nil, errors.New("gRPC unavailable") + }} + hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key") + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false)) + + handler := mw.Protect(newPassthroughHandler()) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.Header.Set("X-API-Key", "some-key") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadGateway, rec.Code) +} + +func TestProtect_HeaderAuth_SubsequentRequestUsesSessionCookie(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key") + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false)) + + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + // First request with header auth. + req1 := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req1.Header.Set("X-API-Key", "secret-key") + req1 = req1.WithContext(proxy.WithCapturedData(req1.Context(), proxy.NewCapturedData(""))) + rec1 := httptest.NewRecorder() + handler.ServeHTTP(rec1, req1) + require.Equal(t, http.StatusOK, rec1.Code) + + // Extract session cookie. + var sessionCookie *http.Cookie + for _, c := range rec1.Result().Cookies() { + if c.Name == auth.SessionCookieName { + sessionCookie = c + break + } + } + require.NotNil(t, sessionCookie) + + // Second request with only the session cookie (no header). + capturedData2 := proxy.NewCapturedData("") + req2 := httptest.NewRequest(http.MethodGet, "http://example.com/other", nil) + req2.AddCookie(sessionCookie) + req2 = req2.WithContext(proxy.WithCapturedData(req2.Context(), capturedData2)) + rec2 := httptest.NewRecorder() + handler.ServeHTTP(rec2, req2) + + assert.Equal(t, http.StatusOK, rec2.Code) + assert.Equal(t, "header-user", capturedData2.GetUserID()) + assert.Equal(t, "header", capturedData2.GetAuthMethod()) +} + +// TestProtect_HeaderAuth_MultipleValuesSameHeader verifies that the proxy +// correctly handles multiple valid credentials for the same header name. +// In production, the mgmt gRPC authenticateHeader iterates all configured +// header auths and accepts if any hash matches (OR semantics). The proxy +// creates one Header scheme per entry, but a single gRPC call checks all. +func TestProtect_HeaderAuth_MultipleValuesSameHeader(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + // Mock simulates mgmt behavior: accepts either token-a or token-b. + accepted := map[string]bool{"Bearer token-a": true, "Bearer token-b": true} + mock := &mockAuthenticator{fn: func(_ context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) { + ha := req.GetHeaderAuth() + if ha != nil && accepted[ha.GetHeaderValue()] { + token, err := sessionkey.SignToken(kp.PrivateKey, "header-user", "", "example.com", auth.MethodHeader, nil, nil, time.Hour) + require.NoError(t, err) + return &proto.AuthenticateResponse{Success: true, SessionToken: token}, nil + } + return &proto.AuthenticateResponse{Success: false}, nil + }} + + // Single Header scheme (as if one entry existed), but the mock checks both values. + hdr := NewHeader(mock, "svc1", "acc1", "Authorization") + require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false)) + + var backendCalled bool + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + backendCalled = true + w.WriteHeader(http.StatusOK) + })) + + t.Run("first value accepted", func(t *testing.T) { + backendCalled = false + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.Header.Set("Authorization", "Bearer token-a") + req = req.WithContext(proxy.WithCapturedData(req.Context(), proxy.NewCapturedData(""))) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, backendCalled, "first token should be accepted") + }) + + t.Run("second value accepted", func(t *testing.T) { + backendCalled = false + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.Header.Set("Authorization", "Bearer token-b") + req = req.WithContext(proxy.WithCapturedData(req.Context(), proxy.NewCapturedData(""))) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, backendCalled, "second token should be accepted") + }) + + t.Run("unknown value rejected", func(t *testing.T) { + backendCalled = false + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.Header.Set("Authorization", "Bearer token-c") + req = req.WithContext(proxy.WithCapturedData(req.Context(), proxy.NewCapturedData(""))) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.False(t, backendCalled, "unknown token should be rejected") + }) +} + +// TestProtect_OIDCOnPlainHTTP_BlockedWith400 verifies that when an OIDC +// scheme is configured and the request arrived without TLS, the middleware +// short-circuits with a 400 instead of dispatching to the IdP redirect. +func TestProtect_OIDCOnPlainHTTP_BlockedWith400(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{ + method: auth.MethodOIDC, + authFn: func(_ *http.Request) (string, string, error) { + return "", "https://idp.example.com/authorize", nil + }, + } + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + + handler := mw.Protect(newPassthroughHandler()) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code, "OIDC over plain HTTP should be rejected") + assert.Contains(t, rec.Body.String(), "OIDC requires TLS", "response body should explain the rejection") +} + +// TestProtect_OIDCOverTLS_NotBlocked confirms the same configuration works +// over TLS — the block only fires on plain HTTP. +func TestProtect_OIDCOverTLS_NotBlocked(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{ + method: auth.MethodOIDC, + authFn: func(_ *http.Request) (string, string, error) { + return "", "https://idp.example.com/authorize", nil + }, + } + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + + handler := mw.Protect(newPassthroughHandler()) + + req := httptest.NewRequest(http.MethodGet, "https://example.com/", nil) + req.TLS = &tls.ConnectionState{} + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusFound, rec.Code, "OIDC over TLS should redirect to IdP") +} + +// TestProtect_NonOIDCSchemes_PlainHTTP_NotBlocked confirms that the OIDC +// block only fires when an OIDC scheme is configured. PIN-only domains +// pass through normally on plain HTTP. +func TestProtect_NonOIDCSchemes_PlainHTTP_NotBlocked(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + + handler := mw.Protect(newPassthroughHandler()) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code, "PIN-only domain should serve the login page on plain HTTP") +} + +// TestProtect_TunnelPeerFastPath_RequiresInboundMarker guards the +// anti-spoof gate: a request with an RFC1918 source IP arriving on the +// public listener (no TunnelLookupFromContext attached) must not be +// allowed to take the tunnel-peer fast-path. Without this gate a public +// client whose source IP happens to fall inside an RFC1918 range could +// bypass the configured auth scheme by colliding with a known tunnel +// IP. +func TestProtect_TunnelPeerFastPath_RequiresInboundMarker(t *testing.T) { + validator := &stubTunnelValidator{ + resp: &proto.ValidateTunnelPeerResponse{ + Valid: true, + SessionToken: "should-not-be-used", + UserId: "user-1", + }, + } + mw := NewMiddleware(log.StandardLogger(), validator, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + + handler := mw.Protect(newPassthroughHandler()) + + // Request from an RFC1918 source IP on the public listener — no + // TunnelLookupFromContext attached. The fast-path must reject this + // and fall through to the PIN scheme (which renders 401 on plain + // HTTP for a non-authenticated request). + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.RemoteAddr = "100.64.0.5:5000" + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.False(t, validator.called, + "ValidateTunnelPeer must not be invoked when the request lacks the inbound TunnelLookup marker") + assert.Equal(t, http.StatusUnauthorized, rec.Code, + "without the inbound marker the request must fall through to the operator auth scheme") +} + +// TestProtect_TunnelPeerFastPath_TakesPathWithInboundMarker verifies +// the positive side: a request marked as overlay-origin (carrying the +// TunnelLookup context value) and matching a tunnel-IP range does take +// the fast-path and reach management. +func TestProtect_TunnelPeerFastPath_TakesPathWithInboundMarker(t *testing.T) { + validator := &stubTunnelValidator{ + resp: &proto.ValidateTunnelPeerResponse{ + Valid: true, + SessionToken: "tunnel-session-token", + UserId: "user-1", + }, + } + mw := NewMiddleware(log.StandardLogger(), validator, nil) + kp := generateTestKeyPair(t) + + scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"} + require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)) + + handler := mw.Protect(newPassthroughHandler()) + + lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) { + return PeerIdentity{}, true + }) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.RemoteAddr = "100.64.0.5:5000" + req = req.WithContext(WithTunnelLookup(req.Context(), lookup)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.True(t, validator.called, + "ValidateTunnelPeer must run when the request carries the inbound TunnelLookup marker") + assert.Equal(t, http.StatusOK, rec.Code, + "a successful tunnel-peer validation must forward to the next handler") +} diff --git a/proxy/internal/auth/oidc.go b/proxy/internal/auth/oidc.go new file mode 100644 index 000000000..a60e6437a --- /dev/null +++ b/proxy/internal/auth/oidc.go @@ -0,0 +1,66 @@ +package auth + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +type urlGenerator interface { + GetOIDCURL(context.Context, *proto.GetOIDCURLRequest, ...grpc.CallOption) (*proto.GetOIDCURLResponse, error) +} + +type OIDC struct { + id types.ServiceID + accountId types.AccountID + forwardedProto string + client urlGenerator +} + +// NewOIDC creates a new OIDC authentication scheme +func NewOIDC(client urlGenerator, id types.ServiceID, accountId types.AccountID, forwardedProto string) OIDC { + return OIDC{ + id: id, + accountId: accountId, + forwardedProto: forwardedProto, + client: client, + } +} + +func (OIDC) Type() auth.Method { + return auth.MethodOIDC +} + +// Authenticate checks for an OIDC session token or obtains the OIDC redirect URL. +func (o OIDC) Authenticate(r *http.Request) (string, string, error) { + // Check for the session_token query param (from OIDC redirects). + // The management server passes the token in the URL because it cannot set + // cookies for the proxy's domain (cookies are domain-scoped per RFC 6265). + if token := r.URL.Query().Get("session_token"); token != "" { + return token, "", nil + } + + redirectURL := &url.URL{ + Scheme: auth.ResolveProto(o.forwardedProto, r.TLS), + Host: r.Host, + Path: r.URL.Path, + } + + res, err := o.client.GetOIDCURL(r.Context(), &proto.GetOIDCURLRequest{ + Id: string(o.id), + AccountId: string(o.accountId), + RedirectUrl: redirectURL.String(), + }) + if err != nil { + return "", "", fmt.Errorf("get OIDC URL: %w", err) + } + + return "", res.GetUrl(), nil +} diff --git a/proxy/internal/auth/password.go b/proxy/internal/auth/password.go new file mode 100644 index 000000000..6a7eda3e1 --- /dev/null +++ b/proxy/internal/auth/password.go @@ -0,0 +1,63 @@ +package auth + +import ( + "fmt" + "net/http" + + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +const passwordFormId = "password" + +type Password struct { + id types.ServiceID + accountId types.AccountID + client authenticator +} + +func NewPassword(client authenticator, id types.ServiceID, accountId types.AccountID) Password { + return Password{ + id: id, + accountId: accountId, + client: client, + } +} + +func (Password) Type() auth.Method { + return auth.MethodPassword +} + +// Authenticate attempts to authenticate the request using a form +// value passed in the request. +// If authentication fails, the required HTTP form ID is returned +// so that it can be injected into a request from the UI so that +// authentication may be successful. +func (p Password) Authenticate(r *http.Request) (string, string, error) { + password := r.FormValue(passwordFormId) + + if password == "" { + // No password submitted; return the form ID so the UI can prompt the user. + return "", passwordFormId, nil + } + + res, err := p.client.Authenticate(r.Context(), &proto.AuthenticateRequest{ + Id: string(p.id), + AccountId: string(p.accountId), + Request: &proto.AuthenticateRequest_Password{ + Password: &proto.PasswordRequest{ + Password: password, + }, + }, + }) + if err != nil { + return "", "", fmt.Errorf("authenticate password: %w", err) + } + + if res.GetSuccess() { + return res.GetSessionToken(), "", nil + } + + return "", passwordFormId, nil +} diff --git a/proxy/internal/auth/pin.go b/proxy/internal/auth/pin.go new file mode 100644 index 000000000..4d08f3dc6 --- /dev/null +++ b/proxy/internal/auth/pin.go @@ -0,0 +1,63 @@ +package auth + +import ( + "fmt" + "net/http" + + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +const pinFormId = "pin" + +type Pin struct { + id types.ServiceID + accountId types.AccountID + client authenticator +} + +func NewPin(client authenticator, id types.ServiceID, accountId types.AccountID) Pin { + return Pin{ + id: id, + accountId: accountId, + client: client, + } +} + +func (Pin) Type() auth.Method { + return auth.MethodPIN +} + +// Authenticate attempts to authenticate the request using a form +// value passed in the request. +// If authentication fails, the required HTTP form ID is returned +// so that it can be injected into a request from the UI so that +// authentication may be successful. +func (p Pin) Authenticate(r *http.Request) (string, string, error) { + pin := r.FormValue(pinFormId) + + if pin == "" { + // No PIN submitted; return the form ID so the UI can prompt the user. + return "", pinFormId, nil + } + + res, err := p.client.Authenticate(r.Context(), &proto.AuthenticateRequest{ + Id: string(p.id), + AccountId: string(p.accountId), + Request: &proto.AuthenticateRequest_Pin{ + Pin: &proto.PinRequest{ + Pin: pin, + }, + }, + }) + if err != nil { + return "", "", fmt.Errorf("authenticate pin: %w", err) + } + + if res.GetSuccess() { + return res.GetSessionToken(), "", nil + } + + return "", pinFormId, nil +} diff --git a/proxy/internal/auth/tunnel_cache.go b/proxy/internal/auth/tunnel_cache.go new file mode 100644 index 000000000..10b671d82 --- /dev/null +++ b/proxy/internal/auth/tunnel_cache.go @@ -0,0 +1,171 @@ +package auth + +import ( + "context" + "net/netip" + "sync" + "time" + + "golang.org/x/sync/singleflight" + + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// tunnelCacheTTL caps how long a positive ValidateTunnelPeer result is +// reused before re-fetching from management. 5 minutes balances freshness +// against management load on busy mesh networks. +const tunnelCacheTTL = 300 * time.Second + +// tunnelCachePerAccount caps the number of cached identities per account. +// Bounded eviction avoids memory growth in pathological cases (huge peer +// roster, brief request bursts) while staying generous for normal use. +const tunnelCachePerAccount = 1024 + +// tunnelCacheKey identifies a cached entry by tunnel IP and originating +// account. Domain is part of the value, not the key, because the +// management response is per (account, IP) — domain only gates whether a +// re-fetch is needed if the operator is accessing a different service. +type tunnelCacheKey struct { + accountID types.AccountID + tunnelIP netip.Addr + domain string +} + +// tunnelCacheEntry stores a positive validation response with the time it +// was minted. Entries past tunnelCacheTTL are treated as misses. +type tunnelCacheEntry struct { + resp *proto.ValidateTunnelPeerResponse + cachedAt time.Time +} + +// tunnelValidationCache memoizes ValidateTunnelPeer responses keyed by +// (accountID, tunnelIP, domain). Only successful, valid responses are +// cached — denials skip the cache so policy changes apply immediately. +// Single-flight de-duplicates concurrent fetches for the same key so a +// burst of cold requests collapses into a single RPC. +type tunnelValidationCache struct { + mu sync.Mutex + entries map[types.AccountID]*accountBucket + flight singleflight.Group + ttl time.Duration + maxSize int + now func() time.Time +} + +// accountBucket holds the cached entries for a single account, with a +// FIFO eviction queue used when the bucket exceeds maxSize. +type accountBucket struct { + items map[tunnelCacheKey]tunnelCacheEntry + order []tunnelCacheKey +} + +// newTunnelValidationCache constructs a cache with default TTL and bounds. +func newTunnelValidationCache() *tunnelValidationCache { + return &tunnelValidationCache{ + entries: make(map[types.AccountID]*accountBucket), + ttl: tunnelCacheTTL, + maxSize: tunnelCachePerAccount, + now: time.Now, + } +} + +// get returns a cached response for the key, or nil when missing or +// expired. Expired entries are evicted lazily on read. +func (c *tunnelValidationCache) get(key tunnelCacheKey) *proto.ValidateTunnelPeerResponse { + c.mu.Lock() + defer c.mu.Unlock() + + bucket, ok := c.entries[key.accountID] + if !ok { + return nil + } + entry, ok := bucket.items[key] + if !ok { + return nil + } + if c.now().Sub(entry.cachedAt) > c.ttl { + delete(bucket.items, key) + bucket.order = removeKey(bucket.order, key) + return nil + } + return entry.resp +} + +// put records a positive response under the key. Evicts the oldest entry +// in the account's bucket when the bound is exceeded. +func (c *tunnelValidationCache) put(key tunnelCacheKey, resp *proto.ValidateTunnelPeerResponse) { + c.mu.Lock() + defer c.mu.Unlock() + + bucket, ok := c.entries[key.accountID] + if !ok { + bucket = &accountBucket{items: make(map[tunnelCacheKey]tunnelCacheEntry)} + c.entries[key.accountID] = bucket + } + if _, exists := bucket.items[key]; !exists { + bucket.order = append(bucket.order, key) + } + bucket.items[key] = tunnelCacheEntry{resp: resp, cachedAt: c.now()} + + for len(bucket.order) > c.maxSize { + oldest := bucket.order[0] + bucket.order = bucket.order[1:] + delete(bucket.items, oldest) + } +} + +// removeKey drops the first occurrence of needle from order. The cache +// uses small slices so a linear scan is cheaper than a map+slice combo. +func removeKey(order []tunnelCacheKey, needle tunnelCacheKey) []tunnelCacheKey { + for i, k := range order { + if k == needle { + return append(order[:i], order[i+1:]...) + } + } + return order +} + +// flightKey turns a cache key into a single-flight string. AccountID and +// IP isolation by themselves are insufficient because different domains +// for the same peer/account may have different group access. +func flightKey(key tunnelCacheKey) string { + return string(key.accountID) + "|" + key.tunnelIP.String() + "|" + key.domain +} + +// validateTunnelPeerFn is the RPC entry point the cache wraps. It matches +// the SessionValidator.ValidateTunnelPeer signature without exposing the +// gRPC option variadic, since callers don't need it on the cache hot path. +type validateTunnelPeerFn func(ctx context.Context, req *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) + +// fetch returns a cached response when present, otherwise calls validate +// under single-flight and caches the result. Denied responses pass +// through but are not cached so policy changes apply immediately. +func (c *tunnelValidationCache) fetch(ctx context.Context, key tunnelCacheKey, validate validateTunnelPeerFn) (*proto.ValidateTunnelPeerResponse, bool, error) { + if resp := c.get(key); resp != nil { + return resp, true, nil + } + + flight := flightKey(key) + res, err, _ := c.flight.Do(flight, func() (any, error) { + if cached := c.get(key); cached != nil { + return cached, nil + } + resp, err := validate(ctx, &proto.ValidateTunnelPeerRequest{ + TunnelIp: key.tunnelIP.String(), + Domain: key.domain, + }) + if err != nil { + return nil, err + } + if resp.GetValid() && resp.GetSessionToken() != "" { + c.put(key, resp) + } + return resp, nil + }) + if err != nil { + return nil, false, err + } + resp, _ := res.(*proto.ValidateTunnelPeerResponse) + return resp, false, nil +} diff --git a/proxy/internal/auth/tunnel_cache_test.go b/proxy/internal/auth/tunnel_cache_test.go new file mode 100644 index 000000000..1a63dc107 --- /dev/null +++ b/proxy/internal/auth/tunnel_cache_test.go @@ -0,0 +1,171 @@ +package auth + +import ( + "context" + "net/netip" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +func newTestKey(account types.AccountID, ip string, domain string) tunnelCacheKey { + return tunnelCacheKey{ + accountID: account, + tunnelIP: netip.MustParseAddr(ip), + domain: domain, + } +} + +func TestTunnelCache_HitSkipsRPC(t *testing.T) { + cache := newTunnelValidationCache() + key := newTestKey("acct-1", "100.64.0.10", "svc.example") + + var calls int32 + validate := func(_ context.Context, req *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) { + atomic.AddInt32(&calls, 1) + return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok", UserId: "user-1"}, nil + } + + resp, fromCache, err := cache.fetch(context.Background(), key, validate) + require.NoError(t, err) + require.NotNil(t, resp, "first fetch returns RPC response") + assert.False(t, fromCache, "first fetch must not be cached") + + resp2, fromCache2, err := cache.fetch(context.Background(), key, validate) + require.NoError(t, err) + require.NotNil(t, resp2, "second fetch returns cached response") + assert.True(t, fromCache2, "second fetch must be served from cache") + assert.Equal(t, "user-1", resp2.GetUserId(), "cached response should preserve user identity") + assert.Equal(t, int32(1), atomic.LoadInt32(&calls), "validate should run exactly once with one cache hit") +} + +func TestTunnelCache_ExpiredEntryRefetches(t *testing.T) { + cache := newTunnelValidationCache() + clock := time.Now() + cache.now = func() time.Time { return clock } + + key := newTestKey("acct-1", "100.64.0.10", "svc.example") + var calls int32 + validate := func(_ context.Context, _ *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) { + atomic.AddInt32(&calls, 1) + return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok"}, nil + } + + _, _, err := cache.fetch(context.Background(), key, validate) + require.NoError(t, err) + assert.Equal(t, int32(1), atomic.LoadInt32(&calls), "first fetch issues one RPC") + + clock = clock.Add(tunnelCacheTTL + time.Second) + + _, fromCache, err := cache.fetch(context.Background(), key, validate) + require.NoError(t, err) + assert.False(t, fromCache, "expired entry must miss the cache") + assert.Equal(t, int32(2), atomic.LoadInt32(&calls), "expired entry forces a re-fetch") +} + +func TestTunnelCache_DeniedResponseNotCached(t *testing.T) { + cache := newTunnelValidationCache() + key := newTestKey("acct-1", "100.64.0.10", "svc.example") + + var calls int32 + validate := func(_ context.Context, _ *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) { + atomic.AddInt32(&calls, 1) + return &proto.ValidateTunnelPeerResponse{Valid: false, DeniedReason: "not_in_group"}, nil + } + + for i := 0; i < 3; i++ { + _, _, err := cache.fetch(context.Background(), key, validate) + require.NoError(t, err, "fetch must not error on denied response") + } + assert.Equal(t, int32(3), atomic.LoadInt32(&calls), "denied responses bypass the cache so policy changes apply immediately") +} + +func TestTunnelCache_ConcurrentColdHitsCoalesce(t *testing.T) { + cache := newTunnelValidationCache() + key := newTestKey("acct-1", "100.64.0.10", "svc.example") + + gate := make(chan struct{}) + var calls int32 + validate := func(_ context.Context, _ *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) { + atomic.AddInt32(&calls, 1) + <-gate + return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok"}, nil + } + + const workers = 16 + var wg sync.WaitGroup + wg.Add(workers) + results := make([]bool, workers) + for i := 0; i < workers; i++ { + go func(idx int) { + defer wg.Done() + resp, _, err := cache.fetch(context.Background(), key, validate) + results[idx] = err == nil && resp.GetValid() + }(i) + } + + time.Sleep(20 * time.Millisecond) + close(gate) + wg.Wait() + + for i, ok := range results { + assert.Truef(t, ok, "worker %d should observe a successful response", i) + } + assert.Equal(t, int32(1), atomic.LoadInt32(&calls), "single-flight must collapse concurrent cold fetches into one RPC") +} + +func TestTunnelCache_PerAccountIsolation(t *testing.T) { + cache := newTunnelValidationCache() + keyA := newTestKey("acct-a", "100.64.0.10", "svc.example") + keyB := newTestKey("acct-b", "100.64.0.10", "svc.example") + + var callsA, callsB int32 + validateA := func(_ context.Context, _ *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) { + atomic.AddInt32(&callsA, 1) + return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok-a", UserId: "user-a"}, nil + } + validateB := func(_ context.Context, _ *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) { + atomic.AddInt32(&callsB, 1) + return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok-b", UserId: "user-b"}, nil + } + + respA, _, err := cache.fetch(context.Background(), keyA, validateA) + require.NoError(t, err) + respB, _, err := cache.fetch(context.Background(), keyB, validateB) + require.NoError(t, err) + + assert.Equal(t, "user-a", respA.GetUserId(), "account A response should belong to user-a") + assert.Equal(t, "user-b", respB.GetUserId(), "account B response must not be served from account A's cache") + assert.Equal(t, int32(1), atomic.LoadInt32(&callsA), "validateA called exactly once") + assert.Equal(t, int32(1), atomic.LoadInt32(&callsB), "validateB called exactly once") +} + +func TestTunnelCache_BoundedSizeEvictsOldest(t *testing.T) { + cache := newTunnelValidationCache() + cache.maxSize = 2 + + validate := func(_ context.Context, req *proto.ValidateTunnelPeerRequest) (*proto.ValidateTunnelPeerResponse, error) { + return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok-" + req.GetTunnelIp()}, nil + } + + keys := []tunnelCacheKey{ + newTestKey("acct-1", "100.64.0.10", "svc"), + newTestKey("acct-1", "100.64.0.11", "svc"), + newTestKey("acct-1", "100.64.0.12", "svc"), + } + for _, k := range keys { + _, _, err := cache.fetch(context.Background(), k, validate) + require.NoError(t, err) + } + + assert.Nil(t, cache.get(keys[0]), "oldest key should be evicted past maxSize") + assert.NotNil(t, cache.get(keys[1]), "second-newest must remain cached") + assert.NotNil(t, cache.get(keys[2]), "newest must remain cached") +} diff --git a/proxy/internal/auth/tunnel_lookup_test.go b/proxy/internal/auth/tunnel_lookup_test.go new file mode 100644 index 000000000..808aa8b41 --- /dev/null +++ b/proxy/internal/auth/tunnel_lookup_test.go @@ -0,0 +1,355 @@ +package auth + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/netip" + "sync/atomic" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// stubSessionValidator records ValidateTunnelPeer calls and returns the +// pre-canned response. Counts let tests assert RPC traffic. +type stubSessionValidator struct { + respFn func(req *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse + respErr error + tunnelCalls atomic.Int32 +} + +func (s *stubSessionValidator) ValidateSession(_ context.Context, _ *proto.ValidateSessionRequest, _ ...grpc.CallOption) (*proto.ValidateSessionResponse, error) { + return &proto.ValidateSessionResponse{Valid: false}, nil +} + +func (s *stubSessionValidator) ValidateTunnelPeer(_ context.Context, in *proto.ValidateTunnelPeerRequest, _ ...grpc.CallOption) (*proto.ValidateTunnelPeerResponse, error) { + s.tunnelCalls.Add(1) + if s.respErr != nil { + return nil, s.respErr + } + if s.respFn != nil { + return s.respFn(in), nil + } + return &proto.ValidateTunnelPeerResponse{Valid: false}, nil +} + +func newTunnelMiddleware(t *testing.T, validator SessionValidator) *Middleware { + t.Helper() + mw := NewMiddleware(log.New(), validator, nil) + require.NoError(t, mw.AddDomain("svc.example", nil, "", 0, "acct-1", "svc-1", nil, false)) + return mw +} + +func newTunnelRequest(remoteAddr string) (*httptest.ResponseRecorder, *http.Request) { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "https://svc.example/", nil) + r.Host = "svc.example" + r.RemoteAddr = remoteAddr + return w, r +} + +// TestForwardWithTunnelPeer_LocalLookupUnknownIPDeniesFast verifies the +// short-circuit: a tunnel IP not in the account's roster never reaches +// management's ValidateTunnelPeer. +func TestForwardWithTunnelPeer_LocalLookupUnknownIPDeniesFast(t *testing.T) { + validator := &stubSessionValidator{} + mw := newTunnelMiddleware(t, validator) + + lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) { + return PeerIdentity{}, false + }) + + w, r := newTunnelRequest("100.64.0.99:55555") + r = r.WithContext(WithTunnelLookup(r.Context(), lookup)) + + called := false + next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }) + + config, _ := mw.getDomainConfig("svc.example") + handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next) + + assert.False(t, handled, "unknown peer must fall through, not forward") + assert.False(t, called, "next handler must not run for unknown peer") + assert.Equal(t, int32(0), validator.tunnelCalls.Load(), "ValidateTunnelPeer must be skipped on local-lookup miss") +} + +// TestForwardWithTunnelPeer_GroupsPropagateToCapturedData verifies the proxy +// surfaces the calling peer's group memberships from ValidateTunnelPeerResponse +// onto CapturedData so policy-aware middlewares can authorise without an +// extra management round-trip. +func TestForwardWithTunnelPeer_GroupsPropagateToCapturedData(t *testing.T) { + groups := []string{"engineering", "sre"} + validator := &stubSessionValidator{ + respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse { + return &proto.ValidateTunnelPeerResponse{ + Valid: true, + SessionToken: "tok", + UserId: "user-1", + PeerGroupIds: groups, + } + }, + } + mw := newTunnelMiddleware(t, validator) + + w, r := newTunnelRequest("100.64.0.10:55555") + cd := proxy.NewCapturedData("") + lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) { + return PeerIdentity{}, true + }) + r = r.WithContext(proxy.WithCapturedData(WithTunnelLookup(r.Context(), lookup), cd)) + + called := false + next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }) + + config, _ := mw.getDomainConfig("svc.example") + handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next) + + require.True(t, handled, "valid tunnel-peer response must forward") + require.True(t, called, "next handler must run") + assert.Equal(t, "user-1", cd.GetUserID(), "user id must propagate from tunnel-peer response") + assert.Equal(t, groups, cd.GetUserGroups(), "peer group IDs must propagate from tunnel-peer response") +} + +// TestForwardWithTunnelPeer_LocalLookupKnownPeerStillRPCs verifies that a +// known tunnel IP still triggers ValidateTunnelPeer for the user-identity +// tail (UserID + group access). Phase 3 only short-circuits the deny path. +func TestForwardWithTunnelPeer_LocalLookupKnownPeerStillRPCs(t *testing.T) { + validator := &stubSessionValidator{ + respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse { + return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok", UserId: "user-1"} + }, + } + mw := newTunnelMiddleware(t, validator) + + knownIP := netip.MustParseAddr("100.64.0.10") + lookup := TunnelLookupFunc(func(ip netip.Addr) (PeerIdentity, bool) { + if ip == knownIP { + return PeerIdentity{PubKey: "pk", TunnelIP: ip, FQDN: "peer.netbird.cloud"}, true + } + return PeerIdentity{}, false + }) + + w, r := newTunnelRequest(knownIP.String() + ":55555") + r = r.WithContext(WithTunnelLookup(r.Context(), lookup)) + + called := false + next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }) + + config, _ := mw.getDomainConfig("svc.example") + handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next) + + assert.True(t, handled, "known peer with valid RPC response must forward") + assert.True(t, called, "next handler must run on success") + assert.Equal(t, int32(1), validator.tunnelCalls.Load(), "RPC must run for the user-identity tail when local lookup confirms the peer") +} + +// TestForwardWithTunnelPeer_NoLookupRefusesFastPath guards the +// anti-spoof gate: requests that didn't arrive on the per-account +// inbound listener (no TunnelLookup attached) must never reach +// management's ValidateTunnelPeer, even when the source IP looks like +// a tunnel address. A colliding RFC1918 / CGNAT source on the public +// listener would otherwise impersonate a mesh peer. +func TestForwardWithTunnelPeer_NoLookupRefusesFastPath(t *testing.T) { + validator := &stubSessionValidator{ + respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse { + return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok", UserId: "user-1"} + }, + } + mw := newTunnelMiddleware(t, validator) + + w, r := newTunnelRequest("100.64.0.10:55555") + called := false + next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }) + + config, _ := mw.getDomainConfig("svc.example") + handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next) + + assert.False(t, handled, "fast-path must refuse without the inbound marker") + assert.False(t, called, "next handler must not run") + assert.Equal(t, int32(0), validator.tunnelCalls.Load(), "ValidateTunnelPeer must not be invoked without the inbound marker") +} + +// TestForwardWithTunnelPeer_RPCErrorFallsThrough validates that an RPC +// failure still falls through to the next scheme (no false positive). +func TestForwardWithTunnelPeer_RPCErrorFallsThrough(t *testing.T) { + validator := &stubSessionValidator{respErr: errors.New("management down")} + mw := newTunnelMiddleware(t, validator) + + knownIP := netip.MustParseAddr("100.64.0.10") + lookup := TunnelLookupFunc(func(ip netip.Addr) (PeerIdentity, bool) { + return PeerIdentity{TunnelIP: ip}, true + }) + + w, r := newTunnelRequest(knownIP.String() + ":55555") + r = r.WithContext(WithTunnelLookup(r.Context(), lookup)) + + config, _ := mw.getDomainConfig("svc.example") + handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + + assert.False(t, handled, "RPC error must let the caller try other schemes") + assert.Equal(t, int32(1), validator.tunnelCalls.Load(), "RPC was attempted exactly once") +} + +// TestForwardWithTunnelPeer_CacheReusesPositiveResponse confirms the +// (account, IP, domain) cache prevents repeated RPCs for the same peer. +func TestForwardWithTunnelPeer_CacheReusesPositiveResponse(t *testing.T) { + validator := &stubSessionValidator{ + respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse { + return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok", UserId: "user-1"} + }, + } + mw := newTunnelMiddleware(t, validator) + + lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) { + return PeerIdentity{}, true + }) + + for i := 0; i < 4; i++ { + w, r := newTunnelRequest("100.64.0.10:55555") + r = r.WithContext(WithTunnelLookup(r.Context(), lookup)) + next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + config, _ := mw.getDomainConfig("svc.example") + handled := mw.forwardWithTunnelPeer(w, r, "svc.example", config, next) + require.True(t, handled, "iteration %d should forward", i) + } + + assert.Equal(t, int32(1), validator.tunnelCalls.Load(), "subsequent forwards must hit the cache, not management") +} + +// TestForwardWithTunnelPeer_RoutesAccountIDIntoCacheKey ensures cache keys +// honour account scoping — same tunnel IP on different accounts must not +// collide. +func TestForwardWithTunnelPeer_RoutesAccountIDIntoCacheKey(t *testing.T) { + validator := &stubSessionValidator{ + respFn: func(req *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse { + return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok", UserId: "user"} + }, + } + mw := NewMiddleware(log.New(), validator, nil) + + require.NoError(t, mw.AddDomain("svc-a.example", nil, "", 0, "acct-a", "svc-a", nil, false)) + require.NoError(t, mw.AddDomain("svc-b.example", nil, "", 0, "acct-b", "svc-b", nil, false)) + + // The fast-path requires the inbound-listener marker on the context. + // The peerstore lookup itself is account-agnostic at this level + // (one TunnelLookupFunc per account is attached by inbound.go); a + // trivial "always hit" lookup is enough to exercise the cache-key + // branch this test covers. + lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) { + return PeerIdentity{}, true + }) + + for _, host := range []string{"svc-a.example", "svc-b.example"} { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "https://"+host+"/", nil) + r.Host = host + r.RemoteAddr = "100.64.0.10:55555" + r = r.WithContext(WithTunnelLookup(r.Context(), lookup)) + config, _ := mw.getDomainConfig(host) + handled := mw.forwardWithTunnelPeer(w, r, host, config, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + require.True(t, handled, "host %s should forward", host) + } + + assert.Equal(t, int32(2), validator.tunnelCalls.Load(), "cache must not collide across accounts even when tunnel IPs match") +} + +// TestForwardWithTunnelPeer_LocalLookupShortCircuitDoesNotPopulateCache +// guarantees that the deny-fast path leaves the cache untouched, so a +// subsequent request from the same IP after the peerstore catches up +// goes through the normal RPC flow. +func TestForwardWithTunnelPeer_LocalLookupShortCircuitDoesNotPopulateCache(t *testing.T) { + validator := &stubSessionValidator{ + respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse { + return &proto.ValidateTunnelPeerResponse{Valid: true, SessionToken: "tok"} + }, + } + mw := newTunnelMiddleware(t, validator) + + knownIP := netip.MustParseAddr("100.64.0.10") + known := false + lookup := TunnelLookupFunc(func(ip netip.Addr) (PeerIdentity, bool) { + if known && ip == knownIP { + return PeerIdentity{TunnelIP: ip}, true + } + return PeerIdentity{}, false + }) + + doRequest := func() bool { + w, r := newTunnelRequest(knownIP.String() + ":55555") + r = r.WithContext(WithTunnelLookup(r.Context(), lookup)) + config, _ := mw.getDomainConfig("svc.example") + return mw.forwardWithTunnelPeer(w, r, "svc.example", config, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + } + + require.False(t, doRequest(), "first request must short-circuit") + require.Equal(t, int32(0), validator.tunnelCalls.Load(), "short-circuit must not populate the cache") + + known = true + require.True(t, doRequest(), "second request with peer in roster must forward via RPC") + assert.Equal(t, int32(1), validator.tunnelCalls.Load(), "RPC runs once after peerstore catches up") +} + +func TestPrivateService_FailsClosedOnTunnelPeerFailure(t *testing.T) { + mw := NewMiddleware(log.New(), nil, nil) + require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true)) + + called := false + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "https://private.svc/", nil) + req.Host = "private.svc" + req.RemoteAddr = "100.64.0.10:55555" + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + assert.False(t, called) +} + +func TestPrivateService_ForwardsOnTunnelPeerSuccess(t *testing.T) { + validator := &stubSessionValidator{ + respFn: func(_ *proto.ValidateTunnelPeerRequest) *proto.ValidateTunnelPeerResponse { + return &proto.ValidateTunnelPeerResponse{ + Valid: true, + SessionToken: "tok", + UserId: "user-1", + } + }, + } + mw := NewMiddleware(log.New(), validator, nil) + require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true)) + + called := false + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + + // Per-account inbound listener attaches WithTunnelLookup; without it + // forwardWithTunnelPeer refuses to take the fast-path. Mirror the + // real flow so this test exercises the post-gating success branch. + lookup := TunnelLookupFunc(func(_ netip.Addr) (PeerIdentity, bool) { + return PeerIdentity{}, true + }) + + req := httptest.NewRequest(http.MethodGet, "https://private.svc/", nil) + req.Host = "private.svc" + req.RemoteAddr = "100.64.0.10:55555" + req = req.WithContext(WithTunnelLookup(req.Context(), lookup)) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.True(t, called) +} diff --git a/proxy/internal/certwatch/watcher.go b/proxy/internal/certwatch/watcher.go new file mode 100644 index 000000000..6366a53c6 --- /dev/null +++ b/proxy/internal/certwatch/watcher.go @@ -0,0 +1,286 @@ +// Package certwatch watches TLS certificate files on disk and provides +// a hot-reloading GetCertificate callback for tls.Config. +package certwatch + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "path/filepath" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + log "github.com/sirupsen/logrus" +) + +const ( + defaultPollInterval = 30 * time.Second + debounceDelay = 500 * time.Millisecond +) + +// Watcher monitors TLS certificate files on disk and caches the loaded +// certificate in memory. It detects changes via fsnotify (with a polling +// fallback for filesystems like NFS that lack inotify support) and +// reloads the certificate pair automatically. +type Watcher struct { + certPath string + keyPath string + + mu sync.RWMutex + cert *tls.Certificate + leaf *x509.Certificate + + pollInterval time.Duration + logger *log.Logger +} + +// NewWatcher creates a Watcher that monitors the given cert and key files. +// It performs an initial load of the certificate and returns an error +// if the initial load fails. +func NewWatcher(certPath, keyPath string, logger *log.Logger) (*Watcher, error) { + if logger == nil { + logger = log.StandardLogger() + } + + w := &Watcher{ + certPath: certPath, + keyPath: keyPath, + pollInterval: defaultPollInterval, + logger: logger, + } + + if err := w.reload(); err != nil { + return nil, fmt.Errorf("initial certificate load: %w", err) + } + + return w, nil +} + +// GetCertificate returns the current in-memory certificate. +// It is safe for concurrent use and compatible with tls.Config.GetCertificate. +func (w *Watcher) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { + w.mu.RLock() + defer w.mu.RUnlock() + + return w.cert, nil +} + +// Leaf returns the parsed leaf certificate, or nil if not yet loaded. +func (w *Watcher) Leaf() *x509.Certificate { + w.mu.RLock() + defer w.mu.RUnlock() + return w.leaf +} + +// Watch starts watching for certificate file changes. It blocks until +// ctx is cancelled. It uses fsnotify for immediate detection and falls +// back to polling if fsnotify is unavailable (e.g. on NFS). +// Even with fsnotify active, a periodic poll runs as a safety net. +func (w *Watcher) Watch(ctx context.Context) { + // Watch the parent directory rather than individual files. Some volume + // mounts use an atomic symlink swap (..data -> timestamped dir), so + // watching the parent directory catches the link replacement. + certDir := filepath.Dir(w.certPath) + keyDir := filepath.Dir(w.keyPath) + + watcher, err := fsnotify.NewWatcher() + if err != nil { + w.logger.Warnf("fsnotify unavailable, using polling only: %v", err) + w.pollLoop(ctx) + return + } + defer func() { + if err := watcher.Close(); err != nil { + w.logger.Debugf("close fsnotify watcher: %v", err) + } + }() + + if err := watcher.Add(certDir); err != nil { + w.logger.Warnf("fsnotify watch on %s failed, using polling only: %v", certDir, err) + w.pollLoop(ctx) + return + } + + if keyDir != certDir { + if err := watcher.Add(keyDir); err != nil { + w.logger.Warnf("fsnotify watch on %s failed: %v", keyDir, err) + } + } + + w.logger.Infof("watching certificate files in %s", certDir) + w.fsnotifyLoop(ctx, watcher) +} + +func (w *Watcher) fsnotifyLoop(ctx context.Context, watcher *fsnotify.Watcher) { + certBase := filepath.Base(w.certPath) + keyBase := filepath.Base(w.keyPath) + + var debounce *time.Timer + defer func() { + if debounce != nil { + debounce.Stop() + } + }() + + // Periodic poll as a safety net for missed fsnotify events. + pollTicker := time.NewTicker(w.pollInterval) + defer pollTicker.Stop() + + for { + select { + case <-ctx.Done(): + return + + case event, ok := <-watcher.Events: + if !ok { + return + } + + base := filepath.Base(event.Name) + if !isRelevantFile(base, certBase, keyBase) { + w.logger.Debugf("fsnotify: ignoring event %s on %s", event.Op, event.Name) + continue + } + if !event.Has(fsnotify.Create) && !event.Has(fsnotify.Write) && !event.Has(fsnotify.Rename) { + w.logger.Debugf("fsnotify: ignoring op %s on %s", event.Op, base) + continue + } + + w.logger.Debugf("fsnotify: detected %s on %s, scheduling reload", event.Op, base) + + // Debounce: cert-manager may write cert and key as separate + // operations. Wait briefly to load both at once. + if debounce != nil { + debounce.Stop() + } + debounce = time.AfterFunc(debounceDelay, func() { + if ctx.Err() != nil { + return + } + w.tryReload() + }) + + case err, ok := <-watcher.Errors: + if !ok { + return + } + w.logger.Warnf("fsnotify error: %v", err) + + case <-pollTicker.C: + w.tryReload() + } + } +} + +func (w *Watcher) pollLoop(ctx context.Context) { + ticker := time.NewTicker(w.pollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + w.tryReload() + } + } +} + +// reload loads the certificate from disk and updates the in-memory cache. +func (w *Watcher) reload() error { + cert, err := tls.LoadX509KeyPair(w.certPath, w.keyPath) + if err != nil { + return err + } + + // Parse the leaf for comparison on subsequent reloads. + if cert.Leaf == nil && len(cert.Certificate) > 0 { + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return fmt.Errorf("parse leaf certificate: %w", err) + } + cert.Leaf = leaf + } + + w.mu.Lock() + w.cert = &cert + w.leaf = cert.Leaf + w.mu.Unlock() + + w.logCertDetails("loaded certificate", cert.Leaf) + + return nil +} + +// tryReload attempts to reload the certificate. It skips the update +// if the certificate on disk is identical to the one in memory (same +// serial number and issuer) to avoid redundant log noise. +func (w *Watcher) tryReload() { + cert, err := tls.LoadX509KeyPair(w.certPath, w.keyPath) + if err != nil { + w.logger.Warnf("reload certificate: %v", err) + return + } + + if cert.Leaf == nil && len(cert.Certificate) > 0 { + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + w.logger.Warnf("parse reloaded leaf certificate: %v", err) + return + } + cert.Leaf = leaf + } + + w.mu.Lock() + + if w.leaf != nil && cert.Leaf != nil && + w.leaf.SerialNumber.Cmp(cert.Leaf.SerialNumber) == 0 && + w.leaf.Issuer.CommonName == cert.Leaf.Issuer.CommonName { + w.mu.Unlock() + return + } + + prev := w.leaf + w.cert = &cert + w.leaf = cert.Leaf + w.mu.Unlock() + + w.logCertChange(prev, cert.Leaf) +} + +func (w *Watcher) logCertDetails(msg string, leaf *x509.Certificate) { + if leaf == nil { + w.logger.Info(msg) + return + } + + w.logger.Infof("%s: subject=%q serial=%s SANs=%v notAfter=%s", + msg, + leaf.Subject.CommonName, + leaf.SerialNumber.Text(16), + leaf.DNSNames, + leaf.NotAfter.UTC().Format(time.RFC3339), + ) +} + +func (w *Watcher) logCertChange(prev, next *x509.Certificate) { + if prev == nil || next == nil { + w.logCertDetails("certificate reloaded from disk", next) + return + } + + w.logger.Infof("certificate reloaded from disk: subject=%q -> %q serial=%s -> %s notAfter=%s -> %s", + prev.Subject.CommonName, next.Subject.CommonName, + prev.SerialNumber.Text(16), next.SerialNumber.Text(16), + prev.NotAfter.UTC().Format(time.RFC3339), next.NotAfter.UTC().Format(time.RFC3339), + ) +} + +// isRelevantFile returns true if the changed file name is one we care about. +// This includes the cert/key files themselves and the ..data symlink used +// by atomic volume mounts. +func isRelevantFile(changed, certBase, keyBase string) bool { + return changed == certBase || changed == keyBase || changed == "..data" +} diff --git a/proxy/internal/certwatch/watcher_test.go b/proxy/internal/certwatch/watcher_test.go new file mode 100644 index 000000000..06b0a4bb8 --- /dev/null +++ b/proxy/internal/certwatch/watcher_test.go @@ -0,0 +1,292 @@ +package certwatch + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func generateSelfSignedCert(t *testing.T, serial int64) (certPEM, keyPEM []byte) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(serial), + Subject: pkix.Name{CommonName: "test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + keyDER, err := x509.MarshalECPrivateKey(key) + require.NoError(t, err) + keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + + return certPEM, keyPEM +} + +func writeCert(t *testing.T, dir string, certPEM, keyPEM []byte) { + t.Helper() + + require.NoError(t, os.WriteFile(filepath.Join(dir, "tls.crt"), certPEM, 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "tls.key"), keyPEM, 0o600)) +} + +func TestNewWatcher(t *testing.T) { + dir := t.TempDir() + certPEM, keyPEM := generateSelfSignedCert(t, 1) + writeCert(t, dir, certPEM, keyPEM) + + w, err := NewWatcher( + filepath.Join(dir, "tls.crt"), + filepath.Join(dir, "tls.key"), + nil, + ) + require.NoError(t, err) + + cert, err := w.GetCertificate(nil) + require.NoError(t, err) + require.NotNil(t, cert) + assert.Equal(t, int64(1), cert.Leaf.SerialNumber.Int64()) +} + +func TestNewWatcherMissingFiles(t *testing.T) { + dir := t.TempDir() + + _, err := NewWatcher( + filepath.Join(dir, "tls.crt"), + filepath.Join(dir, "tls.key"), + nil, + ) + assert.Error(t, err) +} + +func TestReload(t *testing.T) { + dir := t.TempDir() + certPEM1, keyPEM1 := generateSelfSignedCert(t, 100) + writeCert(t, dir, certPEM1, keyPEM1) + + w, err := NewWatcher( + filepath.Join(dir, "tls.crt"), + filepath.Join(dir, "tls.key"), + nil, + ) + require.NoError(t, err) + + cert1, err := w.GetCertificate(nil) + require.NoError(t, err) + assert.Equal(t, int64(100), cert1.Leaf.SerialNumber.Int64()) + + // Write a new cert with a different serial. + certPEM2, keyPEM2 := generateSelfSignedCert(t, 200) + writeCert(t, dir, certPEM2, keyPEM2) + + // Manually trigger reload. + w.tryReload() + + cert2, err := w.GetCertificate(nil) + require.NoError(t, err) + assert.Equal(t, int64(200), cert2.Leaf.SerialNumber.Int64()) +} + +func TestTryReloadSkipsUnchanged(t *testing.T) { + dir := t.TempDir() + certPEM, keyPEM := generateSelfSignedCert(t, 42) + writeCert(t, dir, certPEM, keyPEM) + + w, err := NewWatcher( + filepath.Join(dir, "tls.crt"), + filepath.Join(dir, "tls.key"), + nil, + ) + require.NoError(t, err) + + cert1, err := w.GetCertificate(nil) + require.NoError(t, err) + + // Reload with same cert - pointer should remain the same. + w.tryReload() + + cert2, err := w.GetCertificate(nil) + require.NoError(t, err) + assert.Same(t, cert1, cert2, "cert pointer should not change when content is the same") +} + +func TestWatchDetectsChanges(t *testing.T) { + dir := t.TempDir() + certPEM1, keyPEM1 := generateSelfSignedCert(t, 1) + writeCert(t, dir, certPEM1, keyPEM1) + + w, err := NewWatcher( + filepath.Join(dir, "tls.crt"), + filepath.Join(dir, "tls.key"), + nil, + ) + require.NoError(t, err) + + // Use a short poll interval for the test. + w.pollInterval = 100 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go w.Watch(ctx) + + // Write new cert. + certPEM2, keyPEM2 := generateSelfSignedCert(t, 999) + writeCert(t, dir, certPEM2, keyPEM2) + + // Wait for the watcher to pick it up. + require.Eventually(t, func() bool { + cert, err := w.GetCertificate(nil) + if err != nil { + return false + } + return cert.Leaf.SerialNumber.Int64() == 999 + }, 5*time.Second, 50*time.Millisecond, "watcher should detect cert change") +} + +func TestIsRelevantFile(t *testing.T) { + assert.True(t, isRelevantFile("tls.crt", "tls.crt", "tls.key")) + assert.True(t, isRelevantFile("tls.key", "tls.crt", "tls.key")) + assert.True(t, isRelevantFile("..data", "tls.crt", "tls.key")) + assert.False(t, isRelevantFile("other.txt", "tls.crt", "tls.key")) +} + +// TestWatchSymlinkRotation simulates Kubernetes secret volume updates where +// the data directory is atomically swapped via a ..data symlink. +func TestWatchSymlinkRotation(t *testing.T) { + base := t.TempDir() + + // Create initial target directory with certs. + dir1 := filepath.Join(base, "dir1") + require.NoError(t, os.Mkdir(dir1, 0o755)) + certPEM1, keyPEM1 := generateSelfSignedCert(t, 1) + require.NoError(t, os.WriteFile(filepath.Join(dir1, "tls.crt"), certPEM1, 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir1, "tls.key"), keyPEM1, 0o600)) + + // Create ..data symlink pointing to dir1. + dataLink := filepath.Join(base, "..data") + require.NoError(t, os.Symlink(dir1, dataLink)) + + // Create tls.crt and tls.key as symlinks to ..data/{file}. + certLink := filepath.Join(base, "tls.crt") + keyLink := filepath.Join(base, "tls.key") + require.NoError(t, os.Symlink(filepath.Join(dataLink, "tls.crt"), certLink)) + require.NoError(t, os.Symlink(filepath.Join(dataLink, "tls.key"), keyLink)) + + w, err := NewWatcher(certLink, keyLink, nil) + require.NoError(t, err) + + cert, err := w.GetCertificate(nil) + require.NoError(t, err) + assert.Equal(t, int64(1), cert.Leaf.SerialNumber.Int64()) + + w.pollInterval = 100 * time.Millisecond + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go w.Watch(ctx) + + // Simulate k8s atomic rotation: create dir2, swap ..data symlink. + dir2 := filepath.Join(base, "dir2") + require.NoError(t, os.Mkdir(dir2, 0o755)) + certPEM2, keyPEM2 := generateSelfSignedCert(t, 777) + require.NoError(t, os.WriteFile(filepath.Join(dir2, "tls.crt"), certPEM2, 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir2, "tls.key"), keyPEM2, 0o600)) + + // Atomic swap: create temp link, then rename over ..data. + tmpLink := filepath.Join(base, "..data_tmp") + require.NoError(t, os.Symlink(dir2, tmpLink)) + require.NoError(t, os.Rename(tmpLink, dataLink)) + + require.Eventually(t, func() bool { + cert, err := w.GetCertificate(nil) + if err != nil { + return false + } + return cert.Leaf.SerialNumber.Int64() == 777 + }, 5*time.Second, 50*time.Millisecond, "watcher should detect symlink rotation") +} + +// TestPollLoopDetectsChanges verifies the poll-only fallback path works. +func TestPollLoopDetectsChanges(t *testing.T) { + dir := t.TempDir() + certPEM1, keyPEM1 := generateSelfSignedCert(t, 1) + writeCert(t, dir, certPEM1, keyPEM1) + + w, err := NewWatcher( + filepath.Join(dir, "tls.crt"), + filepath.Join(dir, "tls.key"), + nil, + ) + require.NoError(t, err) + + w.pollInterval = 100 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Directly use pollLoop to test the fallback path. + go w.pollLoop(ctx) + + certPEM2, keyPEM2 := generateSelfSignedCert(t, 555) + writeCert(t, dir, certPEM2, keyPEM2) + + require.Eventually(t, func() bool { + cert, err := w.GetCertificate(nil) + if err != nil { + return false + } + return cert.Leaf.SerialNumber.Int64() == 555 + }, 5*time.Second, 50*time.Millisecond, "poll loop should detect cert change") +} + +func TestGetCertificateConcurrency(t *testing.T) { + dir := t.TempDir() + certPEM, keyPEM := generateSelfSignedCert(t, 1) + writeCert(t, dir, certPEM, keyPEM) + + w, err := NewWatcher( + filepath.Join(dir, "tls.crt"), + filepath.Join(dir, "tls.key"), + nil, + ) + require.NoError(t, err) + + // Hammer GetCertificate concurrently while reloading. + done := make(chan struct{}) + go func() { + for i := 0; i < 100; i++ { + w.tryReload() + } + close(done) + }() + + for i := 0; i < 1000; i++ { + cert, err := w.GetCertificate(&tls.ClientHelloInfo{}) + assert.NoError(t, err) + assert.NotNil(t, cert) + } + + <-done +} diff --git a/proxy/internal/conntrack/conn.go b/proxy/internal/conntrack/conn.go new file mode 100644 index 000000000..8446d638f --- /dev/null +++ b/proxy/internal/conntrack/conn.go @@ -0,0 +1,51 @@ +package conntrack + +import ( + "bufio" + "net" + "net/http" +) + +// trackedConn wraps a net.Conn and removes itself from the tracker on Close. +type trackedConn struct { + net.Conn + tracker *HijackTracker + host string +} + +func (c *trackedConn) Close() error { + c.tracker.remove(c) + return c.Conn.Close() +} + +// trackingWriter wraps an http.ResponseWriter and intercepts Hijack calls +// to replace the raw connection with a trackedConn that auto-deregisters. +type trackingWriter struct { + http.ResponseWriter + tracker *HijackTracker + host string +} + +func (w *trackingWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hijacker, ok := w.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, http.ErrNotSupported + } + conn, buf, err := hijacker.Hijack() + if err != nil { + return nil, nil, err + } + tc := &trackedConn{Conn: conn, tracker: w.tracker, host: w.host} + w.tracker.add(tc) + return tc, buf, nil +} + +func (w *trackingWriter) Flush() { + if flusher, ok := w.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } +} + +func (w *trackingWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} diff --git a/proxy/internal/conntrack/hijacked.go b/proxy/internal/conntrack/hijacked.go new file mode 100644 index 000000000..911f93f3d --- /dev/null +++ b/proxy/internal/conntrack/hijacked.go @@ -0,0 +1,96 @@ +package conntrack + +import ( + "net/http" + "sync" +) + +// HijackTracker tracks connections that have been hijacked (e.g. WebSocket +// upgrades). http.Server.Shutdown does not close hijacked connections, so +// they must be tracked and closed explicitly during graceful shutdown. +// +// Connections are indexed by the request Host so they can be closed +// per-domain when a service mapping is removed. +// +// Use Middleware as the outermost HTTP middleware to ensure hijacked +// connections are tracked and automatically deregistered when closed. +type HijackTracker struct { + mu sync.Mutex + conns map[*trackedConn]struct{} +} + +// Middleware returns an HTTP middleware that wraps the ResponseWriter so that +// hijacked connections are tracked and automatically deregistered from the +// tracker when closed. This should be the outermost middleware in the chain. +func (t *HijackTracker) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(&trackingWriter{ + ResponseWriter: w, + tracker: t, + host: hostOnly(r.Host), + }, r) + }) +} + +// CloseAll closes all tracked hijacked connections and returns the count. +func (t *HijackTracker) CloseAll() int { + t.mu.Lock() + conns := t.conns + t.conns = nil + t.mu.Unlock() + + for tc := range conns { + _ = tc.Conn.Close() + } + return len(conns) +} + +// CloseByHost closes all tracked hijacked connections for the given host +// and returns the number of connections closed. +func (t *HijackTracker) CloseByHost(host string) int { + host = hostOnly(host) + t.mu.Lock() + var toClose []*trackedConn + for tc := range t.conns { + if tc.host == host { + toClose = append(toClose, tc) + } + } + for _, tc := range toClose { + delete(t.conns, tc) + } + t.mu.Unlock() + + for _, tc := range toClose { + _ = tc.Conn.Close() + } + return len(toClose) +} + +func (t *HijackTracker) add(tc *trackedConn) { + t.mu.Lock() + if t.conns == nil { + t.conns = make(map[*trackedConn]struct{}) + } + t.conns[tc] = struct{}{} + t.mu.Unlock() +} + +func (t *HijackTracker) remove(tc *trackedConn) { + t.mu.Lock() + delete(t.conns, tc) + t.mu.Unlock() +} + +// hostOnly strips the port from a host:port string. +func hostOnly(hostport string) string { + for i := len(hostport) - 1; i >= 0; i-- { + if hostport[i] == ':' { + return hostport[:i] + } + if hostport[i] < '0' || hostport[i] > '9' { + return hostport + } + } + return hostport +} diff --git a/proxy/internal/conntrack/hijacked_test.go b/proxy/internal/conntrack/hijacked_test.go new file mode 100644 index 000000000..9ceefff78 --- /dev/null +++ b/proxy/internal/conntrack/hijacked_test.go @@ -0,0 +1,142 @@ +package conntrack + +import ( + "bufio" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeHijackWriter implements http.ResponseWriter and http.Hijacker for testing. +type fakeHijackWriter struct { + http.ResponseWriter + conn net.Conn +} + +func (f *fakeHijackWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + rw := bufio.NewReadWriter(bufio.NewReader(f.conn), bufio.NewWriter(f.conn)) + return f.conn, rw, nil +} + +func TestCloseByHost(t *testing.T) { + var tracker HijackTracker + + // Simulate hijacking two connections for different hosts. + connA1, connA2 := net.Pipe() + defer connA2.Close() + connB1, connB2 := net.Pipe() + defer connB2.Close() + + twA := &trackingWriter{ + ResponseWriter: httptest.NewRecorder(), + tracker: &tracker, + host: "a.example.com", + } + twB := &trackingWriter{ + ResponseWriter: httptest.NewRecorder(), + tracker: &tracker, + host: "b.example.com", + } + + // Use fakeHijackWriter to provide the Hijack method. + twA.ResponseWriter = &fakeHijackWriter{ResponseWriter: twA.ResponseWriter, conn: connA1} + twB.ResponseWriter = &fakeHijackWriter{ResponseWriter: twB.ResponseWriter, conn: connB1} + + _, _, err := twA.Hijack() + require.NoError(t, err) + _, _, err = twB.Hijack() + require.NoError(t, err) + + tracker.mu.Lock() + assert.Equal(t, 2, len(tracker.conns), "should track 2 connections") + tracker.mu.Unlock() + + // Close only host A. + n := tracker.CloseByHost("a.example.com") + assert.Equal(t, 1, n, "should close 1 connection for host A") + + tracker.mu.Lock() + assert.Equal(t, 1, len(tracker.conns), "should have 1 remaining connection") + tracker.mu.Unlock() + + // Verify host A's conn is actually closed. + buf := make([]byte, 1) + _, err = connA2.Read(buf) + assert.Error(t, err, "host A pipe should be closed") + + // Host B should still be alive. + go func() { _, _ = connB1.Write([]byte("x")) }() + + // Close all remaining. + n = tracker.CloseAll() + assert.Equal(t, 1, n, "should close remaining 1 connection") + + tracker.mu.Lock() + assert.Equal(t, 0, len(tracker.conns), "should have 0 connections after CloseAll") + tracker.mu.Unlock() +} + +func TestCloseAll(t *testing.T) { + var tracker HijackTracker + + for range 5 { + c1, c2 := net.Pipe() + defer c2.Close() + tc := &trackedConn{Conn: c1, tracker: &tracker, host: "test.com"} + tracker.add(tc) + } + + tracker.mu.Lock() + assert.Equal(t, 5, len(tracker.conns)) + tracker.mu.Unlock() + + n := tracker.CloseAll() + assert.Equal(t, 5, n) + + // Double CloseAll is safe. + n = tracker.CloseAll() + assert.Equal(t, 0, n) +} + +func TestTrackedConn_AutoDeregister(t *testing.T) { + var tracker HijackTracker + + c1, c2 := net.Pipe() + defer c2.Close() + + tc := &trackedConn{Conn: c1, tracker: &tracker, host: "auto.com"} + tracker.add(tc) + + tracker.mu.Lock() + assert.Equal(t, 1, len(tracker.conns)) + tracker.mu.Unlock() + + // Close the tracked conn: should auto-deregister. + require.NoError(t, tc.Close()) + + tracker.mu.Lock() + assert.Equal(t, 0, len(tracker.conns), "should auto-deregister on close") + tracker.mu.Unlock() +} + +func TestHostOnly(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"example.com:443", "example.com"}, + {"example.com", "example.com"}, + {"127.0.0.1:8080", "127.0.0.1"}, + {"[::1]:443", "[::1]"}, + {"", ""}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.want, hostOnly(tt.input)) + }) + } +} diff --git a/proxy/internal/crowdsec/bouncer.go b/proxy/internal/crowdsec/bouncer.go new file mode 100644 index 000000000..06a452520 --- /dev/null +++ b/proxy/internal/crowdsec/bouncer.go @@ -0,0 +1,251 @@ +// Package crowdsec provides a CrowdSec stream bouncer that maintains a local +// decision cache for IP reputation checks. +package crowdsec + +import ( + "context" + "errors" + "net/netip" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/crowdsecurity/crowdsec/pkg/models" + csbouncer "github.com/crowdsecurity/go-cs-bouncer" + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/internal/restrict" +) + +// Bouncer wraps a CrowdSec StreamBouncer, maintaining a local cache of +// active decisions for fast IP lookups. It implements restrict.CrowdSecChecker. +type Bouncer struct { + mu sync.RWMutex + ips map[netip.Addr]*restrict.CrowdSecDecision + prefixes map[netip.Prefix]*restrict.CrowdSecDecision + ready atomic.Bool + + apiURL string + apiKey string + tickerInterval time.Duration + logger *log.Entry + + // lifeMu protects cancel and done from concurrent Start/Stop calls. + lifeMu sync.Mutex + cancel context.CancelFunc + done chan struct{} +} + +// compile-time check +var _ restrict.CrowdSecChecker = (*Bouncer)(nil) + +// NewBouncer creates a bouncer but does not start the stream. +func NewBouncer(apiURL, apiKey string, logger *log.Entry) *Bouncer { + return &Bouncer{ + apiURL: apiURL, + apiKey: apiKey, + logger: logger, + ips: make(map[netip.Addr]*restrict.CrowdSecDecision), + prefixes: make(map[netip.Prefix]*restrict.CrowdSecDecision), + } +} + +// Start launches the background goroutine that streams decisions from the +// CrowdSec LAPI. The stream runs until Stop is called or ctx is cancelled. +func (b *Bouncer) Start(ctx context.Context) error { + interval := b.tickerInterval + if interval == 0 { + interval = 10 * time.Second + } + stream := &csbouncer.StreamBouncer{ + APIKey: b.apiKey, + APIUrl: b.apiURL, + TickerInterval: interval.String(), + UserAgent: "netbird-proxy/1.0", + Scopes: []string{"ip", "range"}, + RetryInitialConnect: true, + } + + b.logger.Infof("connecting to CrowdSec LAPI at %s", b.apiURL) + + if err := stream.Init(); err != nil { + return err + } + + // Reset state from any previous run. + b.mu.Lock() + b.ips = make(map[netip.Addr]*restrict.CrowdSecDecision) + b.prefixes = make(map[netip.Prefix]*restrict.CrowdSecDecision) + b.mu.Unlock() + b.ready.Store(false) + + ctx, cancel := context.WithCancel(ctx) + done := make(chan struct{}) + + b.lifeMu.Lock() + if b.cancel != nil { + b.lifeMu.Unlock() + cancel() + return errors.New("bouncer already started") + } + b.cancel = cancel + b.done = done + b.lifeMu.Unlock() + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + if err := stream.Run(ctx); err != nil && ctx.Err() == nil { + b.logger.Errorf("CrowdSec stream ended: %v", err) + } + }() + + go func() { + defer wg.Done() + b.consumeStream(ctx, stream) + }() + + go func() { + wg.Wait() + close(done) + }() + + return nil +} + +// Stop cancels the stream and waits for all goroutines to finish. +func (b *Bouncer) Stop() { + b.lifeMu.Lock() + cancel := b.cancel + done := b.done + b.cancel = nil + b.lifeMu.Unlock() + + if cancel != nil { + cancel() + <-done + } +} + +// Ready returns true after the first batch of decisions has been processed. +func (b *Bouncer) Ready() bool { + return b.ready.Load() +} + +// CheckIP looks up addr in the local decision cache. Returns nil if no +// active decision exists for the address. +// +// Prefix lookups are O(1): instead of scanning all stored prefixes, we +// probe the map for every possible containing prefix of the address +// (at most 33 for IPv4, 129 for IPv6). +func (b *Bouncer) CheckIP(addr netip.Addr) *restrict.CrowdSecDecision { + addr = addr.Unmap() + + b.mu.RLock() + defer b.mu.RUnlock() + + if d, ok := b.ips[addr]; ok { + return d + } + + maxBits := 32 + if addr.Is6() { + maxBits = 128 + } + // Walk from most-specific to least-specific prefix so the narrowest + // matching decision wins when ranges overlap. + for bits := maxBits; bits >= 0; bits-- { + prefix := netip.PrefixFrom(addr, bits).Masked() + if d, ok := b.prefixes[prefix]; ok { + return d + } + } + + return nil +} + +func (b *Bouncer) consumeStream(ctx context.Context, stream *csbouncer.StreamBouncer) { + first := true + for { + select { + case <-ctx.Done(): + return + case resp, ok := <-stream.Stream: + if !ok { + return + } + b.mu.Lock() + b.applyDeleted(resp.Deleted) + b.applyNew(resp.New) + b.mu.Unlock() + + if first { + b.ready.Store(true) + b.logger.Info("CrowdSec bouncer synced initial decisions") + first = false + } + } + } +} + +func (b *Bouncer) applyDeleted(decisions []*models.Decision) { + for _, d := range decisions { + if d.Value == nil || d.Scope == nil { + continue + } + value := *d.Value + + if strings.ToLower(*d.Scope) == "range" || strings.Contains(value, "/") { + prefix, err := netip.ParsePrefix(value) + if err != nil { + b.logger.Debugf("skip unparsable CrowdSec range deletion %q: %v", value, err) + continue + } + prefix = normalizePrefix(prefix) + delete(b.prefixes, prefix) + } else { + addr, err := netip.ParseAddr(value) + if err != nil { + b.logger.Debugf("skip unparsable CrowdSec IP deletion %q: %v", value, err) + continue + } + delete(b.ips, addr.Unmap()) + } + } +} + +func (b *Bouncer) applyNew(decisions []*models.Decision) { + for _, d := range decisions { + if d.Value == nil || d.Type == nil || d.Scope == nil { + continue + } + dec := &restrict.CrowdSecDecision{Type: restrict.DecisionType(*d.Type)} + value := *d.Value + + if strings.ToLower(*d.Scope) == "range" || strings.Contains(value, "/") { + prefix, err := netip.ParsePrefix(value) + if err != nil { + b.logger.Debugf("skip unparsable CrowdSec range %q: %v", value, err) + continue + } + prefix = normalizePrefix(prefix) + b.prefixes[prefix] = dec + } else { + addr, err := netip.ParseAddr(value) + if err != nil { + b.logger.Debugf("skip unparsable CrowdSec IP %q: %v", value, err) + continue + } + b.ips[addr.Unmap()] = dec + } + } +} + +// normalizePrefix unmaps v4-mapped-v6 addresses and zeros host bits so +// the prefix is a valid map key that matches CheckIP's probe logic. +func normalizePrefix(p netip.Prefix) netip.Prefix { + return netip.PrefixFrom(p.Addr().Unmap(), p.Bits()).Masked() +} diff --git a/proxy/internal/crowdsec/bouncer_test.go b/proxy/internal/crowdsec/bouncer_test.go new file mode 100644 index 000000000..3bd8aa068 --- /dev/null +++ b/proxy/internal/crowdsec/bouncer_test.go @@ -0,0 +1,337 @@ +package crowdsec + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/netip" + "sync" + "testing" + "time" + + "github.com/crowdsecurity/crowdsec/pkg/models" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/restrict" +) + +func TestBouncer_CheckIP_Empty(t *testing.T) { + b := newTestBouncer() + b.ready.Store(true) + + assert.Nil(t, b.CheckIP(netip.MustParseAddr("1.2.3.4"))) +} + +func TestBouncer_CheckIP_ExactMatch(t *testing.T) { + b := newTestBouncer() + b.ready.Store(true) + b.ips[netip.MustParseAddr("10.0.0.1")] = &restrict.CrowdSecDecision{Type: restrict.DecisionBan} + + d := b.CheckIP(netip.MustParseAddr("10.0.0.1")) + require.NotNil(t, d) + assert.Equal(t, restrict.DecisionBan, d.Type) + + assert.Nil(t, b.CheckIP(netip.MustParseAddr("10.0.0.2"))) +} + +func TestBouncer_CheckIP_PrefixMatch(t *testing.T) { + b := newTestBouncer() + b.ready.Store(true) + b.prefixes[netip.MustParsePrefix("192.168.1.0/24")] = &restrict.CrowdSecDecision{Type: restrict.DecisionBan} + + d := b.CheckIP(netip.MustParseAddr("192.168.1.100")) + require.NotNil(t, d) + assert.Equal(t, restrict.DecisionBan, d.Type) + + assert.Nil(t, b.CheckIP(netip.MustParseAddr("192.168.2.1"))) +} + +func TestBouncer_CheckIP_UnmapsV4InV6(t *testing.T) { + b := newTestBouncer() + b.ready.Store(true) + b.ips[netip.MustParseAddr("10.0.0.1")] = &restrict.CrowdSecDecision{Type: restrict.DecisionBan} + + d := b.CheckIP(netip.MustParseAddr("::ffff:10.0.0.1")) + require.NotNil(t, d) + assert.Equal(t, restrict.DecisionBan, d.Type) +} + +func TestBouncer_Ready(t *testing.T) { + b := newTestBouncer() + assert.False(t, b.Ready()) + + b.ready.Store(true) + assert.True(t, b.Ready()) +} + +func TestBouncer_CheckIP_ExactBeforePrefix(t *testing.T) { + b := newTestBouncer() + b.ready.Store(true) + b.ips[netip.MustParseAddr("10.0.0.1")] = &restrict.CrowdSecDecision{Type: restrict.DecisionCaptcha} + b.prefixes[netip.MustParsePrefix("10.0.0.0/8")] = &restrict.CrowdSecDecision{Type: restrict.DecisionBan} + + d := b.CheckIP(netip.MustParseAddr("10.0.0.1")) + require.NotNil(t, d) + assert.Equal(t, restrict.DecisionCaptcha, d.Type) + + d2 := b.CheckIP(netip.MustParseAddr("10.0.0.2")) + require.NotNil(t, d2) + assert.Equal(t, restrict.DecisionBan, d2.Type) +} + +func TestBouncer_ApplyNew_IP(t *testing.T) { + b := newTestBouncer() + + b.applyNew(makeDecisions( + decision{scope: "ip", value: "1.2.3.4", dtype: "ban", scenario: "test/brute"}, + decision{scope: "ip", value: "5.6.7.8", dtype: "captcha", scenario: "test/crawl"}, + )) + + require.Len(t, b.ips, 2) + assert.Equal(t, restrict.DecisionBan, b.ips[netip.MustParseAddr("1.2.3.4")].Type) + assert.Equal(t, restrict.DecisionCaptcha, b.ips[netip.MustParseAddr("5.6.7.8")].Type) +} + +func TestBouncer_ApplyNew_Range(t *testing.T) { + b := newTestBouncer() + + b.applyNew(makeDecisions( + decision{scope: "range", value: "10.0.0.0/8", dtype: "ban"}, + )) + + require.Len(t, b.prefixes, 1) + assert.NotNil(t, b.prefixes[netip.MustParsePrefix("10.0.0.0/8")]) +} + +func TestBouncer_ApplyDeleted_IP(t *testing.T) { + b := newTestBouncer() + b.ips[netip.MustParseAddr("1.2.3.4")] = &restrict.CrowdSecDecision{Type: restrict.DecisionBan} + b.ips[netip.MustParseAddr("5.6.7.8")] = &restrict.CrowdSecDecision{Type: restrict.DecisionBan} + + b.applyDeleted(makeDecisions( + decision{scope: "ip", value: "1.2.3.4", dtype: "ban"}, + )) + + assert.Len(t, b.ips, 1) + assert.Nil(t, b.ips[netip.MustParseAddr("1.2.3.4")]) + assert.NotNil(t, b.ips[netip.MustParseAddr("5.6.7.8")]) +} + +func TestBouncer_ApplyDeleted_Range(t *testing.T) { + b := newTestBouncer() + b.prefixes[netip.MustParsePrefix("10.0.0.0/8")] = &restrict.CrowdSecDecision{Type: restrict.DecisionBan} + b.prefixes[netip.MustParsePrefix("192.168.0.0/16")] = &restrict.CrowdSecDecision{Type: restrict.DecisionBan} + + b.applyDeleted(makeDecisions( + decision{scope: "range", value: "10.0.0.0/8", dtype: "ban"}, + )) + + require.Len(t, b.prefixes, 1) + assert.NotNil(t, b.prefixes[netip.MustParsePrefix("192.168.0.0/16")]) +} + +func TestBouncer_ApplyNew_OverwritesExisting(t *testing.T) { + b := newTestBouncer() + b.ips[netip.MustParseAddr("1.2.3.4")] = &restrict.CrowdSecDecision{Type: restrict.DecisionBan} + + b.applyNew(makeDecisions( + decision{scope: "ip", value: "1.2.3.4", dtype: "captcha"}, + )) + + assert.Equal(t, restrict.DecisionCaptcha, b.ips[netip.MustParseAddr("1.2.3.4")].Type) +} + +func TestBouncer_ApplyNew_SkipsInvalid(t *testing.T) { + b := newTestBouncer() + + b.applyNew(makeDecisions( + decision{scope: "ip", value: "not-an-ip", dtype: "ban"}, + decision{scope: "range", value: "also-not-valid", dtype: "ban"}, + )) + + assert.Empty(t, b.ips) + assert.Empty(t, b.prefixes) +} + +// TestBouncer_StreamIntegration tests the full flow: fake LAPI → StreamBouncer → Bouncer cache → CheckIP. +func TestBouncer_StreamIntegration(t *testing.T) { + lapi := newFakeLAPI() + ts := httptest.NewServer(lapi) + defer ts.Close() + + // Seed the LAPI with initial decisions. + lapi.setDecisions( + decision{scope: "ip", value: "1.2.3.4", dtype: "ban", scenario: "crowdsecurity/ssh-bf"}, + decision{scope: "range", value: "10.0.0.0/8", dtype: "ban", scenario: "crowdsecurity/http-probing"}, + decision{scope: "ip", value: "5.5.5.5", dtype: "captcha", scenario: "crowdsecurity/http-crawl"}, + ) + + b := NewBouncer(ts.URL, "test-key", log.NewEntry(log.StandardLogger())) + b.tickerInterval = 200 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + require.NoError(t, b.Start(ctx)) + defer b.Stop() + + // Wait for initial sync. + require.Eventually(t, b.Ready, 5*time.Second, 50*time.Millisecond, "bouncer should become ready") + + // Verify decisions are cached. + d := b.CheckIP(netip.MustParseAddr("1.2.3.4")) + require.NotNil(t, d, "1.2.3.4 should be banned") + assert.Equal(t, restrict.DecisionBan, d.Type) + + d2 := b.CheckIP(netip.MustParseAddr("10.1.2.3")) + require.NotNil(t, d2, "10.1.2.3 should match range ban") + assert.Equal(t, restrict.DecisionBan, d2.Type) + + d3 := b.CheckIP(netip.MustParseAddr("5.5.5.5")) + require.NotNil(t, d3, "5.5.5.5 should have captcha") + assert.Equal(t, restrict.DecisionCaptcha, d3.Type) + + assert.Nil(t, b.CheckIP(netip.MustParseAddr("9.9.9.9")), "unknown IP should be nil") + + // Simulate a delta update: delete one IP, add a new one. + lapi.setDelta( + []decision{{scope: "ip", value: "1.2.3.4", dtype: "ban"}}, + []decision{{scope: "ip", value: "2.3.4.5", dtype: "throttle", scenario: "crowdsecurity/http-flood"}}, + ) + + // Wait for the delta to be picked up. + require.Eventually(t, func() bool { + return b.CheckIP(netip.MustParseAddr("2.3.4.5")) != nil + }, 5*time.Second, 50*time.Millisecond, "new decision should appear") + + assert.Nil(t, b.CheckIP(netip.MustParseAddr("1.2.3.4")), "deleted decision should be gone") + + d4 := b.CheckIP(netip.MustParseAddr("2.3.4.5")) + require.NotNil(t, d4) + assert.Equal(t, restrict.DecisionThrottle, d4.Type) + + // Range ban should still be active. + assert.NotNil(t, b.CheckIP(netip.MustParseAddr("10.99.99.99"))) +} + +// Helpers + +func newTestBouncer() *Bouncer { + return &Bouncer{ + ips: make(map[netip.Addr]*restrict.CrowdSecDecision), + prefixes: make(map[netip.Prefix]*restrict.CrowdSecDecision), + logger: log.NewEntry(log.StandardLogger()), + } +} + +type decision struct { + scope string + value string + dtype string + scenario string +} + +func makeDecisions(decs ...decision) []*models.Decision { + out := make([]*models.Decision, len(decs)) + for i, d := range decs { + out[i] = &models.Decision{ + Scope: strPtr(d.scope), + Value: strPtr(d.value), + Type: strPtr(d.dtype), + Scenario: strPtr(d.scenario), + Duration: strPtr("1h"), + Origin: strPtr("cscli"), + } + } + return out +} + +func strPtr(s string) *string { return &s } + +// fakeLAPI is a minimal fake CrowdSec LAPI that serves /v1/decisions/stream. +type fakeLAPI struct { + mu sync.Mutex + initial []decision + newDelta []decision + delDelta []decision + served bool // true after the initial snapshot has been served +} + +func newFakeLAPI() *fakeLAPI { + return &fakeLAPI{} +} + +func (f *fakeLAPI) setDecisions(decs ...decision) { + f.mu.Lock() + defer f.mu.Unlock() + f.initial = decs + f.served = false +} + +func (f *fakeLAPI) setDelta(deleted, added []decision) { + f.mu.Lock() + defer f.mu.Unlock() + f.delDelta = deleted + f.newDelta = added +} + +func (f *fakeLAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/decisions/stream" { + http.NotFound(w, r) + return + } + + f.mu.Lock() + defer f.mu.Unlock() + + resp := streamResponse{} + + if !f.served { + for _, d := range f.initial { + resp.New = append(resp.New, toLAPIDecision(d)) + } + f.served = true + } else { + for _, d := range f.delDelta { + resp.Deleted = append(resp.Deleted, toLAPIDecision(d)) + } + for _, d := range f.newDelta { + resp.New = append(resp.New, toLAPIDecision(d)) + } + // Clear delta after serving once. + f.delDelta = nil + f.newDelta = nil + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) //nolint:errcheck +} + +// streamResponse mirrors the CrowdSec LAPI /v1/decisions/stream JSON structure. +type streamResponse struct { + New []*lapiDecision `json:"new"` + Deleted []*lapiDecision `json:"deleted"` +} + +type lapiDecision struct { + Duration *string `json:"duration"` + Origin *string `json:"origin"` + Scenario *string `json:"scenario"` + Scope *string `json:"scope"` + Type *string `json:"type"` + Value *string `json:"value"` +} + +func toLAPIDecision(d decision) *lapiDecision { + return &lapiDecision{ + Duration: strPtr("1h"), + Origin: strPtr("cscli"), + Scenario: strPtr(d.scenario), + Scope: strPtr(d.scope), + Type: strPtr(d.dtype), + Value: strPtr(d.value), + } +} diff --git a/proxy/internal/crowdsec/registry.go b/proxy/internal/crowdsec/registry.go new file mode 100644 index 000000000..652fb6f9f --- /dev/null +++ b/proxy/internal/crowdsec/registry.go @@ -0,0 +1,103 @@ +package crowdsec + +import ( + "context" + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/internal/types" +) + +// Registry manages a single shared Bouncer instance with reference counting. +// The bouncer starts when the first service acquires it and stops when the +// last service releases it. +type Registry struct { + mu sync.Mutex + bouncer *Bouncer + refs map[types.ServiceID]struct{} + apiURL string + apiKey string + logger *log.Entry + cancel context.CancelFunc +} + +// NewRegistry creates a registry. The bouncer is not started until Acquire is called. +func NewRegistry(apiURL, apiKey string, logger *log.Entry) *Registry { + return &Registry{ + apiURL: apiURL, + apiKey: apiKey, + logger: logger, + refs: make(map[types.ServiceID]struct{}), + } +} + +// Available returns true when the LAPI URL and API key are configured. +func (r *Registry) Available() bool { + return r.apiURL != "" && r.apiKey != "" +} + +// Acquire registers svcID as a consumer and starts the bouncer if this is the +// first consumer. Returns the shared Bouncer (which implements the restrict +// package's CrowdSecChecker interface). Returns nil if not Available. +func (r *Registry) Acquire(svcID types.ServiceID) *Bouncer { + r.mu.Lock() + defer r.mu.Unlock() + + if !r.Available() { + return nil + } + + if _, exists := r.refs[svcID]; exists { + return r.bouncer + } + + if r.bouncer == nil { + r.startLocked() + } + + // startLocked may fail, leaving r.bouncer nil. + if r.bouncer == nil { + return nil + } + + r.refs[svcID] = struct{}{} + return r.bouncer +} + +// Release removes svcID as a consumer. Stops the bouncer when the last +// consumer releases. +func (r *Registry) Release(svcID types.ServiceID) { + r.mu.Lock() + defer r.mu.Unlock() + + delete(r.refs, svcID) + + if len(r.refs) == 0 && r.bouncer != nil { + r.stopLocked() + } +} + +func (r *Registry) startLocked() { + b := NewBouncer(r.apiURL, r.apiKey, r.logger) + + ctx, cancel := context.WithCancel(context.Background()) + r.cancel = cancel + + if err := b.Start(ctx); err != nil { + r.logger.Errorf("failed to start CrowdSec bouncer: %v", err) + cancel() + return + } + + r.bouncer = b + r.logger.Info("CrowdSec bouncer started") +} + +func (r *Registry) stopLocked() { + r.bouncer.Stop() + r.cancel() + r.bouncer = nil + r.cancel = nil + r.logger.Info("CrowdSec bouncer stopped") +} diff --git a/proxy/internal/crowdsec/registry_test.go b/proxy/internal/crowdsec/registry_test.go new file mode 100644 index 000000000..f1567b186 --- /dev/null +++ b/proxy/internal/crowdsec/registry_test.go @@ -0,0 +1,66 @@ +package crowdsec + +import ( + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/proxy/internal/types" +) + +func TestRegistry_Available(t *testing.T) { + r := NewRegistry("http://localhost:8080/", "test-key", log.NewEntry(log.StandardLogger())) + assert.True(t, r.Available()) + + r2 := NewRegistry("", "", log.NewEntry(log.StandardLogger())) + assert.False(t, r2.Available()) + + r3 := NewRegistry("http://localhost:8080/", "", log.NewEntry(log.StandardLogger())) + assert.False(t, r3.Available()) +} + +func TestRegistry_Acquire_NotAvailable(t *testing.T) { + r := NewRegistry("", "", log.NewEntry(log.StandardLogger())) + b := r.Acquire("svc-1") + assert.Nil(t, b) +} + +func TestRegistry_Acquire_Idempotent(t *testing.T) { + r := newTestRegistry() + + b1 := r.Acquire("svc-1") + // Can't start without a real LAPI, but we can verify the ref tracking. + // The bouncer will be nil because Start fails, but the ref is tracked. + _ = b1 + + assert.Len(t, r.refs, 1) + + // Second acquire of same service should not add another ref. + r.Acquire("svc-1") + assert.Len(t, r.refs, 1) +} + +func TestRegistry_Release_Removes(t *testing.T) { + r := newTestRegistry() + r.refs[types.ServiceID("svc-1")] = struct{}{} + + r.Release("svc-1") + assert.Empty(t, r.refs) +} + +func TestRegistry_Release_Noop(t *testing.T) { + r := newTestRegistry() + // Releasing a service that was never acquired should not panic. + r.Release("nonexistent") + assert.Empty(t, r.refs) +} + +func newTestRegistry() *Registry { + return &Registry{ + apiURL: "http://localhost:8080/", + apiKey: "test-key", + logger: log.NewEntry(log.StandardLogger()), + refs: make(map[types.ServiceID]struct{}), + } +} diff --git a/proxy/internal/debug/client.go b/proxy/internal/debug/client.go new file mode 100644 index 000000000..77772637c --- /dev/null +++ b/proxy/internal/debug/client.go @@ -0,0 +1,576 @@ +// Package debug provides HTTP debug endpoints and CLI client for the proxy server. +package debug + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +// StatusFilters contains filter options for status queries. +type StatusFilters struct { + IPs []string + Names []string + Status string + ConnectionType string +} + +// Client provides CLI access to debug endpoints. +type Client struct { + baseURL string + jsonOutput bool + httpClient *http.Client + out io.Writer +} + +// NewClient creates a new debug client. +func NewClient(baseURL string, jsonOutput bool, out io.Writer) *Client { + if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { + baseURL = "http://" + baseURL + } + baseURL = strings.TrimSuffix(baseURL, "/") + + return &Client{ + baseURL: baseURL, + jsonOutput: jsonOutput, + out: out, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// Health fetches the health status. +func (c *Client) Health(ctx context.Context) error { + return c.fetchAndPrint(ctx, "/debug/health", c.printHealth) +} + +func (c *Client) printHealth(data map[string]any) { + _, _ = fmt.Fprintf(c.out, "Status: %v\n", data["status"]) + _, _ = fmt.Fprintf(c.out, "Uptime: %v\n", data["uptime"]) + _, _ = fmt.Fprintf(c.out, "Management Connected: %s\n", boolIcon(data["management_connected"])) + _, _ = fmt.Fprintf(c.out, "All Clients Healthy: %s\n", boolIcon(data["all_clients_healthy"])) + + total, _ := data["certs_total"].(float64) + ready, _ := data["certs_ready"].(float64) + pending, _ := data["certs_pending"].(float64) + failed, _ := data["certs_failed"].(float64) + if total > 0 { + _, _ = fmt.Fprintf(c.out, "Certificates: %d ready, %d pending, %d failed (%d total)\n", + int(ready), int(pending), int(failed), int(total)) + } + if domains, ok := data["certs_ready_domains"].([]any); ok && len(domains) > 0 { + _, _ = fmt.Fprintf(c.out, " Ready:\n") + for _, d := range domains { + _, _ = fmt.Fprintf(c.out, " %v\n", d) + } + } + if domains, ok := data["certs_pending_domains"].([]any); ok && len(domains) > 0 { + _, _ = fmt.Fprintf(c.out, " Pending:\n") + for _, d := range domains { + _, _ = fmt.Fprintf(c.out, " %v\n", d) + } + } + if domains, ok := data["certs_failed_domains"].(map[string]any); ok && len(domains) > 0 { + _, _ = fmt.Fprintf(c.out, " Failed:\n") + for d, errMsg := range domains { + _, _ = fmt.Fprintf(c.out, " %s: %v\n", d, errMsg) + } + } + + c.printHealthClients(data) +} + +func (c *Client) printHealthClients(data map[string]any) { + clients, ok := data["clients"].(map[string]any) + if !ok || len(clients) == 0 { + return + } + + _, _ = fmt.Fprintf(c.out, "\n%-38s %-9s %-7s %-8s %-8s %-16s %s\n", + "ACCOUNT ID", "HEALTHY", "MGMT", "SIGNAL", "RELAYS", "PEERS (P2P/RLY)", "DEGRADED") + _, _ = fmt.Fprintln(c.out, strings.Repeat("-", 110)) + + for accountID, v := range clients { + ch, ok := v.(map[string]any) + if !ok { + continue + } + + healthy := boolIcon(ch["healthy"]) + mgmt := boolIcon(ch["management_connected"]) + signal := boolIcon(ch["signal_connected"]) + + relaysConn, _ := ch["relays_connected"].(float64) + relaysTotal, _ := ch["relays_total"].(float64) + relays := fmt.Sprintf("%d/%d", int(relaysConn), int(relaysTotal)) + + peersConnected, _ := ch["peers_connected"].(float64) + peersTotal, _ := ch["peers_total"].(float64) + peersP2P, _ := ch["peers_p2p"].(float64) + peersRelayed, _ := ch["peers_relayed"].(float64) + peersDegraded, _ := ch["peers_degraded"].(float64) + peers := fmt.Sprintf("%d/%d (%d/%d)", int(peersConnected), int(peersTotal), int(peersP2P), int(peersRelayed)) + degraded := fmt.Sprintf("%d", int(peersDegraded)) + + _, _ = fmt.Fprintf(c.out, "%-38s %-9s %-7s %-8s %-8s %-16s %s", accountID, healthy, mgmt, signal, relays, peers, degraded) + if errMsg, ok := ch["error"].(string); ok && errMsg != "" { + _, _ = fmt.Fprintf(c.out, " (%s)", errMsg) + } + _, _ = fmt.Fprintln(c.out) + } +} + +func boolIcon(v any) string { + b, ok := v.(bool) + if !ok { + return "?" + } + if b { + return "yes" + } + return "no" +} + +// ListClients fetches the list of all clients. +func (c *Client) ListClients(ctx context.Context) error { + return c.fetchAndPrint(ctx, "/debug/clients", c.printClients) +} + +func (c *Client) printClients(data map[string]any) { + _, _ = fmt.Fprintf(c.out, "Uptime: %v\n", data["uptime"]) + _, _ = fmt.Fprintf(c.out, "Clients: %v\n\n", data["client_count"]) + + clients, ok := data["clients"].([]any) + if !ok || len(clients) == 0 { + _, _ = fmt.Fprintln(c.out, "No clients connected.") + return + } + + _, _ = fmt.Fprintf(c.out, "%-38s %-12s %-40s %s\n", "ACCOUNT ID", "AGE", "SERVICES", "HAS CLIENT") + _, _ = fmt.Fprintln(c.out, strings.Repeat("-", 110)) + + for _, item := range clients { + c.printClientRow(item) + } + + c.printInboundListeners(clients) +} + +func (c *Client) printInboundListeners(clients []any) { + type row struct { + accountID string + tunnelIP string + httpsPort int + httpPort int + } + var rows []row + for _, item := range clients { + client, ok := item.(map[string]any) + if !ok { + continue + } + inbound, ok := client["inbound_listener"].(map[string]any) + if !ok { + continue + } + tunnelIP, _ := inbound["tunnel_ip"].(string) + httpsPort, _ := inbound["https_port"].(float64) + httpPort, _ := inbound["http_port"].(float64) + accountID, _ := client["account_id"].(string) + rows = append(rows, row{ + accountID: accountID, + tunnelIP: tunnelIP, + httpsPort: int(httpsPort), + httpPort: int(httpPort), + }) + } + if len(rows) == 0 { + return + } + + _, _ = fmt.Fprintln(c.out) + _, _ = fmt.Fprintln(c.out, "Inbound listeners (per-account):") + _, _ = fmt.Fprintf(c.out, " %-38s %-20s %-7s %s\n", "ACCOUNT ID", "TUNNEL IP", "HTTPS", "HTTP") + _, _ = fmt.Fprintln(c.out, " "+strings.Repeat("-", 78)) + for _, r := range rows { + _, _ = fmt.Fprintf(c.out, " %-38s %-20s %-7d %d\n", r.accountID, r.tunnelIP, r.httpsPort, r.httpPort) + } +} + +func (c *Client) printClientRow(item any) { + client, ok := item.(map[string]any) + if !ok { + return + } + + services := c.extractServiceKeys(client) + hasClient := "no" + if hc, ok := client["has_client"].(bool); ok && hc { + hasClient = "yes" + } + + _, _ = fmt.Fprintf(c.out, "%-38s %-12v %s %s\n", + client["account_id"], + client["age"], + services, + hasClient, + ) +} + +func (c *Client) extractServiceKeys(client map[string]any) string { + d, ok := client["service_keys"].([]any) + if !ok || len(d) == 0 { + return "-" + } + + parts := make([]string, len(d)) + for i, key := range d { + parts[i] = fmt.Sprint(key) + } + return strings.Join(parts, ", ") +} + +// ClientStatus fetches the status of a specific client. +func (c *Client) ClientStatus(ctx context.Context, accountID string, filters StatusFilters) error { + params := url.Values{} + if len(filters.IPs) > 0 { + params.Set("filter-by-ips", strings.Join(filters.IPs, ",")) + } + if len(filters.Names) > 0 { + params.Set("filter-by-names", strings.Join(filters.Names, ",")) + } + if filters.Status != "" { + params.Set("filter-by-status", filters.Status) + } + if filters.ConnectionType != "" { + params.Set("filter-by-connection-type", filters.ConnectionType) + } + + path := "/debug/clients/" + url.PathEscape(accountID) + if len(params) > 0 { + path += "?" + params.Encode() + } + return c.fetchAndPrint(ctx, path, c.printClientStatus) +} + +func (c *Client) printClientStatus(data map[string]any) { + _, _ = fmt.Fprintf(c.out, "Account: %v\n", data["account_id"]) + if inbound, ok := data["inbound_listener"].(map[string]any); ok { + tunnelIP, _ := inbound["tunnel_ip"].(string) + httpsPort, _ := inbound["https_port"].(float64) + httpPort, _ := inbound["http_port"].(float64) + _, _ = fmt.Fprintf(c.out, "Inbound listener: %s (https=%d, http=%d)\n", tunnelIP, int(httpsPort), int(httpPort)) + } + _, _ = fmt.Fprintln(c.out) + if status, ok := data["status"].(string); ok { + _, _ = fmt.Fprint(c.out, status) + } +} + +// ClientSyncResponse fetches the sync response of a specific client. +func (c *Client) ClientSyncResponse(ctx context.Context, accountID string) error { + path := "/debug/clients/" + url.PathEscape(accountID) + "/syncresponse" + return c.fetchAndPrintJSON(ctx, path) +} + +// PingTCP performs a TCP ping through a client. +// ipVersion may be "4", "6", or "" for automatic. +func (c *Client) PingTCP(ctx context.Context, accountID, host string, port int, timeout time.Duration, ipVersion string) error { + params := url.Values{} + params.Set("host", host) + params.Set("port", fmt.Sprintf("%d", port)) + if timeout > 0 { + params.Set("timeout", timeout.String()) + } + if ipVersion != "" { + params.Set("ip_version", ipVersion) + } + + path := fmt.Sprintf("/debug/clients/%s/pingtcp?%s", url.PathEscape(accountID), params.Encode()) + return c.fetchAndPrint(ctx, path, c.printPingResult) +} + +func (c *Client) printPingResult(data map[string]any) { + success, _ := data["success"].(bool) + host := net.JoinHostPort(fmt.Sprint(data["host"]), fmt.Sprint(data["port"])) + if success { + remote, _ := data["remote"].(string) + if remote != "" && remote != host { + _, _ = fmt.Fprintf(c.out, "Success: %s (via %s)\n", host, remote) + } else { + _, _ = fmt.Fprintf(c.out, "Success: %s\n", host) + } + _, _ = fmt.Fprintf(c.out, "Latency: %v\n", data["latency"]) + } else { + _, _ = fmt.Fprintf(c.out, "Failed: %s\n", host) + c.printError(data) + } +} + +// SetLogLevel sets the log level of a specific client. +func (c *Client) SetLogLevel(ctx context.Context, accountID, level string) error { + params := url.Values{} + params.Set("level", level) + + path := fmt.Sprintf("/debug/clients/%s/loglevel?%s", url.PathEscape(accountID), params.Encode()) + return c.fetchAndPrint(ctx, path, c.printLogLevelResult) +} + +func (c *Client) printLogLevelResult(data map[string]any) { + success, _ := data["success"].(bool) + if success { + _, _ = fmt.Fprintf(c.out, "Log level set to: %v\n", data["level"]) + } else { + _, _ = fmt.Fprintln(c.out, "Failed to set log level") + c.printError(data) + } +} + +// PerfSet live-retunes the tunnel buffer pool cap on all running embedded +// clients. Batch size is not live-tunable; configure it at proxy startup. +func (c *Client) PerfSet(ctx context.Context, value uint32) error { + path := fmt.Sprintf("/debug/perf?value=%d", value) + return c.fetchAndPrint(ctx, path, c.printPerfSet) +} + +func (c *Client) printPerfSet(data map[string]any) { + if errMsg, ok := data["error"].(string); ok && errMsg != "" { + c.printError(data) + return + } + val, _ := data["value"].(float64) + applied, _ := data["applied"].(float64) + _, _ = fmt.Fprintf(c.out, "Pool cap set to: %d\n", uint32(val)) + _, _ = fmt.Fprintf(c.out, "Applied to %d live clients\n", int(applied)) + if failed, ok := data["failed"].(map[string]any); ok && len(failed) > 0 { + _, _ = fmt.Fprintln(c.out, "Failed:") + for k, v := range failed { + _, _ = fmt.Fprintf(c.out, " %s: %v\n", k, v) + } + } +} + +// Runtime fetches runtime stats (heap, goroutines, RSS). +func (c *Client) Runtime(ctx context.Context) error { + return c.fetchAndPrint(ctx, "/debug/runtime", c.printRuntime) +} + +func (c *Client) printRuntime(data map[string]any) { + i := func(k string) uint64 { + v, _ := data[k].(float64) + return uint64(v) + } + mb := func(n uint64) string { return fmt.Sprintf("%.1f MB", float64(n)/(1<<20)) } + + _, _ = fmt.Fprintf(c.out, "Uptime: %v\n", data["uptime"]) + _, _ = fmt.Fprintf(c.out, "Go: %v on %d CPU (GOMAXPROCS=%d)\n", data["go_version"], uint32(i("num_cpu")), uint32(i("gomaxprocs"))) + _, _ = fmt.Fprintf(c.out, "Goroutines: %d\n", i("goroutines")) + _, _ = fmt.Fprintf(c.out, "Live objects: %d\n", i("live_objects")) + _, _ = fmt.Fprintf(c.out, "GC: %d cycles, %v pause total\n", i("num_gc"), time.Duration(i("pause_total_ns"))) + _, _ = fmt.Fprintln(c.out, "Heap:") + _, _ = fmt.Fprintf(c.out, " alloc: %s\n", mb(i("heap_alloc"))) + _, _ = fmt.Fprintf(c.out, " in-use: %s\n", mb(i("heap_inuse"))) + _, _ = fmt.Fprintf(c.out, " idle: %s\n", mb(i("heap_idle"))) + _, _ = fmt.Fprintf(c.out, " released: %s\n", mb(i("heap_released"))) + _, _ = fmt.Fprintf(c.out, " sys: %s\n", mb(i("heap_sys"))) + _, _ = fmt.Fprintf(c.out, "Total sys: %s\n", mb(i("sys"))) + if _, ok := data["vm_rss"]; ok { + _, _ = fmt.Fprintln(c.out, "Process:") + _, _ = fmt.Fprintf(c.out, " VmRSS: %s\n", mb(i("vm_rss"))) + _, _ = fmt.Fprintf(c.out, " VmSize: %s\n", mb(i("vm_size"))) + _, _ = fmt.Fprintf(c.out, " VmData: %s\n", mb(i("vm_data"))) + } + _, _ = fmt.Fprintf(c.out, "Clients: %d (%d started)\n", i("clients"), i("started")) +} + +// StartClient starts a specific client. +func (c *Client) StartClient(ctx context.Context, accountID string) error { + path := "/debug/clients/" + url.PathEscape(accountID) + "/start" + return c.fetchAndPrint(ctx, path, c.printStartResult) +} + +func (c *Client) printStartResult(data map[string]any) { + success, _ := data["success"].(bool) + if success { + _, _ = fmt.Fprintln(c.out, "Client started") + } else { + _, _ = fmt.Fprintln(c.out, "Failed to start client") + c.printError(data) + } +} + +// StopClient stops a specific client. +func (c *Client) StopClient(ctx context.Context, accountID string) error { + path := "/debug/clients/" + url.PathEscape(accountID) + "/stop" + return c.fetchAndPrint(ctx, path, c.printStopResult) +} + +func (c *Client) printStopResult(data map[string]any) { + success, _ := data["success"].(bool) + if success { + _, _ = fmt.Fprintln(c.out, "Client stopped") + } else { + _, _ = fmt.Fprintln(c.out, "Failed to stop client") + c.printError(data) + } +} + +func (c *Client) printError(data map[string]any) { + if errMsg, ok := data["error"].(string); ok { + _, _ = fmt.Fprintf(c.out, "Error: %s\n", errMsg) + } +} + +// CaptureOptions configures a capture request. +type CaptureOptions struct { + AccountID string + Duration string + FilterExpr string + Text bool + Verbose bool + ASCII bool + Output io.Writer +} + +// Capture streams a packet capture from the debug endpoint. The response body +// (pcap or text) is written directly to opts.Output until the server closes the +// connection or the context is cancelled. +func (c *Client) Capture(ctx context.Context, opts CaptureOptions) error { + if opts.AccountID == "" { + return fmt.Errorf("account ID is required") + } + if opts.Output == nil { + return fmt.Errorf("output writer is required") + } + + params := url.Values{} + if opts.Duration != "" { + params.Set("duration", opts.Duration) + } + if opts.FilterExpr != "" { + params.Set("filter", opts.FilterExpr) + } + if opts.Text { + params.Set("format", "text") + } + if opts.Verbose { + params.Set("verbose", "true") + } + if opts.ASCII { + params.Set("ascii", "true") + } + + path := fmt.Sprintf("/debug/clients/%s/capture", url.PathEscape(opts.AccountID)) + if len(params) > 0 { + path += "?" + params.Encode() + } + + fullURL := c.baseURL + path + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + + // Use a separate client without timeout since captures stream for their full duration. + httpClient := &http.Client{} + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("server error (%d): %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + _, err = io.Copy(opts.Output, resp.Body) + if err != nil && ctx.Err() != nil { + return nil + } + return err +} + +func (c *Client) fetchAndPrint(ctx context.Context, path string, printer func(map[string]any)) error { + data, raw, err := c.fetch(ctx, path) + if err != nil { + return err + } + + if c.jsonOutput { + return c.writeJSON(data) + } + + if data != nil { + printer(data) + return nil + } + + _, _ = fmt.Fprintln(c.out, string(raw)) + return nil +} + +func (c *Client) fetchAndPrintJSON(ctx context.Context, path string) error { + data, raw, err := c.fetch(ctx, path) + if err != nil { + return err + } + + if data != nil { + return c.writeJSON(data) + } + + _, _ = fmt.Fprintln(c.out, string(raw)) + return nil +} + +func (c *Client) writeJSON(data map[string]any) error { + enc := json.NewEncoder(c.out) + enc.SetIndent("", " ") + return enc.Encode(data) +} + +func (c *Client) fetch(ctx context.Context, path string) (map[string]any, []byte, error) { + fullURL := c.baseURL + path + if !strings.Contains(path, "format=json") { + if strings.Contains(path, "?") { + fullURL += "&format=json" + } else { + fullURL += "?format=json" + } + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return nil, nil, fmt.Errorf("create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, nil, fmt.Errorf("server error (%d): %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var data map[string]any + if err := json.Unmarshal(body, &data); err != nil { + return nil, body, nil + } + + return data, body, nil +} diff --git a/proxy/internal/debug/client_test.go b/proxy/internal/debug/client_test.go new file mode 100644 index 000000000..0d627a94e --- /dev/null +++ b/proxy/internal/debug/client_test.go @@ -0,0 +1,71 @@ +package debug + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPrintHealth_WithCertsAndClients(t *testing.T) { + var buf bytes.Buffer + c := NewClient("localhost:8444", false, &buf) + + data := map[string]any{ + "status": "ok", + "uptime": "1h30m", + "management_connected": true, + "all_clients_healthy": true, + "certs_total": float64(3), + "certs_ready": float64(2), + "certs_pending": float64(1), + "certs_failed": float64(0), + "certs_ready_domains": []any{"a.example.com", "b.example.com"}, + "certs_pending_domains": []any{"c.example.com"}, + "clients": map[string]any{ + "acc-1": map[string]any{ + "healthy": true, + "management_connected": true, + "signal_connected": true, + "relays_connected": float64(1), + "relays_total": float64(2), + "peers_connected": float64(3), + "peers_total": float64(5), + "peers_p2p": float64(2), + "peers_relayed": float64(1), + "peers_degraded": float64(0), + }, + }, + } + + c.printHealth(data) + out := buf.String() + + assert.Contains(t, out, "Status: ok") + assert.Contains(t, out, "Uptime: 1h30m") + assert.Contains(t, out, "yes") // management_connected + assert.Contains(t, out, "2 ready, 1 pending, 0 failed (3 total)") + assert.Contains(t, out, "a.example.com") + assert.Contains(t, out, "c.example.com") + assert.Contains(t, out, "acc-1") +} + +func TestPrintHealth_Minimal(t *testing.T) { + var buf bytes.Buffer + c := NewClient("localhost:8444", false, &buf) + + data := map[string]any{ + "status": "ok", + "uptime": "5m", + "management_connected": false, + "all_clients_healthy": false, + } + + c.printHealth(data) + out := buf.String() + + assert.Contains(t, out, "Status: ok") + assert.Contains(t, out, "Uptime: 5m") + assert.NotContains(t, out, "Certificates") + assert.NotContains(t, out, "ACCOUNT ID") +} diff --git a/proxy/internal/debug/handler.go b/proxy/internal/debug/handler.go new file mode 100644 index 000000000..6300228d7 --- /dev/null +++ b/proxy/internal/debug/handler.go @@ -0,0 +1,960 @@ +// Package debug provides HTTP debug endpoints for the proxy server. +package debug + +import ( + "cmp" + "context" + "embed" + "encoding/json" + "fmt" + "html/template" + "maps" + "net" + "net/http" + "os" + "runtime" + "slices" + "strconv" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "google.golang.org/protobuf/encoding/protojson" + + nbembed "github.com/netbirdio/netbird/client/embed" + nbstatus "github.com/netbirdio/netbird/client/status" + "github.com/netbirdio/netbird/proxy/internal/health" + "github.com/netbirdio/netbird/proxy/internal/roundtrip" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/version" +) + +//go:embed templates/*.html +var templateFS embed.FS + +const defaultPingTimeout = 10 * time.Second + +// formatDuration formats a duration with 2 decimal places using appropriate units. +func formatDuration(d time.Duration) string { + switch { + case d >= time.Hour: + return fmt.Sprintf("%.2fh", d.Hours()) + case d >= time.Minute: + return fmt.Sprintf("%.2fm", d.Minutes()) + case d >= time.Second: + return fmt.Sprintf("%.2fs", d.Seconds()) + case d >= time.Millisecond: + return fmt.Sprintf("%.2fms", float64(d.Microseconds())/1000) + case d >= time.Microsecond: + return fmt.Sprintf("%.2fµs", float64(d.Nanoseconds())/1000) + default: + return fmt.Sprintf("%dns", d.Nanoseconds()) + } +} + +func sortedAccountIDs(m map[types.AccountID]roundtrip.ClientDebugInfo) []types.AccountID { + return slices.Sorted(maps.Keys(m)) +} + +// clientProvider provides access to NetBird clients. +type clientProvider interface { + GetClient(accountID types.AccountID) (*nbembed.Client, bool) + ListClientsForDebug() map[types.AccountID]roundtrip.ClientDebugInfo + ListClientsForStartup() map[types.AccountID]*nbembed.Client +} + +// InboundListenerInfo describes a per-account inbound listener as +// surfaced through the debug HTTP handler. Mirrors the proto sub-message +// emitted with SendStatusUpdate so dashboards and CLI tooling see the +// same shape. +type InboundListenerInfo struct { + TunnelIP string `json:"tunnel_ip"` + HTTPSPort uint16 `json:"https_port"` + HTTPPort uint16 `json:"http_port"` +} + +// InboundProvider exposes per-account inbound listener state. Optional; +// when nil the debug endpoint omits the inbound section entirely so the +// existing JSON shape stays additive. +type InboundProvider interface { + InboundListeners() map[types.AccountID]InboundListenerInfo +} + +// healthChecker provides health probe state. +type healthChecker interface { + ReadinessProbe() bool + StartupProbe(ctx context.Context) bool + CheckClientsConnected(ctx context.Context) (bool, map[types.AccountID]health.ClientHealth) +} + +type certStatus interface { + TotalDomains() int + PendingDomains() []string + ReadyDomains() []string + FailedDomains() map[string]string +} + +// Handler provides HTTP debug endpoints. +type Handler struct { + provider clientProvider + health healthChecker + certStatus certStatus + inbound InboundProvider + logger *log.Logger + startTime time.Time + templates *template.Template + templateMu sync.RWMutex +} + +// NewHandler creates a new debug handler. +func NewHandler(provider clientProvider, healthChecker healthChecker, logger *log.Logger) *Handler { + if logger == nil { + logger = log.StandardLogger() + } + h := &Handler{ + provider: provider, + health: healthChecker, + logger: logger, + startTime: time.Now(), + } + if err := h.loadTemplates(); err != nil { + logger.Errorf("failed to load embedded templates: %v", err) + } + return h +} + +// SetCertStatus sets the certificate status provider for ACME prefetch observability. +func (h *Handler) SetCertStatus(cs certStatus) { + h.certStatus = cs +} + +// SetInboundProvider wires per-account inbound listener observability. +// Pass nil (or skip the call) to keep the inbound section out of debug +// responses on proxies that don't run --private. +func (h *Handler) SetInboundProvider(p InboundProvider) { + h.inbound = p +} + +func (h *Handler) loadTemplates() error { + tmpl, err := template.ParseFS(templateFS, "templates/*.html") + if err != nil { + return fmt.Errorf("parse embedded templates: %w", err) + } + + h.templateMu.Lock() + h.templates = tmpl + h.templateMu.Unlock() + + return nil +} + +func (h *Handler) getTemplates() *template.Template { + h.templateMu.RLock() + defer h.templateMu.RUnlock() + return h.templates +} + +// ServeHTTP handles debug requests. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + wantJSON := r.URL.Query().Get("format") == "json" || strings.HasSuffix(path, "/json") + path = strings.TrimSuffix(path, "/json") + + switch path { + case "/debug", "/debug/": + h.handleIndex(w, r, wantJSON) + case "/debug/clients": + h.handleListClients(w, r, wantJSON) + case "/debug/health": + h.handleHealth(w, r, wantJSON) + case "/debug/perf": + h.handlePerf(w, r) + case "/debug/runtime": + h.handleRuntime(w, r) + default: + if h.handleClientRoutes(w, r, path, wantJSON) { + return + } + http.NotFound(w, r) + } +} + +func (h *Handler) handleClientRoutes(w http.ResponseWriter, r *http.Request, path string, wantJSON bool) bool { + if !strings.HasPrefix(path, "/debug/clients/") { + return false + } + + rest := strings.TrimPrefix(path, "/debug/clients/") + parts := strings.SplitN(rest, "/", 2) + accountID := types.AccountID(parts[0]) + + if len(parts) == 1 { + h.handleClientStatus(w, r, accountID, wantJSON) + return true + } + + switch parts[1] { + case "syncresponse": + h.handleClientSyncResponse(w, r, accountID, wantJSON) + case "tools": + h.handleClientTools(w, r, accountID) + case "pingtcp": + h.handlePingTCP(w, r, accountID) + case "loglevel": + h.handleLogLevel(w, r, accountID) + case "start": + h.handleClientStart(w, r, accountID) + case "stop": + h.handleClientStop(w, r, accountID) + case "capture": + h.handleCapture(w, r, accountID) + default: + return false + } + return true +} + +type failedDomain struct { + Domain string + Error string +} + +type indexData struct { + Version string + Uptime string + ClientCount int + TotalServices int + CertsTotal int + CertsReady int + CertsPending int + CertsFailed int + CertsPendingDomains []string + CertsReadyDomains []string + CertsFailedDomains []failedDomain + Clients []clientData +} + +type clientData struct { + AccountID string + Services string + Age string + Status string +} + +func (h *Handler) handleIndex(w http.ResponseWriter, _ *http.Request, wantJSON bool) { + clients := h.provider.ListClientsForDebug() + sortedIDs := sortedAccountIDs(clients) + + totalServices := 0 + for _, info := range clients { + totalServices += info.ServiceCount + } + + var certsTotal, certsReady, certsPending, certsFailed int + var certsPendingDomains, certsReadyDomains []string + var certsFailedDomains map[string]string + if h.certStatus != nil { + certsTotal = h.certStatus.TotalDomains() + certsPendingDomains = h.certStatus.PendingDomains() + certsReadyDomains = h.certStatus.ReadyDomains() + certsFailedDomains = h.certStatus.FailedDomains() + certsReady = len(certsReadyDomains) + certsPending = len(certsPendingDomains) + certsFailed = len(certsFailedDomains) + } + + if wantJSON { + clientsJSON := make([]map[string]any, 0, len(clients)) + for _, id := range sortedIDs { + info := clients[id] + clientsJSON = append(clientsJSON, map[string]any{ + "account_id": info.AccountID, + "service_count": info.ServiceCount, + "service_keys": info.ServiceKeys, + "has_client": info.HasClient, + "created_at": info.CreatedAt, + "age": time.Since(info.CreatedAt).Round(time.Second).String(), + }) + } + resp := map[string]any{ + "version": version.NetbirdVersion(), + "uptime": time.Since(h.startTime).Round(time.Second).String(), + "client_count": len(clients), + "total_services": totalServices, + "certs_total": certsTotal, + "certs_ready": certsReady, + "certs_pending": certsPending, + "certs_failed": certsFailed, + "clients": clientsJSON, + } + if len(certsPendingDomains) > 0 { + resp["certs_pending_domains"] = certsPendingDomains + } + if len(certsReadyDomains) > 0 { + resp["certs_ready_domains"] = certsReadyDomains + } + if len(certsFailedDomains) > 0 { + resp["certs_failed_domains"] = certsFailedDomains + } + h.writeJSON(w, resp) + return + } + + sortedFailed := make([]failedDomain, 0, len(certsFailedDomains)) + for d, e := range certsFailedDomains { + sortedFailed = append(sortedFailed, failedDomain{Domain: d, Error: e}) + } + slices.SortFunc(sortedFailed, func(a, b failedDomain) int { + return cmp.Compare(a.Domain, b.Domain) + }) + + data := indexData{ + Version: version.NetbirdVersion(), + Uptime: time.Since(h.startTime).Round(time.Second).String(), + ClientCount: len(clients), + TotalServices: totalServices, + CertsTotal: certsTotal, + CertsReady: certsReady, + CertsPending: certsPending, + CertsFailed: certsFailed, + CertsPendingDomains: certsPendingDomains, + CertsReadyDomains: certsReadyDomains, + CertsFailedDomains: sortedFailed, + Clients: make([]clientData, 0, len(clients)), + } + + for _, id := range sortedIDs { + info := clients[id] + services := strings.Join(info.ServiceKeys, ", ") + if services == "" { + services = "-" + } + status := "No client" + if info.HasClient { + status = "Active" + } + data.Clients = append(data.Clients, clientData{ + AccountID: string(info.AccountID), + Services: services, + Age: time.Since(info.CreatedAt).Round(time.Second).String(), + Status: status, + }) + } + + h.renderTemplate(w, "index", data) +} + +type clientsData struct { + Uptime string + Clients []clientData +} + +func (h *Handler) handleListClients(w http.ResponseWriter, _ *http.Request, wantJSON bool) { + clients := h.provider.ListClientsForDebug() + sortedIDs := sortedAccountIDs(clients) + + if wantJSON { + var inboundAll map[types.AccountID]InboundListenerInfo + if h.inbound != nil { + inboundAll = h.inbound.InboundListeners() + } + clientsJSON := make([]map[string]any, 0, len(clients)) + for _, id := range sortedIDs { + info := clients[id] + row := map[string]any{ + "account_id": info.AccountID, + "service_count": info.ServiceCount, + "service_keys": info.ServiceKeys, + "has_client": info.HasClient, + "created_at": info.CreatedAt, + "age": time.Since(info.CreatedAt).Round(time.Second).String(), + } + if inb, ok := inboundAll[id]; ok { + row["inbound_listener"] = inb + } + clientsJSON = append(clientsJSON, row) + } + resp := map[string]any{ + "uptime": time.Since(h.startTime).Round(time.Second).String(), + "client_count": len(clients), + "clients": clientsJSON, + } + if len(inboundAll) > 0 { + resp["inbound_listener_count"] = len(inboundAll) + } + h.writeJSON(w, resp) + return + } + + data := clientsData{ + Uptime: time.Since(h.startTime).Round(time.Second).String(), + Clients: make([]clientData, 0, len(clients)), + } + + for _, id := range sortedIDs { + info := clients[id] + services := strings.Join(info.ServiceKeys, ", ") + if services == "" { + services = "-" + } + status := "No client" + if info.HasClient { + status = "Active" + } + data.Clients = append(data.Clients, clientData{ + AccountID: string(info.AccountID), + Services: services, + Age: time.Since(info.CreatedAt).Round(time.Second).String(), + Status: status, + }) + } + + h.renderTemplate(w, "clients", data) +} + +type clientDetailData struct { + AccountID string + ActiveTab string + Content string +} + +func (h *Handler) handleClientStatus(w http.ResponseWriter, r *http.Request, accountID types.AccountID, wantJSON bool) { + client, ok := h.provider.GetClient(accountID) + if !ok { + http.Error(w, "Client not found: "+string(accountID), http.StatusNotFound) + return + } + + fullStatus, err := client.Status() + if err != nil { + http.Error(w, "Error getting status: "+err.Error(), http.StatusInternalServerError) + return + } + + // Parse filter parameters + query := r.URL.Query() + statusFilter := query.Get("filter-by-status") + connectionTypeFilter := query.Get("filter-by-connection-type") + + var prefixNamesFilter []string + var prefixNamesFilterMap map[string]struct{} + if names := query.Get("filter-by-names"); names != "" { + prefixNamesFilter = strings.Split(names, ",") + prefixNamesFilterMap = make(map[string]struct{}) + for _, name := range prefixNamesFilter { + prefixNamesFilterMap[strings.ToLower(strings.TrimSpace(name))] = struct{}{} + } + } + + var ipsFilterMap map[string]struct{} + if ips := query.Get("filter-by-ips"); ips != "" { + ipsFilterMap = make(map[string]struct{}) + for _, ip := range strings.Split(ips, ",") { + ipsFilterMap[strings.TrimSpace(ip)] = struct{}{} + } + } + + pbStatus := nbstatus.ToProtoFullStatus(fullStatus) + overview := nbstatus.ConvertToStatusOutputOverview(pbStatus, nbstatus.ConvertOptions{ + StatusFilter: statusFilter, + PrefixNamesFilter: prefixNamesFilter, + PrefixNamesFilterMap: prefixNamesFilterMap, + IPsFilter: ipsFilterMap, + ConnectionTypeFilter: connectionTypeFilter, + }) + + if wantJSON { + resp := map[string]any{ + "account_id": accountID, + "status": overview.FullDetailSummary(), + } + if info, ok := h.inboundInfoFor(accountID); ok { + resp["inbound_listener"] = info + } + h.writeJSON(w, resp) + return + } + + data := clientDetailData{ + AccountID: string(accountID), + ActiveTab: "status", + Content: overview.FullDetailSummary(), + } + + h.renderTemplate(w, "clientDetail", data) +} + +// inboundInfoFor returns the inbound listener info for an account, or +// ok=false when no inbound provider is wired or the account has no live +// listener. +func (h *Handler) inboundInfoFor(accountID types.AccountID) (InboundListenerInfo, bool) { + if h.inbound == nil { + return InboundListenerInfo{}, false + } + all := h.inbound.InboundListeners() + info, ok := all[accountID] + return info, ok +} + +func (h *Handler) handleClientSyncResponse(w http.ResponseWriter, _ *http.Request, accountID types.AccountID, wantJSON bool) { + client, ok := h.provider.GetClient(accountID) + if !ok { + http.Error(w, "Client not found: "+string(accountID), http.StatusNotFound) + return + } + + syncResp, err := client.GetLatestSyncResponse() + if err != nil { + http.Error(w, "Error getting sync response: "+err.Error(), http.StatusInternalServerError) + return + } + + if syncResp == nil { + http.Error(w, "No sync response available for client: "+string(accountID), http.StatusNotFound) + return + } + + opts := protojson.MarshalOptions{ + EmitUnpopulated: true, + UseProtoNames: true, + Indent: " ", + AllowPartial: true, + } + + jsonBytes, err := opts.Marshal(syncResp) + if err != nil { + http.Error(w, "Error marshaling sync response: "+err.Error(), http.StatusInternalServerError) + return + } + + if wantJSON { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(jsonBytes) + return + } + + data := clientDetailData{ + AccountID: string(accountID), + ActiveTab: "syncresponse", + Content: string(jsonBytes), + } + + h.renderTemplate(w, "clientDetail", data) +} + +type toolsData struct { + AccountID string +} + +func (h *Handler) handleClientTools(w http.ResponseWriter, _ *http.Request, accountID types.AccountID) { + _, ok := h.provider.GetClient(accountID) + if !ok { + http.Error(w, "Client not found: "+string(accountID), http.StatusNotFound) + return + } + + data := toolsData{ + AccountID: string(accountID), + } + + h.renderTemplate(w, "tools", data) +} + +func (h *Handler) handlePingTCP(w http.ResponseWriter, r *http.Request, accountID types.AccountID) { + client, ok := h.provider.GetClient(accountID) + if !ok { + h.writeJSON(w, map[string]any{"error": "client not found"}) + return + } + + host := r.URL.Query().Get("host") + portStr := r.URL.Query().Get("port") + if host == "" || portStr == "" { + h.writeJSON(w, map[string]any{"error": "host and port parameters required"}) + return + } + + port, err := strconv.Atoi(portStr) + if err != nil || port < 1 || port > 65535 { + h.writeJSON(w, map[string]any{"error": "invalid port"}) + return + } + + timeout := defaultPingTimeout + if t := r.URL.Query().Get("timeout"); t != "" { + if d, err := time.ParseDuration(t); err == nil { + timeout = d + } + } + + network := "tcp" + if v := r.URL.Query().Get("ip_version"); v == "4" || v == "6" { + network += v + } + + ctx, cancel := context.WithTimeout(r.Context(), timeout) + defer cancel() + + address := net.JoinHostPort(host, strconv.Itoa(port)) + start := time.Now() + + conn, err := client.Dial(ctx, network, address) + if err != nil { + h.writeJSON(w, map[string]any{ + "success": false, + "host": host, + "port": port, + "error": err.Error(), + }) + return + } + + remote := conn.RemoteAddr().String() + if err := conn.Close(); err != nil { + h.logger.Debugf("close tcp ping connection: %v", err) + } + + latency := time.Since(start) + h.writeJSON(w, map[string]any{ + "success": true, + "host": host, + "port": port, + "remote": remote, + "latency_ms": latency.Milliseconds(), + "latency": formatDuration(latency), + }) +} + +func (h *Handler) handleLogLevel(w http.ResponseWriter, r *http.Request, accountID types.AccountID) { + client, ok := h.provider.GetClient(accountID) + if !ok { + h.writeJSON(w, map[string]any{"error": "client not found"}) + return + } + + level := r.URL.Query().Get("level") + if level == "" { + h.writeJSON(w, map[string]any{"error": "level parameter required (trace, debug, info, warn, error)"}) + return + } + + if err := client.SetLogLevel(level); err != nil { + h.writeJSON(w, map[string]any{ + "success": false, + "error": err.Error(), + }) + return + } + + h.writeJSON(w, map[string]any{ + "success": true, + "level": level, + }) +} + +const clientActionTimeout = 30 * time.Second + +func (h *Handler) handleClientStart(w http.ResponseWriter, r *http.Request, accountID types.AccountID) { + client, ok := h.provider.GetClient(accountID) + if !ok { + h.writeJSON(w, map[string]any{"error": "client not found"}) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), clientActionTimeout) + defer cancel() + + if err := client.Start(ctx); err != nil { + h.writeJSON(w, map[string]any{ + "success": false, + "error": err.Error(), + }) + return + } + + h.writeJSON(w, map[string]any{ + "success": true, + "message": "client started", + }) +} + +func (h *Handler) handleClientStop(w http.ResponseWriter, r *http.Request, accountID types.AccountID) { + client, ok := h.provider.GetClient(accountID) + if !ok { + h.writeJSON(w, map[string]any{"error": "client not found"}) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), clientActionTimeout) + defer cancel() + + if err := client.Stop(ctx); err != nil { + h.writeJSON(w, map[string]any{ + "success": false, + "error": err.Error(), + }) + return + } + + h.writeJSON(w, map[string]any{ + "success": true, + "message": "client stopped", + }) +} + +func (h *Handler) handlePerf(w http.ResponseWriter, r *http.Request) { + raw := r.URL.Query().Get("value") + if raw == "" { + http.Error(w, "value parameter is required", http.StatusBadRequest) + return + } + n, err := strconv.ParseUint(raw, 10, 32) + if err != nil { + http.Error(w, fmt.Sprintf("invalid value %q: %v", raw, err), http.StatusBadRequest) + return + } + + capN := uint32(n) + applied := 0 + failed := map[string]string{} + for accountID, client := range h.provider.ListClientsForStartup() { + if err := client.SetPerformance(nbembed.Performance{PreallocatedBuffersPerPool: &capN}); err != nil { + failed[string(accountID)] = err.Error() + continue + } + applied++ + } + + resp := map[string]any{ + "success": true, + "value": capN, + "applied": applied, + } + if len(failed) > 0 { + resp["failed"] = failed + } + h.writeJSON(w, resp) +} + +// handleRuntime returns cheap runtime and process stats. Safe to hit on a +// running proxy; does not read pprof profiles. +func (h *Handler) handleRuntime(w http.ResponseWriter, _ *http.Request) { + var m runtime.MemStats + runtime.ReadMemStats(&m) + + clients := h.provider.ListClientsForDebug() + started := 0 + for _, c := range clients { + if c.HasClient { + started++ + } + } + + resp := map[string]any{ + "uptime": time.Since(h.startTime).Round(time.Second).String(), + "goroutines": runtime.NumGoroutine(), + "num_cpu": runtime.NumCPU(), + "gomaxprocs": runtime.GOMAXPROCS(0), + "go_version": runtime.Version(), + "heap_alloc": m.HeapAlloc, + "heap_inuse": m.HeapInuse, + "heap_idle": m.HeapIdle, + "heap_released": m.HeapReleased, + "heap_sys": m.HeapSys, + "sys": m.Sys, + "live_objects": m.Mallocs - m.Frees, + "num_gc": m.NumGC, + "pause_total_ns": m.PauseTotalNs, + "clients": len(clients), + "started": started, + } + + if proc := readProcStatus(); proc != nil { + resp["vm_rss"] = proc["VmRSS"] + resp["vm_size"] = proc["VmSize"] + resp["vm_data"] = proc["VmData"] + } + + h.writeJSON(w, resp) +} + +// readProcStatus parses /proc/self/status on Linux and returns size fields +// in bytes. Returns nil on non-Linux or read failure. +func readProcStatus() map[string]uint64 { + raw, err := os.ReadFile("/proc/self/status") + if err != nil { + return nil + } + out := map[string]uint64{} + for _, line := range strings.Split(string(raw), "\n") { + k, v, ok := strings.Cut(line, ":") + if !ok { + continue + } + if k != "VmRSS" && k != "VmSize" && k != "VmData" { + continue + } + fields := strings.Fields(v) + if len(fields) < 1 { + continue + } + n, err := strconv.ParseUint(fields[0], 10, 64) + if err != nil { + continue + } + // Values are reported in kB. + out[k] = n * 1024 + } + return out +} + +const maxCaptureDuration = 30 * time.Minute + +// handleCapture streams a pcap or text packet capture for the given client. +// +// Query params: +// +// duration: capture duration (0 or absent = max, capped at 30m) +// format: "text" for human-readable output (default: pcap) +// filter: BPF-like filter expression (e.g. "host 10.0.0.1 and tcp port 443") +func (h *Handler) handleCapture(w http.ResponseWriter, r *http.Request, accountID types.AccountID) { + client, ok := h.provider.GetClient(accountID) + if !ok { + http.Error(w, "client not found", http.StatusNotFound) + return + } + + duration := maxCaptureDuration + if durationStr := r.URL.Query().Get("duration"); durationStr != "" { + d, err := time.ParseDuration(durationStr) + if err != nil { + http.Error(w, "invalid duration: "+err.Error(), http.StatusBadRequest) + return + } + if d < 0 { + http.Error(w, "duration must not be negative", http.StatusBadRequest) + return + } + if d > 0 { + duration = min(d, maxCaptureDuration) + } + } + + filter := r.URL.Query().Get("filter") + wantText := r.URL.Query().Get("format") == "text" + verbose := r.URL.Query().Get("verbose") == "true" + ascii := r.URL.Query().Get("ascii") == "true" + + opts := nbembed.CaptureOptions{Filter: filter, Verbose: verbose, ASCII: ascii} + if wantText { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + opts.TextOutput = w + } else { + w.Header().Set("Content-Type", "application/vnd.tcpdump.pcap") + w.Header().Set("Content-Disposition", + fmt.Sprintf("attachment; filename=capture-%s.pcap", accountID)) + opts.Output = w + } + + cs, err := client.StartCapture(opts) + if err != nil { + http.Error(w, "start capture: "+err.Error(), http.StatusServiceUnavailable) + return + } + defer cs.Stop() + + // Flush headers after setup succeeds so errors above can still set status codes. + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + + timer := time.NewTimer(duration) + defer timer.Stop() + + select { + case <-r.Context().Done(): + case <-timer.C: + } + + cs.Stop() + + stats := cs.Stats() + h.logger.Infof("capture for %s finished: %d packets, %d bytes, %d dropped", + accountID, stats.Packets, stats.Bytes, stats.Dropped) +} + +func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request, wantJSON bool) { + if !wantJSON { + http.Redirect(w, r, "/debug", http.StatusSeeOther) + return + } + + uptime := time.Since(h.startTime).Round(10 * time.Millisecond).String() + + ready := h.health.ReadinessProbe() + allHealthy, clientHealth := h.health.CheckClientsConnected(r.Context()) + + status := "ok" + // No clients is not a health issue; only degrade when actual clients are unhealthy + if !ready || (!allHealthy && len(clientHealth) > 0) { + status = "degraded" + } + + var certsTotal, certsReady, certsPending, certsFailed int + var certsPendingDomains, certsReadyDomains []string + var certsFailedDomains map[string]string + if h.certStatus != nil { + certsTotal = h.certStatus.TotalDomains() + certsPendingDomains = h.certStatus.PendingDomains() + certsReadyDomains = h.certStatus.ReadyDomains() + certsFailedDomains = h.certStatus.FailedDomains() + certsReady = len(certsReadyDomains) + certsPending = len(certsPendingDomains) + certsFailed = len(certsFailedDomains) + } + + resp := map[string]any{ + "status": status, + "uptime": uptime, + "management_connected": ready, + "all_clients_healthy": allHealthy, + "certs_total": certsTotal, + "certs_ready": certsReady, + "certs_pending": certsPending, + "certs_failed": certsFailed, + "clients": clientHealth, + } + if len(certsPendingDomains) > 0 { + resp["certs_pending_domains"] = certsPendingDomains + } + if len(certsReadyDomains) > 0 { + resp["certs_ready_domains"] = certsReadyDomains + } + if len(certsFailedDomains) > 0 { + resp["certs_failed_domains"] = certsFailedDomains + } + h.writeJSON(w, resp) +} + +func (h *Handler) renderTemplate(w http.ResponseWriter, name string, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + tmpl := h.getTemplates() + if tmpl == nil { + http.Error(w, "Templates not loaded", http.StatusInternalServerError) + return + } + if err := tmpl.ExecuteTemplate(w, name, data); err != nil { + h.logger.Errorf("execute template %s: %v", name, err) + http.Error(w, "Template error", http.StatusInternalServerError) + } +} + +func (h *Handler) writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + h.logger.Errorf("encode JSON response: %v", err) + } +} diff --git a/proxy/internal/debug/templates/base.html b/proxy/internal/debug/templates/base.html new file mode 100644 index 000000000..737bd5b85 --- /dev/null +++ b/proxy/internal/debug/templates/base.html @@ -0,0 +1,101 @@ +{{define "style"}} +body { + font-family: monospace; + margin: 20px; + background: #1a1a1a; + color: #eee; +} +a { + color: #6cf; +} +h1, h2, h3 { + color: #fff; +} +.info { + color: #aaa; +} +table { + border-collapse: collapse; + margin: 10px 0; +} +th, td { + border: 1px solid #444; + padding: 8px; + text-align: left; +} +th { + background: #333; +} +.nav { + margin-bottom: 20px; +} +.nav a { + margin-right: 15px; + padding: 8px 16px; + background: #333; + text-decoration: none; + border-radius: 4px; +} +.nav a.active { + background: #6cf; + color: #000; +} +pre { + background: #222; + padding: 15px; + border-radius: 4px; + overflow-x: auto; + white-space: pre-wrap; +} +input, select, textarea { + background: #333; + color: #eee; + border: 1px solid #555; + padding: 8px; + border-radius: 4px; + font-family: monospace; +} +input:focus, select:focus, textarea:focus { + outline: none; + border-color: #6cf; +} +button { + background: #6cf; + color: #000; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-family: monospace; +} +button:hover { + background: #5be; +} +button:disabled { + background: #555; + color: #888; + cursor: not-allowed; +} +.form-group { + margin-bottom: 15px; +} +.form-group label { + display: block; + margin-bottom: 5px; + color: #aaa; +} +.form-row { + display: flex; + gap: 10px; + align-items: flex-end; +} +.result { + margin-top: 20px; +} +.success { + color: #5f5; +} +.error { + color: #f55; +} +{{end}} diff --git a/proxy/internal/debug/templates/client_detail.html b/proxy/internal/debug/templates/client_detail.html new file mode 100644 index 000000000..8eb27b1e5 --- /dev/null +++ b/proxy/internal/debug/templates/client_detail.html @@ -0,0 +1,19 @@ +{{define "clientDetail"}} + + + + Client {{.AccountID}} + + + +

                                                Client: {{.AccountID}}

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

                                                All Clients

                                                +

                                                Uptime: {{.Uptime}} | ← Back

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

                                                No clients connected

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

                                                NetBird Proxy Debug

                                                +

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

                                                +

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

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

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

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

                                                No clients connected

                                                + {{end}} +

                                                Endpoints

                                                + +

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

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

                                                Client: {{.AccountID}}

                                                + + +

                                                Client Control

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

                                                Log Level

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

                                                TCP Ping

                                                +
                                                +
                                                + + +
                                                +
                                                + + +
                                                +
                                                +   + +
                                                +
                                                +
                                                + + + + +{{end}} diff --git a/proxy/internal/flock/flock_other.go b/proxy/internal/flock/flock_other.go new file mode 100644 index 000000000..a3916a442 --- /dev/null +++ b/proxy/internal/flock/flock_other.go @@ -0,0 +1,20 @@ +//go:build !unix + +package flock + +import ( + "context" + "os" +) + +// Lock is a no-op on non-Unix platforms. Returns (nil, nil) to indicate +// that no lock was acquired; callers must treat a nil file as "proceed +// without lock" rather than "lock held by someone else." +func Lock(_ context.Context, _ string) (*os.File, error) { + return nil, nil //nolint:nilnil // intentional: nil file signals locking unsupported on this platform +} + +// Unlock is a no-op on non-Unix platforms. +func Unlock(_ *os.File) error { + return nil +} diff --git a/proxy/internal/flock/flock_test.go b/proxy/internal/flock/flock_test.go new file mode 100644 index 000000000..501a173f7 --- /dev/null +++ b/proxy/internal/flock/flock_test.go @@ -0,0 +1,79 @@ +//go:build unix + +package flock + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLockUnlock(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "test.lock") + + f, err := Lock(context.Background(), lockPath) + require.NoError(t, err) + require.NotNil(t, f) + + _, err = os.Stat(lockPath) + assert.NoError(t, err, "lock file should exist") + + err = Unlock(f) + assert.NoError(t, err) +} + +func TestUnlockNil(t *testing.T) { + err := Unlock(nil) + assert.NoError(t, err, "unlocking nil should be a no-op") +} + +func TestLockRespectsContext(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "test.lock") + + f1, err := Lock(context.Background(), lockPath) + require.NoError(t, err) + defer func() { require.NoError(t, Unlock(f1)) }() + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + _, err = Lock(ctx, lockPath) + assert.ErrorIs(t, err, context.DeadlineExceeded) +} + +func TestLockBlocks(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "test.lock") + + f1, err := Lock(context.Background(), lockPath) + require.NoError(t, err) + + var wg sync.WaitGroup + wg.Add(1) + + start := time.Now() + var elapsed time.Duration + + go func() { + defer wg.Done() + f2, err := Lock(context.Background(), lockPath) + elapsed = time.Since(start) + assert.NoError(t, err) + if f2 != nil { + assert.NoError(t, Unlock(f2)) + } + }() + + // Hold the lock for 200ms, then release. + time.Sleep(200 * time.Millisecond) + require.NoError(t, Unlock(f1)) + + wg.Wait() + assert.GreaterOrEqual(t, elapsed, 150*time.Millisecond, + "Lock should have blocked for at least ~200ms") +} diff --git a/proxy/internal/flock/flock_unix.go b/proxy/internal/flock/flock_unix.go new file mode 100644 index 000000000..738859a6f --- /dev/null +++ b/proxy/internal/flock/flock_unix.go @@ -0,0 +1,77 @@ +//go:build unix + +// Package flock provides best-effort advisory file locking using flock(2). +// +// This is used for cross-replica coordination (e.g. preventing duplicate +// ACME requests). Note that flock(2) does NOT work reliably on NFS volumes: +// on NFSv3 it depends on the NLM daemon, on NFSv4 Linux emulates it via +// fcntl locks with different semantics. Callers must treat lock failures +// as non-fatal and proceed without the lock. +package flock + +import ( + "context" + "errors" + "fmt" + "os" + "syscall" + "time" + + log "github.com/sirupsen/logrus" +) + +const retryInterval = 100 * time.Millisecond + +// Lock acquires an exclusive advisory lock on the given file path. +// It creates the lock file if it does not exist. The lock attempt +// respects context cancellation by using non-blocking flock with polling. +// The caller must call Unlock with the returned *os.File when done. +func Lock(ctx context.Context, path string) (*os.File, error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("open lock file %s: %w", path, err) + } + + timer := time.NewTimer(retryInterval) + defer timer.Stop() + + for { + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err == nil { + return f, nil + } else if !errors.Is(err, syscall.EWOULDBLOCK) { + if cerr := f.Close(); cerr != nil { + log.Debugf("close lock file %s: %v", path, cerr) + } + return nil, fmt.Errorf("acquire lock on %s: %w", path, err) + } + + select { + case <-ctx.Done(): + if cerr := f.Close(); cerr != nil { + log.Debugf("close lock file %s: %v", path, cerr) + } + return nil, ctx.Err() + case <-timer.C: + timer.Reset(retryInterval) + } + } +} + +// Unlock releases the lock and closes the file. +func Unlock(f *os.File) error { + if f == nil { + return nil + } + + defer func() { + if cerr := f.Close(); cerr != nil { + log.Debugf("close lock file: %v", cerr) + } + }() + + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_UN); err != nil { + return fmt.Errorf("release lock: %w", err) + } + + return nil +} diff --git a/proxy/internal/geolocation/download.go b/proxy/internal/geolocation/download.go new file mode 100644 index 000000000..64d515275 --- /dev/null +++ b/proxy/internal/geolocation/download.go @@ -0,0 +1,264 @@ +package geolocation + +import ( + "archive/tar" + "bufio" + "compress/gzip" + "crypto/sha256" + "errors" + "fmt" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + mmdbTarGZURL = "https://pkgs.netbird.io/geolocation-dbs/GeoLite2-City/download?suffix=tar.gz" + mmdbSha256URL = "https://pkgs.netbird.io/geolocation-dbs/GeoLite2-City/download?suffix=tar.gz.sha256" + mmdbInnerName = "GeoLite2-City.mmdb" + + downloadTimeout = 2 * time.Minute + maxMMDBSize = 256 << 20 // 256 MB +) + +// ensureMMDB checks for an existing MMDB file in dataDir. If none is found, +// it downloads from pkgs.netbird.io with SHA256 verification. +func ensureMMDB(logger *log.Logger, dataDir string) (string, error) { + if err := os.MkdirAll(dataDir, 0o755); err != nil { + return "", fmt.Errorf("create geo data directory %s: %w", dataDir, err) + } + + pattern := filepath.Join(dataDir, mmdbGlob) + if files, _ := filepath.Glob(pattern); len(files) > 0 { + mmdbPath := files[len(files)-1] + logger.Debugf("using existing geolocation database: %s", mmdbPath) + return mmdbPath, nil + } + + logger.Info("geolocation database not found, downloading from pkgs.netbird.io") + return downloadMMDB(logger, dataDir) +} + +func downloadMMDB(logger *log.Logger, dataDir string) (string, error) { + client := &http.Client{Timeout: downloadTimeout} + + datedName, err := fetchRemoteFilename(client, mmdbTarGZURL) + if err != nil { + return "", fmt.Errorf("get remote filename: %w", err) + } + + mmdbFilename := deriveMMDBFilename(datedName) + mmdbPath := filepath.Join(dataDir, mmdbFilename) + + tmp, err := os.MkdirTemp("", "geolite-proxy-*") + if err != nil { + return "", fmt.Errorf("create temp directory: %w", err) + } + defer os.RemoveAll(tmp) + + checksumFile := filepath.Join(tmp, "checksum.sha256") + if err := downloadToFile(client, mmdbSha256URL, checksumFile); err != nil { + return "", fmt.Errorf("download checksum: %w", err) + } + + expectedHash, err := readChecksumFile(checksumFile) + if err != nil { + return "", fmt.Errorf("read checksum: %w", err) + } + + tarFile := filepath.Join(tmp, datedName) + logger.Debugf("downloading geolocation database (%s)", datedName) + if err := downloadToFile(client, mmdbTarGZURL, tarFile); err != nil { + return "", fmt.Errorf("download database: %w", err) + } + + if err := verifySHA256(tarFile, expectedHash); err != nil { + return "", fmt.Errorf("verify database checksum: %w", err) + } + + if err := extractMMDBFromTarGZ(tarFile, mmdbPath); err != nil { + return "", fmt.Errorf("extract database: %w", err) + } + + logger.Infof("geolocation database downloaded: %s", mmdbPath) + return mmdbPath, nil +} + +// deriveMMDBFilename converts a tar.gz filename to an MMDB filename. +// Example: GeoLite2-City_20240101.tar.gz -> GeoLite2-City_20240101.mmdb +func deriveMMDBFilename(tarName string) string { + base, _, _ := strings.Cut(tarName, ".") + if !strings.Contains(base, "_") { + return "GeoLite2-City.mmdb" + } + return base + ".mmdb" +} + +func fetchRemoteFilename(client *http.Client, url string) (string, error) { + resp, err := client.Head(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HEAD request: HTTP %d", resp.StatusCode) + } + + cd := resp.Header.Get("Content-Disposition") + if cd == "" { + return "", errors.New("no Content-Disposition header") + } + + _, params, err := mime.ParseMediaType(cd) + if err != nil { + return "", fmt.Errorf("parse Content-Disposition: %w", err) + } + + name := filepath.Base(params["filename"]) + if name == "" || name == "." { + return "", errors.New("no filename in Content-Disposition") + } + return name, nil +} + +func downloadToFile(client *http.Client, url, dest string) error { + resp, err := client.Get(url) //nolint:gosec + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + f, err := os.Create(dest) //nolint:gosec + if err != nil { + return err + } + defer f.Close() + + // Cap download at 256 MB to prevent unbounded reads from a compromised server. + if _, err := io.Copy(f, io.LimitReader(resp.Body, maxMMDBSize)); err != nil { + return err + } + return nil +} + +func readChecksumFile(path string) (string, error) { + f, err := os.Open(path) //nolint:gosec + if err != nil { + return "", err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + if scanner.Scan() { + parts := strings.Fields(scanner.Text()) + if len(parts) > 0 { + return parts[0], nil + } + } + if err := scanner.Err(); err != nil { + return "", err + } + return "", errors.New("empty checksum file") +} + +func verifySHA256(path, expected string) error { + f, err := os.Open(path) //nolint:gosec + if err != nil { + return err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return err + } + + actual := fmt.Sprintf("%x", h.Sum(nil)) + if actual != expected { + return fmt.Errorf("SHA256 mismatch: expected %s, got %s", expected, actual) + } + return nil +} + +func extractMMDBFromTarGZ(tarGZPath, destPath string) error { + f, err := os.Open(tarGZPath) //nolint:gosec + if err != nil { + return err + } + defer f.Close() + + gz, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return err + } + + if hdr.Typeflag == tar.TypeReg && filepath.Base(hdr.Name) == mmdbInnerName { + if hdr.Size < 0 || hdr.Size > maxMMDBSize { + return fmt.Errorf("mmdb entry size %d exceeds limit %d", hdr.Size, maxMMDBSize) + } + if err := extractToFileAtomic(io.LimitReader(tr, hdr.Size), destPath); err != nil { + return err + } + return nil + } + } + + return fmt.Errorf("%s not found in archive", mmdbInnerName) +} + +// extractToFileAtomic writes r to a temporary file in the same directory as +// destPath, then renames it into place so a crash never leaves a truncated file. +func extractToFileAtomic(r io.Reader, destPath string) error { + dir := filepath.Dir(destPath) + tmp, err := os.CreateTemp(dir, ".mmdb-*.tmp") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpPath := tmp.Name() + + if _, err := io.Copy(tmp, r); err != nil { //nolint:gosec // G110: caller bounds with LimitReader + if closeErr := tmp.Close(); closeErr != nil { + log.Debugf("failed to close temp file %s: %v", tmpPath, closeErr) + } + if removeErr := os.Remove(tmpPath); removeErr != nil { + log.Debugf("failed to remove temp file %s: %v", tmpPath, removeErr) + } + return fmt.Errorf("write mmdb: %w", err) + } + if err := tmp.Close(); err != nil { + if removeErr := os.Remove(tmpPath); removeErr != nil { + log.Debugf("failed to remove temp file %s: %v", tmpPath, removeErr) + } + return fmt.Errorf("close temp file: %w", err) + } + if err := os.Rename(tmpPath, destPath); err != nil { + if removeErr := os.Remove(tmpPath); removeErr != nil { + log.Debugf("failed to remove temp file %s: %v", tmpPath, removeErr) + } + return fmt.Errorf("rename to %s: %w", destPath, err) + } + return nil +} diff --git a/proxy/internal/geolocation/geolocation.go b/proxy/internal/geolocation/geolocation.go new file mode 100644 index 000000000..81b02efb3 --- /dev/null +++ b/proxy/internal/geolocation/geolocation.go @@ -0,0 +1,152 @@ +// Package geolocation provides IP-to-country lookups using MaxMind GeoLite2 databases. +package geolocation + +import ( + "fmt" + "net/netip" + "os" + "strconv" + "sync" + + "github.com/oschwald/maxminddb-golang" + log "github.com/sirupsen/logrus" +) + +const ( + // EnvDisable disables geolocation lookups entirely when set to a truthy value. + EnvDisable = "NB_PROXY_DISABLE_GEOLOCATION" + + mmdbGlob = "GeoLite2-City_*.mmdb" +) + +type record struct { + Country struct { + ISOCode string `maxminddb:"iso_code"` + } `maxminddb:"country"` + City struct { + Names struct { + En string `maxminddb:"en"` + } `maxminddb:"names"` + } `maxminddb:"city"` + Subdivisions []struct { + ISOCode string `maxminddb:"iso_code"` + Names struct { + En string `maxminddb:"en"` + } `maxminddb:"names"` + } `maxminddb:"subdivisions"` +} + +// Result holds the outcome of a geo lookup. +type Result struct { + CountryCode string + CityName string + SubdivisionCode string + SubdivisionName string +} + +// Lookup provides IP geolocation lookups. +type Lookup struct { + mu sync.RWMutex + db *maxminddb.Reader + logger *log.Logger +} + +// NewLookup opens or downloads the GeoLite2-City MMDB in dataDir. +// Returns nil without error if geolocation is disabled via environment +// variable, no data directory is configured, or the download fails +// (graceful degradation: country restrictions will deny all requests). +func NewLookup(logger *log.Logger, dataDir string) (*Lookup, error) { + if isDisabledByEnv(logger) { + logger.Info("geolocation disabled via environment variable") + return nil, nil //nolint:nilnil + } + + if dataDir == "" { + return nil, nil //nolint:nilnil + } + + mmdbPath, err := ensureMMDB(logger, dataDir) + if err != nil { + logger.Warnf("geolocation database unavailable: %v", err) + logger.Warn("country-based access restrictions will deny all requests until a database is available") + return nil, nil //nolint:nilnil + } + + db, err := maxminddb.Open(mmdbPath) + if err != nil { + return nil, fmt.Errorf("open GeoLite2 database %s: %w", mmdbPath, err) + } + + logger.Infof("geolocation database loaded from %s", mmdbPath) + return &Lookup{db: db, logger: logger}, nil +} + +// LookupAddr returns the country ISO code and city name for the given IP. +// Returns an empty Result if the database is nil or the lookup fails. +func (l *Lookup) LookupAddr(addr netip.Addr) Result { + if l == nil { + return Result{} + } + + l.mu.RLock() + defer l.mu.RUnlock() + + if l.db == nil { + return Result{} + } + + addr = addr.Unmap() + + var rec record + if err := l.db.Lookup(addr.AsSlice(), &rec); err != nil { + l.logger.Debugf("geolocation lookup %s: %v", addr, err) + return Result{} + } + r := Result{ + CountryCode: rec.Country.ISOCode, + CityName: rec.City.Names.En, + } + if len(rec.Subdivisions) > 0 { + r.SubdivisionCode = rec.Subdivisions[0].ISOCode + r.SubdivisionName = rec.Subdivisions[0].Names.En + } + return r +} + +// Available reports whether the lookup has a loaded database. +func (l *Lookup) Available() bool { + if l == nil { + return false + } + l.mu.RLock() + defer l.mu.RUnlock() + return l.db != nil +} + +// Close releases the database resources. +func (l *Lookup) Close() error { + if l == nil { + return nil + } + l.mu.Lock() + defer l.mu.Unlock() + if l.db != nil { + err := l.db.Close() + l.db = nil + return err + } + return nil +} + +func isDisabledByEnv(logger *log.Logger) bool { + val := os.Getenv(EnvDisable) + if val == "" { + return false + } + disabled, err := strconv.ParseBool(val) + if err != nil { + logger.Warnf("parse %s=%q: %v", EnvDisable, val, err) + return false + } + return disabled +} diff --git a/proxy/internal/grpc/auth.go b/proxy/internal/grpc/auth.go new file mode 100644 index 000000000..ce1a23f68 --- /dev/null +++ b/proxy/internal/grpc/auth.go @@ -0,0 +1,48 @@ +// Package grpc provides gRPC utilities for the proxy client. +package grpc + +import ( + "context" + "os" + "strconv" + + log "github.com/sirupsen/logrus" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +// EnvProxyAllowInsecure controls whether the proxy token can be sent over non-TLS connections. +const EnvProxyAllowInsecure = "NB_PROXY_ALLOW_INSECURE" + +var _ credentials.PerRPCCredentials = (*proxyAuthToken)(nil) + +type proxyAuthToken struct { + token string + allowInsecure bool +} + +func (t proxyAuthToken) GetRequestMetadata(context.Context, ...string) (map[string]string, error) { + return map[string]string{ + "authorization": "Bearer " + t.token, + }, nil +} + +// RequireTransportSecurity returns true by default to protect the token in transit. +// Set NB_PROXY_ALLOW_INSECURE=true to allow non-TLS connections (not recommended for production). +func (t proxyAuthToken) RequireTransportSecurity() bool { + return !t.allowInsecure +} + +// WithProxyToken returns a DialOption that sets the proxy access token on each outbound RPC. +func WithProxyToken(token string) grpc.DialOption { + allowInsecure := false + if val := os.Getenv(EnvProxyAllowInsecure); val != "" { + parsed, err := strconv.ParseBool(val) + if err != nil { + log.Warnf("invalid value for %s: %v", EnvProxyAllowInsecure, err) + } else { + allowInsecure = parsed + } + } + return grpc.WithPerRPCCredentials(proxyAuthToken{token: token, allowInsecure: allowInsecure}) +} diff --git a/proxy/internal/health/health.go b/proxy/internal/health/health.go new file mode 100644 index 000000000..60ce7f8ef --- /dev/null +++ b/proxy/internal/health/health.go @@ -0,0 +1,405 @@ +// Package health provides health probes for the proxy server. +package health + +import ( + "context" + "encoding/json" + "net" + "net/http" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/embed" + "github.com/netbirdio/netbird/proxy/internal/types" +) + +const handshakeStaleThreshold = 5 * time.Minute + +const ( + maxConcurrentChecks = 3 + maxClientCheckTimeout = 5 * time.Minute +) + +// clientProvider provides access to NetBird clients for health checks. +type clientProvider interface { + ListClientsForStartup() map[types.AccountID]*embed.Client +} + +// Checker tracks health state and provides probe endpoints. +type Checker struct { + logger *log.Logger + provider clientProvider + + mu sync.RWMutex + managementConnected bool + initialSyncComplete bool + shuttingDown bool + + // checkSem limits concurrent client health checks. + checkSem chan struct{} + + // checkHealth checks the health of a single client. + // Defaults to checkClientHealth; overridable in tests. + checkHealth func(*embed.Client) ClientHealth +} + +// ClientHealth represents the health status of a single NetBird client. +type ClientHealth struct { + Healthy bool `json:"healthy"` + ManagementConnected bool `json:"management_connected"` + SignalConnected bool `json:"signal_connected"` + RelaysConnected int `json:"relays_connected"` + RelaysTotal int `json:"relays_total"` + PeersTotal int `json:"peers_total"` + PeersConnected int `json:"peers_connected"` + PeersP2P int `json:"peers_p2p"` + PeersRelayed int `json:"peers_relayed"` + PeersDegraded int `json:"peers_degraded"` + Error string `json:"error,omitempty"` +} + +// ProbeResponse represents the JSON response for health probes. +type ProbeResponse struct { + Status string `json:"status"` + Checks map[string]bool `json:"checks,omitempty"` + Clients map[types.AccountID]ClientHealth `json:"clients,omitempty"` +} + +// Server runs the health probe HTTP server on a dedicated port. +type Server struct { + server *http.Server + logger *log.Logger + checker *Checker +} + +// SetManagementConnected updates the management connection state. +func (c *Checker) SetManagementConnected(connected bool) { + c.mu.Lock() + defer c.mu.Unlock() + c.managementConnected = connected +} + +// SetInitialSyncComplete marks that the initial mapping sync has completed. +func (c *Checker) SetInitialSyncComplete() { + c.mu.Lock() + defer c.mu.Unlock() + c.initialSyncComplete = true +} + +// SetShuttingDown marks the server as shutting down. +// This causes ReadinessProbe to return false so load balancers stop routing traffic. +func (c *Checker) SetShuttingDown() { + c.mu.Lock() + defer c.mu.Unlock() + c.shuttingDown = true +} + +// CheckClientsConnected verifies all clients are connected to management/signal/relay. +// Uses the provided context for timeout/cancellation, with a maximum bound of maxClientCheckTimeout. +// Limits concurrent checks via semaphore. +func (c *Checker) CheckClientsConnected(ctx context.Context) (bool, map[types.AccountID]ClientHealth) { + // Apply upper bound timeout in case parent context has no deadline + ctx, cancel := context.WithTimeout(ctx, maxClientCheckTimeout) + defer cancel() + + clients := c.provider.ListClientsForStartup() + + // No clients is not a health issue + if len(clients) == 0 { + return true, make(map[types.AccountID]ClientHealth) + } + + type result struct { + accountID types.AccountID + health ClientHealth + } + + resultsCh := make(chan result, len(clients)) + var wg sync.WaitGroup + + for accountID, client := range clients { + wg.Add(1) + go func(id types.AccountID, cl *embed.Client) { + defer wg.Done() + + // Acquire semaphore + select { + case c.checkSem <- struct{}{}: + defer func() { <-c.checkSem }() + case <-ctx.Done(): + resultsCh <- result{id, ClientHealth{Healthy: false, Error: ctx.Err().Error()}} + return + } + + resultsCh <- result{id, c.checkHealth(cl)} + }(accountID, client) + } + + go func() { + wg.Wait() + close(resultsCh) + }() + + results := make(map[types.AccountID]ClientHealth) + allHealthy := true + for r := range resultsCh { + results[r.accountID] = r.health + if !r.health.Healthy { + allHealthy = false + } + } + + return allHealthy, results +} + +// LivenessProbe returns true if the process is alive. +// This should always return true if we can respond. +func (c *Checker) LivenessProbe() bool { + return true +} + +// ReadinessProbe returns true if the server can accept traffic. +func (c *Checker) ReadinessProbe() bool { + c.mu.RLock() + defer c.mu.RUnlock() + if c.shuttingDown { + return false + } + return c.managementConnected +} + +// StartupProbe checks if initial startup is complete. +// Checks management connection, initial sync, and all client health directly. +// Uses the provided context for timeout/cancellation. +func (c *Checker) StartupProbe(ctx context.Context) bool { + c.mu.RLock() + mgmt := c.managementConnected + sync := c.initialSyncComplete + c.mu.RUnlock() + + if !mgmt || !sync { + return false + } + + // Check all clients are connected to management/signal/relay. + // Returns true when no clients exist (nothing to check). + allHealthy, _ := c.CheckClientsConnected(ctx) + return allHealthy +} + +// Handler returns an http.Handler for health probe endpoints. +func (c *Checker) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/healthz/live", c.handleLiveness) + mux.HandleFunc("/healthz/ready", c.handleReadiness) + mux.HandleFunc("/healthz/startup", c.handleStartup) + mux.HandleFunc("/healthz", c.handleFull) + return mux +} + +func (c *Checker) handleLiveness(w http.ResponseWriter, r *http.Request) { + if c.LivenessProbe() { + c.writeProbeResponse(w, http.StatusOK, "ok", nil, nil) + return + } + c.writeProbeResponse(w, http.StatusServiceUnavailable, "fail", nil, nil) +} + +func (c *Checker) handleReadiness(w http.ResponseWriter, r *http.Request) { + c.mu.RLock() + checks := map[string]bool{ + "management_connected": c.managementConnected, + } + c.mu.RUnlock() + + if c.ReadinessProbe() { + c.writeProbeResponse(w, http.StatusOK, "ok", checks, nil) + return + } + c.writeProbeResponse(w, http.StatusServiceUnavailable, "fail", checks, nil) +} + +func (c *Checker) handleStartup(w http.ResponseWriter, r *http.Request) { + c.mu.RLock() + mgmt := c.managementConnected + syncComplete := c.initialSyncComplete + c.mu.RUnlock() + + allClientsHealthy, clientHealth := c.CheckClientsConnected(r.Context()) + + checks := map[string]bool{ + "management_connected": mgmt, + "initial_sync_complete": syncComplete, + "all_clients_healthy": allClientsHealthy, + } + + ready := mgmt && syncComplete && allClientsHealthy + if ready { + c.writeProbeResponse(w, http.StatusOK, "ok", checks, clientHealth) + return + } + c.writeProbeResponse(w, http.StatusServiceUnavailable, "fail", checks, clientHealth) +} + +func (c *Checker) handleFull(w http.ResponseWriter, r *http.Request) { + c.mu.RLock() + mgmt := c.managementConnected + sync := c.initialSyncComplete + c.mu.RUnlock() + + allClientsHealthy, clientHealth := c.CheckClientsConnected(r.Context()) + + checks := map[string]bool{ + "management_connected": mgmt, + "initial_sync_complete": sync, + "all_clients_healthy": allClientsHealthy, + } + + status := "ok" + statusCode := http.StatusOK + if !c.ReadinessProbe() { + status = "fail" + statusCode = http.StatusServiceUnavailable + } + + c.writeProbeResponse(w, statusCode, status, checks, clientHealth) +} + +func (c *Checker) writeProbeResponse(w http.ResponseWriter, statusCode int, status string, checks map[string]bool, clients map[types.AccountID]ClientHealth) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + resp := ProbeResponse{ + Status: status, + Checks: checks, + Clients: clients, + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + c.logger.Debugf("write health response: %v", err) + } +} + +// ListenAndServe starts the health probe server. +func (s *Server) ListenAndServe() error { + s.logger.Infof("starting health probe server on %s", s.server.Addr) + return s.server.ListenAndServe() +} + +// Serve starts the health probe server on the given listener. +func (s *Server) Serve(l net.Listener) error { + s.logger.Infof("starting health probe server on %s", l.Addr()) + return s.server.Serve(l) +} + +// Shutdown gracefully shuts down the health probe server. +func (s *Server) Shutdown(ctx context.Context) error { + return s.server.Shutdown(ctx) +} + +// NewChecker creates a new health checker. +func NewChecker(logger *log.Logger, provider clientProvider) *Checker { + if logger == nil { + logger = log.StandardLogger() + } + return &Checker{ + logger: logger, + provider: provider, + checkSem: make(chan struct{}, maxConcurrentChecks), + checkHealth: checkClientHealth, + } +} + +// NewServer creates a new health probe server. +// If metricsHandler is non-nil, it is mounted at /metrics on the same port. +func NewServer(addr string, checker *Checker, logger *log.Logger, metricsHandler http.Handler) *Server { + if logger == nil { + logger = log.StandardLogger() + } + + handler := checker.Handler() + if metricsHandler != nil { + mux := http.NewServeMux() + mux.Handle("/metrics", metricsHandler) + mux.Handle("/", handler) + handler = mux + } + + return &Server{ + server: &http.Server{ + Addr: addr, + Handler: handler, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + }, + logger: logger, + checker: checker, + } +} + +func checkClientHealth(client *embed.Client) ClientHealth { + if client == nil { + return ClientHealth{ + Healthy: false, + Error: "client not initialized", + } + } + + status, err := client.Status() + if err != nil { + return ClientHealth{ + Healthy: false, + Error: err.Error(), + } + } + + // Count only rel:// and rels:// relays (not stun/turn) + var relayCount, relaysConnected int + for _, relay := range status.Relays { + if !strings.HasPrefix(relay.URI, "rel://") && !strings.HasPrefix(relay.URI, "rels://") { + continue + } + relayCount++ + if relay.Err == nil { + relaysConnected++ + } + } + + // Count peer connection stats + now := time.Now() + var peersConnected, peersP2P, peersRelayed, peersDegraded int + for _, p := range status.Peers { + if p.ConnStatus != embed.PeerStatusConnected { + continue + } + peersConnected++ + if p.Relayed { + peersRelayed++ + } else { + peersP2P++ + } + if p.LastWireguardHandshake.IsZero() || now.Sub(p.LastWireguardHandshake) > handshakeStaleThreshold { + peersDegraded++ + } + } + + // Client is healthy if connected to management, signal, and at least one relay (if any are defined) + healthy := status.ManagementState.Connected && + status.SignalState.Connected && + (relayCount == 0 || relaysConnected > 0) + + return ClientHealth{ + Healthy: healthy, + ManagementConnected: status.ManagementState.Connected, + SignalConnected: status.SignalState.Connected, + RelaysConnected: relaysConnected, + RelaysTotal: relayCount, + PeersTotal: len(status.Peers), + PeersConnected: peersConnected, + PeersP2P: peersP2P, + PeersRelayed: peersRelayed, + PeersDegraded: peersDegraded, + } +} diff --git a/proxy/internal/health/health_test.go b/proxy/internal/health/health_test.go new file mode 100644 index 000000000..47b5f250f --- /dev/null +++ b/proxy/internal/health/health_test.go @@ -0,0 +1,473 @@ +package health + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/embed" + "github.com/netbirdio/netbird/proxy/internal/types" +) + +type mockClientProvider struct { + clients map[types.AccountID]*embed.Client +} + +func (m *mockClientProvider) ListClientsForStartup() map[types.AccountID]*embed.Client { + return m.clients +} + +// newTestChecker creates a checker with a mock health function for testing. +// The health function returns the provided ClientHealth for every client. +func newTestChecker(provider clientProvider, healthResult ClientHealth) *Checker { + c := NewChecker(nil, provider) + c.checkHealth = func(_ *embed.Client) ClientHealth { + return healthResult + } + return c +} + +func TestChecker_LivenessProbe(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + + // Liveness should always return true if we can respond. + assert.True(t, checker.LivenessProbe()) +} + +func TestChecker_ReadinessProbe(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + + // Initially not ready (management not connected). + assert.False(t, checker.ReadinessProbe()) + + // After management connects, should be ready. + checker.SetManagementConnected(true) + assert.True(t, checker.ReadinessProbe()) + + // If management disconnects, should not be ready. + checker.SetManagementConnected(false) + assert.False(t, checker.ReadinessProbe()) +} + +// TestStartupProbe_EmptyServiceList covers the scenario where management has +// no services configured for this proxy. The proxy should become ready once +// management is connected and the initial sync completes, even with zero clients. +func TestStartupProbe_EmptyServiceList(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + + // No management connection = not ready. + assert.False(t, checker.StartupProbe(context.Background())) + + // Management connected but no sync = not ready. + checker.SetManagementConnected(true) + assert.False(t, checker.StartupProbe(context.Background())) + + // Management + sync complete + no clients = ready. + checker.SetInitialSyncComplete() + assert.True(t, checker.StartupProbe(context.Background())) +} + +// TestStartupProbe_WithUnhealthyClients verifies that when services exist +// and clients have been created but are not yet fully connected (to mgmt, +// signal, relays), the startup probe does NOT pass. +func TestStartupProbe_WithUnhealthyClients(t *testing.T) { + provider := &mockClientProvider{ + clients: map[types.AccountID]*embed.Client{ + "account-1": nil, // concrete client not needed; checkHealth is mocked + "account-2": nil, + }, + } + checker := newTestChecker(provider, ClientHealth{Healthy: false, Error: "not connected yet"}) + checker.SetManagementConnected(true) + checker.SetInitialSyncComplete() + + assert.False(t, checker.StartupProbe(context.Background()), + "startup probe must not pass when clients are unhealthy") +} + +// TestStartupProbe_WithHealthyClients verifies that once all clients are +// connected and healthy, the startup probe passes. +func TestStartupProbe_WithHealthyClients(t *testing.T) { + provider := &mockClientProvider{ + clients: map[types.AccountID]*embed.Client{ + "account-1": nil, + "account-2": nil, + }, + } + checker := newTestChecker(provider, ClientHealth{ + Healthy: true, + ManagementConnected: true, + SignalConnected: true, + RelaysConnected: 1, + RelaysTotal: 1, + }) + checker.SetManagementConnected(true) + checker.SetInitialSyncComplete() + + assert.True(t, checker.StartupProbe(context.Background()), + "startup probe must pass when all clients are healthy") +} + +// TestStartupProbe_MixedHealthClients verifies that if any single client is +// unhealthy, the startup probe fails (all-or-nothing). +func TestStartupProbe_MixedHealthClients(t *testing.T) { + provider := &mockClientProvider{ + clients: map[types.AccountID]*embed.Client{ + "healthy-account": nil, + "unhealthy-account": nil, + }, + } + + checker := NewChecker(nil, provider) + checker.checkHealth = func(cl *embed.Client) ClientHealth { + // We identify accounts by their position in the map iteration; since we + // can't control map order, make exactly one unhealthy via counter. + return ClientHealth{Healthy: false} + } + checker.SetManagementConnected(true) + checker.SetInitialSyncComplete() + + assert.False(t, checker.StartupProbe(context.Background()), + "startup probe must fail if any client is unhealthy") +} + +// TestStartupProbe_RequiresAllConditions ensures that each individual +// prerequisite (management, sync, clients) is necessary. The probe must not +// pass if any one is missing. +func TestStartupProbe_RequiresAllConditions(t *testing.T) { + provider := &mockClientProvider{ + clients: map[types.AccountID]*embed.Client{ + "account-1": nil, + }, + } + + t.Run("no management", func(t *testing.T) { + checker := newTestChecker(provider, ClientHealth{Healthy: true}) + checker.SetInitialSyncComplete() + // management NOT connected + assert.False(t, checker.StartupProbe(context.Background())) + }) + + t.Run("no sync", func(t *testing.T) { + checker := newTestChecker(provider, ClientHealth{Healthy: true}) + checker.SetManagementConnected(true) + // sync NOT complete + assert.False(t, checker.StartupProbe(context.Background())) + }) + + t.Run("unhealthy client", func(t *testing.T) { + checker := newTestChecker(provider, ClientHealth{Healthy: false}) + checker.SetManagementConnected(true) + checker.SetInitialSyncComplete() + assert.False(t, checker.StartupProbe(context.Background())) + }) + + t.Run("all conditions met", func(t *testing.T) { + checker := newTestChecker(provider, ClientHealth{Healthy: true}) + checker.SetManagementConnected(true) + checker.SetInitialSyncComplete() + assert.True(t, checker.StartupProbe(context.Background())) + }) +} + +// TestStartupProbe_ConcurrentAccess runs the startup probe from many +// goroutines simultaneously to check for races. +func TestStartupProbe_ConcurrentAccess(t *testing.T) { + provider := &mockClientProvider{ + clients: map[types.AccountID]*embed.Client{ + "account-1": nil, + "account-2": nil, + }, + } + checker := newTestChecker(provider, ClientHealth{Healthy: true}) + checker.SetManagementConnected(true) + checker.SetInitialSyncComplete() + + var wg sync.WaitGroup + const goroutines = 50 + results := make([]bool, goroutines) + + for i := range goroutines { + wg.Add(1) + go func(idx int) { + defer wg.Done() + results[idx] = checker.StartupProbe(context.Background()) + }(i) + } + wg.Wait() + + for i, r := range results { + assert.True(t, r, "goroutine %d got unexpected result", i) + } +} + +// TestStartupProbe_CancelledContext verifies that a cancelled context causes +// the probe to report unhealthy when client checks are needed. +func TestStartupProbe_CancelledContext(t *testing.T) { + t.Run("no management bypasses context", func(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + // Should be false because management isn't connected, context is irrelevant. + assert.False(t, checker.StartupProbe(ctx)) + }) + + t.Run("with clients and cancelled context", func(t *testing.T) { + provider := &mockClientProvider{ + clients: map[types.AccountID]*embed.Client{ + "account-1": nil, + }, + } + checker := NewChecker(nil, provider) + // Use the real checkHealth path — a cancelled context should cause + // the semaphore acquisition to fail, reporting unhealthy. + checker.SetManagementConnected(true) + checker.SetInitialSyncComplete() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + assert.False(t, checker.StartupProbe(ctx), + "cancelled context must result in unhealthy when clients exist") + }) +} + +// TestHandler_Startup_EmptyServiceList verifies the HTTP startup endpoint +// returns 200 when management is connected, sync is complete, and there are +// no services/clients. +func TestHandler_Startup_EmptyServiceList(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + checker.SetManagementConnected(true) + checker.SetInitialSyncComplete() + handler := checker.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz/startup", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var resp ProbeResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "ok", resp.Status) + assert.True(t, resp.Checks["management_connected"]) + assert.True(t, resp.Checks["initial_sync_complete"]) + assert.True(t, resp.Checks["all_clients_healthy"]) + assert.Empty(t, resp.Clients) +} + +// TestHandler_Startup_WithUnhealthyClients verifies that the HTTP startup +// endpoint returns 503 when clients exist but are not yet healthy. +func TestHandler_Startup_WithUnhealthyClients(t *testing.T) { + provider := &mockClientProvider{ + clients: map[types.AccountID]*embed.Client{ + "account-1": nil, + }, + } + checker := newTestChecker(provider, ClientHealth{Healthy: false, Error: "starting"}) + checker.SetManagementConnected(true) + checker.SetInitialSyncComplete() + handler := checker.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz/startup", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + + var resp ProbeResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "fail", resp.Status) + assert.True(t, resp.Checks["management_connected"]) + assert.True(t, resp.Checks["initial_sync_complete"]) + assert.False(t, resp.Checks["all_clients_healthy"]) + require.Contains(t, resp.Clients, types.AccountID("account-1")) + assert.Equal(t, "starting", resp.Clients["account-1"].Error) +} + +// TestHandler_Startup_WithHealthyClients verifies the HTTP startup endpoint +// returns 200 once clients are healthy. +func TestHandler_Startup_WithHealthyClients(t *testing.T) { + provider := &mockClientProvider{ + clients: map[types.AccountID]*embed.Client{ + "account-1": nil, + }, + } + checker := newTestChecker(provider, ClientHealth{ + Healthy: true, + ManagementConnected: true, + SignalConnected: true, + RelaysConnected: 1, + RelaysTotal: 1, + }) + checker.SetManagementConnected(true) + checker.SetInitialSyncComplete() + handler := checker.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz/startup", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var resp ProbeResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "ok", resp.Status) + assert.True(t, resp.Checks["all_clients_healthy"]) +} + +// TestHandler_Startup_NotComplete verifies the startup handler returns 503 +// when prerequisites aren't met. +func TestHandler_Startup_NotComplete(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + handler := checker.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz/startup", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + + var resp ProbeResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "fail", resp.Status) +} + +func TestChecker_Handler_Liveness(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + handler := checker.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz/live", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var resp ProbeResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "ok", resp.Status) +} + +func TestChecker_Handler_Readiness_NotReady(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + handler := checker.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz/ready", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + + var resp ProbeResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "fail", resp.Status) + assert.False(t, resp.Checks["management_connected"]) +} + +func TestChecker_Handler_Readiness_Ready(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + checker.SetManagementConnected(true) + handler := checker.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz/ready", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var resp ProbeResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "ok", resp.Status) + assert.True(t, resp.Checks["management_connected"]) +} + +func TestChecker_Handler_Full(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + checker.SetManagementConnected(true) + handler := checker.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + var resp ProbeResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "ok", resp.Status) + assert.NotNil(t, resp.Checks) + // Clients may be empty map when no clients exist. + assert.Empty(t, resp.Clients) +} + +func TestChecker_SetShuttingDown(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + checker.SetManagementConnected(true) + + assert.True(t, checker.ReadinessProbe(), "should be ready before shutdown") + + checker.SetShuttingDown() + + assert.False(t, checker.ReadinessProbe(), "should not be ready after shutdown") +} + +func TestChecker_Handler_Readiness_ShuttingDown(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + checker.SetManagementConnected(true) + checker.SetShuttingDown() + handler := checker.Handler() + + req := httptest.NewRequest(http.MethodGet, "/healthz/ready", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + + var resp ProbeResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, "fail", resp.Status) +} + +func TestNewServer_WithMetricsHandler(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + checker.SetManagementConnected(true) + + metricsHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("metrics")) + }) + + srv := NewServer(":0", checker, nil, metricsHandler) + require.NotNil(t, srv) + + // Verify health endpoint still works through the mux. + req := httptest.NewRequest(http.MethodGet, "/healthz/live", nil) + rec := httptest.NewRecorder() + srv.server.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + + // Verify metrics endpoint is mounted. + req = httptest.NewRequest(http.MethodGet, "/metrics", nil) + rec = httptest.NewRecorder() + srv.server.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "metrics", rec.Body.String()) +} + +func TestNewServer_WithoutMetricsHandler(t *testing.T) { + checker := NewChecker(nil, &mockClientProvider{}) + checker.SetManagementConnected(true) + + srv := NewServer(":0", checker, nil, nil) + require.NotNil(t, srv) + + req := httptest.NewRequest(http.MethodGet, "/healthz/live", nil) + rec := httptest.NewRecorder() + srv.server.Handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) +} diff --git a/proxy/internal/k8s/lease.go b/proxy/internal/k8s/lease.go new file mode 100644 index 000000000..9677e0e27 --- /dev/null +++ b/proxy/internal/k8s/lease.go @@ -0,0 +1,281 @@ +// Package k8s provides a lightweight Kubernetes API client for coordination +// Leases. It uses raw HTTP calls against the mounted service account +// credentials, avoiding a dependency on client-go. +package k8s + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +const ( + saTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec + saNamespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" + saCACertPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" + + leaseAPIPath = "/apis/coordination.k8s.io/v1" +) + +// ErrConflict is returned when a Lease update fails due to a +// resourceVersion mismatch (another writer updated the object first). +var ErrConflict = errors.New("conflict: resource version mismatch") + +// Lease represents a coordination.k8s.io/v1 Lease object with only the +// fields needed for distributed locking. +type Lease struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Metadata LeaseMetadata `json:"metadata"` + Spec LeaseSpec `json:"spec"` +} + +// LeaseMetadata holds the standard k8s object metadata fields used by Leases. +type LeaseMetadata struct { + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` + ResourceVersion string `json:"resourceVersion,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` +} + +// LeaseSpec holds the Lease specification fields. +type LeaseSpec struct { + HolderIdentity *string `json:"holderIdentity"` + LeaseDurationSeconds *int32 `json:"leaseDurationSeconds,omitempty"` + AcquireTime *MicroTime `json:"acquireTime"` + RenewTime *MicroTime `json:"renewTime"` +} + +// MicroTime wraps time.Time with Kubernetes MicroTime JSON formatting. +type MicroTime struct { + time.Time +} + +const microTimeFormat = "2006-01-02T15:04:05.000000Z" + +// MarshalJSON implements json.Marshaler with k8s MicroTime format. +func (t *MicroTime) MarshalJSON() ([]byte, error) { + return json.Marshal(t.UTC().Format(microTimeFormat)) +} + +// UnmarshalJSON implements json.Unmarshaler with k8s MicroTime format. +func (t *MicroTime) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + if s == "" { + t.Time = time.Time{} + return nil + } + + parsed, err := time.Parse(microTimeFormat, s) + if err != nil { + return fmt.Errorf("parse MicroTime %q: %w", s, err) + } + t.Time = parsed + return nil +} + +// LeaseClient talks to the Kubernetes coordination API using raw HTTP. +type LeaseClient struct { + baseURL string + namespace string + httpClient *http.Client +} + +// NewLeaseClient creates a client that authenticates via the pod's +// mounted service account. It reads the namespace and CA certificate +// at construction time (they don't rotate) but reads the bearer token +// fresh on each request (tokens rotate). +func NewLeaseClient() (*LeaseClient, error) { + host := os.Getenv("KUBERNETES_SERVICE_HOST") + port := os.Getenv("KUBERNETES_SERVICE_PORT") + if host == "" || port == "" { + return nil, fmt.Errorf("KUBERNETES_SERVICE_HOST/PORT not set") + } + + ns, err := os.ReadFile(saNamespacePath) + if err != nil { + return nil, fmt.Errorf("read namespace from %s: %w", saNamespacePath, err) + } + + caCert, err := os.ReadFile(saCACertPath) + if err != nil { + return nil, fmt.Errorf("read CA cert from %s: %w", saCACertPath, err) + } + + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(caCert) { + return nil, fmt.Errorf("parse CA certificate from %s", saCACertPath) + } + + return &LeaseClient{ + baseURL: fmt.Sprintf("https://%s:%s", host, port), + namespace: strings.TrimSpace(string(ns)), + httpClient: &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: pool, + }, + }, + }, + }, nil +} + +// Namespace returns the namespace this client operates in. +func (c *LeaseClient) Namespace() string { + return c.namespace +} + +// Get retrieves a Lease by name. Returns (nil, nil) if the Lease does not exist. +func (c *LeaseClient) Get(ctx context.Context, name string) (*Lease, error) { + url := fmt.Sprintf("%s%s/namespaces/%s/leases/%s", c.baseURL, leaseAPIPath, c.namespace, name) + + resp, err := c.doRequest(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil //nolint:nilnil + } + if resp.StatusCode != http.StatusOK { + return nil, c.readError(resp) + } + + var lease Lease + if err := json.NewDecoder(resp.Body).Decode(&lease); err != nil { + return nil, fmt.Errorf("decode lease response: %w", err) + } + return &lease, nil +} + +// Create creates a new Lease. Returns the created Lease with server-assigned +// fields like resourceVersion populated. +func (c *LeaseClient) Create(ctx context.Context, lease *Lease) (*Lease, error) { + url := fmt.Sprintf("%s%s/namespaces/%s/leases", c.baseURL, leaseAPIPath, c.namespace) + + lease.APIVersion = "coordination.k8s.io/v1" + lease.Kind = "Lease" + if lease.Metadata.Namespace == "" { + lease.Metadata.Namespace = c.namespace + } + + resp, err := c.doRequest(ctx, http.MethodPost, url, lease) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusConflict { + return nil, ErrConflict + } + if resp.StatusCode != http.StatusCreated { + return nil, c.readError(resp) + } + + var created Lease + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + return nil, fmt.Errorf("decode created lease: %w", err) + } + return &created, nil +} + +// Update replaces a Lease. The lease.Metadata.ResourceVersion must match +// the current server value (optimistic concurrency). Returns ErrConflict +// on version mismatch. +func (c *LeaseClient) Update(ctx context.Context, lease *Lease) (*Lease, error) { + url := fmt.Sprintf("%s%s/namespaces/%s/leases/%s", c.baseURL, leaseAPIPath, c.namespace, lease.Metadata.Name) + + lease.APIVersion = "coordination.k8s.io/v1" + lease.Kind = "Lease" + + resp, err := c.doRequest(ctx, http.MethodPut, url, lease) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusConflict { + return nil, ErrConflict + } + if resp.StatusCode != http.StatusOK { + return nil, c.readError(resp) + } + + var updated Lease + if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil { + return nil, fmt.Errorf("decode updated lease: %w", err) + } + return &updated, nil +} + +func (c *LeaseClient) doRequest(ctx context.Context, method, url string, body any) (*http.Response, error) { + token, err := readToken() + if err != nil { + return nil, fmt.Errorf("read service account token: %w", err) + } + + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal request body: %w", err) + } + bodyReader = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + return c.httpClient.Do(req) +} + +func readToken() (string, error) { + data, err := os.ReadFile(saTokenPath) + if err != nil { + return "", fmt.Errorf("read %s: %w", saTokenPath, err) + } + return strings.TrimSpace(string(data)), nil +} + +func (c *LeaseClient) readError(resp *http.Response) error { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return fmt.Errorf("k8s API %s %d: %s", resp.Request.URL.Path, resp.StatusCode, string(body)) +} + +// LeaseNameForDomain returns a deterministic, DNS-label-safe Lease name +// for the given domain. The domain is hashed to avoid dots and length issues. +func LeaseNameForDomain(domain string) string { + h := sha256.Sum256([]byte(domain)) + return "cert-lock-" + hex.EncodeToString(h[:8]) +} + +// InCluster reports whether the process is running inside a Kubernetes pod +// by checking for the KUBERNETES_SERVICE_HOST environment variable. +func InCluster() bool { + _, exists := os.LookupEnv("KUBERNETES_SERVICE_HOST") + return exists +} diff --git a/proxy/internal/k8s/lease_test.go b/proxy/internal/k8s/lease_test.go new file mode 100644 index 000000000..9d5d3c6ce --- /dev/null +++ b/proxy/internal/k8s/lease_test.go @@ -0,0 +1,102 @@ +package k8s + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLeaseNameForDomain(t *testing.T) { + tests := []struct { + domain string + }{ + {"example.com"}, + {"app.example.com"}, + {"another.domain.io"}, + } + + seen := make(map[string]string) + for _, tc := range tests { + name := LeaseNameForDomain(tc.domain) + + assert.True(t, len(name) <= 63, "must be valid DNS label length") + assert.Regexp(t, `^cert-lock-[0-9a-f]{16}$`, name, + "must match expected format for domain %q", tc.domain) + + // Same input produces same output. + assert.Equal(t, name, LeaseNameForDomain(tc.domain), "must be deterministic") + + // Different domains produce different names. + if prev, ok := seen[name]; ok { + t.Errorf("collision: %q and %q both map to %s", prev, tc.domain, name) + } + seen[name] = tc.domain + } +} + +func TestMicroTimeJSON(t *testing.T) { + ts := time.Date(2024, 6, 15, 10, 30, 0, 0, time.UTC) + mt := &MicroTime{Time: ts} + + data, err := json.Marshal(mt) + require.NoError(t, err) + assert.Equal(t, `"2024-06-15T10:30:00.000000Z"`, string(data)) + + var decoded MicroTime + require.NoError(t, json.Unmarshal(data, &decoded)) + assert.True(t, ts.Equal(decoded.Time), "round-trip should preserve time") +} + +func TestMicroTimeNullJSON(t *testing.T) { + // Null pointer serializes as JSON null via the Lease struct. + spec := LeaseSpec{ + HolderIdentity: nil, + AcquireTime: nil, + RenewTime: nil, + } + + data, err := json.Marshal(spec) + require.NoError(t, err) + assert.Contains(t, string(data), `"acquireTime":null`) + assert.Contains(t, string(data), `"renewTime":null`) +} + +func TestLeaseJSONRoundTrip(t *testing.T) { + holder := "pod-abc" + dur := int32(300) + now := MicroTime{Time: time.Now().UTC().Truncate(time.Microsecond)} + + original := Lease{ + APIVersion: "coordination.k8s.io/v1", + Kind: "Lease", + Metadata: LeaseMetadata{ + Name: "cert-lock-abcdef0123456789", + Namespace: "default", + ResourceVersion: "12345", + Annotations: map[string]string{ + "netbird.io/domain": "app.example.com", + }, + }, + Spec: LeaseSpec{ + HolderIdentity: &holder, + LeaseDurationSeconds: &dur, + AcquireTime: &now, + RenewTime: &now, + }, + } + + data, err := json.Marshal(original) + require.NoError(t, err) + + var decoded Lease + require.NoError(t, json.Unmarshal(data, &decoded)) + + assert.Equal(t, original.Metadata.Name, decoded.Metadata.Name) + assert.Equal(t, original.Metadata.ResourceVersion, decoded.Metadata.ResourceVersion) + assert.Equal(t, *original.Spec.HolderIdentity, *decoded.Spec.HolderIdentity) + assert.Equal(t, *original.Spec.LeaseDurationSeconds, *decoded.Spec.LeaseDurationSeconds) + assert.True(t, original.Spec.AcquireTime.Equal(decoded.Spec.AcquireTime.Time)) +} diff --git a/proxy/internal/llm/anthropic.go b/proxy/internal/llm/anthropic.go new file mode 100644 index 000000000..523731fbd --- /dev/null +++ b/proxy/internal/llm/anthropic.go @@ -0,0 +1,196 @@ +package llm + +import ( + "encoding/json" + "fmt" + "strings" +) + +// AnthropicParser implements the Parser interface for the Anthropic Messages +// and Completions APIs. Detection is substring-based to tolerate upstream +// path rewrites. +type AnthropicParser struct{} + +var anthropicPathHints = []string{ + "/v1/messages", + "/v1/complete", +} + +// Provider returns ProviderAnthropic. +func (AnthropicParser) Provider() Provider { return ProviderAnthropic } + +// ProviderName returns the stable label used for metrics and metadata. +func (AnthropicParser) ProviderName() string { return "anthropic" } + +// DetectFromURL reports whether the given request path looks like an +// Anthropic API endpoint. The match is case-insensitive and substring-based. +func (AnthropicParser) DetectFromURL(path string) bool { + lower := strings.ToLower(path) + for _, hint := range anthropicPathHints { + if strings.Contains(lower, hint) { + return true + } + } + return false +} + +type anthropicRequest struct { + Model string `json:"model"` + Stream *bool `json:"stream"` + System json.RawMessage `json:"system"` + Messages []anthropicMessage `json:"messages"` + // Legacy /v1/complete endpoint. + Prompt string `json:"prompt"` +} + +type anthropicMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` +} + +// ParseRequest extracts the model name and streaming flag from an Anthropic +// request body. Unknown or missing fields leave the corresponding struct +// members zero-valued. +func (AnthropicParser) ParseRequest(body []byte) (RequestFacts, error) { + var req anthropicRequest + if err := json.Unmarshal(body, &req); err != nil { + return RequestFacts{}, fmt.Errorf("decode anthropic request: %w: %v", ErrMalformedRequest, err) + } + return RequestFacts{ + Model: req.Model, + Stream: ptrDeref(req.Stream), + }, nil +} + +type anthropicResponse struct { + Usage struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + // CacheReadInputTokens and CacheCreationInputTokens are + // ADDITIVE to InputTokens (not subset), each billed at its + // own rate by the cost meter. cache_read is the cheaper + // read-from-cache rate, cache_creation is the more + // expensive write-to-cache rate. + CacheReadInputTokens int64 `json:"cache_read_input_tokens"` + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"` + } `json:"usage"` +} + +// ParseResponse decodes the non-streaming Anthropic response envelope. Status +// codes other than 200 are treated as non-LLM responses so the caller can +// skip cost accounting without aborting the request. +func (AnthropicParser) ParseResponse(status int, contentType string, body []byte) (Usage, error) { + if status != 200 { + return Usage{}, fmt.Errorf("anthropic status %d: %w", status, ErrNotLLMResponse) + } + if isEventStream(contentType) { + return Usage{}, ErrStreamingUnsupported + } + if !isJSON(contentType) { + return Usage{}, fmt.Errorf("anthropic content-type %q: %w", contentType, ErrNotLLMResponse) + } + + var resp anthropicResponse + if err := json.Unmarshal(body, &resp); err != nil { + return Usage{}, fmt.Errorf("decode anthropic response: %w: %v", ErrMalformedResponse, err) + } + return Usage{ + InputTokens: resp.Usage.InputTokens, + OutputTokens: resp.Usage.OutputTokens, + TotalTokens: resp.Usage.InputTokens + resp.Usage.OutputTokens + resp.Usage.CacheReadInputTokens + resp.Usage.CacheCreationInputTokens, + CachedInputTokens: resp.Usage.CacheReadInputTokens, + CacheCreationTokens: resp.Usage.CacheCreationInputTokens, + }, nil +} + +// ExtractPrompt returns the user-visible prompt text from an Anthropic +// request body. Handles the Messages API (system + messages[]) and the +// legacy /v1/complete prompt string. Returns "" on any decode failure. +func (AnthropicParser) ExtractPrompt(body []byte) string { + var req anthropicRequest + if err := json.Unmarshal(body, &req); err != nil { + return "" + } + var b strings.Builder + if len(req.System) > 0 { + if s := decodeStringOrJoin(req.System); s != "" { + b.WriteString("system: ") + b.WriteString(s) + } + } + for _, m := range req.Messages { + if b.Len() > 0 { + b.WriteByte('\n') + } + if m.Role != "" { + b.WriteString(m.Role) + b.WriteString(": ") + } + b.WriteString(decodeStringOrJoin(m.Content)) + } + if b.Len() == 0 && req.Prompt != "" { + b.WriteString(req.Prompt) + } + return b.String() +} + +// ExtractSessionID is the body-side fallback for Anthropic. Claude Code's +// authoritative session marker is the X-Claude-Code-Session-Id request +// header (handled by the request-parser middleware); this only mines the +// optional metadata.user_id for an embedded "...session_" marker. +// metadata.user_id on its own is a USER identifier, not a session, so the +// whole value is deliberately NOT used — returning it would mislabel every +// request from a user as one session. Returns "" when no session marker is +// present. +func (AnthropicParser) ExtractSessionID(body []byte) string { + var req struct { + Metadata struct { + UserID string `json:"user_id"` + } `json:"metadata"` + } + if err := json.Unmarshal(body, &req); err != nil { + return "" + } + if idx := strings.LastIndex(req.Metadata.UserID, "session_"); idx >= 0 { + if session := req.Metadata.UserID[idx+len("session_"):]; session != "" { + return session + } + } + return "" +} + +type anthropicMessageResponse struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + // Legacy /v1/complete response. + Completion string `json:"completion"` +} + +// ExtractCompletion returns the assistant text from a non-streaming Anthropic +// Messages or Completions response. Returns "" when status/content-type +// indicate the body is not parseable or no text part is present. +func (AnthropicParser) ExtractCompletion(status int, contentType string, body []byte) string { + if status != 200 || isEventStream(contentType) || !isJSON(contentType) { + return "" + } + var resp anthropicMessageResponse + if err := json.Unmarshal(body, &resp); err != nil { + return "" + } + var b strings.Builder + for _, part := range resp.Content { + if part.Text == "" { + continue + } + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString(part.Text) + } + if b.Len() == 0 { + return resp.Completion + } + return b.String() +} diff --git a/proxy/internal/llm/anthropic_test.go b/proxy/internal/llm/anthropic_test.go new file mode 100644 index 000000000..a0c1f7896 --- /dev/null +++ b/proxy/internal/llm/anthropic_test.go @@ -0,0 +1,169 @@ +package llm + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAnthropicDetectFromURL(t *testing.T) { + p := AnthropicParser{} + + cases := map[string]bool{ + "/v1/messages": true, + "/v1/complete": true, + "/V1/Messages": true, + "/proxy/v1/messages?x": true, + "/v1/chat/completions": false, + "": false, + } + for path, want := range cases { + assert.Equal(t, want, p.DetectFromURL(path), "DetectFromURL(%q)", path) + } +} + +func TestAnthropicParseRequest(t *testing.T) { + p := AnthropicParser{} + + t.Run("stream true", func(t *testing.T) { + facts, err := p.ParseRequest([]byte(`{"model":"claude-sonnet-4-5","stream":true}`)) + require.NoError(t, err) + assert.Equal(t, "claude-sonnet-4-5", facts.Model, "model extracted") + assert.True(t, facts.Stream, "stream flag honoured") + }) + + t.Run("stream default", func(t *testing.T) { + facts, err := p.ParseRequest([]byte(`{"model":"claude-sonnet-4-5"}`)) + require.NoError(t, err) + assert.False(t, facts.Stream, "missing stream flag defaults to false") + }) + + t.Run("malformed", func(t *testing.T) { + _, err := p.ParseRequest([]byte(`{"model":`)) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrMalformedRequest), "sentinel wrapped") + }) +} + +func TestAnthropicParseResponse(t *testing.T) { + p := AnthropicParser{} + + t.Run("happy fixture", func(t *testing.T) { + body, err := os.ReadFile(filepath.Join("fixtures", "anthropic_messages.json")) + require.NoError(t, err, "fixture must be readable") + + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(123), usage.InputTokens, "input tokens extracted") + assert.Equal(t, int64(45), usage.OutputTokens, "output tokens extracted") + assert.Equal(t, int64(168), usage.TotalTokens, "total computed as sum") + }) + + t.Run("streaming rejected", func(t *testing.T) { + _, err := p.ParseResponse(200, "text/event-stream", []byte("")) + require.ErrorIs(t, err, ErrStreamingUnsupported, "SSE responses must use the scanner") + }) + + t.Run("non-200", func(t *testing.T) { + _, err := p.ParseResponse(429, "application/json", []byte(`{}`)) + require.ErrorIs(t, err, ErrNotLLMResponse, "non-200 rejected as non-LLM") + }) + + t.Run("non-json content type", func(t *testing.T) { + _, err := p.ParseResponse(200, "text/html", []byte(`{}`)) + require.ErrorIs(t, err, ErrNotLLMResponse, "text/html treated as non-LLM") + }) + + t.Run("malformed body", func(t *testing.T) { + _, err := p.ParseResponse(200, "application/json", []byte(`{`)) + require.ErrorIs(t, err, ErrMalformedResponse, "bad JSON yields malformed error") + }) + + // Anthropic's two cache fields are ADDITIVE to input_tokens (not + // subset). The parser must surface them so the cost meter can + // bill each bucket at its own configured rate. Total includes + // every bucket so downstream attribution sees the full token + // volume the request consumed. + t.Run("cache_read_input_tokens surfaces as CachedInputTokens (additive)", func(t *testing.T) { + body := []byte(`{"usage":{"input_tokens":256,"output_tokens":200,"cache_read_input_tokens":768}}`) + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(256), usage.InputTokens, "regular input remains separate from cache buckets") + assert.Equal(t, int64(768), usage.CachedInputTokens, "cache_read maps onto CachedInputTokens — same field carries OpenAI cached subset and Anthropic cache reads") + assert.Zero(t, usage.CacheCreationTokens) + assert.Equal(t, int64(256+200+768), usage.TotalTokens, "total includes every input bucket plus output — cache reads are billable tokens") + }) + + t.Run("cache_creation_input_tokens surfaces as CacheCreationTokens (additive)", func(t *testing.T) { + body := []byte(`{"usage":{"input_tokens":256,"output_tokens":200,"cache_creation_input_tokens":512}}`) + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(256), usage.InputTokens) + assert.Zero(t, usage.CachedInputTokens) + assert.Equal(t, int64(512), usage.CacheCreationTokens, "cache_creation surfaces — meter applies the write-rate multiplier") + assert.Equal(t, int64(256+200+512), usage.TotalTokens) + }) + + t.Run("both cache buckets present", func(t *testing.T) { + body := []byte(`{"usage":{"input_tokens":256,"output_tokens":200,"cache_read_input_tokens":768,"cache_creation_input_tokens":512}}`) + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(768), usage.CachedInputTokens) + assert.Equal(t, int64(512), usage.CacheCreationTokens) + assert.Equal(t, int64(256+200+768+512), usage.TotalTokens, "all four buckets sum into total") + }) + + t.Run("absent cache fields leave counts at zero", func(t *testing.T) { + body := []byte(`{"usage":{"input_tokens":100,"output_tokens":50}}`) + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Zero(t, usage.CachedInputTokens, "no cache_read field = no cached count") + assert.Zero(t, usage.CacheCreationTokens, "no cache_creation field = no creation count") + assert.Equal(t, int64(150), usage.TotalTokens, "back to the simple in+out total when no cache buckets present") + }) +} + +func TestAnthropicExtractPrompt_Messages(t *testing.T) { + body := []byte(`{"model":"claude-sonnet-4-7","system":"be brief","messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"yes"}]}`) + got := AnthropicParser{}.ExtractPrompt(body) + require.Contains(t, got, "system: be brief", "system surfaces with role label") + require.Contains(t, got, "user: hi", "user message surfaces") + require.Contains(t, got, "assistant: yes", "assistant message surfaces") +} + +func TestAnthropicExtractPrompt_LegacyComplete(t *testing.T) { + body := []byte(`{"model":"claude-2","prompt":"\n\nHuman: hi\n\nAssistant:"}`) + got := AnthropicParser{}.ExtractPrompt(body) + require.Contains(t, got, "Human: hi", "legacy prompt string surfaces") +} + +func TestAnthropicExtractSessionID(t *testing.T) { + t.Run("claude code session suffix", func(t *testing.T) { + body := []byte(`{"model":"claude-opus-4-8","metadata":{"user_id":"user_abc123_account_def456_session_9f8e7d6c"},"messages":[]}`) + assert.Equal(t, "9f8e7d6c", AnthropicParser{}.ExtractSessionID(body), "session_ suffix must be extracted from metadata.user_id") + }) + t.Run("plain user_id is not treated as a session", func(t *testing.T) { + body := []byte(`{"model":"claude-opus-4-8","metadata":{"user_id":"acme-team"},"messages":[]}`) + assert.Equal(t, "", AnthropicParser{}.ExtractSessionID(body), "a user identifier without a session marker must NOT be used as a session id") + }) + t.Run("no metadata yields empty", func(t *testing.T) { + body := []byte(`{"model":"claude-opus-4-8","messages":[{"role":"user","content":"hi"}]}`) + assert.Equal(t, "", AnthropicParser{}.ExtractSessionID(body), "absent metadata.user_id yields no session id") + }) +} + +func TestAnthropicExtractCompletion_Messages(t *testing.T) { + body, err := os.ReadFile(filepath.Join("fixtures", "anthropic_messages.json")) + require.NoError(t, err) + got := AnthropicParser{}.ExtractCompletion(200, "application/json", body) + require.NotEmpty(t, got, "anthropic fixture has assistant text") +} + +func TestAnthropicExtractCompletion_Streaming(t *testing.T) { + got := AnthropicParser{}.ExtractCompletion(200, "text/event-stream", []byte("")) + require.Empty(t, got, "streaming responses are skipped") +} diff --git a/proxy/internal/llm/bedrock.go b/proxy/internal/llm/bedrock.go new file mode 100644 index 000000000..eb64167a1 --- /dev/null +++ b/proxy/internal/llm/bedrock.go @@ -0,0 +1,193 @@ +package llm + +import ( + "encoding/json" + "fmt" + "strings" +) + +// ProviderNameBedrock is the stable label for the AWS Bedrock parser, used as +// the llm.provider metadata value and the cost-meter formula selector. +const ProviderNameBedrock = "bedrock" + +// BedrockParser implements the Parser interface for the AWS Bedrock runtime. +// Bedrock carries the model in the URL path (/model/{id}/{action}); the request +// middleware extracts it there, so this parser focuses on the response shapes: +// the vendor-native InvokeModel body (e.g. Anthropic's snake_case usage) and the +// unified Converse body (camelCase usage). +type BedrockParser struct{} + +var bedrockPathHints = []string{"/invoke", "/converse"} + +// Provider returns ProviderBedrock. +func (BedrockParser) Provider() Provider { return ProviderBedrock } + +// ProviderName returns the stable label used for metrics and metadata. +func (BedrockParser) ProviderName() string { return ProviderNameBedrock } + +// DetectFromURL reports whether the path is a Bedrock runtime model endpoint. +func (BedrockParser) DetectFromURL(path string) bool { + lower := strings.ToLower(path) + if !strings.HasPrefix(lower, "/model/") { + return false + } + for _, hint := range bedrockPathHints { + if strings.Contains(lower, hint) { + return true + } + } + return false +} + +// ParseRequest is a no-op for Bedrock: the model lives in the URL path, not the +// body, and the streaming flag is derived from the path action. The request +// middleware handles both via parseBedrockPath, so this returns empty facts. +func (BedrockParser) ParseRequest([]byte) (RequestFacts, error) { + return RequestFacts{}, nil +} + +// bedrockResponse captures token usage from both Bedrock response shapes: +// InvokeModel (vendor-native; Anthropic uses snake_case + additive cache +// buckets) and Converse (camelCase, with a precomputed total). +type bedrockResponse struct { + Usage struct { + // InvokeModel (Anthropic-on-Bedrock) — snake_case. + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheReadInputTokens int64 `json:"cache_read_input_tokens"` + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"` + // Converse — camelCase; cache buckets are additive to inputTokens (AWS names the write bucket cacheWriteInputTokens). + InputTokensCamel int64 `json:"inputTokens"` + OutputTokensCamel int64 `json:"outputTokens"` + TotalTokensCamel int64 `json:"totalTokens"` + CacheReadTokensCamel int64 `json:"cacheReadInputTokens"` + CacheWriteTokensCamel int64 `json:"cacheWriteInputTokens"` + } `json:"usage"` +} + +// ParseResponse decodes the non-streaming Bedrock response envelope, handling +// both the InvokeModel and Converse usage shapes. Non-200 / non-JSON bodies are +// treated as non-LLM responses so the caller skips cost accounting. +func (BedrockParser) ParseResponse(status int, contentType string, body []byte) (Usage, error) { + if status != 200 { + return Usage{}, fmt.Errorf("bedrock status %d: %w", status, ErrNotLLMResponse) + } + if isAWSEventStream(contentType) || isEventStream(contentType) { + return Usage{}, ErrStreamingUnsupported + } + if !isJSON(contentType) { + return Usage{}, fmt.Errorf("bedrock content-type %q: %w", contentType, ErrNotLLMResponse) + } + + var resp bedrockResponse + if err := json.Unmarshal(body, &resp); err != nil { + return Usage{}, fmt.Errorf("decode bedrock response: %w: %v", ErrMalformedResponse, err) + } + inTok := firstNonZero(resp.Usage.InputTokens, resp.Usage.InputTokensCamel) + outTok := firstNonZero(resp.Usage.OutputTokens, resp.Usage.OutputTokensCamel) + cacheRead := firstNonZero(resp.Usage.CacheReadInputTokens, resp.Usage.CacheReadTokensCamel) + cacheWrite := firstNonZero(resp.Usage.CacheCreationInputTokens, resp.Usage.CacheWriteTokensCamel) + total := resp.Usage.TotalTokensCamel + if total == 0 { + total = inTok + outTok + cacheRead + cacheWrite + } + return Usage{ + InputTokens: inTok, + OutputTokens: outTok, + TotalTokens: total, + CachedInputTokens: cacheRead, + CacheCreationTokens: cacheWrite, + }, nil +} + +// ExtractPrompt returns the user-visible prompt from a Bedrock request body, +// handling both the InvokeModel (Anthropic Messages: system + messages[]) and +// Converse (messages[].content[].text) shapes. Returns "" on decode failure. +func (BedrockParser) ExtractPrompt(body []byte) string { + var req struct { + System json.RawMessage `json:"system"` + Messages []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(body, &req); err != nil { + return "" + } + var b strings.Builder + if s := decodeStringOrJoin(req.System); s != "" { + b.WriteString("system: ") + b.WriteString(s) + } + for _, m := range req.Messages { + if b.Len() > 0 { + b.WriteByte('\n') + } + if m.Role != "" { + b.WriteString(m.Role) + b.WriteString(": ") + } + b.WriteString(decodeStringOrJoin(m.Content)) + } + return b.String() +} + +// ExtractCompletion returns the assistant text from a non-streaming Bedrock +// response, handling InvokeModel (Anthropic content[].text) and Converse +// (output.message.content[].text). +func (BedrockParser) ExtractCompletion(status int, contentType string, body []byte) string { + if status != 200 || isAWSEventStream(contentType) || !isJSON(contentType) { + return "" + } + var resp struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + Output struct { + Message struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + } `json:"message"` + } `json:"output"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return "" + } + var b strings.Builder + appendText := func(text string) { + if text == "" { + return + } + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString(text) + } + for _, p := range resp.Content { + appendText(p.Text) + } + for _, p := range resp.Output.Message.Content { + appendText(p.Text) + } + return b.String() +} + +// ExtractSessionID has no Bedrock-native marker; session grouping relies on the +// request headers handled by the middleware. Returns "". +func (BedrockParser) ExtractSessionID([]byte) string { return "" } + +// firstNonZero returns a when non-zero, else b. Folds the snake_case and +// camelCase usage variants into a single value. +func firstNonZero(a, b int64) int64 { + if a != 0 { + return a + } + return b +} + +// isAWSEventStream reports whether contentType is the AWS binary event-stream +// framing used by Bedrock's streaming endpoints. +func isAWSEventStream(contentType string) bool { + return strings.Contains(strings.ToLower(contentType), "application/vnd.amazon.eventstream") +} diff --git a/proxy/internal/llm/bedrock_model.go b/proxy/internal/llm/bedrock_model.go new file mode 100644 index 000000000..a4c4704f7 --- /dev/null +++ b/proxy/internal/llm/bedrock_model.go @@ -0,0 +1,38 @@ +package llm + +import ( + "regexp" + "strings" +) + +// bedrockRegionPrefixes are the cross-region inference-profile prefixes that +// front a Bedrock model id (e.g. "eu.anthropic.claude-..."). +var bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."} + +// bedrockVersionSuffix matches the trailing "-vN[:N]" or "-YYYYMMDD-vN[:N]" +// version/throughput suffix of a Bedrock model id. +var bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`) + +// NormalizeBedrockModel strips an ARN wrapper, a cross-region inference-profile +// prefix, and the version/throughput suffix from a Bedrock model id so it +// matches the catalog/pricing key, e.g. +// "eu.anthropic.claude-sonnet-4-5-20250929-v1:0" -> "anthropic.claude-sonnet-4-5" +// and the inference-profile ARN's last segment likewise. It is the single +// source of truth shared by the request parser (which normalizes the request +// model from the URL path) and the router (which normalizes the operator's +// registered Bedrock model ids so both sides compare equal). +func NormalizeBedrockModel(modelID string) string { + m := modelID + if strings.HasPrefix(m, "arn:") { + if i := strings.LastIndex(m, "/"); i >= 0 { + m = m[i+1:] + } + } + for _, p := range bedrockRegionPrefixes { + if strings.HasPrefix(m, p) { + m = m[len(p):] + break + } + } + return bedrockVersionSuffix.ReplaceAllString(m, "") +} diff --git a/proxy/internal/llm/bedrock_model_test.go b/proxy/internal/llm/bedrock_model_test.go new file mode 100644 index 000000000..3bd9662b7 --- /dev/null +++ b/proxy/internal/llm/bedrock_model_test.go @@ -0,0 +1,23 @@ +package llm + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeBedrockModel(t *testing.T) { + cases := map[string]string{ + "eu.anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5", + "us.anthropic.claude-haiku-4-5": "anthropic.claude-haiku-4-5", + "us.anthropic.claude-opus-4-8-20250101-v1:0": "anthropic.claude-opus-4-8", + "anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5", + "meta.llama3-3-70b-instruct-v1:0": "meta.llama3-3-70b-instruct", + "amazon.nova-pro-v1:0": "amazon.nova-pro", + // Inference-profile ARN — model id lives in the last path segment. + "arn:aws:bedrock:eu-central-1:123456789012:inference-profile/eu.anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5", + } + for in, want := range cases { + require.Equal(t, want, NormalizeBedrockModel(in), "normalize %q", in) + } +} diff --git a/proxy/internal/llm/bedrock_test.go b/proxy/internal/llm/bedrock_test.go new file mode 100644 index 000000000..e99ee55df --- /dev/null +++ b/proxy/internal/llm/bedrock_test.go @@ -0,0 +1,77 @@ +package llm + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBedrockParser_ParseResponse_Invoke(t *testing.T) { + body := []byte(`{"usage":{"input_tokens":13,"output_tokens":5,"cache_read_input_tokens":2,"cache_creation_input_tokens":4}}`) + u, err := BedrockParser{}.ParseResponse(200, "application/json", body) + require.NoError(t, err) + require.Equal(t, int64(13), u.InputTokens, "invoke input tokens") + require.Equal(t, int64(5), u.OutputTokens, "invoke output tokens") + require.Equal(t, int64(2), u.CachedInputTokens, "invoke cache-read tokens") + require.Equal(t, int64(4), u.CacheCreationTokens, "invoke cache-creation tokens") + require.Equal(t, int64(13+5+2+4), u.TotalTokens, "invoke total is additive") +} + +func TestBedrockParser_ParseResponse_Converse(t *testing.T) { + body := []byte(`{"output":{"message":{"content":[{"text":"pong"}]}},"usage":{"inputTokens":11,"outputTokens":3,"totalTokens":14}}`) + u, err := BedrockParser{}.ParseResponse(200, "application/json", body) + require.NoError(t, err) + require.Equal(t, int64(11), u.InputTokens, "converse camelCase input tokens") + require.Equal(t, int64(3), u.OutputTokens, "converse camelCase output tokens") + require.Equal(t, int64(14), u.TotalTokens, "converse uses provider total") +} + +// Converse camelCase cache fields must land in the billed Usage buckets, same as the InvokeModel snake_case fields. +func TestBedrockParser_ParseResponse_ConverseCacheBuckets(t *testing.T) { + body := []byte(`{"usage":{"inputTokens":11,"outputTokens":3,"cacheReadInputTokens":7,"cacheWriteInputTokens":9}}`) + u, err := BedrockParser{}.ParseResponse(200, "application/json", body) + require.NoError(t, err) + require.Equal(t, int64(11), u.InputTokens, "converse input tokens") + require.Equal(t, int64(3), u.OutputTokens, "converse output tokens") + require.Equal(t, int64(7), u.CachedInputTokens, "converse cache-read tokens") + require.Equal(t, int64(9), u.CacheCreationTokens, "converse cache-write tokens") + require.Equal(t, int64(11+3+7+9), u.TotalTokens, "total backfill is additive when the provider omits totalTokens") +} + +func TestBedrockParser_ParseResponse_StreamingUnsupported(t *testing.T) { + _, err := BedrockParser{}.ParseResponse(200, "application/vnd.amazon.eventstream", []byte("binary")) + require.ErrorIs(t, err, ErrStreamingUnsupported, "event-stream must route to the streaming accumulator") +} + +func TestBedrockParser_ParseResponse_NonSuccess(t *testing.T) { + _, err := BedrockParser{}.ParseResponse(404, "application/json", []byte(`{"message":"gated"}`)) + require.ErrorIs(t, err, ErrNotLLMResponse, "non-200 is not an LLM response") +} + +func TestBedrockParser_ExtractCompletion(t *testing.T) { + invoke := BedrockParser{}.ExtractCompletion(200, "application/json", []byte(`{"content":[{"text":"a"},{"text":"b"}]}`)) + require.Equal(t, "a\nb", invoke, "invoke completion joins content parts") + + converse := BedrockParser{}.ExtractCompletion(200, "application/json", []byte(`{"output":{"message":{"content":[{"text":"x"}]}}}`)) + require.Equal(t, "x", converse, "converse completion reads output.message.content") +} + +func TestBedrockParser_ExtractPrompt(t *testing.T) { + invoke := BedrockParser{}.ExtractPrompt([]byte(`{"messages":[{"role":"user","content":"hi"}]}`)) + require.Equal(t, "user: hi", invoke, "invoke prompt reads anthropic content string") + + converse := BedrockParser{}.ExtractPrompt([]byte(`{"messages":[{"role":"user","content":[{"text":"hello"}]}]}`)) + require.Equal(t, "user: hello", converse, "converse prompt reads content parts") +} + +func TestBedrockParser_DetectFromURL(t *testing.T) { + require.True(t, BedrockParser{}.DetectFromURL("/model/eu.anthropic.claude/invoke"), "invoke path") + require.True(t, BedrockParser{}.DetectFromURL("/model/x/converse-stream"), "converse-stream path") + require.False(t, BedrockParser{}.DetectFromURL("/v1/chat/completions"), "openai path is not bedrock") +} + +func TestBedrockParser_RegisteredByName(t *testing.T) { + p, ok := ParserByName(ProviderNameBedrock) + require.True(t, ok, "bedrock parser is registered") + require.Equal(t, ProviderNameBedrock, p.ProviderName()) +} diff --git a/proxy/internal/llm/errors.go b/proxy/internal/llm/errors.go new file mode 100644 index 000000000..09019fbbe --- /dev/null +++ b/proxy/internal/llm/errors.go @@ -0,0 +1,31 @@ +package llm + +import "errors" + +// Sentinel errors returned by parsers and the pricing loader. Callers use +// errors.Is to branch on a condition without coupling to parser internals. +var ( + // ErrUnknownProvider indicates no parser claimed the request path. + ErrUnknownProvider = errors.New("llmobs: unknown provider") + + // ErrUnsupportedModel indicates the response parsed successfully but the + // model is absent from the pricing table. Token counts are still valid. + ErrUnsupportedModel = errors.New("llmobs: unsupported model") + + // ErrNotLLMResponse indicates the response is not a JSON success body + // that a non-streaming parser can consume (non-200 or wrong content type). + ErrNotLLMResponse = errors.New("llmobs: not an LLM response") + + // ErrStreamingUnsupported indicates the caller passed an SSE response to + // a non-streaming parser. Streaming is handled separately via the SSE + // scanner. + ErrStreamingUnsupported = errors.New("llmobs: streaming response requires SSE scanner") + + // ErrMalformedResponse indicates the response body could not be decoded + // as the provider-specific JSON schema. + ErrMalformedResponse = errors.New("llmobs: malformed response body") + + // ErrMalformedRequest indicates the request body could not be decoded as + // the provider-specific JSON schema. + ErrMalformedRequest = errors.New("llmobs: malformed request body") +) diff --git a/proxy/internal/llm/fixtures/anthropic_messages.json b/proxy/internal/llm/fixtures/anthropic_messages.json new file mode 100644 index 000000000..2c9bb663a --- /dev/null +++ b/proxy/internal/llm/fixtures/anthropic_messages.json @@ -0,0 +1,17 @@ +{ + "id": "msg_abc", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + { + "type": "text", + "text": "Hello, world!" + } + ], + "stop_reason": "end_turn", + "usage": { + "input_tokens": 123, + "output_tokens": 45 + } +} diff --git a/proxy/internal/llm/fixtures/anthropic_stream.txt b/proxy/internal/llm/fixtures/anthropic_stream.txt new file mode 100644 index 000000000..2b8bb889c --- /dev/null +++ b/proxy/internal/llm/fixtures/anthropic_stream.txt @@ -0,0 +1,21 @@ +event: message_start +data: {"type":"message_start","message":{"id":"msg_abc","type":"message","role":"assistant","model":"claude-sonnet-4-5","content":[],"stop_reason":null,"usage":{"input_tokens":123,"output_tokens":1}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":", world!"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":45}} + +event: message_stop +data: {"type":"message_stop"} + diff --git a/proxy/internal/llm/fixtures/openai_chat_completion.json b/proxy/internal/llm/fixtures/openai_chat_completion.json new file mode 100644 index 000000000..d0e25337b --- /dev/null +++ b/proxy/internal/llm/fixtures/openai_chat_completion.json @@ -0,0 +1,21 @@ +{ + "id": "chatcmpl-abc", + "object": "chat.completion", + "created": 1700000000, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello, world!" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 123, + "completion_tokens": 45, + "total_tokens": 168 + } +} diff --git a/proxy/internal/llm/fixtures/openai_responses.json b/proxy/internal/llm/fixtures/openai_responses.json new file mode 100644 index 000000000..f998fcd33 --- /dev/null +++ b/proxy/internal/llm/fixtures/openai_responses.json @@ -0,0 +1,24 @@ +{ + "id": "resp_abc", + "object": "response", + "created_at": 1700000000, + "model": "gpt-5.4", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "ok"}] + } + ], + "usage": { + "input_tokens": 15, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 414, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 429 + } +} diff --git a/proxy/internal/llm/fixtures/openai_responses_stream.txt b/proxy/internal/llm/fixtures/openai_responses_stream.txt new file mode 100644 index 000000000..2801fa99d --- /dev/null +++ b/proxy/internal/llm/fixtures/openai_responses_stream.txt @@ -0,0 +1,24 @@ +event: response.created +data: {"type":"response.created","response":{"id":"resp_abc","object":"response","model":"gpt-5.5","usage":null}} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_abc","usage":null}} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","content":[]}} + +event: response.content_part.added +data: {"type":"response.content_part.added","item_id":"msg_1","output_index":0,"content_index":0,"part":{"type":"output_text","text":""}} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"Hello"} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":", world!"} + +event: response.output_text.done +data: {"type":"response.output_text.done","item_id":"msg_1","output_index":0,"content_index":0,"text":"Hello, world!"} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_abc","object":"response","model":"gpt-5.5","usage":{"input_tokens":123,"input_tokens_details":{"cached_tokens":40},"output_tokens":45,"output_tokens_details":{"reasoning_tokens":12},"total_tokens":168}}} + diff --git a/proxy/internal/llm/fixtures/openai_stream.txt b/proxy/internal/llm/fixtures/openai_stream.txt new file mode 100644 index 000000000..058b7ce22 --- /dev/null +++ b/proxy/internal/llm/fixtures/openai_stream.txt @@ -0,0 +1,8 @@ +data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]} + +data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":", world!"},"finish_reason":null}]} + +data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":123,"completion_tokens":45,"total_tokens":168}} + +data: [DONE] + diff --git a/proxy/internal/llm/fixtures/pricing.yaml b/proxy/internal/llm/fixtures/pricing.yaml new file mode 100644 index 000000000..3d26ff803 --- /dev/null +++ b/proxy/internal/llm/fixtures/pricing.yaml @@ -0,0 +1,59 @@ +# Realistic-pricing starter for llm_observability. Drop this into the +# directory you point the proxy at via --plugin-data-dir, then reference it +# from the target's plugin config: +# +# plugins: +# - id: llm_observability +# enabled: true +# params: +# pricing_path: pricing.yaml +# +# Values are USD per 1_000 tokens. Public list prices drift; treat this as a +# starting point and keep your production copy current. + +openai: + # GPT-5 family + gpt-5: + input_per_1k: 0.00125 + output_per_1k: 0.01 + gpt-5-mini: + input_per_1k: 0.00025 + output_per_1k: 0.002 + gpt-5-nano: + input_per_1k: 0.00005 + output_per_1k: 0.0004 + gpt-5.4: + input_per_1k: 0.00125 + output_per_1k: 0.01 + # GPT-4o family + gpt-4o: + input_per_1k: 0.0025 + output_per_1k: 0.01 + gpt-4o-mini: + input_per_1k: 0.00015 + output_per_1k: 0.0006 + # Embeddings + text-embedding-3-large: + input_per_1k: 0.00013 + output_per_1k: 0 + text-embedding-3-small: + input_per_1k: 0.00002 + output_per_1k: 0 + +anthropic: + # Claude 4.x family + claude-opus-4-7: + input_per_1k: 0.015 + output_per_1k: 0.075 + claude-sonnet-4-7: + input_per_1k: 0.003 + output_per_1k: 0.015 + claude-sonnet-4-6: + input_per_1k: 0.003 + output_per_1k: 0.015 + claude-sonnet-4-5: + input_per_1k: 0.003 + output_per_1k: 0.015 + claude-haiku-4-5: + input_per_1k: 0.0008 + output_per_1k: 0.004 diff --git a/proxy/internal/llm/openai.go b/proxy/internal/llm/openai.go new file mode 100644 index 000000000..86ee30797 --- /dev/null +++ b/proxy/internal/llm/openai.go @@ -0,0 +1,412 @@ +package llm + +import ( + "encoding/json" + "fmt" + "strings" +) + +// OpenAIParser implements the Parser interface for OpenAI-compatible APIs. +// It recognizes chat.completions, completions, embeddings, and the newer +// responses endpoint; any proxy path-prefix stripping is tolerated by the +// substring match in DetectFromURL. +type OpenAIParser struct{} + +// openAIPathHints are substring patterns that mark a request as +// OpenAI-shaped. The bare `/chat/completions` is listed alongside +// `/v1/chat/completions` because gateways like Cloudflare AI +// Gateway place their own version segment before the provider +// slug (gateway/v1/{account}/{gateway}/openai/chat/completions) — +// the canonical `/v1/` ends up nowhere near `/chat/completions`, +// so the `/v1/chat/completions` hint misses. `/chat/completions` +// is OpenAI's API contract: any service accepting OpenAI bodies +// serves at this path, so false-positive risk is negligible. +// `/completions` (legacy), `/embeddings`, and `/responses` are +// kept on the canonical-only path because their bare forms are +// too generic to be safe substrings. +var openAIPathHints = []string{ + "/v1/chat/completions", + "/v1/completions", + "/v1/embeddings", + "/v1/responses", + "/chat/completions", +} + +// Provider returns ProviderOpenAI. +func (OpenAIParser) Provider() Provider { return ProviderOpenAI } + +// ProviderName returns the stable label used for metrics and metadata. +func (OpenAIParser) ProviderName() string { return "openai" } + +// DetectFromURL reports whether the given request path looks like an OpenAI +// API endpoint. The match is case-insensitive and substring-based so that a +// reverse proxy prefix strip or rewrite does not defeat detection. +func (OpenAIParser) DetectFromURL(path string) bool { + lower := strings.ToLower(path) + for _, hint := range openAIPathHints { + if strings.Contains(lower, hint) { + return true + } + } + return false +} + +type openAIRequest struct { + Model string `json:"model"` + Stream *bool `json:"stream"` + StreamOptions *struct { + IncludeUsage *bool `json:"include_usage"` + } `json:"stream_options"` + // Chat Completions / Completions: messages[].content (string or array of + // content parts). Responses API: input is either a string or an array of + // items with content parts. We use json.RawMessage to defer parsing each + // shape independently. + Messages []openAIMessage `json:"messages"` + Prompt json.RawMessage `json:"prompt"` + Input json.RawMessage `json:"input"` +} + +type openAIMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` +} + +// ParseRequest extracts the model name and streaming flag from an OpenAI +// request body. Unknown or missing fields leave the corresponding struct +// members zero-valued. +func (OpenAIParser) ParseRequest(body []byte) (RequestFacts, error) { + var req openAIRequest + if err := json.Unmarshal(body, &req); err != nil { + return RequestFacts{}, fmt.Errorf("decode openai request: %w: %v", ErrMalformedRequest, err) + } + return RequestFacts{ + Model: req.Model, + Stream: ptrDeref(req.Stream), + }, nil +} + +// openAIResponse accepts both naming conventions in a single struct because +// OpenAI's older Chat Completions API uses prompt_tokens/completion_tokens +// while the newer Responses API (/v1/responses) uses input_tokens/output_tokens +// (aligned with Anthropic). Pointer fields let us tell "absent" from "zero". +// +// PromptTokensDetails.CachedTokens (Chat Completions) and +// InputTokensDetails.CachedTokens (Responses API) carry the SUBSET of +// prompt/input tokens that hit the prompt cache. Cost-meter applies the +// discount rate to that subset and the regular rate to the remainder so +// we never double-bill the cached portion. +type openAIResponse struct { + Usage struct { + PromptTokens *int64 `json:"prompt_tokens"` + CompletionTokens *int64 `json:"completion_tokens"` + InputTokens *int64 `json:"input_tokens"` + OutputTokens *int64 `json:"output_tokens"` + TotalTokens *int64 `json:"total_tokens"` + PromptTokensDetails *struct { + CachedTokens *int64 `json:"cached_tokens"` + } `json:"prompt_tokens_details"` + InputTokensDetails *struct { + CachedTokens *int64 `json:"cached_tokens"` + } `json:"input_tokens_details"` + } `json:"usage"` +} + +// ParseResponse decodes the non-streaming OpenAI response envelope. Status +// codes other than 200 are treated as non-LLM responses so the caller can +// skip cost accounting without aborting the request. +func (OpenAIParser) ParseResponse(status int, contentType string, body []byte) (Usage, error) { + if status != 200 { + return Usage{}, fmt.Errorf("openai status %d: %w", status, ErrNotLLMResponse) + } + if isEventStream(contentType) { + return Usage{}, ErrStreamingUnsupported + } + if !isJSON(contentType) { + return Usage{}, fmt.Errorf("openai content-type %q: %w", contentType, ErrNotLLMResponse) + } + + var resp openAIResponse + if err := json.Unmarshal(body, &resp); err != nil { + return Usage{}, fmt.Errorf("decode openai response: %w: %v", ErrMalformedResponse, err) + } + + // Responses-API names take precedence when present; fall back to the older + // Chat Completions names. This handles both endpoints transparently + // without forcing a per-route configuration. + u := Usage{ + InputTokens: pickInt64(resp.Usage.InputTokens, resp.Usage.PromptTokens), + OutputTokens: pickInt64(resp.Usage.OutputTokens, resp.Usage.CompletionTokens), + TotalTokens: derefInt64(resp.Usage.TotalTokens), + CachedInputTokens: openAICachedTokens(resp), + } + if u.TotalTokens == 0 && (u.InputTokens > 0 || u.OutputTokens > 0) { + u.TotalTokens = u.InputTokens + u.OutputTokens + } + return u, nil +} + +// openAICachedTokens returns the cached-prompt subset reported by +// either the Responses-API (input_tokens_details.cached_tokens) or +// the Chat-Completions API (prompt_tokens_details.cached_tokens). +// Responses-API takes precedence when both are populated. +func openAICachedTokens(resp openAIResponse) int64 { + // Responses-API details are authoritative when present: an explicit + // cached_tokens of 0 must be honored, not treated as missing and + // overridden by the Chat-Completions field (which would overstate cache). + if resp.Usage.InputTokensDetails != nil && resp.Usage.InputTokensDetails.CachedTokens != nil { + return derefInt64(resp.Usage.InputTokensDetails.CachedTokens) + } + if resp.Usage.PromptTokensDetails != nil { + return derefInt64(resp.Usage.PromptTokensDetails.CachedTokens) + } + return 0 +} + +// ExtractPrompt returns the user-visible prompt text from an OpenAI request. +// Handles chat.completions (messages[].content), legacy completions (prompt +// string), and the Responses API (input as string or content-part array). +// Returns "" when nothing extractable is found. +func (OpenAIParser) ExtractPrompt(body []byte) string { + var req openAIRequest + if err := json.Unmarshal(body, &req); err != nil { + return "" + } + if len(req.Messages) > 0 { + return joinMessages(req.Messages) + } + if len(req.Input) > 0 { + return extractResponsesInput(req.Input) + } + if len(req.Prompt) > 0 { + return decodeStringOrJoin(req.Prompt) + } + return "" +} + +// extractResponsesInput handles the Responses API `input` field. It is one +// of three shapes: a plain string, an array of message items +// ({role, content: string | [parts]}) as sent by Codex and the Responses +// SDK, or a flat array of content parts ({type, text/input_text}). Message +// items are flattened to "role: text" lines; items without extractable text +// (reasoning blocks, tool calls) are skipped. +func extractResponsesInput(raw json.RawMessage) string { + if s, ok := tryDecodeString(raw); ok { + return s + } + var items []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + Text string `json:"text"` + InputText string `json:"input_text"` + } + if err := json.Unmarshal(raw, &items); err != nil { + return extractContentParts(raw) + } + var b strings.Builder + for _, it := range items { + var text string + switch { + case len(it.Content) > 0: + text = decodeStringOrJoin(it.Content) + case it.Text != "": + text = it.Text + case it.InputText != "": + text = it.InputText + } + if text == "" { + continue + } + if b.Len() > 0 { + b.WriteByte('\n') + } + if it.Role != "" { + b.WriteString(it.Role) + b.WriteString(": ") + } + b.WriteString(text) + } + return b.String() +} + +// ExtractSessionID reads the OpenAI session marker. Codex (the Responses +// API client) stamps client_metadata.session_id on every request body; +// plain chat.completions traffic carries no session id and yields "". +func (OpenAIParser) ExtractSessionID(body []byte) string { + var req struct { + ClientMetadata struct { + SessionID string `json:"session_id"` + } `json:"client_metadata"` + } + if err := json.Unmarshal(body, &req); err != nil { + return "" + } + return req.ClientMetadata.SessionID +} + +type openAIChatChoice struct { + Message struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } `json:"message"` + Text string `json:"text"` +} + +type openAIChatResponse struct { + Choices []openAIChatChoice `json:"choices"` + // Responses API: output[].content[].text + Output []struct { + Type string `json:"type"` + Content json.RawMessage `json:"content"` + Text string `json:"text"` + } `json:"output"` + OutputText string `json:"output_text"` +} + +// ExtractCompletion returns the assistant text from a non-streaming OpenAI +// response. Handles chat.completions (choices[].message.content), legacy +// completions (choices[].text), and Responses API (output[].content[].text +// or the convenience output_text field). +func (OpenAIParser) ExtractCompletion(status int, contentType string, body []byte) string { + if status != 200 || isEventStream(contentType) || !isJSON(contentType) { + return "" + } + var resp openAIChatResponse + if err := json.Unmarshal(body, &resp); err != nil { + return "" + } + if resp.OutputText != "" { + return resp.OutputText + } + for _, c := range resp.Choices { + if len(c.Message.Content) > 0 { + if s := decodeStringOrJoin(c.Message.Content); s != "" { + return s + } + } + if c.Text != "" { + return c.Text + } + } + for _, o := range resp.Output { + if o.Text != "" { + return o.Text + } + if len(o.Content) > 0 { + if s := extractContentParts(o.Content); s != "" { + return s + } + } + } + return "" +} + +// joinMessages flattens a chat.completions messages array into a single +// "role: content" string per message, separated by newlines. Roles surface +// system/user/assistant context which is useful for log review. +func joinMessages(msgs []openAIMessage) string { + var b strings.Builder + for i, m := range msgs { + if i > 0 { + b.WriteByte('\n') + } + if m.Role != "" { + b.WriteString(m.Role) + b.WriteString(": ") + } + b.WriteString(decodeStringOrJoin(m.Content)) + } + return b.String() +} + +// extractContentParts handles the Responses-API content shape, which is +// either a single string or an array of {type, text} parts. text and +// input_text both carry user-facing content. +func extractContentParts(raw json.RawMessage) string { + if s, ok := tryDecodeString(raw); ok { + return s + } + var parts []struct { + Type string `json:"type"` + Text string `json:"text"` + InputText string `json:"input_text"` + } + if err := json.Unmarshal(raw, &parts); err != nil { + // Last-ditch: array of strings. + var arr []string + if json.Unmarshal(raw, &arr) == nil { + return strings.Join(arr, "\n") + } + return "" + } + var b strings.Builder + for _, p := range parts { + var text string + switch { + case p.Text != "": + text = p.Text + case p.InputText != "": + text = p.InputText + } + if text == "" { + continue + } + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString(text) + } + return b.String() +} + +// decodeStringOrJoin accepts either a JSON string or a content-parts array +// (chat.completions multimodal) and returns a flat string. Multimodal parts +// are separated by newlines; non-text parts are skipped. +func decodeStringOrJoin(raw json.RawMessage) string { + if s, ok := tryDecodeString(raw); ok { + return s + } + return extractContentParts(raw) +} + +func tryDecodeString(raw json.RawMessage) (string, bool) { + if len(raw) == 0 { + return "", false + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s, true + } + return "", false +} + +// pickInt64 returns the first non-nil pointer's value. Used to prefer one +// naming convention while transparently falling back to another. +func pickInt64(preferred, fallback *int64) int64 { + if preferred != nil { + return *preferred + } + return derefInt64(fallback) +} + +func derefInt64(v *int64) int64 { + if v == nil { + return 0 + } + return *v +} + +func ptrDeref(b *bool) bool { + if b == nil { + return false + } + return *b +} + +func isEventStream(contentType string) bool { + return strings.Contains(strings.ToLower(contentType), "text/event-stream") +} + +func isJSON(contentType string) bool { + lower := strings.ToLower(contentType) + return strings.Contains(lower, "application/json") || strings.Contains(lower, "+json") +} diff --git a/proxy/internal/llm/openai_test.go b/proxy/internal/llm/openai_test.go new file mode 100644 index 000000000..86b0c963f --- /dev/null +++ b/proxy/internal/llm/openai_test.go @@ -0,0 +1,255 @@ +package llm + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOpenAIDetectFromURL(t *testing.T) { + p := OpenAIParser{} + + cases := map[string]bool{ + "/v1/chat/completions": true, + "/v1/completions": true, + "/v1/embeddings": true, + "/v1/responses": true, + "/API/V1/Chat/Completions": true, + "/upstream/v1/chat/completions?trace=1": true, + // Cloudflare AI Gateway puts its own /v1/{account}/{gateway} + // segment between the canonical /v1/ and the provider slug, + // so the /v1/chat/completions substring no longer appears + // adjacent in the path. The bare /chat/completions hint + // catches Cloudflare's OpenAI direct path + // (/v1/{account}/{gateway}/openai/chat/completions) and + // compat path (/v1/{account}/{gateway}/compat/chat/completions). + "/v1/{account}/{gateway}/openai/chat/completions": true, + "/v1/{account}/{gateway}/compat/chat/completions": true, + "/chat/completions": true, + "/v1/messages": false, + "/healthz": false, + "": false, + } + for path, want := range cases { + assert.Equal(t, want, p.DetectFromURL(path), "DetectFromURL(%q)", path) + } +} + +func TestOpenAIParseRequest(t *testing.T) { + p := OpenAIParser{} + + t.Run("stream true", func(t *testing.T) { + facts, err := p.ParseRequest([]byte(`{"model":"gpt-4o","stream":true,"stream_options":{"include_usage":true}}`)) + require.NoError(t, err) + assert.Equal(t, "gpt-4o", facts.Model, "request model extracted") + assert.True(t, facts.Stream, "request marked as streaming") + }) + + t.Run("stream default", func(t *testing.T) { + facts, err := p.ParseRequest([]byte(`{"model":"gpt-4o-mini"}`)) + require.NoError(t, err) + assert.Equal(t, "gpt-4o-mini", facts.Model, "request model extracted") + assert.False(t, facts.Stream, "missing stream flag defaults to false") + }) + + t.Run("malformed", func(t *testing.T) { + _, err := p.ParseRequest([]byte(`{not json}`)) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrMalformedRequest), "sentinel error wrapped") + }) +} + +func TestOpenAIParseResponse(t *testing.T) { + p := OpenAIParser{} + + t.Run("happy fixture", func(t *testing.T) { + body, err := os.ReadFile(filepath.Join("fixtures", "openai_chat_completion.json")) + require.NoError(t, err, "fixture must be readable") + + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(123), usage.InputTokens, "prompt tokens become input") + assert.Equal(t, int64(45), usage.OutputTokens, "completion tokens become output") + assert.Equal(t, int64(168), usage.TotalTokens, "total_tokens carried through") + }) + + t.Run("total computed when missing", func(t *testing.T) { + body := []byte(`{"usage":{"prompt_tokens":10,"completion_tokens":5}}`) + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(15), usage.TotalTokens, "total computed from in+out") + }) + + t.Run("streaming rejected", func(t *testing.T) { + _, err := p.ParseResponse(200, "text/event-stream", []byte("")) + require.ErrorIs(t, err, ErrStreamingUnsupported, "SSE responses must use the scanner") + }) + + t.Run("non-200", func(t *testing.T) { + _, err := p.ParseResponse(500, "application/json", []byte(`{"error":"x"}`)) + require.ErrorIs(t, err, ErrNotLLMResponse, "non-200 rejected as non-LLM") + }) + + t.Run("non-json content type", func(t *testing.T) { + _, err := p.ParseResponse(200, "text/plain", []byte(`{}`)) + require.ErrorIs(t, err, ErrNotLLMResponse, "text/plain treated as non-LLM") + }) + + t.Run("malformed body", func(t *testing.T) { + _, err := p.ParseResponse(200, "application/json", []byte(`{not json`)) + require.ErrorIs(t, err, ErrMalformedResponse, "bad JSON yields malformed error") + }) + + // Responses-API fixture: /v1/responses returns input_tokens/output_tokens + // (Anthropic-style) instead of prompt_tokens/completion_tokens. The parser + // must accept both. + t.Run("responses api fixture", func(t *testing.T) { + body, err := os.ReadFile(filepath.Join("fixtures", "openai_responses.json")) + require.NoError(t, err, "fixture must be readable") + + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(15), usage.InputTokens, "input_tokens should map directly") + assert.Equal(t, int64(414), usage.OutputTokens, "output_tokens should map directly") + assert.Equal(t, int64(429), usage.TotalTokens, "total_tokens carried through") + }) + + t.Run("responses api naming preferred over chat-completions when both present", func(t *testing.T) { + body := []byte(`{"usage":{"prompt_tokens":1,"completion_tokens":2,"input_tokens":15,"output_tokens":414,"total_tokens":429}}`) + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(15), usage.InputTokens, "responses-api names take precedence") + assert.Equal(t, int64(414), usage.OutputTokens, "responses-api names take precedence") + }) + + t.Run("chat-completions naming still works alone", func(t *testing.T) { + body := []byte(`{"usage":{"prompt_tokens":15,"completion_tokens":414,"total_tokens":429}}`) + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(15), usage.InputTokens, "prompt_tokens fallback") + assert.Equal(t, int64(414), usage.OutputTokens, "completion_tokens fallback") + }) + + // Cached-prompt accounting. cached_tokens is a SUBSET of + // prompt_tokens — input_tokens carries the full prompt count and + // the cached subset is reported separately so the cost meter can + // apply the discount rate to that portion. + t.Run("chat-completions cached_tokens subset surfaces", func(t *testing.T) { + body := []byte(`{"usage":{"prompt_tokens":1024,"completion_tokens":200,"total_tokens":1224,"prompt_tokens_details":{"cached_tokens":768}}}`) + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(1024), usage.InputTokens, "input remains the full prompt count — cached is a subset, not a separate bucket") + assert.Equal(t, int64(768), usage.CachedInputTokens, "cached_tokens must surface so cost meter can discount the cached subset") + assert.Zero(t, usage.CacheCreationTokens, "OpenAI has no cache_creation analogue") + }) + + t.Run("responses-api input_tokens_details.cached_tokens surfaces", func(t *testing.T) { + body := []byte(`{"usage":{"input_tokens":2048,"output_tokens":100,"total_tokens":2148,"input_tokens_details":{"cached_tokens":1500}}}`) + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(2048), usage.InputTokens) + assert.Equal(t, int64(1500), usage.CachedInputTokens, "Responses-API input_tokens_details.cached_tokens path must surface too") + }) + + t.Run("responses-api cached takes precedence over chat-completions when both present", func(t *testing.T) { + body := []byte(`{"usage":{"prompt_tokens":1,"input_tokens":2,"output_tokens":3,"prompt_tokens_details":{"cached_tokens":50},"input_tokens_details":{"cached_tokens":99}}}`) + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Equal(t, int64(99), usage.CachedInputTokens, "Responses-API field wins when both naming conventions are present") + }) + + t.Run("absent cached_tokens leaves cached counts at zero", func(t *testing.T) { + body := []byte(`{"usage":{"prompt_tokens":15,"completion_tokens":414,"total_tokens":429}}`) + usage, err := p.ParseResponse(200, "application/json", body) + require.NoError(t, err) + assert.Zero(t, usage.CachedInputTokens, "no prompt_tokens_details = no cached subset") + }) +} + +func TestOpenAIExtractPrompt_ChatCompletions(t *testing.T) { + body := []byte(`{"model":"gpt-4o-mini","messages":[{"role":"system","content":"be brief"},{"role":"user","content":"ping"}]}`) + got := OpenAIParser{}.ExtractPrompt(body) + require.NotEmpty(t, got, "messages array must extract") + require.Contains(t, got, "system: be brief", "system role and content surface") + require.Contains(t, got, "user: ping", "user role and content surface") +} + +func TestOpenAIExtractPrompt_ResponsesAPIStringInput(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","input":"Hello there"}`) + got := OpenAIParser{}.ExtractPrompt(body) + require.Equal(t, "Hello there", got, "string input field should pass through") +} + +func TestOpenAIExtractPrompt_ResponsesAPIInputParts(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","input":[{"type":"input_text","input_text":"first"},{"type":"input_text","input_text":"second"}]}`) + got := OpenAIParser{}.ExtractPrompt(body) + require.Contains(t, got, "first", "first content part surfaces") + require.Contains(t, got, "second", "second content part surfaces") +} + +// TestOpenAIExtractPrompt_ResponsesAPIMessageItems guards the live Codex +// shape: input is an array of message items whose text is nested under +// content[].text, not flat content parts. The old code fed the outer array +// to the content-part decoder and extracted nothing, so the stored prompt +// was empty. +func TestOpenAIExtractPrompt_ResponsesAPIMessageItems(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","input":[` + + `{"type":"message","role":"developer","content":[{"type":"input_text","text":"system rules"}]},` + + `{"type":"message","role":"user","content":[{"type":"input_text","text":"hello there"}]},` + + `{"type":"reasoning","encrypted_content":"opaque","summary":[]},` + + `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"prior reply"}]}` + + `]}`) + got := OpenAIParser{}.ExtractPrompt(body) + require.Contains(t, got, "system rules", "developer message content must surface") + require.Contains(t, got, "hello there", "user message content must surface") + require.Contains(t, got, "developer:", "role labels must prefix each message") + require.NotContains(t, got, "opaque", "reasoning items without text must be skipped") +} + +func TestOpenAIExtractPrompt_LegacyCompletion(t *testing.T) { + body := []byte(`{"model":"text-davinci-003","prompt":"once upon a time"}`) + got := OpenAIParser{}.ExtractPrompt(body) + require.Equal(t, "once upon a time", got, "string prompt field should pass through") +} + +func TestOpenAIExtractSessionID(t *testing.T) { + t.Run("codex client_metadata.session_id", func(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","client_metadata":{"session_id":"019eeb72-ab7c-7cd2","thread_id":"t1"},"input":[]}`) + assert.Equal(t, "019eeb72-ab7c-7cd2", OpenAIParser{}.ExtractSessionID(body), "Codex session id must come from client_metadata.session_id") + }) + t.Run("plain chat has no session", func(t *testing.T) { + body := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}`) + assert.Equal(t, "", OpenAIParser{}.ExtractSessionID(body), "plain chat.completions carries no session id") + }) + t.Run("non-JSON yields empty", func(t *testing.T) { + assert.Equal(t, "", OpenAIParser{}.ExtractSessionID([]byte("not json")), "malformed body must not error") + }) +} + +func TestOpenAIExtractCompletion_ChatCompletions(t *testing.T) { + body, err := os.ReadFile(filepath.Join("fixtures", "openai_chat_completion.json")) + require.NoError(t, err) + got := OpenAIParser{}.ExtractCompletion(200, "application/json", body) + require.NotEmpty(t, got, "fixture has assistant content") +} + +func TestOpenAIExtractCompletion_ResponsesAPI(t *testing.T) { + body, err := os.ReadFile(filepath.Join("fixtures", "openai_responses.json")) + require.NoError(t, err) + got := OpenAIParser{}.ExtractCompletion(200, "application/json", body) + require.NotEmpty(t, got, "responses-api fixture has output content") +} + +func TestOpenAIExtractCompletion_Streaming(t *testing.T) { + got := OpenAIParser{}.ExtractCompletion(200, "text/event-stream", []byte("")) + require.Empty(t, got, "streaming responses are skipped") +} + +func TestOpenAIExtractCompletion_NonOK(t *testing.T) { + got := OpenAIParser{}.ExtractCompletion(500, "application/json", []byte(`{"choices":[{"message":{"content":"x"}}]}`)) + require.Empty(t, got, "non-200 returns empty") +} diff --git a/proxy/internal/llm/parser.go b/proxy/internal/llm/parser.go new file mode 100644 index 000000000..81fa11f97 --- /dev/null +++ b/proxy/internal/llm/parser.go @@ -0,0 +1,112 @@ +// Package llm provides the shared LLM request and response parsing +// library consumed by proxy middleware. It is runtime agnostic: the same +// package is used by the native built-in executor now and will be reused +// by the WASM adapter later. +package llm + +// Provider identifies an LLM API provider. +type Provider int + +const ( + // ProviderUnknown signals that no parser matched the request. + ProviderUnknown Provider = 0 + // ProviderOpenAI identifies the OpenAI API surface. + ProviderOpenAI Provider = 1 + // ProviderAnthropic identifies the Anthropic Messages API surface. + ProviderAnthropic Provider = 2 + // ProviderBedrock identifies the AWS Bedrock runtime surface. + ProviderBedrock Provider = 3 +) + +// RequestFacts captures the subset of the LLM request body that the +// middleware annotates as metadata (model, streaming flag). Additional +// fields are added as parsers grow. +type RequestFacts struct { + Model string + Stream bool +} + +// Usage is the provider-agnostic token accounting emitted to metrics and +// access logs. Downstream consumers map InputTokens/OutputTokens to the +// plg.llm.* metadata allowlist entries. +// +// CachedInputTokens carries OpenAI's prompt_tokens_details.cached_tokens +// (a SUBSET of InputTokens) when the response is from OpenAI, or +// Anthropic's cache_read_input_tokens (ADDITIVE to InputTokens) when from +// Anthropic. The cost meter switches formula on KeyLLMProvider so the +// two shapes are billed correctly without double-counting. +// +// CacheCreationTokens carries Anthropic's cache_creation_input_tokens +// (ADDITIVE; not present in the OpenAI shape). +type Usage struct { + InputTokens int64 + OutputTokens int64 + TotalTokens int64 + CachedInputTokens int64 + CacheCreationTokens int64 +} + +// Parser is the per-provider interface implemented in this package. The +// dispatcher selects a parser by calling DetectFromURL against the incoming +// request path; ties break by registration order (see Parsers). +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 returns the user-facing prompt text from a request body. + // Different endpoint shapes (chat.completions, responses, messages) are + // handled by the per-provider implementation. Returns "" when no prompt + // can be extracted; never returns an error — extraction is best-effort + // because callers use the result for observability, not authorization. + ExtractPrompt(body []byte) string + // ExtractCompletion returns the assistant-facing completion text from a + // non-streaming response body. status and contentType match the + // ParseResponse arguments so implementations can fast-fail uniformly. + ExtractCompletion(status int, contentType string, body []byte) string + // ExtractSessionID returns a stable identifier that groups requests of + // the same conversation / coding session, read from the per-provider + // location clients populate (e.g. OpenAI Codex's client_metadata.session_id, + // Claude Code's metadata.user_id). Returns "" when the body carries no + // recognised session marker; extraction is best-effort and never errors. + ExtractSessionID(body []byte) string +} + +// Parsers returns the built-in parser set in a stable order. The order is +// deterministic so that DetectFromURL ties produce consistent routing. +func Parsers() []Parser { + return []Parser{ + OpenAIParser{}, + AnthropicParser{}, + BedrockParser{}, + } +} + +// DetectParser returns the first parser whose DetectFromURL matches the given +// request path. ok=false means no parser claimed the path. +func DetectParser(path string) (Parser, bool) { + for _, p := range Parsers() { + if p.DetectFromURL(path) { + return p, true + } + } + return nil, false +} + +// ParserByName returns the parser whose ProviderName matches id. Used by +// callers that already know which provider surface a request will hit +// (e.g. the agent-network middleware chain configured per synthesised +// service) so they can skip URL sniffing. ok=false when no parser is +// registered under that name. +func ParserByName(id string) (Parser, bool) { + if id == "" { + return nil, false + } + for _, p := range Parsers() { + if p.ProviderName() == id { + return p, true + } + } + return nil, false +} diff --git a/proxy/internal/llm/parser_test.go b/proxy/internal/llm/parser_test.go new file mode 100644 index 000000000..b3052ce68 --- /dev/null +++ b/proxy/internal/llm/parser_test.go @@ -0,0 +1,54 @@ +package llm + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParsers_ProviderNames(t *testing.T) { + parsers := Parsers() + require.Len(t, parsers, 3, "three built-in parsers expected") + + names := make([]string, 0, len(parsers)) + for _, p := range parsers { + names = append(names, p.ProviderName()) + } + assert.Contains(t, names, "openai", "OpenAI parser should be registered") + assert.Contains(t, names, "anthropic", "Anthropic parser should be registered") + assert.Contains(t, names, "bedrock", "Bedrock parser should be registered") +} + +func TestDetectParser(t *testing.T) { + cases := []struct { + name string + path string + expectedName string + expectOK bool + }{ + {"openai chat", "/v1/chat/completions", "openai", true}, + {"openai prefixed", "/api/v1/chat/completions", "openai", true}, + {"openai responses", "/v1/responses", "openai", true}, + {"anthropic messages", "/v1/messages", "anthropic", true}, + {"anthropic prefixed", "/proxy/v1/messages?query", "anthropic", true}, + {"unknown path", "/healthz", "", false}, + {"empty path", "", "", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p, ok := DetectParser(tc.path) + require.Equal(t, tc.expectOK, ok, "detection success mismatch for %q", tc.path) + if ok { + assert.Equal(t, tc.expectedName, p.ProviderName(), "provider name mismatch") + } + }) + } +} + +func TestProviderValues(t *testing.T) { + assert.Equal(t, Provider(0), ProviderUnknown, "unknown provider is the zero value") + assert.Equal(t, ProviderOpenAI, OpenAIParser{}.Provider(), "OpenAI parser reports its provider enum") + assert.Equal(t, ProviderAnthropic, AnthropicParser{}.Provider(), "Anthropic parser reports its provider enum") +} diff --git a/proxy/internal/llm/pricing/defaults_coverage_test.go b/proxy/internal/llm/pricing/defaults_coverage_test.go new file mode 100644 index 000000000..8df1557ea --- /dev/null +++ b/proxy/internal/llm/pricing/defaults_coverage_test.go @@ -0,0 +1,65 @@ +package pricing + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDefaultTable_FirstPartyModelCoverage guards the embedded defaults against +// silent drift/gaps: every metered first-party model the management catalog +// enumerates must resolve to a price, and a few rates that previously drifted +// are pinned to their LiteLLM-validated values. Keep this list in step with the +// catalog (management/server/agentnetwork/catalog) when adding models. +func TestDefaultTable_FirstPartyModelCoverage(t *testing.T) { + tbl := DefaultTable() + require.NotNil(t, tbl, "embedded default pricing table must load") + + mustPrice := map[string][]string{ + // openai parser covers openai_api, azure_openai_api, and mistral_api. + "openai": { + "gpt-5.5", "gpt-5.5-pro", "gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano", + "gpt-5.3-codex", "gpt-5.3-chat-latest", "o4-mini", + "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano", "gpt-4o", "gpt-4o-mini", + "gpt-4-turbo", "gpt-3.5-turbo", "gpt-35-turbo", + "text-embedding-3-large", "text-embedding-3-small", + "mistral-large-latest", "mistral-medium-3-5", "codestral-2508", + "ministral-8b-latest", "mistral-embed", + }, + "anthropic": { + "claude-fable-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", + "claude-opus-4-1", "claude-sonnet-4-6", "claude-sonnet-4-5", "claude-haiku-4-5", + }, + // bedrock keys are the normalized ids the request parser emits. + "bedrock": { + "anthropic.claude-opus-5", "anthropic.claude-opus-4-8", "anthropic.claude-opus-4-7", "anthropic.claude-opus-4-6", + "anthropic.claude-opus-4-1", "anthropic.claude-sonnet-4-6", "anthropic.claude-sonnet-4-5", + "anthropic.claude-haiku-4-5", "meta.llama3-3-70b-instruct", + "amazon.nova-pro", "amazon.nova-lite", "amazon.nova-micro", "amazon.nova-2-lite", + }, + } + for provider, models := range mustPrice { + for _, m := range models { + _, ok := tbl.Cost(provider, m, 1000, 1000, 0, 0) + assert.True(t, ok, "%s/%s must be priced in the embedded defaults", provider, m) + } + } + + // Pin per-direction rates independently (input-only then output-only) so a + // swap or skew of input<->output that preserves the combined total is still + // caught — these are rates that previously drifted or are easy to mis-enter. + in, ok := tbl.Cost("openai", "gpt-5.4", 1000, 0, 0, 0) + require.True(t, ok) + assert.InDelta(t, 0.0025, in, 1e-9, "gpt-5.4 input = 0.0025 per 1k") + out, ok := tbl.Cost("openai", "gpt-5.4", 0, 1000, 0, 0) + require.True(t, ok) + assert.InDelta(t, 0.015, out, 1e-9, "gpt-5.4 output = 0.015 per 1k") + + in, ok = tbl.Cost("bedrock", "anthropic.claude-sonnet-4-5", 1000, 0, 0, 0) + require.True(t, ok) + assert.InDelta(t, 0.003, in, 1e-9, "bedrock sonnet-4-5 input = 0.003 per 1k") + out, ok = tbl.Cost("bedrock", "anthropic.claude-sonnet-4-5", 0, 1000, 0, 0) + require.True(t, ok) + assert.InDelta(t, 0.015, out, 1e-9, "bedrock sonnet-4-5 output = 0.015 per 1k") +} diff --git a/proxy/internal/llm/pricing/defaults_pricing.yaml b/proxy/internal/llm/pricing/defaults_pricing.yaml new file mode 100644 index 000000000..988426105 --- /dev/null +++ b/proxy/internal/llm/pricing/defaults_pricing.yaml @@ -0,0 +1,298 @@ +# Embedded default pricing for llm_observability. Compiled into the proxy +# binary via go:embed in pricing.go; cost annotation works out of the box +# without any operator action. +# +# Operators override entries by dropping a pricing.yaml into --plugin-data-dir +# (or whichever basename is given via params.pricing_path). The override file +# only needs entries the operator wants to change; missing entries fall +# through to these defaults. +# +# Values are USD per 1_000 tokens. Public list prices drift; ship a fresh +# binary or override individual entries via the override file as needed. +# +# Optional cache fields: +# cached_input_per_1k OpenAI: rate for prompt_tokens_details.cached_tokens +# (a SUBSET of prompt_tokens). Typically 0.5x input. +# Absent → cached portion bills at input_per_1k. +# cache_read_per_1k Anthropic: rate for cache_read_input_tokens +# (ADDITIVE to input_tokens). Typically 0.1x input. +# Absent → cache reads bill at input_per_1k. +# cache_creation_per_1k Anthropic: rate for cache_creation_input_tokens +# (ADDITIVE to input_tokens). Typically 1.25x input. +# Absent → cache writes bill at input_per_1k. + +openai: + # OpenAI + OpenAI-compatible providers (openai_api, azure_openai_api, + # mistral_api, and the openai-parser gateways) all emit llm.provider="openai", + # so their models are priced here. Kept in sync with the management catalog; + # rates cross-checked against LiteLLM model_prices_and_context_window.json. + + # GPT-5.x family — cache reads 10% of input (0.1x). + gpt-5.5: + input_per_1k: 0.005 + output_per_1k: 0.03 + cached_input_per_1k: 0.0005 + gpt-5.5-pro: + input_per_1k: 0.03 + output_per_1k: 0.18 + cached_input_per_1k: 0.003 + gpt-5.4: + input_per_1k: 0.0025 + output_per_1k: 0.015 + cached_input_per_1k: 0.00025 + gpt-5.4-pro: + input_per_1k: 0.03 + output_per_1k: 0.18 + cached_input_per_1k: 0.003 + gpt-5.4-mini: + input_per_1k: 0.00075 + output_per_1k: 0.0045 + cached_input_per_1k: 0.000075 + gpt-5.4-nano: + input_per_1k: 0.0002 + output_per_1k: 0.00125 + cached_input_per_1k: 0.00002 + gpt-5.3-codex: + input_per_1k: 0.00175 + output_per_1k: 0.014 + cached_input_per_1k: 0.000175 + gpt-5.3-chat-latest: + input_per_1k: 0.00175 + output_per_1k: 0.014 + cached_input_per_1k: 0.000175 + # GPT-5 (2025) family — kept for gateway requests using the unsuffixed ids. + gpt-5: + input_per_1k: 0.00125 + output_per_1k: 0.01 + cached_input_per_1k: 0.000125 + gpt-5-mini: + input_per_1k: 0.00025 + output_per_1k: 0.002 + cached_input_per_1k: 0.000025 + gpt-5-nano: + input_per_1k: 0.00005 + output_per_1k: 0.0004 + cached_input_per_1k: 0.000005 + o4-mini: + input_per_1k: 0.0011 + output_per_1k: 0.0044 + cached_input_per_1k: 0.000275 + # GPT-4.1 family — cache reads 25% of input. + gpt-4.1: + input_per_1k: 0.002 + output_per_1k: 0.008 + cached_input_per_1k: 0.0005 + gpt-4.1-mini: + input_per_1k: 0.0004 + output_per_1k: 0.0016 + cached_input_per_1k: 0.0001 + gpt-4.1-nano: + input_per_1k: 0.0001 + output_per_1k: 0.0004 + cached_input_per_1k: 0.000025 + # GPT-4o family — cache reads 50% of input (0.5x). + gpt-4o: + input_per_1k: 0.0025 + output_per_1k: 0.01 + cached_input_per_1k: 0.00125 + gpt-4o-mini: + input_per_1k: 0.00015 + output_per_1k: 0.0006 + cached_input_per_1k: 0.000075 + # Older GPT — no prompt caching. + gpt-4-turbo: + input_per_1k: 0.01 + output_per_1k: 0.03 + gpt-3.5-turbo: + input_per_1k: 0.0005 + output_per_1k: 0.0015 + gpt-35-turbo: # Azure deployment alias of gpt-3.5-turbo + input_per_1k: 0.0005 + output_per_1k: 0.0015 + # Embeddings — no caching, no output tokens. + text-embedding-3-large: + input_per_1k: 0.00013 + output_per_1k: 0 + text-embedding-3-small: + input_per_1k: 0.00002 + output_per_1k: 0 + + # Mistral (mistral_api) — routed via the openai parser; no prompt caching. + mistral-large-latest: + input_per_1k: 0.0005 + output_per_1k: 0.0015 + mistral-medium-latest: + input_per_1k: 0.0004 + output_per_1k: 0.002 + mistral-medium-3-5: + input_per_1k: 0.0015 + output_per_1k: 0.0075 + mistral-small-latest: + input_per_1k: 0.00006 + output_per_1k: 0.00018 + magistral-medium-latest: + input_per_1k: 0.002 + output_per_1k: 0.005 + magistral-small-latest: + input_per_1k: 0.0005 + output_per_1k: 0.0015 + devstral-medium-latest: + input_per_1k: 0.0004 + output_per_1k: 0.002 + devstral-small-latest: + input_per_1k: 0.0001 + output_per_1k: 0.0003 + codestral-2508: + input_per_1k: 0.0003 + output_per_1k: 0.0009 + codestral-latest: + input_per_1k: 0.001 + output_per_1k: 0.003 + ministral-3-14b-2512: + input_per_1k: 0.0002 + output_per_1k: 0.0002 + ministral-8b-latest: + input_per_1k: 0.00015 + output_per_1k: 0.00015 + ministral-3-3b-2512: + input_per_1k: 0.0001 + output_per_1k: 0.0001 + mistral-embed: + input_per_1k: 0.0001 + output_per_1k: 0 + + # Kimi / Moonshot AI (kimi_api) — OpenAI-compatible /v1 endpoint. Moonshot + # reports cache hits OpenAI-style when present; cached input is 10% of + # input ($0.30 vs $3.00 per MTok). kimi-k3 is the only model the platform + # serves newer accounts (K2-era ids and kimi-latest 404), matching the + # management catalog. + kimi-k3: + input_per_1k: 0.003 + output_per_1k: 0.015 + cached_input_per_1k: 0.0003 + +anthropic: + # Claude 4.x family — cache reads ≈10% of input, cache writes ≈125% of input. + # Pricing source: Anthropic's current published rates per million tokens, + # divided by 1000 for the per-1k figures stored here. + claude-fable-5: + input_per_1k: 0.010 + output_per_1k: 0.050 + cache_read_per_1k: 0.001 + cache_creation_per_1k: 0.0125 + claude-opus-5: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 + claude-opus-4-8: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 + claude-opus-4-7: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 + claude-opus-4-6: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 + claude-opus-4-1: + input_per_1k: 0.015 + output_per_1k: 0.075 + cache_read_per_1k: 0.0015 + cache_creation_per_1k: 0.01875 + claude-sonnet-4-6: + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + cache_creation_per_1k: 0.00375 + claude-sonnet-4-5: + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + cache_creation_per_1k: 0.00375 + claude-haiku-4-5: + input_per_1k: 0.001 + output_per_1k: 0.005 + cache_read_per_1k: 0.0001 + cache_creation_per_1k: 0.00125 + + # Kimi / Moonshot AI (kimi_api) via the Anthropic-compatible endpoint + # (/anthropic/v1/messages — the official Claude Code setup). Same rates + # as the OpenAI-shape entry above. "kimi-k3[1m]" is the model id some + # Claude Code guides set for the 1M-context alias; priced identically so + # cost metering doesn't silently skip those requests. + kimi-k3: + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + "kimi-k3[1m]": + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + +bedrock: + # AWS Bedrock model ids, normalised by the request parser (cross-region + # inference-profile prefix + version/throughput suffix stripped), e.g. + # eu.anthropic.claude-sonnet-4-5-20250929-v1:0 -> anthropic.claude-sonnet-4-5. + # Anthropic-on-Bedrock keeps the additive cache buckets (read ≈0.1x input, + # write ≈1.25x input); Nova / Llama report no cache, so cost is input+output. + anthropic.claude-opus-5: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 + anthropic.claude-opus-4-8: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 + anthropic.claude-opus-4-7: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 + anthropic.claude-opus-4-6: + input_per_1k: 0.005 + output_per_1k: 0.025 + cache_read_per_1k: 0.0005 + cache_creation_per_1k: 0.00625 + anthropic.claude-opus-4-1: + input_per_1k: 0.015 + output_per_1k: 0.075 + cache_read_per_1k: 0.0015 + cache_creation_per_1k: 0.01875 + anthropic.claude-sonnet-4-6: + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + cache_creation_per_1k: 0.00375 + anthropic.claude-sonnet-4-5: + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + cache_creation_per_1k: 0.00375 + anthropic.claude-haiku-4-5: + input_per_1k: 0.001 + output_per_1k: 0.005 + cache_read_per_1k: 0.0001 + cache_creation_per_1k: 0.00125 + meta.llama3-3-70b-instruct: + input_per_1k: 0.00072 + output_per_1k: 0.00072 + amazon.nova-2-lite: + input_per_1k: 0.0003 + output_per_1k: 0.0025 + amazon.nova-pro: + input_per_1k: 0.0008 + output_per_1k: 0.0032 + amazon.nova-lite: + input_per_1k: 0.00006 + output_per_1k: 0.00024 + amazon.nova-micro: + input_per_1k: 0.000035 + output_per_1k: 0.00014 diff --git a/proxy/internal/llm/pricing/pricing.go b/proxy/internal/llm/pricing/pricing.go new file mode 100644 index 000000000..b77000000 --- /dev/null +++ b/proxy/internal/llm/pricing/pricing.go @@ -0,0 +1,489 @@ +// Package pricing implements the embedded-default + override pricing table +// shared by middleware that converts LLM token usage into a USD cost +// estimate. The table is hot-reloadable from a basename under the proxy +// data directory; missing override files keep the embedded defaults so +// cost annotation works without operator action. +package pricing + +import ( + "bytes" + "context" + _ "embed" + "errors" + "fmt" + "io" + "io/fs" + "math" + "path/filepath" + "regexp" + "strings" + "sync" + "sync/atomic" + "time" + + log "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "gopkg.in/yaml.v3" +) + +//go:embed defaults_pricing.yaml +var defaultPricingYAML []byte + +var ( + defaultTableOnce sync.Once + defaultTablePtr *Table +) + +// DefaultTable returns the pricing table embedded in the binary. The result +// is parsed once and shared; callers must not mutate the returned value. +// Cost annotation works without any operator action because every loader +// starts with this table. +func DefaultTable() *Table { + defaultTableOnce.Do(func() { + t, err := parsePricingBytes(defaultPricingYAML) + if err != nil { + panic(fmt.Sprintf("llmobs: embedded default pricing failed to parse: %v", err)) + } + defaultTablePtr = t + }) + return defaultTablePtr +} + +// mergeOver returns a new Table containing every entry from base, with any +// matching entry from overlay replacing the base value. Either argument may +// be nil. Result is a fresh allocation so callers can mutate / Store safely. +func mergeOver(base, overlay *Table) *Table { + if overlay == nil || len(overlay.entries) == 0 { + return base + } + if base == nil || len(base.entries) == 0 { + return overlay + } + out := make(map[string]map[string]Entry, len(base.entries)) + for provider, models := range base.entries { + inner := make(map[string]Entry, len(models)) + for model, e := range models { + inner[model] = e + } + out[provider] = inner + } + for provider, models := range overlay.entries { + inner, ok := out[provider] + if !ok { + inner = make(map[string]Entry, len(models)) + out[provider] = inner + } + for model, e := range models { + inner[model] = e + } + } + return &Table{entries: out} +} + +// Entry is a single model's input and output pricing, expressed in USD per +// 1000 tokens. +// +// CachedInputPer1K applies to OpenAI's cached prompt tokens, which are a +// subset of input_tokens — when set, the cached portion is billed at this +// rate and the non-cached remainder at InputPer1K. Zero means "no discount +// configured", and cached tokens are billed at InputPer1K (matches current +// behaviour where cached counts weren't extracted at all). +// +// CacheReadPer1K and CacheCreationPer1K apply to Anthropic's two prompt- +// cache fields, which are additive to input_tokens: cache_read is the +// cheaper read-from-cache rate, cache_creation is the more expensive +// write-to-cache rate. Zero means "no rate configured" and the +// corresponding token bucket is billed at InputPer1K. This is more +// accurate than today's behaviour, where Anthropic's cache tokens are +// ignored and not charged at all. +type Entry struct { + InputPer1K float64 + OutputPer1K float64 + CachedInputPer1K float64 + CacheReadPer1K float64 + CacheCreationPer1K float64 +} + +// Table is a provider-to-model pricing lookup. Instances are immutable once +// built and are swapped atomically by Loader. +type Table struct { + entries map[string]map[string]Entry +} + +// Cost returns the estimated USD cost for the given token counts. ok is +// false when the provider or model is not present in the table; the caller +// can still emit token metrics with a model=unknown label. +// +// Provider-shape semantics for cached / cache-creation counts: +// +// - OpenAI: cachedInput is a SUBSET of inTokens. The cached portion is +// billed at CachedInputPer1K (or InputPer1K when no override), and the +// non-cached remainder of inTokens at InputPer1K. cacheCreation is +// ignored (OpenAI has no analogue). +// - Anthropic: cachedInput (cache_read) and cacheCreation are ADDITIVE to +// inTokens. The three buckets are billed at CacheReadPer1K, +// CacheCreationPer1K, and InputPer1K respectively, each falling back +// to InputPer1K when the corresponding rate is zero. +// - Other providers: cached and cacheCreation are ignored; cost is +// inTokens*InputPer1K + outTokens*OutputPer1K. +func (t *Table) Cost(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (float64, bool) { + c, ok := t.Costs(provider, model, inTokens, outTokens, cachedInput, cacheCreation) + return c.TotalUSD, ok +} + +// Costs is a per-request cost split. The four per-bucket fields are the base +// of the breakdown — one per token bucket the provider bills separately — and +// the two aggregates are derived from them: +// +// TotalUSD = InputUSD + CachedInputUSD + CacheCreationUSD + OutputUSD +// CacheUSD = CachedInputUSD + CacheCreationUSD +// +// InputUSD is always the cost of the *non-cached* input bucket, for both +// provider shapes: on OpenAI the cached subset is carved out of inTokens and +// billed as CachedInputUSD, so the two never double-count. Buckets a provider +// doesn't bill are zero, which keeps the identities above true everywhere. +type Costs struct { + InputUSD float64 + CachedInputUSD float64 + CacheCreationUSD float64 + OutputUSD float64 + TotalUSD float64 + CacheUSD float64 +} + +// newCosts assembles a split from its per-bucket parts, deriving the two +// aggregates so TotalUSD and CacheUSD can never drift from the breakdown. +func newCosts(input, cachedInput, cacheCreation, output float64) Costs { + return Costs{ + InputUSD: input, + CachedInputUSD: cachedInput, + CacheCreationUSD: cacheCreation, + OutputUSD: output, + TotalUSD: input + cachedInput + cacheCreation + output, + CacheUSD: cachedInput + cacheCreation, + } +} + +// Costs returns the estimated USD cost split for the given token counts, with +// the same semantics as Cost. +func (t *Table) Costs(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (Costs, bool) { + // Clamp negatives to zero before any pricing math so a malformed + // upstream count can never produce a negative cost. + if inTokens < 0 { + inTokens = 0 + } + if outTokens < 0 { + outTokens = 0 + } + if cachedInput < 0 { + cachedInput = 0 + } + if cacheCreation < 0 { + cacheCreation = 0 + } + if t == nil { + return Costs{}, false + } + byModel, ok := t.entries[provider] + if !ok { + return Costs{}, false + } + entry, ok := byModel[model] + if !ok { + return Costs{}, false + } + output := (float64(outTokens) / 1000.0) * entry.OutputPer1K + switch provider { + case "openai": + // cachedInput is a subset of inTokens; clamp so a malformed + // upstream (cached > total) can't produce a negative remainder. + clamped := cachedInput + if clamped > inTokens { + clamped = inTokens + } + cachedRate := entry.CachedInputPer1K + if cachedRate <= 0 { + cachedRate = entry.InputPer1K + } + nonCached := float64(inTokens-clamped) / 1000.0 * entry.InputPer1K + cached := float64(clamped) / 1000.0 * cachedRate + return newCosts(nonCached, cached, 0, output), true + case "anthropic", "bedrock": + // Bedrock-Anthropic returns the same additive cache buckets as + // first-party Anthropic; non-Anthropic Bedrock models simply report + // zero cache tokens, so this formula degrades to input + output. + readRate := entry.CacheReadPer1K + if readRate <= 0 { + readRate = entry.InputPer1K + } + createRate := entry.CacheCreationPer1K + if createRate <= 0 { + createRate = entry.InputPer1K + } + input := float64(inTokens) / 1000.0 * entry.InputPer1K + read := float64(cachedInput) / 1000.0 * readRate + create := float64(cacheCreation) / 1000.0 * createRate + return newCosts(input, read, create, output), true + default: + input := float64(inTokens) / 1000.0 * entry.InputPer1K + return newCosts(input, 0, 0, output), true + } +} + +// Has reports whether the provider/model pair is present in the table. +func (t *Table) Has(provider, model string) bool { + if t == nil { + return false + } + byModel, ok := t.entries[provider] + if !ok { + return false + } + _, ok = byModel[model] + return ok +} + +// pricingFile mirrors the on-disk YAML schema. Keys are provider names; the +// nested map keys are model names. +type pricingFile map[string]map[string]struct { + InputPer1K float64 `yaml:"input_per_1k"` + OutputPer1K float64 `yaml:"output_per_1k"` + CachedInputPer1K float64 `yaml:"cached_input_per_1k"` + CacheReadPer1K float64 `yaml:"cache_read_per_1k"` + CacheCreationPer1K float64 `yaml:"cache_creation_per_1k"` +} + +const ( + // ReloadInterval is the mtime-poll cadence for the background reloader. + ReloadInterval = 30 * time.Second + + // errorBackoff bounds how often the loader logs a repeated parse error. + errorBackoff = 5 * time.Minute +) + +var basenameRegex = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) + +// Loader is a confined, hot-reloadable pricing table reader. Construction +// must succeed against the target file; subsequent reload failures keep the +// previously-loaded table so callers never observe a blank price list. +type Loader struct { + baseDir string + fullPath string + pluginID string + table atomic.Pointer[Table] + mtime atomic.Int64 + failures metric.Int64Counter + interval time.Duration +} + +// NewLoader returns a pricing loader that overlays an optional file-based +// table on top of the embedded defaults. Missing override file, baseDir, or +// relPath is not an error: the loader keeps the embedded defaults so cost +// metadata is still emitted for known models. +// +// Errors: +// - bad basename, traversal segment, or absolute relPath are rejected so a +// misconfigured target surfaces immediately. +// - permission errors and YAML parse errors keep the defaults but log a +// warning; cost annotation does not silently break. +// +// failures is optional; pass nil in tests that do not care about +// reload-failure telemetry. +func NewLoader(baseDir, relPath, pluginID string, failures metric.Int64Counter) (*Loader, error) { + defaults := DefaultTable() + l := &Loader{ + baseDir: baseDir, + pluginID: pluginID, + failures: failures, + } + l.table.Store(defaults) + + if strings.TrimSpace(baseDir) == "" || strings.TrimSpace(relPath) == "" { + return l, nil + } + + full, err := resolveMiddlewareDataPath(baseDir, relPath) + if err != nil { + return nil, err + } + l.fullPath = full + + overlay, mtime, err := loadPricing(full) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + // Override file is optional. Defaults already stored. + return l, nil + } + // Symlink rejection, oversize file, parse failure, permission errors + // — surface so a misconfigured operator sees the problem instead of + // silently running with stale defaults. + return nil, fmt.Errorf("load pricing %s: %w", full, err) + } + l.table.Store(mergeOver(defaults, overlay)) + l.mtime.Store(mtime.UnixNano()) + return l, nil +} + +// Get returns the current pricing table. The returned pointer is immutable; +// callers must not mutate its contents. +func (l *Loader) Get() *Table { + if l == nil { + return nil + } + return l.table.Load() +} + +// WatchesFile reports whether this loader is bound to an override file on +// disk. False for defaults-only loaders (no operator override given). +// Callers use this to decide whether to spawn the mtime-poll goroutine. +func (l *Loader) WatchesFile() bool { + if l == nil { + return false + } + return l.fullPath != "" +} + +// SetReloadInterval overrides the mtime-poll cadence used by Reload. Calls +// after Reload has started have no effect on the running loop. Intended for +// tests; production code uses the default ReloadInterval. +func (l *Loader) SetReloadInterval(d time.Duration) { + if l == nil || d <= 0 { + return + } + l.interval = d +} + +// Reload runs a polling loop that checks the pricing file mtime every +// ReloadInterval (or the value passed to SetReloadInterval). Returns when +// ctx is cancelled. +func (l *Loader) Reload(ctx context.Context) { + if l == nil { + return + } + interval := l.interval + if interval <= 0 { + interval = ReloadInterval + } + t := time.NewTicker(interval) + defer t.Stop() + + var lastErrAt time.Time + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := l.reload(); err != nil { + if l.failures != nil { + l.failures.Add(ctx, 1, metric.WithAttributes( + attribute.String("plugin", l.pluginID), + )) + } + now := time.Now() + if now.Sub(lastErrAt) >= errorBackoff { + log.Warnf("llmobs: pricing reload failed for %s: %v", l.fullPath, err) + lastErrAt = now + } + } + } + } +} + +// reload performs a single-shot mtime check and reload. The reloaded +// override file is merged on top of the embedded defaults; missing override +// (e.g. operator deleted the file) is not an error and reverts to defaults. +func (l *Loader) reload() error { + if l.fullPath == "" { + // Defaults-only loader; nothing on disk to reload. + return nil + } + mtime, err := statMtime(l.fullPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + // File was removed since startup. Drop back to defaults and + // reset mtime so a future re-creation triggers a reload. + l.table.Store(DefaultTable()) + l.mtime.Store(0) + return nil + } + return err + } + if mtime.UnixNano() == l.mtime.Load() { + return nil + } + + overlay, newMtime, err := loadPricing(l.fullPath) + if err != nil { + return err + } + l.table.Store(mergeOver(DefaultTable(), overlay)) + l.mtime.Store(newMtime.UnixNano()) + return nil +} + +// resolveMiddlewareDataPath validates relPath is a safe basename and resolves +// it under baseDir. An additional cleaned-prefix check guards against +// CVE-style edge cases where Join is used with trailing path segments. +func resolveMiddlewareDataPath(baseDir, relPath string) (string, error) { + if strings.TrimSpace(baseDir) == "" { + return "", errors.New("middleware-data-dir is not configured") + } + if relPath == "" { + return "", errors.New("pricing path is empty") + } + if !basenameRegex.MatchString(relPath) { + return "", fmt.Errorf("pricing path %q is not a safe basename", relPath) + } + if filepath.IsAbs(relPath) { + return "", fmt.Errorf("pricing path %q must be a basename, not absolute", relPath) + } + + cleanBase, err := filepath.Abs(filepath.Clean(baseDir)) + if err != nil { + return "", fmt.Errorf("resolve middleware-data-dir: %w", err) + } + full := filepath.Join(cleanBase, relPath) + cleanedFull := filepath.Clean(full) + if !strings.HasPrefix(cleanedFull, cleanBase+string(filepath.Separator)) && cleanedFull != cleanBase { + return "", fmt.Errorf("pricing path %q escapes middleware-data-dir", relPath) + } + return cleanedFull, nil +} + +func parsePricingBytes(data []byte) (*Table, error) { + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + + var raw pricingFile + if err := dec.Decode(&raw); err != nil && !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("decode pricing yaml: %w", err) + } + + out := make(map[string]map[string]Entry, len(raw)) + for provider, models := range raw { + inner := make(map[string]Entry, len(models)) + for model, entry := range models { + for field, v := range map[string]float64{ + "input_per_1k": entry.InputPer1K, + "output_per_1k": entry.OutputPer1K, + "cached_input_per_1k": entry.CachedInputPer1K, + "cache_read_per_1k": entry.CacheReadPer1K, + "cache_creation_per_1k": entry.CacheCreationPer1K, + } { + if v < 0 || math.IsNaN(v) || math.IsInf(v, 0) { + return nil, fmt.Errorf("pricing %s/%s: %s must be a finite, non-negative rate, got %v", provider, model, field, v) + } + } + inner[model] = Entry{ + InputPer1K: entry.InputPer1K, + OutputPer1K: entry.OutputPer1K, + CachedInputPer1K: entry.CachedInputPer1K, + CacheReadPer1K: entry.CacheReadPer1K, + CacheCreationPer1K: entry.CacheCreationPer1K, + } + } + out[provider] = inner + } + return &Table{entries: out}, nil +} diff --git a/proxy/internal/llm/pricing/pricing_other.go b/proxy/internal/llm/pricing/pricing_other.go new file mode 100644 index 000000000..e65fffff1 --- /dev/null +++ b/proxy/internal/llm/pricing/pricing_other.go @@ -0,0 +1,20 @@ +//go:build !unix + +package pricing + +import ( + "fmt" + "time" +) + +// loadPricing is unavailable on non-Unix platforms because O_NOFOLLOW and +// fstat-from-FD are required to honour the spec's symlink-safety rules. The +// proxy is only deployed on Linux today; a Windows port would need an +// equivalent path-as-handle implementation. +func loadPricing(path string) (*Table, time.Time, error) { + return nil, time.Time{}, fmt.Errorf("llmobs pricing loader is not supported on this platform: %s", path) +} + +func statMtime(path string) (time.Time, error) { + return time.Time{}, fmt.Errorf("llmobs pricing loader is not supported on this platform: %s", path) +} diff --git a/proxy/internal/llm/pricing/pricing_test.go b/proxy/internal/llm/pricing/pricing_test.go new file mode 100644 index 000000000..7ac2a85dc --- /dev/null +++ b/proxy/internal/llm/pricing/pricing_test.go @@ -0,0 +1,432 @@ +//go:build unix + +package pricing + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func copyFixture(t *testing.T, src, dst string) { + t.Helper() + data, err := os.ReadFile(src) + require.NoError(t, err, "read source fixture") + require.NoError(t, os.WriteFile(dst, data, 0o600), "write target fixture") +} + +func TestNewLoader_HappyPath(t *testing.T) { + base := t.TempDir() + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing.yaml")) + + l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) + require.NoError(t, err, "NewLoader must succeed with a valid fixture") + table := l.Get() + require.NotNil(t, table, "table populated after load") + + cost, ok := table.Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0) + require.True(t, ok, "known provider/model resolves") + assert.InDelta(t, 0.00075, cost, 1e-9, "cost = 0.00015 + 0.0006 per 1k tokens") + + cost, ok = table.Cost("openai", "gpt-4o", 2000, 1000, 0, 0) + require.True(t, ok, "second known model resolves") + assert.InDelta(t, 0.015, cost, 1e-9, "cost for gpt-4o: 2*0.0025 + 1*0.01") + + cost, ok = table.Cost("anthropic", "claude-sonnet-4-5", 1000, 1000, 0, 0) + require.True(t, ok, "anthropic model resolves") + assert.InDelta(t, 0.018, cost, 1e-9, "cost for claude-sonnet-4-5: 0.003 + 0.015") +} + +// TestCost_OpenAICachedSubsetDiscount proves OpenAI's cached input +// tokens are billed at the configured cached_input_per_1k rate while +// the non-cached remainder of input_tokens is billed at the regular +// rate. Critical because OpenAI returns cached_tokens as a SUBSET of +// prompt_tokens — naïvely charging the cached count on top of +// prompt_tokens would double-bill that portion. +func TestCost_OpenAICachedSubsetDiscount(t *testing.T) { + tbl := &Table{entries: map[string]map[string]Entry{ + "openai": {"gpt-4o": { + InputPer1K: 0.0025, // 0.0025 USD per 1k input tokens + OutputPer1K: 0.01, + CachedInputPer1K: 0.00125, // 0.5x discount on cached + }}, + }} + // 1000 prompt tokens, 750 of which were cached. 250 non-cached + // at regular rate, 750 cached at the discount rate, 500 output. + cost, ok := tbl.Cost("openai", "gpt-4o", 1000, 500, 750, 0) + require.True(t, ok, "known model resolves") + want := (250.0/1000.0)*0.0025 + (750.0/1000.0)*0.00125 + (500.0/1000.0)*0.01 + assert.InDelta(t, want, cost, 1e-12, + "cached subset must bill at the discount rate; non-cached remainder at regular rate") +} + +// TestCost_OpenAICachedFallsBackToInputRate covers the operator +// opt-in contract: when CachedInputPer1K is unset (zero), cached +// tokens bill at the regular input rate. This matches today's +// behaviour (cached counts weren't extracted at all so they +// implicitly billed at the input rate via prompt_tokens). +func TestCost_OpenAICachedFallsBackToInputRate(t *testing.T) { + tbl := &Table{entries: map[string]map[string]Entry{ + "openai": {"gpt-4o": {InputPer1K: 0.0025, OutputPer1K: 0.01}}, + }} + cost, ok := tbl.Cost("openai", "gpt-4o", 1000, 500, 750, 0) + require.True(t, ok) + want := 0.0025 + (500.0/1000.0)*0.01 + assert.InDelta(t, want, cost, 1e-12, + "absent cached_input_per_1k rate must fall back to input_per_1k — same as pre-feature behaviour") +} + +// TestCost_OpenAIClampsCachedToInputCount is the defensive guard +// against malformed upstream responses that report cached_tokens > +// prompt_tokens. We clamp so the formula never produces a negative +// "non-cached remainder" multiplied by the input rate. +func TestCost_OpenAIClampsCachedToInputCount(t *testing.T) { + tbl := &Table{entries: map[string]map[string]Entry{ + "openai": {"gpt-4o": {InputPer1K: 0.0025, OutputPer1K: 0.01, CachedInputPer1K: 0.00125}}, + }} + cost, ok := tbl.Cost("openai", "gpt-4o", 100, 0, 9999, 0) + require.True(t, ok) + // All 100 cached, 0 non-cached. Output is 0. + want := (100.0 / 1000.0) * 0.00125 + assert.InDelta(t, want, cost, 1e-12, + "cached count > input count must clamp to input — never bill negative non-cached tokens") +} + +// TestCost_AnthropicCacheReadAndCreationAreAdditive proves the +// Anthropic shape: cache_read and cache_creation tokens are +// ADDITIVE to input_tokens (not subset), each billed at its own +// configured rate. The two rates pull in opposite directions — +// cache_read is the cheaper read-from-cache rate (≈0.1× input), +// cache_creation is the more expensive write-to-cache rate +// (≈1.25× input). +func TestCost_AnthropicCacheReadAndCreationAreAdditive(t *testing.T) { + tbl := &Table{entries: map[string]map[string]Entry{ + "anthropic": {"claude-sonnet": { + InputPer1K: 0.003, + OutputPer1K: 0.015, + CacheReadPer1K: 0.0003, // 0.1x of input + CacheCreationPer1K: 0.00375, // 1.25x of input + }}, + }} + // 256 regular input + 768 cache_read + 512 cache_creation + + // 200 output. Each input bucket bills at its own rate. + cost, ok := tbl.Cost("anthropic", "claude-sonnet", 256, 200, 768, 512) + require.True(t, ok, "known model resolves") + want := (256.0/1000.0)*0.003 + + (768.0/1000.0)*0.0003 + + (512.0/1000.0)*0.00375 + + (200.0/1000.0)*0.015 + assert.InDelta(t, want, cost, 1e-12, + "each Anthropic input bucket must bill at its own configured rate") +} + +// TestCost_AnthropicCacheRatesFallBackToInput covers the no-opt-in +// path: when neither CacheReadPer1K nor CacheCreationPer1K is set, +// cache tokens bill at the regular input rate. This is more +// accurate than today's behaviour (cache tokens ignored entirely) +// without requiring operators to opt in via YAML. +func TestCost_AnthropicCacheRatesFallBackToInput(t *testing.T) { + tbl := &Table{entries: map[string]map[string]Entry{ + "anthropic": {"claude-sonnet": {InputPer1K: 0.003, OutputPer1K: 0.015}}, + }} + cost, ok := tbl.Cost("anthropic", "claude-sonnet", 256, 200, 768, 512) + require.True(t, ok) + // Without overrides: every input bucket at input_per_1k. + want := ((256.0+768.0+512.0)/1000.0)*0.003 + (200.0/1000.0)*0.015 + assert.InDelta(t, want, cost, 1e-12, + "absent cache rates must fall back to input_per_1k — Anthropic cache tokens were ignored before this change, billing at input rate is more accurate as a default") +} + +func TestNewLoader_UnknownModel(t *testing.T) { + base := t.TempDir() + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing.yaml")) + + l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) + require.NoError(t, err) + + _, ok := l.Get().Cost("openai", "fantasy-model", 10, 10, 0, 0) + assert.False(t, ok, "unknown model returns ok=false") + + _, ok = l.Get().Cost("cohere", "anything", 10, 10, 0, 0) + assert.False(t, ok, "unknown provider returns ok=false") +} + +func TestNewLoader_InvalidYAMLRejected(t *testing.T) { + base := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(base, "pricing.yaml"), []byte("\t- this is not: valid: yaml: :["), 0o600)) + + _, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) + require.Error(t, err, "invalid YAML must surface as construction error") +} + +func TestLoader_ReloadKeepsPreviousOnParseError(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "pricing.yaml") + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), target) + + l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) + require.NoError(t, err) + require.NotNil(t, l.Get(), "initial table populated") + + // Overwrite with content that violates the strict schema (extra field) + // plus a bumped mtime to trigger reload. + require.NoError(t, os.WriteFile(target, []byte("openai:\n gpt-4o:\n input_per_1k: 1.0\n output_per_1k: 2.0\n bogus_field: nope\n"), 0o600)) + future := time.Now().Add(time.Hour) + require.NoError(t, os.Chtimes(target, future, future)) + + err = l.reload() + require.Error(t, err, "parse error surfaced by reload()") + + cost, ok := l.Get().Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0) + require.True(t, ok, "previous table still available after parse failure") + assert.InDelta(t, 0.00075, cost, 1e-9, "previous cost preserved") +} + +func TestLoader_ReloadNoChangeIsNoOp(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "pricing.yaml") + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), target) + + l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) + require.NoError(t, err) + ptrBefore := l.Get() + + require.NoError(t, l.reload(), "no-change reload must not error") + ptrAfter := l.Get() + assert.Same(t, ptrBefore, ptrAfter, "table pointer unchanged when mtime unchanged") +} + +func TestLoader_ReloadDetectsChange(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "pricing.yaml") + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), target) + + l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) + require.NoError(t, err) + + updated := []byte("openai:\n gpt-4o-mini:\n input_per_1k: 1.00\n output_per_1k: 2.00\n") + require.NoError(t, os.WriteFile(target, updated, 0o600)) + future := time.Now().Add(time.Hour) + require.NoError(t, os.Chtimes(target, future, future)) + + require.NoError(t, l.reload(), "reload must succeed on valid new content") + + cost, ok := l.Get().Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0) + require.True(t, ok, "updated model still present") + assert.InDelta(t, 3.0, cost, 0.0001, "new prices are applied: 1 + 2 per 1k") +} + +// TestLoader_ReloadGoroutinePicksUpChanges proves the background goroutine +// started via Reload actually swaps the pricing table when the file changes +// on disk. Without that goroutine running, pricing edits would never reach +// requests until a proxy restart. +func TestLoader_ReloadGoroutinePicksUpChanges(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "pricing.yaml") + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), target) + + l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) + require.NoError(t, err) + l.SetReloadInterval(20 * time.Millisecond) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan struct{}) + go func() { + l.Reload(ctx) + close(done) + }() + + // Before any rewrite, the loader holds the fixture's prices. + costBefore, ok := l.Get().Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0) + require.True(t, ok, "fixture model must resolve initially") + assert.InDelta(t, 0.00075, costBefore, 1e-9, "fixture prices apply before rewrite") + + updated := []byte("openai:\n gpt-4o-mini:\n input_per_1k: 1.00\n output_per_1k: 2.00\n") + require.NoError(t, os.WriteFile(target, updated, 0o600)) + future := time.Now().Add(time.Hour) + require.NoError(t, os.Chtimes(target, future, future)) + + deadline := time.Now().Add(2 * time.Second) + for { + cost, ok := l.Get().Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0) + if ok && cost > 2.5 { + break + } + if time.Now().After(deadline) { + t.Fatalf("background reloader did not pick up rewrite within deadline") + } + time.Sleep(10 * time.Millisecond) + } + + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Reload loop did not exit after cancel") + } +} + +func TestLoader_ReloadBackgroundLoopCancellation(t *testing.T) { + base := t.TempDir() + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing.yaml")) + l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + l.Reload(ctx) + close(done) + }() + cancel() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Reload loop did not exit on context cancel") + } +} + +func TestNewLoader_PathValidation(t *testing.T) { + base := t.TempDir() + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing.yaml")) + + cases := []struct { + name string + relPath string + }{ + {"traversal", "../../etc/passwd"}, + {"absolute", "/etc/passwd"}, + {"slash in basename", "sub/pricing.yaml"}, + {"control chars", "pricing\x00.yaml"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := NewLoader(base, tc.relPath, "llm_observability", nil) + require.Error(t, err, "NewLoader must reject %q", tc.relPath) + }) + } + + // Empty relPath is no longer a validation error: the loader treats it + // as "no override file, defaults only" so cost metadata is still + // emitted for the embedded models out of the box. + t.Run("empty falls back to defaults", func(t *testing.T) { + l, err := NewLoader(base, "", "llm_observability", nil) + require.NoError(t, err, "empty relPath should yield a defaults-only loader") + require.NotNil(t, l, "loader must be returned") + require.False(t, l.WatchesFile(), "no file watching when no override is given") + _, ok := l.Get().Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0) + assert.True(t, ok, "embedded defaults should still resolve gpt-4o-mini") + }) +} + +// TestNewLoader_PathValidation_Extended covers the remaining attack shapes +// called out in C2: dot references, embedded traversal segments, and a +// newline in the basename. The basename regex must reject each one even +// though filepath.Clean would otherwise collapse them. +func TestNewLoader_PathValidation_Extended(t *testing.T) { + base := t.TempDir() + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing.yaml")) + + cases := []struct { + name string + relPath string + }{ + {"dot", "."}, + {"dotdot", ".."}, + {"relative traversal", "../pricing.yaml"}, + {"embedded slash", "pri/cing.yaml"}, + {"newline", "pricing\n.yaml"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := NewLoader(base, tc.relPath, "llm_observability", nil) + require.Error(t, err, "NewLoader must reject %q", tc.relPath) + }) + } +} + +// TestNewLoader_ValidBasenameLoads proves the allowlist is exclusive: a +// basename containing only safe characters under baseDir loads. Without this +// a regression that over-tightened the regex would silently break valid +// deployments. +func TestNewLoader_ValidBasenameLoads(t *testing.T) { + base := t.TempDir() + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing-v2_prod.yaml")) + + l, err := NewLoader(base, "pricing-v2_prod.yaml", "llm_observability", nil) + require.NoError(t, err, "basename with _, -, . must load") + require.NotNil(t, l.Get(), "table populated") +} + +// TestNewLoader_SymlinkOutsideBaseDirRejected constructs a symlink under +// baseDir that points to a file outside it. O_NOFOLLOW must refuse to open +// the symlink even though the symlink path itself is a valid basename under +// baseDir. +func TestNewLoader_SymlinkOutsideBaseDirRejected(t *testing.T) { + outside := t.TempDir() + target := filepath.Join(outside, "evil.yaml") + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), target) + + base := t.TempDir() + link := filepath.Join(base, "pricing.yaml") + require.NoError(t, os.Symlink(target, link), "symlink setup") + + _, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) + require.Error(t, err, "O_NOFOLLOW must reject symlink even when it points outside baseDir") +} + +func TestNewLoader_SymlinkRejected(t *testing.T) { + base := t.TempDir() + concrete := filepath.Join(base, "real.yaml") + copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), concrete) + + link := filepath.Join(base, "pricing.yaml") + require.NoError(t, os.Symlink(concrete, link), "symlink setup") + + _, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) + require.Error(t, err, "O_NOFOLLOW must reject symlinked targets") +} + +func TestTableCost_NilSafe(t *testing.T) { + var t1 *Table + cost, ok := t1.Cost("x", "y", 1, 1, 0, 0) + assert.False(t, ok, "nil table reports unknown") + assert.Zero(t, cost, "nil table returns zero cost") + assert.False(t, t1.Has("x", "y"), "nil table has nothing") +} + +func TestLoaderGet_NilSafe(t *testing.T) { + var l *Loader + assert.Nil(t, l.Get(), "nil loader returns nil table") +} + +// TestNewLoader_RejectsOversizedFile_FixesM4 proves the loader bounds reads +// at maxPricingBytes so a hostile file cannot exhaust process memory. +func TestNewLoader_RejectsOversizedFile_FixesM4(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "pricing.yaml") + + // Build a YAML payload larger than the cap. We pad with valid YAML + // comments so a partial read would still fail the size check rather + // than the parser. + header := "openai:\n" + bigComment := make([]byte, maxPricingBytes+1024) + for i := range bigComment { + bigComment[i] = ' ' + } + bigComment[0] = '#' + bigComment[len(bigComment)-1] = '\n' + payload := append([]byte(header), bigComment...) + require.NoError(t, os.WriteFile(target, payload, 0o600)) + + _, err := NewLoader(base, "pricing.yaml", "llm_observability", nil) + require.Error(t, err, "oversized pricing file must be rejected") + assert.Contains(t, err.Error(), "exceeds", "rejection must reference the byte cap") +} diff --git a/proxy/internal/llm/pricing/pricing_unix.go b/proxy/internal/llm/pricing/pricing_unix.go new file mode 100644 index 000000000..4f3ea33a2 --- /dev/null +++ b/proxy/internal/llm/pricing/pricing_unix.go @@ -0,0 +1,68 @@ +//go:build unix + +package pricing + +import ( + "fmt" + "io" + "os" + "syscall" + "time" + + log "github.com/sirupsen/logrus" +) + +// maxPricingBytes caps the size of the pricing YAML on read so a hostile or +// runaway file cannot exhaust process memory during reload. 1 MiB is several +// orders of magnitude larger than any reasonable pricing table. +const maxPricingBytes int64 = 1 << 20 + +// loadPricing opens the file with O_NOFOLLOW, fstats the open descriptor, +// and parses from that same descriptor. Never re-opens by path so a +// mid-read rename or symlink swap cannot substitute content. Bytes are +// capped at maxPricingBytes so the loader cannot be coerced into reading an +// unbounded file. +func loadPricing(path string) (*Table, time.Time, error) { + f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0) + if err != nil { + return nil, time.Time{}, fmt.Errorf("open %s: %w", path, err) + } + defer func() { + if cerr := f.Close(); cerr != nil { + log.Debugf("close pricing file %s: %v", path, cerr) + } + }() + + info, err := f.Stat() + if err != nil { + return nil, time.Time{}, fmt.Errorf("fstat %s: %w", path, err) + } + if !info.Mode().IsRegular() { + return nil, time.Time{}, fmt.Errorf("pricing file %s is not a regular file", path) + } + + data, err := io.ReadAll(io.LimitReader(f, maxPricingBytes+1)) + if err != nil { + return nil, time.Time{}, fmt.Errorf("read %s: %w", path, err) + } + if int64(len(data)) > maxPricingBytes { + return nil, time.Time{}, fmt.Errorf("pricing file %s exceeds %d bytes", path, maxPricingBytes) + } + + table, err := parsePricingBytes(data) + if err != nil { + return nil, time.Time{}, err + } + return table, info.ModTime(), nil +} + +// statMtime returns the mtime of the file at path. It uses lstat semantics +// via os.Lstat so a symlink swap is detected even though O_NOFOLLOW will +// later reject the open. +func statMtime(path string) (time.Time, error) { + info, err := os.Lstat(path) + if err != nil { + return time.Time{}, fmt.Errorf("lstat %s: %w", path, err) + } + return info.ModTime(), nil +} diff --git a/proxy/internal/llm/sse.go b/proxy/internal/llm/sse.go new file mode 100644 index 000000000..3d33ab577 --- /dev/null +++ b/proxy/internal/llm/sse.go @@ -0,0 +1,117 @@ +package llm + +import ( + "bufio" + "errors" + "fmt" + "io" + "strings" +) + +// Event represents a single server-sent event. Type is the dispatch name +// carried on an "event:" line (empty when the stream uses only "data:" +// lines). Data is the concatenation of every "data:" line that made up the +// event, joined by a single newline. +type Event struct { + Type string + Data string +} + +// Scanner reads SSE events from an underlying byte stream. Events are +// delimited by a blank line ("\n\n"). CRLF line endings are normalized to LF +// transparently so fixtures captured from live servers can be replayed. +// +// Scanner is not safe for concurrent use. +type Scanner struct { + r *bufio.Reader + maxLine int +} + +// NewScanner wraps the given reader. The default underlying buffer size is +// large enough for typical provider events (~64 KiB); callers needing +// larger events can wrap the reader in their own bufio.Reader beforehand. +func NewScanner(r io.Reader) *Scanner { + return &Scanner{ + r: bufio.NewReaderSize(r, 64*1024), + maxLine: 1 << 20, + } +} + +// Next returns the next event. It returns io.EOF after the final event has +// been consumed. A trailing event that is not terminated by a blank line is +// still returned before io.EOF so that servers which close the connection +// without a trailing newline are handled correctly. +func (s *Scanner) Next() (Event, error) { + var ( + event Event + dataBuf strings.Builder + hasData bool + hasAny bool + ) + + for { + line, err := s.readLine() + if err != nil { + if errors.Is(err, io.EOF) && hasAny { + event.Data = dataBuf.String() + return event, nil + } + return Event{}, err + } + + if line == "" { + if !hasAny { + continue + } + event.Data = dataBuf.String() + return event, nil + } + + hasAny = true + if strings.HasPrefix(line, ":") { + continue + } + + field, value := splitField(line) + switch field { + case "event": + event.Type = value + case "data": + if hasData { + dataBuf.WriteByte('\n') + } + dataBuf.WriteString(value) + hasData = true + } + } +} + +func (s *Scanner) readLine() (string, error) { + line, err := s.r.ReadString('\n') + if err != nil { + if errors.Is(err, io.EOF) && line != "" { + return trimEOL(line), nil + } + return "", err + } + if len(line) > s.maxLine { + return "", fmt.Errorf("sse line exceeds %d bytes", s.maxLine) + } + return trimEOL(line), nil +} + +func trimEOL(line string) string { + line = strings.TrimRight(line, "\n") + line = strings.TrimRight(line, "\r") + return line +} + +func splitField(line string) (string, string) { + idx := strings.IndexByte(line, ':') + if idx < 0 { + return line, "" + } + field := line[:idx] + value := strings.TrimPrefix(line[idx+1:], " ") + return field, value +} diff --git a/proxy/internal/llm/sse_test.go b/proxy/internal/llm/sse_test.go new file mode 100644 index 000000000..96cecc111 --- /dev/null +++ b/proxy/internal/llm/sse_test.go @@ -0,0 +1,175 @@ +package llm + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func collectEvents(t *testing.T, r io.Reader) []Event { + t.Helper() + s := NewScanner(r) + var out []Event + for { + ev, err := s.Next() + if errors.Is(err, io.EOF) { + return out + } + require.NoError(t, err, "unexpected error scanning SSE") + out = append(out, ev) + } +} + +func TestSSEScanner_OpenAIFixture(t *testing.T) { + f, err := os.Open(filepath.Join("fixtures", "openai_stream.txt")) + require.NoError(t, err, "fixture must be openable") + defer f.Close() + + events := collectEvents(t, f) + require.Len(t, events, 4, "expected 4 data frames (3 chunks + [DONE])") + + for _, ev := range events { + assert.Empty(t, ev.Type, "OpenAI stream uses data-only frames") + } + assert.Contains(t, events[2].Data, `"usage"`, "third chunk carries usage block") + assert.Equal(t, "[DONE]", events[3].Data, "final frame is the OpenAI DONE sentinel") +} + +func TestSSEScanner_AnthropicFixture(t *testing.T) { + f, err := os.Open(filepath.Join("fixtures", "anthropic_stream.txt")) + require.NoError(t, err, "fixture must be openable") + defer f.Close() + + events := collectEvents(t, f) + require.Len(t, events, 7, "expected 7 Anthropic events") + + types := make([]string, 0, len(events)) + for _, ev := range events { + types = append(types, ev.Type) + } + assert.Equal(t, []string{ + "message_start", + "content_block_start", + "content_block_delta", + "content_block_delta", + "content_block_stop", + "message_delta", + "message_stop", + }, types, "Anthropic event ordering matches fixture") + + var deltaUsage Event + for _, ev := range events { + if ev.Type == "message_delta" { + deltaUsage = ev + break + } + } + assert.Contains(t, deltaUsage.Data, `"output_tokens":45`, "message_delta carries partial usage") +} + +func TestSSEScanner_MultilineData(t *testing.T) { + raw := "event: ping\ndata: line1\ndata: line2\ndata: line3\n\n" + events := collectEvents(t, strings.NewReader(raw)) + + require.Len(t, events, 1, "one logical event from three data lines") + assert.Equal(t, "ping", events[0].Type, "event name honored") + assert.Equal(t, "line1\nline2\nline3", events[0].Data, "data lines joined with newline") +} + +func TestSSEScanner_CRLF(t *testing.T) { + raw := "event: foo\r\ndata: bar\r\n\r\ndata: baz\r\n\r\n" + events := collectEvents(t, strings.NewReader(raw)) + + require.Len(t, events, 2, "CRLF-delimited events recognized") + assert.Equal(t, "foo", events[0].Type, "first event type preserved") + assert.Equal(t, "bar", events[0].Data, "first event data preserved") + assert.Empty(t, events[1].Type, "second event has no event name") + assert.Equal(t, "baz", events[1].Data, "second event data preserved") +} + +func TestSSEScanner_EmptyInput(t *testing.T) { + s := NewScanner(strings.NewReader("")) + _, err := s.Next() + require.ErrorIs(t, err, io.EOF, "empty input yields immediate EOF") +} + +func TestSSEScanner_CommentIgnored(t *testing.T) { + raw := ": this is a comment\ndata: hi\n\n" + events := collectEvents(t, strings.NewReader(raw)) + require.Len(t, events, 1, "comment line does not emit an event") + assert.Equal(t, "hi", events[0].Data, "data line honoured after comment") +} + +func TestSSEScanner_TrailingWithoutBlankLine(t *testing.T) { + raw := "event: foo\ndata: bar\n" + events := collectEvents(t, strings.NewReader(raw)) + require.Len(t, events, 1, "trailing event without blank line still emitted") + assert.Equal(t, "foo", events[0].Type) + assert.Equal(t, "bar", events[0].Data) +} + +// TestSSEScanner_ManyConsecutiveEmptyLines feeds a stream that is nothing +// but empty lines. The scanner must terminate without panic — empty lines +// alone do not constitute an event and must yield io.EOF. +func TestSSEScanner_ManyConsecutiveEmptyLines(t *testing.T) { + raw := strings.Repeat("\n", 100) + s := NewScanner(strings.NewReader(raw)) + _, err := s.Next() + require.ErrorIs(t, err, io.EOF, "100 empty lines must terminate as EOF without panic") +} + +// TestSSEScanner_InterleavedCRLFAndLF mixes \r\n and \n terminators within +// the same event. The scanner normalizes both and must still recover a +// coherent event. +func TestSSEScanner_InterleavedCRLFAndLF(t *testing.T) { + raw := "event: mix\r\ndata: first\ndata: second\r\n\n" + events := collectEvents(t, strings.NewReader(raw)) + require.Len(t, events, 1, "mixed line endings must still produce one event") + assert.Equal(t, "mix", events[0].Type) + assert.Equal(t, "first\nsecond", events[0].Data, "both data lines joined") +} + +// TestSSEScanner_LongSingleDataLine constructs a single data line that +// exceeds the default bufio buffer (64 KiB) but stays under the scanner +// maxLine. The scanner must round-trip the value intact without panicking +// or truncating silently. +func TestSSEScanner_LongSingleDataLine(t *testing.T) { + big := strings.Repeat("x", 80<<10) + raw := "data: " + big + "\n\n" + events := collectEvents(t, strings.NewReader(raw)) + require.Len(t, events, 1, "long single-line event must be emitted") + assert.Equal(t, big, events[0].Data, "long data preserved") +} + +// TestSSEScanner_BinaryGarbageInData validates that non-printable bytes +// inside a data line do not crash the parser. The scanner should either +// round-trip them or return a well-formed error — never panic. +func TestSSEScanner_BinaryGarbageInData(t *testing.T) { + raw := "data: \x00\x01\x02\xff\xfe\n\n" + defer func() { + if r := recover(); r != nil { + t.Fatalf("scanner panicked on binary garbage: %v", r) + } + }() + s := NewScanner(strings.NewReader(raw)) + ev, err := s.Next() + require.NoError(t, err, "binary bytes in data should not surface as error") + assert.Equal(t, "\x00\x01\x02\xff\xfe", ev.Data, "binary payload round-trips") +} + +// TestSSEScanner_UnknownFieldsIgnored stresses the field parser by sending +// unrecognized field names ("id:", "retry:", "custom:"). They must be +// silently ignored per the SSE spec; the scanner must not panic or emit +// spurious events. +func TestSSEScanner_UnknownFieldsIgnored(t *testing.T) { + raw := "id: 1\nretry: 5000\ncustom: value\ndata: payload\n\n" + events := collectEvents(t, strings.NewReader(raw)) + require.Len(t, events, 1, "unknown fields must not spawn extra events") + assert.Equal(t, "payload", events[0].Data, "data field survives amid unknown fields") +} diff --git a/proxy/internal/metrics/l4_metrics_test.go b/proxy/internal/metrics/l4_metrics_test.go new file mode 100644 index 000000000..055158828 --- /dev/null +++ b/proxy/internal/metrics/l4_metrics_test.go @@ -0,0 +1,69 @@ +package metrics_test + +import ( + "context" + "reflect" + "testing" + "time" + + promexporter "go.opentelemetry.io/otel/exporters/prometheus" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + + "github.com/netbirdio/netbird/proxy/internal/metrics" + "github.com/netbirdio/netbird/proxy/internal/types" +) + +func newTestMetrics(t *testing.T) *metrics.Metrics { + t.Helper() + + exporter, err := promexporter.New() + if err != nil { + t.Fatalf("create prometheus exporter: %v", err) + } + + provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(exporter)) + pkg := reflect.TypeOf(metrics.Metrics{}).PkgPath() + meter := provider.Meter(pkg) + + m, err := metrics.New(context.Background(), meter) + if err != nil { + t.Fatalf("create metrics: %v", err) + } + return m +} + +func TestL4ServiceGauge(t *testing.T) { + m := newTestMetrics(t) + + m.L4ServiceAdded(types.ServiceModeTCP) + m.L4ServiceAdded(types.ServiceModeTCP) + m.L4ServiceAdded(types.ServiceModeUDP) + m.L4ServiceRemoved(types.ServiceModeTCP) +} + +func TestTCPRelayMetrics(t *testing.T) { + m := newTestMetrics(t) + + acct := types.AccountID("acct-1") + + m.TCPRelayStarted(acct) + m.TCPRelayStarted(acct) + m.TCPRelayEnded(acct, 10*time.Second, 1000, 500) + m.TCPRelayDialError(acct) + m.TCPRelayRejected(acct) +} + +func TestUDPSessionMetrics(t *testing.T) { + m := newTestMetrics(t) + + acct := types.AccountID("acct-2") + + m.UDPSessionStarted(acct) + m.UDPSessionStarted(acct) + m.UDPSessionEnded(acct) + m.UDPSessionDialError(acct) + m.UDPSessionRejected(acct) + m.UDPPacketRelayed(types.RelayDirectionClientToBackend, 100) + m.UDPPacketRelayed(types.RelayDirectionClientToBackend, 200) + m.UDPPacketRelayed(types.RelayDirectionBackendToClient, 150) +} diff --git a/proxy/internal/metrics/metrics.go b/proxy/internal/metrics/metrics.go new file mode 100644 index 000000000..5fd23d934 --- /dev/null +++ b/proxy/internal/metrics/metrics.go @@ -0,0 +1,459 @@ +package metrics + +import ( + "context" + "net/http" + "sync" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + + "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/proxy/internal/responsewriter" + "github.com/netbirdio/netbird/proxy/internal/types" +) + +// Metrics collects OpenTelemetry metrics for the proxy. +type Metrics struct { + ctx context.Context + meter metric.Meter + requestsTotal metric.Int64Counter + activeRequests metric.Int64UpDownCounter + configuredDomains metric.Int64UpDownCounter + totalPaths metric.Int64UpDownCounter + requestDuration metric.Int64Histogram + backendDuration metric.Int64Histogram + certificateIssueDuration metric.Int64Histogram + + // Management sync metrics. + snapshotSyncDuration metric.Int64Histogram + snapshotBatchDuration metric.Int64Histogram + addPeerDuration metric.Int64Histogram + + // L4 service-level metrics. + l4Services metric.Int64UpDownCounter + + // L4 TCP connection-level metrics. + tcpActiveConns metric.Int64UpDownCounter + tcpConnsTotal metric.Int64Counter + tcpConnDuration metric.Int64Histogram + tcpBytesTotal metric.Int64Counter + + // L4 UDP session-level metrics. + udpActiveSess metric.Int64UpDownCounter + udpSessionsTotal metric.Int64Counter + udpPacketsTotal metric.Int64Counter + udpBytesTotal metric.Int64Counter + + mappingsMux sync.Mutex + mappingPaths map[string]int +} + +// Meter returns the OpenTelemetry meter the bundle was built with, so other +// subsystems (e.g. the middleware manager) register instruments on the same +// meter. +func (m *Metrics) Meter() metric.Meter { + return m.meter +} + +// New creates a Metrics instance using the given OpenTelemetry meter. +func New(ctx context.Context, meter metric.Meter) (*Metrics, error) { + m := &Metrics{ + ctx: ctx, + meter: meter, + mappingPaths: make(map[string]int), + } + + if err := m.initHTTPMetrics(meter); err != nil { + return nil, err + } + if err := m.initSyncMetrics(meter); err != nil { + return nil, err + } + if err := m.initL4Metrics(meter); err != nil { + return nil, err + } + + return m, nil +} + +func (m *Metrics) initHTTPMetrics(meter metric.Meter) error { + var err error + + m.requestsTotal, err = meter.Int64Counter( + "proxy.http.request.counter", + metric.WithUnit("1"), + metric.WithDescription("Total number of requests made to the netbird proxy"), + ) + if err != nil { + return err + } + + m.activeRequests, err = meter.Int64UpDownCounter( + "proxy.http.active_requests", + metric.WithUnit("1"), + metric.WithDescription("Current in-flight requests handled by the netbird proxy"), + ) + if err != nil { + return err + } + + m.configuredDomains, err = meter.Int64UpDownCounter( + "proxy.domains.count", + metric.WithUnit("1"), + metric.WithDescription("Current number of domains configured on the netbird proxy"), + ) + if err != nil { + return err + } + + m.totalPaths, err = meter.Int64UpDownCounter( + "proxy.paths.count", + metric.WithUnit("1"), + metric.WithDescription("Total number of paths configured on the netbird proxy"), + ) + if err != nil { + return err + } + + m.requestDuration, err = meter.Int64Histogram( + "proxy.http.request.duration.ms", + metric.WithUnit("milliseconds"), + metric.WithDescription("Duration of requests made to the netbird proxy"), + ) + if err != nil { + return err + } + + m.backendDuration, err = meter.Int64Histogram( + "proxy.backend.duration.ms", + metric.WithUnit("milliseconds"), + metric.WithDescription("Duration of peer round trip time from the netbird proxy"), + ) + if err != nil { + return err + } + + m.certificateIssueDuration, err = meter.Int64Histogram( + "proxy.certificate.issue.duration.ms", + metric.WithUnit("milliseconds"), + metric.WithDescription("Duration of ACME certificate issuance"), + ) + return err +} + +func (m *Metrics) initSyncMetrics(meter metric.Meter) error { + var err error + + m.snapshotSyncDuration, err = meter.Int64Histogram( + "proxy.sync.snapshot.duration.ms", + metric.WithUnit("milliseconds"), + metric.WithDescription("Duration from management connect until the initial snapshot sync is complete"), + metric.WithExplicitBucketBoundaries(100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000, 120000, 300000), + ) + if err != nil { + return err + } + + m.snapshotBatchDuration, err = meter.Int64Histogram( + "proxy.sync.batch.duration.ms", + metric.WithUnit("milliseconds"), + metric.WithDescription("Duration to process a single mapping batch during initial snapshot sync"), + metric.WithExplicitBucketBoundaries(100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000, 120000, 300000), + ) + if err != nil { + return err + } + + m.addPeerDuration, err = meter.Int64Histogram( + "proxy.peer.add.duration.ms", + metric.WithUnit("milliseconds"), + metric.WithDescription("Duration to add a peer for an account (keygen + gRPC CreateProxyPeer + embed.New)"), + metric.WithExplicitBucketBoundaries(10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000), + ) + return err +} + +// RecordSnapshotSyncDuration records the total time from connect to sync-complete. +func (m *Metrics) RecordSnapshotSyncDuration(d time.Duration) { + m.snapshotSyncDuration.Record(m.ctx, d.Milliseconds()) +} + +// RecordSnapshotBatchDuration records the time to process one mapping batch during initial sync. +func (m *Metrics) RecordSnapshotBatchDuration(d time.Duration) { + m.snapshotBatchDuration.Record(m.ctx, d.Milliseconds()) +} + +// RecordAddPeerDuration records the time to create a new peer for an account. +func (m *Metrics) RecordAddPeerDuration(d time.Duration, err error) { + result := "success" + if err != nil { + result = "error" + } + m.addPeerDuration.Record(m.ctx, d.Milliseconds(), metric.WithAttributes( + attribute.String("result", result), + )) +} + +func (m *Metrics) initL4Metrics(meter metric.Meter) error { + var err error + + m.l4Services, err = meter.Int64UpDownCounter( + "proxy.l4.services.count", + metric.WithUnit("1"), + metric.WithDescription("Current number of configured L4 services (TCP/TLS/UDP) by mode"), + ) + if err != nil { + return err + } + + m.tcpActiveConns, err = meter.Int64UpDownCounter( + "proxy.tcp.active_connections", + metric.WithUnit("1"), + metric.WithDescription("Current number of active TCP/TLS relay connections"), + ) + if err != nil { + return err + } + + m.tcpConnsTotal, err = meter.Int64Counter( + "proxy.tcp.connections.total", + metric.WithUnit("1"), + metric.WithDescription("Total TCP/TLS relay connections by result and account"), + ) + if err != nil { + return err + } + + m.tcpConnDuration, err = meter.Int64Histogram( + "proxy.tcp.connection.duration.ms", + metric.WithUnit("milliseconds"), + metric.WithDescription("Duration of TCP/TLS relay connections"), + ) + if err != nil { + return err + } + + m.tcpBytesTotal, err = meter.Int64Counter( + "proxy.tcp.bytes.total", + metric.WithUnit("bytes"), + metric.WithDescription("Total bytes transferred through TCP/TLS relay by direction"), + ) + if err != nil { + return err + } + + m.udpActiveSess, err = meter.Int64UpDownCounter( + "proxy.udp.active_sessions", + metric.WithUnit("1"), + metric.WithDescription("Current number of active UDP relay sessions"), + ) + if err != nil { + return err + } + + m.udpSessionsTotal, err = meter.Int64Counter( + "proxy.udp.sessions.total", + metric.WithUnit("1"), + metric.WithDescription("Total UDP relay sessions by result and account"), + ) + if err != nil { + return err + } + + m.udpPacketsTotal, err = meter.Int64Counter( + "proxy.udp.packets.total", + metric.WithUnit("1"), + metric.WithDescription("Total UDP packets relayed by direction"), + ) + if err != nil { + return err + } + + m.udpBytesTotal, err = meter.Int64Counter( + "proxy.udp.bytes.total", + metric.WithUnit("bytes"), + metric.WithDescription("Total bytes transferred through UDP relay by direction"), + ) + return err +} + +type responseInterceptor struct { + *responsewriter.PassthroughWriter + status int + size int +} + +func (w *responseInterceptor) WriteHeader(status int) { + w.status = status + w.PassthroughWriter.WriteHeader(status) +} + +func (w *responseInterceptor) Write(b []byte) (int, error) { + size, err := w.PassthroughWriter.Write(b) + w.size += size + return size, err +} + +// Unwrap returns the underlying ResponseWriter so http.ResponseController +// can reach through to the original writer for Hijack/Flush operations. +func (w *responseInterceptor) Unwrap() http.ResponseWriter { + return w.PassthroughWriter +} + +// Middleware wraps an HTTP handler with request metrics. +func (m *Metrics) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + m.requestsTotal.Add(m.ctx, 1) + m.activeRequests.Add(m.ctx, 1) + + interceptor := &responseInterceptor{PassthroughWriter: responsewriter.New(w)} + + start := time.Now() + defer func() { + duration := time.Since(start) + m.activeRequests.Add(m.ctx, -1) + m.requestDuration.Record(m.ctx, duration.Milliseconds()) + }() + + next.ServeHTTP(interceptor, r) + }) +} + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} + +// RoundTripper wraps an http.RoundTripper with backend duration metrics. +func (m *Metrics) RoundTripper(next http.RoundTripper) http.RoundTripper { + return roundTripperFunc(func(req *http.Request) (*http.Response, error) { + start := time.Now() + res, err := next.RoundTrip(req) + duration := time.Since(start) + + m.backendDuration.Record(m.ctx, duration.Milliseconds()) + + return res, err + }) +} + +// AddMapping records that a domain mapping was added. +func (m *Metrics) AddMapping(mapping proxy.Mapping) { + m.mappingsMux.Lock() + defer m.mappingsMux.Unlock() + + newPathCount := len(mapping.Paths) + oldPathCount, exists := m.mappingPaths[mapping.Host] + + if !exists { + m.configuredDomains.Add(m.ctx, 1) + } + + pathDelta := newPathCount - oldPathCount + if pathDelta != 0 { + m.totalPaths.Add(m.ctx, int64(pathDelta)) + } + + m.mappingPaths[mapping.Host] = newPathCount +} + +// RemoveMapping records that a domain mapping was removed. +func (m *Metrics) RemoveMapping(mapping proxy.Mapping) { + m.mappingsMux.Lock() + defer m.mappingsMux.Unlock() + + oldPathCount, exists := m.mappingPaths[mapping.Host] + if !exists { + return + } + + m.configuredDomains.Add(m.ctx, -1) + m.totalPaths.Add(m.ctx, -int64(oldPathCount)) + + delete(m.mappingPaths, mapping.Host) +} + +// RecordCertificateIssuance records the duration of a certificate issuance. +func (m *Metrics) RecordCertificateIssuance(duration time.Duration) { + m.certificateIssueDuration.Record(m.ctx, duration.Milliseconds()) +} + +// L4ServiceAdded increments the L4 service gauge for the given mode. +func (m *Metrics) L4ServiceAdded(mode types.ServiceMode) { + m.l4Services.Add(m.ctx, 1, metric.WithAttributes(attribute.String("mode", string(mode)))) +} + +// L4ServiceRemoved decrements the L4 service gauge for the given mode. +func (m *Metrics) L4ServiceRemoved(mode types.ServiceMode) { + m.l4Services.Add(m.ctx, -1, metric.WithAttributes(attribute.String("mode", string(mode)))) +} + +// TCPRelayStarted records a new TCP relay connection starting. +func (m *Metrics) TCPRelayStarted(accountID types.AccountID) { + acct := attribute.String("account_id", string(accountID)) + m.tcpActiveConns.Add(m.ctx, 1, metric.WithAttributes(acct)) + m.tcpConnsTotal.Add(m.ctx, 1, metric.WithAttributes(acct, attribute.String("result", "success"))) +} + +// TCPRelayEnded records a TCP relay connection ending and accumulates bytes and duration. +func (m *Metrics) TCPRelayEnded(accountID types.AccountID, duration time.Duration, srcToDst, dstToSrc int64) { + acct := attribute.String("account_id", string(accountID)) + m.tcpActiveConns.Add(m.ctx, -1, metric.WithAttributes(acct)) + m.tcpConnDuration.Record(m.ctx, duration.Milliseconds(), metric.WithAttributes(acct)) + m.tcpBytesTotal.Add(m.ctx, srcToDst, metric.WithAttributes(attribute.String("direction", "client_to_backend"))) + m.tcpBytesTotal.Add(m.ctx, dstToSrc, metric.WithAttributes(attribute.String("direction", "backend_to_client"))) +} + +// TCPRelayDialError records a dial failure for a TCP relay. +func (m *Metrics) TCPRelayDialError(accountID types.AccountID) { + m.tcpConnsTotal.Add(m.ctx, 1, metric.WithAttributes( + attribute.String("account_id", string(accountID)), + attribute.String("result", "dial_error"), + )) +} + +// TCPRelayRejected records a rejected TCP relay (semaphore full). +func (m *Metrics) TCPRelayRejected(accountID types.AccountID) { + m.tcpConnsTotal.Add(m.ctx, 1, metric.WithAttributes( + attribute.String("account_id", string(accountID)), + attribute.String("result", "rejected"), + )) +} + +// UDPSessionStarted records a new UDP session starting. +func (m *Metrics) UDPSessionStarted(accountID types.AccountID) { + acct := attribute.String("account_id", string(accountID)) + m.udpActiveSess.Add(m.ctx, 1, metric.WithAttributes(acct)) + m.udpSessionsTotal.Add(m.ctx, 1, metric.WithAttributes(acct, attribute.String("result", "success"))) +} + +// UDPSessionEnded records a UDP session ending. +func (m *Metrics) UDPSessionEnded(accountID types.AccountID) { + m.udpActiveSess.Add(m.ctx, -1, metric.WithAttributes(attribute.String("account_id", string(accountID)))) +} + +// UDPSessionDialError records a dial failure for a UDP session. +func (m *Metrics) UDPSessionDialError(accountID types.AccountID) { + m.udpSessionsTotal.Add(m.ctx, 1, metric.WithAttributes( + attribute.String("account_id", string(accountID)), + attribute.String("result", "dial_error"), + )) +} + +// UDPSessionRejected records a rejected UDP session (limit or rate limited). +func (m *Metrics) UDPSessionRejected(accountID types.AccountID) { + m.udpSessionsTotal.Add(m.ctx, 1, metric.WithAttributes( + attribute.String("account_id", string(accountID)), + attribute.String("result", "rejected"), + )) +} + +// UDPPacketRelayed records a packet relayed in the given direction with its size in bytes. +func (m *Metrics) UDPPacketRelayed(direction types.RelayDirection, bytes int) { + dir := attribute.String("direction", string(direction)) + m.udpPacketsTotal.Add(m.ctx, 1, metric.WithAttributes(dir)) + m.udpBytesTotal.Add(m.ctx, int64(bytes), metric.WithAttributes(dir)) +} diff --git a/proxy/internal/metrics/metrics_test.go b/proxy/internal/metrics/metrics_test.go new file mode 100644 index 000000000..f81072eda --- /dev/null +++ b/proxy/internal/metrics/metrics_test.go @@ -0,0 +1,83 @@ +package metrics_test + +import ( + "context" + "net/http" + "net/url" + "reflect" + "testing" + + "github.com/google/go-cmp/cmp" + "go.opentelemetry.io/otel/exporters/prometheus" + "go.opentelemetry.io/otel/sdk/metric" + + "github.com/netbirdio/netbird/proxy/internal/metrics" +) + +type testRoundTripper struct { + response *http.Response + err error +} + +func (t *testRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return t.response, t.err +} + +func TestMetrics_RoundTripper(t *testing.T) { + testResponse := http.Response{ + StatusCode: http.StatusOK, + Body: http.NoBody, + } + + tests := map[string]struct { + roundTripper http.RoundTripper + request *http.Request + response *http.Response + err error + }{ + "ok": { + roundTripper: &testRoundTripper{response: &testResponse}, + request: &http.Request{Method: "GET", URL: &url.URL{Path: "/foo"}}, + response: &testResponse, + }, + "nil url": { + roundTripper: &testRoundTripper{response: &testResponse}, + request: &http.Request{Method: "GET", URL: nil}, + response: &testResponse, + }, + "nil response": { + roundTripper: &testRoundTripper{response: nil}, + request: &http.Request{Method: "GET", URL: &url.URL{Path: "/foo"}}, + }, + } + + exporter, err := prometheus.New() + if err != nil { + t.Fatalf("create prometheus exporter: %v", err) + } + + provider := metric.NewMeterProvider(metric.WithReader(exporter)) + pkg := reflect.TypeOf(metrics.Metrics{}).PkgPath() + meter := provider.Meter(pkg) + + m, err := metrics.New(context.Background(), meter) + if err != nil { + t.Fatalf("create metrics: %v", err) + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + rt := m.RoundTripper(test.roundTripper) + res, err := rt.RoundTrip(test.request) + if res != nil && res.Body != nil { + defer res.Body.Close() + } + if diff := cmp.Diff(test.err, err); diff != "" { + t.Errorf("Incorrect error (-want +got):\n%s", diff) + } + if diff := cmp.Diff(test.response, res); diff != "" { + t.Errorf("Incorrect response (-want +got):\n%s", diff) + } + }) + } +} diff --git a/proxy/internal/middleware/bodypolicy.go b/proxy/internal/middleware/bodypolicy.go new file mode 100644 index 000000000..f31486fc8 --- /dev/null +++ b/proxy/internal/middleware/bodypolicy.go @@ -0,0 +1,63 @@ +package middleware + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" +) + +// ErrExpectContinue is returned when a middleware attempts to replace +// the body of a request that advertised Expect: 100-continue. +var ErrExpectContinue = errors.New("body replace rejected: request has Expect: 100-continue") + +// ErrOriginalNotDrained is returned when the original body was not +// fully consumed before replacement. This prevents the backend from +// seeing a mix of original bytes and the replacement. +var ErrOriginalNotDrained = errors.New("body replace rejected: original body not drained") + +// ErrContentLengthMismatch is returned when the client-advertised +// Content-Length disagrees with the number of bytes actually read from +// the body (short-read). +var ErrContentLengthMismatch = errors.New("body replace rejected: content-length mismatch (short read)") + +// ValidateBodyReplace runs the smuggling-prevention rules before a +// body replacement is applied. Callers must pass originalDrained=true +// once they have read r.Body to EOF. +func ValidateBodyReplace(r *http.Request, newBody []byte, originalDrained bool) error { + if r == nil { + return errors.New("body replace rejected: nil request") + } + if strings.EqualFold(r.Header.Get("Expect"), "100-continue") { + return ErrExpectContinue + } + if !originalDrained { + return ErrOriginalNotDrained + } + if cl := r.Header.Get("Content-Length"); cl != "" && r.ContentLength > 0 { + parsed, err := strconv.ParseInt(cl, 10, 64) + if err == nil && parsed != r.ContentLength { + return fmt.Errorf("%w: header=%d actual=%d", ErrContentLengthMismatch, parsed, r.ContentLength) + } + } + return nil +} + +// ApplyBodyReplace swaps r.Body for a reader over newBody, recomputes +// Content-Length, and strips Transfer-Encoding and Trailer so no stale +// framing reaches the backend. +func ApplyBodyReplace(r *http.Request, newBody []byte) { + if r == nil { + return + } + r.Body = io.NopCloser(bytes.NewReader(newBody)) + r.ContentLength = int64(len(newBody)) + r.Header.Set("Content-Length", strconv.Itoa(len(newBody))) + r.Header.Del("Transfer-Encoding") + r.Header.Del("Trailer") + r.TransferEncoding = nil + r.Trailer = nil +} diff --git a/proxy/internal/middleware/bodytap/request.go b/proxy/internal/middleware/bodytap/request.go new file mode 100644 index 000000000..826883a96 --- /dev/null +++ b/proxy/internal/middleware/bodytap/request.go @@ -0,0 +1,344 @@ +// Package bodytap owns the framework-side body capture used by the +// middleware chain. Request capture buffers up to N bytes of the +// request body for middleware inspection while replaying the original +// stream to the upstream. Response capture tees up to N bytes off the +// streaming response while every byte continues to flow to the client +// untouched. +// +// The package is the single owner of body access — middlewares never +// read req.Body or hijack the response writer. All inspection happens +// against the buffer surfaced by the tap, so streaming remains +// transparent to the client even when middlewares need access to the +// payload. +package bodytap + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "strings" + "sync" +) + +// MaxRoutingScanBytes bounds how far ScanRoutingFields will read into a +// request body to recover routing fields when the normal capture is +// bypassed for size. Sized to comfortably hold a 1M-token context +// request (whose `model` field a client may place after a multi-MB +// `messages` array) while still capping pathological inputs. +const MaxRoutingScanBytes int64 = 32 << 20 + +// Request bypass reasons emitted as the `mw.capture.bypass_reason` +// metadata key by the chain when a request body is not surfaced. +const ( + BypassUpgradeHeader = "upgrade_header" + BypassConnectionUpgrd = "connection_upgrade" + BypassContentType = "content_type_not_allowed" + BypassBudget = "capture_budget_exhausted" + BypassNoConfig = "no_capture_config" + BypassNoMiddlewares = "no_middlewares" + BypassCapZero = "cap_zero" + BypassContentLengthCap = "content_length_over_cap" +) + +// DefaultCaptureBudgetBytes is the default global capture-budget size. +const DefaultCaptureBudgetBytes int64 = 256 << 20 + +// Config holds per-target body capture limits after clamp validation. +// A zero MaxRequestBytes / MaxResponseBytes disables capture in that +// direction. +type Config struct { + MaxRequestBytes int64 + MaxResponseBytes int64 + ContentTypes []string +} + +// Budget is the global token-bucket semaphore shared across all +// in-flight captures so a single misbehaving target cannot exhaust the +// proxy. +type Budget interface { + Acquire(n int64) bool + Release(n int64) +} + +// NewBudget returns a Budget with the given total byte cap. A zero or +// negative total disables the budget check. +func NewBudget(total int64) Budget { + return &budget{total: total} +} + +type budget struct { + mu sync.Mutex + used int64 + total int64 +} + +func (b *budget) Acquire(n int64) bool { + if n <= 0 { + return true + } + b.mu.Lock() + defer b.mu.Unlock() + if b.total <= 0 { + return true + } + if b.used+n > b.total { + return false + } + b.used += n + return true +} + +func (b *budget) Release(n int64) { + if n <= 0 { + return + } + b.mu.Lock() + defer b.mu.Unlock() + if b.total <= 0 { + return + } + b.used -= n + if b.used < 0 { + b.used = 0 + } +} + +// CaptureRequest reads up to cfg.MaxRequestBytes from r.Body into a +// buffer suitable for middleware inspection, replacing r.Body with a +// replay reader so the upstream still sees the original bytes. When +// bypass != "" no body is read and r.Body is left untouched. The +// returned release function must be invoked once the request is fully +// processed; it returns the acquired budget tokens to the shared pool. +// release is always non-nil and is safe to defer immediately after the +// call. +func CaptureRequest(r *http.Request, cfg *Config, b Budget) (body []byte, truncated bool, originalSize int64, bypass string, release func(), err error) { + release = func() {} + if r == nil { + return nil, false, 0, BypassNoConfig, release, nil + } + if cfg == nil { + return nil, false, 0, BypassNoConfig, release, nil + } + if cfg.MaxRequestBytes <= 0 { + return nil, false, 0, BypassCapZero, release, nil + } + if r.Header.Get("Upgrade") != "" { + return nil, false, 0, BypassUpgradeHeader, release, nil + } + if strings.EqualFold(r.Header.Get("Connection"), "upgrade") { + return nil, false, 0, BypassConnectionUpgrd, release, nil + } + if !contentTypeAllowed(r.Header.Get("Content-Type"), cfg.ContentTypes) { + return nil, false, 0, BypassContentType, release, nil + } + + originalSize = parseContentLength(r.Header.Get("Content-Length")) + if originalSize > cfg.MaxRequestBytes { + return nil, true, originalSize, BypassContentLengthCap, release, nil + } + + limit := cfg.MaxRequestBytes + if b != nil && !b.Acquire(limit) { + return nil, false, originalSize, BypassBudget, release, nil + } + if b != nil { + var released sync.Once + release = func() { + released.Do(func() { b.Release(limit) }) + } + } + + if r.Body == nil || r.Body == http.NoBody { + release() + release = func() {} + return nil, false, originalSize, "", release, nil + } + + limited := io.LimitReader(r.Body, limit+1) + buf, readErr := io.ReadAll(limited) + if readErr != nil && !errors.Is(readErr, io.EOF) { + release() + release = func() {} + return nil, false, originalSize, "", release, readErr + } + + truncated = int64(len(buf)) > limit + if truncated { + replay := append([]byte(nil), buf...) + viewable := buf[:limit] + r.Body = &replayReadCloser{replay: bytes.NewReader(replay), tail: r.Body} + return viewable, true, originalSize, "", release, nil + } + _ = r.Body.Close() + r.Body = io.NopCloser(bytes.NewReader(buf)) + if originalSize <= 0 { + originalSize = int64(len(buf)) + } + return buf, false, originalSize, "", release, nil +} + +// replayReadCloser replays the captured prefix and then forwards the +// remaining bytes from the original body so the upstream sees the +// full request stream even when capture truncates. +type replayReadCloser struct { + replay *bytes.Reader + tail io.ReadCloser + drained bool +} + +func (r *replayReadCloser) Read(p []byte) (int, error) { + if !r.drained { + n, err := r.replay.Read(p) + if n > 0 { + return n, nil + } + if errors.Is(err, io.EOF) { + r.drained = true + } else if err != nil { + return 0, err + } + } + return r.tail.Read(p) +} + +func (r *replayReadCloser) Close() error { + return r.tail.Close() +} + +// ScanRoutingFields recovers the LLM routing fields ("model" and +// "stream") from a request whose normal capture was bypassed or +// truncated for size. It reads up to maxScan bytes of r.Body to locate +// the top-level keys — clients (e.g. Claude Code) may place `model` +// after a multi-MB `messages` array — then restores r.Body so the +// upstream still receives the full, untouched stream. Only the small +// routing fields are extracted; the prompt is never buffered for +// capture, keeping memory bounded. Returns ok=false when the body isn't +// a JSON object, the model field isn't found within maxScan, or on a +// read error. +func ScanRoutingFields(r *http.Request, maxScan int64) (model string, stream bool, ok bool) { + if r == nil || r.Body == nil || r.Body == http.NoBody || maxScan <= 0 { + return "", false, false + } + limited := io.LimitReader(r.Body, maxScan+1) + buf, readErr := io.ReadAll(limited) + if readErr != nil && !errors.Is(readErr, io.EOF) { + // Mid-stream read error (e.g. client disconnect): restore the bytes + // read so far plus the untouched tail and abort, rather than + // forwarding only the partial prefix as if it were the whole body. + r.Body = &replayReadCloser{replay: bytes.NewReader(append([]byte(nil), buf...)), tail: r.Body} + return "", false, false + } + if int64(len(buf)) > maxScan { + // Body exceeds the scan ceiling: restore the read prefix plus the + // untouched tail so the upstream still gets every byte. + r.Body = &replayReadCloser{replay: bytes.NewReader(append([]byte(nil), buf...)), tail: r.Body} + } else { + _ = r.Body.Close() + r.Body = io.NopCloser(bytes.NewReader(buf)) + } + return scanTopLevelModelStream(buf) +} + +// scanTopLevelModelStream walks the top level of a JSON object via a +// streaming token reader, extracting the "model" string and "stream" +// bool without materialising large values (each non-target value is +// skipped as a RawMessage). Tolerant of truncation: returns whatever was +// found before a malformed/short tail. +func scanTopLevelModelStream(body []byte) (model string, stream bool, ok bool) { + dec := json.NewDecoder(bytes.NewReader(body)) + tok, err := dec.Token() + if err != nil { + return "", false, false + } + if d, isDelim := tok.(json.Delim); !isDelim || d != '{' { + return "", false, false + } + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return model, stream, ok + } + key, _ := keyTok.(string) + switch key { + case "model": + var v string + if dec.Decode(&v) == nil { + model, ok = v, true + } + case "stream": + var v bool + if dec.Decode(&v) == nil { + stream = v + } + default: + // Skip the value by walking tokens instead of decoding it into + // a json.RawMessage — a multi-MB messages array would otherwise + // be materialised in full just to be discarded. + if err := skipValue(dec); err != nil { + return model, stream, ok + } + } + } + return model, stream, ok +} + +// skipValue consumes one JSON value from dec without materialising it. +// Scalars are a single token; objects/arrays are walked to their matching +// close delimiter so nested structures are skipped in bounded memory. +func skipValue(dec *json.Decoder) error { + tok, err := dec.Token() + if err != nil { + return err + } + d, isDelim := tok.(json.Delim) + if !isDelim || (d != '{' && d != '[') { + return nil + } + depth := 1 + for depth > 0 { + tok, err := dec.Token() + if err != nil { + return err + } + if d, ok := tok.(json.Delim); ok { + switch d { + case '{', '[': + depth++ + case '}', ']': + depth-- + } + } + } + return nil +} + +func contentTypeAllowed(ct string, allowed []string) bool { + if len(allowed) == 0 { + return false + } + media := ct + if idx := strings.Index(ct, ";"); idx >= 0 { + media = ct[:idx] + } + media = strings.TrimSpace(strings.ToLower(media)) + for _, a := range allowed { + if strings.EqualFold(strings.TrimSpace(a), media) { + return true + } + } + return false +} + +func parseContentLength(v string) int64 { + if v == "" { + return 0 + } + parsed, err := strconv.ParseInt(v, 10, 64) + if err != nil || parsed < 0 { + return 0 + } + return parsed +} diff --git a/proxy/internal/middleware/bodytap/response.go b/proxy/internal/middleware/bodytap/response.go new file mode 100644 index 000000000..c23e35b34 --- /dev/null +++ b/proxy/internal/middleware/bodytap/response.go @@ -0,0 +1,189 @@ +package bodytap + +import ( + "bytes" + "net/http" + "sync" + + "github.com/netbirdio/netbird/proxy/internal/responsewriter" +) + +// CapturingResponseWriter wraps an http.ResponseWriter, forwards bytes +// immediately to the client, and tees a bounded copy into an internal +// buffer for middleware inspection. Streaming-aware in the sense that +// every byte the upstream emits flows to the client without queuing +// — the tee just sees a bounded prefix. SSE-aware parsing happens in +// the response middleware against the buffered prefix; this writer +// makes no attempt to demux event boundaries. +// +// Flusher and Hijacker are preserved via responsewriter.PassthroughWriter. +type CapturingResponseWriter struct { + *responsewriter.PassthroughWriter + mu sync.Mutex + buf bytes.Buffer + cap int64 + status int + statusSet bool + written int64 + truncated bool + stopped bool + releaseBuf func() + released sync.Once + bypassed bool + bypassReas string + acquiredCap int64 +} + +// NewCapturingResponseWriter returns a writer that tees up to maxBytes +// into a capped buffer while forwarding bytes to the underlying writer +// immediately. When budget is non-nil the writer pre-acquires maxBytes +// from it and the returned wrapper must be released by calling +// Release() once the response is fully forwarded. If the budget cannot +// be acquired the writer falls back to forwarding the response +// unmodified, exposes Bypassed()=true with reason BypassBudget, and +// releases nothing. +func NewCapturingResponseWriter(w http.ResponseWriter, maxBytes int64, b Budget) *CapturingResponseWriter { + cw := &CapturingResponseWriter{ + PassthroughWriter: responsewriter.New(w), + cap: maxBytes, + status: http.StatusOK, + releaseBuf: func() {}, + } + if maxBytes <= 0 { + // Capture disabled: mark stopped so Write never tees and never + // flags truncation (a zero cap means "don't capture", not + // "captured nothing"). + cw.stopped = true + return cw + } + if b == nil { + return cw + } + if !b.Acquire(maxBytes) { + cw.bypassed = true + cw.bypassReas = BypassBudget + cw.cap = 0 + cw.stopped = true + return cw + } + cw.acquiredCap = maxBytes + cw.releaseBuf = func() { b.Release(maxBytes) } + return cw +} + +// Release returns the response capture budget acquired at construction +// back to the shared pool. Idempotent. Safe to call from a defer +// immediately after construction even when the writer ended up +// bypassing the budget. +func (c *CapturingResponseWriter) Release() { + if c == nil { + return + } + c.released.Do(func() { + if c.releaseBuf != nil { + c.releaseBuf() + } + }) +} + +// Bypassed reports whether the writer fell through to a no-tee +// passthrough because the response capture budget could not be +// acquired. +func (c *CapturingResponseWriter) Bypassed() bool { + if c == nil { + return false + } + c.mu.Lock() + defer c.mu.Unlock() + return c.bypassed +} + +// BypassReason returns the bypass code recorded by the budget check. +// Empty when capture proceeded normally. +func (c *CapturingResponseWriter) BypassReason() string { + if c == nil { + return "" + } + c.mu.Lock() + defer c.mu.Unlock() + return c.bypassReas +} + +// WriteHeader records the status code and forwards it to the underlying +// writer. Only the first call commits the status — matching HTTP semantics, +// where superfluous WriteHeader calls (and any call after the body has +// started) are ignored — so Status() reflects the code actually sent. +func (c *CapturingResponseWriter) WriteHeader(status int) { + c.mu.Lock() + if c.statusSet { + c.mu.Unlock() + return + } + c.status = status + c.statusSet = true + c.mu.Unlock() + c.PassthroughWriter.WriteHeader(status) +} + +// Write forwards p to the underlying writer unmodified and copies up +// to the remaining buffer capacity into the tee buffer. +func (c *CapturingResponseWriter) Write(p []byte) (int, error) { + n, err := c.PassthroughWriter.Write(p) + if n > 0 { + c.mu.Lock() + // The first byte commits the status (implicit 200 if WriteHeader was + // never called); a later WriteHeader must not change Status(). + c.statusSet = true + c.written += int64(n) + if !c.stopped { + remaining := c.cap - int64(c.buf.Len()) + if remaining <= 0 { + c.truncated = true + c.stopped = true + } else { + take := int64(n) + if take > remaining { + take = remaining + c.truncated = true + c.stopped = true + } + c.buf.Write(p[:take]) + } + } + c.mu.Unlock() + } + return n, err +} + +// Status returns the captured status code (defaults to 200 when +// WriteHeader has not been called). +func (c *CapturingResponseWriter) Status() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.status +} + +// Body returns a copy of the buffered response prefix. +func (c *CapturingResponseWriter) Body() []byte { + c.mu.Lock() + defer c.mu.Unlock() + out := make([]byte, c.buf.Len()) + copy(out, c.buf.Bytes()) + return out +} + +// Truncated reports whether the buffered prefix stopped short of the +// full response stream. +func (c *CapturingResponseWriter) Truncated() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.truncated +} + +// BytesWritten returns the total number of bytes forwarded to the +// underlying writer. +func (c *CapturingResponseWriter) BytesWritten() int64 { + c.mu.Lock() + defer c.mu.Unlock() + return c.written +} diff --git a/proxy/internal/middleware/bodytap/routing_scan_test.go b/proxy/internal/middleware/bodytap/routing_scan_test.go new file mode 100644 index 000000000..1748c989e --- /dev/null +++ b/proxy/internal/middleware/bodytap/routing_scan_test.go @@ -0,0 +1,86 @@ +package bodytap + +import ( + "fmt" + "io" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// makeBigAnthropicBody builds a request body shaped like Claude Code's: +// a multi-MB "messages" array with the routing fields (model, stream) +// placed AFTER it, which is the ordering that defeats a prefix-only +// capture. +func makeBigAnthropicBody(t *testing.T, model string, stream bool, messagesBytes int) string { + t.Helper() + filler := strings.Repeat("x", messagesBytes) + return fmt.Sprintf( + `{"max_tokens":64000,"messages":[{"role":"user","content":%q}],"model":%q,"stream":%t}`, + filler, model, stream, + ) +} + +func TestScanRoutingFields_ModelAfterLargeMessages(t *testing.T) { + body := makeBigAnthropicBody(t, "claude-opus-4-8", true, 3<<20) // 3 MiB messages + req := httptest.NewRequest("POST", "https://x/v1/messages", strings.NewReader(body)) + + model, stream, ok := ScanRoutingFields(req, MaxRoutingScanBytes) + require.True(t, ok, "model must be recovered even when it follows a multi-MB messages array") + assert.Equal(t, "claude-opus-4-8", model, "model field must be extracted") + assert.True(t, stream, "stream field must be extracted") + + // Body must be fully restored for the upstream. + got, err := io.ReadAll(req.Body) + require.NoError(t, err) + assert.Equal(t, body, string(got), "the full request body must be replayed to upstream after scanning") +} + +func TestScanRoutingFields_SmallBody(t *testing.T) { + body := `{"model":"claude-opus-4-8","stream":false,"messages":[]}` + req := httptest.NewRequest("POST", "https://x/v1/messages", strings.NewReader(body)) + + model, stream, ok := ScanRoutingFields(req, MaxRoutingScanBytes) + require.True(t, ok) + assert.Equal(t, "claude-opus-4-8", model) + assert.False(t, stream) + + got, _ := io.ReadAll(req.Body) + assert.Equal(t, body, string(got), "small bodies must also be restored intact") +} + +func TestScanRoutingFields_NoModel(t *testing.T) { + body := `{"stream":true,"messages":[]}` + req := httptest.NewRequest("POST", "https://x/v1/messages", strings.NewReader(body)) + + _, _, ok := ScanRoutingFields(req, MaxRoutingScanBytes) + assert.False(t, ok, "ok must be false when no model field is present") + + got, _ := io.ReadAll(req.Body) + assert.Equal(t, body, string(got), "body must be restored even when model is absent") +} + +func TestScanRoutingFields_NotJSON(t *testing.T) { + body := "this is not json at all" + req := httptest.NewRequest("POST", "https://x/v1/messages", strings.NewReader(body)) + + _, _, ok := ScanRoutingFields(req, MaxRoutingScanBytes) + assert.False(t, ok, "ok must be false for a non-JSON body") +} + +func TestScanRoutingFields_ModelBeyondScanCeiling(t *testing.T) { + // model sits after 4 MiB of messages but the scan ceiling is 1 MiB: + // model can't be recovered, yet the full body must still replay. + body := makeBigAnthropicBody(t, "claude-opus-4-8", true, 4<<20) + req := httptest.NewRequest("POST", "https://x/v1/messages", strings.NewReader(body)) + + _, _, ok := ScanRoutingFields(req, 1<<20) + assert.False(t, ok, "model beyond the scan ceiling is not recoverable") + + got, err := io.ReadAll(req.Body) + require.NoError(t, err) + assert.Equal(t, body, string(got), "the full body must still replay to upstream even when the scan gives up") +} diff --git a/proxy/internal/middleware/builtin/agentnetwork_chain_integration_test.go b/proxy/internal/middleware/builtin/agentnetwork_chain_integration_test.go new file mode 100644 index 000000000..b00495494 --- /dev/null +++ b/proxy/internal/middleware/builtin/agentnetwork_chain_integration_test.go @@ -0,0 +1,318 @@ +package builtin_test + +import ( + "context" + "net" + "runtime" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/server/store" + nbtypes "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_limit_check" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_limit_record" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// chainIntegrationFixture wires the BOTH new agent-network +// middlewares against a live in-process management stack: real +// sqlite store + real Manager + real gRPC server. The proxy chain +// framework itself isn't constructed (its dispatcher / accumulator / +// metadata gate are tested separately); we exercise the middleware +// pair as the proxy runtime would, by invoking each with a crafted +// Input and asserting the wire path between them. +// +// This is the regression cover for item 16 in the design review: +// real LLM request → cost stamped → consumption row in the table. +type chainIntegrationFixture struct { + store store.Store + manager agentnetwork.Manager + gatecase *llm_limit_check.Middleware + recorder *llm_limit_record.Middleware +} + +func newChainIntegration(t *testing.T) *chainIntegrationFixture { + 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) + + manager := agentnetwork.NewManager(st, nil, nil, nil) + + server := &mgmtgrpc.ProxyServiceServer{} + server.SetAgentNetworkLimitsService(manager) + + const bufSize = 1024 * 1024 + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + proto.RegisterProxyServiceServer(srv, server) + go func() { _ = srv.Serve(lis) }() + t.Cleanup(srv.Stop) + + conn, err := grpc.NewClient("passthrough:///bufnet", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { return lis.Dial() }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + mgmtClient := proto.NewProxyServiceClient(conn) + return &chainIntegrationFixture{ + store: st, + manager: manager, + gatecase: llm_limit_check.New(mgmtClient, nil), + recorder: llm_limit_record.New(mgmtClient, nil), + } +} + +// chainInput builds a middleware Input that mirrors what the proxy +// framework would synthesise for a tunnel-peer LLM request. The +// gate consumes the resolved provider id from upstream metadata +// (set by llm_router); the recorder consumes the attribution +// metadata stamped by the gate plus tokens / cost from +// llm_response_parser + cost_meter. +func chainInput(account, user, group, providerID string, requestMeta []middleware.KV) *middleware.Input { + _ = providerID // packed into requestMeta by the caller as KeyLLMResolvedProviderID + return &middleware.Input{ + AccountID: account, + UserID: user, + UserGroups: []string{group}, + Metadata: requestMeta, + } +} + +// chainCapPolicy builds a tight token-cap policy fixture for the +// chain integration tests. Inlined here (rather than imported) because +// the equivalent helper in the management gRPC package is unexported +// and this is a different package boundary. +func chainCapPolicy(id, account string, sourceGroups []string, providerID string, tokenCap, windowSec int64) *agentNetworkTypes.Policy { + return &agentNetworkTypes.Policy{ + ID: id, + AccountID: account, + Enabled: true, + Name: id, + SourceGroups: sourceGroups, + DestinationProviderIDs: []string{providerID}, + Limits: agentNetworkTypes.PolicyLimits{ + TokenLimit: agentNetworkTypes.PolicyTokenLimit{ + Enabled: true, + GroupCap: tokenCap, + WindowSeconds: windowSec, + }, + }, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } +} + +// TestChain_AllowPath_StampsAttributionAndRecordsCounter walks the +// full happy path: gate calls CheckLLMPolicyLimits → stamps +// attribution metadata → recorder reads metadata + tokens / cost → +// calls RecordLLMUsage → counters land in sqlite. Asserting on the +// store at the end proves every leg of the wire works together, +// not just each leg in isolation (which the unit tests already cover). +func TestChain_AllowPath_StampsAttributionAndRecordsCounter(t *testing.T) { + f := newChainIntegration(t) + + const account = "acc-1" + const user = "user-bob" + const group = "grp-engineers" + const provider = "prov-1" + + // Seed a policy with token + budget caps; both halves carry + // real ceilings so the request stays within headroom. + require.NoError(t, f.store.SaveAgentNetworkPolicy(context.Background(), + chainCapPolicy("pol-1", account, []string{group}, provider, 10_000, 86_400))) + + // ── Stage 1 — gate: pre-flight check ────────────────────── + gateIn := chainInput(account, user, group, provider, []middleware.KV{ + {Key: middleware.KeyLLMResolvedProviderID, Value: provider}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + }) + gateOut, err := f.gatecase.Invoke(context.Background(), gateIn) + require.NoError(t, err) + require.Equal(t, middleware.DecisionAllow, gateOut.Decision, "fresh policy must allow") + + // Verify attribution metadata was stamped — the recorder + // depends on these keys. + metaMap := map[string]string{} + for _, kv := range gateOut.Metadata { + metaMap[kv.Key] = kv.Value + } + assert.Equal(t, "pol-1", metaMap[middleware.KeyLLMSelectedPolicyID]) + assert.Equal(t, group, metaMap[middleware.KeyLLMAttributionGroupID]) + assert.Equal(t, "86400", metaMap[middleware.KeyLLMAttributionWindowS]) + + // ── Stage 2 — recorder: post-flight write ───────────────── + // Build the response-leg Input the framework would synthesise + // for the recorder: gate's emitted attribution metadata + the + // tokens / cost stamped by llm_response_parser + cost_meter. + const tokensIn = int64(123) + const tokensOut = int64(45) + const costUSD = 0.0042 + recordIn := chainInput(account, user, group, provider, append([]middleware.KV{}, + gateOut.Metadata...)) + recordIn.Metadata = append(recordIn.Metadata, + middleware.KV{Key: middleware.KeyLLMInputTokens, Value: strconv.FormatInt(tokensIn, 10)}, + middleware.KV{Key: middleware.KeyLLMOutputTokens, Value: strconv.FormatInt(tokensOut, 10)}, + middleware.KV{Key: middleware.KeyCostUSDTotal, Value: strconv.FormatFloat(costUSD, 'f', 6, 64)}, + ) + recordOut, err := f.recorder.Invoke(context.Background(), recordIn) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, recordOut.Decision, "recorder always allows; its only side effect is the counter write") + + // ── Stage 3 — assert state in sqlite ────────────────────── + windowStart := agentNetworkTypes.WindowStart(time.Now(), 86_400) + userRow, err := f.store.GetAgentNetworkConsumption( + context.Background(), store.LockingStrengthNone, account, + agentNetworkTypes.DimensionUser, user, int64(86_400), windowStart, + ) + require.NoError(t, err) + assert.Equal(t, tokensIn, userRow.TokensInput, "user counter must hold the input tokens the recorder posted") + assert.Equal(t, tokensOut, userRow.TokensOutput) + assert.InDelta(t, costUSD, userRow.CostUSD, 1e-6) + + groupRow, err := f.store.GetAgentNetworkConsumption( + context.Background(), store.LockingStrengthNone, account, + agentNetworkTypes.DimensionGroup, group, int64(86_400), windowStart, + ) + require.NoError(t, err) + assert.Equal(t, tokensIn, groupRow.TokensInput, "group counter mirrors the user counter — single Record posts both dims") +} + +// TestChain_DenyPath_GateRejectsAndNoConsumptionWritten covers the +// negative side: when the gate denies, the recorder is never +// invoked (the proxy framework short-circuits on Decision=Deny). +// We assert no consumption row materialises after the gate-deny +// path, even though the test technically calls the recorder +// afterwards — the recorder must skip on missing attribution +// metadata so the framework's short-circuit isn't load-bearing for +// data integrity. +func TestChain_DenyPath_GateRejectsAndNoConsumptionWritten(t *testing.T) { + f := newChainIntegration(t) + + const account = "acc-1" + const user = "user-bob" + const group = "grp-tight" + const provider = "prov-1" + + policy := chainCapPolicy("pol-tight", account, []string{group}, provider, 100, 86_400) + require.NoError(t, f.store.SaveAgentNetworkPolicy(context.Background(), policy)) + + // Pre-burn the counter to the cap so the gate denies. + require.NoError(t, f.store.IncrementAgentNetworkConsumption( + context.Background(), account, + agentNetworkTypes.DimensionGroup, group, int64(86_400), + agentNetworkTypes.WindowStart(time.Now(), 86_400), + 100, 0, 0, + )) + + gateOut, err := f.gatecase.Invoke(context.Background(), chainInput(account, user, group, provider, + []middleware.KV{ + {Key: middleware.KeyLLMResolvedProviderID, Value: provider}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + }, + )) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionDeny, gateOut.Decision, "policy at-cap must deny on the gate") + require.NotNil(t, gateOut.DenyReason) + assert.Equal(t, "llm_policy.token_cap_exceeded", gateOut.DenyReason.Code) + + // On deny, the gate emits no attribution metadata. If the + // proxy framework still invokes the recorder (defense in + // depth), the recorder's "no attribution window = skip" guard + // prevents a phantom counter increment. + recordOut, err := f.recorder.Invoke(context.Background(), chainInput(account, user, group, provider, + gateOut.Metadata, // no llm.attribution_window_seconds stamped + )) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, recordOut.Decision) + + // The pre-burned 100 tokens are the only counter movement — + // the recorder must NOT have added a fresh row for the user + // dimension on this denied request. + windowStart := agentNetworkTypes.WindowStart(time.Now(), 86_400) + userRow, err := f.store.GetAgentNetworkConsumption( + context.Background(), store.LockingStrengthNone, account, + agentNetworkTypes.DimensionUser, user, int64(86_400), windowStart, + ) + require.NoError(t, err) + assert.Zero(t, userRow.TokensInput, "user dimension must not gain tokens from a denied request — recorder skip is the safety net") +} + +// TestChain_CapExhaustTransition exercises the allow→deny boundary +// the operator cares most about: a request just under cap allows +// AND records, the next request post-record at-cap denies. This is +// the same lifecycle 50-grpc-allow-record-deny.sh runs in bash, but +// against the actual middleware pair rather than the smoke binary +// driving the gRPC RPCs directly. +func TestChain_CapExhaustTransition(t *testing.T) { + f := newChainIntegration(t) + + const account = "acc-1" + const user = "user-alice" + const group = "grp-cap-edge" + const provider = "prov-1" + const tightCap = int64(100) + + require.NoError(t, f.store.SaveAgentNetworkPolicy(context.Background(), + chainCapPolicy("pol-edge", account, []string{group}, provider, tightCap, 86_400))) + + // Pre-burn 99 tokens so we're at the very edge. + require.NoError(t, f.store.IncrementAgentNetworkConsumption( + context.Background(), account, + agentNetworkTypes.DimensionGroup, group, int64(86_400), + agentNetworkTypes.WindowStart(time.Now(), 86_400), + 99, 0, 0, + )) + + // Gate at 99/100 — must allow (one token of headroom). + gateOut, err := f.gatecase.Invoke(context.Background(), chainInput(account, user, group, provider, + []middleware.KV{ + {Key: middleware.KeyLLMResolvedProviderID, Value: provider}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + }, + )) + require.NoError(t, err) + require.Equal(t, middleware.DecisionAllow, gateOut.Decision, "99/100 must allow — one token of headroom") + + // Record one more input token — pushes us to 100/100. + recordIn := chainInput(account, user, group, provider, append([]middleware.KV{}, + gateOut.Metadata...)) + recordIn.Metadata = append(recordIn.Metadata, + middleware.KV{Key: middleware.KeyLLMInputTokens, Value: "1"}, + middleware.KV{Key: middleware.KeyLLMOutputTokens, Value: "0"}, + middleware.KV{Key: middleware.KeyCostUSDTotal, Value: "0.000001"}, + ) + _, err = f.recorder.Invoke(context.Background(), recordIn) + require.NoError(t, err) + + // Next gate call must deny — counter is exactly at cap. + gateOut2, err := f.gatecase.Invoke(context.Background(), chainInput(account, user, group, provider, + []middleware.KV{ + {Key: middleware.KeyLLMResolvedProviderID, Value: provider}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + }, + )) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionDeny, gateOut2.Decision, + "once recorder pushed the group counter to 100/100, the next gate call must deny — allow→deny transition is the operator-visible product semantic") + require.NotNil(t, gateOut2.DenyReason) + assert.Equal(t, "llm_policy.token_cap_exceeded", gateOut2.DenyReason.Code) +} diff --git a/proxy/internal/middleware/builtin/all_test.go b/proxy/internal/middleware/builtin/all_test.go new file mode 100644 index 000000000..28576e248 --- /dev/null +++ b/proxy/internal/middleware/builtin/all_test.go @@ -0,0 +1,40 @@ +package builtin_test + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" + + mwbuiltin "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" + + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/cost_meter" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_identity_inject" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_limit_check" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_limit_record" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_request_parser" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_response_parser" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_router" +) + +// TestDefaultRegistry_BuiltinIDs locks the set of middleware IDs that +// the default builtin registry exposes once every sub-package's init() +// has run. The list is the source of truth wired by the synthesiser +// in management; adding a new built-in middleware should consciously +// extend this list. +func TestDefaultRegistry_BuiltinIDs(t *testing.T) { + got := mwbuiltin.DefaultRegistry().IDs() + sort.Strings(got) + want := []string{ + "cost_meter", + "llm_guardrail", + "llm_identity_inject", + "llm_limit_check", + "llm_limit_record", + "llm_request_parser", + "llm_response_parser", + "llm_router", + } + assert.Equal(t, want, got, "default registry must expose every built-in middleware after anonymous imports") +} diff --git a/proxy/internal/middleware/builtin/builtin.go b/proxy/internal/middleware/builtin/builtin.go new file mode 100644 index 000000000..9ea4cf89d --- /dev/null +++ b/proxy/internal/middleware/builtin/builtin.go @@ -0,0 +1,93 @@ +// Package builtin holds the package-level middleware registry that +// concrete middleware packages register themselves into via init(). +// Server boot anonymous-imports each middleware sub-package; the +// resolver attached to the middleware Manager pulls factories out of +// this registry. +package builtin + +import ( + "context" + "sync" + + log "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel/metric" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// MgmtClient is the narrow slice of proto.ProxyServiceClient that +// builtin middlewares may use during request / response handling. +// Only the agent-network limit pair (llm_limit_check + llm_limit_record) +// uses this today; declaring the surface here keeps the dependency +// explicit at boot time. +// +// proto.ProxyServiceClient already satisfies this interface so server +// boot just forwards its existing client. +type MgmtClient interface { + CheckLLMPolicyLimits(ctx context.Context, in *proto.CheckLLMPolicyLimitsRequest, opts ...grpc.CallOption) (*proto.CheckLLMPolicyLimitsResponse, error) + RecordLLMUsage(ctx context.Context, in *proto.RecordLLMUsageRequest, opts ...grpc.CallOption) (*proto.RecordLLMUsageResponse, error) +} + +// defaultRegistry is the package-level registry that concrete builtin +// middlewares register themselves into via init(). +var defaultRegistry = middleware.NewRegistry() + +// FactoryContext is the per-process bag that concrete factories may +// consult during construction. It carries the proxy-lifetime context, +// the data directory used for static config files (pricing tables, +// allowlists), the OTel meter, and the proxy logger. +// +// Configure must be called once at boot before any chain build calls +// Resolve. Calling it twice overwrites the prior value; tests may rely +// on this to reset state. +type FactoryContext struct { + Context context.Context + DataDir string + Meter metric.Meter + Logger *log.Logger + MgmtClient MgmtClient +} + +var ( + ctxStore FactoryContext + ctxMu sync.RWMutex +) + +// Configure stores the per-process FactoryContext. Concrete factories +// reach for it via Context(). mgmt may be nil on tests / standalone +// builds with no management server; consumers must guard. +func Configure(ctx context.Context, dataDir string, meter metric.Meter, logger *log.Logger, mgmt MgmtClient) { + ctxMu.Lock() + defer ctxMu.Unlock() + ctxStore = FactoryContext{ + Context: ctx, + DataDir: dataDir, + Meter: meter, + Logger: logger, + MgmtClient: mgmt, + } +} + +// Context returns the stored FactoryContext. Returns a zero value when +// Configure was never called; consumers must guard against nil +// Context/Meter/Logger if they care. +func Context() FactoryContext { + ctxMu.RLock() + defer ctxMu.RUnlock() + return ctxStore +} + +// Register adds a factory to the default registry. Called from init() +// blocks of concrete middleware packages. Panics on collision so +// duplicate IDs surface at startup. +func Register(f middleware.Factory) { + defaultRegistry.MustRegister(f) +} + +// DefaultRegistry returns the shared registry. The proxy server +// constructs the Resolver from it at boot. +func DefaultRegistry() *middleware.Registry { + return defaultRegistry +} diff --git a/proxy/internal/middleware/builtin/cost_calculation_matrix_test.go b/proxy/internal/middleware/builtin/cost_calculation_matrix_test.go new file mode 100644 index 000000000..f479eb563 --- /dev/null +++ b/proxy/internal/middleware/builtin/cost_calculation_matrix_test.go @@ -0,0 +1,329 @@ +package builtin_test + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "strconv" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/cost_meter" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_request_parser" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_response_parser" +) + +// Drives the real pipeline (llm_request_parser → llm_response_parser → cost_meter) on the embedded default pricing +// table and asserts exact USD amounts hardcoded from the vendors' published prices, including the cache split. +func TestCostCalculation_ProviderMatrix(t *testing.T) { + // Empty data dir → embedded defaults, like a proxy with no pricing override. + builtin.Configure(context.Background(), t.TempDir(), nil, nil, nil) + + reqMW, err := llm_request_parser.Factory{}.New(nil) + require.NoError(t, err, "build llm_request_parser") + respMW, err := llm_response_parser.Factory{}.New(nil) + require.NoError(t, err, "build llm_response_parser") + costMW, err := cost_meter.Factory{}.New(nil) + require.NoError(t, err, "build cost_meter") + t.Cleanup(func() { _ = costMW.Close() }) + + const jsonCT = "application/json" + const sseCT = "text/event-stream" + const awsCT = "application/vnd.amazon.eventstream" + + cases := []struct { + name string + url string + reqBody []byte + respCT string + respBody []byte + + wantProvider string + wantModel string + wantCost float64 // exact expected USD; ignored when wantSkip is set + wantCacheCost float64 // expected cost.usd_cache portion of wantCost + wantSkip string // expected cost.skipped reason, "" when priced + }{ + { + // gpt-4o-mini $0.15/$0.60 per MTok: 1000×0.15/1M + 500×0.60/1M. + name: "openai chat completions", + url: "https://api.openai.com/v1/chat/completions", + reqBody: []byte(`{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"choices":[{"message":{"content":"pong"}}],"usage":{"prompt_tokens":1000,"completion_tokens":500,"total_tokens":1500}}`), + wantProvider: "openai", + wantModel: "gpt-4o-mini", + wantCost: 0.00045, + }, + { + // OpenAI cached tokens are a SUBSET of prompt_tokens at a discount; gpt-4o $2.50/$10 per MTok, cached $1.25/M: + // 250×2.5/1M + 750×1.25/1M + 500×10/1M. + name: "openai cached subset discount", + url: "https://api.openai.com/v1/chat/completions", + reqBody: []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"usage":{"prompt_tokens":1000,"completion_tokens":500,"prompt_tokens_details":{"cached_tokens":750}}}`), + wantProvider: "openai", + wantModel: "gpt-4o", + wantCost: 0.0065625, + wantCacheCost: 0.0009375, + }, + { + // OpenAI streaming: usage rides the final SSE frame. + name: "openai chat SSE stream", + url: "https://api.openai.com/v1/chat/completions", + reqBody: []byte(`{"model":"gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"hi"}]}`), + respCT: sseCT, + respBody: sseBody(`{"choices":[{"delta":{"content":"po"}}]}`, `{"choices":[{"delta":{"content":"ng"}}]}`, `{"choices":[],"usage":{"prompt_tokens":1000,"completion_tokens":500}}`, "[DONE]"), + wantProvider: "openai", + wantModel: "gpt-4o-mini", + wantCost: 0.00045, + }, + { + // Mistral speaks the OpenAI shape: mistral-large-latest $0.50/$1.50 per MTok. + name: "mistral via openai shape", + url: "https://api.mistral.ai/v1/chat/completions", + reqBody: []byte(`{"model":"mistral-large-latest","messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"usage":{"prompt_tokens":1000,"completion_tokens":1000}}`), + wantProvider: "openai", + wantModel: "mistral-large-latest", + wantCost: 0.002, + }, + { + // The field report, minus caching: Bedrock Sonnet 4.6 $3/$15 per MTok, 3×3/1M + 1514×15/1M = $0.022719. + // Also covers inference-profile normalization of the region-prefixed versioned id in the URL. + name: "bedrock invoke — reported scenario, no cache", + url: "https://bedrock-runtime.eu-central-1.amazonaws.com/model/global.anthropic.claude-sonnet-4-6-20260115-v1:0/invoke", + reqBody: []byte(`{"messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"content":[{"type":"text","text":"pong"}],"usage":{"input_tokens":3,"output_tokens":1514}}`), + wantProvider: "bedrock", + wantModel: "anthropic.claude-sonnet-4-6", + wantCost: 0.022719, + }, + { + // The field report as observed: the FIRST call of a session also wrote a 30,528-token prompt cache at + // 1.25× input ($3.75/M): 0.022719 + 30528×3.75/1M = $0.137199 — the reported $0.1372. + name: "bedrock invoke — reported scenario with cache write", + url: "https://bedrock-runtime.eu-central-1.amazonaws.com/model/global.anthropic.claude-sonnet-4-6-20260115-v1:0/invoke", + reqBody: []byte(`{"messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"usage":{"input_tokens":3,"output_tokens":1514,"cache_creation_input_tokens":30528,"cache_read_input_tokens":0}}`), + wantProvider: "bedrock", + wantModel: "anthropic.claude-sonnet-4-6", + wantCost: 0.137199, + wantCacheCost: 0.11448, + }, + { + // Same numbers over the InvokeModel event-stream: message_start carries input + cache, message_delta the output. + name: "bedrock invoke stream with cache write", + url: "https://bedrock-runtime.eu-central-1.amazonaws.com/model/global.anthropic.claude-sonnet-4-6-20260115-v1:0/invoke-with-response-stream", + reqBody: []byte(`{"messages":[{"role":"user","content":"hi"}]}`), + respCT: awsCT, + respBody: bedrockInvokeStream(t, `{"type":"message_start","message":{"usage":{"input_tokens":3,"output_tokens":1,"cache_creation_input_tokens":30528}}}`, `{"type":"content_block_delta","delta":{"type":"text_delta","text":"pong"}}`, `{"type":"message_delta","usage":{"output_tokens":1514}}`), + wantProvider: "bedrock", + wantModel: "anthropic.claude-sonnet-4-6", + wantCost: 0.137199, + wantCacheCost: 0.11448, + }, + { + // Converse camelCase usage incl. cache buckets. Haiku 4.5 $1/$5 per MTok, read $0.10/M, write $1.25/M: + // 50×1/1M + 100×5/1M + 2000×0.1/1M + 1000×1.25/1M = $0.002. + name: "bedrock converse with cache buckets", + url: "https://bedrock-runtime.eu-central-1.amazonaws.com/model/eu.anthropic.claude-haiku-4-5-20251001-v1:0/converse", + reqBody: []byte(`{"messages":[{"role":"user","content":[{"text":"hi"}]}]}`), + respCT: jsonCT, + respBody: []byte(`{"output":{"message":{"content":[{"text":"pong"}]}},"usage":{"inputTokens":50,"outputTokens":100,"totalTokens":3150,"cacheReadInputTokens":2000,"cacheWriteInputTokens":1000}}`), + wantProvider: "bedrock", + wantModel: "anthropic.claude-haiku-4-5", + wantCost: 0.002, + wantCacheCost: 0.00145, + }, + { + // Same numbers over converse-stream: usage rides the trailing metadata frame. + name: "bedrock converse stream with cache buckets", + url: "https://bedrock-runtime.eu-central-1.amazonaws.com/model/eu.anthropic.claude-haiku-4-5-20251001-v1:0/converse-stream", + reqBody: []byte(`{"messages":[{"role":"user","content":[{"text":"hi"}]}]}`), + respCT: awsCT, + respBody: bedrockConverseStream(t, + `{"delta":{"text":"pong"}}`, + `{"usage":{"inputTokens":50,"outputTokens":100,"totalTokens":3150,"cacheReadInputTokens":2000,"cacheWriteInputTokens":1000}}`, + ), + wantProvider: "bedrock", + wantModel: "anthropic.claude-haiku-4-5", + wantCost: 0.002, + wantCacheCost: 0.00145, + }, + { + // First-party Anthropic, additive cache buckets. Sonnet 4.6: + // 256×3/1M + 200×15/1M + 768×0.3/1M + 512×3.75/1M. + name: "anthropic messages with cache buckets", + url: "https://api.anthropic.com/v1/messages", + reqBody: []byte(`{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"content":[{"type":"text","text":"pong"}],"usage":{"input_tokens":256,"output_tokens":200,"cache_read_input_tokens":768,"cache_creation_input_tokens":512}}`), + wantProvider: "anthropic", + wantModel: "claude-sonnet-4-6", + wantCost: 0.0059184, + wantCacheCost: 0.0021504, + }, + { + // Anthropic SSE: input from message_start, output from message_delta. Haiku 4.5: 1000×1/1M + 2000×5/1M. + name: "anthropic SSE stream", + url: "https://api.anthropic.com/v1/messages", + reqBody: []byte(`{"model":"claude-haiku-4-5","stream":true,"messages":[{"role":"user","content":"hi"}]}`), + respCT: sseCT, + respBody: sseBody(`{"type":"message_start","message":{"usage":{"input_tokens":1000,"output_tokens":2}}}`, `{"type":"content_block_delta","delta":{"type":"text_delta","text":"pong"}}`, `{"type":"message_delta","usage":{"output_tokens":2000}}`, `{"type":"message_stop"}`), + wantProvider: "anthropic", + wantModel: "claude-haiku-4-5", + wantCost: 0.011, + }, + { + // Kimi's Anthropic-compatible endpoint: kimi-k3 $3/$15 per MTok under the anthropic table. + name: "kimi anthropic shape", + url: "https://api.moonshot.ai/anthropic/v1/messages", + reqBody: []byte(`{"model":"kimi-k3","messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"usage":{"input_tokens":1000,"output_tokens":1000}}`), + wantProvider: "anthropic", + wantModel: "kimi-k3", + wantCost: 0.018, + }, + { + // Vertex path-routed model with "@version" stripped; Anthropic-on-Vertex priced under the anthropic table. + name: "vertex anthropic path-routed", + url: "https://aiplatform.googleapis.com/v1/projects/p/locations/global/publishers/anthropic/models/claude-sonnet-4-6@20260115:rawPredict", + reqBody: []byte(`{"anthropic_version":"vertex-2023-10-16","messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"usage":{"input_tokens":200,"output_tokens":100}}`), + wantProvider: "anthropic", + wantModel: "claude-sonnet-4-6", + wantCost: 0.0021, + }, + { + // Gateway-prefixed model ids are not in the pricing table: the meter must SKIP, never guess a rate. + name: "gateway-prefixed model skips pricing", + url: "https://gateway.example.com/v1/chat/completions", + reqBody: []byte(`{"model":"openai/gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}`), + respCT: jsonCT, + respBody: []byte(`{"usage":{"prompt_tokens":1000,"completion_tokens":500}}`), + wantProvider: "openai", + wantModel: "openai/gpt-4o-mini", + wantSkip: "unknown_model", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + in := &middleware.Input{ + Method: "POST", + URL: tc.url, + Headers: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + Body: tc.reqBody, + } + + reqOut, err := reqMW.Invoke(context.Background(), in) + require.NoError(t, err, "request parser") + in.Metadata = append(in.Metadata, reqOut.Metadata...) + + require.Equal(t, tc.wantProvider, metaKV(in.Metadata, middleware.KeyLLMProvider), "detected provider") + require.Equal(t, tc.wantModel, metaKV(in.Metadata, middleware.KeyLLMModel), "detected (normalized) model") + + in.Status = 200 + in.RespHeaders = []middleware.KV{{Key: "Content-Type", Value: tc.respCT}} + in.RespBody = tc.respBody + + respOut, err := respMW.Invoke(context.Background(), in) + require.NoError(t, err, "response parser") + in.Metadata = append(in.Metadata, respOut.Metadata...) + + costOut, err := costMW.Invoke(context.Background(), in) + require.NoError(t, err, "cost meter") + + if tc.wantSkip != "" { + assert.Equal(t, tc.wantSkip, metaKV(costOut.Metadata, middleware.KeyCostSkipped), "expected cost skip reason") + assert.Empty(t, metaKV(costOut.Metadata, middleware.KeyCostUSDTotal), "no cost may be emitted on skip") + return + } + + raw := metaKV(costOut.Metadata, middleware.KeyCostUSDTotal) + require.NotEmpty(t, raw, "cost.usd_total must be emitted; skip=%q", metaKV(costOut.Metadata, middleware.KeyCostSkipped)) + got, err := strconv.ParseFloat(raw, 64) + require.NoError(t, err, "cost must be a float") + // cost.usd_total is rendered with %.6f: allow half of the last printed digit on top of float error. + assert.InDelta(t, tc.wantCost, got, 5.1e-7, "USD cost for %s", tc.name) + + rawCache := metaKV(costOut.Metadata, middleware.KeyCostUSDCache) + require.NotEmpty(t, rawCache, "cost.usd_cache must be emitted next to cost.usd_total") + gotCache, err := strconv.ParseFloat(rawCache, 64) + require.NoError(t, err, "cache cost must be a float") + assert.InDelta(t, tc.wantCacheCost, gotCache, 5.1e-7, "cache USD cost for %s", tc.name) + }) + } +} + +// metaKV returns the value for key in kvs, or "" when absent. +func metaKV(kvs []middleware.KV, key string) string { + for _, kv := range kvs { + if kv.Key == key { + return kv.Value + } + } + return "" +} + +// sseBody renders data frames as a text/event-stream body. +func sseBody(frames ...string) []byte { + var b bytes.Buffer + for _, f := range frames { + b.WriteString("data: ") + b.WriteString(f) + b.WriteString("\n\n") + } + return b.Bytes() +} + +// awsFrame encodes one AWS event-stream frame with the given :event-type. +func awsFrame(t *testing.T, eventType string, payload []byte) []byte { + t.Helper() + var buf bytes.Buffer + enc := eventstream.NewEncoder() + require.NoError(t, enc.Encode(&buf, eventstream.Message{ + Headers: eventstream.Headers{{Name: ":event-type", Value: eventstream.StringValue(eventType)}}, + Payload: payload, + }), "encode event-stream frame") + return buf.Bytes() +} + +// bedrockInvokeStream builds an invoke-with-response-stream body: each "chunk" frame wraps a base64 Anthropic event. +func bedrockInvokeStream(t *testing.T, events ...string) []byte { + t.Helper() + var body bytes.Buffer + for _, ev := range events { + wrap, err := json.Marshal(map[string]string{"bytes": base64.StdEncoding.EncodeToString([]byte(ev))}) + require.NoError(t, err) + body.Write(awsFrame(t, "chunk", wrap)) + } + return body.Bytes() +} + +// bedrockConverseStream builds a converse-stream body: contentBlockDelta frames plus a trailing metadata usage frame. +func bedrockConverseStream(t *testing.T, deltas ...string) []byte { + t.Helper() + var body bytes.Buffer + for i, ev := range deltas { + eventType := "contentBlockDelta" + if i == len(deltas)-1 { + eventType = "metadata" + } + body.Write(awsFrame(t, eventType, []byte(ev))) + } + return body.Bytes() +} diff --git a/proxy/internal/middleware/builtin/cost_meter/factory.go b/proxy/internal/middleware/builtin/cost_meter/factory.go new file mode 100644 index 000000000..b8a58d10e --- /dev/null +++ b/proxy/internal/middleware/builtin/cost_meter/factory.go @@ -0,0 +1,88 @@ +package cost_meter + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + + "github.com/netbirdio/netbird/proxy/internal/llm/pricing" + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" +) + +// defaultPricingFilename is the basename probed inside the proxy data +// directory when no override is configured. +const defaultPricingFilename = "pricing.yaml" + +// Config is the on-wire configuration for the middleware. +type Config struct { + // PricingPath optionally overrides the basename of the pricing + // file probed inside the proxy data directory. When empty the + // loader falls back to "pricing.yaml". + PricingPath string `json:"pricing_path"` +} + +// Factory builds cost_meter instances from raw config bytes. +type Factory struct{} + +// ID returns the registry identifier. +func (Factory) ID() string { return ID } + +// New constructs a middleware instance. Empty, null, and {} configs +// are accepted; non-empty rawConfig that fails to unmarshal is +// rejected so misconfigurations surface at chain build time. The +// pricing loader is built once per instance and reused across +// invocations. +func (Factory) New(rawConfig []byte) (middleware.Middleware, error) { + cfg, err := decodeConfig(rawConfig) + if err != nil { + return nil, err + } + + fctx := builtin.Context() + pricingPath := cfg.PricingPath + if pricingPath == "" { + pricingPath = defaultPricingFilename + } + + loader, err := pricing.NewLoader(fctx.DataDir, pricingPath, ID, nil) + if err != nil { + return nil, fmt.Errorf("init pricing loader: %w", err) + } + + cancel := startReloader(fctx.Context, loader) + + return newMiddleware(loader, cancel), nil +} + +// startReloader binds the loader's mtime-poll goroutine to a context +// derived from the proxy-lifetime context and returns its cancel func so +// the owning middleware can stop the goroutine on teardown. Returns nil +// when there's nothing to watch (nil context or defaults-only loader), in +// which case the middleware's Close is a no-op. +func startReloader(ctx context.Context, loader *pricing.Loader) context.CancelFunc { + if ctx == nil || !loader.WatchesFile() { + return nil + } + cctx, cancel := context.WithCancel(ctx) + go loader.Reload(cctx) + return cancel +} + +// decodeConfig accepts empty, null, and {} configs, returning a +// zero-value Config. Non-empty payloads must parse cleanly. +func decodeConfig(rawConfig []byte) (Config, error) { + var cfg Config + if len(bytes.TrimSpace(rawConfig)) == 0 { + return cfg, nil + } + if err := json.Unmarshal(rawConfig, &cfg); err != nil { + return cfg, fmt.Errorf("decode config: %w", err) + } + return cfg, nil +} + +func init() { + builtin.Register(Factory{}) +} diff --git a/proxy/internal/middleware/builtin/cost_meter/middleware.go b/proxy/internal/middleware/builtin/cost_meter/middleware.go new file mode 100644 index 000000000..63da6d17b --- /dev/null +++ b/proxy/internal/middleware/builtin/cost_meter/middleware.go @@ -0,0 +1,218 @@ +// Package cost_meter implements the SlotOnResponse middleware that +// converts token-usage metadata emitted by llm_response_parser into a +// per-request USD cost estimate. The middleware uses the shared pricing +// loader so operator pricing overrides apply to the chain. +package cost_meter + +import ( + "context" + "fmt" + "strconv" + + "github.com/netbirdio/netbird/proxy/internal/llm/pricing" + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +// ID is the registry identifier for this middleware. +const ID = "cost_meter" + +// Version is the implementation version emitted via the spec merge. +const Version = "1.0.0" + +// Skip reasons emitted under KeyCostSkipped. The set is closed; the +// dashboard surfaces these verbatim. +const ( + skipMissingProvider = "missing_provider" + skipMissingModel = "missing_model" + skipMissingTokens = "missing_tokens" + //nolint:gosec // skip-reason label, not a credential + skipUnparseableTokens = "unparseable_tokens" + skipZeroTokens = "zero_tokens" + skipUnknownModel = "unknown_model" +) + +var metadataKeys = []string{ + middleware.KeyCostUSDInput, + middleware.KeyCostUSDCachedInput, + middleware.KeyCostUSDCacheCreation, + middleware.KeyCostUSDOutput, + middleware.KeyCostUSDTotal, + middleware.KeyCostUSDCache, + middleware.KeyCostSkipped, +} + +// Middleware computes a per-response cost estimate from the token +// counts emitted upstream by llm_response_parser. +type Middleware struct { + loader *pricing.Loader + // cancel stops this instance's pricing-reload goroutine. Non-nil only + // when the loader watches an override file; Close calls it so a chain + // rebuild doesn't leak a poll goroutine per retired instance. + cancel context.CancelFunc +} + +// newMiddleware constructs a Middleware bound to the given pricing loader. +// cancel may be nil (defaults-only loader with no reloader to stop). +func newMiddleware(loader *pricing.Loader, cancel context.CancelFunc) *Middleware { + return &Middleware{loader: loader, cancel: cancel} +} + +// ID returns the registry identifier. +func (m *Middleware) ID() string { return ID } + +// Version returns the implementation version. +func (m *Middleware) Version() string { return Version } + +// Slot reports that the middleware runs after the upstream call. +func (m *Middleware) Slot() middleware.Slot { return middleware.SlotOnResponse } + +// AcceptedContentTypes is empty: cost_meter never inspects bodies. +func (m *Middleware) AcceptedContentTypes() []string { return []string{} } + +// MetadataKeys returns the closed allowlist of keys this middleware +// may emit. +func (m *Middleware) MetadataKeys() []string { + return append([]string(nil), metadataKeys...) +} + +// MutationsSupported reports that this middleware never mutates the +// response. +func (m *Middleware) MutationsSupported() bool { return false } + +// Close stops this instance's pricing-reload goroutine, if any. Called by +// the chain when a rebuild retires the instance, so the mtime-poll loop +// doesn't outlive the chain it belonged to. Safe to call on a nil receiver +// and on an instance with no reloader. +func (m *Middleware) Close() error { + if m != nil && m.cancel != nil { + m.cancel() + } + return nil +} + +// Invoke reads provider, model, and token metadata, looks up pricing, +// and emits either KeyCostUSDTotal or KeyCostSkipped. The decision is +// always DecisionAllow; cost metering never denies or mutates. +func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) { + out := &middleware.Output{Decision: middleware.DecisionAllow} + if in == nil { + return out, nil + } + + provider := lookupKV(in.Metadata, middleware.KeyLLMProvider) + if provider == "" { + out.Metadata = skip(skipMissingProvider) + return out, nil + } + + model := lookupKV(in.Metadata, middleware.KeyLLMModel) + if model == "" { + out.Metadata = skip(skipMissingModel) + return out, nil + } + + inRaw, hasIn := lookupKVOK(in.Metadata, middleware.KeyLLMInputTokens) + outRaw, hasOut := lookupKVOK(in.Metadata, middleware.KeyLLMOutputTokens) + if !hasIn || !hasOut { + out.Metadata = skip(skipMissingTokens) + return out, nil + } + + inTokens, err := strconv.ParseInt(inRaw, 10, 64) + if err != nil || inTokens < 0 { + // Unparseable or negative tokens are not a runtime error: the + // upstream llm_response_parser emitted a non-numeric / invalid + // value, so we surface that as cost.skipped and continue with + // Allow rather than pricing a negative count. + out.Metadata = skip(skipUnparseableTokens) + return out, nil //nolint:nilerr // structured skip; not a runtime error + } + outTokens, err := strconv.ParseInt(outRaw, 10, 64) + if err != nil || outTokens < 0 { + out.Metadata = skip(skipUnparseableTokens) + return out, nil //nolint:nilerr // structured skip; not a runtime error + } + + // Cache buckets are optional and silently zeroed on a missing / + // malformed value; they're a refinement on top of input cost, + // not a precondition. A buggy value falls back to 0, never aborts. + cachedTokens := parseOptionalInt64(in.Metadata, middleware.KeyLLMCachedInputTokens) + cacheCreationTokens := parseOptionalInt64(in.Metadata, middleware.KeyLLMCacheCreationTokens) + + if inTokens == 0 && outTokens == 0 && cachedTokens == 0 && cacheCreationTokens == 0 { + out.Metadata = skip(skipZeroTokens) + return out, nil + } + + table := m.loader.Get() + costs, ok := table.Costs(provider, model, inTokens, outTokens, cachedTokens, cacheCreationTokens) + if !ok { + out.Metadata = skip(skipUnknownModel) + return out, nil + } + + // Per-bucket costs first: they're the base of the breakdown, and the two + // aggregates that follow are derived from exactly these four values. + out.Metadata = []middleware.KV{ + {Key: middleware.KeyCostUSDInput, Value: usd(costs.InputUSD)}, + {Key: middleware.KeyCostUSDCachedInput, Value: usd(costs.CachedInputUSD)}, + {Key: middleware.KeyCostUSDCacheCreation, Value: usd(costs.CacheCreationUSD)}, + {Key: middleware.KeyCostUSDOutput, Value: usd(costs.OutputUSD)}, + {Key: middleware.KeyCostUSDTotal, Value: usd(costs.TotalUSD)}, + {Key: middleware.KeyCostUSDCache, Value: usd(costs.CacheUSD)}, + } + return out, nil +} + +// usd renders a cost as the fixed-precision string every cost.usd_* key +// carries, so the per-bucket values and the aggregates round identically. +// +// 9 decimals, not 6: these values are summed downstream — per request, per +// session, and per usage bucket — so the rounding step is applied once per +// bucket per row and then accumulated. At 6 decimals a single row loses up to +// 2e-6 across its four buckets (enough to break a 1e-6 reconciliation against +// published rates), and a bucket smaller than half a microdollar quantises to +// zero outright: 16 cache-read tokens on a cheap model is 1.6e-9, so summing +// 10k such rows reports 0.02 instead of 0.016. Nano-dollar precision keeps the +// per-row error ~1000x below the smallest realistic bucket. +func usd(v float64) string { return fmt.Sprintf("%.9f", v) } + +// skip returns a single-entry metadata slice carrying the given skip +// reason under KeyCostSkipped. +func skip(reason string) []middleware.KV { + return []middleware.KV{{Key: middleware.KeyCostSkipped, Value: reason}} +} + +// lookupKV returns the value associated with key, or the empty string +// when the key is absent. +func lookupKV(kvs []middleware.KV, key string) string { + v, _ := lookupKVOK(kvs, key) + return v +} + +// lookupKVOK returns the value associated with key plus a presence +// flag so callers can distinguish absent from empty. +func lookupKVOK(kvs []middleware.KV, key string) (string, bool) { + for _, kv := range kvs { + if kv.Key == key { + return kv.Value, true + } + } + return "", false +} + +// parseOptionalInt64 reads a metadata value and decodes it as int64. +// Absent or unparseable values yield 0 — the caller treats absence as +// "no cached tokens" rather than an error, since cache buckets are a +// refinement, not a precondition. +func parseOptionalInt64(kvs []middleware.KV, key string) int64 { + raw, ok := lookupKVOK(kvs, key) + if !ok { + return 0 + } + v, err := strconv.ParseInt(raw, 10, 64) + if err != nil || v < 0 { + return 0 + } + return v +} diff --git a/proxy/internal/middleware/builtin/cost_meter/middleware_test.go b/proxy/internal/middleware/builtin/cost_meter/middleware_test.go new file mode 100644 index 000000000..e5d431d77 --- /dev/null +++ b/proxy/internal/middleware/builtin/cost_meter/middleware_test.go @@ -0,0 +1,508 @@ +package cost_meter + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" +) + +const fixturePricing = `openai: + gpt-4o: + input_per_1k: 0.0025 + output_per_1k: 0.01 + gpt-4o-mini: + input_per_1k: 0.00015 + output_per_1k: 0.0006 +anthropic: + claude-sonnet-4-5: + input_per_1k: 0.003 + output_per_1k: 0.015 +` + +// configureBuiltin points the package-level FactoryContext at a tmp +// directory containing the test pricing fixture. Returns the path so +// callers can override files later if needed. +func configureBuiltin(t *testing.T) string { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "pricing.yaml"), []byte(fixturePricing), 0o600), "write pricing fixture") + builtin.Configure(context.Background(), dir, nil, nil, nil) + return dir +} + +func metaValue(t *testing.T, kvs []middleware.KV, key string) (string, bool) { + t.Helper() + for _, kv := range kvs { + if kv.Key == key { + return kv.Value, true + } + } + return "", false +} + +func buildMiddleware(t *testing.T, raw []byte) middleware.Middleware { + t.Helper() + mw, err := Factory{}.New(raw) + require.NoError(t, err, "factory must accept the supplied config") + return mw +} + +func TestMiddleware_StaticSurface(t *testing.T) { + configureBuiltin(t) + mw := buildMiddleware(t, nil) + + assert.Equal(t, ID, mw.ID(), "ID must match the registered constant") + assert.Equal(t, Version, mw.Version(), "Version must match the constant") + assert.Equal(t, middleware.SlotOnResponse, mw.Slot(), "must run in the response slot") + assert.Empty(t, mw.AcceptedContentTypes(), "cost_meter does not inspect bodies") + assert.False(t, mw.MutationsSupported(), "cost_meter never mutates") + assert.NoError(t, mw.Close(), "Close on stateless middleware is a no-op") + + keys := mw.MetadataKeys() + expected := []string{ + middleware.KeyCostUSDInput, + middleware.KeyCostUSDCachedInput, + middleware.KeyCostUSDCacheCreation, + middleware.KeyCostUSDOutput, + middleware.KeyCostUSDTotal, + middleware.KeyCostUSDCache, + middleware.KeyCostSkipped, + } + assert.Equal(t, expected, keys, "metadata key allowlist must match the spec") +} + +func TestFactory_AcceptsEmptyAndJSONConfig(t *testing.T) { + configureBuiltin(t) + cases := [][]byte{nil, {}, []byte("null"), []byte("{}"), []byte(" ")} + for _, raw := range cases { + mw, err := Factory{}.New(raw) + require.NoError(t, err, "empty/null/object config must be accepted") + require.NotNil(t, mw, "factory must return a middleware instance") + } +} + +func TestFactory_RejectsMalformedConfig(t *testing.T) { + configureBuiltin(t) + mw, err := Factory{}.New([]byte("{not json")) + require.Error(t, err, "malformed config must surface at construction") + assert.Nil(t, mw, "no instance is returned on error") +} + +func TestFactory_DefaultPricingPathLoadsFixture(t *testing.T) { + configureBuiltin(t) + mw := buildMiddleware(t, nil) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o-mini"}, + {Key: middleware.KeyLLMInputTokens, Value: "1000"}, + {Key: middleware.KeyLLMOutputTokens, Value: "1000"}, + }, + }) + require.NoError(t, err) + require.Equal(t, middleware.DecisionAllow, out.Decision, "cost_meter always allows") + + value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) + require.True(t, ok, "cost.usd_total must be emitted for known model") + assert.Equal(t, "0.000750000", value, "0.00015 + 0.0006 per 1k tokens, 9-decimal format") +} + +func TestFactory_PricingPathOverride(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "custom.yaml"), []byte(fixturePricing), 0o600), "write custom pricing") + builtin.Configure(context.Background(), dir, nil, nil, nil) + + raw, err := json.Marshal(Config{PricingPath: "custom.yaml"}) + require.NoError(t, err) + + mw := buildMiddleware(t, raw) + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + {Key: middleware.KeyLLMInputTokens, Value: "2000"}, + {Key: middleware.KeyLLMOutputTokens, Value: "1000"}, + }, + }) + require.NoError(t, err) + + value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) + require.True(t, ok, "cost.usd_total must be emitted with custom pricing path") + assert.Equal(t, "0.015000000", value, "2*0.0025 + 1*0.01 = 0.015 with 9-decimal format") +} + +func TestInvoke_ComputesCostForKnownModel(t *testing.T) { + configureBuiltin(t) + mw := buildMiddleware(t, nil) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "anthropic"}, + {Key: middleware.KeyLLMModel, Value: "claude-sonnet-4-5"}, + {Key: middleware.KeyLLMInputTokens, Value: "1000"}, + {Key: middleware.KeyLLMOutputTokens, Value: "1000"}, + }, + }) + require.NoError(t, err) + + value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) + require.True(t, ok, "cost.usd_total must be emitted") + assert.Equal(t, "0.018000000", value, "0.003 + 0.015 = 0.018 with 9-decimal format") + _, skipped := metaValue(t, out.Metadata, middleware.KeyCostSkipped) + assert.False(t, skipped, "cost.skipped must not be set when cost is computed") +} + +func TestInvoke_MissingProvider(t *testing.T) { + configureBuiltin(t) + mw := buildMiddleware(t, nil) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + {Key: middleware.KeyLLMInputTokens, Value: "10"}, + {Key: middleware.KeyLLMOutputTokens, Value: "10"}, + }, + }) + require.NoError(t, err) + value, ok := metaValue(t, out.Metadata, middleware.KeyCostSkipped) + require.True(t, ok, "cost.skipped must be set when provider is missing") + assert.Equal(t, skipMissingProvider, value, "skip reason matches missing_provider") +} + +func TestInvoke_MissingModel(t *testing.T) { + configureBuiltin(t) + mw := buildMiddleware(t, nil) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMInputTokens, Value: "10"}, + {Key: middleware.KeyLLMOutputTokens, Value: "10"}, + }, + }) + require.NoError(t, err) + value, ok := metaValue(t, out.Metadata, middleware.KeyCostSkipped) + require.True(t, ok, "cost.skipped must be set when model is missing") + assert.Equal(t, skipMissingModel, value, "skip reason matches missing_model") +} + +func TestInvoke_MissingTokens(t *testing.T) { + configureBuiltin(t) + mw := buildMiddleware(t, nil) + + cases := []struct { + name string + md []middleware.KV + }{ + { + name: "input only", + md: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + {Key: middleware.KeyLLMInputTokens, Value: "10"}, + }, + }, + { + name: "output only", + md: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + {Key: middleware.KeyLLMOutputTokens, Value: "10"}, + }, + }, + { + name: "neither", + md: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, err := mw.Invoke(context.Background(), &middleware.Input{Metadata: tc.md}) + require.NoError(t, err) + value, ok := metaValue(t, out.Metadata, middleware.KeyCostSkipped) + require.True(t, ok, "cost.skipped must be set when token keys are missing") + assert.Equal(t, skipMissingTokens, value, "skip reason matches missing_tokens") + }) + } +} + +func TestInvoke_UnparseableTokens(t *testing.T) { + configureBuiltin(t) + mw := buildMiddleware(t, nil) + + cases := []struct { + name string + in string + out string + }{ + {name: "input non-numeric", in: "abc", out: "10"}, + {name: "output non-numeric", in: "10", out: "xyz"}, + {name: "both garbage", in: "??", out: "??"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + {Key: middleware.KeyLLMInputTokens, Value: tc.in}, + {Key: middleware.KeyLLMOutputTokens, Value: tc.out}, + }, + }) + require.NoError(t, err) + value, ok := metaValue(t, out.Metadata, middleware.KeyCostSkipped) + require.True(t, ok, "cost.skipped must be set on unparseable tokens") + assert.Equal(t, skipUnparseableTokens, value, "skip reason matches unparseable_tokens") + }) + } +} + +func TestInvoke_ZeroTokens(t *testing.T) { + configureBuiltin(t) + mw := buildMiddleware(t, nil) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + {Key: middleware.KeyLLMInputTokens, Value: "0"}, + {Key: middleware.KeyLLMOutputTokens, Value: "0"}, + }, + }) + require.NoError(t, err) + value, ok := metaValue(t, out.Metadata, middleware.KeyCostSkipped) + require.True(t, ok, "cost.skipped must be set when both token counts are zero") + assert.Equal(t, skipZeroTokens, value, "skip reason matches zero_tokens") + _, hasCost := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) + assert.False(t, hasCost, "cost.usd_total must not be emitted for zero tokens") +} + +func TestInvoke_UnknownModel(t *testing.T) { + configureBuiltin(t) + mw := buildMiddleware(t, nil) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "fantasy-model-9000"}, + {Key: middleware.KeyLLMInputTokens, Value: "10"}, + {Key: middleware.KeyLLMOutputTokens, Value: "10"}, + }, + }) + require.NoError(t, err) + value, ok := metaValue(t, out.Metadata, middleware.KeyCostSkipped) + require.True(t, ok, "cost.skipped must be set when pricing entry is absent") + assert.Equal(t, skipUnknownModel, value, "skip reason matches unknown_model") +} + +func TestInvoke_NilInput(t *testing.T) { + configureBuiltin(t) + mw := buildMiddleware(t, nil) + + out, err := mw.Invoke(context.Background(), nil) + require.NoError(t, err) + require.NotNil(t, out, "output must be returned even on nil input") + assert.Equal(t, middleware.DecisionAllow, out.Decision, "decision must be allow on nil input") + assert.Empty(t, out.Metadata, "no metadata must be emitted on nil input") +} + +const fixturePricingWithCache = `openai: + gpt-4o: + input_per_1k: 0.0025 + output_per_1k: 0.01 + cached_input_per_1k: 0.00125 +anthropic: + claude-sonnet-4-5: + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + cache_creation_per_1k: 0.00375 +` + +// configureBuiltinWithCacheRates points the package-level +// FactoryContext at a tmp directory containing pricing entries that +// include the cache rate fields. +func configureBuiltinWithCacheRates(t *testing.T) { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "pricing.yaml"), []byte(fixturePricingWithCache), 0o600), "write cache-aware pricing fixture") + builtin.Configure(context.Background(), dir, nil, nil, nil) +} + +// TestInvoke_OpenAICachedSubsetDiscount proves the OpenAI shape end +// to end through the middleware: cached_input_tokens is treated as a +// SUBSET of input_tokens and discounted at the configured rate, not +// added on top. +func TestInvoke_OpenAICachedSubsetDiscount(t *testing.T) { + configureBuiltinWithCacheRates(t) + mw := buildMiddleware(t, nil) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + {Key: middleware.KeyLLMInputTokens, Value: "1000"}, + {Key: middleware.KeyLLMOutputTokens, Value: "500"}, + {Key: middleware.KeyLLMCachedInputTokens, Value: "750"}, + }, + }) + require.NoError(t, err) + require.Equal(t, middleware.DecisionAllow, out.Decision) + + value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) + require.True(t, ok, "cached subset path must produce a cost — never a skip") + // 250 non-cached at 0.0025/1k + 750 cached at 0.00125/1k + 500 output at 0.01/1k. + assert.Equal(t, "0.006562500", value, + "cached subset must be billed at the discount rate, non-cached at the full rate; never double-billed") + + cache, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDCache) + require.True(t, ok, "cost.usd_cache must be emitted alongside cost.usd_total") + // 750 cached at 0.00125/1k = 0.0009375. + assert.Equal(t, "0.000937500", cache, "cache cost is the discounted cost of the cached subset") + + // Per-bucket breakdown. On OpenAI the cached subset is carved out of the + // input bucket, so input covers only the 250 non-cached tokens — the two + // must never double-count the same 750 tokens. + assertBucket(t, out.Metadata, middleware.KeyCostUSDInput, "0.000625000", + "input bucket bills only the non-cached remainder") + assertBucket(t, out.Metadata, middleware.KeyCostUSDCachedInput, "0.000937500", + "cached-input bucket bills the discounted subset") + assertBucket(t, out.Metadata, middleware.KeyCostUSDCacheCreation, "0.000000000", + "OpenAI has no cache-write bucket") + assertBucket(t, out.Metadata, middleware.KeyCostUSDOutput, "0.005000000", + "output bucket bills 500 tokens at 0.01/1k") +} + +// TestInvoke_AnthropicCacheBucketsAdditive proves the Anthropic +// shape: cache_read and cache_creation are additive to input_tokens +// and each carries its own rate. +func TestInvoke_AnthropicCacheBucketsAdditive(t *testing.T) { + configureBuiltinWithCacheRates(t) + mw := buildMiddleware(t, nil) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "anthropic"}, + {Key: middleware.KeyLLMModel, Value: "claude-sonnet-4-5"}, + {Key: middleware.KeyLLMInputTokens, Value: "256"}, + {Key: middleware.KeyLLMOutputTokens, Value: "200"}, + {Key: middleware.KeyLLMCachedInputTokens, Value: "768"}, + {Key: middleware.KeyLLMCacheCreationTokens, Value: "512"}, + }, + }) + require.NoError(t, err) + require.Equal(t, middleware.DecisionAllow, out.Decision) + + value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) + require.True(t, ok) + // 256 input * 0.003 + 768 cache_read * 0.0003 + 512 cache_creation * 0.00375 + 200 output * 0.015 + // = 0.000768 + 0.0002304 + 0.00192 + 0.003 = 0.0059184. + assert.Equal(t, "0.005918400", value, + "each Anthropic input bucket must bill at its own rate — cache_read cheap, cache_creation expensive, regular input mid") + + cache, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDCache) + require.True(t, ok, "cost.usd_cache must be emitted alongside cost.usd_total") + // 768 cache_read * 0.0003 + 512 cache_creation * 0.00375 = 0.0021504. + assert.Equal(t, "0.002150400", cache, "cache cost sums the read and creation buckets") + + // Per-bucket breakdown: four separately-billed buckets, each at its own rate. + assertBucket(t, out.Metadata, middleware.KeyCostUSDInput, "0.000768000", + "input bucket bills 256 tokens at 0.003/1k") + assertBucket(t, out.Metadata, middleware.KeyCostUSDCachedInput, "0.000230400", + "cache-read bucket bills 768 tokens at the cheap 0.0003/1k") + assertBucket(t, out.Metadata, middleware.KeyCostUSDCacheCreation, "0.001920000", + "cache-write bucket bills 512 tokens at the expensive 0.00375/1k") + assertBucket(t, out.Metadata, middleware.KeyCostUSDOutput, "0.003000000", + "output bucket bills 200 tokens at 0.015/1k") +} + +// assertBucket asserts one per-bucket cost key carries the expected +// 6-decimal value. +func assertBucket(t *testing.T, md []middleware.KV, key, want, msg string) { + t.Helper() + got, ok := metaValue(t, md, key) + require.Truef(t, ok, "%s must be emitted", key) + assert.Equal(t, want, got, msg) +} + +// TestInvoke_CachedTokensAbsentFallsBackToBaseFormula covers the +// "operator hasn't opted in" path: with no cached metadata keys +// emitted, the meter must produce exactly the same cost as before +// the feature landed. Critical so operators with the new binary but +// no YAML changes see no behavioural drift on OpenAI requests. +func TestInvoke_CachedTokensAbsentFallsBackToBaseFormula(t *testing.T) { + configureBuiltinWithCacheRates(t) + mw := buildMiddleware(t, nil) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + {Key: middleware.KeyLLMInputTokens, Value: "1000"}, + {Key: middleware.KeyLLMOutputTokens, Value: "500"}, + // No KeyLLMCachedInputTokens — the parser didn't see one. + }, + }) + require.NoError(t, err) + value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) + require.True(t, ok) + // 1000 input * 0.0025 + 500 output * 0.01 = 0.0025 + 0.005 = 0.0075 + assert.Equal(t, "0.007500000", value, "no cached metadata = same cost as before the feature landed") +} + +// TestInvoke_UnparseableCachedTokensSkippedSilently proves the +// optional-bucket contract: a malformed cached_input_tokens metadata +// value falls back to 0 (= no cached count) and continues with the +// regular formula. Cache buckets are a refinement, never a reason to +// abort cost computation. +func TestInvoke_UnparseableCachedTokensSkippedSilently(t *testing.T) { + configureBuiltinWithCacheRates(t) + mw := buildMiddleware(t, nil) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + {Key: middleware.KeyLLMInputTokens, Value: "1000"}, + {Key: middleware.KeyLLMOutputTokens, Value: "500"}, + {Key: middleware.KeyLLMCachedInputTokens, Value: "not-a-number"}, + }, + }) + require.NoError(t, err) + value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal) + require.True(t, ok, "garbage cache metadata must NOT switch the response from a cost to a skip — fall back to 0 cached") + assert.Equal(t, "0.007500000", value, "same as the no-cached-metadata path") +} + +// TestMiddleware_CloseCancelsReloader proves Close stops the per-instance +// pricing-reload goroutine: a chain rebuild retires the old instance and +// calls Close, which must invoke the cancel func startReloader handed it so +// the mtime-poll loop doesn't outlive the chain. +func TestMiddleware_CloseCancelsReloader(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + m := newMiddleware(nil, cancel) + + require.NoError(t, m.Close(), "Close must not error") + require.Error(t, ctx.Err(), "Close must cancel the reloader context so the poll goroutine exits") +} + +// TestMiddleware_CloseNilSafe confirms Close is a no-op (no panic) for an +// instance with no reloader and for a nil receiver. +func TestMiddleware_CloseNilSafe(t *testing.T) { + require.NoError(t, newMiddleware(nil, nil).Close(), "no-reloader Close must be a no-op") + var m *Middleware + require.NoError(t, m.Close(), "nil-receiver Close must be safe") +} diff --git a/proxy/internal/middleware/builtin/llm_guardrail/factory.go b/proxy/internal/middleware/builtin/llm_guardrail/factory.go new file mode 100644 index 000000000..6dd2a8e8d --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_guardrail/factory.go @@ -0,0 +1,82 @@ +package llm_guardrail + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" +) + +// Config is the JSON-decoded shape accepted by the factory. The +// runtime path consumes the normalised allowlist; raw config is not +// retained beyond construction. +type Config struct { + ModelAllowlist []string `json:"model_allowlist"` + PromptCapture PromptCapture `json:"prompt_capture"` +} + +// PromptCapture toggles the optional prompt capture + redaction step +// that emits llm.request_prompt onto the metadata bag. +type PromptCapture struct { + Enabled bool `json:"enabled"` + RedactPii bool `json:"redact_pii"` +} + +// Factory builds a configured llm_guardrail middleware instance. +type Factory struct{} + +// ID returns the registry identifier matching the middleware ID. +func (Factory) ID() string { return ID } + +// New decodes the raw JSON config and returns a ready Middleware. An +// empty / null / empty-object payload yields a zero-value Config. +func (Factory) New(rawConfig []byte) (middleware.Middleware, error) { + cfg := Config{} + if len(rawConfig) > 0 && !isEmptyJSON(rawConfig) { + if err := json.Unmarshal(rawConfig, &cfg); err != nil { + return nil, fmt.Errorf("decode config: %w", err) + } + } + return New(cfg), nil +} + +// isEmptyJSON reports whether the payload is whitespace, null, or an +// empty object/array. The caller skips Unmarshal in that case so the +// zero-value Config flows through unchanged. +func isEmptyJSON(raw []byte) bool { + trimmed := strings.TrimSpace(string(raw)) + switch trimmed { + case "", "null", "{}", "[]": + return true + } + return false +} + +// normaliseConfig lowercases and trims allowlist entries so the runtime +// match is case-insensitive. Empty entries are dropped. +func normaliseConfig(cfg Config) Config { + if len(cfg.ModelAllowlist) == 0 { + return cfg + } + cleaned := make([]string, 0, len(cfg.ModelAllowlist)) + for _, entry := range cfg.ModelAllowlist { + n := normaliseModel(entry) + if n == "" { + continue + } + cleaned = append(cleaned, n) + } + cfg.ModelAllowlist = cleaned + return cfg +} + +// normaliseModel lowercases and trims a single model identifier. +func normaliseModel(model string) string { + return strings.ToLower(strings.TrimSpace(model)) +} + +func init() { + builtin.Register(Factory{}) +} diff --git a/proxy/internal/middleware/builtin/llm_guardrail/middleware.go b/proxy/internal/middleware/builtin/llm_guardrail/middleware.go new file mode 100644 index 000000000..eded877ac --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_guardrail/middleware.go @@ -0,0 +1,205 @@ +// Package llm_guardrail implements the SlotOnRequest middleware that +// enforces the per-target LLM guardrail policy: a model allowlist +// check and an opt-in prompt-capture step that may run a PII redactor +// before emitting the prompt into the metadata bag. +// +// The middleware runs after llm_request_parser, which is responsible +// for extracting the model and raw prompt onto the metadata side +// channel. llm_guardrail consumes those keys, decides allow/deny, and +// emits its own decision metadata plus the optional redacted prompt. +package llm_guardrail + +import ( + "context" + "unicode/utf8" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +// ID is the registry key for this middleware. +const ID = "llm_guardrail" + +const ( + version = "1.0.0" + maxPromptBytes = 3500 + denyCodeModel = "llm_policy.model_blocked" + denyReasonModel = "model_blocked" + denyMessageModel = "model is not in the policy allowlist" + // Deny reason used when an allowlist is configured but the request model + // could not be determined. URL/path-routed providers (AWS Bedrock, Google + // Vertex, ...) carry the model outside the JSON body, so a request shape the + // parser does not recognise reaches the guardrail with no model. Such a + // request must be denied (fail closed), never waved through. + denyCodeModelUnknown = "llm_policy.model_unknown" + denyReasonModelUnknown = "model_unknown" + denyMessageModelUnknown = "request model could not be determined for the policy allowlist" +) + +// Middleware enforces the model allowlist and optionally captures the +// request prompt with PII redaction. +type Middleware struct { + cfg Config +} + +// New constructs a Middleware with the supplied configuration. Model +// allowlist entries are normalised so the runtime check is +// case-insensitive and trim-tolerant. +func New(cfg Config) *Middleware { + return &Middleware{cfg: normaliseConfig(cfg)} +} + +// ID returns the registry identifier. +func (m *Middleware) ID() string { return ID } + +// Version returns the implementation version. +func (m *Middleware) Version() string { return version } + +// Slot reports the chain slot the middleware lives in. +func (m *Middleware) Slot() middleware.Slot { return middleware.SlotOnRequest } + +// AcceptedContentTypes lists the request body content types the +// middleware needs. Guardrail consumes metadata produced upstream and +// does not touch the body itself, but we keep application/json so the +// body policy retains the parsed payload upstream when required. +func (m *Middleware) AcceptedContentTypes() []string { + return []string{"application/json"} +} + +// MetadataKeys is the closed set of metadata keys this middleware may +// emit. The accumulator drops anything outside this allowlist. +func (m *Middleware) MetadataKeys() []string { + return []string{ + middleware.KeyLLMPolicyDecision, + middleware.KeyLLMPolicyReason, + middleware.KeyLLMRequestPrompt, + } +} + +// MutationsSupported reports whether the middleware emits header / body +// mutations. Guardrail never mutates the request. +func (m *Middleware) MutationsSupported() bool { return false } + +// Invoke runs the policy. The model allowlist is the only deny path; +// prompt capture only affects the metadata emitted alongside an allow. +func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) { + model, modelPresent := lookupMetadata(in.Metadata, middleware.KeyLLMModel) + + if denial := m.evaluateAllowlist(model, modelPresent); denial != nil { + return denial, nil + } + + out := &middleware.Output{ + Decision: middleware.DecisionAllow, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMPolicyDecision, Value: "allow"}, + {Key: middleware.KeyLLMPolicyReason, Value: ""}, + }, + } + + if prompt, ok := m.capturePrompt(in.Metadata); ok { + out.Metadata = append(out.Metadata, middleware.KV{ + Key: middleware.KeyLLMRequestPrompt, + Value: prompt, + }) + } + + return out, nil +} + +// Close releases resources owned by the middleware. Stateless, so this +// is a no-op. +func (m *Middleware) Close() error { return nil } + +// evaluateAllowlist returns a deny Output when the configured allowlist +// rejects the model. A nil return means the request should proceed. +func (m *Middleware) evaluateAllowlist(model string, modelPresent bool) *middleware.Output { + if len(m.cfg.ModelAllowlist) == 0 { + return nil + } + // Fail closed: with an allowlist configured, a request whose model the + // upstream parser could not extract (absent or empty) must be denied rather + // than allowed. This is what enforces the allowlist for URL/path-routed + // providers (Bedrock, Vertex, ...) whose model lives outside the JSON body. + if !modelPresent || normaliseModel(model) == "" { + return denyModel("", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown) + } + if m.modelInAllowlist(model) { + return nil + } + return denyModel(model, denyCodeModel, denyMessageModel, denyReasonModel) +} + +// denyModel builds a 403 deny Output for a model-allowlist rejection. model is +// included in the details only when non-empty. +func denyModel(model, code, message, reason string) *middleware.Output { + details := map[string]string{} + if model != "" { + details["model"] = model + } + return &middleware.Output{ + Decision: middleware.DecisionDeny, + DenyStatus: 403, + DenyReason: &middleware.DenyReason{ + Code: code, + Message: message, + Details: details, + }, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMPolicyDecision, Value: "deny"}, + {Key: middleware.KeyLLMPolicyReason, Value: reason}, + }, + } +} + +// modelInAllowlist reports whether the model matches any allowlist +// entry under the case-insensitive, trim-tolerant comparison rule. +func (m *Middleware) modelInAllowlist(model string) bool { + normalised := normaliseModel(model) + if normalised == "" { + return false + } + for _, allowed := range m.cfg.ModelAllowlist { + if allowed == normalised { + return true + } + } + return false +} + +// capturePrompt returns the prompt to emit and whether it should be +// emitted at all. The truncation guarantee is upheld here regardless of +// whether redaction grew the string. +func (m *Middleware) capturePrompt(meta []middleware.KV) (string, bool) { + if !m.cfg.PromptCapture.Enabled { + return "", false + } + raw, ok := lookupMetadata(meta, middleware.KeyLLMRequestPromptRaw) + if !ok { + return "", false + } + prompt := raw + if m.cfg.PromptCapture.RedactPii { + prompt = redactPII(prompt) + } + if len(prompt) > maxPromptBytes { + // Back off to a UTF-8 rune boundary so we never emit a string + // split mid-rune. + cut := maxPromptBytes + for cut > 0 && !utf8.RuneStart(prompt[cut]) { + cut-- + } + prompt = prompt[:cut] + } + return prompt, true +} + +// lookupMetadata finds the first KV with the given key. Returns the +// value and true when present; the empty string and false otherwise. +func lookupMetadata(meta []middleware.KV, key string) (string, bool) { + for _, kv := range meta { + if kv.Key == key { + return kv.Value, true + } + } + return "", false +} diff --git a/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go b/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go new file mode 100644 index 000000000..cd7e256dd --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_guardrail/middleware_test.go @@ -0,0 +1,250 @@ +package llm_guardrail + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +func metaValue(t *testing.T, kvs []middleware.KV, key string) (string, bool) { + t.Helper() + for _, kv := range kvs { + if kv.Key == key { + return kv.Value, true + } + } + return "", false +} + +func newInput(meta ...middleware.KV) *middleware.Input { + return &middleware.Input{Slot: middleware.SlotOnRequest, Metadata: meta} +} + +func TestMiddlewareIdentity(t *testing.T) { + mw := New(Config{}) + assert.Equal(t, ID, mw.ID(), "middleware ID must be llm_guardrail") + assert.Equal(t, "1.0.0", mw.Version(), "version must be 1.0.0") + assert.Equal(t, middleware.SlotOnRequest, mw.Slot(), "guardrail must run in SlotOnRequest") + assert.False(t, mw.MutationsSupported(), "guardrail must not mutate requests") + assert.Equal(t, []string{"application/json"}, mw.AcceptedContentTypes(), "guardrail accepts application/json bodies") + assert.Equal(t, + []string{ + middleware.KeyLLMPolicyDecision, + middleware.KeyLLMPolicyReason, + middleware.KeyLLMRequestPrompt, + }, + mw.MetadataKeys(), + "metadata key allowlist must match the spec", + ) + require.NoError(t, mw.Close()) +} + +func TestAllowlistEmptyAllowsAnyModel(t *testing.T) { + mw := New(Config{}) + out, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + )) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "empty allowlist must allow any model") + v, ok := metaValue(t, out.Metadata, middleware.KeyLLMPolicyDecision) + require.True(t, ok, "decision metadata must be emitted") + assert.Equal(t, "allow", v, "decision must be allow") + r, ok := metaValue(t, out.Metadata, middleware.KeyLLMPolicyReason) + require.True(t, ok, "reason metadata must be emitted") + assert.Equal(t, "", r, "reason must be empty on allow") +} + +func TestAllowlistMatchAllows(t *testing.T) { + mw := New(Config{ModelAllowlist: []string{"gpt-4o", "claude-opus-4"}}) + out, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + )) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "model in allowlist must be allowed") +} + +func TestAllowlistMissDenies(t *testing.T) { + mw := New(Config{ModelAllowlist: []string{"gpt-4o"}}) + out, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMModel, Value: "claude-opus-4"}, + )) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "non-allowlisted model must be denied") + assert.Equal(t, 403, out.DenyStatus, "deny status must be 403") + require.NotNil(t, out.DenyReason, "deny reason must be populated") + assert.Equal(t, "llm_policy.model_blocked", out.DenyReason.Code, "deny code must match spec") + assert.Equal(t, "model is not in the policy allowlist", out.DenyReason.Message, "deny message must match spec") + assert.Equal(t, "claude-opus-4", out.DenyReason.Details["model"], "deny details must include the offending model") + + dec, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyDecision) + assert.Equal(t, "deny", dec, "decision metadata must be deny") + reason, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyReason) + assert.Equal(t, "model_blocked", reason, "reason metadata must be model_blocked") +} + +func TestAllowlistCaseInsensitive(t *testing.T) { + mw := New(Config{ModelAllowlist: []string{" GPT-4o ", "Claude-OPUS-4"}}) + cases := []string{"gpt-4o", "GPT-4O", " claude-opus-4 "} + for _, model := range cases { + out, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMModel, Value: model}, + )) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "case/whitespace variants must match: %q", model) + } +} + +func TestAllowlistMissingModelKeyDenies(t *testing.T) { + // Fail closed: with an allowlist configured, a request whose model the + // parser could not extract (URL/path-routed providers such as Bedrock or + // Vertex whose shape wasn't recognised) must be denied, not allowed. + mw := New(Config{ModelAllowlist: []string{"gpt-4o"}}) + out, err := mw.Invoke(context.Background(), newInput()) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "absent model must be denied when an allowlist is set") + assert.Equal(t, 403, out.DenyStatus, "deny status must be 403") + require.NotNil(t, out.DenyReason, "deny reason must be populated") + assert.Equal(t, "llm_policy.model_unknown", out.DenyReason.Code, "deny code must be model_unknown") + dec, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyDecision) + assert.Equal(t, "deny", dec, "decision must be deny when model key is absent") + reason, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyReason) + assert.Equal(t, "model_unknown", reason, "reason metadata must be model_unknown") +} + +func TestAllowlistEmptyModelValueDenies(t *testing.T) { + // A present-but-empty model is as undeterminable as an absent one. + mw := New(Config{ModelAllowlist: []string{"gpt-4o"}}) + out, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMModel, Value: " "}, + )) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "empty model must be denied when an allowlist is set") + require.NotNil(t, out.DenyReason, "deny reason must be populated") + assert.Equal(t, "llm_policy.model_unknown", out.DenyReason.Code, "deny code must be model_unknown") +} + +func TestAllowlistEmptyListAllowsMissingModel(t *testing.T) { + // Without an allowlist there is nothing to enforce, so a missing model is + // still allowed — the fail-closed rule only applies when a list is set. + mw := New(Config{}) + out, err := mw.Invoke(context.Background(), newInput()) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "no allowlist must allow even without a model") +} + +func TestPromptCaptureDisabledEmitsNoPrompt(t *testing.T) { + mw := New(Config{}) + out, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMRequestPromptRaw, Value: "hello world"}, + )) + require.NoError(t, err) + _, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPrompt) + assert.False(t, ok, "prompt must not be emitted when capture is disabled") +} + +func TestPromptCaptureNoRedactionEmitsRaw(t *testing.T) { + mw := New(Config{PromptCapture: PromptCapture{Enabled: true}}) + raw := "hello world from user@example.com" + out, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMRequestPromptRaw, Value: raw}, + )) + require.NoError(t, err) + prompt, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPrompt) + require.True(t, ok, "prompt must be emitted when capture is enabled") + assert.Equal(t, raw, prompt, "prompt must pass through unchanged when redaction is off") +} + +func TestPromptCaptureWithRedactionRedacts(t *testing.T) { + mw := New(Config{PromptCapture: PromptCapture{Enabled: true, RedactPii: true}}) + raw := "contact me at user@example.com or +14155551234" + out, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMRequestPromptRaw, Value: raw}, + )) + require.NoError(t, err) + prompt, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPrompt) + require.True(t, ok, "prompt must be emitted when capture is enabled") + assert.Contains(t, prompt, "[REDACTED:email]", "email must be redacted") + assert.Contains(t, prompt, "[REDACTED:phone]", "phone must be redacted") + assert.NotContains(t, prompt, "user@example.com", "raw email must not leak") +} + +func TestPromptCaptureRedactionTruncatesIfGrows(t *testing.T) { + mw := New(Config{PromptCapture: PromptCapture{Enabled: true, RedactPii: true}}) + body := strings.Repeat("a", maxPromptBytes-10) + " user@example.com" + out, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMRequestPromptRaw, Value: body}, + )) + require.NoError(t, err) + prompt, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPrompt) + require.True(t, ok, "prompt must be emitted when capture is enabled") + assert.LessOrEqual(t, len(prompt), maxPromptBytes, "prompt must be truncated to maxPromptBytes") +} + +func TestPromptCaptureMissingRawNoEmit(t *testing.T) { + mw := New(Config{PromptCapture: PromptCapture{Enabled: true, RedactPii: true}}) + out, err := mw.Invoke(context.Background(), newInput()) + require.NoError(t, err) + _, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPrompt) + assert.False(t, ok, "prompt must not be emitted when raw key is missing") +} + +func TestFactoryAcceptsZeroConfigs(t *testing.T) { + cases := map[string][]byte{ + "nil": nil, + "empty": []byte(""), + "whitespace": []byte(" \n "), + "null": []byte("null"), + "emptyObject": []byte("{}"), + } + f := Factory{} + for name, raw := range cases { + mw, err := f.New(raw) + require.NoError(t, err, "case %s must yield a zero-value config", name) + require.NotNil(t, mw) + assert.Equal(t, ID, mw.ID(), "case %s must build a guardrail middleware", name) + } +} + +func TestFactoryDecodesValidConfig(t *testing.T) { + cfg := Config{ + ModelAllowlist: []string{"gpt-4o"}, + PromptCapture: PromptCapture{Enabled: true, RedactPii: true}, + } + raw, err := json.Marshal(cfg) + require.NoError(t, err, "marshalling test config must succeed") + mw, err := Factory{}.New(raw) + require.NoError(t, err) + require.NotNil(t, mw) +} + +func TestFactoryRejectsMalformedJSON(t *testing.T) { + mw, err := Factory{}.New([]byte("{not-json")) + assert.Error(t, err, "malformed JSON must surface as a factory error") + assert.Nil(t, mw, "no middleware must be returned on malformed config") +} + +func TestFactoryNormalisesAllowlist(t *testing.T) { + raw := []byte(`{"model_allowlist":[" GPT-4o ","",""," Claude-3 "]}`) + mw, err := Factory{}.New(raw) + require.NoError(t, err) + out, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + )) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "factory must lowercase + trim allowlist entries") + out2, err := mw.Invoke(context.Background(), newInput( + middleware.KV{Key: middleware.KeyLLMModel, Value: "claude-3"}, + )) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out2.Decision, "trimmed entry must still match") +} diff --git a/proxy/internal/middleware/builtin/llm_guardrail/redact.go b/proxy/internal/middleware/builtin/llm_guardrail/redact.go new file mode 100644 index 000000000..c6cb270df --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_guardrail/redact.go @@ -0,0 +1,75 @@ +package llm_guardrail + +import ( + "regexp" + "strings" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +// PII redactor scope: redact prompt content BEFORE it lands in the metadata +// bag. The bearer-with-keyword pass runs first so the keyword is preserved. +// We then chain the package-level middleware.Scan to pick up PEM, JWT, AWS +// access keys, generic bearer tokens (40+ chars), and Luhn-validated credit +// cards — keeping prompt redaction in sync with metadata-value scanning. Email, +// SSN (dashed form), phone (E.164 + NA), and IPv4 are prompt-shaped patterns +// the metadata scanner intentionally leaves alone. +var ( + emailRegex = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`) + ssnRegex = regexp.MustCompile(`\b\d{3}-\d{2}-\d{4}\b`) + phoneE164 = regexp.MustCompile(`\+\d{8,15}\b`) + // phoneNARgx accepts the 3-3-4 North-American shape with any of the common + // separators (space, dot, dash, slash) or none at all between the area code + // and the body. The optional `\(?...\)?` wraps the area code; the separator + // classes use `*` (not `?`) so multi-char separators ("(202) " followed by + // space-and-something) and zero-separator runs ("2025550134") both match. + // False-positive tradeoff: 10 consecutive digits in a prompt will be + // treated as a phone number. For PII redaction that is the correct way to + // err — under-redaction leaks; over-redaction is annoying. + phoneNARgx = regexp.MustCompile(`\(?\b\d{3}\)?[\s.\-/]*\d{3}[\s.\-/]*\d{4}\b`) + bearerRegex = regexp.MustCompile(`(?i)\b(bearer|token|api[_-]?key|authorization)([\s:=]+)(\S{20,})`) + ipv4Regex = regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\b`) +) + +// redactPII is the package-private alias kept for internal callers; new code +// outside the guardrail middleware should call RedactPII. +func redactPII(value string) string { return RedactPII(value) } + +// RedactPII replaces high-signal PII patterns in value with +// `[REDACTED:]`. Non-matching input is returned unchanged. Exported so +// the request / response parsers can reuse the same coverage on raw prompts +// and completions when the account's redact_pii toggle is on. +func RedactPII(value string) string { + if value == "" { + return value + } + result := value + // Keyword-preserving bearer first so the "bearer "/"token=" prefix survives + // before the generic scanner gets at the same content. + result = bearerRegex.ReplaceAllStringFunc(result, redactBearer) + // Structured secrets shared with metadata-value scanning: PEM, JWT, AWS + // keys, generic bearer (40+), and Luhn-validated credit cards. + result = middleware.Scan(result) + // Prompt-shaped PII the metadata scanner doesn't cover. + result = emailRegex.ReplaceAllString(result, "[REDACTED:email]") + result = ssnRegex.ReplaceAllString(result, "[REDACTED:ssn]") + result = phoneE164.ReplaceAllString(result, "[REDACTED:phone]") + result = phoneNARgx.ReplaceAllString(result, "[REDACTED:phone]") + result = ipv4Regex.ReplaceAllString(result, "[REDACTED:ip]") + return result +} + +// redactBearer keeps the leading keyword and its separator, replacing +// only the secret payload so the surrounding context is preserved. +func redactBearer(match string) string { + sub := bearerRegex.FindStringSubmatch(match) + if len(sub) < 4 { + return "[REDACTED:bearer]" + } + var b strings.Builder + b.Grow(len(sub[1]) + len(sub[2]) + len("[REDACTED:bearer]")) + b.WriteString(sub[1]) + b.WriteString(sub[2]) + b.WriteString("[REDACTED:bearer]") + return b.String() +} diff --git a/proxy/internal/middleware/builtin/llm_guardrail/redact_test.go b/proxy/internal/middleware/builtin/llm_guardrail/redact_test.go new file mode 100644 index 000000000..ef17f1d0a --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_guardrail/redact_test.go @@ -0,0 +1,217 @@ +package llm_guardrail + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRedactPIIEmptyInput(t *testing.T) { + assert.Equal(t, "", redactPII(""), "empty input must round-trip unchanged") +} + +func TestRedactPIIPlainTextUntouched(t *testing.T) { + in := "the quick brown fox jumps over the lazy dog" + assert.Equal(t, in, redactPII(in), "non-PII text must pass through unchanged") +} + +func TestRedactPIIEmail(t *testing.T) { + cases := []string{ + "contact user@example.com today", + "first.last+tag@sub.example.co", + "USER_42@EXAMPLE.COM", + } + for _, in := range cases { + out := redactPII(in) + assert.Contains(t, out, "[REDACTED:email]", "email must be redacted in %q", in) + assert.NotContains(t, strings.ToLower(out), "@example", "raw email host must not survive in %q", in) + } +} + +func TestRedactPIISSN(t *testing.T) { + in := "ssn 123-45-6789 should be hidden" + out := redactPII(in) + assert.Contains(t, out, "[REDACTED:ssn]", "SSN must be redacted") + assert.NotContains(t, out, "123-45-6789", "raw SSN must not survive") +} + +func TestRedactPIIPhoneE164(t *testing.T) { + in := "call me at +14155551234 anytime" + out := redactPII(in) + assert.Contains(t, out, "[REDACTED:phone]", "E.164 phone must be redacted") + assert.NotContains(t, out, "+14155551234", "raw E.164 phone must not survive") +} + +func TestRedactPIIPhoneNorthAmerican(t *testing.T) { + cases := []string{ + "call (415) 555-1234 now", + "call 415-555-1234 now", + "call 415.555.1234 now", + "call 415 555 1234 now", + } + for _, in := range cases { + out := redactPII(in) + assert.Contains(t, out, "[REDACTED:phone]", "NA phone must be redacted in %q", in) + assert.NotContains(t, out, "555-1234", "raw NA phone must not survive in %q", in) + } +} + +func TestRedactPIIBearerKeepsKeyword(t *testing.T) { + cases := []struct { + in string + keyword string + }{ + {"Authorization: Bearer abcdefghijklmnopqrstuvwxyz0123", "Bearer"}, + {"token = abcdefghijklmnopqrstuvwxyz", "token"}, + {"api_key=abcdefghijklmnopqrstuvwxyz0123", "api_key"}, + {"API-KEY: abcdefghijklmnopqrstuvwxyz0123", "API-KEY"}, + {"authorization: abcdefghijklmnopqrstuvwxyz0123", "authorization"}, + } + for _, tc := range cases { + out := redactPII(tc.in) + assert.Contains(t, out, "[REDACTED:bearer]", "bearer-style secret must be redacted in %q", tc.in) + assert.Contains(t, out, tc.keyword, "leading keyword %q must be preserved in %q", tc.keyword, tc.in) + assert.NotContains(t, out, "abcdefghijklmnopqrstuvwxyz0123", "raw bearer payload must not survive in %q", tc.in) + } +} + +func TestRedactPIIBearerShortValueUntouched(t *testing.T) { + in := "token=short" + out := redactPII(in) + assert.Equal(t, in, out, "short bearer-style values must not be redacted") +} + +func TestRedactPIICombined(t *testing.T) { + in := "email user@example.com phone +14155551234 ssn 123-45-6789 token abcdefghijklmnopqrstuvwxyz0123" + out := redactPII(in) + assert.Contains(t, out, "[REDACTED:email]", "email must be redacted in combined input") + assert.Contains(t, out, "[REDACTED:phone]", "phone must be redacted in combined input") + assert.Contains(t, out, "[REDACTED:ssn]", "SSN must be redacted in combined input") + assert.Contains(t, out, "[REDACTED:bearer]", "bearer must be redacted in combined input") + assert.NotContains(t, out, "user@example.com", "raw email must not survive combined input") + assert.NotContains(t, out, "+14155551234", "raw phone must not survive combined input") + assert.NotContains(t, out, "123-45-6789", "raw SSN must not survive combined input") +} + +func TestRedactPIICreditCard(t *testing.T) { + // 4242424242424242 is a well-known Stripe test number (Visa, Luhn-valid). + cases := []string{ + "please charge 4242424242424242 now", + "card: 4242-4242-4242-4242", + "4242 4242 4242 4242 expires 12/30", + } + for _, in := range cases { + out := redactPII(in) + assert.Contains(t, out, "[REDACTED:cc]", "Luhn-valid credit card must be redacted in %q", in) + assert.NotContains(t, out, "4242424242424242", "raw card digits must not survive in %q", in) + assert.NotContains(t, out, "4242-4242-4242-4242", "raw dashed card must not survive in %q", in) + } +} + +func TestRedactPIIIPv4(t *testing.T) { + cases := []string{ + "connect to 10.0.42.7 over the tunnel", + "server 192.168.1.100 down", + "public address 203.0.113.42 was hit", + } + for _, in := range cases { + out := redactPII(in) + assert.Contains(t, out, "[REDACTED:ip]", "IPv4 must be redacted in %q", in) + } +} + +func TestRedactPIIJWT(t *testing.T) { + // No "token "/"bearer " prefix here, so the bearer-with-keyword pass leaves + // it alone and the JWT pattern from middleware.Scan must catch it. + in := "session eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyXzQyIn0.signaturepart expires soon" + out := redactPII(in) + assert.Contains(t, out, "[REDACTED:jwt]", "JWT must be redacted when no bearer keyword precedes it") + assert.NotContains(t, out, "eyJhbGciOiJIUzI1NiJ9", "raw JWT header must not survive") +} + +func TestRedactPIIAWSAccessKey(t *testing.T) { + in := "the key AKIAIOSFODNN7EXAMPLE belongs to test user" + out := redactPII(in) + assert.Contains(t, out, "[REDACTED:aws_key]", "AWS access key must be redacted") + assert.NotContains(t, out, "AKIAIOSFODNN7EXAMPLE", "raw AWS key must not survive") +} + +func TestRedactPIIPlainNumbersUntouched(t *testing.T) { + // 1234567890123 is 13 digits but fails Luhn; must NOT trip the CC redactor. + // We use a 13-digit value (the CC-candidate range starts at 13) so the only + // risk is the CC pattern firing. Phone redaction is 10-digit by design and + // would catch 1234567890123 as a phone — that's expected and not what this + // test guards against. + in := "order number 1234567890123 is queued" + out := redactPII(in) + assert.NotContains(t, out, "[REDACTED:cc]", "non-Luhn digit sequences must not be redacted as credit cards") +} + +// piiFixture mirrors the user-supplied test fixture: each record carries one +// email, one SSN, and one phone in a representative format. The test asserts +// that EVERY raw token disappears after redaction and the right [REDACTED:*] +// markers show up. Names are kept in the input and must survive — names are +// not a pattern the redactor tries to catch. +type piiFixture struct { + name string // person name (must survive redaction) + email string + ssn string + phone string +} + +var fixtureRecords = []piiFixture{ + {"Alice Johnson", "alice.johnson@example.com", "123-45-6789", "(202) 555-0147"}, + {"Brian Smith", "brian.smith@example.org", "987-65-4321", "202-555-0163"}, + {"Carla Nguyen", "c.nguyen@test.local", "111-22-3333", "+1-202-555-0188"}, + {"David Martinez", "david.martinez@example.com", "222-33-4444", "202.555.0199"}, + {"Evelyn Parker", "evelyn.parker@example.org", "333-44-5555", "1-202-555-0112"}, + {"Frank O'Connor", "frank.oconnor@test.local", "444-55-6666", "2025550134"}, + {"Grace Lee", "grace.lee@example.com", "555-66-7777", "(202)555-0156"}, + {"Hassan Ali", "hassan.ali@example.org", "666-77-8888", "+1 (202) 555-0175"}, + {"Isabella Rossi", "i.rossi@test.local", "777-88-9999", "202 555 0121"}, + {"Jamal Thompson", "jamal.thompson@example.com", "888-99-0001", "202/555/0108"}, +} + +// TestRedactPII_FixtureRecord drives every record through redactPII and +// asserts the email, SSN, and phone are all redacted, the name survives, and +// the appropriate REDACTED markers are present. This is the spec the redactor +// must meet for the kind of prompts operators throw at it. +func TestRedactPII_FixtureRecord(t *testing.T) { + for _, rec := range fixtureRecords { + t.Run(rec.name, func(t *testing.T) { + in := "Name: " + rec.name + "\n Email: " + rec.email + "\n SSN: " + rec.ssn + "\n Phone: " + rec.phone + out := redactPII(in) + + assert.Contains(t, out, rec.name, "name must survive (not a PII pattern the redactor catches)") + assert.Contains(t, out, "[REDACTED:email]", "email marker must appear for %q", rec.email) + assert.Contains(t, out, "[REDACTED:ssn]", "ssn marker must appear for %q", rec.ssn) + assert.Contains(t, out, "[REDACTED:phone]", "phone marker must appear for %q", rec.phone) + + assert.NotContains(t, out, rec.email, "raw email must not survive: %q", rec.email) + assert.NotContains(t, out, rec.ssn, "raw SSN must not survive: %q", rec.ssn) + // Phone: assert the local digits (last 7) are gone. Country-code + // remnants like "+1 " or "1-" may remain in front of the redaction + // because the E.164 pattern needs digits-only after '+' — that's + // acceptable, the personally-identifying portion is removed. + localDigits := lastSevenDigits(rec.phone) + assert.NotContains(t, out, localDigits, "raw phone local digits %q must not survive in redacted output of %q", localDigits, rec.phone) + }) + } +} + +// lastSevenDigits returns the last 7 digits of a phone number, ignoring +// formatting. It's the unique "subscriber" portion that absolutely must be +// scrubbed regardless of which prefix the redactor leaves behind. +func lastSevenDigits(phone string) string { + digits := make([]byte, 0, len(phone)) + for i := 0; i < len(phone); i++ { + if phone[i] >= '0' && phone[i] <= '9' { + digits = append(digits, phone[i]) + } + } + if len(digits) <= 7 { + return string(digits) + } + return string(digits[len(digits)-7:]) +} diff --git a/proxy/internal/middleware/builtin/llm_identity_inject/factory.go b/proxy/internal/middleware/builtin/llm_identity_inject/factory.go new file mode 100644 index 000000000..8a5314337 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_identity_inject/factory.go @@ -0,0 +1,113 @@ +package llm_identity_inject + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" +) + +// ProviderInjection describes one resolved provider's injection rule. +// Identity stamping uses one of HeaderPair / JSONMetadata; ExtraHeaders +// is independent — each entry is a static (operator-configured) header +// stamped on every matching request with anti-spoof. A rule with no +// shape AND no extras is dropped at New() time as a no-op. +type ProviderInjection struct { + // ProviderID is the resolved provider id — matches the value + // llm_router stamps under KeyLLMResolvedProviderID. + ProviderID string `json:"provider_id"` + // HeaderPair is the LiteLLM-style wire convention: separate + // headers for end-user id and tags CSV. + HeaderPair *HeaderPairRule `json:"header_pair,omitempty"` + // JSONMetadata is the Portkey-style wire convention: a single + // header carrying a JSON object keyed by reserved field names. + JSONMetadata *JSONMetadataRule `json:"json_metadata,omitempty"` + // ExtraHeaders is an operator-configured list of static headers + // (e.g. "x-portkey-config: pc-...") that the middleware stamps + // on every matching request. The synth pre-resolves the values + // from the provider record's ExtraValues map; the middleware + // just emits them. Each name is also added to HeadersRemove for + // anti-spoof so a client can't smuggle their own value. + ExtraHeaders []ExtraHeaderKV `json:"extra_headers,omitempty"` +} + +// ExtraHeaderKV is one static header entry the middleware stamps as-is. +type ExtraHeaderKV struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// HeaderPairRule emits identity through dedicated per-dimension +// headers. The two *InBody flags layer body-level identity on top: when +// TagsInBody is set the middleware also writes the tag list into the +// request body's metadata.tags array (required for LiteLLM tag-budget +// enforcement, which only inspects the body); when EndUserIDInBody is +// set the display identity is also written into the body's top-level +// "user" field (the OpenAI-standard end-user identifier — defense-in- +// depth and anti-spoof on top of the header path). +type HeaderPairRule struct { + EndUserIDHeader string `json:"end_user_id_header,omitempty"` + TagsHeader string `json:"tags_header,omitempty"` + TagsInBody bool `json:"tags_in_body,omitempty"` + EndUserIDInBody bool `json:"end_user_id_in_body,omitempty"` +} + +// JSONMetadataRule emits identity through a single JSON-object header. +// Empty UserKey/GroupsKey skip that dimension at emit time. When +// MaxValueLength > 0 each emitted JSON value is truncated to that many +// bytes — Portkey enforces 128 chars per value. +type JSONMetadataRule struct { + Header string `json:"header"` + UserKey string `json:"user_key,omitempty"` + GroupsKey string `json:"groups_key,omitempty"` + MaxValueLength int `json:"max_value_length,omitempty"` + // Sanitize replaces characters outside the destination provider's accepted + // set with '_' before emitting each value. AWS Bedrock's + // X-Amzn-Bedrock-Request-Metadata restricts values to [A-Za-z0-9 +-=._:/@]; + // group display names with other characters would otherwise 400. + Sanitize bool `json:"sanitize,omitempty"` +} + +// Config is the on-wire configuration accepted by the factory. An +// empty Providers slice yields a no-op middleware (every resolved +// provider passes through unchanged). +type Config struct { + Providers []ProviderInjection `json:"providers"` +} + +// Factory builds llm_identity_inject instances from raw config bytes. +type Factory struct{} + +// ID returns the registry identifier. +func (Factory) ID() string { return ID } + +// New constructs a middleware instance. Empty, null, and {} configs +// yield a no-op middleware. Non-empty payloads must parse cleanly so +// misconfigurations surface at chain build time. +func (Factory) New(rawConfig []byte) (middleware.Middleware, error) { + cfg := Config{} + if !isEmptyJSON(rawConfig) { + if err := json.Unmarshal(rawConfig, &cfg); err != nil { + return nil, fmt.Errorf("decode config: %w", err) + } + } + return New(cfg), nil +} + +// isEmptyJSON reports whether the payload is whitespace, null, or an +// empty object/array. +func isEmptyJSON(raw []byte) bool { + trimmed := strings.TrimSpace(string(bytes.TrimSpace(raw))) + switch trimmed { + case "", "null", "{}", "[]": + return true + } + return false +} + +func init() { + builtin.Register(Factory{}) +} diff --git a/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go b/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go new file mode 100644 index 000000000..722588a15 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_identity_inject/middleware.go @@ -0,0 +1,475 @@ +// Package llm_identity_inject implements the SlotOnRequest middleware +// that stamps the caller's NetBird identity onto upstream LLM-gateway +// requests. It runs after llm_router (which resolves the provider) and +// looks up the resolved provider id against a per-account injection +// table built by the synthesiser from the catalog's IdentityInjection +// metadata. +// +// Two wire shapes are supported, dispatched per-rule: +// +// - HeaderPair (LiteLLM-style): separate end-user-id and tags +// headers; tags emitted as a CSV value. +// - JSONMetadata (Portkey-style): one header carrying a JSON +// object with reserved keys for user / groups; per-value byte +// length capped when the rule sets MaxValueLength. +// +// In both cases, identity comes from Input.UserEmail (peer-attached +// user's email or peer.Name fallback) and groups come from the +// authorising-groups intersection llm_router emitted (with +// id→display-name translation via Input.UserGroups / UserGroupNames +// positional pairing). HeadersRemove runs before HeadersAdd in the +// framework, so a client can never spoof identity by stamping these +// headers themselves. +package llm_identity_inject + +import ( + "context" + "encoding/json" + "sort" + "strings" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +// ID is the registry identifier for this middleware. +const ID = "llm_identity_inject" + +// Version is reported via Middleware.Version(). +const Version = "1.0.0" + +// Middleware stamps NetBird identity onto upstream requests for the +// configured set of resolved providers. +type Middleware struct { + cfg Config + byID map[string]ProviderInjection +} + +// New constructs a Middleware from the supplied configuration. A nil +// or empty Providers slice yields a no-op middleware. +func New(cfg Config) *Middleware { + byID := make(map[string]ProviderInjection, len(cfg.Providers)) + for _, p := range cfg.Providers { + if p.ProviderID == "" || !injectionEmitsAnything(p) { + continue + } + byID[p.ProviderID] = p + } + return &Middleware{cfg: cfg, byID: byID} +} + +// injectionEmitsAnything reports whether a provider injection rule would +// stamp anything at runtime. Rules that set both identity shapes are a +// configuration error (we refuse to guess which wins), and rules that +// resolve to no headers are dropped to keep the runtime check tight. +// Non-empty extras alone keep a rule alive even when neither identity +// shape is set. +func injectionEmitsAnything(p ProviderInjection) bool { + hasExtras := false + for _, e := range p.ExtraHeaders { + if e.Name != "" && e.Value != "" { + hasExtras = true + break + } + } + switch { + case p.HeaderPair != nil && p.JSONMetadata != nil: + return false + case p.HeaderPair != nil: + return p.HeaderPair.EndUserIDHeader != "" || p.HeaderPair.TagsHeader != "" || + p.HeaderPair.TagsInBody || p.HeaderPair.EndUserIDInBody || hasExtras + case p.JSONMetadata != nil: + if p.JSONMetadata.Header == "" { + return false + } + return p.JSONMetadata.UserKey != "" || p.JSONMetadata.GroupsKey != "" || hasExtras + default: + return hasExtras + } +} + +// ID returns the registry identifier. +func (m *Middleware) ID() string { return ID } + +// Version returns the implementation version. +func (m *Middleware) Version() string { return Version } + +// Slot reports the chain slot the middleware lives in. +func (m *Middleware) Slot() middleware.Slot { return middleware.SlotOnRequest } + +// AcceptedContentTypes returns nil — this middleware reads only +// metadata and identity fields on the Input envelope. +func (m *Middleware) AcceptedContentTypes() []string { return nil } + +// MetadataKeys is empty: the middleware emits no metadata. Identity +// stamping is a header-only operation. +func (m *Middleware) MetadataKeys() []string { return nil } + +// MutationsSupported reports that the middleware emits header +// mutations on the Output envelope. +func (m *Middleware) MutationsSupported() bool { return true } + +// Close releases resources owned by the middleware. Stateless, so +// this is a no-op. +func (m *Middleware) Close() error { return nil } + +// Invoke stamps identity headers when the resolved provider has an +// injection rule. Always Allow. +func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) { + out := &middleware.Output{Decision: middleware.DecisionAllow} + if len(m.byID) == 0 || in == nil { + return out, nil + } + resolved, ok := lookupMetadata(in.Metadata, middleware.KeyLLMResolvedProviderID) + if !ok || resolved == "" { + return out, nil + } + rule, ok := m.byID[resolved] + if !ok { + return out, nil + } + + var mutations *middleware.Mutations + switch { + case rule.HeaderPair != nil: + mutations = applyHeaderPair(rule.HeaderPair, in) + case rule.JSONMetadata != nil: + mutations = applyJSONMetadata(rule.JSONMetadata, in) + } + + // ExtraHeaders are independent of the identity shape. Stamp each + // non-empty entry with anti-spoof: Remove first (frame strips it + // before our Add lands) so a client can't smuggle a value, then + // Add our trusted one. + if len(rule.ExtraHeaders) > 0 { + if mutations == nil { + mutations = &middleware.Mutations{} + } + for _, h := range rule.ExtraHeaders { + if h.Name == "" || h.Value == "" { + continue + } + mutations.HeadersRemove = append(mutations.HeadersRemove, h.Name) + mutations.HeadersAdd = append(mutations.HeadersAdd, middleware.KV{ + Key: h.Name, + Value: h.Value, + }) + } + } + + if mutations == nil || (len(mutations.HeadersAdd) == 0 && len(mutations.HeadersRemove) == 0 && len(mutations.BodyReplace) == 0) { + return out, nil + } + out.Mutations = mutations + return out, nil +} + +// applyHeaderPair builds the LiteLLM-style mutations: separate per- +// dimension headers, with anti-spoof Removes paired with trusted Adds. +func applyHeaderPair(rule *HeaderPairRule, in *middleware.Input) *middleware.Mutations { + mutations := &middleware.Mutations{} + + if rule.EndUserIDHeader != "" { + mutations.HeadersRemove = append(mutations.HeadersRemove, rule.EndUserIDHeader) + // Prefer the email when the auth path carried it: gateways + // like LiteLLM key per-user budgets and dashboards on a + // human-readable identifier; the user_id is an opaque + // management-server primary key. Fall back to user_id when + // no email is available (non-OIDC schemes, legacy JWTs). + if identity := identityFor(in); identity != "" { + mutations.HeadersAdd = append(mutations.HeadersAdd, middleware.KV{ + Key: rule.EndUserIDHeader, + Value: identity, + }) + } + } + + if rule.TagsHeader != "" { + mutations.HeadersRemove = append(mutations.HeadersRemove, rule.TagsHeader) + if csv := authorisingTagsCSV(in); csv != "" { + mutations.HeadersAdd = append(mutations.HeadersAdd, middleware.KV{ + Key: rule.TagsHeader, + Value: csv, + }) + } + } + + if rule.TagsInBody || rule.EndUserIDInBody { + // Body-level identity unlocks gateway behaviour the header + // path can't reach (LiteLLM's _tag_max_budget_check only + // inspects the body; OpenAI direct only reads the body's + // "user" field for attribution). The header path stays + // intact, so we still get attribution + per-end-user budget + // gating when body inject can't run (truncated body, + // non-JSON, hostile metadata shape). + var bodyTags []string + if rule.TagsInBody { + bodyTags = authorisingTagsSlice(in) + } + var bodyUser string + if rule.EndUserIDInBody { + bodyUser = identityFor(in) + } + if newBody, ok := injectIntoBody(in, bodyTags, bodyUser); ok { + mutations.BodyReplace = newBody + } + } + + return mutations +} + +// injectIntoBody parses the request body and writes the supplied +// identity dimensions into it. Tags land at metadata.tags (creating +// the metadata object when absent); the user identity lands at the +// top-level "user" field (OpenAI-standard end-user identifier). +// Returns the re-marshaled body and ok=true when at least one field +// was written. Returns ok=false (no mutation) when: +// +// - both inputs are empty (nothing to write); +// - the body is empty or truncated (we don't have the full document +// to safely round-trip); +// - the body isn't a JSON object (skip silently — this middleware +// only knows how to inject into OpenAI-compatible JSON payloads). +// +// A non-object existing `metadata` field skips the tag write but +// still allows the user write to land — we don't clobber the client's +// non-object metadata, but the orthogonal user field is fair game. +// The header path emission still runs in skip cases, so spend tracking +// + header-resolved end-user budgets continue to work without body- +// level enforcement. +func injectIntoBody(in *middleware.Input, tags []string, userID string) ([]byte, bool) { + wantTags := len(tags) > 0 + wantUser := userID != "" + if !wantTags && !wantUser { + return nil, false + } + if in == nil || len(in.Body) == 0 || in.BodyTruncated { + return nil, false + } + var doc map[string]any + if err := json.Unmarshal(in.Body, &doc); err != nil { + return nil, false + } + injected := false + if wantTags { + var meta map[string]any + if existing, ok := doc["metadata"]; ok { + if typed, isObject := existing.(map[string]any); isObject { + meta = typed + } + // non-object metadata: leave it; tags go unwritten so we + // don't clobber the client's value. Header fallback covers + // spend tracking. + } else { + meta = map[string]any{} + } + if meta != nil { + meta["tags"] = tags + doc["metadata"] = meta + injected = true + } + } + if wantUser { + // Anti-spoof: overwrite any client-supplied "user" so the + // gateway only sees our trusted identity. + doc["user"] = userID + injected = true + } + if !injected { + return nil, false + } + out, err := json.Marshal(doc) + if err != nil { + return nil, false + } + return out, true +} + +// applyJSONMetadata builds the Portkey-style mutations: a single header +// carrying a JSON object keyed by the rule's reserved field names. Per- +// value byte length is capped at MaxValueLength when set (Portkey +// enforces 128 chars). +func applyJSONMetadata(rule *JSONMetadataRule, in *middleware.Input) *middleware.Mutations { + mutations := &middleware.Mutations{} + mutations.HeadersRemove = append(mutations.HeadersRemove, rule.Header) + + emit := func(v string) string { + if rule.Sanitize { + v = sanitizeMetadataValue(v) + } + return truncate(v, rule.MaxValueLength) + } + payload := map[string]string{} + if rule.UserKey != "" { + if identity := identityFor(in); identity != "" { + payload[rule.UserKey] = emit(identity) + } + } + if rule.GroupsKey != "" { + if csv := authorisingTagsCSV(in); csv != "" { + payload[rule.GroupsKey] = emit(csv) + } + } + if len(payload) == 0 { + return mutations + } + raw, err := json.Marshal(payload) + if err != nil { + return mutations + } + mutations.HeadersAdd = append(mutations.HeadersAdd, middleware.KV{ + Key: rule.Header, + Value: string(raw), + }) + return mutations +} + +// identityFor returns the caller's display identity. UserEmail wins +// (carries the user email when peer-attached, peer.Name otherwise); +// UserID falls in only as a defensive last resort. +func identityFor(in *middleware.Input) string { + if in.UserEmail != "" { + return in.UserEmail + } + return in.UserID +} + +// authorisingTagsSlice returns the sorted, deduplicated slice of group +// display names the request was authorised under. Prefers the per- +// request authorising groups emitted by llm_router (intersection of the +// caller's UserGroups with the resolved route's AllowedGroupIDs) so the +// tags carry only the groups that actually authorise THIS request, not +// every group the peer happens to be in. Falls back to the full +// UserGroups when the router metadata key is absent. +func authorisingTagsSlice(in *middleware.Input) []string { + ids := tagsIDsFromAuthorising(in.Metadata) + if len(ids) == 0 { + ids = in.UserGroups + } + return tagsNamedSlice(ids, in.UserGroups, in.UserGroupNames) +} + +// authorisingTagsCSV is a convenience wrapper that joins +// authorisingTagsSlice with commas for HeaderPair-style emission. +func authorisingTagsCSV(in *middleware.Input) string { + return strings.Join(authorisingTagsSlice(in), ",") +} + +// truncate caps s to maxBytes bytes when maxBytes > 0. No-op when +// maxBytes <= 0 or s already fits. Truncation is byte-wise — sufficient +// for Portkey's 128-char ASCII limit. UTF-8 sequences could in theory +// be split, but the gateway treats the value as opaque bytes. +func truncate(s string, maxBytes int) string { + if maxBytes <= 0 || len(s) <= maxBytes { + return s + } + return s[:maxBytes] +} + +// sanitizeMetadataValue replaces any character outside AWS Bedrock's accepted +// request-metadata class — letters, digits, space, and + - = . _ : / @ — with +// '_'. This keeps values (notably the groups CSV, whose commas are rejected, and +// group display names with arbitrary characters) from making Bedrock reject the +// request with 400. The result stays opaque to the gateway. +func sanitizeMetadataValue(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if metadataCharAllowed(r) { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + return b.String() +} + +func metadataCharAllowed(r rune) bool { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + return true + } + switch r { + case ' ', '+', '-', '=', '.', '_', ':', '/', '@': + return true + } + return false +} + +// tagsIDsFromAuthorising reads llm_router's authorising-groups metadata +// (a CSV of group ids) and returns the parsed slice. Returns nil when +// the key is absent or empty so the caller can fall back to the full +// UserGroups. +func tagsIDsFromAuthorising(meta []middleware.KV) []string { + v, ok := lookupMetadata(meta, middleware.KeyLLMAuthorisingGroups) + if !ok { + return nil + } + v = strings.TrimSpace(v) + if v == "" { + return nil + } + parts := strings.Split(v, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + if len(out) == 0 { + return nil + } + return out +} + +// tagsNamedSlice returns the sorted, deduplicated list of group display +// names. ids carries the canonical group identifiers to emit; +// userGroups + userGroupNames provide the positional id→name +// translation table from the Input envelope. When a name is missing +// for a given id (slice shorter than userGroups, or id absent from the +// table), the id is used verbatim so the tag still attributes +// correctly. Sorted so the same caller produces the same header value +// across requests (helps gateway-side cache hits and log correlation). +func tagsNamedSlice(ids, userGroups, userGroupNames []string) []string { + if len(ids) == 0 { + return nil + } + idToName := make(map[string]string, len(userGroups)) + for i, id := range userGroups { + if i < len(userGroupNames) { + idToName[id] = userGroupNames[i] + } + } + seen := make(map[string]struct{}, len(ids)) + out := make([]string, 0, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + tag := idToName[id] + if tag == "" { + tag = id + } + if _, dup := seen[tag]; dup { + continue + } + seen[tag] = struct{}{} + out = append(out, tag) + } + if len(out) == 0 { + return nil + } + sort.Strings(out) + return out +} + +// lookupMetadata returns the value for key plus a presence flag. +func lookupMetadata(meta []middleware.KV, key string) (string, bool) { + for _, kv := range meta { + if kv.Key == key { + return kv.Value, true + } + } + return "", false +} diff --git a/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go b/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go new file mode 100644 index 000000000..8ec0930b5 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_identity_inject/middleware_test.go @@ -0,0 +1,706 @@ +package llm_identity_inject + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +const ( + litellmProvider = "ainp_litellm-test" + portkeyProvider = "ainp_portkey-test" +) + +func newInput(resolvedProvider, userID string, groups []string) *middleware.Input { + return &middleware.Input{ + Slot: middleware.SlotOnRequest, + AccountID: "acct-test", + UserID: userID, + UserGroups: groups, + SourceIP: "100.64.0.5", + RequestID: "req-1", + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMResolvedProviderID, Value: resolvedProvider}, + }, + } +} + +func liteLLMRule() ProviderInjection { + return ProviderInjection{ + ProviderID: litellmProvider, + HeaderPair: &HeaderPairRule{ + EndUserIDHeader: "x-litellm-end-user-id", + TagsHeader: "x-litellm-tags", + }, + } +} + +func TestMiddlewareIdentity(t *testing.T) { + mw := New(Config{}) + assert.Equal(t, ID, mw.ID()) + assert.Equal(t, Version, mw.Version()) + assert.Equal(t, middleware.SlotOnRequest, mw.Slot()) + assert.True(t, mw.MutationsSupported()) + assert.Empty(t, mw.MetadataKeys(), "middleware emits no metadata") + assert.Nil(t, mw.AcceptedContentTypes()) + require.NoError(t, mw.Close()) +} + +func TestInject_MatchedProvider_StampsHeaders(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRule()}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng", "grp-it"}) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision) + require.NotNil(t, out.Mutations) + + // Strips the same headers we're about to add (anti-spoof). + assert.ElementsMatch(t, + []string{"x-litellm-end-user-id", "x-litellm-tags"}, + out.Mutations.HeadersRemove, + "every injected header must also appear in HeadersRemove so client-supplied values are wiped before our trusted values land") + + added := map[string]string{} + for _, kv := range out.Mutations.HeadersAdd { + added[kv.Key] = kv.Value + } + assert.Equal(t, "alice", added["x-litellm-end-user-id"]) + assert.Equal(t, "grp-eng,grp-it", added["x-litellm-tags"], "tags CSV must be sorted") +} + +func TestInject_UnmatchedProvider_NoMutations(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRule()}}) + in := newInput("ainp_some-other-provider", "alice", []string{"grp-eng"}) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision) + assert.Nil(t, out.Mutations, "non-LiteLLM resolved provider must produce no mutations") +} + +func TestInject_NoResolvedProvider_NoMutations(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRule()}}) + in := &middleware.Input{Slot: middleware.SlotOnRequest, UserID: "alice"} + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + assert.Nil(t, out.Mutations, + "missing llm.resolved_provider_id metadata means the router didn't run; never stamp identity blindly") +} + +func TestInject_PartialRule_StampsOnlyConfiguredHeaders(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{{ + ProviderID: litellmProvider, + HeaderPair: &HeaderPairRule{ + EndUserIDHeader: "x-litellm-end-user-id", + // TagsHeader intentionally empty. + }, + }}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng"}) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.Mutations) + + assert.Equal(t, []string{"x-litellm-end-user-id"}, out.Mutations.HeadersRemove, + "only configured header should be stripped") + require.Len(t, out.Mutations.HeadersAdd, 1) + assert.Equal(t, "x-litellm-end-user-id", out.Mutations.HeadersAdd[0].Key) + assert.Equal(t, "alice", out.Mutations.HeadersAdd[0].Value) +} + +func TestInject_EmptyIdentity_StripsButDoesNotAdd(t *testing.T) { + // Caller has no UserID and no groups. We still strip the headers + // (so the client can't inject identity) but we don't add empty + // values that would mislead the gateway. + mw := New(Config{Providers: []ProviderInjection{liteLLMRule()}}) + in := newInput(litellmProvider, "", nil) + in.AccountID = "" + in.SourceIP = "" + in.RequestID = "" + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.Mutations) + + assert.ElementsMatch(t, + []string{"x-litellm-end-user-id", "x-litellm-tags"}, + out.Mutations.HeadersRemove, + "identity headers must be stripped even when we don't have values to add — anti-spoof") + assert.Empty(t, out.Mutations.HeadersAdd, + "no NetBird identity available; do not stamp empty / misleading values") +} + +func TestInject_TagsCSV_DedupesAndSorts(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRule()}}) + in := newInput(litellmProvider, "alice", []string{"grp-zzz", "grp-aaa", "grp-zzz", "", " "}) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.Mutations) + + for _, kv := range out.Mutations.HeadersAdd { + if kv.Key == "x-litellm-tags" { + assert.Equal(t, "grp-aaa,grp-zzz", kv.Value, + "tags CSV must dedupe, drop empty, and sort") + return + } + } + t.Fatalf("expected x-litellm-tags in HeadersAdd; got %v", out.Mutations.HeadersAdd) +} + +func TestFactory_RejectsBadJSON(t *testing.T) { + _, err := Factory{}.New([]byte("{not json")) + require.Error(t, err) +} + +func TestFactory_AcceptsEmptyShapes(t *testing.T) { + for _, raw := range [][]byte{nil, []byte(""), []byte(" "), []byte("null"), []byte("{}"), []byte("[]")} { + mw, err := Factory{}.New(raw) + require.NoError(t, err) + require.NotNil(t, mw) + + out, ierr := mw.Invoke(context.Background(), + newInput(litellmProvider, "alice", []string{"grp-eng"})) + require.NoError(t, ierr) + assert.Equal(t, middleware.DecisionAllow, out.Decision) + assert.Nil(t, out.Mutations, + "empty config means no providers to inject for; every resolved provider passes through") + } +} + +func TestFactory_DropsInjectionRuleWithEmptyHeaders(t *testing.T) { + mw, err := Factory{}.New([]byte(`{"providers":[{"provider_id":"x"}]}`)) + require.NoError(t, err) + out, ierr := mw.Invoke(context.Background(), newInput("x", "alice", []string{"grp-eng"})) + require.NoError(t, ierr) + assert.Nil(t, out.Mutations, + "a rule with no header names is functionally a no-op and must be dropped at New() time") +} + +// TestInject_TagsFromAuthorisingMetadata pins that when llm_router has +// emitted llm.authorising_groups, the inject middleware uses THAT +// (the per-request authorising intersection) for the tags header — not +// the full UserGroups, which can include groups unrelated to this +// request's routing. +func TestInject_TagsFromAuthorisingMetadata(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRule()}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng", "grp-it", "grp-oncall"}) + in.Metadata = append(in.Metadata, middleware.KV{ + Key: middleware.KeyLLMAuthorisingGroups, + Value: "grp-eng", + }) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.Mutations) + + for _, kv := range out.Mutations.HeadersAdd { + if kv.Key == "x-litellm-tags" { + assert.Equal(t, "grp-eng", kv.Value, + "tags must come from llm.authorising_groups, not the full UserGroups; unrelated peer groups must not leak") + return + } + } + t.Fatalf("expected x-litellm-tags in HeadersAdd; got %v", out.Mutations.HeadersAdd) +} + +// TestInject_TagsFallsBackToUserGroups pins the defensive fallback: if +// llm_router didn't emit authorising-groups metadata (chain +// misconfiguration) the middleware uses UserGroups so identity is +// still stamped, just over-broad. +func TestInject_TagsFallsBackToUserGroups(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRule()}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng", "grp-it"}) + // No llm.authorising_groups metadata. + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.Mutations) + + for _, kv := range out.Mutations.HeadersAdd { + if kv.Key == "x-litellm-tags" { + assert.Equal(t, "grp-eng,grp-it", kv.Value, + "absent metadata must fall back to the full UserGroups CSV") + return + } + } + t.Fatalf("expected x-litellm-tags in HeadersAdd; got %v", out.Mutations.HeadersAdd) +} + +// portkeyRule is the JSONMetadata-shape analogue of liteLLMRule: a +// single x-portkey-metadata header carrying _user and groups, with +// Portkey's 128-byte per-value cap. +func portkeyRule() ProviderInjection { + return ProviderInjection{ + ProviderID: portkeyProvider, + JSONMetadata: &JSONMetadataRule{ + Header: "x-portkey-metadata", + UserKey: "_user", + GroupsKey: "groups", + MaxValueLength: 128, + }, + } +} + +// TestInject_JSONMetadata_StampsHeader pins the Portkey-style emission: +// one header carrying a JSON envelope with reserved keys for user +// identity and groups CSV. +func TestInject_JSONMetadata_StampsHeader(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{portkeyRule()}}) + in := newInput(portkeyProvider, "alice", []string{"grp-eng", "grp-it"}) + in.UserEmail = "alice@example.com" + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.Mutations) + + assert.Equal(t, []string{"x-portkey-metadata"}, out.Mutations.HeadersRemove, + "the JSON header must be stripped before we add our trusted value") + require.Len(t, out.Mutations.HeadersAdd, 1) + added := out.Mutations.HeadersAdd[0] + assert.Equal(t, "x-portkey-metadata", added.Key) + + var payload map[string]string + require.NoError(t, json.Unmarshal([]byte(added.Value), &payload)) + assert.Equal(t, "alice@example.com", payload["_user"], + "_user reserved key carries the display identity (UserEmail)") + assert.Equal(t, "grp-eng,grp-it", payload["groups"], + "groups key carries the sorted CSV of group display names") +} + +// TestInject_JSONMetadata_TruncatesValues pins the per-value byte cap. +// Portkey rejects metadata values longer than 128 chars; oversized +// values are truncated rather than failing the request. +func TestInject_JSONMetadata_TruncatesValues(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{portkeyRule()}}) + in := newInput(portkeyProvider, "alice", []string{"grp-eng"}) + in.UserEmail = strings.Repeat("a", 200) + "@example.com" + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations) + require.Len(t, out.Mutations.HeadersAdd, 1) + + var payload map[string]string + require.NoError(t, json.Unmarshal([]byte(out.Mutations.HeadersAdd[0].Value), &payload)) + assert.Len(t, payload["_user"], 128, + "per-value byte length must be capped at MaxValueLength") +} + +// TestInject_JSONMetadata_Sanitize pins the AWS-Bedrock sanitization path: when +// Sanitize is set, characters outside Bedrock's accepted metadata class +// (notably the groups CSV comma and arbitrary characters in group display +// names) are replaced with '_' so Bedrock doesn't reject the request. Allowed +// characters (letters, digits, spaces, and @ . _ : / + - =) pass through. +func TestInject_JSONMetadata_Sanitize(t *testing.T) { + rule := ProviderInjection{ + ProviderID: portkeyProvider, + JSONMetadata: &JSONMetadataRule{ + Header: "X-Amzn-Bedrock-Request-Metadata", + UserKey: "user", + GroupsKey: "group", + MaxValueLength: 256, + Sanitize: true, + }, + } + mw := New(Config{Providers: []ProviderInjection{rule}}) + in := newInput(portkeyProvider, "alice", []string{"g1", "g2"}) + in.UserEmail = "alice@example.com" + // Group display names carry characters Bedrock rejects (comma, '#'); the CSV + // join adds another comma between the two groups. + in.UserGroupNames = []string{"Eng,Team", "Ops#1"} + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations) + require.Len(t, out.Mutations.HeadersAdd, 1) + added := out.Mutations.HeadersAdd[0] + assert.Equal(t, "X-Amzn-Bedrock-Request-Metadata", added.Key, + "the Bedrock cost-allocation header carries the metadata JSON") + + var payload map[string]string + require.NoError(t, json.Unmarshal([]byte(added.Value), &payload)) + assert.Equal(t, "alice@example.com", payload["user"], + "'@' and '.' are in Bedrock's accepted set and must be preserved") + assert.NotContains(t, payload["group"], ",", "commas must be sanitized — Bedrock rejects them") + assert.NotContains(t, payload["group"], "#", "disallowed characters must be sanitized") + assert.Contains(t, payload["group"], "Eng", "allowed characters must be preserved") +} + +// TestInject_JSONMetadata_EmptyIdentity_StripsButDoesNotAdd verifies the +// anti-spoof Remove still fires when there's nothing to stamp. +func TestInject_JSONMetadata_EmptyIdentity_StripsButDoesNotAdd(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{portkeyRule()}}) + in := newInput(portkeyProvider, "", nil) + in.UserEmail = "" + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations) + + assert.Equal(t, []string{"x-portkey-metadata"}, out.Mutations.HeadersRemove, + "strip even with no payload — client can't smuggle identity headers") + assert.Empty(t, out.Mutations.HeadersAdd, + "no NetBird identity available; do not stamp empty / misleading values") +} + +// TestFactory_RejectsRuleWithBothShapes pins the configuration-error +// guard: a rule that sets both HeaderPair and JSONMetadata is dropped +// at New() time rather than guessing which wins. +func TestFactory_RejectsRuleWithBothShapes(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{{ + ProviderID: litellmProvider, + HeaderPair: &HeaderPairRule{ + EndUserIDHeader: "x-litellm-end-user-id", + }, + JSONMetadata: &JSONMetadataRule{ + Header: "x-portkey-metadata", + UserKey: "_user", + }, + }}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng"}) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + assert.Nil(t, out.Mutations, + "a rule that sets both shapes is ambiguous and must be dropped at New() time") +} + +// liteLLMRuleWithBody is the LiteLLM-style rule with body tag injection +// enabled (matches the catalog default). +func liteLLMRuleWithBody() ProviderInjection { + return ProviderInjection{ + ProviderID: litellmProvider, + HeaderPair: &HeaderPairRule{ + EndUserIDHeader: "x-litellm-end-user-id", + TagsHeader: "x-litellm-tags", + TagsInBody: true, + }, + } +} + +// TestInject_BodyTags_AddsMetadataTags pins the body-inject path that +// LiteLLM's _tag_max_budget_check requires. With TagsInBody set, the +// middleware writes the authorising-groups slice into +// request.metadata.tags (in addition to the header). +func TestInject_BodyTags_AddsMetadataTags(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRuleWithBody()}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng", "grp-sre"}) + in.Body = []byte(`{"model":"gpt-4o-mini","messages":[]}`) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations) + require.NotEmpty(t, out.Mutations.BodyReplace, "body must be rewritten when TagsInBody is set") + + var doc map[string]any + require.NoError(t, json.Unmarshal(out.Mutations.BodyReplace, &doc)) + meta, ok := doc["metadata"].(map[string]any) + require.True(t, ok, "metadata must be an object") + tags, ok := meta["tags"].([]any) + require.True(t, ok, "metadata.tags must be a JSON array") + got := make([]string, 0, len(tags)) + for _, t := range tags { + s, _ := t.(string) + got = append(got, s) + } + assert.Equal(t, []string{"grp-eng", "grp-sre"}, got, + "metadata.tags must carry the sorted authorising-groups slice") + assert.Equal(t, "gpt-4o-mini", doc["model"], + "the rest of the body must be preserved verbatim") +} + +// TestInject_BodyTags_PreservesExistingMetadata pins that an existing +// metadata object on the request is merged with our tags rather than +// clobbered — clients sometimes set metadata fields the proxy +// shouldn't blow away (jobID, taskName, etc.). +func TestInject_BodyTags_PreservesExistingMetadata(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRuleWithBody()}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng"}) + in.Body = []byte(`{"model":"gpt-4o-mini","metadata":{"jobID":"j-42","tags":["should-be-replaced"]}}`) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotEmpty(t, out.Mutations.BodyReplace) + + var doc map[string]any + require.NoError(t, json.Unmarshal(out.Mutations.BodyReplace, &doc)) + meta := doc["metadata"].(map[string]any) + assert.Equal(t, "j-42", meta["jobID"], + "client-supplied metadata fields outside `tags` must survive") + tags := meta["tags"].([]any) + require.Len(t, tags, 1) + assert.Equal(t, "grp-eng", tags[0], + "our tags overwrite any client-supplied metadata.tags so spoofing is impossible") +} + +// TestInject_BodyTags_SkipsHostileMetadataShape pins the defensive +// refusal: when the request body has a non-object metadata field +// (string/number/array), we don't inject — header path still emits. +func TestInject_BodyTags_SkipsHostileMetadataShape(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRuleWithBody()}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng"}) + in.Body = []byte(`{"model":"gpt-4o-mini","metadata":"not-an-object"}`) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations) + assert.Empty(t, out.Mutations.BodyReplace, + "non-object metadata must skip body inject (don't clobber)") + + for _, kv := range out.Mutations.HeadersAdd { + if kv.Key == "x-litellm-tags" { + assert.Equal(t, "grp-eng", kv.Value, + "header path must still emit so spend tracking keeps working") + return + } + } + t.Fatalf("expected x-litellm-tags header even when body inject was skipped") +} + +// TestInject_BodyTags_SkipsTruncatedBody pins that we don't blindly +// rewrite a body we don't have in full. The header path still runs. +func TestInject_BodyTags_SkipsTruncatedBody(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRuleWithBody()}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng"}) + in.Body = []byte(`{"model":"gpt-4o-mini","messages":[]}`) + in.BodyTruncated = true + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + assert.Empty(t, out.Mutations.BodyReplace, + "truncated body must skip body inject — re-marshaling would corrupt the request") +} + +// TestInject_BodyTags_SkipsNonJSONBody pins graceful behavior when the +// body isn't JSON (e.g. a streaming binary or form upload sneaking +// through the LLM chain). Header path still runs. +func TestInject_BodyTags_SkipsNonJSONBody(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRuleWithBody()}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng"}) + in.Body = []byte(`not even close to json`) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + assert.Empty(t, out.Mutations.BodyReplace, + "non-JSON body must skip body inject silently") +} + +// liteLLMRuleFull mirrors the catalog default: header path + body +// metadata.tags (groups) + body user (end-user id). +func liteLLMRuleFull() ProviderInjection { + return ProviderInjection{ + ProviderID: litellmProvider, + HeaderPair: &HeaderPairRule{ + EndUserIDHeader: "x-litellm-end-user-id", + TagsHeader: "x-litellm-tags", + TagsInBody: true, + EndUserIDInBody: true, + }, + } +} + +// TestInject_BodyUser_WritesTopLevelUser pins the EndUserIDInBody path +// alone: body's top-level "user" field carries the display identity. +// Tags-in-body is OFF here so we isolate the user write. +func TestInject_BodyUser_WritesTopLevelUser(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{{ + ProviderID: litellmProvider, + HeaderPair: &HeaderPairRule{ + EndUserIDHeader: "x-litellm-end-user-id", + EndUserIDInBody: true, + }, + }}}) + in := newInput(litellmProvider, "alice", nil) + in.UserEmail = "alice@example.com" + in.Body = []byte(`{"model":"gpt-4o-mini","messages":[]}`) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations) + require.NotEmpty(t, out.Mutations.BodyReplace) + + var doc map[string]any + require.NoError(t, json.Unmarshal(out.Mutations.BodyReplace, &doc)) + assert.Equal(t, "alice@example.com", doc["user"], + "body's top-level user field must carry the display identity") + _, hasMeta := doc["metadata"] + assert.False(t, hasMeta, "TagsInBody is off; metadata must not be added") +} + +// TestInject_BodyUser_OverwritesClientSupplied pins anti-spoof: a +// client-supplied "user" in the body is overwritten so the gateway +// only sees our trusted identity. +func TestInject_BodyUser_OverwritesClientSupplied(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRuleFull()}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng"}) + in.UserEmail = "alice@example.com" + in.Body = []byte(`{"model":"gpt-4o-mini","user":"ceo@company.com"}`) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotEmpty(t, out.Mutations.BodyReplace) + + var doc map[string]any + require.NoError(t, json.Unmarshal(out.Mutations.BodyReplace, &doc)) + assert.Equal(t, "alice@example.com", doc["user"], + "client-supplied user must be overwritten with the trusted identity") +} + +// TestInject_BodyCombined_TagsAndUser pins that with both flags on, +// the body carries both metadata.tags AND top-level user, and the +// header path still emits. +func TestInject_BodyCombined_TagsAndUser(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRuleFull()}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng", "grp-sre"}) + in.UserEmail = "alice@example.com" + in.Body = []byte(`{"model":"gpt-4o-mini"}`) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotEmpty(t, out.Mutations.BodyReplace) + + var doc map[string]any + require.NoError(t, json.Unmarshal(out.Mutations.BodyReplace, &doc)) + assert.Equal(t, "alice@example.com", doc["user"]) + meta := doc["metadata"].(map[string]any) + tags := meta["tags"].([]any) + require.Len(t, tags, 2) + assert.Equal(t, "grp-eng", tags[0]) + assert.Equal(t, "grp-sre", tags[1]) + + // Header path still emits — header end-user-id is the primary + // path for LiteLLM's resolver, body is defense-in-depth. + added := map[string]string{} + for _, kv := range out.Mutations.HeadersAdd { + added[kv.Key] = kv.Value + } + assert.Equal(t, "alice@example.com", added["x-litellm-end-user-id"]) + assert.Equal(t, "grp-eng,grp-sre", added["x-litellm-tags"]) +} + +// TestInject_BodyCombined_HostileMetadataKeepsUser pins the partial- +// success path: a hostile (non-object) metadata field skips the tag +// write but still allows the orthogonal user write to land. +func TestInject_BodyCombined_HostileMetadataKeepsUser(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{liteLLMRuleFull()}}) + in := newInput(litellmProvider, "alice", []string{"grp-eng"}) + in.UserEmail = "alice@example.com" + in.Body = []byte(`{"model":"gpt-4o-mini","metadata":"not-an-object"}`) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotEmpty(t, out.Mutations.BodyReplace, + "user write must still go through even when metadata is hostile") + + var doc map[string]any + require.NoError(t, json.Unmarshal(out.Mutations.BodyReplace, &doc)) + assert.Equal(t, "alice@example.com", doc["user"]) + assert.Equal(t, "not-an-object", doc["metadata"], + "hostile metadata must be left untouched, not clobbered") +} + +// TestInject_ExtraHeaders_Stamped pins the extras path: with a +// per-provider ExtraHeader configured (e.g. Portkey config id), the +// middleware stamps it on every matching request and adds the same +// name to HeadersRemove for anti-spoof. +func TestInject_ExtraHeaders_Stamped(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{{ + ProviderID: portkeyProvider, + JSONMetadata: &JSONMetadataRule{ + Header: "x-portkey-metadata", + UserKey: "_user", + GroupsKey: "groups", + }, + ExtraHeaders: []ExtraHeaderKV{ + {Name: "x-portkey-config", Value: "pc-prod-3f2a"}, + }, + }}}) + in := newInput(portkeyProvider, "alice", []string{"grp-eng"}) + in.UserEmail = "alice@example.com" + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations) + + assert.Contains(t, out.Mutations.HeadersRemove, "x-portkey-config", + "extras must be stripped before stamping for anti-spoof") + added := map[string]string{} + for _, kv := range out.Mutations.HeadersAdd { + added[kv.Key] = kv.Value + } + assert.Equal(t, "pc-prod-3f2a", added["x-portkey-config"], + "extras must carry the operator-configured value verbatim") + // Identity-stamping shape (JSONMetadata header) still emitted. + assert.Contains(t, added, "x-portkey-metadata", + "extras and identity stamping are independent — both must land") +} + +// TestInject_ExtraHeaders_OnlyRule pins that an extras-only rule +// (no HeaderPair, no JSONMetadata) survives New() and stamps the +// extras anyway. Useful for hypothetical gateways that need a static +// routing header but no NetBird identity stamping. +func TestInject_ExtraHeaders_OnlyRule(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{{ + ProviderID: "ainp_extras-only", + ExtraHeaders: []ExtraHeaderKV{ + {Name: "x-routing-key", Value: "rk-1"}, + }, + }}}) + in := newInput("ainp_extras-only", "alice", nil) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations, + "extras alone keep the rule alive — middleware must emit them") + added := map[string]string{} + for _, kv := range out.Mutations.HeadersAdd { + added[kv.Key] = kv.Value + } + assert.Equal(t, "rk-1", added["x-routing-key"]) +} + +// TestInject_ExtraHeaders_EmptyValueSkipped pins that empty values are +// dropped silently (the synth would normally not send them, but the +// middleware is defensive). +func TestInject_ExtraHeaders_EmptyValueSkipped(t *testing.T) { + mw := New(Config{Providers: []ProviderInjection{{ + ProviderID: portkeyProvider, + JSONMetadata: &JSONMetadataRule{ + Header: "x-portkey-metadata", + UserKey: "_user", + }, + ExtraHeaders: []ExtraHeaderKV{ + {Name: "x-portkey-config", Value: ""}, + }, + }}}) + in := newInput(portkeyProvider, "alice", []string{"grp-eng"}) + in.UserEmail = "alice@example.com" + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out.Mutations) + assert.NotContains(t, out.Mutations.HeadersRemove, "x-portkey-config", + "empty extra value must not even strip the header") + for _, kv := range out.Mutations.HeadersAdd { + assert.NotEqual(t, "x-portkey-config", kv.Key, + "empty extra value must not be stamped") + } +} diff --git a/proxy/internal/middleware/builtin/llm_limit_check/factory.go b/proxy/internal/middleware/builtin/llm_limit_check/factory.go new file mode 100644 index 000000000..1068a6867 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_limit_check/factory.go @@ -0,0 +1,38 @@ +// Package llm_limit_check is the SlotOnRequest middleware that asks +// management which agent-network policy "pays" for the current LLM +// request. On allow, it stamps the selected policy id, attribution +// group id, and effective window length onto the metadata bag so the +// post-flight llm_limit_record middleware can tick the right counters. +// On deny, it returns a 403 carrying the canonical llm_policy.* deny +// code surfaced by management. +package llm_limit_check + +import ( + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" +) + +// ID is the registry identifier for this middleware. +const ID = "llm_limit_check" + +// Factory builds a configured llm_limit_check instance. The factory +// has no per-target config — it pulls the management gRPC client from +// the package-level FactoryContext at construction time. A nil +// MgmtClient on the context is allowed; the middleware then becomes +// a no-op pass-through (allow without attribution) so a partially +// wired environment doesn't break the chain. +type Factory struct{} + +// ID returns the registry identifier matching the middleware ID. +func (Factory) ID() string { return ID } + +// New ignores the rawConfig payload (no per-target config today) and +// returns a Middleware bound to the FactoryContext's MgmtClient. +func (Factory) New(_ []byte) (middleware.Middleware, error) { + ctx := builtin.Context() + return New(ctx.MgmtClient, ctx.Logger), nil +} + +func init() { + builtin.Register(Factory{}) +} diff --git a/proxy/internal/middleware/builtin/llm_limit_check/middleware.go b/proxy/internal/middleware/builtin/llm_limit_check/middleware.go new file mode 100644 index 000000000..bebe4dca4 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_limit_check/middleware.go @@ -0,0 +1,196 @@ +package llm_limit_check + +import ( + "context" + "strconv" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// Version is reported via Middleware.Version(). +const Version = "1.0.0" + +// callTimeout caps the wall-clock budget for the pre-flight RPC. The +// middleware sits on the request leg, so a slow management call +// translates directly to user-visible latency. 2s is loose enough for +// a healthy management cluster but tight enough that a stalled call +// fails open via the same path nil-MgmtClient does — an enforcement +// gate that adds 30s of latency is worse than a stale gate. +const callTimeout = 2 * time.Second + +// Middleware is the per-target instance that runs the pre-flight check. +type Middleware struct { + mgmt builtin.MgmtClient + logger *log.Logger +} + +// New constructs a Middleware. mgmt may be nil — that's the +// no-management-wired case where the middleware is a pass-through +// (allow without attribution); useful for unit tests and for +// progressive rollout of the management RPC. +func New(mgmt builtin.MgmtClient, logger *log.Logger) *Middleware { + if logger == nil { + logger = log.StandardLogger() + } + return &Middleware{mgmt: mgmt, logger: logger} +} + +// ID returns the registry identifier. +func (m *Middleware) ID() string { return ID } + +// Version returns the implementation version. +func (m *Middleware) Version() string { return Version } + +// Slot reports the chain slot the middleware lives in. +func (m *Middleware) Slot() middleware.Slot { return middleware.SlotOnRequest } + +// AcceptedContentTypes returns nil because the gate consults metadata +// emitted upstream (KeyLLMResolvedProviderID) and never inspects bodies. +func (m *Middleware) AcceptedContentTypes() []string { return nil } + +// MetadataKeys is the closed allowlist of keys this middleware emits. +func (m *Middleware) MetadataKeys() []string { + return []string{ + middleware.KeyLLMSelectedPolicyID, + middleware.KeyLLMAttributionGroupID, + middleware.KeyLLMAttributionWindowS, + middleware.KeyLLMPolicyDecision, + middleware.KeyLLMPolicyReason, + } +} + +// MutationsSupported reports that the middleware never mutates the +// request body or headers; the only outcome is allow + metadata or +// deny. +func (m *Middleware) MutationsSupported() bool { return false } + +// Close releases resources owned by the middleware. Stateless, so +// this is a no-op. +func (m *Middleware) Close() error { return nil } + +// Invoke runs the pre-flight policy check. +func (m *Middleware) Invoke(ctx context.Context, in *middleware.Input) (*middleware.Output, error) { + if m.mgmt == nil { + // No management client wired — fall through to allow with + // no attribution. RecordLLMUsage on the response leg will + // also be a no-op so counters stay at zero. This matches + // the PR1 behaviour exactly so a partial wiring is + // indistinguishable from "no enforcement". + return allowNoAttribution(), nil + } + + providerID := lookupKV(in.Metadata, middleware.KeyLLMResolvedProviderID) + if providerID == "" { + // llm_router didn't emit a resolved provider id — usually + // because the request didn't carry an llm.model. The + // router itself denied; we won't reach here in production, + // but defensively pass through so we never deny on top of + // an upstream allow. + return allowNoAttribution(), nil + } + + rpcCtx, cancel := context.WithTimeout(ctx, callTimeout) + defer cancel() + + resp, err := m.mgmt.CheckLLMPolicyLimits(rpcCtx, &proto.CheckLLMPolicyLimitsRequest{ + AccountId: in.AccountID, + UserId: in.UserID, + GroupIds: append([]string(nil), in.UserGroups...), + ProviderId: providerID, + Model: lookupKV(in.Metadata, middleware.KeyLLMModel), + }) + if err != nil { + // Fail-open on transport / management errors. The + // alternative — denying every request when management is + // unreachable — is worse for v1 (operational outage = + // total LLM outage). Operators can audit via the + // access-log; PR3 can switch to fail-closed under a flag. + m.logger.WithError(err). + WithField("middleware", ID). + Debugf("management pre-flight failed; failing open") + return allowNoAttribution(), nil + } + + if resp.GetDecision() == "deny" { + return denyFromManagement(resp), nil + } + return allowFromManagement(resp), nil +} + +// allowNoAttribution returns the no-op allow envelope used when no +// management client is wired or no provider was resolved. Stamps +// decision=allow but no policy / attribution metadata so +// llm_limit_record skips its post-flight write. +func allowNoAttribution() *middleware.Output { + return &middleware.Output{ + Decision: middleware.DecisionAllow, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMPolicyDecision, Value: "allow"}, + }, + } +} + +// allowFromManagement converts a successful CheckLLMPolicyLimits +// response into the chain's allow envelope, stamping the attribution +// metadata the response leg consumes. +func allowFromManagement(resp *proto.CheckLLMPolicyLimitsResponse) *middleware.Output { + out := &middleware.Output{ + Decision: middleware.DecisionAllow, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMPolicyDecision, Value: "allow"}, + }, + } + if id := resp.GetSelectedPolicyId(); id != "" { + out.Metadata = append(out.Metadata, middleware.KV{Key: middleware.KeyLLMSelectedPolicyID, Value: id}) + } + if g := resp.GetAttributionGroupId(); g != "" { + out.Metadata = append(out.Metadata, middleware.KV{Key: middleware.KeyLLMAttributionGroupID, Value: g}) + } + if w := resp.GetWindowSeconds(); w > 0 { + out.Metadata = append(out.Metadata, middleware.KV{Key: middleware.KeyLLMAttributionWindowS, Value: strconv.FormatInt(w, 10)}) + } + return out +} + +// denyFromManagement converts a deny response into the chain's deny +// envelope. The deny code surfaces verbatim through the framework's +// fixed JSON template; arbitrary middleware bytes can't reach the +// wire. +func denyFromManagement(resp *proto.CheckLLMPolicyLimitsResponse) *middleware.Output { + code := resp.GetDenyCode() + if code == "" { + code = "llm_policy.cap_exceeded" + } + // The canonical code is safe to surface; the management-supplied + // reason can name internal quota details (used amounts, caps, rule + // ids), so keep the public message generic and leave the detail to + // server-side logs. + return &middleware.Output{ + Decision: middleware.DecisionDeny, + DenyStatus: 403, + DenyReason: &middleware.DenyReason{ + Code: code, + Message: "LLM policy limit exceeded", + }, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMPolicyDecision, Value: "deny"}, + {Key: middleware.KeyLLMPolicyReason, Value: code}, + }, + } +} + +// lookupKV returns the value associated with key, or the empty +// string when absent. +func lookupKV(kvs []middleware.KV, key string) string { + for _, kv := range kvs { + if kv.Key == key { + return kv.Value + } + } + return "" +} diff --git a/proxy/internal/middleware/builtin/llm_limit_check/middleware_test.go b/proxy/internal/middleware/builtin/llm_limit_check/middleware_test.go new file mode 100644 index 000000000..2c26c2abe --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_limit_check/middleware_test.go @@ -0,0 +1,186 @@ +package llm_limit_check + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// fakeMgmt is a minimal builtin.MgmtClient stub that lets the test +// drive CheckLLMPolicyLimits responses without a real gRPC dial. +type fakeMgmt struct { + checkResp *proto.CheckLLMPolicyLimitsResponse + checkErr error + checkReq *proto.CheckLLMPolicyLimitsRequest +} + +func (f *fakeMgmt) CheckLLMPolicyLimits(_ context.Context, in *proto.CheckLLMPolicyLimitsRequest, _ ...grpc.CallOption) (*proto.CheckLLMPolicyLimitsResponse, error) { + f.checkReq = in + return f.checkResp, f.checkErr +} + +func (f *fakeMgmt) RecordLLMUsage(_ context.Context, _ *proto.RecordLLMUsageRequest, _ ...grpc.CallOption) (*proto.RecordLLMUsageResponse, error) { + return &proto.RecordLLMUsageResponse{}, nil +} + +func runInvoke(t *testing.T, m *Middleware, in *middleware.Input) *middleware.Output { + t.Helper() + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "Invoke must not propagate transport errors") + require.NotNil(t, out, "Invoke must always return an Output") + return out +} + +// TestInvoke_AllowStampsAttributionMetadata covers the happy path: +// management returns an allow decision with selected_policy_id + +// attribution_group_id + window_seconds, the middleware emits all three +// onto the metadata bag so the post-flight llm_limit_record +// middleware has everything it needs to tick the right counter. +func TestInvoke_AllowStampsAttributionMetadata(t *testing.T) { + mgmt := &fakeMgmt{ + checkResp: &proto.CheckLLMPolicyLimitsResponse{ + Decision: "allow", + SelectedPolicyId: "pol-X", + AttributionGroupId: "grp-engineers", + WindowSeconds: 86_400, + }, + } + m := New(mgmt, nil) + + out := runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + UserID: "user-bob", + UserGroups: []string{"grp-engineers"}, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMResolvedProviderID, Value: "prov-1"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + }, + }) + + assert.Equal(t, middleware.DecisionAllow, out.Decision) + assert.Equal(t, "acc-1", mgmt.checkReq.GetAccountId(), "account_id must round-trip onto the RPC") + assert.Equal(t, "user-bob", mgmt.checkReq.GetUserId()) + assert.Equal(t, []string{"grp-engineers"}, mgmt.checkReq.GetGroupIds()) + assert.Equal(t, "prov-1", mgmt.checkReq.GetProviderId(), "resolved provider id must come from metadata") + assert.Equal(t, "gpt-4o", mgmt.checkReq.GetModel(), "model must come from metadata") + + want := map[string]string{ + middleware.KeyLLMPolicyDecision: "allow", + middleware.KeyLLMSelectedPolicyID: "pol-X", + middleware.KeyLLMAttributionGroupID: "grp-engineers", + middleware.KeyLLMAttributionWindowS: "86400", + } + got := map[string]string{} + for _, kv := range out.Metadata { + got[kv.Key] = kv.Value + } + assert.Equal(t, want, got, "attribution metadata must land on the bag for the response leg to consume") +} + +// TestInvoke_DenyConvertsToProxyDeny proves the deny envelope round- +// trips: management's deny code becomes the proxy framework's deny +// payload at status 403, and the deny reason text is preserved so +// operators can debug from the access log. +func TestInvoke_DenyConvertsToProxyDeny(t *testing.T) { + mgmt := &fakeMgmt{ + checkResp: &proto.CheckLLMPolicyLimitsResponse{ + Decision: "deny", + DenyCode: "llm_policy.token_cap_exceeded", + DenyReason: "group token cap exhausted on policy pol-X (used 1000 of 1000)", + }, + } + m := New(mgmt, nil) + + out := runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + UserGroups: []string{"grp-engineers"}, + Metadata: []middleware.KV{{Key: middleware.KeyLLMResolvedProviderID, Value: "prov-1"}}, + }) + + assert.Equal(t, middleware.DecisionDeny, out.Decision) + assert.Equal(t, 403, out.DenyStatus, "policy denials are 403 — same as llm_router's") + require.NotNil(t, out.DenyReason, "deny envelope must carry a reason payload") + assert.Equal(t, "llm_policy.token_cap_exceeded", out.DenyReason.Code, "canonical deny code surfaces to the caller") + // The public message must stay generic: the management reason names + // internal quota detail (used/cap, rule id) that must not leak. + assert.Equal(t, "LLM policy limit exceeded", out.DenyReason.Message, "public deny message must be generic") + assert.NotContains(t, out.DenyReason.Message, "exhausted", "internal quota detail must not reach the caller") + assert.NotContains(t, out.DenyReason.Message, "1000", "internal cap numbers must not reach the caller") +} + +// TestInvoke_NoMgmtClientPassesThrough proves the partial-wiring +// safety: a middleware constructed without a management client +// allows every request without attribution. This makes a half-set-up +// environment indistinguishable from "no enforcement" rather than +// breaking the chain. +func TestInvoke_NoMgmtClientPassesThrough(t *testing.T) { + m := New(nil, nil) + + out := runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + UserGroups: []string{"grp-engineers"}, + Metadata: []middleware.KV{{Key: middleware.KeyLLMResolvedProviderID, Value: "prov-1"}}, + }) + + assert.Equal(t, middleware.DecisionAllow, out.Decision) + for _, kv := range out.Metadata { + assert.NotEqual(t, middleware.KeyLLMSelectedPolicyID, kv.Key, + "no mgmt client = no attribution metadata; record middleware then skips its write") + } +} + +// TestInvoke_NoResolvedProviderPassesThrough covers the defensive +// path: when llm_router didn't set llm.resolved_provider_id (which +// only happens on the deny side of llm_router), the gate must NOT +// stack a second deny on top — pass through and let the upstream +// deny stand. +func TestInvoke_NoResolvedProviderPassesThrough(t *testing.T) { + m := New(&fakeMgmt{}, nil) + + out := runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + Metadata: []middleware.KV{}, + }) + + assert.Equal(t, middleware.DecisionAllow, out.Decision, + "no resolved provider = the gate has nothing to check; never deny on top of an upstream allow") +} + +// TestInvoke_RPCErrorFailsOpen proves the fail-open contract: a +// transport error from management does NOT deny the request. v1 +// trades enforcement strictness for availability — an unreachable +// management server otherwise turns into a total LLM outage. +func TestInvoke_RPCErrorFailsOpen(t *testing.T) { + m := New(&fakeMgmt{checkErr: errors.New("connection refused")}, nil) + + out := runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + UserGroups: []string{"grp-engineers"}, + Metadata: []middleware.KV{{Key: middleware.KeyLLMResolvedProviderID, Value: "prov-1"}}, + }) + + assert.Equal(t, middleware.DecisionAllow, out.Decision, + "transport errors must not cascade into total LLM outages — operators audit via access log") +} + +// TestMetadataKeys_Allowlist locks the closed set this middleware can +// emit. The accumulator drops anything outside this list; adding a +// new emission means updating both the slice and this test. +func TestMetadataKeys_Allowlist(t *testing.T) { + keys := New(nil, nil).MetadataKeys() + want := []string{ + middleware.KeyLLMSelectedPolicyID, + middleware.KeyLLMAttributionGroupID, + middleware.KeyLLMAttributionWindowS, + middleware.KeyLLMPolicyDecision, + middleware.KeyLLMPolicyReason, + } + assert.ElementsMatch(t, want, keys) +} diff --git a/proxy/internal/middleware/builtin/llm_limit_record/factory.go b/proxy/internal/middleware/builtin/llm_limit_record/factory.go new file mode 100644 index 000000000..b42931c0c --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_limit_record/factory.go @@ -0,0 +1,35 @@ +// Package llm_limit_record is the SlotOnResponse middleware that +// posts the served request's token + cost deltas back to management +// so the per-(user, group, window) consumption counters tick. Reads +// the attribution metadata stamped by llm_limit_check on the request +// leg + the token / cost metadata stamped by llm_response_parser and +// cost_meter; skips the write entirely when no attribution metadata +// is present (e.g. catch-all-allow policy with no caps configured). +package llm_limit_record + +import ( + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" +) + +// ID is the registry identifier for this middleware. +const ID = "llm_limit_record" + +// Factory builds a configured llm_limit_record instance bound to the +// FactoryContext's MgmtClient. nil-MgmtClient disables the post-flight +// write entirely (no-op pass-through), matching the request-leg gate's +// behaviour so a partially wired environment is consistent. +type Factory struct{} + +// ID returns the registry identifier matching the middleware ID. +func (Factory) ID() string { return ID } + +// New ignores the rawConfig payload (no per-target config today). +func (Factory) New(_ []byte) (middleware.Middleware, error) { + ctx := builtin.Context() + return New(ctx.MgmtClient, ctx.Logger), nil +} + +func init() { + builtin.Register(Factory{}) +} diff --git a/proxy/internal/middleware/builtin/llm_limit_record/middleware.go b/proxy/internal/middleware/builtin/llm_limit_record/middleware.go new file mode 100644 index 000000000..52dfc73f0 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_limit_record/middleware.go @@ -0,0 +1,144 @@ +package llm_limit_record + +import ( + "context" + "strconv" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// Version is reported via Middleware.Version(). +const Version = "1.0.0" + +// callTimeout caps the wall-clock budget for the post-flight RPC. +// Longer than the pre-flight gate because this runs after the +// upstream returned and is not on the user-facing latency path — +// a slow record is just a delayed counter increment, not a delayed +// response to the caller. +const callTimeout = 5 * time.Second + +// Middleware posts token + cost deltas to management after a served +// request. Stateless; per-call values come entirely from metadata +// emitted upstream. +type Middleware struct { + mgmt builtin.MgmtClient + logger *log.Logger +} + +// New constructs a Middleware bound to the supplied management +// client. mgmt may be nil — that disables the write entirely so a +// partially wired environment doesn't attempt to dial nothing. +func New(mgmt builtin.MgmtClient, logger *log.Logger) *Middleware { + if logger == nil { + logger = log.StandardLogger() + } + return &Middleware{mgmt: mgmt, logger: logger} +} + +// ID returns the registry identifier. +func (m *Middleware) ID() string { return ID } + +// Version returns the implementation version. +func (m *Middleware) Version() string { return Version } + +// Slot reports that the middleware runs after the upstream call. +func (m *Middleware) Slot() middleware.Slot { return middleware.SlotOnResponse } + +// AcceptedContentTypes is empty: this middleware never inspects +// bodies. It only reads metadata emitted upstream. +func (m *Middleware) AcceptedContentTypes() []string { return []string{} } + +// MetadataKeys is empty — the record middleware never emits its own +// metadata. Its only side effect is the gRPC write to management. +func (m *Middleware) MetadataKeys() []string { return []string{} } + +// MutationsSupported reports that the middleware never mutates the +// response. Its outcome is always Allow. +func (m *Middleware) MutationsSupported() bool { return false } + +// Close releases resources owned by the middleware. Stateless. +func (m *Middleware) Close() error { return nil } + +// Invoke reads the attribution + tokens + cost metadata, calls +// management's RecordLLMUsage, and always returns Allow. RPC errors +// are logged at debug level — the response has already been served +// to the client by the time we get here, so a record failure must +// not surface back through the proxy. +func (m *Middleware) Invoke(ctx context.Context, in *middleware.Input) (*middleware.Output, error) { + out := &middleware.Output{Decision: middleware.DecisionAllow} + if m.mgmt == nil { + return out, nil + } + + tokensIn, _ := strconv.ParseInt(lookupKV(in.Metadata, middleware.KeyLLMInputTokens), 10, 64) + tokensOut, _ := strconv.ParseInt(lookupKV(in.Metadata, middleware.KeyLLMOutputTokens), 10, 64) + costUSD, _ := strconv.ParseFloat(lookupKV(in.Metadata, middleware.KeyCostUSDTotal), 64) + if tokensIn == 0 && tokensOut == 0 && costUSD == 0 { + // llm_response_parser couldn't read usage off the upstream + // response (streaming-not-yet-supported, malformed body, …). + // Skipping the write keeps phantom rows out of the + // consumption table. + return out, nil + } + + windowStr := lookupKV(in.Metadata, middleware.KeyLLMAttributionWindowS) + windowSeconds, _ := strconv.ParseInt(windowStr, 10, 64) + groupID := lookupKV(in.Metadata, middleware.KeyLLMAttributionGroupID) + + // A zero attribution window means no policy cap bound this request (deny at + // the gate, or a catch-all-allow policy). We still record so account-level + // budget rules — which live in their own windows and bind independently of + // policies — accumulate. The management side books the policy dimensions + // only when window_seconds > 0 and fans out to account rules regardless. + if in.UserID == "" && groupID == "" && len(in.UserGroups) == 0 { + m.logger.WithField("middleware", ID). + WithField("account_id", in.AccountID). + Debugf("post-flight skipped: no user/group/groups to attribute (tokens=%d/%d cost=%g window=%d)", tokensIn, tokensOut, costUSD, windowSeconds) + return out, nil + } + + rpcCtx, cancel := context.WithTimeout(ctx, callTimeout) + defer cancel() + + m.logger.WithField("middleware", ID). + WithField("account_id", in.AccountID). + WithField("user_id", in.UserID). + WithField("group_id", groupID). + WithField("group_ids_len", len(in.UserGroups)). + Debugf("post-flight sending RecordLLMUsage (tokens=%d/%d cost=%g window=%d)", tokensIn, tokensOut, costUSD, windowSeconds) + + if _, err := m.mgmt.RecordLLMUsage(rpcCtx, &proto.RecordLLMUsageRequest{ + AccountId: in.AccountID, + UserId: in.UserID, + GroupId: groupID, + WindowSeconds: windowSeconds, + TokensInput: tokensIn, + TokensOutput: tokensOut, + CostUsd: costUSD, + GroupIds: append([]string(nil), in.UserGroups...), + }); err != nil { + m.logger.WithError(err). + WithField("middleware", ID). + WithField("account_id", in.AccountID). + WithField("user_id", in.UserID). + WithField("group_id", groupID). + Debugf("post-flight record failed; counter will lag this request") + } + return out, nil +} + +// lookupKV returns the value associated with key, or the empty +// string when absent. +func lookupKV(kvs []middleware.KV, key string) string { + for _, kv := range kvs { + if kv.Key == key { + return kv.Value + } + } + return "" +} diff --git a/proxy/internal/middleware/builtin/llm_limit_record/middleware_test.go b/proxy/internal/middleware/builtin/llm_limit_record/middleware_test.go new file mode 100644 index 000000000..a98ce9b9f --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_limit_record/middleware_test.go @@ -0,0 +1,191 @@ +package llm_limit_record + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/shared/management/proto" +) + +type fakeMgmt struct { + recordReq *proto.RecordLLMUsageRequest + recordCalled bool + recordErr error +} + +func (f *fakeMgmt) CheckLLMPolicyLimits(_ context.Context, _ *proto.CheckLLMPolicyLimitsRequest, _ ...grpc.CallOption) (*proto.CheckLLMPolicyLimitsResponse, error) { + return &proto.CheckLLMPolicyLimitsResponse{Decision: "allow"}, nil +} + +func (f *fakeMgmt) RecordLLMUsage(_ context.Context, in *proto.RecordLLMUsageRequest, _ ...grpc.CallOption) (*proto.RecordLLMUsageResponse, error) { + f.recordCalled = true + f.recordReq = in + return &proto.RecordLLMUsageResponse{}, f.recordErr +} + +func runInvoke(t *testing.T, m *Middleware, in *middleware.Input) *middleware.Output { + t.Helper() + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + return out +} + +// TestInvoke_PostsAttributionWithTokensAndCost covers the happy path: +// when the request leg stamped attribution + the upstream parsers +// stamped tokens + cost, the post-flight call carries every field +// through to RecordLLMUsage. +func TestInvoke_PostsAttributionWithTokensAndCost(t *testing.T) { + mgmt := &fakeMgmt{} + m := New(mgmt, nil) + + out := runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + UserID: "user-bob", + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMAttributionGroupID, Value: "grp-engineers"}, + {Key: middleware.KeyLLMAttributionWindowS, Value: "86400"}, + {Key: middleware.KeyLLMInputTokens, Value: "150"}, + {Key: middleware.KeyLLMOutputTokens, Value: "75"}, + {Key: middleware.KeyCostUSDTotal, Value: "0.0125"}, + }, + }) + + assert.Equal(t, middleware.DecisionAllow, out.Decision) + require.True(t, mgmt.recordCalled, "record must be invoked when attribution + usage are both present") + assert.Equal(t, "acc-1", mgmt.recordReq.GetAccountId()) + assert.Equal(t, "user-bob", mgmt.recordReq.GetUserId()) + assert.Equal(t, "grp-engineers", mgmt.recordReq.GetGroupId()) + assert.Equal(t, int64(86_400), mgmt.recordReq.GetWindowSeconds()) + assert.Equal(t, int64(150), mgmt.recordReq.GetTokensInput()) + assert.Equal(t, int64(75), mgmt.recordReq.GetTokensOutput()) + assert.InDelta(t, 0.0125, mgmt.recordReq.GetCostUsd(), 1e-9) +} + +// TestInvoke_NoAttributionWindowStillRecordsForAccountFanOut proves the +// catch-all-allow path now STILL records (window 0): account-level budget +// rules live in their own windows and bind independently of policies, so the +// management side needs the post-flight call even when no policy cap applied. +// The full group set is forwarded so the account fan-out can attribute. +func TestInvoke_NoAttributionWindowStillRecordsForAccountFanOut(t *testing.T) { + mgmt := &fakeMgmt{} + m := New(mgmt, nil) + + runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + UserID: "user-bob", + UserGroups: []string{"grp-eng", "grp-oncall"}, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMInputTokens, Value: "150"}, + {Key: middleware.KeyLLMOutputTokens, Value: "75"}, + {Key: middleware.KeyCostUSDTotal, Value: "0.0125"}, + }, + }) + + require.True(t, mgmt.recordCalled, "must record even without a policy window so account budgets accumulate") + assert.Equal(t, int64(0), mgmt.recordReq.GetWindowSeconds(), "no policy window is forwarded as 0") + assert.Empty(t, mgmt.recordReq.GetGroupId(), "no attribution group without a policy") + assert.Equal(t, []string{"grp-eng", "grp-oncall"}, mgmt.recordReq.GetGroupIds(), "full group set must be forwarded for the account fan-out") +} + +// TestInvoke_NoPrincipalSkipsRecord proves that with neither a user nor any +// groups there is nothing to attribute, so the write is skipped. +func TestInvoke_NoPrincipalSkipsRecord(t *testing.T) { + mgmt := &fakeMgmt{} + m := New(mgmt, nil) + + runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMInputTokens, Value: "150"}, + {Key: middleware.KeyCostUSDTotal, Value: "0.0125"}, + }, + }) + + assert.False(t, mgmt.recordCalled, "no user and no groups = nothing to attribute") +} + +// TestInvoke_ZeroUsageSkipsRecord proves the no-usage-no-write path: +// when the upstream parser couldn't extract token counts (streaming, +// malformed body, …), skipping the write keeps phantom rows out of +// the consumption table. +func TestInvoke_ZeroUsageSkipsRecord(t *testing.T) { + mgmt := &fakeMgmt{} + m := New(mgmt, nil) + + runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + UserID: "user-bob", + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMAttributionGroupID, Value: "grp-engineers"}, + {Key: middleware.KeyLLMAttributionWindowS, Value: "86400"}, + }, + }) + + assert.False(t, mgmt.recordCalled, "zero tokens AND zero cost = nothing to record; an upstream parse miss must not surface as a row") +} + +// TestInvoke_RPCErrorIsSwallowed proves the post-flight isolation +// contract: management errors must NOT cascade back to the proxy +// because the upstream response has already been served — failing +// the chain at this point would corrupt the response. Errors are +// logged at debug level and swallowed. +func TestInvoke_RPCErrorIsSwallowed(t *testing.T) { + mgmt := &fakeMgmt{recordErr: errors.New("management down")} + m := New(mgmt, nil) + + out := runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + UserID: "user-bob", + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMAttributionGroupID, Value: "grp-engineers"}, + {Key: middleware.KeyLLMAttributionWindowS, Value: "86400"}, + {Key: middleware.KeyLLMInputTokens, Value: "100"}, + }, + }) + + assert.Equal(t, middleware.DecisionAllow, out.Decision, + "a record failure must not surface — the upstream response is already on the wire") +} + +// TestInvoke_NoMgmtClientPassesThrough mirrors the gate's safety +// contract: a partial wiring is consistent. No mgmt client = silent +// skip rather than an unhandled nil-deref. +func TestInvoke_NoMgmtClientPassesThrough(t *testing.T) { + m := New(nil, nil) + out := runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMAttributionGroupID, Value: "grp-engineers"}, + {Key: middleware.KeyLLMAttributionWindowS, Value: "86400"}, + {Key: middleware.KeyLLMInputTokens, Value: "100"}, + }, + }) + assert.Equal(t, middleware.DecisionAllow, out.Decision) +} + +// TestInvoke_NoIdentitySkipsRecord covers a defensive guard: stamped +// attribution but no user_id AND no group_id (shouldn't happen, but +// possible if the gate ever changes shape) must not write a row keyed +// on empty dimension ids. +func TestInvoke_NoIdentitySkipsRecord(t *testing.T) { + mgmt := &fakeMgmt{} + m := New(mgmt, nil) + + runInvoke(t, m, &middleware.Input{ + AccountID: "acc-1", + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMAttributionWindowS, Value: "86400"}, + {Key: middleware.KeyLLMInputTokens, Value: "100"}, + }, + }) + + assert.False(t, mgmt.recordCalled, + "empty user + group identity must skip the write — never key on empty dimension ids") +} diff --git a/proxy/internal/middleware/builtin/llm_request_parser/bedrock_test.go b/proxy/internal/middleware/builtin/llm_request_parser/bedrock_test.go new file mode 100644 index 000000000..827b81d07 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_request_parser/bedrock_test.go @@ -0,0 +1,55 @@ +package llm_request_parser + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizeBedrockModel(t *testing.T) { + cases := map[string]string{ + "eu.anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5", + "us.anthropic.claude-opus-4-8-20250101-v1:0": "anthropic.claude-opus-4-8", + "apac.anthropic.claude-haiku-4-5-v1:0": "anthropic.claude-haiku-4-5", + "anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5", + "meta.llama3-3-70b-instruct-v1:0": "meta.llama3-3-70b-instruct", + "amazon.nova-pro-v1:0": "amazon.nova-pro", + "amazon.nova-2-lite-v1:0": "amazon.nova-2-lite", + // Inference-profile ARN — model id lives in the last path segment. + "arn:aws:bedrock:eu-central-1:123456789012:inference-profile/eu.anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5", + } + for in, want := range cases { + require.Equal(t, want, normalizeBedrockModel(in), "normalize %q", in) + } +} + +func TestParseBedrockPath(t *testing.T) { + tests := []struct { + path string + model string + stream bool + ok bool + }{ + {"/model/eu.anthropic.claude-sonnet-4-5-20250929-v1:0/invoke", "anthropic.claude-sonnet-4-5", false, true}, + {"/model/eu.anthropic.claude-sonnet-4-5-20250929-v1:0/invoke-with-response-stream", "anthropic.claude-sonnet-4-5", true, true}, + {"/model/eu.anthropic.claude-sonnet-4-5-20250929-v1:0/converse", "anthropic.claude-sonnet-4-5", false, true}, + {"/model/eu.anthropic.claude-sonnet-4-5-20250929-v1:0/converse-stream", "anthropic.claude-sonnet-4-5", true, true}, + // URL-encoded colon in the version suffix. + {"/model/eu.anthropic.claude-sonnet-4-5-20250929-v1%3A0/invoke", "anthropic.claude-sonnet-4-5", false, true}, + // Optional "/bedrock" gateway-namespace prefix. + {"/bedrock/model/eu.anthropic.claude-sonnet-4-5-20250929-v1:0/invoke-with-response-stream", "anthropic.claude-sonnet-4-5", true, true}, + {"/bedrock/model/anthropic.claude-sonnet-4-5-20250929-v1:0/converse", "anthropic.claude-sonnet-4-5", false, true}, + {"/v1/chat/completions", "", false, false}, + {"/model/foo", "", false, false}, + {"/model//invoke", "", false, false}, + {"/model/x/unknown-action", "", false, false}, + } + for _, tt := range tests { + br, ok := parseBedrockPath(tt.path) + require.Equal(t, tt.ok, ok, "ok for %q", tt.path) + if tt.ok { + require.Equal(t, tt.model, br.model, "model for %q", tt.path) + require.Equal(t, tt.stream, br.stream, "stream for %q", tt.path) + } + } +} diff --git a/proxy/internal/middleware/builtin/llm_request_parser/factory.go b/proxy/internal/middleware/builtin/llm_request_parser/factory.go new file mode 100644 index 000000000..8b3776877 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_request_parser/factory.go @@ -0,0 +1,71 @@ +package llm_request_parser + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" +) + +// config is the on-wire config envelope for the middleware. +// +// ProviderID, when set, names the parser to use directly (matched +// against llm.ParserByName, e.g. "openai", "anthropic"). The +// agent-network synthesiser stamps this so requests routed through a +// synthesised provider service don't depend on URL-shape sniffing, +// which is the only signal the middleware otherwise has. +type config struct { + ProviderID string `json:"provider_id,omitempty"` + // RedactPii, when true, runs PII redaction over the captured raw prompt + // before it is emitted as llm.request_prompt_raw — so the + // agent-network access-log row does NOT carry raw emails / SSNs / + // phone numbers even though the framework's per-key redactor (Scan) + // doesn't cover those prompt-shaped patterns. Sourced by the + // synthesiser from the account's redact_pii toggle. + RedactPii bool `json:"redact_pii,omitempty"` + // CapturePrompt gates emission of llm.request_prompt_raw. A nil pointer + // preserves the legacy default (emit), so callers that don't know about + // the toggle (or pre-existing tests with empty config) keep working. + // The synthesiser sets this explicitly to the account's + // enable_prompt_collection toggle: false here suppresses the key + // entirely so the access-log row carries no prompt content at all, + // independent of redact_pii (which only controls the form of the + // content when it IS emitted). + CapturePrompt *bool `json:"capture_prompt,omitempty"` +} + +// Factory builds llm_request_parser instances from raw config bytes. +type Factory struct{} + +// ID returns the registry identifier. +func (Factory) ID() string { return ID } + +// New constructs a middleware instance. Empty, null, and {} configs are +// accepted; non-empty rawConfig that fails to unmarshal is rejected so +// misconfigurations surface at chain build time. +func (Factory) New(rawConfig []byte) (middleware.Middleware, error) { + var cfg config + if len(bytes.TrimSpace(rawConfig)) > 0 { + // Strict decode: a typo'd field (e.g. "capture_prompts") must fail + // chain build rather than silently fall back to the emit-everything + // default and leak prompts. + dec := json.NewDecoder(bytes.NewReader(rawConfig)) + dec.DisallowUnknownFields() + if err := dec.Decode(&cfg); err != nil { + return nil, fmt.Errorf("decode config: %w", err) + } + } + // Default capturePrompt to true (legacy emission) when the field is + // absent so non-agent-network callers and pre-toggle tests keep working. + capturePrompt := true + if cfg.CapturePrompt != nil { + capturePrompt = *cfg.CapturePrompt + } + return middlewareImpl{providerID: cfg.ProviderID, redactPii: cfg.RedactPii, capturePrompt: capturePrompt}, nil +} + +func init() { + builtin.Register(Factory{}) +} diff --git a/proxy/internal/middleware/builtin/llm_request_parser/guardrail_allowlist_test.go b/proxy/internal/middleware/builtin/llm_request_parser/guardrail_allowlist_test.go new file mode 100644 index 000000000..0074411cc --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_request_parser/guardrail_allowlist_test.go @@ -0,0 +1,106 @@ +package llm_request_parser + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail" +) + +// runParserGuardrail runs the request parser then the model-allowlist guardrail +// in SlotOnRequest order, threading the parser's metadata into the guardrail the +// same way the real chain does. It returns the guardrail decision so tests can +// assert allowlist enforcement for URL/path-routed providers end to end. +func runParserGuardrail(t *testing.T, url string, body []byte, allowlist []string) *middleware.Output { + t.Helper() + parser := newMiddleware(t) + parsed, err := parser.Invoke(context.Background(), &middleware.Input{ + Slot: middleware.SlotOnRequest, + URL: url, + Body: body, + }) + require.NoError(t, err, "parser must not error") + + guard := llm_guardrail.New(llm_guardrail.Config{ModelAllowlist: allowlist}) + out, err := guard.Invoke(context.Background(), &middleware.Input{ + Slot: middleware.SlotOnRequest, + Metadata: parsed.Metadata, + }) + require.NoError(t, err, "guardrail must not error") + require.NotNil(t, out, "guardrail must return an output") + return out +} + +// TestModelAllowlist_URLRoutedProviders validates that the model allowlist is +// enforced for providers whose model travels in the URL path (AWS Bedrock, +// Google Vertex) rather than the JSON body. The "unknown action" case is the +// regression guard for #6751: a Bedrock request shape the parser cannot map to a +// model must fail closed under an allowlist instead of bypassing it. +func TestModelAllowlist_URLRoutedProviders(t *testing.T) { + const bedrockBody = `{"anthropic_version":"bedrock-2023-05-31","messages":[{"role":"user","content":"hi"}]}` + const vertexBody = `{"anthropic_version":"vertex-2023-10-16","messages":[{"role":"user","content":"hi"}]}` + + tests := []struct { + name string + url string + body string + allowlist []string + decision middleware.Decision + denyCode string + }{ + { + name: "bedrock allowed model passes", + url: "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-v1:0/invoke", + body: bedrockBody, + allowlist: []string{"anthropic.claude-haiku-4-5"}, + decision: middleware.DecisionAllow, + }, + { + name: "bedrock disallowed model denied", + url: "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-opus-4-8-v1:0/invoke", + body: bedrockBody, + allowlist: []string{"anthropic.claude-haiku-4-5"}, + decision: middleware.DecisionDeny, + denyCode: "llm_policy.model_blocked", + }, + { + name: "bedrock unknown action fails closed", + url: "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-opus-4-8-v1:0/some-future-action", + body: bedrockBody, + allowlist: []string{"anthropic.claude-haiku-4-5"}, + decision: middleware.DecisionDeny, + denyCode: "llm_policy.model_unknown", + }, + { + name: "vertex disallowed model denied", + url: "/v1/projects/p/locations/global/publishers/anthropic/models/claude-opus-4-8@20250101:rawPredict", + body: vertexBody, + allowlist: []string{"claude-haiku-4-5"}, + decision: middleware.DecisionDeny, + denyCode: "llm_policy.model_blocked", + }, + { + name: "vertex allowed model passes", + url: "/v1/projects/p/locations/global/publishers/anthropic/models/claude-haiku-4-5@20250101:rawPredict", + body: vertexBody, + allowlist: []string{"claude-haiku-4-5"}, + decision: middleware.DecisionAllow, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out := runParserGuardrail(t, tt.url, []byte(tt.body), tt.allowlist) + assert.Equal(t, tt.decision, out.Decision, "unexpected decision for %s", tt.name) + if tt.decision == middleware.DecisionDeny { + require.NotNil(t, out.DenyReason, "deny reason must be set for %s", tt.name) + assert.Equal(t, 403, out.DenyStatus, "deny status must be 403 for %s", tt.name) + assert.Equal(t, tt.denyCode, out.DenyReason.Code, "deny code for %s", tt.name) + } + }) + } +} diff --git a/proxy/internal/middleware/builtin/llm_request_parser/middleware.go b/proxy/internal/middleware/builtin/llm_request_parser/middleware.go new file mode 100644 index 000000000..64ca04e6a --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_request_parser/middleware.go @@ -0,0 +1,453 @@ +// Package llm_request_parser implements the SlotOnRequest middleware +// that detects the LLM provider from the request URL, parses the JSON +// request body for model and streaming flags, and extracts the user +// prompt text. Emitted metadata feeds downstream middlewares (guardrail, +// cost meter) and the access-log terminal sink. +package llm_request_parser + +import ( + "context" + "net/url" + "regexp" + "strconv" + "strings" + "unicode/utf8" + + "github.com/netbirdio/netbird/proxy/internal/llm" + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail" +) + +// ID is the registry key for this middleware. +const ID = "llm_request_parser" + +// Version is reported via Middleware.Version(). +const Version = "1.0.0" + +// maxPromptBytes caps llm.request_prompt_raw at a size that fits within +// MaxMetadataValueBytes with headroom. Truncation is rune-safe. +const maxPromptBytes = 3500 + +// middlewareImpl is the concrete implementation. providerID, when set, +// names the parser to use directly (bypasses URL sniffing). It is empty +// for non-agent-network targets, which fall back to DetectParser on the +// request path. +type middlewareImpl struct { + providerID string + redactPii bool + capturePrompt bool +} + +// ID returns the registry identifier. +func (middlewareImpl) ID() string { return ID } + +// Version returns the implementation version. +func (middlewareImpl) Version() string { return Version } + +// Slot reports the request slot. +func (middlewareImpl) Slot() middleware.Slot { return middleware.SlotOnRequest } + +// AcceptedContentTypes restricts body inspection to JSON. +func (middlewareImpl) AcceptedContentTypes() []string { + return []string{"application/json"} +} + +// MetadataKeys lists the closed allowlist of keys this middleware emits. +func (middlewareImpl) MetadataKeys() []string { + return []string{ + middleware.KeyLLMProvider, + middleware.KeyLLMModel, + middleware.KeyLLMStream, + middleware.KeyLLMRequestPromptRaw, + middleware.KeyLLMCaptureTruncated, + middleware.KeyLLMSessionID, + } +} + +// MutationsSupported reports that this middleware never mutates. +func (middlewareImpl) MutationsSupported() bool { return false } + +// Close is a no-op; the middleware is stateless. +func (middlewareImpl) Close() error { return nil } + +// Invoke detects the LLM provider, parses request facts, and emits +// metadata. Always returns DecisionAllow; never errors. Provider +// selection prefers the configured providerID (synthesiser-stamped on +// agent-network targets) so requests routed to a custom upstream URL +// still resolve. Falls back to URL sniffing when no providerID is set. +func (m middlewareImpl) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) { + out := &middleware.Output{Decision: middleware.DecisionAllow} + if in == nil { + return out, nil + } + + // Google Vertex AI carries the model + publisher (vendor) in the URL path, + // not the body, so it needs a dedicated extraction path. + if vx, okv := parseVertexPath(extractPath(in.URL)); okv { + return m.invokeVertex(in, vx), nil + } + + // AWS Bedrock likewise carries the model in the URL path (/model/{id}/{action}). + if br, okb := parseBedrockPath(extractPath(in.URL)); okb { + return m.invokeBedrock(in, br), nil + } + + parser, ok := llm.ParserByName(m.providerID) + if !ok { + parser, ok = llm.DetectParser(extractPath(in.URL)) + } + if !ok { + return out, nil + } + + md := []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: parser.ProviderName()}, + } + + // Session id is an opaque grouping identifier, not prompt content, so + // it's emitted regardless of the prompt-collection toggle — session + // grouping must work even when prompt capture is off. Prefer a header + // (Codex sends the session as an HTTP header, and headers survive an + // oversized request whose body capture was bypassed) and resolve it + // before ParseRequest so a malformed body still keeps the header id. + sessionID := sessionIDFromHeaders(in.Headers) + if sessionID == "" { + sessionID = parser.ExtractSessionID(in.Body) + } + appendSessionID := func(md []middleware.KV) []middleware.KV { + if sessionID != "" { + return append(md, middleware.KV{Key: middleware.KeyLLMSessionID, Value: sessionID}) + } + return md + } + + facts, err := parser.ParseRequest(in.Body) + if err != nil { + if logger := builtin.Context().Logger; logger != nil { + logger.Debugf("llm_request_parser: parse request body: %v", err) + } + md = appendSessionID(md) + md = appendCaptureTruncated(md, false, in.BodyTruncated) + out.Metadata = md + return out, nil + } + + if facts.Model != "" { + md = append(md, middleware.KV{Key: middleware.KeyLLMModel, Value: facts.Model}) + } + md = append(md, middleware.KV{Key: middleware.KeyLLMStream, Value: strconv.FormatBool(facts.Stream)}) + md = appendSessionID(md) + + prompt, promptTruncated := truncatePrompt(parser.ExtractPrompt(in.Body)) + if prompt != "" && m.capturePrompt { + if m.redactPii { + // Apply redaction BEFORE the value lands in the metadata bag, so + // the access-log row never carries raw emails / SSNs / phones. + // The downstream llm_guardrail middleware reads this key to + // produce llm.request_prompt; RedactPII is idempotent so its + // second pass is a no-op. Redaction can grow the text, so + // re-truncate to keep the value within the metadata cap. + prompt = llm_guardrail.RedactPII(prompt) + var redactedTruncated bool + prompt, redactedTruncated = truncatePrompt(prompt) + promptTruncated = promptTruncated || redactedTruncated + } + md = append(md, middleware.KV{Key: middleware.KeyLLMRequestPromptRaw, Value: prompt}) + } + + md = appendCaptureTruncated(md, promptTruncated, in.BodyTruncated) + out.Metadata = md + return out, nil +} + +// sessionIDHeaders are request header names that may carry a client +// session identifier, checked in order, case-insensitively. Matching is +// against Go's canonical header form, so use the hyphenated names the +// clients actually send: "x-claude-code-session-id" (Claude Code), +// "session-id" (OpenAI Codex — confirmed on the wire as "Session-Id"), +// and "x-session-id" as a generic convention. +var sessionIDHeaders = []string{"x-claude-code-session-id", "session-id", "x-session-id"} + +// sessionIDFromHeaders returns the first non-empty value among the known +// session header names, or "" when none is present. Headers arrive in +// canonical form, so the match is case-insensitive. +func sessionIDFromHeaders(headers []middleware.KV) string { + for _, want := range sessionIDHeaders { + for _, kv := range headers { + if strings.EqualFold(kv.Key, want) && kv.Value != "" { + return kv.Value + } + } + } + return "" +} + +// appendCaptureTruncated stamps the capture_truncated marker reflecting +// either prompt-side truncation or upstream body truncation. +func appendCaptureTruncated(md []middleware.KV, promptTruncated, bodyTruncated bool) []middleware.KV { + value := "false" + if promptTruncated || bodyTruncated { + value = "true" + } + return append(md, middleware.KV{Key: middleware.KeyLLMCaptureTruncated, Value: value}) +} + +// truncatePrompt clamps a prompt string to maxPromptBytes on a UTF-8 +// rune boundary. Returns the clamped string and whether truncation +// occurred. +func truncatePrompt(s string) (string, bool) { + if len(s) <= maxPromptBytes { + return s, false + } + cut := maxPromptBytes + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + return s[:cut], true +} + +// extractPath returns the path component of a URL that may be absolute +// or already a path. Parse errors fall back to the raw input. +func extractPath(raw string) string { + if raw == "" { + return "" + } + u, err := url.Parse(raw) + if err != nil || u.Path == "" { + return raw + } + return u.Path +} + +// vertexRequest is the model + vendor extracted from a Vertex AI publisher +// path (the model is in the URL, not the body). +type vertexRequest struct { + publisher string + model string + stream bool +} + +// parseVertexPath extracts the publisher, model, and streaming flag from a +// Vertex publisher endpoint: +// +// /v1/projects/{project}/locations/{region}/publishers/{publisher}/models/{model}:{action} +// +// The model's "@version" suffix is stripped so it matches catalog/pricing. +func parseVertexPath(reqPath string) (vertexRequest, bool) { + const pubSep, modSep = "/publishers/", "/models/" + if !strings.HasPrefix(reqPath, "/v1/projects/") { + return vertexRequest{}, false + } + pubIdx := strings.Index(reqPath, pubSep) + modIdx := strings.Index(reqPath, modSep) + if pubIdx < 0 || modIdx <= pubIdx { + return vertexRequest{}, false + } + publisher := reqPath[pubIdx+len(pubSep) : modIdx] + rest := reqPath[modIdx+len(modSep):] // {model}:{action} + if publisher == "" || rest == "" { + return vertexRequest{}, false + } + model, action := rest, "" + if c := strings.LastIndex(rest, ":"); c >= 0 { + model, action = rest[:c], rest[c+1:] + } + if at := strings.Index(model, "@"); at >= 0 { + model = model[:at] + } + if model == "" { + return vertexRequest{}, false + } + return vertexRequest{publisher: publisher, model: model, stream: strings.HasPrefix(action, "stream")}, true +} + +// vertexPublisherVendor maps a Vertex publisher to the parser surface its +// requests/responses speak. Empty for publishers without a parser yet +// (e.g. google/gemini) — the request still routes, but isn't metered. +func vertexPublisherVendor(publisher string) string { + switch strings.ToLower(publisher) { + case "anthropic": + return "anthropic" + case "openai": + return "openai" + default: + return "" + } +} + +// invokeVertex emits the model/vendor/session/prompt for a Vertex publisher +// request, using the publisher's parser to read the (vendor-native) body. +func (m middlewareImpl) invokeVertex(in *middleware.Input, vx vertexRequest) *middleware.Output { + out := &middleware.Output{Decision: middleware.DecisionAllow} + vendor := vertexPublisherVendor(vx.publisher) + + md := []middleware.KV{} + if vendor != "" { + md = append(md, middleware.KV{Key: middleware.KeyLLMProvider, Value: vendor}) + } + md = append(md, middleware.KV{Key: middleware.KeyLLMModel, Value: vx.model}) + md = append(md, middleware.KV{Key: middleware.KeyLLMStream, Value: strconv.FormatBool(vx.stream)}) + + var parser llm.Parser + if vendor != "" { + parser, _ = llm.ParserByName(vendor) + } + + sessionID := sessionIDFromHeaders(in.Headers) + if sessionID == "" && parser != nil { + sessionID = parser.ExtractSessionID(in.Body) + } + if sessionID != "" { + md = append(md, middleware.KV{Key: middleware.KeyLLMSessionID, Value: sessionID}) + } + + promptTruncated := false + if parser != nil && m.capturePrompt { + var prompt string + prompt, promptTruncated = truncatePrompt(parser.ExtractPrompt(in.Body)) + if prompt != "" { + if m.redactPii { + prompt = llm_guardrail.RedactPII(prompt) + var rt bool + prompt, rt = truncatePrompt(prompt) + promptTruncated = promptTruncated || rt + } + md = append(md, middleware.KV{Key: middleware.KeyLLMRequestPromptRaw, Value: prompt}) + } + } + md = appendCaptureTruncated(md, promptTruncated, in.BodyTruncated) + out.Metadata = md + return out +} + +// bedrockRequest is the model + streaming flag extracted from an AWS Bedrock +// model path. The InvokeModel vs Converse distinction is recovered downstream +// from the response body shape, so only the streaming flag is carried here. +type bedrockRequest struct { + model string + stream bool +} + +// bedrockNamespacePrefix is an optional gateway-namespace prefix some clients +// put before the native Bedrock path to disambiguate it from other providers +// that also use "/model/...". +const bedrockNamespacePrefix = "/bedrock" + +// trimBedrockNamespace removes an optional "/bedrock" namespace prefix, leaving +// the native Bedrock path ("/model/..."). +func trimBedrockNamespace(reqPath string) string { + if strings.HasPrefix(reqPath, bedrockNamespacePrefix+"/") { + return strings.TrimPrefix(reqPath, bedrockNamespacePrefix) + } + return reqPath +} + +// bedrockRegionPrefixes are the cross-region inference-profile prefixes that +// front a Bedrock model id (e.g. "eu.anthropic.claude-..."). +var bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."} + +// bedrockVersionSuffix matches the trailing "-vN[:N]" or "-YYYYMMDD-vN[:N]" +// version/throughput suffix of a Bedrock model id. +var bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`) + +// parseBedrockPath extracts the model and streaming/converse flags from an AWS +// Bedrock runtime model endpoint: +// +// /model/{modelId}/{action} +// +// action ∈ {invoke, invoke-with-response-stream, converse, converse-stream}. +// The modelId may be URL-encoded and may carry a cross-region inference-profile +// prefix and a version suffix; normalizeBedrockModel strips both so the model +// matches catalog pricing. +func parseBedrockPath(reqPath string) (bedrockRequest, bool) { + reqPath = trimBedrockNamespace(reqPath) + const prefix = "/model/" + if !strings.HasPrefix(reqPath, prefix) { + return bedrockRequest{}, false + } + rest := reqPath[len(prefix):] + slash := strings.LastIndex(rest, "/") + if slash <= 0 || slash == len(rest)-1 { + return bedrockRequest{}, false + } + rawModel, action := rest[:slash], rest[slash+1:] + if decoded, err := url.PathUnescape(rawModel); err == nil { + rawModel = decoded + } + model := normalizeBedrockModel(rawModel) + if model == "" { + return bedrockRequest{}, false + } + switch action { + case "invoke", "converse": + return bedrockRequest{model: model}, true + case "invoke-with-response-stream", "converse-stream": + return bedrockRequest{model: model, stream: true}, true + default: + return bedrockRequest{}, false + } +} + +// normalizeBedrockModel strips an ARN wrapper, a cross-region inference-profile +// prefix, and the version/throughput suffix from a Bedrock model id so it +// matches the catalog/pricing key, e.g. +// "eu.anthropic.claude-sonnet-4-5-20250929-v1:0" -> "anthropic.claude-sonnet-4-5" +// and "arn:aws:bedrock:eu-central-1:123:inference-profile/eu.anthropic.claude-sonnet-4-5-20250929-v1:0" +// -> "anthropic.claude-sonnet-4-5". +func normalizeBedrockModel(modelID string) string { + m := modelID + // A full ARN (inference-profile / provisioned-throughput / foundation-model) + // carries the model id in its last path segment. + if strings.HasPrefix(m, "arn:") { + if i := strings.LastIndex(m, "/"); i >= 0 { + m = m[i+1:] + } + } + for _, p := range bedrockRegionPrefixes { + if strings.HasPrefix(m, p) { + m = m[len(p):] + break + } + } + return bedrockVersionSuffix.ReplaceAllString(m, "") +} + +// invokeBedrock emits the model/provider/session/prompt for an AWS Bedrock +// request. Bedrock is metered under the dedicated "bedrock" parser, which reads +// both the InvokeModel and Converse response shapes. +func (m middlewareImpl) invokeBedrock(in *middleware.Input, br bedrockRequest) *middleware.Output { + out := &middleware.Output{Decision: middleware.DecisionAllow} + md := []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: llm.ProviderNameBedrock}, + {Key: middleware.KeyLLMModel, Value: br.model}, + {Key: middleware.KeyLLMStream, Value: strconv.FormatBool(br.stream)}, + } + + parser, _ := llm.ParserByName(llm.ProviderNameBedrock) + sessionID := sessionIDFromHeaders(in.Headers) + if sessionID == "" && parser != nil { + sessionID = parser.ExtractSessionID(in.Body) + } + if sessionID != "" { + md = append(md, middleware.KV{Key: middleware.KeyLLMSessionID, Value: sessionID}) + } + + promptTruncated := false + if parser != nil && m.capturePrompt { + var prompt string + prompt, promptTruncated = truncatePrompt(parser.ExtractPrompt(in.Body)) + if prompt != "" { + if m.redactPii { + prompt = llm_guardrail.RedactPII(prompt) + var rt bool + prompt, rt = truncatePrompt(prompt) + promptTruncated = promptTruncated || rt + } + md = append(md, middleware.KV{Key: middleware.KeyLLMRequestPromptRaw, Value: prompt}) + } + } + md = appendCaptureTruncated(md, promptTruncated, in.BodyTruncated) + out.Metadata = md + return out +} diff --git a/proxy/internal/middleware/builtin/llm_request_parser/middleware_test.go b/proxy/internal/middleware/builtin/llm_request_parser/middleware_test.go new file mode 100644 index 000000000..bc185b295 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_request_parser/middleware_test.go @@ -0,0 +1,418 @@ +package llm_request_parser + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +func metaValue(t *testing.T, kvs []middleware.KV, key string) (string, bool) { + t.Helper() + for _, kv := range kvs { + if kv.Key == key { + return kv.Value, true + } + } + return "", false +} + +func newMiddleware(t *testing.T) middleware.Middleware { + t.Helper() + mw, err := Factory{}.New(nil) + require.NoError(t, err, "factory must accept nil config") + return mw +} + +func TestMiddleware_StaticSurface(t *testing.T) { + mw := newMiddleware(t) + assert.Equal(t, ID, mw.ID(), "ID must match the registered constant") + assert.Equal(t, Version, mw.Version(), "Version must match the constant") + assert.Equal(t, middleware.SlotOnRequest, mw.Slot(), "must run in the request slot") + assert.Equal(t, []string{"application/json"}, mw.AcceptedContentTypes(), "only JSON bodies are needed") + assert.False(t, mw.MutationsSupported(), "request parser never mutates") + assert.NoError(t, mw.Close(), "Close on stateless middleware is a no-op") + + keys := mw.MetadataKeys() + expected := []string{ + middleware.KeyLLMProvider, + middleware.KeyLLMModel, + middleware.KeyLLMStream, + middleware.KeyLLMRequestPromptRaw, + middleware.KeyLLMCaptureTruncated, + middleware.KeyLLMSessionID, + } + assert.Equal(t, expected, keys, "metadata key allowlist must match the spec") +} + +func TestFactory_AcceptsEmptyAndJSONConfig(t *testing.T) { + cases := [][]byte{nil, {}, []byte("null"), []byte("{}"), []byte(" ")} + for _, raw := range cases { + mw, err := Factory{}.New(raw) + require.NoError(t, err, "empty/null/object config must be accepted") + require.NotNil(t, mw, "factory must return a middleware instance") + } +} + +func TestFactory_RejectsMalformedConfig(t *testing.T) { + mw, err := Factory{}.New([]byte("{not json")) + require.Error(t, err, "malformed config must surface at construction") + assert.Nil(t, mw, "no instance is returned on error") +} + +func TestInvoke_OpenAIBufferedChatCompletion(t *testing.T) { + mw := newMiddleware(t) + body := []byte(`{"model":"gpt-4o-mini","stream":false,"messages":[{"role":"user","content":"Hello, world!"}]}`) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/chat/completions", + Body: body, + }) + require.NoError(t, err) + require.NotNil(t, out, "output must be returned") + assert.Equal(t, middleware.DecisionAllow, out.Decision, "request parser always allows") + + provider, ok := metaValue(t, out.Metadata, middleware.KeyLLMProvider) + require.True(t, ok, "provider metadata must be set") + assert.Equal(t, "openai", provider, "OpenAI provider detected from path") + + model, ok := metaValue(t, out.Metadata, middleware.KeyLLMModel) + require.True(t, ok, "model metadata must be set") + assert.Equal(t, "gpt-4o-mini", model, "model echoed from request body") + + stream, ok := metaValue(t, out.Metadata, middleware.KeyLLMStream) + require.True(t, ok, "stream metadata must be set") + assert.Equal(t, "false", stream, "buffered request reports stream=false") + + prompt, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPromptRaw) + require.True(t, ok, "prompt metadata must be set when extractable") + assert.Contains(t, prompt, "Hello, world!", "extracted prompt carries the user message") + + truncated, ok := metaValue(t, out.Metadata, middleware.KeyLLMCaptureTruncated) + require.True(t, ok, "capture_truncated must always be emitted on success") + assert.Equal(t, "false", truncated, "no truncation on a small body") +} + +func TestInvoke_EmitsSessionID(t *testing.T) { + mw := newMiddleware(t) + + t.Run("codex session from client_metadata", func(t *testing.T) { + body := []byte(`{"model":"gpt-5.5","client_metadata":{"session_id":"sess-codex-1"},"input":[]}`) + out, err := mw.Invoke(context.Background(), &middleware.Input{URL: "/v1/responses", Body: body}) + require.NoError(t, err) + sid, ok := metaValue(t, out.Metadata, middleware.KeyLLMSessionID) + require.True(t, ok, "session id must be emitted for Codex requests") + assert.Equal(t, "sess-codex-1", sid, "session id must come from client_metadata.session_id") + }) + + t.Run("no session id key when absent", func(t *testing.T) { + body := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}`) + out, err := mw.Invoke(context.Background(), &middleware.Input{URL: "/v1/chat/completions", Body: body}) + require.NoError(t, err) + _, ok := metaValue(t, out.Metadata, middleware.KeyLLMSessionID) + assert.False(t, ok, "no session id key emitted when the request carries none") + }) + + t.Run("claude code session header", func(t *testing.T) { + body := []byte(`{"model":"claude-opus-4-8","messages":[{"role":"user","content":"hi"}]}`) + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/messages", + Body: body, + Headers: []middleware.KV{{Key: "X-Claude-Code-Session-Id", Value: "cc-sess-1"}}, + }) + require.NoError(t, err) + sid, ok := metaValue(t, out.Metadata, middleware.KeyLLMSessionID) + require.True(t, ok, "Claude Code session id must be read from X-Claude-Code-Session-Id") + assert.Equal(t, "cc-sess-1", sid, "session id must come from the Claude Code session header") + }) + + t.Run("codex Session-Id header", func(t *testing.T) { + // Codex sends the session as the canonical header "Session-Id". + body := []byte(`{"model":"gpt-5.5","input":[]}`) + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/responses", + Body: body, + Headers: []middleware.KV{{Key: "Session-Id", Value: "sess-hdr-1"}}, + }) + require.NoError(t, err) + sid, ok := metaValue(t, out.Metadata, middleware.KeyLLMSessionID) + require.True(t, ok, "session id must be read from the Session-Id header") + assert.Equal(t, "sess-hdr-1", sid, "session id must come from the Codex Session-Id header") + }) + + t.Run("header wins over body and survives bypassed body", func(t *testing.T) { + // Oversized request: body was bypassed to a routing stub with no + // client_metadata, but the header still carries the session. + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/responses", + Body: []byte(`{"model":"gpt-5.5","stream":true}`), + Headers: []middleware.KV{{Key: "X-Session-Id", Value: "sess-hdr-2"}}, + }) + require.NoError(t, err) + sid, _ := metaValue(t, out.Metadata, middleware.KeyLLMSessionID) + assert.Equal(t, "sess-hdr-2", sid, "x-session-id header must be honoured when the body carries no marker") + }) +} + +func TestInvoke_OpenAIStreamingChatCompletion(t *testing.T) { + mw := newMiddleware(t) + body := []byte(`{"model":"gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"hi"}]}`) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/chat/completions", + Body: body, + }) + require.NoError(t, err) + + stream, ok := metaValue(t, out.Metadata, middleware.KeyLLMStream) + require.True(t, ok, "stream metadata must be set") + assert.Equal(t, "true", stream, "stream flag echoed for SSE-bound request") +} + +func TestInvoke_AnthropicMessages(t *testing.T) { + mw := newMiddleware(t) + body := []byte(`{"model":"claude-sonnet-4-5","stream":false,"messages":[{"role":"user","content":"What is the weather?"}]}`) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/messages", + Body: body, + }) + require.NoError(t, err) + + provider, ok := metaValue(t, out.Metadata, middleware.KeyLLMProvider) + require.True(t, ok, "provider metadata must be set") + assert.Equal(t, "anthropic", provider, "Anthropic provider detected from path") + + model, _ := metaValue(t, out.Metadata, middleware.KeyLLMModel) + assert.Equal(t, "claude-sonnet-4-5", model, "anthropic model echoed") + + prompt, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPromptRaw) + require.True(t, ok, "prompt metadata must be set") + assert.Contains(t, prompt, "What is the weather?", "anthropic message text extracted") +} + +func TestInvoke_UnknownURLNoMetadata(t *testing.T) { + mw := newMiddleware(t) + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/healthz", + Body: []byte(`{"model":"x"}`), + }) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "unknown paths still allow") + assert.Empty(t, out.Metadata, "no metadata is emitted when no parser matches") +} + +func TestInvoke_ProviderIDConfigBypassesURLSniff(t *testing.T) { + mw, err := Factory{}.New([]byte(`{"provider_id":"openai"}`)) + require.NoError(t, err, "factory must accept provider_id config") + + // URL doesn't match any of the OpenAI path hints — the provider_id + // config is the only signal the middleware has. + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/custom/gateway/foo/bar", + Body: []byte(`{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Hi"}]}`), + }) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision) + + provider, ok := metaValue(t, out.Metadata, middleware.KeyLLMProvider) + require.True(t, ok, "provider must be emitted when provider_id is configured even on unknown URLs") + assert.Equal(t, "openai", provider, "provider_id config selects the OpenAI parser") + + model, ok := metaValue(t, out.Metadata, middleware.KeyLLMModel) + require.True(t, ok, "model still extracted from the body") + assert.Equal(t, "gpt-4o-mini", model) +} + +func TestInvoke_UnknownProviderIDFallsBackToURL(t *testing.T) { + mw, err := Factory{}.New([]byte(`{"provider_id":"not-a-real-parser"}`)) + require.NoError(t, err, "factory must accept any provider_id string") + + // URL hits the OpenAI surface, so URL sniffing should still resolve + // even though the configured provider_id doesn't match a parser. + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/chat/completions", + Body: []byte(`{"model":"gpt-4o-mini"}`), + }) + require.NoError(t, err) + require.NotNil(t, out) + + provider, ok := metaValue(t, out.Metadata, middleware.KeyLLMProvider) + require.True(t, ok, "fallback URL sniffing must populate the provider") + assert.Equal(t, "openai", provider) +} + +func TestInvoke_MalformedBodyAllowsWithProvider(t *testing.T) { + mw := newMiddleware(t) + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/chat/completions", + Body: []byte(`{not json`), + }) + require.NoError(t, err, "malformed body must not error") + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "decision is always allow") + + provider, ok := metaValue(t, out.Metadata, middleware.KeyLLMProvider) + require.True(t, ok, "provider metadata is emitted before body parse") + assert.Equal(t, "openai", provider, "provider stays even when body parse fails") + + _, hasModel := metaValue(t, out.Metadata, middleware.KeyLLMModel) + assert.False(t, hasModel, "no model metadata when parse fails") + + truncated, ok := metaValue(t, out.Metadata, middleware.KeyLLMCaptureTruncated) + require.True(t, ok, "capture_truncated is emitted on parse error path") + assert.Equal(t, "false", truncated, "no truncation marker without truncated body or prompt") +} + +func TestInvoke_TruncatesLongPrompt(t *testing.T) { + mw := newMiddleware(t) + long := strings.Repeat("x", maxPromptBytes*2) + body := []byte(`{"model":"gpt-4o-mini","messages":[{"role":"user","content":"` + long + `"}]}`) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/chat/completions", + Body: body, + }) + require.NoError(t, err) + + prompt, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPromptRaw) + require.True(t, ok, "prompt metadata must be set") + assert.LessOrEqual(t, len(prompt), maxPromptBytes, "prompt must respect the byte budget") + + truncated, ok := metaValue(t, out.Metadata, middleware.KeyLLMCaptureTruncated) + require.True(t, ok, "capture_truncated must be set") + assert.Equal(t, "true", truncated, "truncation marker raised when prompt is clipped") +} + +func TestInvoke_TruncatesOnRuneBoundary(t *testing.T) { + mw := newMiddleware(t) + // Each ☃ is 3 bytes in UTF-8; build a string whose byte length exceeds + // maxPromptBytes with snowmen straddling the cut point. + rune3 := "☃" + repeats := (maxPromptBytes / len(rune3)) + 5 + long := strings.Repeat(rune3, repeats) + body := []byte(`{"model":"gpt-4o-mini","messages":[{"role":"user","content":"` + long + `"}]}`) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/chat/completions", + Body: body, + }) + require.NoError(t, err) + + prompt, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPromptRaw) + require.True(t, ok, "prompt metadata must be set") + assert.LessOrEqual(t, len(prompt), maxPromptBytes, "prompt must respect the byte budget") + assert.True(t, strings.HasSuffix(prompt, rune3) || !strings.ContainsRune(prompt[len(prompt)-1:], 0xFFFD), + "truncation must not split a multi-byte rune") +} + +func TestInvoke_BodyTruncatedRaisesCaptureTruncated(t *testing.T) { + mw := newMiddleware(t) + body := []byte(`{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}`) + + out, err := mw.Invoke(context.Background(), &middleware.Input{ + URL: "/v1/chat/completions", + Body: body, + BodyTruncated: true, + }) + require.NoError(t, err) + + truncated, ok := metaValue(t, out.Metadata, middleware.KeyLLMCaptureTruncated) + require.True(t, ok, "capture_truncated must be set") + assert.Equal(t, "true", truncated, "BodyTruncated input flips the marker even when prompt fits") +} + +// TestInvoke_RedactPii_RedactsBeforeEmittingRawPrompt covers the GC contract: +// when the synthesiser sets redact_pii=true on the parser config, the value +// emitted as llm.request_prompt_raw must already be redacted, so the +// access-log row never carries raw emails / SSNs / phones — even though the +// downstream llm_guardrail middleware also runs. +func TestInvoke_RedactPii_RedactsBeforeEmittingRawPrompt(t *testing.T) { + mw, err := Factory{}.New([]byte(`{"redact_pii":true}`)) + require.NoError(t, err) + + body := []byte(`{"model":"gpt-4o-mini","stream":false,"messages":[{"role":"user","content":"contact alice.johnson@example.com SSN 123-45-6789 phone (202) 555-0147 and bob 202/555/0108"}]}`) + out, err := mw.Invoke(context.Background(), &middleware.Input{URL: "/v1/chat/completions", Body: body}) + require.NoError(t, err) + require.NotNil(t, out) + + raw, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPromptRaw) + require.True(t, ok, "raw prompt key must still be emitted") + assert.Contains(t, raw, "[REDACTED:email]", "email must be redacted before emit") + assert.Contains(t, raw, "[REDACTED:ssn]", "ssn must be redacted before emit") + assert.Contains(t, raw, "[REDACTED:phone]", "phone must be redacted before emit") + assert.NotContains(t, raw, "alice.johnson@example.com", "raw email must not survive") + assert.NotContains(t, raw, "123-45-6789", "raw SSN must not survive") + assert.NotContains(t, raw, "(202) 555-0147", "parenthesised phone must not survive") + assert.NotContains(t, raw, "202/555/0108", "slash-separated phone must not survive") +} + +// TestInvoke_CapturePromptOff_DoesNotEmitRawPrompt covers the contract for +// the account-level enable_prompt_collection toggle: when the synthesiser sets +// capture_prompt=false (operator hasn't opted in to prompt content), the +// parser MUST NOT emit llm.request_prompt_raw at all — otherwise the access +// log carries the user's input even though log collection is meant to be +// metadata-only (provider, model, tokens, cost). The other facts the parser +// emits (provider, model, stream, capture_truncated) stay. +func TestInvoke_CapturePromptOff_DoesNotEmitRawPrompt(t *testing.T) { + mw, err := Factory{}.New([]byte(`{"capture_prompt":false}`)) + require.NoError(t, err) + body := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"contact alice@example.com SSN 123-45-6789"}]}`) + out, err := mw.Invoke(context.Background(), &middleware.Input{URL: "/v1/chat/completions", Body: body}) + require.NoError(t, err) + require.NotNil(t, out) + + _, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPromptRaw) + assert.False(t, ok, "llm.request_prompt_raw must NOT be emitted when capture_prompt is false") + // Non-content facts must still flow. + _, ok = metaValue(t, out.Metadata, middleware.KeyLLMModel) + assert.True(t, ok, "model fact must still be emitted") + _, ok = metaValue(t, out.Metadata, middleware.KeyLLMProvider) + assert.True(t, ok, "provider fact must still be emitted") +} + +// TestInvoke_CapturePromptUnset_PreservesLegacyEmission documents the default +// behavior: an empty / legacy config (no capture_prompt field) keeps the +// existing emission, so non-agent-network callers and pre-toggle tests don't +// suddenly lose data. +func TestInvoke_CapturePromptUnset_PreservesLegacyEmission(t *testing.T) { + mw, err := Factory{}.New([]byte(`{}`)) + require.NoError(t, err) + body := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hello"}]}`) + out, err := mw.Invoke(context.Background(), &middleware.Input{URL: "/v1/chat/completions", Body: body}) + require.NoError(t, err) + _, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPromptRaw) + assert.True(t, ok, "absent capture_prompt must preserve emission (backwards-compatible default)") +} + +// TestInvoke_RedactPii_OffShipsRawPrompt is the inverse: when redact_pii is +// false (default) the operator opted out and the raw prompt is shipped +// verbatim, so audit / debugging consumers still get the full body. +func TestInvoke_RedactPii_OffShipsRawPrompt(t *testing.T) { + mw, err := Factory{}.New([]byte(`{}`)) + require.NoError(t, err) + + body := []byte(`{"model":"gpt-4o-mini","messages":[{"role":"user","content":"alice.johnson@example.com"}]}`) + out, err := mw.Invoke(context.Background(), &middleware.Input{URL: "/v1/chat/completions", Body: body}) + require.NoError(t, err) + + raw, ok := metaValue(t, out.Metadata, middleware.KeyLLMRequestPromptRaw) + require.True(t, ok) + assert.Contains(t, raw, "alice.johnson@example.com", "redact off → raw email passes through") + assert.NotContains(t, raw, "[REDACTED:", "redact off → no markers") +} + +func TestInvoke_NilInputAllows(t *testing.T) { + mw := newMiddleware(t) + out, err := mw.Invoke(context.Background(), nil) + require.NoError(t, err, "nil input must not panic or error") + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "nil input still allows") + assert.Empty(t, out.Metadata, "nil input emits no metadata") +} diff --git a/proxy/internal/middleware/builtin/llm_response_parser/factory.go b/proxy/internal/middleware/builtin/llm_response_parser/factory.go new file mode 100644 index 000000000..e7d634109 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_response_parser/factory.go @@ -0,0 +1,43 @@ +package llm_response_parser + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" +) + +// Factory constructs configured Middleware instances for the registry. +type Factory struct{} + +// ID returns the registry identifier. +func (Factory) ID() string { return ID } + +// New decodes RawConfig (empty / null / "{}" all accepted) and returns +// a configured Middleware. Construction never fails on a well-formed +// empty config; only structurally invalid JSON is rejected. +func (Factory) New(rawConfig []byte) (middleware.Middleware, error) { + cfg, err := decodeConfig(rawConfig) + if err != nil { + return nil, fmt.Errorf("decode config: %w", err) + } + return New(cfg), nil +} + +func decodeConfig(raw []byte) (config, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return config{}, nil + } + var cfg config + if err := json.Unmarshal(trimmed, &cfg); err != nil { + return config{}, err + } + return cfg, nil +} + +func init() { + builtin.Register(Factory{}) +} diff --git a/proxy/internal/middleware/builtin/llm_response_parser/gzip_test.go b/proxy/internal/middleware/builtin/llm_response_parser/gzip_test.go new file mode 100644 index 000000000..017153e4c --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_response_parser/gzip_test.go @@ -0,0 +1,133 @@ +package llm_response_parser + +import ( + "bytes" + "compress/flate" + "compress/gzip" + "compress/zlib" + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +// gzipBytes returns data gzip-compressed — the wire shape Anthropic +// returns when the client (Claude Code) negotiated Accept-Encoding: gzip. +func gzipBytes(t *testing.T, data []byte) []byte { + t.Helper() + var buf bytes.Buffer + w := gzip.NewWriter(&buf) + _, err := w.Write(data) + require.NoError(t, err, "gzip write must succeed") + require.NoError(t, w.Close(), "gzip close must succeed") + return buf.Bytes() +} + +// TestInvoke_AnthropicStreaming_Gzip is the regression guard for the live +// bug: Claude Code negotiates gzip, Anthropic gzips the SSE stream, the +// proxy captures the compressed bytes, and the parser must decompress +// before accumulating — otherwise token usage is silently dropped and +// cost_meter skips with missing_tokens. +func TestInvoke_AnthropicStreaming_Gzip(t *testing.T) { + m := newTestMiddleware(t) + body := gzipBytes(t, loadFixture(t, "anthropic_stream.txt")) + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{ + {Key: "Content-Type", Value: "text/event-stream; charset=utf-8"}, + {Key: "Content-Encoding", Value: "gzip"}, + }, + RespBody: body, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "anthropic"}, + {Key: middleware.KeyLLMModel, Value: "claude-opus-4-8"}, + }, + } + + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "Invoke must not error on a gzip-encoded streaming body") + + in123, ok := metaValue(out.Metadata, middleware.KeyLLMInputTokens) + require.True(t, ok, "input tokens must be emitted from a gzip SSE stream") + assert.Equal(t, "123", in123, "input tokens must survive gzip decompression") + + outTok, _ := metaValue(out.Metadata, middleware.KeyLLMOutputTokens) + assert.Equal(t, "45", outTok, "output tokens must survive gzip decompression") + + totTok, _ := metaValue(out.Metadata, middleware.KeyLLMTotalTokens) + assert.Equal(t, "168", totTok, "total tokens must survive gzip decompression") +} + +// TestInvoke_AnthropicBuffered_Gzip covers the non-streaming JSON path +// under gzip — the same decode must happen before ParseResponse. +func TestInvoke_AnthropicBuffered_Gzip(t *testing.T) { + m := newTestMiddleware(t) + body := gzipBytes(t, loadFixture(t, "anthropic_messages.json")) + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{ + {Key: "Content-Type", Value: "application/json"}, + {Key: "Content-Encoding", Value: "gzip"}, + }, + RespBody: body, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "anthropic"}, + {Key: middleware.KeyLLMModel, Value: "claude-opus-4-8"}, + }, + } + + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "Invoke must not error on a gzip-encoded buffered body") + + _, ok := metaValue(out.Metadata, middleware.KeyLLMInputTokens) + require.True(t, ok, "input tokens must be emitted from a gzip JSON body") +} + +// TestDecodeResponseBody covers the encoding matrix directly. +func TestDecodeResponseBody(t *testing.T) { + plain := []byte(`{"hello":"world"}`) + + t.Run("identity passthrough", func(t *testing.T) { + assert.Equal(t, plain, decodeResponseBody(plain, "")) + assert.Equal(t, plain, decodeResponseBody(plain, "identity")) + }) + + t.Run("gzip", func(t *testing.T) { + assert.Equal(t, plain, decodeResponseBody(gzipBytes(t, plain), "gzip")) + }) + + t.Run("gzip with multi-coding header takes outermost", func(t *testing.T) { + assert.Equal(t, plain, decodeResponseBody(gzipBytes(t, plain), "identity, gzip")) + }) + + t.Run("deflate zlib-wrapped", func(t *testing.T) { + var buf bytes.Buffer + zw := zlib.NewWriter(&buf) + _, _ = zw.Write(plain) + _ = zw.Close() + assert.Equal(t, plain, decodeResponseBody(buf.Bytes(), "deflate")) + }) + + t.Run("deflate raw flate fallback", func(t *testing.T) { + var buf bytes.Buffer + fw, _ := flate.NewWriter(&buf, flate.DefaultCompression) + _, _ = fw.Write(plain) + _ = fw.Close() + assert.Equal(t, plain, decodeResponseBody(buf.Bytes(), "deflate")) + }) + + t.Run("gzip header but not actually gzip falls back to raw", func(t *testing.T) { + assert.Equal(t, plain, decodeResponseBody(plain, "gzip")) + }) + + t.Run("unknown encoding (br) returns raw", func(t *testing.T) { + assert.Equal(t, plain, decodeResponseBody(plain, "br")) + }) +} diff --git a/proxy/internal/middleware/builtin/llm_response_parser/middleware.go b/proxy/internal/middleware/builtin/llm_response_parser/middleware.go new file mode 100644 index 000000000..a204460cb --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_response_parser/middleware.go @@ -0,0 +1,339 @@ +// Package llm_response_parser implements the SlotOnResponse middleware +// that decodes OpenAI- and Anthropic-shaped LLM responses (buffered or +// streaming) and emits token usage and completion metadata. Provider +// and model are read from the request-side metadata bag emitted by +// llm_request_parser; without that context the middleware is a no-op. +package llm_response_parser + +import ( + "bytes" + "compress/flate" + "compress/gzip" + "compress/zlib" + "context" + "io" + "strconv" + "strings" + "unicode/utf8" + + "github.com/netbirdio/netbird/proxy/internal/llm" + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail" +) + +// ID is the registry identifier for this middleware. +const ID = "llm_response_parser" + +const version = "1.0.0" + +// maxCompletionBytes is the rune-safe cap applied to the extracted +// completion text before emitting it as metadata. +const maxCompletionBytes = 3500 + +// maxDecodedBytes bounds the inflated size of a compressed response body +// so a small gzip/deflate payload can't expand into a memory blow-up. The +// captured input is already capped (per-direction body cap), so this only +// bounds the decompression ratio; the parser is best-effort and tolerates a +// truncated decode. +const maxDecodedBytes = 16 << 20 + +var ( + acceptedContentTypes = []string{"application/json", "text/event-stream"} + metadataKeys = []string{ + middleware.KeyLLMInputTokens, + middleware.KeyLLMOutputTokens, + middleware.KeyLLMTotalTokens, + middleware.KeyLLMCachedInputTokens, + middleware.KeyLLMCacheCreationTokens, + middleware.KeyLLMResponseCompletion, + } +) + +// config is the wire-side configuration for this middleware. RedactPii, when +// true, runs PII redaction on the extracted completion text BEFORE it is +// emitted as llm.response_completion — keeping the access-log row free of +// emails / SSNs / phone numbers the model itself generated. CaptureCompletion +// gates emission of the completion key entirely: a nil pointer preserves +// legacy emission (so callers without the toggle aren't broken), an explicit +// false suppresses the key so the access-log row carries token / cost facts +// only. Both are sourced by the synthesiser from the account's redact_pii +// and enable_prompt_collection toggles respectively. +type config struct { + RedactPii bool `json:"redact_pii,omitempty"` + CaptureCompletion *bool `json:"capture_completion,omitempty"` +} + +// Middleware implements middleware.Middleware. +type Middleware struct { + parsers []llm.Parser + redactPii bool + captureCompletion bool +} + +// New constructs a configured Middleware instance. +func New(cfg config) *Middleware { + capture := true + if cfg.CaptureCompletion != nil { + capture = *cfg.CaptureCompletion + } + return &Middleware{parsers: llm.Parsers(), redactPii: cfg.RedactPii, captureCompletion: capture} +} + +// ID returns the registry identifier. +func (m *Middleware) ID() string { return ID } + +// Version returns the implementation version. +func (m *Middleware) Version() string { return version } + +// Slot reports that the middleware runs after the upstream call. +func (m *Middleware) Slot() middleware.Slot { return middleware.SlotOnResponse } + +// AcceptedContentTypes lists the response content types the middleware +// inspects. +func (m *Middleware) AcceptedContentTypes() []string { + return append([]string(nil), acceptedContentTypes...) +} + +// MetadataKeys returns the closed allowlist of keys this middleware +// may emit. +func (m *Middleware) MetadataKeys() []string { + return append([]string(nil), metadataKeys...) +} + +// MutationsSupported reports that this middleware never mutates the +// response. +func (m *Middleware) MutationsSupported() bool { return false } + +// Close releases any resources held by the middleware. The parser-set +// is stateless so this is a no-op. +func (m *Middleware) Close() error { return nil } + +// Invoke decodes the response body and emits token-usage and completion +// metadata. The decision is always DecisionAllow; parse errors degrade +// silently to omitted metadata rather than chain failures. +func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) { + out := &middleware.Output{Decision: middleware.DecisionAllow} + if in == nil { + return out, nil + } + + provider := lookupKV(in.Metadata, middleware.KeyLLMProvider) + if provider == "" { + return out, nil + } + + parser := m.parserByName(provider) + if parser == nil { + return out, nil + } + + // Upstreams compress the response when the client negotiated it + // (Claude Code sends Accept-Encoding: gzip). The transport leaves it + // compressed because the request carried an explicit Accept-Encoding, + // so the captured copy is gzip/deflate bytes — decompress it before + // parsing or token usage is silently lost. The forwarded client + // stream is untouched; this only affects our parse copy. + body := decodeResponseBody(in.RespBody, headerLookup(in.RespHeaders, "Content-Encoding")) + + contentType := headerLookup(in.RespHeaders, "Content-Type") + switch { + case isEventStream(contentType), isAWSEventStream(contentType): + out.Metadata = m.invokeStreaming(parser, body) + case isJSON(contentType): + out.Metadata = m.invokeBuffered(parser, in, contentType, body) + } + + return out, nil +} + +// invokeBuffered decodes a non-streaming JSON response body. Status +// codes >= 400 short-circuit because providers don't include usage on +// error responses. +func (m *Middleware) invokeBuffered(parser llm.Parser, in *middleware.Input, contentType string, body []byte) []middleware.KV { + if in.Status >= 400 { + return nil + } + + var md []middleware.KV + + usage, err := parser.ParseResponse(in.Status, contentType, body) + if err == nil { + md = appendUsage(md, usage) + } + + if completion := truncateCompletion(parser.ExtractCompletion(in.Status, contentType, body)); completion != "" && m.captureCompletion { + if m.redactPii { + completion = llm_guardrail.RedactPII(completion) + } + md = append(md, middleware.KV{Key: middleware.KeyLLMResponseCompletion, Value: completion}) + } + + return md +} + +// invokeStreaming walks the buffered SSE prefix and accumulates token +// deltas plus completion text. Truncated bodies are processed +// best-effort; partial usage is preferred over no metadata. +func (m *Middleware) invokeStreaming(parser llm.Parser, body []byte) []middleware.KV { + if len(body) == 0 { + return nil + } + + usage, completion := accumulateStream(parser.ProviderName(), body) + + var md []middleware.KV + if usage.InputTokens > 0 || usage.OutputTokens > 0 || usage.TotalTokens > 0 { + md = appendUsage(md, usage) + } + if c := truncateCompletion(completion); c != "" && m.captureCompletion { + if m.redactPii { + c = llm_guardrail.RedactPII(c) + } + md = append(md, middleware.KV{Key: middleware.KeyLLMResponseCompletion, Value: c}) + } + return md +} + +// parserByName returns the parser matching the provider label emitted +// by llm_request_parser, or nil when none claims it. +func (m *Middleware) parserByName(name string) llm.Parser { + for _, p := range m.parsers { + if p.ProviderName() == name { + return p + } + } + return nil +} + +// appendUsage emits the three baseline token-count metadata keys plus +// optional cached / cache-creation bucket counts when nonzero. Total +// is computed when the provider omitted one but reported per-direction +// counts; cache buckets are excluded from the legacy total because +// llm.input_tokens already absorbs the OpenAI cached subset and the +// sum-of-everything is a separate downstream concern. +func appendUsage(md []middleware.KV, usage llm.Usage) []middleware.KV { + total := usage.TotalTokens + if total == 0 && (usage.InputTokens > 0 || usage.OutputTokens > 0) { + total = usage.InputTokens + usage.OutputTokens + } + md = append(md, + middleware.KV{Key: middleware.KeyLLMInputTokens, Value: strconv.FormatInt(usage.InputTokens, 10)}, + middleware.KV{Key: middleware.KeyLLMOutputTokens, Value: strconv.FormatInt(usage.OutputTokens, 10)}, + middleware.KV{Key: middleware.KeyLLMTotalTokens, Value: strconv.FormatInt(total, 10)}, + ) + if usage.CachedInputTokens > 0 { + md = append(md, middleware.KV{ + Key: middleware.KeyLLMCachedInputTokens, + Value: strconv.FormatInt(usage.CachedInputTokens, 10), + }) + } + if usage.CacheCreationTokens > 0 { + md = append(md, middleware.KV{ + Key: middleware.KeyLLMCacheCreationTokens, + Value: strconv.FormatInt(usage.CacheCreationTokens, 10), + }) + } + return md +} + +// truncateCompletion clamps an extracted completion to maxCompletionBytes. +// The cut is rune-safe so we never split a multi-byte UTF-8 sequence. +func truncateCompletion(s string) string { + if len(s) <= maxCompletionBytes { + return s + } + cut := maxCompletionBytes + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + return s[:cut] +} + +func lookupKV(kvs []middleware.KV, key string) string { + for _, kv := range kvs { + if kv.Key == key { + return kv.Value + } + } + return "" +} + +func headerLookup(h []middleware.KV, name string) string { + lower := strings.ToLower(name) + for _, kv := range h { + if strings.ToLower(kv.Key) == lower { + return kv.Value + } + } + return "" +} + +func isEventStream(contentType string) bool { + return strings.Contains(strings.ToLower(contentType), "text/event-stream") +} + +// isAWSEventStream reports whether contentType is the AWS binary event-stream +// framing used by Bedrock's streaming endpoints. +func isAWSEventStream(contentType string) bool { + return strings.Contains(strings.ToLower(contentType), "application/vnd.amazon.eventstream") +} + +func isJSON(contentType string) bool { + lower := strings.ToLower(contentType) + return strings.Contains(lower, "application/json") || strings.Contains(lower, "+json") +} + +// decodeResponseBody returns body decompressed per its Content-Encoding, +// or the original bytes when the encoding is identity, unrecognised +// (e.g. br — no stdlib decoder), or the body isn't actually compressed. +// Decoding is best-effort: a truncated stream (capture hit the byte cap) +// yields the decompressed prefix rather than an error, which is enough to +// recover the leading message_start usage on Anthropic SSE. +func decodeResponseBody(body []byte, contentEncoding string) []byte { + enc := strings.ToLower(strings.TrimSpace(contentEncoding)) + // Content-Encoding may list multiple codings; the last applied is + // the outermost on the wire. + if idx := strings.LastIndex(enc, ","); idx >= 0 { + enc = strings.TrimSpace(enc[idx+1:]) + } + switch enc { + case "", "identity": + return body + case "gzip", "x-gzip": + zr, err := gzip.NewReader(bytes.NewReader(body)) + if err != nil { + return body + } + defer zr.Close() + if out := readCapped(zr); len(out) > 0 { + return out + } + return body + case "deflate": + // "deflate" on the wire is usually zlib-wrapped; fall back to raw + // flate when there's no zlib header. + if zr, err := zlib.NewReader(bytes.NewReader(body)); err == nil { + defer zr.Close() + if out := readCapped(zr); len(out) > 0 { + return out + } + return body + } + fr := flate.NewReader(bytes.NewReader(body)) + defer fr.Close() + if out := readCapped(fr); len(out) > 0 { + return out + } + return body + default: + return body + } +} + +// readCapped reads at most maxDecodedBytes from r, discarding any excess. +// Best-effort: a read error returns whatever was decoded so far, which is +// enough for the parser to recover leading usage events. +func readCapped(r io.Reader) []byte { + out, _ := io.ReadAll(io.LimitReader(r, maxDecodedBytes)) + return out +} diff --git a/proxy/internal/middleware/builtin/llm_response_parser/middleware_test.go b/proxy/internal/middleware/builtin/llm_response_parser/middleware_test.go new file mode 100644 index 000000000..084118802 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_response_parser/middleware_test.go @@ -0,0 +1,433 @@ +package llm_response_parser + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +func loadFixture(t *testing.T, name string) []byte { + t.Helper() + root, err := os.Getwd() + require.NoError(t, err, "must resolve cwd to locate fixture") + + dir := root + for i := 0; i < 8; i++ { + candidate := filepath.Join(dir, "proxy", "internal", "llm", "fixtures", name) + if data, err := os.ReadFile(candidate); err == nil { + return data + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + t.Fatalf("fixture %q not found relative to %q", name, root) + return nil +} + +func metaValue(kvs []middleware.KV, key string) (string, bool) { + for _, kv := range kvs { + if kv.Key == key { + return kv.Value, true + } + } + return "", false +} + +func newTestMiddleware(t *testing.T) *Middleware { + t.Helper() + mw, err := Factory{}.New(nil) + require.NoError(t, err, "factory must accept empty config") + concrete, ok := mw.(*Middleware) + require.True(t, ok, "factory must return *Middleware") + return concrete +} + +func TestMiddleware_StaticSurface(t *testing.T) { + m := newTestMiddleware(t) + assert.Equal(t, ID, m.ID(), "ID must match registry constant") + assert.Equal(t, "1.0.0", m.Version(), "Version must be 1.0.0") + assert.Equal(t, middleware.SlotOnResponse, m.Slot(), "Slot must be SlotOnResponse") + assert.False(t, m.MutationsSupported(), "response parser does not mutate") + assert.ElementsMatch(t, + []string{"application/json", "text/event-stream"}, + m.AcceptedContentTypes(), + "AcceptedContentTypes must list JSON and SSE", + ) + assert.ElementsMatch(t, + []string{ + middleware.KeyLLMInputTokens, + middleware.KeyLLMOutputTokens, + middleware.KeyLLMTotalTokens, + middleware.KeyLLMCachedInputTokens, + middleware.KeyLLMCacheCreationTokens, + middleware.KeyLLMResponseCompletion, + }, + m.MetadataKeys(), + "MetadataKeys must be the documented response-side keys, including the optional cache buckets emitted only when nonzero", + ) + require.NoError(t, m.Close(), "Close must be a no-op") +} + +func TestFactory_AcceptsEmptyAndNullConfig(t *testing.T) { + for name, raw := range map[string][]byte{ + "nil": nil, + "empty": {}, + "null": []byte("null"), + "obj": []byte("{}"), + "ws": []byte(" "), + } { + t.Run(name, func(t *testing.T) { + mw, err := Factory{}.New(raw) + require.NoError(t, err, "factory must accept %s config", name) + require.NotNil(t, mw, "factory must return middleware for %s", name) + }) + } +} + +func TestFactory_RejectsMalformedJSON(t *testing.T) { + _, err := Factory{}.New([]byte("not-json")) + require.Error(t, err, "malformed config must surface a decode error") +} + +func TestInvoke_OpenAIBuffered(t *testing.T) { + m := newTestMiddleware(t) + body := loadFixture(t, "openai_chat_completion.json") + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: body, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o-mini"}, + }, + } + + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "Invoke must not error on a valid buffered response") + require.Equal(t, middleware.DecisionAllow, out.Decision, "decision must be Allow") + + in123, ok := metaValue(out.Metadata, middleware.KeyLLMInputTokens) + require.True(t, ok, "input tokens must be emitted") + assert.Equal(t, "123", in123, "input tokens must match fixture prompt_tokens") + + outTok, ok := metaValue(out.Metadata, middleware.KeyLLMOutputTokens) + require.True(t, ok, "output tokens must be emitted") + assert.Equal(t, "45", outTok, "output tokens must match fixture completion_tokens") + + totTok, ok := metaValue(out.Metadata, middleware.KeyLLMTotalTokens) + require.True(t, ok, "total tokens must be emitted") + assert.Equal(t, "168", totTok, "total tokens must match fixture") + + completion, ok := metaValue(out.Metadata, middleware.KeyLLMResponseCompletion) + require.True(t, ok, "completion must be emitted") + assert.Equal(t, "Hello, world!", completion, "completion text must match fixture") +} + +func TestInvoke_AnthropicBuffered(t *testing.T) { + m := newTestMiddleware(t) + body := loadFixture(t, "anthropic_messages.json") + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: body, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "anthropic"}, + {Key: middleware.KeyLLMModel, Value: "claude-sonnet-4-5"}, + }, + } + + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "Invoke must not error on a valid buffered response") + require.Equal(t, middleware.DecisionAllow, out.Decision, "decision must be Allow") + + in123, _ := metaValue(out.Metadata, middleware.KeyLLMInputTokens) + assert.Equal(t, "123", in123, "input tokens must match anthropic fixture") + + outTok, _ := metaValue(out.Metadata, middleware.KeyLLMOutputTokens) + assert.Equal(t, "45", outTok, "output tokens must match anthropic fixture") + + totTok, _ := metaValue(out.Metadata, middleware.KeyLLMTotalTokens) + assert.Equal(t, "168", totTok, "total tokens must be input+output for anthropic") + + completion, ok := metaValue(out.Metadata, middleware.KeyLLMResponseCompletion) + require.True(t, ok, "completion must be emitted for anthropic") + assert.Equal(t, "Hello, world!", completion, "completion text must match fixture") +} + +// TestInvoke_OpenAICachedTokensSurfaceOnMetadata covers the +// end-to-end path from the JSON usage block to the +// llm.cached_input_tokens metadata key the cost meter consumes. +// llm.cache_creation_tokens is NOT emitted for OpenAI because +// OpenAI has no cache_creation analogue. +func TestInvoke_OpenAICachedTokensSurfaceOnMetadata(t *testing.T) { + m := newTestMiddleware(t) + body := []byte(`{"usage":{"prompt_tokens":1024,"completion_tokens":200,"total_tokens":1224,"prompt_tokens_details":{"cached_tokens":768}}}`) + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: body, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o"}, + }, + } + + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err) + cached, ok := metaValue(out.Metadata, middleware.KeyLLMCachedInputTokens) + require.True(t, ok, "cached_input_tokens must land on the bag when the OpenAI response carries cached_tokens") + assert.Equal(t, "768", cached) + + _, hasCreation := metaValue(out.Metadata, middleware.KeyLLMCacheCreationTokens) + assert.False(t, hasCreation, "cache_creation_tokens must NOT be emitted for OpenAI — no analogue in the OpenAI shape") +} + +// TestInvoke_AnthropicCacheBucketsSurfaceOnMetadata covers the +// Anthropic shape: both cache_read and cache_creation values flow +// onto the metadata bag so the cost meter can apply per-bucket +// rates. +func TestInvoke_AnthropicCacheBucketsSurfaceOnMetadata(t *testing.T) { + m := newTestMiddleware(t) + body := []byte(`{"usage":{"input_tokens":256,"output_tokens":200,"cache_read_input_tokens":768,"cache_creation_input_tokens":512}}`) + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: body, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "anthropic"}, + {Key: middleware.KeyLLMModel, Value: "claude-sonnet-4-5"}, + }, + } + + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err) + + cached, ok := metaValue(out.Metadata, middleware.KeyLLMCachedInputTokens) + require.True(t, ok, "cache_read_input_tokens lands under cached_input_tokens — same key carries OpenAI cached subset and Anthropic cache reads, meter switches formula on provider") + assert.Equal(t, "768", cached) + + creation, ok := metaValue(out.Metadata, middleware.KeyLLMCacheCreationTokens) + require.True(t, ok, "cache_creation_input_tokens lands under cache_creation_tokens for Anthropic") + assert.Equal(t, "512", creation) +} + +func TestInvoke_NoProviderMetadata_NoOp(t *testing.T) { + m := newTestMiddleware(t) + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: loadFixture(t, "openai_chat_completion.json"), + } + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "missing provider metadata is not an error") + assert.Equal(t, middleware.DecisionAllow, out.Decision, "decision must be Allow") + assert.Empty(t, out.Metadata, "no metadata when provider context is missing") +} + +func TestInvoke_UnknownProvider_NoOp(t *testing.T) { + m := newTestMiddleware(t) + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: loadFixture(t, "openai_chat_completion.json"), + Metadata: []middleware.KV{{Key: middleware.KeyLLMProvider, Value: "cohere"}}, + } + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "unknown provider must not surface an error") + assert.Empty(t, out.Metadata, "unknown providers emit no metadata") +} + +func TestInvoke_ErrorStatus_NoUsageEmitted(t *testing.T) { + m := newTestMiddleware(t) + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 500, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: []byte(`{"error":{"message":"upstream blew up"}}`), + Metadata: []middleware.KV{{Key: middleware.KeyLLMProvider, Value: "openai"}}, + } + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "error responses must not surface as middleware error") + _, ok := metaValue(out.Metadata, middleware.KeyLLMInputTokens) + assert.False(t, ok, "no usage metadata on >=400 responses") +} + +func TestInvoke_NonInspectedContentType_NoOp(t *testing.T) { + m := newTestMiddleware(t) + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "text/plain"}}, + RespBody: []byte("not json"), + Metadata: []middleware.KV{{Key: middleware.KeyLLMProvider, Value: "openai"}}, + } + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "Invoke must tolerate non-inspected content types") + assert.Empty(t, out.Metadata, "no metadata for non-JSON, non-SSE bodies") +} + +func TestInvoke_NilInput(t *testing.T) { + m := newTestMiddleware(t) + out, err := m.Invoke(context.Background(), nil) + require.NoError(t, err, "nil input must not error") + require.Equal(t, middleware.DecisionAllow, out.Decision, "decision must be Allow even on nil input") + assert.Empty(t, out.Metadata, "no metadata for nil input") +} + +func TestInvoke_CompletionTruncatedAt3500Bytes(t *testing.T) { + m := newTestMiddleware(t) + long := strings.Repeat("x", 5000) + body := []byte(`{"id":"x","choices":[{"message":{"role":"assistant","content":"` + long + `"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`) + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: body, + Metadata: []middleware.KV{{Key: middleware.KeyLLMProvider, Value: "openai"}}, + } + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "long-completion body must parse cleanly") + + completion, ok := metaValue(out.Metadata, middleware.KeyLLMResponseCompletion) + require.True(t, ok, "completion must be emitted for long body") + assert.LessOrEqual(t, len(completion), maxCompletionBytes, "completion must be truncated to <=3500 bytes") + assert.Equal(t, maxCompletionBytes, len(completion), "completion must be truncated exactly at the cap when input is ASCII and longer") +} + +// TestInvoke_RedactPii_RedactsCompletionBeforeEmit covers the GC contract on +// the response leg: when the synthesiser sets redact_pii=true, the value +// emitted as llm.response_completion must already be redacted, so the +// access-log row never carries raw emails / SSNs / phones the model generated. +// Without this, the response side leaked dozens of raw PII tokens per request. +func TestInvoke_RedactPii_RedactsCompletionBeforeEmit(t *testing.T) { + mw, err := Factory{}.New([]byte(`{"redact_pii":true}`)) + require.NoError(t, err) + + piiCompletion := "Sample record: Alice Johnson, alice.johnson@example.com, SSN 123-45-6789, phone (202) 555-0147. Bob: 202/555/0108." + body := []byte(`{"id":"x","choices":[{"message":{"role":"assistant","content":"` + piiCompletion + `"}}],"usage":{"prompt_tokens":10,"completion_tokens":50,"total_tokens":60}}`) + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: body, + Metadata: []middleware.KV{{Key: middleware.KeyLLMProvider, Value: "openai"}}, + } + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + + completion, ok := metaValue(out.Metadata, middleware.KeyLLMResponseCompletion) + require.True(t, ok, "completion key must be emitted") + assert.Contains(t, completion, "[REDACTED:email]", "email must be redacted before emit") + assert.Contains(t, completion, "[REDACTED:ssn]", "ssn must be redacted before emit") + assert.Contains(t, completion, "[REDACTED:phone]", "phone must be redacted before emit") + assert.NotContains(t, completion, "alice.johnson@example.com", "raw email must not survive") + assert.NotContains(t, completion, "123-45-6789", "raw SSN must not survive") + assert.NotContains(t, completion, "(202) 555-0147", "parens-phone must not survive") + assert.NotContains(t, completion, "202/555/0108", "slash-phone must not survive") +} + +// TestInvoke_CaptureCompletionOff_DoesNotEmitCompletion mirrors the request +// parser test: when capture_completion=false (operator has enable_prompt_ +// collection off), llm.response_completion MUST NOT appear in the access log. +// The token / cost / usage facts the response parser also emits stay so +// operators still get billing data on log-only mode. +func TestInvoke_CaptureCompletionOff_DoesNotEmitCompletion(t *testing.T) { + mw, err := Factory{}.New([]byte(`{"capture_completion":false}`)) + require.NoError(t, err) + body := []byte(`{"id":"x","choices":[{"message":{"role":"assistant","content":"alice@example.com 123-45-6789"}}],"usage":{"prompt_tokens":10,"completion_tokens":20,"total_tokens":30}}`) + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: body, + Metadata: []middleware.KV{{Key: middleware.KeyLLMProvider, Value: "openai"}}, + } + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + + _, ok := metaValue(out.Metadata, middleware.KeyLLMResponseCompletion) + assert.False(t, ok, "llm.response_completion must NOT be emitted when capture_completion is false") + + // Token facts must still flow. + _, ok = metaValue(out.Metadata, middleware.KeyLLMInputTokens) + assert.True(t, ok, "input tokens fact must still be emitted") + _, ok = metaValue(out.Metadata, middleware.KeyLLMOutputTokens) + assert.True(t, ok, "output tokens fact must still be emitted") +} + +// TestInvoke_CaptureCompletionUnset_PreservesLegacyEmission documents the +// default behavior: empty config keeps emitting completion, so callers +// without the toggle aren't broken. +func TestInvoke_CaptureCompletionUnset_PreservesLegacyEmission(t *testing.T) { + mw, err := Factory{}.New([]byte(`{}`)) + require.NoError(t, err) + body := []byte(`{"id":"x","choices":[{"message":{"role":"assistant","content":"hello"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`) + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: body, + Metadata: []middleware.KV{{Key: middleware.KeyLLMProvider, Value: "openai"}}, + } + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + _, ok := metaValue(out.Metadata, middleware.KeyLLMResponseCompletion) + assert.True(t, ok, "absent capture_completion must preserve emission (backwards-compatible default)") +} + +// TestInvoke_RedactPii_OffShipsRawCompletion covers the inverse: with +// redact_pii=false (default) the model output is shipped verbatim. +func TestInvoke_RedactPii_OffShipsRawCompletion(t *testing.T) { + mw, err := Factory{}.New(nil) + require.NoError(t, err) + + body := []byte(`{"id":"x","choices":[{"message":{"role":"assistant","content":"alice@example.com 123-45-6789"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`) + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "application/json"}}, + RespBody: body, + Metadata: []middleware.KV{{Key: middleware.KeyLLMProvider, Value: "openai"}}, + } + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + + completion, ok := metaValue(out.Metadata, middleware.KeyLLMResponseCompletion) + require.True(t, ok) + assert.Contains(t, completion, "alice@example.com", "redact off → raw email passes through") + assert.Contains(t, completion, "123-45-6789", "redact off → raw SSN passes through") + assert.NotContains(t, completion, "[REDACTED:", "redact off → no markers") +} + +func TestInvoke_CompletionTruncationRuneSafe(t *testing.T) { + rune4 := "\xf0\x9f\x98\x80" // 4-byte emoji + body := strings.Repeat("a", maxCompletionBytes-1) + rune4 + require.Greater(t, len(body), maxCompletionBytes, "test setup must exceed the cap") + + got := truncateCompletion(body) + assert.True(t, len(got) < maxCompletionBytes, "truncated bytes must drop the partial rune entirely") + assert.NotContains(t, got, "\x80", "truncated text must not end on a continuation byte") +} diff --git a/proxy/internal/middleware/builtin/llm_response_parser/responses_stream_test.go b/proxy/internal/middleware/builtin/llm_response_parser/responses_stream_test.go new file mode 100644 index 000000000..0475b8cca --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_response_parser/responses_stream_test.go @@ -0,0 +1,69 @@ +package llm_response_parser + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +// TestInvoke_OpenAIResponsesStreaming is the regression guard for the live +// bug where Codex hits /v1/responses (the OpenAI Responses API), whose SSE +// shape differs from chat.completions: completion text rides +// response.output_text.delta and usage rides response.completed under +// response.usage. The old parser only knew the chat.completions shape, so +// resp_meta came back empty (no tokens, no cost). +func TestInvoke_OpenAIResponsesStreaming(t *testing.T) { + m := newTestMiddleware(t) + body := loadFixture(t, "openai_responses_stream.txt") + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "text/event-stream; charset=utf-8"}}, + RespBody: body, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-5.5"}, + }, + } + + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "Invoke must not error on a Responses-API streaming body") + + inTok, ok := metaValue(out.Metadata, middleware.KeyLLMInputTokens) + require.True(t, ok, "input tokens must be emitted from a Responses-API stream") + assert.Equal(t, "123", inTok, "input_tokens must come from response.completed usage") + + outTok, _ := metaValue(out.Metadata, middleware.KeyLLMOutputTokens) + assert.Equal(t, "45", outTok, "output_tokens must come from response.completed usage") + + totTok, _ := metaValue(out.Metadata, middleware.KeyLLMTotalTokens) + assert.Equal(t, "168", totTok, "total_tokens must come from response.completed usage") + + cached, ok := metaValue(out.Metadata, middleware.KeyLLMCachedInputTokens) + require.True(t, ok, "cached input tokens must surface from input_tokens_details") + assert.Equal(t, "40", cached, "cached_tokens subset must surface for cost discounting") + + completion, ok := metaValue(out.Metadata, middleware.KeyLLMResponseCompletion) + require.True(t, ok, "completion must be emitted for Responses-API streams") + assert.Equal(t, "Hello, world!", completion, "output_text.delta events must concatenate") +} + +// TestAccumulateOpenAIStream_ResponsesNoUsage confirms that a Responses-API +// stream with text but no terminal usage frame still yields the completion +// and leaves tokens at zero rather than erroring. +func TestAccumulateOpenAIStream_ResponsesNoUsage(t *testing.T) { + body := []byte(`event: response.output_text.delta +data: {"type":"response.output_text.delta","delta":"partial"} + +`) + + usage, completion := accumulateOpenAIStream(body) + assert.Equal(t, int64(0), usage.InputTokens, "no usage frame leaves input tokens at zero") + assert.Equal(t, int64(0), usage.OutputTokens, "no usage frame leaves output tokens at zero") + assert.Equal(t, "partial", completion, "output_text deltas accumulate even without a usage frame") +} diff --git a/proxy/internal/middleware/builtin/llm_response_parser/streaming.go b/proxy/internal/middleware/builtin/llm_response_parser/streaming.go new file mode 100644 index 000000000..6462ab4ae --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_response_parser/streaming.go @@ -0,0 +1,270 @@ +package llm_response_parser + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "strings" + + "github.com/netbirdio/netbird/proxy/internal/llm" +) + +// openAIDoneSentinel is the OpenAI end-of-stream marker. The scanner +// stops once this data frame is observed. +const openAIDoneSentinel = "[DONE]" + +// accumulateStream walks the SSE byte slice, dispatches per provider, +// and returns the running token-usage and concatenated completion text. +// Errors from the scanner short-circuit accumulation but never panic +// — partial results are returned for truncated bodies. +func accumulateStream(provider string, body []byte) (llm.Usage, string) { + switch provider { + case "openai": + return accumulateOpenAIStream(body) + case "anthropic": + return accumulateAnthropicStream(body) + case llm.ProviderNameBedrock: + return accumulateBedrockStream(body) + default: + return llm.Usage{}, "" + } +} + +// openAIStreamUsage is the usage block shared by both OpenAI streaming +// envelopes. Pointer fields tell "absent" from zero; the chat.completions +// (prompt_/completion_) and Responses-API (input_/output_) names are both +// accepted so a single decode covers either endpoint. +type openAIStreamUsage struct { + PromptTokens *int64 `json:"prompt_tokens"` + CompletionTokens *int64 `json:"completion_tokens"` + InputTokens *int64 `json:"input_tokens"` + OutputTokens *int64 `json:"output_tokens"` + TotalTokens *int64 `json:"total_tokens"` + PromptTokensDetails *struct { + CachedTokens *int64 `json:"cached_tokens"` + } `json:"prompt_tokens_details"` + InputTokensDetails *struct { + CachedTokens *int64 `json:"cached_tokens"` + } `json:"input_tokens_details"` +} + +// openAIStreamChunk matches both OpenAI streaming envelopes. The +// chat.completions chunk carries text in choices[].delta.content and a +// trailing top-level usage block. The Responses API (/v1/responses) emits +// typed events instead: completion text rides response.output_text.delta +// (top-level "delta" string) and the final usage rides response.completed +// under response.usage. Only fields used for accumulation are declared. +type openAIStreamChunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + Usage *openAIStreamUsage `json:"usage"` + + Type string `json:"type"` + Delta json.RawMessage `json:"delta"` + Response *struct { + Usage *openAIStreamUsage `json:"usage"` + } `json:"response"` +} + +// accumulateOpenAIStream sums per-chunk content deltas and lifts the usage +// block off the final frame, handling both the chat.completions and the +// Responses-API event shapes. Clients without stream_options.include_usage +// (chat.completions) and any provider that omits the final usage simply +// leave tokens at zero; the caller chooses what to emit. +func accumulateOpenAIStream(body []byte) (llm.Usage, string) { + var ( + usage llm.Usage + completion strings.Builder + ) + scanner := llm.NewScanner(bytes.NewReader(body)) + for { + ev, err := scanner.Next() + if err != nil { + break + } + if ev.Data == openAIDoneSentinel { + break + } + if ev.Data == "" { + continue + } + + var chunk openAIStreamChunk + if err := json.Unmarshal([]byte(ev.Data), &chunk); err != nil { + continue + } + for _, c := range chunk.Choices { + completion.WriteString(c.Delta.Content) + } + if chunk.Type == "response.output_text.delta" { + if s, ok := decodeJSONString(chunk.Delta); ok { + completion.WriteString(s) + } + } + + u := chunk.Usage + if u == nil && chunk.Response != nil { + u = chunk.Response.Usage + } + applyOpenAIStreamUsage(u, &usage) + } + return usage, completion.String() +} + +// applyOpenAIStreamUsage lifts the token counts off a final-frame usage +// block into the running usage, normalising the chat.completions +// (prompt_/completion_) and Responses-API (input_/output_) names and +// backfilling total tokens when the provider omits them. +func applyOpenAIStreamUsage(u *openAIStreamUsage, usage *llm.Usage) { + if u == nil { + return + } + usage.InputTokens = pickInt64(u.InputTokens, u.PromptTokens) + usage.OutputTokens = pickInt64(u.OutputTokens, u.CompletionTokens) + usage.TotalTokens = derefInt64(u.TotalTokens) + if u.InputTokensDetails != nil { + if v := derefInt64(u.InputTokensDetails.CachedTokens); v > 0 { + usage.CachedInputTokens = v + } + } + if usage.CachedInputTokens == 0 && u.PromptTokensDetails != nil { + usage.CachedInputTokens = derefInt64(u.PromptTokensDetails.CachedTokens) + } + if usage.TotalTokens == 0 && (usage.InputTokens > 0 || usage.OutputTokens > 0) { + usage.TotalTokens = usage.InputTokens + usage.OutputTokens + } +} + +// decodeJSONString unmarshals a JSON-encoded string value, returning +// ok=false when the raw message is empty or not a string. +func decodeJSONString(raw json.RawMessage) (string, bool) { + if len(raw) == 0 { + return "", false + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return "", false + } + return s, true +} + +// anthropicStreamEvent captures the union of Messages-API stream event +// payloads we care about. Each named event on the wire fills only its +// shape's fields; unknown keys are ignored. +type anthropicStreamUsage struct { + InputTokens *int64 `json:"input_tokens"` + OutputTokens *int64 `json:"output_tokens"` + CacheReadInputTokens *int64 `json:"cache_read_input_tokens"` + CacheCreationInputTokens *int64 `json:"cache_creation_input_tokens"` +} + +type anthropicStreamEvent struct { + Type string `json:"type"` + Message *struct { + Usage *anthropicStreamUsage `json:"usage"` + } `json:"message"` + Delta *struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"delta"` + Usage *anthropicStreamUsage `json:"usage"` +} + +// accumulateAnthropicStream tracks input_tokens from message_start, +// output_tokens from message_delta, and concatenates text_delta payloads +// from content_block_delta events. Final usage prefers message_delta +// values which carry the post-completion totals. +func accumulateAnthropicStream(body []byte) (llm.Usage, string) { + var ( + usage llm.Usage + completion strings.Builder + ) + scanner := llm.NewScanner(bytes.NewReader(body)) + for { + ev, err := scanner.Next() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + break + } + if ev.Data == "" { + continue + } + + var payload anthropicStreamEvent + if err := json.Unmarshal([]byte(ev.Data), &payload); err != nil { + continue + } + + eventType := ev.Type + if eventType == "" { + eventType = payload.Type + } + applyAnthropicStreamEvent(eventType, payload, &usage, &completion) + } + if usage.InputTokens > 0 || usage.OutputTokens > 0 { + usage.TotalTokens = usage.InputTokens + usage.OutputTokens + usage.CachedInputTokens + usage.CacheCreationTokens + } + return usage, completion.String() +} + +// applyAnthropicStreamEvent folds one parsed Anthropic Messages stream event +// into the running usage/completion. Shared by the SSE accumulator and the +// Bedrock InvokeModel event-stream, whose chunks wrap the same event JSON. +func applyAnthropicStreamEvent(eventType string, payload anthropicStreamEvent, usage *llm.Usage, completion *strings.Builder) { + switch eventType { + case "message_start": + if payload.Message != nil { + applyAnthropicStreamUsage(payload.Message.Usage, usage) + } + case "content_block_delta": + if payload.Delta != nil && payload.Delta.Type == "text_delta" { + completion.WriteString(payload.Delta.Text) + } + case "message_delta": + applyAnthropicStreamUsage(payload.Usage, usage) + case "message_stop": + // No-op; Anthropic does not emit usage here. + } +} + +// applyAnthropicStreamUsage folds a non-nil Anthropic usage block into the +// running totals. Each field overwrites only when present and positive, so +// message_delta's post-completion counts supersede the message_start seed +// without zeroing dimensions a later event omits. +func applyAnthropicStreamUsage(u *anthropicStreamUsage, usage *llm.Usage) { + if u == nil { + return + } + if v := derefInt64(u.InputTokens); v > 0 { + usage.InputTokens = v + } + if v := derefInt64(u.OutputTokens); v > 0 { + usage.OutputTokens = v + } + if v := derefInt64(u.CacheReadInputTokens); v > 0 { + usage.CachedInputTokens = v + } + if v := derefInt64(u.CacheCreationInputTokens); v > 0 { + usage.CacheCreationTokens = v + } +} + +func pickInt64(preferred, fallback *int64) int64 { + if preferred != nil { + return *preferred + } + return derefInt64(fallback) +} + +func derefInt64(v *int64) int64 { + if v == nil { + return 0 + } + return *v +} diff --git a/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock.go b/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock.go new file mode 100644 index 000000000..30809b46e --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock.go @@ -0,0 +1,119 @@ +package llm_response_parser + +import ( + "bytes" + "encoding/json" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream" + + "github.com/netbirdio/netbird/proxy/internal/llm" +) + +// bedrockEventTypeHeader names each AWS event-stream frame's event type. +const bedrockEventTypeHeader = ":event-type" + +// accumulateBedrockStream decodes the AWS binary event-stream returned by +// Bedrock's streaming endpoints and folds it into running usage/completion. +// Two framings are handled: +// - InvokeModel (invoke-with-response-stream): each "chunk" frame's payload is +// {"bytes":""} wrapping a vendor-native (Anthropic) stream event. +// - Converse (converse-stream): native frames (contentBlockDelta, metadata, …) +// whose payload JSON carries text deltas and a final usage block. +// +// A truncated stream (cut at the capture cap) decodes best-effort: frames up to +// the cut are applied and the partial usage is returned. +func accumulateBedrockStream(body []byte) (llm.Usage, string) { + var ( + usage llm.Usage + completion strings.Builder + ) + dec := eventstream.NewDecoder() + r := bytes.NewReader(body) + for { + msg, err := dec.Decode(r, nil) + if err != nil { + break // EOF or a partial trailing frame — return what we have. + } + eventType := "" + if v := msg.Headers.Get(bedrockEventTypeHeader); v != nil { + eventType = v.String() + } + if eventType == "chunk" { + applyBedrockInvokeChunk(msg.Payload, &usage, &completion) + continue + } + applyConverseStreamEvent(eventType, msg.Payload, &usage, &completion) + } + if usage.TotalTokens == 0 && (usage.InputTokens > 0 || usage.OutputTokens > 0) { + usage.TotalTokens = usage.InputTokens + usage.OutputTokens + usage.CachedInputTokens + usage.CacheCreationTokens + } + return usage, completion.String() +} + +// applyBedrockInvokeChunk decodes an InvokeModel stream "chunk" frame +// ({"bytes":""}) and folds the wrapped Anthropic event +// into usage/completion via the shared accumulator. +func applyBedrockInvokeChunk(payload []byte, usage *llm.Usage, completion *strings.Builder) { + var wrap struct { + Bytes []byte `json:"bytes"` // base64 string — encoding/json decodes it + } + if err := json.Unmarshal(payload, &wrap); err != nil || len(wrap.Bytes) == 0 { + return + } + var ev anthropicStreamEvent + if err := json.Unmarshal(wrap.Bytes, &ev); err != nil { + return + } + applyAnthropicStreamEvent(ev.Type, ev, usage, completion) +} + +// converseStreamEvent captures the Converse stream frames carrying completion +// text (contentBlockDelta) and the final token usage (metadata). Cache buckets +// are additive to inputTokens (AWS write bucket: cacheWriteInputTokens). +type converseStreamEvent struct { + Delta *struct { + Text string `json:"text"` + } `json:"delta"` + Usage *struct { + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` + TotalTokens int64 `json:"totalTokens"` + CacheReadTokens int64 `json:"cacheReadInputTokens"` + CacheWriteTokens int64 `json:"cacheWriteInputTokens"` + } `json:"usage"` +} + +// applyConverseStreamEvent folds one native Converse stream frame into the +// running usage/completion: contentBlockDelta carries assistant text, and the +// trailing metadata frame carries the final usage block. +func applyConverseStreamEvent(eventType string, payload []byte, usage *llm.Usage, completion *strings.Builder) { + var ev converseStreamEvent + if err := json.Unmarshal(payload, &ev); err != nil { + return + } + switch eventType { + case "contentBlockDelta": + if ev.Delta != nil { + completion.WriteString(ev.Delta.Text) + } + case "metadata": + if ev.Usage != nil { + if ev.Usage.InputTokens > 0 { + usage.InputTokens = ev.Usage.InputTokens + } + if ev.Usage.OutputTokens > 0 { + usage.OutputTokens = ev.Usage.OutputTokens + } + if ev.Usage.TotalTokens > 0 { + usage.TotalTokens = ev.Usage.TotalTokens + } + if ev.Usage.CacheReadTokens > 0 { + usage.CachedInputTokens = ev.Usage.CacheReadTokens + } + if ev.Usage.CacheWriteTokens > 0 { + usage.CacheCreationTokens = ev.Usage.CacheWriteTokens + } + } + } +} diff --git a/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock_test.go b/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock_test.go new file mode 100644 index 000000000..f66347591 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_response_parser/streaming_bedrock_test.go @@ -0,0 +1,92 @@ +package llm_response_parser + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream" + "github.com/stretchr/testify/require" +) + +// bedrockFrame encodes a single AWS event-stream frame with the given +// :event-type header and JSON payload, mirroring what Bedrock sends. +func bedrockFrame(t *testing.T, eventType string, payload []byte) []byte { + t.Helper() + var buf bytes.Buffer + enc := eventstream.NewEncoder() + err := enc.Encode(&buf, eventstream.Message{ + Headers: eventstream.Headers{{Name: ":event-type", Value: eventstream.StringValue(eventType)}}, + Payload: payload, + }) + require.NoError(t, err, "encode event-stream frame") + return buf.Bytes() +} + +func mustJSON(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + require.NoError(t, err) + return b +} + +func TestAccumulateBedrockStream_Invoke(t *testing.T) { + // invoke-with-response-stream: each "chunk" frame wraps a base64-encoded + // Anthropic stream event under {"bytes": ...}. + events := [][]byte{ + mustJSON(t, map[string]any{"type": "message_start", "message": map[string]any{"usage": map[string]any{"input_tokens": 13}}}), + mustJSON(t, map[string]any{"type": "content_block_delta", "delta": map[string]any{"type": "text_delta", "text": "po"}}), + mustJSON(t, map[string]any{"type": "content_block_delta", "delta": map[string]any{"type": "text_delta", "text": "ng"}}), + mustJSON(t, map[string]any{"type": "message_delta", "usage": map[string]any{"output_tokens": 5}}), + } + var body bytes.Buffer + for _, ev := range events { + wrap := mustJSON(t, map[string]any{"bytes": base64.StdEncoding.EncodeToString(ev)}) + body.Write(bedrockFrame(t, "chunk", wrap)) + } + + usage, completion := accumulateBedrockStream(body.Bytes()) + require.Equal(t, int64(13), usage.InputTokens, "input tokens from message_start") + require.Equal(t, int64(5), usage.OutputTokens, "output tokens from message_delta") + require.Equal(t, int64(18), usage.TotalTokens, "total is additive") + require.Equal(t, "pong", completion, "text deltas concatenated") +} + +func TestAccumulateBedrockStream_Converse(t *testing.T) { + var body bytes.Buffer + body.Write(bedrockFrame(t, "contentBlockDelta", mustJSON(t, map[string]any{"delta": map[string]any{"text": "po"}}))) + body.Write(bedrockFrame(t, "contentBlockDelta", mustJSON(t, map[string]any{"delta": map[string]any{"text": "ng"}}))) + body.Write(bedrockFrame(t, "metadata", mustJSON(t, map[string]any{"usage": map[string]any{"inputTokens": 11, "outputTokens": 3, "totalTokens": 14}}))) + + usage, completion := accumulateBedrockStream(body.Bytes()) + require.Equal(t, int64(11), usage.InputTokens, "input tokens from metadata frame") + require.Equal(t, int64(3), usage.OutputTokens, "output tokens from metadata frame") + require.Equal(t, int64(14), usage.TotalTokens, "total from metadata frame") + require.Equal(t, "pong", completion, "converse text deltas concatenated") +} + +// The converse-stream metadata frame's camelCase cache fields must reach the billed cache buckets. +func TestAccumulateBedrockStream_ConverseCacheBuckets(t *testing.T) { + var body bytes.Buffer + body.Write(bedrockFrame(t, "contentBlockDelta", mustJSON(t, map[string]any{"delta": map[string]any{"text": "pong"}}))) + body.Write(bedrockFrame(t, "metadata", mustJSON(t, map[string]any{"usage": map[string]any{ + "inputTokens": 11, "outputTokens": 3, "totalTokens": 30, + "cacheReadInputTokens": 7, "cacheWriteInputTokens": 9, + }}))) + + usage, completion := accumulateBedrockStream(body.Bytes()) + require.Equal(t, int64(11), usage.InputTokens, "input tokens from metadata frame") + require.Equal(t, int64(3), usage.OutputTokens, "output tokens from metadata frame") + require.Equal(t, int64(7), usage.CachedInputTokens, "cache-read tokens from metadata frame") + require.Equal(t, int64(9), usage.CacheCreationTokens, "cache-write tokens from metadata frame") + require.Equal(t, int64(30), usage.TotalTokens, "provider-reported total wins") + require.Equal(t, "pong", completion) +} + +func TestAccumulateBedrockStream_Truncated(t *testing.T) { + // A body cut mid-frame must not panic; partial usage is returned. + full := bedrockFrame(t, "metadata", mustJSON(t, map[string]any{"usage": map[string]any{"inputTokens": 11, "outputTokens": 3}})) + usage, _ := accumulateBedrockStream(full[:len(full)-4]) + require.Zero(t, usage.OutputTokens, "truncated trailing frame is dropped, not panicked on") +} diff --git a/proxy/internal/middleware/builtin/llm_response_parser/streaming_test.go b/proxy/internal/middleware/builtin/llm_response_parser/streaming_test.go new file mode 100644 index 000000000..400aac0bd --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_response_parser/streaming_test.go @@ -0,0 +1,169 @@ +package llm_response_parser + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +func TestInvoke_OpenAIStreamingWithUsage(t *testing.T) { + m := newTestMiddleware(t) + body := loadFixture(t, "openai_stream.txt") + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "text/event-stream"}}, + RespBody: body, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "openai"}, + {Key: middleware.KeyLLMModel, Value: "gpt-4o-mini"}, + }, + } + + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "Invoke must not error on streaming OpenAI body") + + in123, _ := metaValue(out.Metadata, middleware.KeyLLMInputTokens) + assert.Equal(t, "123", in123, "input tokens must come from final-chunk usage block") + + outTok, _ := metaValue(out.Metadata, middleware.KeyLLMOutputTokens) + assert.Equal(t, "45", outTok, "output tokens must come from final-chunk usage block") + + totTok, _ := metaValue(out.Metadata, middleware.KeyLLMTotalTokens) + assert.Equal(t, "168", totTok, "total tokens must come from final-chunk usage block") + + completion, ok := metaValue(out.Metadata, middleware.KeyLLMResponseCompletion) + require.True(t, ok, "completion must be emitted for streaming responses") + assert.Equal(t, "Hello, world!", completion, "deltas must concatenate into the buffered fixture's text") +} + +func TestInvoke_OpenAIStreamingWithoutUsage(t *testing.T) { + body := []byte(`data: {"choices":[{"delta":{"content":"Hi"}}]} + +data: {"choices":[{"delta":{"content":" there"}}]} + +data: [DONE] + +`) + + usage, completion := accumulateOpenAIStream(body) + assert.Equal(t, int64(0), usage.InputTokens, "input tokens must stay zero without a usage frame") + assert.Equal(t, int64(0), usage.OutputTokens, "output tokens must stay zero without a usage frame") + assert.Equal(t, int64(0), usage.TotalTokens, "total tokens must stay zero without a usage frame") + assert.Equal(t, "Hi there", completion, "deltas must still accumulate when usage is absent") +} + +func TestInvoke_OpenAIStreamingNoUsage_OmitsUsageMetadata(t *testing.T) { + m := newTestMiddleware(t) + body := []byte(`data: {"choices":[{"delta":{"content":"Hello"}}]} + +data: [DONE] + +`) + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "text/event-stream"}}, + RespBody: body, + Metadata: []middleware.KV{{Key: middleware.KeyLLMProvider, Value: "openai"}}, + } + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "Invoke must not error on usage-less streams") + + _, hasIn := metaValue(out.Metadata, middleware.KeyLLMInputTokens) + _, hasOut := metaValue(out.Metadata, middleware.KeyLLMOutputTokens) + _, hasTot := metaValue(out.Metadata, middleware.KeyLLMTotalTokens) + assert.False(t, hasIn, "input tokens omitted when no usage frame") + assert.False(t, hasOut, "output tokens omitted when no usage frame") + assert.False(t, hasTot, "total tokens omitted when no usage frame") + + completion, ok := metaValue(out.Metadata, middleware.KeyLLMResponseCompletion) + require.True(t, ok, "completion must still be emitted from deltas") + assert.Equal(t, "Hello", completion, "completion must come from delta accumulation") +} + +func TestInvoke_AnthropicStreaming(t *testing.T) { + m := newTestMiddleware(t) + body := loadFixture(t, "anthropic_stream.txt") + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "text/event-stream"}}, + RespBody: body, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: "anthropic"}, + {Key: middleware.KeyLLMModel, Value: "claude-sonnet-4-5"}, + }, + } + + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "Invoke must not error on streaming Anthropic body") + + in123, _ := metaValue(out.Metadata, middleware.KeyLLMInputTokens) + assert.Equal(t, "123", in123, "input tokens must come from message_start usage") + + outTok, _ := metaValue(out.Metadata, middleware.KeyLLMOutputTokens) + assert.Equal(t, "45", outTok, "output tokens must come from message_delta usage") + + totTok, _ := metaValue(out.Metadata, middleware.KeyLLMTotalTokens) + assert.Equal(t, "168", totTok, "total tokens must be input+output for anthropic streaming") + + completion, ok := metaValue(out.Metadata, middleware.KeyLLMResponseCompletion) + require.True(t, ok, "completion must be emitted from text_delta accumulation") + assert.Equal(t, "Hello, world!", completion, "anthropic streaming text must accumulate across content_block_delta events") +} + +func TestInvoke_StreamingTruncatedBody_BestEffort(t *testing.T) { + m := newTestMiddleware(t) + full := loadFixture(t, "anthropic_stream.txt") + cut := len(full) / 2 + truncated := full[:cut] + + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "text/event-stream"}}, + RespBody: truncated, + RespBodyTruncated: true, + Metadata: []middleware.KV{{Key: middleware.KeyLLMProvider, Value: "anthropic"}}, + } + + require.NotPanics(t, func() { + _, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "truncated streaming body must not surface as error") + }, "Invoke must never panic on a truncated SSE body") +} + +func TestInvoke_StreamingEmptyBody(t *testing.T) { + m := newTestMiddleware(t) + in := &middleware.Input{ + Slot: middleware.SlotOnResponse, + Status: 200, + RespHeaders: []middleware.KV{{Key: "Content-Type", Value: "text/event-stream"}}, + RespBody: nil, + Metadata: []middleware.KV{{Key: middleware.KeyLLMProvider, Value: "openai"}}, + } + out, err := m.Invoke(context.Background(), in) + require.NoError(t, err, "empty SSE body must not surface as error") + assert.Empty(t, out.Metadata, "no metadata for empty SSE body") +} + +func TestAccumulateAnthropicStream_PartialUsage(t *testing.T) { + body := []byte(`event: message_start +data: {"type":"message_start","message":{"usage":{"input_tokens":10}}} + +event: content_block_delta +data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}} + +`) + usage, completion := accumulateAnthropicStream(body) + assert.Equal(t, int64(10), usage.InputTokens, "partial input_tokens must survive truncated stream") + assert.Equal(t, int64(0), usage.OutputTokens, "output_tokens stays zero without message_delta") + assert.Equal(t, "hi", completion, "completion must come from observed text_delta events") +} diff --git a/proxy/internal/middleware/builtin/llm_router/bedrock_route_test.go b/proxy/internal/middleware/builtin/llm_router/bedrock_route_test.go new file mode 100644 index 000000000..40cbcb6bd --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_router/bedrock_route_test.go @@ -0,0 +1,30 @@ +package llm_router + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestRouteClaimsModel_BedrockNormalizesCandidate guards the fix for the native +// Bedrock routing gap: the request model reaches the router already normalized +// (the parser strips the region/inference-profile prefix and version suffix), +// so a provider registered with the raw inference-profile id must still match. +func TestRouteClaimsModel_BedrockNormalizesCandidate(t *testing.T) { + route := ProviderRoute{Bedrock: true, Models: []string{"us.anthropic.claude-haiku-4-5"}} + assert.True(t, routeClaimsModel(route, "anthropic.claude-haiku-4-5"), + "raw region-prefixed Bedrock model must match the normalized request model") + assert.False(t, routeClaimsModel(route, "anthropic.claude-opus-4-8"), + "a model outside the provider's list must not match") + + // A provider registered with the already-normalized id also matches. + normalized := ProviderRoute{Bedrock: true, Models: []string{"anthropic.claude-haiku-4-5"}} + assert.True(t, routeClaimsModel(normalized, "anthropic.claude-haiku-4-5"), + "normalized Bedrock model must match") + + // Non-Bedrock routes keep exact matching (no prefix stripping). + openai := ProviderRoute{Models: []string{"gpt-4o"}} + assert.True(t, routeClaimsModel(openai, "gpt-4o"), "exact model must match") + assert.False(t, routeClaimsModel(openai, "us.gpt-4o"), + "non-Bedrock routes must not strip a us. prefix") +} diff --git a/proxy/internal/middleware/builtin/llm_router/factory.go b/proxy/internal/middleware/builtin/llm_router/factory.go new file mode 100644 index 000000000..938a23ebe --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_router/factory.go @@ -0,0 +1,110 @@ +package llm_router + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" +) + +// ProviderRoute describes one upstream LLM provider the router can +// hand a request to. Models lists the model identifiers the provider +// claims; UpstreamScheme + UpstreamHost replace the synth target's +// placeholder URL on a match. UpstreamPath is the path component of +// the configured upstream URL — the router uses it to disambiguate +// providers that claim the same model: when more than one provider +// matches the model, the route whose UpstreamPath is a prefix of the +// incoming request path is preferred (longest match wins, empty path +// is the catchall). AuthHeaderName + AuthHeaderValue are the +// per-provider credential the router injects after stripping the +// vendor auth headers from the inbound request. +// +// AllowedGroupIDs is the union of source-group IDs across every +// enabled policy that authorises this provider. The router treats it +// as a hard filter: a route whose AllowedGroupIDs has no intersection +// with the caller's UserGroups is removed from the candidate list +// before the path-prefix tiebreak. A route with empty AllowedGroupIDs +// is unreachable; the synthesiser only emits policy-bound routes. +type ProviderRoute struct { + ID string `json:"id"` + // Vendor is the parser surface this provider speaks ("openai", + // "anthropic", …), matching the llm.provider value llm_request_parser + // emits from the request. When set, the router keeps a vendor-tagged + // request on a same-vendor route so catch-all gateways of a different + // vendor can't swallow it. Empty disables vendor filtering for this + // route. + Vendor string `json:"vendor,omitempty"` + Models []string `json:"models"` + UpstreamScheme string `json:"upstream_scheme"` + UpstreamHost string `json:"upstream_host"` + UpstreamPath string `json:"upstream_path,omitempty"` + AuthHeaderName string `json:"auth_header_name"` + AuthHeaderValue string `json:"auth_header_value"` + AllowedGroupIDs []string `json:"allowed_group_ids"` + // Vertex marks a Google Vertex AI provider. Vertex requests carry the + // model in the URL path, so the router selects this route by path + // (isVertexPath) and bypasses the model/vendor table entirely. + Vertex bool `json:"vertex,omitempty"` + // Bedrock marks an AWS Bedrock provider. Bedrock requests carry the model + // in the URL path (/model/{id}/{action}), so the router selects this route + // by path (isBedrockPath) and bypasses the model/vendor table; auth is the + // static AuthHeaderValue bearer token (no token minting). + Bedrock bool `json:"bedrock,omitempty"` + // GCPServiceAccountKeyB64 is a base64-encoded GCP service-account JSON + // key. When set, the router mints + refreshes a short-lived OAuth2 access + // token from it at request time and injects it as the auth header value + // (instead of the static AuthHeaderValue) — so the gateway holds a durable + // Vertex credential rather than a 1-hour token. + GCPServiceAccountKeyB64 string `json:"gcp_sa_key_b64,omitempty"` + // SkipTLSVerify disables upstream TLS certificate verification when dialing + // this route's upstream. For self-hosted / internal gateways behind a + // private or self-signed certificate. + SkipTLSVerify bool `json:"skip_tls_verify,omitempty"` +} + +// Config is the on-wire configuration accepted by the factory. An +// empty Providers slice yields a router that denies every request as +// not-routable; the synthesiser is responsible for stamping the +// account's enabled providers into this slice. +type Config struct { + Providers []ProviderRoute `json:"providers"` +} + +// Factory builds llm_router instances from raw config bytes. +type Factory struct{} + +// ID returns the registry identifier. +func (Factory) ID() string { return ID } + +// New constructs a middleware instance. Empty, null, and {} configs +// yield a router with an empty Providers slice — every request denies +// with model_not_routable. Non-empty payloads must parse cleanly so +// misconfigurations surface at chain build time. +func (Factory) New(rawConfig []byte) (middleware.Middleware, error) { + cfg := Config{} + if !isEmptyJSON(rawConfig) { + if err := json.Unmarshal(rawConfig, &cfg); err != nil { + return nil, fmt.Errorf("decode config: %w", err) + } + } + return New(cfg), nil +} + +// isEmptyJSON reports whether the payload is whitespace, null, or an +// empty object/array. The caller skips Unmarshal in that case so the +// zero-value Config flows through unchanged. +func isEmptyJSON(raw []byte) bool { + trimmed := strings.TrimSpace(string(bytes.TrimSpace(raw))) + switch trimmed { + case "", "null", "{}", "[]": + return true + } + return false +} + +func init() { + builtin.Register(Factory{}) +} diff --git a/proxy/internal/middleware/builtin/llm_router/middleware.go b/proxy/internal/middleware/builtin/llm_router/middleware.go new file mode 100644 index 000000000..2d987eef6 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_router/middleware.go @@ -0,0 +1,803 @@ +// Package llm_router implements the SlotOnRequest middleware that +// routes a request to an upstream LLM provider based on the model name +// emitted upstream by llm_request_parser. The router rewrites the +// request's outbound target (scheme + host), strips known LLM-vendor +// auth headers, and injects the per-provider auth header from the +// matched route. Unknown or unconfigured models deny with a 403 and +// the canonical llm_policy.model_not_routable code. +package llm_router + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + "sync" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + + "github.com/netbirdio/netbird/proxy/internal/llm" + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +// gcpScope is the OAuth2 scope minted for Vertex AI service-account auth. +const gcpScope = "https://www.googleapis.com/auth/cloud-platform" + +// gcpTokenTimeout bounds each GCP token mint/refresh HTTP call so a slow or +// unreachable token endpoint can't block the request indefinitely. +const gcpTokenTimeout = 10 * time.Second + +// ID is the registry key for this middleware. +const ID = "llm_router" + +// Version is reported via Middleware.Version(). +const Version = "1.0.0" + +const ( + denyCodeNotRoutable = "llm_policy.model_not_routable" + denyReasonNotRoutable = "model_not_routable" + denyCodeNoAuthorisedRoute = "llm_policy.no_authorised_provider" + denyReasonNoAuthorisedRoute = "no_authorised_provider" + //nolint:gosec // deny code label, not a credential + denyCodeUpstreamAuth = "llm_policy.upstream_auth_failed" + denyCodeUnmeterable = "llm_policy.unmeterable_publisher" + denyReasonUnmeterable = "unmeterable_publisher" +) + +// strippedAuthHeaders is the closed list of vendor authentication +// credentials the router clears before injecting the provider-specific +// credential. Strictly auth headers — vendor-specific metadata +// (anthropic-version, openai-organization, openai-project, etc.) is +// NOT stripped because the client SDK sets those and the upstream +// requires them (e.g. Anthropic returns 400 without +// anthropic-version). Each entry is canonicalised by Go's +// http.Header.Del/Set, so listing the canonical shapes here is +// sufficient. +var strippedAuthHeaders = []string{ + "Authorization", // OpenAI, OpenAI-compatible, most vendors, Bedrock bearer + "Proxy-Authorization", // upstream proxy auth (defense-in-depth) + "x-api-key", // Anthropic + "api-key", // Azure OpenAI + "X-Amz-Date", // AWS SigV4 — strip client-supplied AWS signing material + "X-Amz-Security-Token", + "X-Amz-Content-Sha256", +} + +// Middleware routes requests to upstream LLM providers based on the +// llm.model metadata emitted by llm_request_parser. +type Middleware struct { + cfg Config + // tokenSrc caches one auto-refreshing OAuth2 TokenSource per GCP + // service-account key (keyed by a hash of the key material), so Vertex + // token minting happens once and refreshes are amortised across requests. + tokenMu sync.Mutex + tokenSrc map[string]oauth2.TokenSource +} + +// New constructs a Middleware with the supplied configuration. Empty +// or nil Providers slice yields a router that denies every request as +// not-routable. +func New(cfg Config) *Middleware { + return &Middleware{cfg: cfg, tokenSrc: map[string]oauth2.TokenSource{}} +} + +// ID returns the registry identifier. +func (m *Middleware) ID() string { return ID } + +// Version returns the implementation version. +func (m *Middleware) Version() string { return Version } + +// Slot reports the chain slot the middleware lives in. +func (m *Middleware) Slot() middleware.Slot { return middleware.SlotOnRequest } + +// AcceptedContentTypes returns nil because the router only consults +// the metadata emitted by llm_request_parser. +func (m *Middleware) AcceptedContentTypes() []string { return nil } + +// MetadataKeys is the closed set of metadata keys this middleware may +// emit. The accumulator drops anything outside this allowlist. +func (m *Middleware) MetadataKeys() []string { + return []string{ + middleware.KeyLLMResolvedProviderID, + middleware.KeyLLMAuthorisingGroups, + middleware.KeyLLMPolicyDecision, + middleware.KeyLLMPolicyReason, + } +} + +// MutationsSupported reports that the middleware emits header and +// upstream-rewrite mutations. +func (m *Middleware) MutationsSupported() bool { return true } + +// Close releases resources owned by the middleware. The router is +// stateless, so this is a no-op. +func (m *Middleware) Close() error { return nil } + +// matchOutcome captures why matchRoute returned what it did so the +// caller can distinguish "no provider knows this model" from "providers +// know it but none authorise this peer's groups". +type matchOutcome int + +const ( + matchOutcomeFound matchOutcome = iota + matchOutcomeUnknownModel + matchOutcomeUnauthorised +) + +// Invoke resolves the model to a provider authorised for the caller's +// groups, strips known vendor auth headers, and injects the route's +// auth header. Unknown models deny with model_not_routable; models +// known to a provider that no policy authorises for the caller deny +// with no_authorised_provider. +func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) { + // Vertex AI carries the model in the URL path, not the body, and is + // selected by path rather than by the model/vendor table. Route it before + // the model lookup so a model the parser extracted from the path can't be + // claimed by a same-vendor direct provider (e.g. claude-* on api.anthropic.com). + reqPath := requestPath(in.URL) + if isVertexPath(reqPath) { + model, _ := lookupMetadata(in.Metadata, middleware.KeyLLMModel) + // The request parser emits no llm.provider for a Vertex publisher it + // can't parse (e.g. google/gemini). Forwarding such a request would + // bypass token/budget metering, so deny it rather than serve it + // unmetered. + if vendor, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider); vendor == "" { + return denyUnmeterable(), nil + } + route, outcome := m.matchVertex(reqPath, model, in.UserGroups) + switch outcome { + case matchOutcomeFound: + return m.allowWithRoute(route, in.UserGroups), nil + case matchOutcomeUnauthorised: + return denyNoAuthorisedRoute(model), nil + default: + return denyUnknownModel(model), nil + } + } + + // Bedrock likewise carries the model in the URL path (/model/{id}/{action}), + // optionally behind a "/bedrock" gateway-namespace prefix. Route it by path + // before the model lookup; when the prefix is present, strip it from the + // forwarded path so the real Bedrock endpoint receives its native path. + if isBedrockPath(reqPath) { + model, _ := lookupMetadata(in.Metadata, middleware.KeyLLMModel) + native, hadPrefix := splitBedrockNamespace(reqPath) + route, outcome := m.matchBedrock(native, model, in.UserGroups) + switch outcome { + case matchOutcomeFound: + out := m.allowWithRoute(route, in.UserGroups) + if hadPrefix && out.Mutations != nil && out.Mutations.RewriteUpstream != nil { + out.Mutations.RewriteUpstream.StripPathPrefix = bedrockNamespacePrefix + } + return out, nil + case matchOutcomeUnauthorised: + return denyNoAuthorisedRoute(model), nil + default: + return denyUnknownModel(model), nil + } + } + + model, ok := lookupMetadata(in.Metadata, middleware.KeyLLMModel) + if !ok || model == "" { + // Non-inference endpoints (model listing) carry no model but still + // need rewriting from the synth placeholder to a real upstream; + // clients such as Codex call GET /v1/models at startup to enumerate + // availability and read a 403 as "model unavailable". + route, outcome := m.matchModelless(requestPath(in.URL), in.UserGroups) + switch outcome { + case matchOutcomeFound: + return m.allowWithRoute(route, in.UserGroups), nil + case matchOutcomeUnauthorised: + // A recognised model-less endpoint exists but no provider + // authorises the caller — deny as an authorisation failure + // rather than masking it as a missing model. + return denyNoAuthorisedRoute(model), nil + default: + return denyMissingModel(), nil + } + } + + vendor, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider) + route, outcome := m.matchRoute(model, vendor, requestPath(in.URL), in.UserGroups) + switch outcome { + case matchOutcomeFound: + return m.allowWithRoute(route, in.UserGroups), nil + case matchOutcomeUnauthorised: + return denyNoAuthorisedRoute(model), nil + default: + return denyUnknownModel(model), nil + } +} + +// matchRoute returns the ProviderRoute that should serve the given +// model + request path for a caller in the given user-groups. Selection +// is: +// +// 1. Filter the configured providers to those whose Models list +// contains the model. +// 2. Filter the model-matched candidates to those whose +// AllowedGroupIDs intersect the caller's UserGroups. A route with +// no AllowedGroupIDs is the catch-all: it stays in the list. If +// the model was known but no candidate is authorised for this +// peer, return matchOutcomeUnauthorised so the caller can emit +// the dedicated no_authorised_provider deny code. +// 3. Vendor precedence: when the request carries a detected vendor +// (llm.provider) and at least one candidate is the same vendor, +// drop the rest — a vendor-tagged request must never cross to +// another vendor's route (e.g. an Anthropic call landing on an +// OpenAI-compatible gateway that also claims the model). +// 4. Model precedence over path: a route that explicitly lists the +// model beats a catch-all (empty Models) gateway. +// 5. Disambiguate the survivors by URL path prefix: longest +// UpstreamPath that prefix-matches the request path wins; an empty +// UpstreamPath is the catchall. If none prefix-matches, fall back +// to declaration order so the model stays routable. +func (m *Middleware) matchRoute(model, vendor, reqPath string, userGroups []string) (ProviderRoute, matchOutcome) { + var modelMatched []ProviderRoute + for _, route := range m.cfg.Providers { + if routeClaimsModel(route, model) { + modelMatched = append(modelMatched, route) + } + } + if len(modelMatched) == 0 { + return ProviderRoute{}, matchOutcomeUnknownModel + } + + // Vendor pinning runs BEFORE the group filter so a request the parser + // tagged with a vendor can never cross to another vendor's route — not + // even an authorised one. Narrow to same-vendor routes when any + // model-matched route declares that vendor; setups with no vendor tag on + // any route fall through unchanged. After narrowing, if no same-vendor + // route authorises the caller, that's matchOutcomeUnauthorised (no + // cross-vendor fallback). + if vendor != "" { + if vendorMatched := matchingVendor(modelMatched, vendor); len(vendorMatched) > 0 { + modelMatched = vendorMatched + } + } + + var candidates []ProviderRoute + for _, route := range modelMatched { + if routeAuthorisesGroups(route, userGroups) { + candidates = append(candidates, route) + } + } + if len(candidates) == 0 { + return ProviderRoute{}, matchOutcomeUnauthorised + } + + // Model routing takes precedence over path. A route that explicitly + // lists the model must beat a catch-all (empty Models) gateway that + // claims every model — otherwise an Anthropic request can fall through + // to an OpenAI-compatible gateway declared earlier. Only when no + // candidate explicitly claims the model do the catch-alls compete, and + // the path-prefix tiebreak applies within whichever tier wins. + if explicit := explicitlyClaiming(candidates, model); len(explicit) > 0 { + candidates = explicit + } + if len(candidates) == 1 { + return candidates[0], matchOutcomeFound + } + + best := candidates[0] + bestLen := -1 + for _, c := range candidates { + if !pathPrefixMatches(c.UpstreamPath, reqPath) { + continue + } + if len(c.UpstreamPath) > bestLen { + best = c + bestLen = len(c.UpstreamPath) + } + } + return best, matchOutcomeFound +} + +// isModelLessPath reports whether reqPath is a known OpenAI-shaped +// non-inference endpoint that legitimately carries no model in its +// request (the model-listing endpoints). These must route to an upstream +// rather than deny, so model enumeration works end to end. +func isModelLessPath(reqPath string) bool { + return reqPath == "/v1/models" || strings.HasPrefix(reqPath, "/v1/models/") +} + +// isVertexPath reports whether reqPath is a Google Vertex AI publisher +// endpoint: /v1/projects/{project}/locations/{region}/publishers/{publisher}/ +// models/{model}:{action}. The model + vendor live in the path, so these +// requests are routed by path to the Vertex provider rather than by model. +func isVertexPath(reqPath string) bool { + return strings.HasPrefix(reqPath, "/v1/projects/") && + strings.Contains(reqPath, "/publishers/") && + strings.Contains(reqPath, "/models/") +} + +// bedrockNamespacePrefix is an optional gateway-namespace prefix some clients +// place before the native Bedrock path to disambiguate it from other providers +// that also use "/model/...". It is stripped before forwarding upstream. +const bedrockNamespacePrefix = "/bedrock" + +// splitBedrockNamespace removes an optional "/bedrock" namespace prefix, +// returning the native Bedrock path and whether the prefix was present. +func splitBedrockNamespace(reqPath string) (string, bool) { + if strings.HasPrefix(reqPath, bedrockNamespacePrefix+"/") { + return strings.TrimPrefix(reqPath, bedrockNamespacePrefix), true + } + return reqPath, false +} + +// isBedrockPath reports whether reqPath is an AWS Bedrock runtime model +// endpoint: /model/{modelId}/{action} where action is invoke, +// invoke-with-response-stream, converse, or converse-stream — optionally behind +// a "/bedrock" gateway-namespace prefix. The model lives in the path, so these +// requests are routed by path to the Bedrock provider. +func isBedrockPath(reqPath string) bool { + native, _ := splitBedrockNamespace(reqPath) + if !strings.HasPrefix(native, "/model/") { + return false + } + return strings.HasSuffix(native, "/invoke") || + strings.HasSuffix(native, "/invoke-with-response-stream") || + strings.HasSuffix(native, "/converse") || + strings.HasSuffix(native, "/converse-stream") +} + +// matchVertex selects the Vertex provider authorised for the caller's groups +// and claiming the requested model. +func (m *Middleware) matchVertex(reqPath, model string, userGroups []string) (ProviderRoute, matchOutcome) { + return m.matchPathRoute(reqPath, model, userGroups, func(r ProviderRoute) bool { return r.Vertex }) +} + +// matchBedrock selects the Bedrock provider authorised for the caller's groups +// and claiming the requested model. +func (m *Middleware) matchBedrock(reqPath, model string, userGroups []string) (ProviderRoute, matchOutcome) { + return m.matchPathRoute(reqPath, model, userGroups, func(r ProviderRoute) bool { return r.Bedrock }) +} + +// matchPathRoute selects a path-routed provider (Vertex/Bedrock). These carry +// the model in the URL, so the model/vendor table is bypassed — but the route's +// configured Models allowlist is still enforced (empty Models = catch-all) so a +// provider credential can't be used for models the operator didn't authorise. +// Returns matchOutcomeUnauthorised when no style route authorises the caller's +// groups, matchOutcomeUnknownModel when an authorised route exists but none +// claims the model (or no style route exists at all), else the chosen route +// (longest UpstreamPath prefix-match wins among multiple). +func (m *Middleware) matchPathRoute(reqPath, model string, userGroups []string, isStyle func(ProviderRoute) bool) (ProviderRoute, matchOutcome) { + var styled []ProviderRoute + for _, route := range m.cfg.Providers { + if isStyle(route) { + styled = append(styled, route) + } + } + if len(styled) == 0 { + return ProviderRoute{}, matchOutcomeUnknownModel + } + + var authorised []ProviderRoute + for _, route := range styled { + if routeAuthorisesGroups(route, userGroups) { + authorised = append(authorised, route) + } + } + if len(authorised) == 0 { + return ProviderRoute{}, matchOutcomeUnauthorised + } + + var candidates []ProviderRoute + for _, route := range authorised { + if routeClaimsModel(route, model) { + candidates = append(candidates, route) + } + } + if len(candidates) == 0 { + return ProviderRoute{}, matchOutcomeUnknownModel + } + if len(candidates) == 1 { + return candidates[0], matchOutcomeFound + } + + best := candidates[0] + bestLen := -1 + for _, c := range candidates { + if !pathPrefixMatches(c.UpstreamPath, reqPath) { + continue + } + if len(c.UpstreamPath) > bestLen { + best = c + bestLen = len(c.UpstreamPath) + } + } + return best, matchOutcomeFound +} + +// matchModelless selects a route for a non-inference, model-less request. +// It mirrors matchRoute's group-authorisation filter and path-prefix +// tiebreak but skips the per-model filter, since any provider the caller's +// groups authorise can serve a model-listing request. Returns +// matchOutcomeFound with the chosen route (single authorised provider wins +// outright; multiple fall to the longest UpstreamPath prefix-match, then +// declaration order), matchOutcomeUnauthorised when no provider authorises +// the caller, or matchOutcomeUnknownModel when the path isn't a recognised +// model-less endpoint. +func (m *Middleware) matchModelless(reqPath string, userGroups []string) (ProviderRoute, matchOutcome) { + if !isModelLessPath(reqPath) { + return ProviderRoute{}, matchOutcomeUnknownModel + } + var candidates []ProviderRoute + for _, route := range m.cfg.Providers { + // Vertex/Bedrock are path-routed and don't serve OpenAI-style + // model-listing endpoints; including them here could rewrite a + // GET /v1/models to an upstream that 404s it. + if route.Vertex || route.Bedrock { + continue + } + if routeAuthorisesGroups(route, userGroups) { + candidates = append(candidates, route) + } + } + if len(candidates) == 0 { + return ProviderRoute{}, matchOutcomeUnauthorised + } + if len(candidates) == 1 { + return candidates[0], matchOutcomeFound + } + + best := candidates[0] + bestLen := -1 + for _, c := range candidates { + if !pathPrefixMatches(c.UpstreamPath, reqPath) { + continue + } + if len(c.UpstreamPath) > bestLen { + best = c + bestLen = len(c.UpstreamPath) + } + } + return best, matchOutcomeFound +} + +// routeAuthorisesGroups reports whether the route's AllowedGroupIDs +// intersect the caller's userGroups. A route with empty AllowedGroupIDs +// is unreachable: the synthesiser only emits routes bound to at least +// one enabled policy, so an empty list signals a misconfiguration that +// must not be allowed to fall through. +func routeAuthorisesGroups(r ProviderRoute, userGroups []string) bool { + for _, ug := range userGroups { + for _, ag := range r.AllowedGroupIDs { + if ug == ag { + return true + } + } + } + return false +} + +// authorisingGroupsCSV returns the sorted, deduplicated comma-separated +// intersection of routeGroups and userGroups — i.e. the groups that +// actually authorise the resolved route for this caller. Returns the +// empty string when the intersection is empty (shouldn't happen on the +// allow path, but defensive). +func authorisingGroupsCSV(routeGroups, userGroups []string) string { + if len(routeGroups) == 0 || len(userGroups) == 0 { + return "" + } + allowed := make(map[string]struct{}, len(routeGroups)) + for _, g := range routeGroups { + allowed[g] = struct{}{} + } + seen := make(map[string]struct{}, len(userGroups)) + out := make([]string, 0, len(userGroups)) + for _, ug := range userGroups { + if _, ok := allowed[ug]; !ok { + continue + } + if _, dup := seen[ug]; dup { + continue + } + seen[ug] = struct{}{} + out = append(out, ug) + } + if len(out) == 0 { + return "" + } + sort.Strings(out) + return strings.Join(out, ",") +} + +// matchingVendor returns the subset of routes whose Vendor equals the +// request's detected vendor. Routes with an empty Vendor never match — an +// untagged route can't be asserted to speak the request's surface, so it +// stays out of the vendor-filtered set (but remains eligible via the +// fall-through when no route matches the vendor at all). +func matchingVendor(routes []ProviderRoute, vendor string) []ProviderRoute { + var out []ProviderRoute + for _, r := range routes { + if r.Vendor == vendor { + out = append(out, r) + } + } + return out +} + +// explicitlyClaiming returns the subset of routes whose Models list +// names the model exactly. Catch-all routes (empty Models) are excluded, +// so callers can prefer a provider that genuinely declares the model over +// a gateway that claims everything. +func explicitlyClaiming(routes []ProviderRoute, model string) []ProviderRoute { + var out []ProviderRoute + for _, r := range routes { + for _, candidate := range r.Models { + if candidate == model { + out = append(out, r) + break + } + } + } + return out +} + +// routeClaimsModel reports whether the route's Models list contains +// the given model identifier. An empty Models list is treated as +// "claim every model" — used by gateway-style providers (LiteLLM, +// custom OpenAI-compatible endpoints) that proxy an open-ended set of +// upstream models the operator can't enumerate in NetBird's provider +// config. +func routeClaimsModel(route ProviderRoute, model string) bool { + if len(route.Models) == 0 { + return true + } + for _, candidate := range route.Models { + if candidate == model { + return true + } + // Bedrock request models reach the router already normalized (the parser + // strips the region / inference-profile prefix and version suffix), but + // the operator may register the raw inference-profile id (e.g. + // "us.anthropic.claude-haiku-4-5"). Normalize the candidate so both sides + // compare equal; otherwise a native Bedrock request denies as not-routable. + if route.Bedrock && llm.NormalizeBedrockModel(candidate) == model { + return true + } + } + return false +} + +// pathPrefixMatches reports whether upstreamPath matches reqPath on a path- +// segment boundary: an exact match, or reqPath continuing after +// upstreamPath at a "/" separator. This avoids a sibling base like +// "/openai" spuriously matching "/openai-test". An empty (or "/") +// upstreamPath always matches (catchall). +func pathPrefixMatches(upstreamPath, reqPath string) bool { + if upstreamPath == "" || upstreamPath == "/" { + return true + } + upstreamPath = strings.TrimRight(upstreamPath, "/") + return reqPath == upstreamPath || strings.HasPrefix(reqPath, upstreamPath+"/") +} + +// requestPath extracts the path component from an Input.URL string +// (which is r.URL.String() — typically "/path?query"). Returns the +// raw input on parse failure so the prefix check can still operate on +// the unparsed value. +func requestPath(raw string) string { + if raw == "" { + return "" + } + parsed, err := url.Parse(raw) + if err != nil { + return raw + } + return parsed.Path +} + +// allowWithRoute builds the Output for a successful route match. The +// returned Mutations carry the upstream rewrite plus — riding on it — +// the StripHeaders list and the AuthHeader to inject. +// +// The strip + inject MUST go through UpstreamRewrite (not HeadersAdd / +// HeadersRemove) because the framework's mutation gate runs every +// header change through a denylist that blocks Authorization, +// Cookie, etc. — exactly the headers the router is replacing. The +// proxy's upstream-build path applies AuthHeader / StripHeaders +// directly, bypassing the denylist by virtue of being a trusted +// proxy operation rather than an arbitrary middleware mutation. +// +// Emits the authorising-groups intersection alongside the resolved +// provider id so identity-stamping middlewares (llm_identity_inject) +// tag the request with ONLY the groups that authorised this specific +// route — not every group the peer happens to be in. +func (m *Middleware) allowWithRoute(route ProviderRoute, userGroups []string) *middleware.Output { + rewrite := &middleware.UpstreamRewrite{ + Scheme: route.UpstreamScheme, + Host: route.UpstreamHost, + // UpstreamPath is the path component the operator pasted on + // the provider record (e.g. "/v1/{account}/{gateway}/compat" + // for Cloudflare AI Gateway). Carrying it on the rewrite so + // the proxy's URL composer joins it with the agent's request + // path — without this, the operator's configured upstream + // path is silently dropped and the gateway returns a 4xx for + // the malformed URL. Empty value leaves the original + // target's path untouched. + Path: route.UpstreamPath, + StripHeaders: append([]string(nil), strippedAuthHeaders...), + SkipTLSVerify: route.SkipTLSVerify, + } + authValue := route.AuthHeaderValue + if route.GCPServiceAccountKeyB64 != "" { + // Mint a short-lived OAuth2 token from the service-account key at + // request time (cached + auto-refreshed) instead of a static value. + bearer, err := m.gcpBearer(route.GCPServiceAccountKeyB64) + if err != nil { + return denyUpstreamAuth() + } + authValue = bearer + } + if route.AuthHeaderName != "" && authValue != "" { + rewrite.AuthHeader = &middleware.AuthHeader{ + Name: route.AuthHeaderName, + Value: authValue, + } + } + return &middleware.Output{ + Decision: middleware.DecisionAllow, + Mutations: &middleware.Mutations{RewriteUpstream: rewrite}, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMResolvedProviderID, Value: route.ID}, + {Key: middleware.KeyLLMAuthorisingGroups, Value: authorisingGroupsCSV(route.AllowedGroupIDs, userGroups)}, + {Key: middleware.KeyLLMPolicyDecision, Value: "allow"}, + }, + } +} + +// gcpBearer returns a "Bearer " value minted from a base64-encoded GCP +// service-account key, using a cached, auto-refreshing token source. +func (m *Middleware) gcpBearer(saKeyB64 string) (string, error) { + ts, err := m.gcpTokenSource(saKeyB64) + if err != nil { + return "", err + } + tok, err := ts.Token() + if err != nil { + return "", fmt.Errorf("mint gcp token: %w", err) + } + return "Bearer " + tok.AccessToken, nil +} + +// gcpTokenSource returns the cached TokenSource for the given service-account +// key, building it (decode base64 → parse JSON → cloud-platform scope) on first +// use. The returned source caches the token and refreshes it before expiry. +func (m *Middleware) gcpTokenSource(saKeyB64 string) (oauth2.TokenSource, error) { + sum := sha256.Sum256([]byte(saKeyB64)) + key := hex.EncodeToString(sum[:]) + + m.tokenMu.Lock() + defer m.tokenMu.Unlock() + if m.tokenSrc == nil { + m.tokenSrc = map[string]oauth2.TokenSource{} + } + if ts, ok := m.tokenSrc[key]; ok { + return ts, nil + } + jsonKey, err := base64.StdEncoding.DecodeString(strings.TrimSpace(saKeyB64)) + if err != nil { + return nil, fmt.Errorf("decode gcp service-account key: %w", err) + } + conf, err := google.JWTConfigFromJSON(jsonKey, gcpScope) + if err != nil { + return nil, fmt.Errorf("parse gcp service-account key: %w", err) + } + // Bound mint/refresh with a timeout HTTP client so a slow token endpoint + // can't hang the request. The oauth2 library uses this client for the + // lifetime of the (auto-refreshing) source. + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{Timeout: gcpTokenTimeout}) + ts := conf.TokenSource(ctx) + m.tokenSrc[key] = ts + return ts, nil +} + +// denyUpstreamAuth is returned when the router cannot obtain the upstream +// credential (e.g. a malformed service-account key or an unreachable token +// endpoint). It surfaces as a 502 — an upstream problem, not a policy denial. +func denyUpstreamAuth() *middleware.Output { + return &middleware.Output{ + Decision: middleware.DecisionDeny, + DenyStatus: 502, + DenyReason: &middleware.DenyReason{ + Code: denyCodeUpstreamAuth, + Message: "could not obtain upstream credential", + }, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMPolicyDecision, Value: "deny"}, + {Key: middleware.KeyLLMPolicyReason, Value: "upstream_auth_failed"}, + }, + } +} + +// denyUnmeterable returns the deny envelope for a path-routed request whose +// publisher has no parser surface, so its usage can't be metered. Serving it +// would bypass token/budget caps, so it is rejected with a 403. +func denyUnmeterable() *middleware.Output { + return &middleware.Output{ + Decision: middleware.DecisionDeny, + DenyStatus: 403, + DenyReason: &middleware.DenyReason{ + Code: denyCodeUnmeterable, + Message: "request publisher is not supported for metering", + }, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMPolicyDecision, Value: "deny"}, + {Key: middleware.KeyLLMPolicyReason, Value: denyReasonUnmeterable}, + }, + } +} + +// denyMissingModel returns the deny envelope for a request whose +// envelope has no llm.model metadata. +func denyMissingModel() *middleware.Output { + return &middleware.Output{ + Decision: middleware.DecisionDeny, + DenyStatus: 403, + DenyReason: &middleware.DenyReason{ + Code: denyCodeNotRoutable, + Message: "missing llm.model on request envelope", + }, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMPolicyDecision, Value: "deny"}, + {Key: middleware.KeyLLMPolicyReason, Value: denyReasonNotRoutable}, + }, + } +} + +// denyUnknownModel returns the deny envelope for a model that no +// configured provider claims. +func denyUnknownModel(model string) *middleware.Output { + return &middleware.Output{ + Decision: middleware.DecisionDeny, + DenyStatus: 403, + DenyReason: &middleware.DenyReason{ + Code: denyCodeNotRoutable, + Message: fmt.Sprintf("no provider configured for model %s", model), + Details: map[string]string{"model": model}, + }, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMPolicyDecision, Value: "deny"}, + {Key: middleware.KeyLLMPolicyReason, Value: denyReasonNotRoutable}, + }, + } +} + +// denyNoAuthorisedRoute returns the deny envelope for a model that one +// or more providers claim, but where no policy authorises the caller's +// groups for any of those providers. +func denyNoAuthorisedRoute(model string) *middleware.Output { + return &middleware.Output{ + Decision: middleware.DecisionDeny, + DenyStatus: 403, + DenyReason: &middleware.DenyReason{ + Code: denyCodeNoAuthorisedRoute, + Message: fmt.Sprintf("no policy authorises model %s for the caller's groups", model), + Details: map[string]string{"model": model}, + }, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMPolicyDecision, Value: "deny"}, + {Key: middleware.KeyLLMPolicyReason, Value: denyReasonNoAuthorisedRoute}, + }, + } +} + +// lookupMetadata returns the value for key plus a presence flag so +// callers can distinguish absent from empty. +func lookupMetadata(meta []middleware.KV, key string) (string, bool) { + for _, kv := range meta { + if kv.Key == key { + return kv.Value, true + } + } + return "", false +} diff --git a/proxy/internal/middleware/builtin/llm_router/middleware_test.go b/proxy/internal/middleware/builtin/llm_router/middleware_test.go new file mode 100644 index 000000000..425c383c1 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_router/middleware_test.go @@ -0,0 +1,875 @@ +package llm_router + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +// metaValue returns the value for the first KV with the given key. +func metaValue(t *testing.T, kvs []middleware.KV, key string) (string, bool) { + t.Helper() + for _, kv := range kvs { + if kv.Key == key { + return kv.Value, true + } + } + return "", false +} + +// defaultTestGroup is the group id used by routes and inputs in tests +// that don't specifically exercise the group-filter logic. Pairing it +// with the same id on every test route keeps the legacy assertions +// focused on routing/path behaviour without each one having to bake in +// its own ACL. +const defaultTestGroup = "grp-test" + +// newInputWithModel returns an Input carrying llm.model in its metadata +// bag, mimicking the post-llm_request_parser state the router observes +// in production. UserGroups is populated with defaultTestGroup so the +// router's group-filter pass authorises any test route whose +// AllowedGroupIDs contains the same id. +func newInputWithModel(model string) *middleware.Input { + return &middleware.Input{ + Slot: middleware.SlotOnRequest, + Metadata: []middleware.KV{{Key: middleware.KeyLLMModel, Value: model}}, + UserGroups: []string{defaultTestGroup}, + } +} + +// newInputWithModelAndURL returns an Input carrying both llm.model and +// a request URL so router tests can exercise path-based disambiguation. +func newInputWithModelAndURL(model, reqURL string) *middleware.Input { + in := newInputWithModel(model) + in.URL = reqURL + return in +} + +func TestMiddlewareIdentity(t *testing.T) { + mw := New(Config{}) + assert.Equal(t, ID, mw.ID(), "middleware ID must be llm_router") + assert.Equal(t, Version, mw.Version(), "version must match the constant") + assert.Equal(t, middleware.SlotOnRequest, mw.Slot(), "router must run in SlotOnRequest") + assert.True(t, mw.MutationsSupported(), "router must declare mutations support") + assert.Nil(t, mw.AcceptedContentTypes(), "router does not inspect bodies") + assert.ElementsMatch(t, + []string{ + middleware.KeyLLMResolvedProviderID, + middleware.KeyLLMAuthorisingGroups, + middleware.KeyLLMPolicyDecision, + middleware.KeyLLMPolicyReason, + }, + mw.MetadataKeys(), + "metadata key allowlist must match the spec", + ) + require.NoError(t, mw.Close()) +} + +func TestRouter_HappyPath(t *testing.T) { + route := ProviderRoute{ + ID: "openai-prod", + Models: []string{"gpt-4o"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer sk-test-123", + } + mw := New(Config{Providers: []ProviderRoute{route}}) + + out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-4o")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "matched model must allow") + + require.NotNil(t, out.Mutations, "matched route must emit mutations") + rewrite := out.Mutations.RewriteUpstream + require.NotNil(t, rewrite, "matched route must emit upstream rewrite") + assert.Equal(t, "https", rewrite.Scheme, "rewrite scheme must come from the matched route") + assert.Equal(t, "api.openai.com", rewrite.Host, "rewrite host must come from the matched route") + + assert.ElementsMatch(t, strippedAuthHeaders, rewrite.StripHeaders, + "strip list rides on UpstreamRewrite (bypasses framework denylist) and must cover every known vendor auth header") + require.NotNil(t, rewrite.AuthHeader, "router must inject the auth header via the rewrite (not HeadersAdd) so the proxy bypasses the denylist") + assert.Equal(t, "Authorization", rewrite.AuthHeader.Name, "injected header name must come from the route") + assert.Equal(t, "Bearer sk-test-123", rewrite.AuthHeader.Value, "injected header value must come from the route") + assert.Empty(t, out.Mutations.HeadersAdd, "router must not use HeadersAdd; auth flows through UpstreamRewrite.AuthHeader") + assert.Empty(t, out.Mutations.HeadersRemove, "router must not use HeadersRemove; strip flows through UpstreamRewrite.StripHeaders") + + resolved, ok := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) + require.True(t, ok, "router must emit llm.resolved_provider_id on a match") + assert.Equal(t, "openai-prod", resolved, "resolved provider id must be the matched route's ID") + dec, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyDecision) + assert.Equal(t, "allow", dec, "decision metadata must be allow on a match") +} + +func TestRouter_SkipTLSVerifyPropagates(t *testing.T) { + base := ProviderRoute{ + ID: "internal-gw", + Models: []string{"gpt-4o"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "gateway.internal", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer sk-test-123", + } + + t.Run("enabled", func(t *testing.T) { + route := base + route.SkipTLSVerify = true + mw := New(Config{Providers: []ProviderRoute{route}}) + + out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-4o")) + require.NoError(t, err) + require.NotNil(t, out.Mutations, "matched route must emit mutations") + require.NotNil(t, out.Mutations.RewriteUpstream, "matched route must emit upstream rewrite") + assert.True(t, out.Mutations.RewriteUpstream.SkipTLSVerify, + "skip_tls_verify on the route must ride on the upstream rewrite") + }) + + t.Run("default off", func(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{base}}) + + out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-4o")) + require.NoError(t, err) + require.NotNil(t, out.Mutations.RewriteUpstream, "matched route must emit upstream rewrite") + assert.False(t, out.Mutations.RewriteUpstream.SkipTLSVerify, + "skip_tls_verify must default to false when the route does not set it") + }) +} + +func TestRouter_MissingModel(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "openai-prod", + Models: []string{"gpt-4o"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + }}}) + + out, err := mw.Invoke(context.Background(), &middleware.Input{Slot: middleware.SlotOnRequest}) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "missing llm.model must deny") + assert.Equal(t, 403, out.DenyStatus, "deny status must be 403") + require.NotNil(t, out.DenyReason, "deny reason must be populated") + assert.Equal(t, "llm_policy.model_not_routable", out.DenyReason.Code, "deny code must be model_not_routable") + assert.Equal(t, "missing llm.model on request envelope", out.DenyReason.Message, "deny message must match spec") + + dec, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyDecision) + assert.Equal(t, "deny", dec, "decision metadata must be deny") + reason, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyReason) + assert.Equal(t, "model_not_routable", reason, "reason metadata must be model_not_routable") +} + +// newModellessInput returns an Input with no llm.model and the given +// request path, mimicking a GET /v1/models call (which carries no body +// from which a model could be parsed). UserGroups matches defaultTestGroup. +func newModellessInput(reqURL string) *middleware.Input { + return &middleware.Input{ + Slot: middleware.SlotOnRequest, + URL: reqURL, + UserGroups: []string{defaultTestGroup}, + } +} + +func TestRouter_ModelLessPath_RoutesToAuthorisedProvider(t *testing.T) { + route := ProviderRoute{ + ID: "openai-prod", + Models: []string{"gpt-4o"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + } + mw := New(Config{Providers: []ProviderRoute{route}}) + + out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models?client_version=1")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "GET /v1/models must pass through, not deny") + require.NotNil(t, out.Mutations, "a pass-through must rewrite the upstream") + require.NotNil(t, out.Mutations.RewriteUpstream, "model-less route must still rewrite to the real upstream") + assert.Equal(t, "api.openai.com", out.Mutations.RewriteUpstream.Host, "must target the authorised provider's host") + + provider, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) + assert.Equal(t, "openai-prod", provider, "resolved provider must be the authorised route") +} + +func TestRouter_ModelLessPath_MultiProviderDeclarationOrder(t *testing.T) { + first := ProviderRoute{ + ID: "openai-a", + Models: []string{"gpt-4o"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "a.example.com", + } + second := ProviderRoute{ + ID: "openai-b", + Models: []string{"gpt-4o-mini"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "b.example.com", + } + mw := New(Config{Providers: []ProviderRoute{first, second}}) + + out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "model-less path must pass through with multiple providers") + provider, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) + assert.Equal(t, "openai-a", provider, "no path-prefix match falls back to declaration order") +} + +func TestRouter_ModelLessPath_UnauthorisedDenies(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "openai-prod", + Models: []string{"gpt-4o"}, + AllowedGroupIDs: []string{"some-other-group"}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + }}}) + + out, err := mw.Invoke(context.Background(), newModellessInput("/v1/models")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "no provider authorising the caller must still deny") +} + +func TestRouter_NonModelLessBodilessStillDenies(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "openai-prod", + Models: []string{"gpt-4o"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + }}}) + + // A bodiless POST to an inference path has no model and is NOT a + // model-less endpoint, so it must keep denying. + out, err := mw.Invoke(context.Background(), newModellessInput("/v1/responses")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "bodiless inference request must still deny") + assert.Equal(t, "llm_policy.model_not_routable", out.DenyReason.Code, "deny code stays model_not_routable") +} + +// TestRouter_ExplicitModelBeatsCatchallGateway is the regression guard +// for multi-provider misrouting: a catch-all (empty Models) OpenAI-compat +// gateway declared first must NOT swallow a model an explicit provider +// claims. Anthropic's claude request must reach the Anthropic route even +// though the gateway claims every model and wins declaration order. +func TestRouter_ExplicitModelBeatsCatchallGateway(t *testing.T) { + gateway := ProviderRoute{ + ID: "openai-gateway", + Models: nil, // catch-all: claims every model + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + } + anthropic := ProviderRoute{ + ID: "anthropic-prod", + Models: []string{"claude-opus-4"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.anthropic.com", + } + // Gateway declared first to prove explicit claim beats declaration order. + mw := New(Config{Providers: []ProviderRoute{gateway, anthropic}}) + + out, err := mw.Invoke(context.Background(), newInputWithModelAndURL("claude-opus-4", "/v1/messages")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "explicit-model request must route, not deny") + require.NotNil(t, out.Mutations) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "api.anthropic.com", out.Mutations.RewriteUpstream.Host, "claude must reach the explicit Anthropic route, not the catch-all gateway") + + provider, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) + assert.Equal(t, "anthropic-prod", provider, "resolved provider must be the explicit Anthropic route") +} + +// TestRouter_CatchallStillServesUnlistedModel confirms the catch-all +// gateway still wins models no explicit provider claims (its whole point). +func TestRouter_CatchallStillServesUnlistedModel(t *testing.T) { + gateway := ProviderRoute{ + ID: "openai-gateway", + Models: nil, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "gateway.example.com", + } + anthropic := ProviderRoute{ + ID: "anthropic-prod", + Models: []string{"claude-opus-4"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.anthropic.com", + } + mw := New(Config{Providers: []ProviderRoute{gateway, anthropic}}) + + out, err := mw.Invoke(context.Background(), newInputWithModelAndURL("some-exotic-model", "/v1/chat/completions")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "unlisted model must still route via the catch-all") + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "gateway.example.com", out.Mutations.RewriteUpstream.Host, "unlisted model falls to the catch-all gateway") +} + +// newInputVendorModelURL returns an Input carrying both the detected +// vendor (llm.provider) and the model, plus a request URL — mimicking the +// post-llm_request_parser state for a real inference call. +func newInputVendorModelURL(vendor, model, reqURL string) *middleware.Input { + return &middleware.Input{ + Slot: middleware.SlotOnRequest, + URL: reqURL, + Metadata: []middleware.KV{ + {Key: middleware.KeyLLMProvider, Value: vendor}, + {Key: middleware.KeyLLMModel, Value: model}, + }, + UserGroups: []string{defaultTestGroup}, + } +} + +// TestRouter_VendorKeepsAnthropicOffOpenAIGateway is the regression guard +// for the reported multi-provider break: two catch-all providers (neither +// enumerates models), the OpenAI one declared first. Without vendor +// awareness, a claude request matches both, no path prefixes, and +// declaration order sends it to OpenAI → 502. The detected vendor must +// pin it to the Anthropic route. +func TestRouter_VendorKeepsAnthropicOffOpenAIGateway(t *testing.T) { + openai := ProviderRoute{ + ID: "openai-gw", + Vendor: "openai", + Models: nil, // catch-all + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + } + anthropic := ProviderRoute{ + ID: "anthropic-gw", + Vendor: "anthropic", + Models: nil, // catch-all + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.anthropic.com", + } + mw := New(Config{Providers: []ProviderRoute{openai, anthropic}}) // openai first + + out, err := mw.Invoke(context.Background(), newInputVendorModelURL("anthropic", "claude-opus-4-8", "/v1/messages")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "claude request must route, not deny") + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "api.anthropic.com", out.Mutations.RewriteUpstream.Host, "anthropic vendor must pin to the anthropic route despite openai being declared first") + + provider, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) + assert.Equal(t, "anthropic-gw", provider) +} + +// TestRouter_VendorKeepsOpenAIOffAnthropic is the reciprocal: an OpenAI +// request must stay on the OpenAI route even when the Anthropic catch-all +// is declared first. +func TestRouter_VendorKeepsOpenAIOffAnthropic(t *testing.T) { + anthropic := ProviderRoute{ + ID: "anthropic-gw", + Vendor: "anthropic", + Models: nil, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.anthropic.com", + } + openai := ProviderRoute{ + ID: "openai-gw", + Vendor: "openai", + Models: nil, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + } + mw := New(Config{Providers: []ProviderRoute{anthropic, openai}}) // anthropic first + + out, err := mw.Invoke(context.Background(), newInputVendorModelURL("openai", "gpt-5.5", "/v1/responses")) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "api.openai.com", out.Mutations.RewriteUpstream.Host, "openai vendor must pin to the openai route despite anthropic being declared first") +} + +// TestRouter_VendorAbsentFallsBackToModelPath confirms vendor filtering is +// inert when the request carries no detected vendor: routing then relies on +// model/path as before. +func TestRouter_VendorAbsentFallsBackToModelPath(t *testing.T) { + openai := ProviderRoute{ + ID: "openai-gw", + Vendor: "openai", + Models: []string{"gpt-5.5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + } + mw := New(Config{Providers: []ProviderRoute{openai}}) + + // No llm.provider in metadata — only the model. + out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-5.5")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "explicit-model match must still route with no vendor present") +} + +func TestRouter_UnknownModel(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "openai-prod", + Models: []string{"gpt-4o"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + }}}) + + out, err := mw.Invoke(context.Background(), newInputWithModel("claude-opus-4")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "unrouted model must deny") + assert.Equal(t, 403, out.DenyStatus, "deny status must be 403") + require.NotNil(t, out.DenyReason, "deny reason must be populated") + assert.Equal(t, "llm_policy.model_not_routable", out.DenyReason.Code, "deny code must be model_not_routable") + assert.Equal(t, "no provider configured for model claude-opus-4", out.DenyReason.Message, "deny message must reference the offending model") + assert.Equal(t, "claude-opus-4", out.DenyReason.Details["model"], "deny details must include the offending model") +} + +func TestRouter_HeaderStripList(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "openai-prod", + Models: []string{"gpt-4o"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer sk-test-123", + }}}) + + out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-4o")) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.Mutations, "matched route must emit mutations") + + expected := []string{ + "Authorization", + "Proxy-Authorization", + "x-api-key", + "api-key", + } + require.NotNil(t, out.Mutations.RewriteUpstream, "matched route must emit upstream rewrite") + for _, header := range expected { + assert.Contains(t, out.Mutations.RewriteUpstream.StripHeaders, header, + "strip list (on UpstreamRewrite) must include the well-known vendor auth header %s", header) + } + + // Vendor metadata headers MUST NOT be stripped: the client SDK sets them + // and the upstream requires them. Anthropic returns 400 "anthropic-version: + // header is required" if we drop it. Lock the regression. + preserved := []string{"anthropic-version", "openai-organization", "openai-project"} + for _, header := range preserved { + assert.NotContains(t, out.Mutations.RewriteUpstream.StripHeaders, header, + "vendor metadata header %s must NOT be stripped — upstreams require it", header) + } +} + +func TestRouter_FirstMatchWins(t *testing.T) { + first := ProviderRoute{ + ID: "first", + Models: []string{"gpt-4o"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "first.test", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer first", + } + second := ProviderRoute{ + ID: "second", + Models: []string{"gpt-4o"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "second.test", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer second", + } + mw := New(Config{Providers: []ProviderRoute{first, second}}) + + out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-4o")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "duplicate-model match must still allow") + require.NotNil(t, out.Mutations, "matched route must emit mutations") + require.NotNil(t, out.Mutations.RewriteUpstream, "matched route must emit upstream rewrite") + assert.Equal(t, "first.test", out.Mutations.RewriteUpstream.Host, "first-match-wins must pick the earlier route") + + resolved, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) + assert.Equal(t, "first", resolved, "resolved provider id must be the earlier route's ID") +} + +// TestRouter_PathDisambiguation_PrefixWinsOverCatchall locks in the +// rule the user nailed down: two providers claim the same model, one +// has an UpstreamPath that prefixes the incoming URL, the other has +// no path. The path-prefixed provider wins because the path is a +// strictly more specific match than the empty catchall. +func TestRouter_PathDisambiguation_PrefixWinsOverCatchall(t *testing.T) { + corp := ProviderRoute{ + ID: "corp-openai-compat", + Models: []string{"gpt-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "corp.example.com", + UpstreamPath: "/openai", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer corp", + } + openai := ProviderRoute{ + ID: "openai", + Models: []string{"gpt-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer openai", + } + mw := New(Config{Providers: []ProviderRoute{openai, corp}}) // openai listed first to prove path beats declaration order + + out, err := mw.Invoke(context.Background(), newInputWithModelAndURL("gpt-5", "/openai/v1/chat/completions")) + require.NoError(t, err) + require.NotNil(t, out) + require.Equal(t, middleware.DecisionAllow, out.Decision, "path-prefix match must allow") + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "corp.example.com", out.Mutations.RewriteUpstream.Host, + "path-prefixed provider must beat the catchall when its UpstreamPath is a prefix of the request path") + resolved, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) + assert.Equal(t, "corp-openai-compat", resolved, "resolved provider id must reflect the path-prefix winner, not the first declared") +} + +// TestRouter_PathDisambiguation_CatchallWhenNoPrefixMatches is the +// inverse: the path-prefixed provider does NOT match the incoming +// path, so the empty-path catchall takes the request. +func TestRouter_PathDisambiguation_CatchallWhenNoPrefixMatches(t *testing.T) { + corp := ProviderRoute{ + ID: "corp-openai-compat", + Models: []string{"gpt-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "corp.example.com", + UpstreamPath: "/openai", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer corp", + } + openai := ProviderRoute{ + ID: "openai", + Models: []string{"gpt-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer openai", + } + mw := New(Config{Providers: []ProviderRoute{corp, openai}}) + + out, err := mw.Invoke(context.Background(), newInputWithModelAndURL("gpt-5", "/v1/chat/completions")) + require.NoError(t, err) + require.NotNil(t, out) + require.Equal(t, middleware.DecisionAllow, out.Decision, "catchall must allow when no path prefix matches") + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "api.openai.com", out.Mutations.RewriteUpstream.Host, + "empty-path catchall must win when the path-prefixed provider's UpstreamPath does not match the request") + resolved, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) + assert.Equal(t, "openai", resolved, "resolved provider id must be the catchall") +} + +// TestRouter_PathDisambiguation_LongestPrefixWins covers the case +// where multiple providers have non-empty UpstreamPath values that +// both prefix the request — the longer (more specific) one wins. +func TestRouter_PathDisambiguation_LongestPrefixWins(t *testing.T) { + short := ProviderRoute{ + ID: "short-prefix", + Models: []string{"gpt-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "short.example.com", + UpstreamPath: "/openai", + } + long := ProviderRoute{ + ID: "long-prefix", + Models: []string{"gpt-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "long.example.com", + UpstreamPath: "/openai/v1", + } + mw := New(Config{Providers: []ProviderRoute{short, long}}) + + out, err := mw.Invoke(context.Background(), newInputWithModelAndURL("gpt-5", "/openai/v1/chat/completions")) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "long.example.com", out.Mutations.RewriteUpstream.Host, + "longest matching UpstreamPath must win — most specific match") +} + +// TestRouter_SingleMatchIgnoresPath proves the path-prefix rule is a +// disambiguation pass, not a gate: when only one provider claims the +// model, it wins regardless of UpstreamPath. Otherwise a path-scoped +// provider would 403 every request whose URL doesn't include the +// path, which would break SDKs configured to hit the gateway root. +func TestRouter_SingleMatchIgnoresPath(t *testing.T) { + only := ProviderRoute{ + ID: "only", + Models: []string{"gpt-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "only.example.com", + UpstreamPath: "/openai", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer only", + } + mw := New(Config{Providers: []ProviderRoute{only}}) + + out, err := mw.Invoke(context.Background(), newInputWithModelAndURL("gpt-5", "/v1/chat/completions")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, + "single model-matching provider must serve the request even when UpstreamPath doesn't prefix the URL — path is a tiebreaker, not a gate") + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "only.example.com", out.Mutations.RewriteUpstream.Host, "the only model-matching provider should be selected") +} + +// TestRouter_PathDisambiguation_FallbackWhenNoPrefixMatches covers +// the multi-candidate edge case where every candidate has a +// non-matching non-empty UpstreamPath. The router falls back to +// declaration order so the model is still routable rather than 403'd. +func TestRouter_PathDisambiguation_FallbackWhenNoPrefixMatches(t *testing.T) { + first := ProviderRoute{ + ID: "first", + Models: []string{"gpt-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "first.example.com", + UpstreamPath: "/openai", + } + second := ProviderRoute{ + ID: "second", + Models: []string{"gpt-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "second.example.com", + UpstreamPath: "/anthropic", + } + mw := New(Config{Providers: []ProviderRoute{first, second}}) + + out, err := mw.Invoke(context.Background(), newInputWithModelAndURL("gpt-5", "/v1/chat/completions")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "no path match among multi-candidates must still allow") + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "first.example.com", out.Mutations.RewriteUpstream.Host, + "when no candidate's UpstreamPath prefix-matches the request, fall back to declaration order") +} + +func TestRouter_FactoryRejectsBadJSON(t *testing.T) { + _, err := Factory{}.New([]byte("{not json")) + require.Error(t, err, "malformed JSON config must be rejected at chain build time") +} + +func TestRouter_FactoryAcceptsEmptyShapes(t *testing.T) { + cases := [][]byte{nil, []byte(""), []byte(" "), []byte("null"), []byte("{}"), []byte("[]")} + for _, raw := range cases { + mw, err := Factory{}.New(raw) + require.NoError(t, err, "empty-shaped config must yield a router with an empty Providers slice") + require.NotNil(t, mw, "factory must return a non-nil middleware on empty config") + + out, invErr := mw.Invoke(context.Background(), newInputWithModel("gpt-4o")) + require.NoError(t, invErr) + assert.Equal(t, middleware.DecisionDeny, out.Decision, + "router with no providers must deny every model as not-routable") + } +} + +// newInputWithModelAndGroups returns an Input carrying llm.model + the +// caller's UserGroups, mimicking the post-auth, post-llm_request_parser +// state the router observes. +func newInputWithModelAndGroups(model string, groups []string) *middleware.Input { + in := newInputWithModel(model) + in.UserGroups = append([]string(nil), groups...) + return in +} + +// TestRouter_GroupFilter_PicksAuthorisedAmongDuplicates pins the Fix A +// behaviour: when two providers claim the same model but each +// authorises a different group, the router must pick the route the +// caller's groups intersect, regardless of declaration order. +func TestRouter_GroupFilter_PicksAuthorisedAmongDuplicates(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{ + { + ID: "openai-marketing", + Models: []string{"gpt-4o-mini"}, + UpstreamScheme: "https", + UpstreamHost: "mkt-openai.example.com", + AllowedGroupIDs: []string{"grp-mkt"}, + }, + { + ID: "openai-engineering", + Models: []string{"gpt-4o-mini"}, + UpstreamScheme: "https", + UpstreamHost: "eng-openai.example.com", + AllowedGroupIDs: []string{"grp-eng"}, + }, + }}) + + out, err := mw.Invoke(context.Background(), + newInputWithModelAndGroups("gpt-4o-mini", []string{"grp-eng"})) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, + "authorised candidate exists; must allow") + + resolved, ok := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) + require.True(t, ok) + assert.Equal(t, "openai-engineering", resolved, + "router must pick the route whose AllowedGroupIDs intersects the caller's groups, ignoring declaration order") +} + +// TestRouter_GroupFilter_NoIntersection_DeniesNoAuthorisedRoute pins +// the dedicated deny code that fires when the model is known to a +// provider but no candidate is authorised for the caller's groups. +func TestRouter_GroupFilter_NoIntersection_DeniesNoAuthorisedRoute(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "openai-marketing", + Models: []string{"gpt-4o-mini"}, + UpstreamScheme: "https", + UpstreamHost: "mkt-openai.example.com", + AllowedGroupIDs: []string{"grp-mkt"}, + }}}) + + out, err := mw.Invoke(context.Background(), + newInputWithModelAndGroups("gpt-4o-mini", []string{"grp-eng"})) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, + "model exists but no route authorises grp-eng; must deny") + require.NotNil(t, out.DenyReason) + assert.Equal(t, "llm_policy.no_authorised_provider", out.DenyReason.Code, + "deny code must be no_authorised_provider, not model_not_routable") + assert.Equal(t, "gpt-4o-mini", out.DenyReason.Details["model"], + "deny details must reference the offending model") + + dec, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyDecision) + assert.Equal(t, "deny", dec) + reason, _ := metaValue(t, out.Metadata, middleware.KeyLLMPolicyReason) + assert.Equal(t, "no_authorised_provider", reason) +} + +// TestRouter_GroupFilter_EmptyAllowedGroupsIsUnreachable pins the +// strict semantics: a route with no AllowedGroupIDs is unreachable. +// The synthesiser only emits policy-bound routes, so an empty ACL +// signals a misconfiguration that must not silently fall through. +func TestRouter_GroupFilter_EmptyAllowedGroupsIsUnreachable(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "openai-shared", + Models: []string{"gpt-4o"}, + UpstreamScheme: "https", + UpstreamHost: "api.openai.com", + // AllowedGroupIDs intentionally left empty. + }}}) + + out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-4o")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionDeny, out.Decision, + "empty AllowedGroupIDs must deny — there is no catch-all for routes without an authorising policy") + require.NotNil(t, out.DenyReason) + assert.Equal(t, "llm_policy.no_authorised_provider", out.DenyReason.Code, + "empty ACL fails the group-filter pass; deny code must reflect that") +} + +// TestRouter_GroupFilter_OverlapTiebreakUnchanged pins that when more +// than one route is authorised for the caller's groups, the existing +// path-prefix tiebreak still decides. Group filtering is a hard gate +// before the tiebreak; it does not change the tiebreak semantics. +func TestRouter_GroupFilter_OverlapTiebreakUnchanged(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{ + { + ID: "openai-a", + Models: []string{"gpt-4o-mini"}, + UpstreamScheme: "https", + UpstreamHost: "a.example.com", + UpstreamPath: "", + AllowedGroupIDs: []string{"grp-eng"}, + }, + { + ID: "openai-b", + Models: []string{"gpt-4o-mini"}, + UpstreamScheme: "https", + UpstreamHost: "b.example.com", + UpstreamPath: "/v1/chat", + AllowedGroupIDs: []string{"grp-eng"}, + }, + }}) + + in := newInputWithModelAndURL("gpt-4o-mini", "/v1/chat/completions") + in.UserGroups = []string{"grp-eng"} + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision) + + resolved, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) + assert.Equal(t, "openai-b", resolved, + "longest-prefix path tiebreak still wins among group-authorised candidates") +} + +// TestRouter_AuthorisingGroups_EmitsIntersection pins that the router +// emits llm.authorising_groups containing only the intersection of the +// caller's UserGroups with the resolved route's AllowedGroupIDs — not +// every group the peer happens to be in. +func TestRouter_AuthorisingGroups_EmitsIntersection(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "openai-eng", + Models: []string{"gpt-4o-mini"}, + UpstreamScheme: "https", + UpstreamHost: "eng-openai.example.com", + AllowedGroupIDs: []string{"grp-eng", "grp-shared"}, + }}}) + + in := newInputWithModelAndGroups("gpt-4o-mini", + []string{"grp-eng", "grp-it", "grp-shared", "grp-oncall"}) + + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + require.Equal(t, middleware.DecisionAllow, out.Decision) + + csv, ok := metaValue(t, out.Metadata, middleware.KeyLLMAuthorisingGroups) + require.True(t, ok, "router must emit llm.authorising_groups on a match") + assert.Equal(t, "grp-eng,grp-shared", csv, + "only groups in BOTH UserGroups AND AllowedGroupIDs may appear; result must be sorted and unique") +} + +// TestRouter_EmptyModelsClaimsAnyModel pins that a route with no +// configured Models matches every model — used by gateway-style +// providers (LiteLLM, custom OpenAI-compatible endpoints) where the +// operator can't enumerate the upstream's model catalog in NetBird. +func TestRouter_EmptyModelsClaimsAnyModel(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{{ + ID: "litellm", + Models: nil, // catch-all + UpstreamScheme: "https", + UpstreamHost: "litellm.example.com", + AllowedGroupIDs: []string{defaultTestGroup}, + }}}) + + out, err := mw.Invoke(context.Background(), newInputWithModel("gpt-5.5")) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, middleware.DecisionAllow, out.Decision, + "a route with empty Models must claim any model so gateway-style providers can route open-ended sets") + resolved, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID) + assert.Equal(t, "litellm", resolved) +} diff --git a/proxy/internal/middleware/builtin/llm_router/path_routed_test.go b/proxy/internal/middleware/builtin/llm_router/path_routed_test.go new file mode 100644 index 000000000..7dbd6b936 --- /dev/null +++ b/proxy/internal/middleware/builtin/llm_router/path_routed_test.go @@ -0,0 +1,159 @@ +package llm_router + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" +) + +// pathRoutedInput builds an Input mimicking the post-llm_request_parser state +// for a path-routed (Vertex/Bedrock) request: a request URL plus the model and +// (optionally) provider/vendor metadata the parser emits. +func pathRoutedInput(url, provider, model string) *middleware.Input { + md := []middleware.KV{{Key: middleware.KeyLLMModel, Value: model}} + if provider != "" { + md = append(md, middleware.KV{Key: middleware.KeyLLMProvider, Value: provider}) + } + return &middleware.Input{ + Slot: middleware.SlotOnRequest, + URL: url, + Metadata: md, + UserGroups: []string{defaultTestGroup}, + } +} + +func vertexRoute() ProviderRoute { + return ProviderRoute{ + ID: "vertex-prod", Vertex: true, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "europe-west1-aiplatform.googleapis.com", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer x", + } +} + +// A Vertex publisher with no parser surface (google/gemini emits no +// llm.provider) must be denied, not forwarded unmetered. +func TestRouter_VertexUnmeterablePublisherDenied(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{vertexRoute()}}) + in := pathRoutedInput( + "/v1/projects/p/locations/global/publishers/google/models/gemini-2.5-pro:generateContent", + "", // google -> request parser emits NO llm.provider + "gemini-2.5-pro", + ) + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "unmeterable Vertex publisher must deny") + assert.Equal(t, 403, out.DenyStatus, "unmeterable deny is a 403") + require.NotNil(t, out.DenyReason) + assert.Equal(t, denyCodeUnmeterable, out.DenyReason.Code, "deny code must flag the unmeterable publisher") +} + +// A Vertex publisher with a parser surface (anthropic) is allowed. +func TestRouter_VertexMeterablePublisherAllowed(t *testing.T) { + mw := New(Config{Providers: []ProviderRoute{vertexRoute()}}) + in := pathRoutedInput( + "/v1/projects/p/locations/global/publishers/anthropic/models/claude-sonnet-4-5:rawPredict", + "anthropic", + "claude-sonnet-4-5", + ) + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "meterable Vertex publisher must allow") +} + +// A path-routed provider with an explicit Models list must reject models not in +// the list (the provider credential can't be used for unauthorised models). +func TestRouter_PathRoutedModelAllowlistEnforced(t *testing.T) { + route := ProviderRoute{ + ID: "bedrock-prod", Bedrock: true, + Models: []string{"anthropic.claude-sonnet-4-5"}, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "bedrock-runtime.eu-central-1.amazonaws.com", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer x", + } + mw := New(Config{Providers: []ProviderRoute{route}}) + + allowed := pathRoutedInput( + "/model/eu.anthropic.claude-sonnet-4-5-20250929-v1:0/invoke", + "bedrock", "anthropic.claude-sonnet-4-5", + ) + out, err := mw.Invoke(context.Background(), allowed) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "model in the allowlist must be served") + + denied := pathRoutedInput( + "/model/amazon.nova-pro-v1:0/invoke", + "bedrock", "amazon.nova-pro", + ) + out, err = mw.Invoke(context.Background(), denied) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionDeny, out.Decision, "model outside the allowlist must deny") + require.NotNil(t, out.DenyReason) + assert.Equal(t, denyCodeNotRoutable, out.DenyReason.Code, "unlisted model denies as not-routable") +} + +// A "/bedrock" gateway-namespace prefix routes the same as the native path and +// records the prefix on the rewrite so the proxy strips it before forwarding. +func TestRouter_BedrockNamespacePrefixStripped(t *testing.T) { + route := ProviderRoute{ + ID: "bedrock-prod", Bedrock: true, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "bedrock-runtime.eu-central-1.amazonaws.com", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer x", + } + mw := New(Config{Providers: []ProviderRoute{route}}) + + prefixed := pathRoutedInput( + "/bedrock/model/eu.anthropic.claude-sonnet-4-5-20250929-v1:0/invoke-with-response-stream", + "bedrock", "anthropic.claude-sonnet-4-5", + ) + out, err := mw.Invoke(context.Background(), prefixed) + require.NoError(t, err) + require.Equal(t, middleware.DecisionAllow, out.Decision, "prefixed Bedrock path must route") + require.NotNil(t, out.Mutations) + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Equal(t, "/bedrock", out.Mutations.RewriteUpstream.StripPathPrefix, + "namespace prefix must be recorded so the proxy strips it before forwarding") + + native := pathRoutedInput( + "/model/eu.anthropic.claude-sonnet-4-5-20250929-v1:0/invoke", + "bedrock", "anthropic.claude-sonnet-4-5", + ) + out, err = mw.Invoke(context.Background(), native) + require.NoError(t, err) + require.Equal(t, middleware.DecisionAllow, out.Decision, "native Bedrock path must route") + require.NotNil(t, out.Mutations.RewriteUpstream) + assert.Empty(t, out.Mutations.RewriteUpstream.StripPathPrefix, + "native path carries no namespace prefix to strip") +} + +// A path-routed provider with no configured Models is catch-all: any model the +// credential can reach is served (preserves the zero-config behaviour). +func TestRouter_PathRoutedCatchAllServesAnyModel(t *testing.T) { + route := ProviderRoute{ + ID: "bedrock-catchall", Bedrock: true, + AllowedGroupIDs: []string{defaultTestGroup}, + UpstreamScheme: "https", + UpstreamHost: "bedrock-runtime.eu-central-1.amazonaws.com", + AuthHeaderName: "Authorization", + AuthHeaderValue: "Bearer x", + } + mw := New(Config{Providers: []ProviderRoute{route}}) + in := pathRoutedInput( + "/model/amazon.nova-pro-v1:0/invoke", + "bedrock", "amazon.nova-pro", + ) + out, err := mw.Invoke(context.Background(), in) + require.NoError(t, err) + assert.Equal(t, middleware.DecisionAllow, out.Decision, "catch-all path-routed provider serves any model") +} diff --git a/proxy/internal/middleware/chain.go b/proxy/internal/middleware/chain.go new file mode 100644 index 000000000..45d32cdb0 --- /dev/null +++ b/proxy/internal/middleware/chain.go @@ -0,0 +1,320 @@ +package middleware + +import ( + "context" + "net/http" + "sync" +) + +// boundMiddleware pairs a validated spec with the resolved middleware +// instance the chain will invoke. +type boundMiddleware struct { + spec Spec + mw Middleware +} + +// Chain is the ordered set of middlewares that run for a specific +// target. Chains are immutable once built; Manager produces a new +// Chain on every Rebuild. +// +// Ordering: middlewares are kept in registration order. RunRequest +// iterates the SlotOnRequest middlewares in order; RunResponse +// iterates the SlotOnResponse middlewares in reverse order +// (middleware-style LIFO so the last to see the request is the first +// to see the response); RunTerminal iterates the SlotTerminal +// middlewares in registration order, after every on_response slot has +// emitted, so the metadata bag they observe is complete. +// +// Close drains in-flight invocations and tears down each middleware. +// Callers swapping a chain via Manager invoke Close on the old chain +// after the swap so live requests finish on the previous instance. +type Chain struct { + targetID string + all []boundMiddleware + onRequest []int + onResponse []int + terminal []int + dispatcher *Dispatcher + inflight sync.WaitGroup +} + +// NewChain assembles a Chain from the bound middlewares. The slice +// order is the registration order; the chain captures index slices +// per slot so iteration does not re-scan the slot field per call. +func NewChain(targetID string, bound []boundMiddleware, d *Dispatcher) *Chain { + c := &Chain{ + targetID: targetID, + all: bound, + dispatcher: d, + } + for i, bm := range bound { + switch bm.spec.Slot { + case SlotOnRequest: + c.onRequest = append(c.onRequest, i) + case SlotOnResponse: + c.onResponse = append(c.onResponse, i) + case SlotTerminal: + c.terminal = append(c.terminal, i) + } + } + return c +} + +// Close waits for outstanding invocations against this chain to +// finish (bounded by ctx) and releases the middleware instances bound +// to it. Safe to call once the chain has been removed from the +// routing snapshot. Subsequent Run* calls are still safe (return +// without invoking) but Close itself is one-shot. +func (c *Chain) Close(ctx context.Context) error { + if c == nil { + return nil + } + if ctx == nil { + ctx = context.Background() + } + done := make(chan struct{}) + go func() { + c.inflight.Wait() + close(done) + }() + select { + case <-done: + case <-ctx.Done(): + // Drain timed out: requests may still be running against these + // middleware instances, so tearing them down now risks a + // use-after-close. Leave them (a bounded leak) and surface the + // timeout; the runaway backstop in the Manager already alerts. + return ctx.Err() + } + for _, bm := range c.all { + if bm.mw == nil { + continue + } + if err := bm.mw.Close(); err != nil { + c.dispatcher.logger.Debugf("middleware %s close: %v", bm.spec.ID, err) + } + } + return nil +} + +// Empty reports whether the chain has no middlewares. +func (c *Chain) Empty() bool { + return c == nil || len(c.all) == 0 +} + +// TargetID returns the key used to find this chain. +func (c *Chain) TargetID() string { + if c == nil { + return "" + } + return c.targetID +} + +// IDs returns the ordered list of middleware IDs bound to this chain. +func (c *Chain) IDs() []string { + if c == nil { + return nil + } + out := make([]string, len(c.all)) + for i, bm := range c.all { + out[i] = bm.spec.ID + } + return out +} + +// RunRequest iterates the on_request slot in registration order. Deny +// short-circuits the remaining middlewares and returns the deny +// output. The caller owns applying mutations to the real request and +// merging the metadata returned in `merged` into the captured-data +// bag passed to subsequent slots. +// +// Each middleware sees the metadata emitted by earlier middlewares in +// the same slot — this is how llm_guardrail reads +// llm.request_prompt_raw from llm_request_parser without a side +// channel, and how cost_meter reads tokens emitted by +// llm_response_parser on the response leg. +// +// If any middleware emits a non-nil Mutations.RewriteUpstream while +// satisfying the mutation gates (CanMutate && MutationsSupported), the +// latest such value is returned to the caller. Last-write-wins so the +// last middleware in the slot can override an earlier rewrite. +func (c *Chain) RunRequest(ctx context.Context, r *http.Request, in *Input, acc *Accumulator) (denied *Output, merged []KV, rewrite *UpstreamRewrite, err error) { + if c.Empty() || len(c.onRequest) == 0 { + return nil, nil, nil, nil + } + c.inflight.Add(1) + defer c.inflight.Done() + running := append([]KV(nil), in.Metadata...) + for _, idx := range c.onRequest { + bm := c.all[idx] + call := cloneInputFor(in, SlotOnRequest) + call.Metadata = append([]KV(nil), running...) + out, invErr := c.dispatcher.Invoke(ctx, bm.spec, bm.mw, call) + if invErr != nil && out == nil { + continue + } + if out == nil { + continue + } + + accepted, rejected := acc.Emit(bm.spec.ID, bm.spec.MetadataKeys, out.Metadata) + for _, rej := range rejected { + c.dispatcher.metrics.IncMetadataRejected(ctx, bm.spec.ID, rej.Reason) + } + merged = append(merged, accepted...) + running = append(running, accepted...) + + if out.Decision == DecisionDeny { + c.dispatcher.metrics.IncRequest(ctx, bm.spec.ID, c.targetID, "deny") + return out, merged, rewrite, nil + } + c.dispatcher.metrics.IncRequest(ctx, bm.spec.ID, c.targetID, "allow") + + if rw := mutationRewrite(bm.spec, out.Mutations); rw != nil { + rewrite = rw + } + if r != nil && bm.spec.CanMutate && out.Mutations != nil { + applyMutations(ctx, c.dispatcher, bm.spec, r, out.Mutations) + } + } + return nil, merged, rewrite, nil +} + +// RunResponse iterates the on_response slot in reverse registration +// order, matching the middleware "last in, first out" convention so +// the last middleware to see the request is the first to see the +// response. Middlewares cannot deny; they emit metadata. +// +// As with RunRequest, each middleware sees the metadata emitted by +// earlier middlewares in this slot — accumulated in the order the +// middlewares run (LIFO of registration). cost_meter relies on this +// to read llm.input_tokens / llm.output_tokens that +// llm_response_parser emitted just before it. +func (c *Chain) RunResponse(ctx context.Context, in *Input, acc *Accumulator) (merged []KV) { + if c.Empty() || len(c.onResponse) == 0 { + return nil + } + c.inflight.Add(1) + defer c.inflight.Done() + running := append([]KV(nil), in.Metadata...) + for i := len(c.onResponse) - 1; i >= 0; i-- { + bm := c.all[c.onResponse[i]] + call := cloneInputFor(in, SlotOnResponse) + call.Metadata = append([]KV(nil), running...) + out, _ := c.dispatcher.Invoke(ctx, bm.spec, bm.mw, call) + if out == nil { + continue + } + accepted, rejected := acc.Emit(bm.spec.ID, bm.spec.MetadataKeys, out.Metadata) + for _, rej := range rejected { + c.dispatcher.metrics.IncMetadataRejected(ctx, bm.spec.ID, rej.Reason) + } + merged = append(merged, accepted...) + running = append(running, accepted...) + c.dispatcher.metrics.IncRequest(ctx, bm.spec.ID, c.targetID, "passthrough") + } + return merged +} + +// RunTerminal iterates the terminal slot in registration order, after +// every on_response middleware has emitted. Terminal middlewares +// observe the full metadata bag carried in `in.Metadata` plus any +// emissions from terminal middlewares that ran before them; they +// cannot deny and cannot mutate. +func (c *Chain) RunTerminal(ctx context.Context, in *Input, acc *Accumulator) (merged []KV) { + if c.Empty() || len(c.terminal) == 0 { + return nil + } + c.inflight.Add(1) + defer c.inflight.Done() + running := append([]KV(nil), in.Metadata...) + for _, idx := range c.terminal { + bm := c.all[idx] + call := cloneInputFor(in, SlotTerminal) + call.Metadata = append([]KV(nil), running...) + out, _ := c.dispatcher.Invoke(ctx, bm.spec, bm.mw, call) + if out == nil { + continue + } + accepted, rejected := acc.Emit(bm.spec.ID, bm.spec.MetadataKeys, out.Metadata) + for _, rej := range rejected { + c.dispatcher.metrics.IncMetadataRejected(ctx, bm.spec.ID, rej.Reason) + } + merged = append(merged, accepted...) + running = append(running, accepted...) + c.dispatcher.metrics.IncRequest(ctx, bm.spec.ID, c.targetID, "terminal") + } + return merged +} + +// mutationRewrite returns the upstream rewrite carried in m when the +// spec's mutation gates allow it. The rewrite itself is not applied +// here; the caller (reverse proxy) decides whether to honour it. +func mutationRewrite(spec Spec, m *Mutations) *UpstreamRewrite { + if m == nil || m.RewriteUpstream == nil { + return nil + } + if !spec.CanMutate || !spec.MutationsSupported { + return nil + } + return m.RewriteUpstream +} + +func applyMutations(ctx context.Context, d *Dispatcher, spec Spec, r *http.Request, m *Mutations) { + if m == nil { + return + } + add, remove, blocked := FilterHeaderMutations(m) + for _, h := range blocked { + d.metrics.IncHeaderMutationBlocked(ctx, spec.ID, h) + } + for _, name := range remove { + r.Header.Del(name) + } + for _, kv := range add { + r.Header.Add(kv.Key, kv.Value) + } + if len(m.BodyReplace) == 0 { + return + } + if err := ValidateBodyReplace(r, m.BodyReplace, true); err != nil { + d.logger.Warnf("middleware %s body replace rejected: %v", spec.ID, err) + return + } + ApplyBodyReplace(r, m.BodyReplace) +} + +// cloneInputFor deep-copies the mutation-prone fields of Input so +// each middleware receives an isolated view. +func cloneInputFor(in *Input, slot Slot) *Input { + if in == nil { + return nil + } + out := *in + out.Slot = slot + out.Headers = cloneKVs(in.Headers) + out.RespHeaders = cloneKVs(in.RespHeaders) + out.Metadata = cloneKVs(in.Metadata) + if len(in.UserGroups) > 0 { + out.UserGroups = append([]string(nil), in.UserGroups...) + } + if len(in.UserGroupNames) > 0 { + out.UserGroupNames = append([]string(nil), in.UserGroupNames...) + } + if len(in.Body) > 0 { + out.Body = append([]byte(nil), in.Body...) + } + if len(in.RespBody) > 0 { + out.RespBody = append([]byte(nil), in.RespBody...) + } + return &out +} + +func cloneKVs(in []KV) []KV { + if len(in) == 0 { + return nil + } + out := make([]KV, len(in)) + copy(out, in) + return out +} diff --git a/proxy/internal/middleware/chain_test.go b/proxy/internal/middleware/chain_test.go new file mode 100644 index 000000000..929ccee08 --- /dev/null +++ b/proxy/internal/middleware/chain_test.go @@ -0,0 +1,370 @@ +package middleware + +import ( + "context" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeMiddleware is a minimal Middleware for chain composition tests. +// It records the metadata the dispatcher hands to it and emits a +// caller-supplied Output. Tests use the recorded snapshot to assert +// that earlier-in-slot emissions are visible to later middlewares. +type fakeMiddleware struct { + id string + slot Slot + keys []string + emit []KV + decision Decision + mutationsSupported bool + canMutate bool + mutations *Mutations + + // seen captures the in.Metadata snapshot the dispatcher passed to + // Invoke, so tests can assert ordering and visibility. + seen []KV +} + +func (f *fakeMiddleware) ID() string { return f.id } +func (f *fakeMiddleware) Version() string { return "test" } +func (f *fakeMiddleware) Slot() Slot { return f.slot } +func (f *fakeMiddleware) AcceptedContentTypes() []string { return nil } +func (f *fakeMiddleware) MetadataKeys() []string { return f.keys } +func (f *fakeMiddleware) MutationsSupported() bool { return f.mutationsSupported } +func (f *fakeMiddleware) Close() error { return nil } + +func (f *fakeMiddleware) Invoke(_ context.Context, in *Input) (*Output, error) { + f.seen = append([]KV(nil), in.Metadata...) + out := &Output{Decision: f.decision, Metadata: append([]KV(nil), f.emit...)} + if f.mutations != nil { + m := *f.mutations + out.Mutations = &m + } + return out, nil +} + +// chainFor builds a Chain over the given middlewares with a noop +// dispatcher. +func chainFor(t *testing.T, mws ...*fakeMiddleware) *Chain { + t.Helper() + bound := make([]boundMiddleware, len(mws)) + for i, mw := range mws { + bound[i] = boundMiddleware{ + spec: Spec{ + ID: mw.id, + Slot: mw.slot, + Enabled: true, + MetadataKeys: mw.keys, + CanMutate: mw.canMutate, + MutationsSupported: mw.mutationsSupported, + }, + mw: mw, + } + } + disp := NewDispatcher(nil, nil) + return NewChain("t-1", bound, disp) +} + +// TestChain_RunRequest_ThreadsMetadataAcrossMiddlewares locks that +// each on_request middleware sees metadata emitted by earlier +// middlewares in the same slot. Regression cover for the original +// chain.go where every iteration cloned from the same source `in` and +// later middlewares (e.g. llm_guardrail) couldn't read what the first +// (e.g. llm_request_parser) had just emitted. +func TestChain_RunRequest_ThreadsMetadataAcrossMiddlewares(t *testing.T) { + first := &fakeMiddleware{ + id: "first", + slot: SlotOnRequest, + keys: []string{"foo.k"}, + emit: []KV{{Key: "foo.k", Value: "v"}}, + } + second := &fakeMiddleware{ + id: "second", + slot: SlotOnRequest, + keys: []string{"bar.k"}, + emit: []KV{{Key: "bar.k", Value: "z"}}, + } + c := chainFor(t, first, second) + acc := NewAccumulator(0) + + denied, merged, rewrite, err := c.RunRequest(context.Background(), nil, &Input{}, acc) + require.NoError(t, err) + assert.Nil(t, denied, "no deny without DecisionDeny") + assert.Nil(t, rewrite, "no rewrite without Mutations.RewriteUpstream") + + require.Len(t, second.seen, 1, "the second middleware must observe one prior emission") + assert.Equal(t, "foo.k", second.seen[0].Key, "second middleware must see the first middleware's key") + assert.Equal(t, "v", second.seen[0].Value, "second middleware must see the first middleware's value") + + require.Len(t, merged, 2, "merged slice contains both middleware emissions") +} + +// TestChain_RunResponse_ThreadsMetadataAcrossMiddlewares does the +// same for the response slot. The response slot iterates in reverse +// registration order, so the middleware registered LAST runs first. +// This test asserts that a middleware running later (in reverse +// order) sees the metadata emitted by the one that ran before it. +func TestChain_RunResponse_ThreadsMetadataAcrossMiddlewares(t *testing.T) { + // Registration order: [outer, inner]. + // Reverse iteration runs inner first, outer second. + // outer must see inner's emission. + outer := &fakeMiddleware{ + id: "outer", + slot: SlotOnResponse, + keys: []string{"outer.k"}, + emit: []KV{{Key: "outer.k", Value: "o"}}, + } + inner := &fakeMiddleware{ + id: "inner", + slot: SlotOnResponse, + keys: []string{"inner.k"}, + emit: []KV{{Key: "inner.k", Value: "i"}}, + } + c := chainFor(t, outer, inner) + acc := NewAccumulator(0) + + merged := c.RunResponse(context.Background(), &Input{}, acc) + + require.Len(t, outer.seen, 1, "outer must observe inner's emission") + assert.Equal(t, "inner.k", outer.seen[0].Key) + require.Len(t, merged, 2, "merged slice contains both response emissions") +} + +// TestChain_RunResponse_CostMeterScenario simulates the synth-service +// chain shape (response_parser registered AFTER cost_meter so reverse +// iter runs response_parser first). The cost_meter analogue must see +// the tokens response_parser just emitted — this is the exact +// regression that produced cost.skipped=missing_tokens in the live +// access logs. +func TestChain_RunResponse_CostMeterScenario(t *testing.T) { + // Synthesizer registers cost_meter first, response_parser second. + costMeter := &fakeMiddleware{ + id: "cost_meter", + slot: SlotOnResponse, + keys: []string{"cost.usd_total", "cost.skipped"}, + } + respParser := &fakeMiddleware{ + id: "llm_response_parser", + slot: SlotOnResponse, + keys: []string{"llm.input_tokens", "llm.output_tokens"}, + emit: []KV{ + {Key: "llm.input_tokens", Value: "13"}, + {Key: "llm.output_tokens", Value: "259"}, + }, + } + c := chainFor(t, costMeter, respParser) + acc := NewAccumulator(0) + + _ = c.RunResponse(context.Background(), &Input{}, acc) + + require.Len(t, costMeter.seen, 2, "cost_meter must observe both token keys emitted by response_parser") + keys := []string{costMeter.seen[0].Key, costMeter.seen[1].Key} + assert.ElementsMatch(t, []string{"llm.input_tokens", "llm.output_tokens"}, keys, + "cost_meter must see the exact keys response_parser emitted") + values := []string{costMeter.seen[0].Value, costMeter.seen[1].Value} + assert.ElementsMatch(t, []string{"13", "259"}, values, "cost_meter must see the exact token counts") + for _, kv := range costMeter.seen { + _, err := strconv.Atoi(kv.Value) + assert.NoError(t, err, "values handed to cost_meter must be numeric (regression for missing_tokens)") + } +} + +// TestChain_RunResponse_DetachedContextStillRecords guards the metering +// fix in reverseproxy.go. The response/terminal phase runs after the body +// is forwarded, so a streaming client has usually disconnected by then, +// cancelling its request context. The dispatcher derives each middleware's +// context from the one passed here and short-circuits to fail-mode the +// instant it's Done, which silently drops token/cost metering. The reverse +// proxy now detaches that phase with context.WithoutCancel; this proves a +// context detached from an already-cancelled parent still lets a response +// middleware emit. (The cancelled-parent direction is intentionally not +// asserted: the dispatcher's select over ctx.Done vs the result channel is +// racy when both are ready, which is exactly why the bug was intermittent.) +func TestChain_RunResponse_DetachedContextStillRecords(t *testing.T) { + resp := &fakeMiddleware{ + id: "recorder", + slot: SlotOnResponse, + keys: []string{"llm.input_tokens"}, + emit: []KV{{Key: "llm.input_tokens", Value: "42"}}, + decision: DecisionPassthrough, + } + c := chainFor(t, resp) + + clientCtx, cancel := context.WithCancel(context.Background()) + cancel() // client disconnected after the stream completed + require.Error(t, clientCtx.Err(), "client context must be cancelled for the test to be meaningful") + + detached := context.WithoutCancel(clientCtx) + require.NoError(t, detached.Err(), "detached context must not inherit the client's cancellation") + + acc := NewAccumulator(MaxRequestMetadataBytes) + merged := c.RunResponse(detached, &Input{Slot: SlotOnResponse}, acc) + + var got string + for _, kv := range merged { + if kv.Key == "llm.input_tokens" { + got = kv.Value + } + } + assert.Equal(t, "42", got, "response middleware must still emit token metadata under the detached context") +} + +// TestChain_RunRequest_LatestRewriteWins asserts that when two +// on_request middlewares both emit an UpstreamRewrite, the chain +// returns the value from the later middleware. +func TestChain_RunRequest_LatestRewriteWins(t *testing.T) { + first := &fakeMiddleware{ + id: "first", + slot: SlotOnRequest, + mutationsSupported: true, + canMutate: true, + mutations: &Mutations{RewriteUpstream: &UpstreamRewrite{Scheme: "https", Host: "first.test"}}, + } + second := &fakeMiddleware{ + id: "second", + slot: SlotOnRequest, + mutationsSupported: true, + canMutate: true, + mutations: &Mutations{RewriteUpstream: &UpstreamRewrite{Scheme: "https", Host: "second.test"}}, + } + c := chainFor(t, first, second) + acc := NewAccumulator(0) + + denied, _, rewrite, err := c.RunRequest(context.Background(), nil, &Input{}, acc) + require.NoError(t, err) + assert.Nil(t, denied, "neither middleware denies") + require.NotNil(t, rewrite, "chain must surface the rewrite emitted by the on_request slot") + assert.Equal(t, "https", rewrite.Scheme, "rewrite scheme must come from the later middleware") + assert.Equal(t, "second.test", rewrite.Host, "rewrite host must come from the later middleware (last-write-wins)") +} + +// TestChain_RunRequest_NoRewrite_NilReturn asserts the chain returns a +// nil rewrite when no middleware emits one. +func TestChain_RunRequest_NoRewrite_NilReturn(t *testing.T) { + first := &fakeMiddleware{id: "first", slot: SlotOnRequest} + second := &fakeMiddleware{id: "second", slot: SlotOnRequest} + c := chainFor(t, first, second) + acc := NewAccumulator(0) + + denied, _, rewrite, err := c.RunRequest(context.Background(), nil, &Input{}, acc) + require.NoError(t, err) + assert.Nil(t, denied, "neither middleware denies") + assert.Nil(t, rewrite, "chain must return nil rewrite when no middleware emits one") +} + +// TestChain_ApplyMutations_RewriteGatedOnCanMutate asserts that a +// middleware emitting an UpstreamRewrite with CanMutate=false has its +// rewrite filtered out by the chain. The dispatcher's filterOutput +// already clears Mutations when the gates fail; the chain's defensive +// gate inside mutationRewrite mirrors that contract so a stale +// Mutations field cannot leak through. +func TestChain_ApplyMutations_RewriteGatedOnCanMutate(t *testing.T) { + mw := &fakeMiddleware{ + id: "first", + slot: SlotOnRequest, + mutationsSupported: true, + canMutate: false, + mutations: &Mutations{RewriteUpstream: &UpstreamRewrite{Scheme: "https", Host: "denied.test"}}, + } + c := chainFor(t, mw) + acc := NewAccumulator(0) + + denied, _, rewrite, err := c.RunRequest(context.Background(), nil, &Input{}, acc) + require.NoError(t, err) + assert.Nil(t, denied, "middleware does not deny") + assert.Nil(t, rewrite, "rewrite must be filtered when CanMutate=false") +} + +// TestChain_RunRequest_PropagatesUserGroups asserts the chain forwards +// Input.UserGroups verbatim through cloneInputFor so policy-aware +// middlewares (e.g. llm_policy_check) can authorise without an extra +// management round-trip. +func TestChain_RunRequest_PropagatesUserGroups(t *testing.T) { + groupCapture := &userGroupCaptureMiddleware{ + id: "group-capture", + slot: SlotOnRequest, + } + c := chainFor(t, groupCapture.fake()) + groupCapture.bind(c) + acc := NewAccumulator(0) + + in := &Input{UserGroups: []string{"g1"}} + denied, _, _, err := c.RunRequest(context.Background(), nil, in, acc) + require.NoError(t, err) + assert.Nil(t, denied, "no deny without DecisionDeny") + + require.Len(t, groupCapture.seenGroups, 1, "middleware must observe the caller's UserGroups") + assert.Equal(t, "g1", groupCapture.seenGroups[0], "UserGroups must reach the middleware verbatim") +} + +// userGroupCaptureMiddleware is a fakeMiddleware variant that records +// Input.UserGroups during Invoke. It exists so the cloneInputFor +// behaviour for the new field can be asserted without leaking into +// every other chain test. +type userGroupCaptureMiddleware struct { + id string + slot Slot + seenGroups []string + fakeMW *fakeMiddleware +} + +func (u *userGroupCaptureMiddleware) fake() *fakeMiddleware { + u.fakeMW = &fakeMiddleware{id: u.id, slot: u.slot} + return u.fakeMW +} + +func (u *userGroupCaptureMiddleware) bind(c *Chain) { + for i, bm := range c.all { + if bm.spec.ID != u.id { + continue + } + c.all[i].mw = userGroupRecorder{ + fakeMiddleware: u.fakeMW, + parent: u, + } + } +} + +type userGroupRecorder struct { + *fakeMiddleware + parent *userGroupCaptureMiddleware +} + +func (r userGroupRecorder) Invoke(ctx context.Context, in *Input) (*Output, error) { + r.parent.seenGroups = append([]string(nil), in.UserGroups...) + return r.fakeMiddleware.Invoke(ctx, in) +} + +// TestChain_RunTerminal_SeesAccumulatedMetadata locks that terminal +// middlewares observe the full bag (the caller-supplied in.Metadata +// plus any prior terminal emissions). +func TestChain_RunTerminal_SeesAccumulatedMetadata(t *testing.T) { + first := &fakeMiddleware{ + id: "term-1", + slot: SlotTerminal, + keys: []string{"term.first"}, + emit: []KV{{Key: "term.first", Value: "1"}}, + } + second := &fakeMiddleware{ + id: "term-2", + slot: SlotTerminal, + keys: []string{"term.second"}, + } + c := chainFor(t, first, second) + acc := NewAccumulator(0) + + in := &Input{Metadata: []KV{{Key: "ext.k", Value: "ext"}}} + merged := c.RunTerminal(context.Background(), in, acc) + + require.Len(t, second.seen, 2, "second terminal must see ext bag + first terminal's emission") + got := map[string]string{} + for _, kv := range second.seen { + got[kv.Key] = kv.Value + } + assert.Equal(t, "ext", got["ext.k"], "external bag carries through") + assert.Equal(t, "1", got["term.first"], "first terminal's emission visible to second terminal") + assert.Len(t, merged, 1, "only first terminal emitted; second emitted nothing") +} diff --git a/proxy/internal/middleware/decision.go b/proxy/internal/middleware/decision.go new file mode 100644 index 000000000..0970bdea4 --- /dev/null +++ b/proxy/internal/middleware/decision.go @@ -0,0 +1,81 @@ +package middleware + +import ( + "encoding/json" + "net/http" + "regexp" +) + +var codeRegex = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,63}$`) + +// denyResponse is the on-wire shape rendered by RenderDenyResponse. +// Keeping this as a typed struct ensures we never leak +// middleware-supplied bytes outside known fields. +type denyResponse struct { + Code string `json:"code"` + Message string `json:"message,omitempty"` + Details map[string]string `json:"details,omitempty"` + Middleware string `json:"middleware,omitempty"` +} + +// RenderDenyResponse writes a structured JSON deny body. Status is +// clamped to [400, 499] excluding 401 (to avoid conflicts with the +// proxy's auth flow). All middleware-supplied strings are redacted and +// truncated. On any validation failure the function writes a generic +// 403. +func RenderDenyResponse(w http.ResponseWriter, middlewareID string, reason *DenyReason, defaultStatus int) { + status := clampDenyStatus(defaultStatus) + + if reason == nil || !codeRegex.MatchString(reason.Code) { + writeGenericDeny(w, middlewareID, status) + return + } + + resp := denyResponse{ + Code: reason.Code, + Message: truncate(Scan(reason.Message), 256), + Middleware: truncate(Scan(middlewareID), 64), + } + if n := len(reason.Details); n > 0 { + resp.Details = make(map[string]string, min(n, 8)) + for k, v := range reason.Details { + if len(resp.Details) >= 8 { + break + } + safeKey := truncate(Scan(k), 64) + if safeKey == "" { + continue + } + resp.Details[safeKey] = truncate(Scan(v), 256) + } + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(resp); err != nil { + return + } +} + +func writeGenericDeny(w http.ResponseWriter, middlewareID string, status int) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(denyResponse{Code: "middleware.error", Middleware: truncate(Scan(middlewareID), 64)}) +} + +func clampDenyStatus(s int) int { + if s < 400 || s >= 500 { + return http.StatusForbidden + } + if s == http.StatusUnauthorized { + return http.StatusForbidden + } + return s +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/proxy/internal/middleware/dispatcher.go b/proxy/internal/middleware/dispatcher.go new file mode 100644 index 000000000..316604651 --- /dev/null +++ b/proxy/internal/middleware/dispatcher.go @@ -0,0 +1,189 @@ +package middleware + +import ( + "context" + "errors" + "fmt" + "reflect" + "runtime" + "time" + + log "github.com/sirupsen/logrus" +) + +// Dispatcher reliability kinds reported via +// proxy.middleware.errors_total{kind=...}. +const ( + ErrorKindPanic = "panic" + ErrorKindTimeout = "timeout" + ErrorKindInvokeError = "invoke_error" +) + +// Dispatcher drives a single middleware invocation with panic +// recovery, deadline, and output filtering. Safe for concurrent use. +type Dispatcher struct { + metrics *Metrics + logger *log.Logger +} + +// NewDispatcher returns a dispatcher that emits on the provided +// metrics bundle and logger. A nil metrics bundle falls back to a noop +// instrument set; a nil logger falls back to the standard logger. +func NewDispatcher(metrics *Metrics, logger *log.Logger) *Dispatcher { + if metrics == nil { + metrics, _ = NewMetrics(nil) + } + if logger == nil { + logger = log.StandardLogger() + } + return &Dispatcher{metrics: metrics, logger: logger} +} + +// Invoke runs a single middleware under the reliability wrappers: +// deadline, panic recovery (type + truncated stack only), fail-mode, +// metric emission, and output filtering. The returned output is always +// safe to apply. +func (d *Dispatcher) Invoke(ctx context.Context, spec Spec, mw Middleware, in *Input) (*Output, error) { + if mw == nil { + return nil, fmt.Errorf("middleware %s: instance unavailable", spec.ID) + } + + timeout := clampTimeout(spec.Timeout) + callCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + d.metrics.IncInvocation(ctx, spec.ID) + start := time.Now() + + type result struct { + out *Output + err error + } + ch := make(chan result, 1) + + go func() { + defer func() { + if r := recover(); r != nil { + stack := make([]byte, 4<<10) + n := runtime.Stack(stack, false) + requestID := "" + if in != nil { + requestID = in.RequestID + } + d.logger.Warnf("middleware %s panic: request_id=%s type=%s stack=%s", + spec.ID, requestID, reflect.TypeOf(r).String(), stack[:n]) + ch <- result{err: panicError{msg: fmt.Sprintf("middleware %s panic: %s", spec.ID, reflect.TypeOf(r).String())}} + } + }() + out, err := mw.Invoke(callCtx, in) + ch <- result{out: out, err: err} + }() + + var ( + out *Output + invErr error + kind string + ) + + select { + case <-callCtx.Done(): + invErr = callCtx.Err() + kind = ErrorKindTimeout + case res := <-ch: + out = res.out + invErr = res.err + if invErr != nil { + kind = d.classifyError(invErr) + } + } + + d.metrics.ObserveDuration(ctx, spec.ID, time.Since(start).Milliseconds()) + + if invErr != nil { + d.metrics.IncError(ctx, spec.ID, kind) + return d.failMode(spec, kind), invErr + } + + return d.filterOutput(spec, out), nil +} + +func (d *Dispatcher) classifyError(err error) string { + if err == nil { + return "" + } + if errors.Is(err, context.DeadlineExceeded) { + return ErrorKindTimeout + } + var pe panicError + if errors.As(err, &pe) { + return ErrorKindPanic + } + return ErrorKindInvokeError +} + +// panicError marks an error as coming from the recover branch so the +// classifier can tag it without string inspection. +type panicError struct{ msg string } + +func (p panicError) Error() string { return p.msg } + +// failMode converts an error into a synthesised output per the +// middleware's fail-mode. An mw..error_kind metadata entry is +// attached so operators can alert on error rate even when the +// decision is fail-open. Slot constraints still apply: response and +// terminal slots clamp deny back to passthrough in filterOutput. +func (d *Dispatcher) failMode(spec Spec, kind string) *Output { + meta := []KV{{Key: fmt.Sprintf(KeyFrameworkErrorKindFmt, spec.ID), Value: kind}} + if spec.FailMode == FailClosed && spec.Slot == SlotOnRequest { + return &Output{ + Decision: DecisionDeny, + DenyStatus: 500, + DenyReason: &DenyReason{Code: "middleware.error"}, + Metadata: meta, + } + } + return &Output{Decision: DecisionAllow, Metadata: meta} +} + +// filterOutput applies the output-filter pipeline (slot-aware decision +// clamp, mutations gate) so downstream consumers never see +// middleware-supplied values that violate the contract. Metadata is +// passed through; the Accumulator is the single owner of allowlist + +// caps + redaction (called by Chain). +func (d *Dispatcher) filterOutput(spec Spec, out *Output) *Output { + if out == nil { + return &Output{Decision: DecisionAllow} + } + if spec.Slot != SlotOnRequest && out.Decision == DecisionDeny { + out.Decision = DecisionPassthrough + out.DenyStatus = 0 + out.DenyReason = nil + } + if out.Decision == DecisionDeny { + if out.DenyStatus == 0 { + out.DenyStatus = 403 + } else { + out.DenyStatus = clampDenyStatus(out.DenyStatus) + } + } + if !spec.CanMutate || !spec.MutationsSupported { + out.Mutations = nil + } + if spec.Slot == SlotTerminal { + out.Mutations = nil + } + return out +} + +func clampTimeout(d time.Duration) time.Duration { + if d <= 0 { + return DefaultTimeout + } + if d < MinTimeout { + return MinTimeout + } + if d > MaxTimeout { + return MaxTimeout + } + return d +} diff --git a/proxy/internal/middleware/headerpolicy.go b/proxy/internal/middleware/headerpolicy.go new file mode 100644 index 000000000..d041ad1e1 --- /dev/null +++ b/proxy/internal/middleware/headerpolicy.go @@ -0,0 +1,99 @@ +package middleware + +import "strings" + +var denyHeaders = []string{ + "Authorization", + "Connection", + "Cookie", + "Set-Cookie", + "Forwarded", + "Keep-Alive", + "Proxy-Authorization", + "Proxy-Authenticate", + "Proxy-Connection", + "TE", + "Upgrade", + "Via", + "X-Real-IP", + "X-Request-ID", + "Host", + "Content-Length", + "Transfer-Encoding", + "Trailer", +} + +var denyHeaderPrefixes = []string{ + "X-Authenticated-", + "X-Forwarded-", + "X-Remote-", + "X-NetBird-", +} + +// IsHeaderMutable reports whether a middleware is allowed to mutate +// the named header. The check is case-insensitive and honours both +// exact matches and the compiled-in prefix denylist. +func IsHeaderMutable(name string) bool { + if name == "" { + return false + } + if !isHeaderFieldName(name) { + return false + } + for _, d := range denyHeaders { + if strings.EqualFold(d, name) { + return false + } + } + for _, p := range denyHeaderPrefixes { + if len(name) >= len(p) && strings.EqualFold(name[:len(p)], p) { + return false + } + } + return true +} + +// isHeaderFieldName reports whether name is a valid RFC 7230 header +// field-name (a non-empty token of tchar octets). Rejects names with +// spaces, control characters, or separators that could enable header +// injection or smuggling when applied to the outbound request. +func isHeaderFieldName(name string) bool { + for i := 0; i < len(name); i++ { + c := name[i] + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') { + continue + } + switch c { + case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~': + continue + default: + return false + } + } + return true +} + +// FilterHeaderMutations returns the subsets of HeadersAdd and +// HeadersRemove that are safe to apply, plus the list of blocked +// header names so the dispatcher can increment the blocked-header +// metric. +func FilterHeaderMutations(m *Mutations) (filteredAdd []KV, filteredRemove []string, blocked []string) { + if m == nil { + return nil, nil, nil + } + for _, kv := range m.HeadersAdd { + if IsHeaderMutable(kv.Key) { + filteredAdd = append(filteredAdd, kv) + continue + } + blocked = append(blocked, kv.Key) + } + for _, name := range m.HeadersRemove { + if IsHeaderMutable(name) { + filteredRemove = append(filteredRemove, name) + continue + } + blocked = append(blocked, name) + } + return filteredAdd, filteredRemove, blocked +} diff --git a/proxy/internal/middleware/keys.go b/proxy/internal/middleware/keys.go new file mode 100644 index 000000000..336bed19f --- /dev/null +++ b/proxy/internal/middleware/keys.go @@ -0,0 +1,97 @@ +package middleware + +// Metadata key namespace constants shared across the built-in +// middlewares. Each domain owns a prefix; middlewares declare their +// per-key allowlist drawn from these constants. Agents implementing +// the G2 middlewares import this file so the dashboard's expanded-row +// viewer and the access-log writer see a stable key surface. +// +// Key shape rules (enforced by the metadata accumulator): +// - Lowercase ASCII letters, digits, dot, underscore, hyphen. +// - At least one dot separating namespace from leaf. +// - Max length: MaxMetadataKeyBytes. +const ( + // LLM request-side metadata (emitted by llm_request_parser). + KeyLLMProvider = "llm.provider" + KeyLLMModel = "llm.model" + KeyLLMStream = "llm.stream" + KeyLLMRequestPromptRaw = "llm.request_prompt_raw" + KeyLLMCaptureTruncated = "llm.capture_truncated" + // KeyLLMSessionID groups requests of the same conversation / coding + // session, read from the per-provider session marker in the request + // body. Empty for clients that don't send one. + KeyLLMSessionID = "llm.session_id" + + // LLM response-side metadata (emitted by llm_response_parser). + //nolint:gosec // metadata key name, not a credential + KeyLLMInputTokens = "llm.input_tokens" + //nolint:gosec // metadata key name, not a credential + KeyLLMOutputTokens = "llm.output_tokens" + //nolint:gosec // metadata key name, not a credential + KeyLLMTotalTokens = "llm.total_tokens" + // LLM cached-input bucket. For OpenAI it's the SUBSET of input + // tokens that hit the prompt cache (prompt_tokens_details. + // cached_tokens) — billed at the cached_input_per_1k rate when + // configured. For Anthropic it's cache_read_input_tokens, which + // is ADDITIVE to llm.input_tokens — billed at cache_read_per_1k. + // cost_meter switches formula on llm.provider. + //nolint:gosec // metadata key name, not a credential + KeyLLMCachedInputTokens = "llm.cached_input_tokens" + // LLM cache-creation bucket (Anthropic only). ADDITIVE to + // llm.input_tokens; billed at cache_creation_per_1k. + //nolint:gosec // metadata key name, not a credential + KeyLLMCacheCreationTokens = "llm.cache_creation_tokens" + KeyLLMResponseCompletion = "llm.response_completion" + + // Guardrail outcomes (emitted by llm_guardrail). The guardrail + // also re-emits llm.request_prompt as a redacted variant of the + // raw prompt and drops llm.request_prompt_raw from the bag. + KeyLLMRequestPrompt = "llm.request_prompt" + KeyLLMPolicyDecision = "llm_policy.decision" + KeyLLMPolicyReason = "llm_policy.reason" + + // LLM router routing decision (emitted by llm_router). The router + // stamps the resolved provider id so downstream middlewares and + // the access-log emitter can attribute the request without + // re-parsing the body. + KeyLLMResolvedProviderID = "llm.resolved_provider_id" + + // LLM authorising groups for this request (emitted by llm_router + // on the allow path). Carries the comma-separated intersection of + // the caller's UserGroups with the resolved route's + // AllowedGroupIDs — i.e. the groups that actually authorise this + // specific request, NOT every group the peer happens to be in. + // Identity-stamping middlewares use this for per-request tag + // attribution so unrelated group memberships don't leak into + // downstream gateways' spend logs. + KeyLLMAuthorisingGroups = "llm.authorising_groups" + + // LLM policy attribution (emitted by llm_limit_check on the allow + // path). Names the policy that paid for this request and the + // dimension counters the post-flight llm_limit_record middleware + // must tick. Empty when no applicable policy has any caps + // configured (catch-all-allow attribution). + KeyLLMSelectedPolicyID = "llm.selected_policy_id" + KeyLLMAttributionGroupID = "llm.attribution_group_id" + KeyLLMAttributionWindowS = "llm.attribution_window_seconds" + + // Cost metering (emitted by cost_meter). The four per-bucket keys are the + // base of the breakdown — one per token bucket the provider bills + // separately — and the two aggregates below are derived from them: + // usd_total is their sum, usd_cache is cached_input + cache_creation. + KeyCostUSDInput = "cost.usd_input" + // KeyCostUSDCachedInput is the cost of the cache-read bucket (Anthropic cache_read; OpenAI's discounted cached subset of input). + KeyCostUSDCachedInput = "cost.usd_cached_input" + // KeyCostUSDCacheCreation is the cost of the cache-write bucket. Zero for providers without one. + KeyCostUSDCacheCreation = "cost.usd_cache_creation" + KeyCostUSDOutput = "cost.usd_output" + KeyCostUSDTotal = "cost.usd_total" + // KeyCostUSDCache is the portion of cost.usd_total billed for prompt-cache buckets (cache read/creation, or OpenAI's cached input subset). + KeyCostUSDCache = "cost.usd_cache" + KeyCostSkipped = "cost.skipped" + + // Framework-emitted error markers. Use the mw..* prefix to + // distinguish framework-injected entries from middleware-emitted + // metadata. + KeyFrameworkErrorKindFmt = "mw.%s.error_kind" +) diff --git a/proxy/internal/middleware/manager.go b/proxy/internal/middleware/manager.go new file mode 100644 index 000000000..9b22edeff --- /dev/null +++ b/proxy/internal/middleware/manager.go @@ -0,0 +1,412 @@ +package middleware + +import ( + "context" + "fmt" + "strings" + "sync" + "sync/atomic" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/internal/middleware/bodytap" +) + +// chainCloseTimeout bounds how long closeChainsAsync waits for an +// individual chain to drain before forcing teardown. Set to 2x +// MaxTimeout so a middleware blocked on the dispatcher's per-Invoke +// deadline always wins; anything running longer is a runaway and gets +// force-closed. +const chainCloseTimeout = 2 * MaxTimeout + +// PathTargetBinding is the minimal per-path binding the server passes +// to Rebuild. It carries the stable keys Manager uses for snapshot +// lookups plus the validated middleware spec list for that path. +type PathTargetBinding struct { + ServiceID string + PathID string + Specs []Spec +} + +// LiveServiceCheck reports whether the given service ID is still +// present in the proxy's live mapping cache. The Manager calls it +// during InvalidateMiddleware so a chain whose service has been +// removed since the last Rebuild is not resurrected from the binding +// cache, closing the auth-revocation race. +type LiveServiceCheck func(serviceID string) bool + +// chainTable holds the immutable per-target chain snapshot. It is +// cloned into a new instance on every Rebuild and swapped in via +// atomic.Pointer. The reverse index byMiddleware lets +// InvalidateMiddleware find the chain keys that reference a given +// middleware without scanning the whole table. +type chainTable struct { + byTarget map[string]*Chain + byMiddleware map[string]map[string]struct{} +} + +func newChainTable() *chainTable { + return &chainTable{ + byTarget: make(map[string]*Chain), + byMiddleware: make(map[string]map[string]struct{}), + } +} + +func (c *chainTable) clone() *chainTable { + out := newChainTable() + for k, v := range c.byTarget { + out.byTarget[k] = v + } + for id, keys := range c.byMiddleware { + set := make(map[string]struct{}, len(keys)) + for k := range keys { + set[k] = struct{}{} + } + out.byMiddleware[id] = set + } + return out +} + +func (c *chainTable) addChain(key string, ch *Chain) { + c.byTarget[key] = ch + if ch == nil { + return + } + for _, bm := range ch.all { + set, ok := c.byMiddleware[bm.spec.ID] + if !ok { + set = make(map[string]struct{}) + c.byMiddleware[bm.spec.ID] = set + } + set[key] = struct{}{} + } +} + +func (c *chainTable) removeChain(key string) (*Chain, []string) { + ch, ok := c.byTarget[key] + if !ok { + return nil, nil + } + delete(c.byTarget, key) + if ch == nil { + return nil, nil + } + ids := make([]string, 0, len(ch.all)) + for _, bm := range ch.all { + ids = append(ids, bm.spec.ID) + set, ok := c.byMiddleware[bm.spec.ID] + if !ok { + continue + } + delete(set, key) + if len(set) == 0 { + delete(c.byMiddleware, bm.spec.ID) + } + } + return ch, ids +} + +// Manager owns the per-target middleware chains, the global capture +// budget, and the shared dispatcher. Readers (ChainFor) are lock-free; +// writers (Rebuild, Invalidate*) serialise on writeMu so two +// concurrent mapping updates do not lose writes. +type Manager struct { + writeMu sync.Mutex + chains atomic.Pointer[chainTable] + budget bodytap.Budget + metrics *Metrics + logger *log.Logger + dispatcher *Dispatcher + resolver *Resolver + lastBindings map[string]PathTargetBinding + liveServiceCheck atomic.Pointer[LiveServiceCheck] +} + +// NewManager constructs a Manager with the given capture budget size. +// A zero or negative budget falls back to bodytap.DefaultCaptureBudgetBytes. +func NewManager(budgetBytes int64, metrics *Metrics, logger *log.Logger) *Manager { + if metrics == nil { + metrics, _ = NewMetrics(nil) + } + if logger == nil { + logger = log.StandardLogger() + } + if budgetBytes <= 0 { + budgetBytes = bodytap.DefaultCaptureBudgetBytes + } + m := &Manager{ + budget: bodytap.NewBudget(budgetBytes), + metrics: metrics, + logger: logger, + dispatcher: NewDispatcher(metrics, logger), + lastBindings: make(map[string]PathTargetBinding), + } + m.chains.Store(newChainTable()) + return m +} + +// SetResolver installs the resolver used by Rebuild. Safe to call +// once at boot before any Rebuild; not safe to swap concurrently. +func (m *Manager) SetResolver(r *Resolver) { + m.resolver = r +} + +// SetLiveServiceCheck installs a callback the Manager uses to confirm +// a service ID still maps to a live mapping before resurrecting its +// chain from the binding cache during InvalidateMiddleware. A nil fn +// disables the check. +func (m *Manager) SetLiveServiceCheck(fn LiveServiceCheck) { + if fn == nil { + m.liveServiceCheck.Store(nil) + return + } + m.liveServiceCheck.Store(&fn) +} + +// Budget returns the shared capture budget. +func (m *Manager) Budget() bodytap.Budget { + return m.budget +} + +// Metrics returns the shared metrics bundle. +func (m *Manager) Metrics() *Metrics { + return m.metrics +} + +// Dispatcher returns the shared dispatcher (primarily for testing). +func (m *Manager) Dispatcher() *Dispatcher { + return m.dispatcher +} + +// Rebuild replaces every chain keyed by serviceID with the provided +// bindings. Entries for other services are preserved. Replaced chains +// are closed asynchronously after the atomic swap so in-flight +// requests against the previous chain finish before middleware +// resources are released. +func (m *Manager) Rebuild(serviceID string, bindings []PathTargetBinding) error { + m.writeMu.Lock() + defer m.writeMu.Unlock() + + cur := m.chains.Load() + next := cur.clone() + + prefix := serviceID + "|" + var retired []*Chain + for k := range cur.byTarget { + if !strings.HasPrefix(k, prefix) { + continue + } + ch, _ := next.removeChain(k) + if ch != nil { + retired = append(retired, ch) + } + delete(m.lastBindings, k) + } + + for _, b := range bindings { + if b.ServiceID != serviceID { + return fmt.Errorf("binding service %q does not match rebuild service %q", b.ServiceID, serviceID) + } + key := chainKey(b.ServiceID, b.PathID) + m.lastBindings[key] = cloneBinding(b) + chain := m.buildChain(b) + if chain == nil || chain.Empty() { + delete(m.lastBindings, key) + continue + } + next.addChain(key, chain) + } + + m.chains.Store(next) + m.closeChainsAsync(retired) + return nil +} + +// Invalidate drops every chain for the given service ID. +func (m *Manager) Invalidate(serviceID string) { + m.writeMu.Lock() + defer m.writeMu.Unlock() + cur := m.chains.Load() + next := cur.clone() + prefix := serviceID + "|" + var retired []*Chain + for k := range cur.byTarget { + if !strings.HasPrefix(k, prefix) { + continue + } + ch, _ := next.removeChain(k) + if ch != nil { + retired = append(retired, ch) + } + delete(m.lastBindings, k) + } + for k := range m.lastBindings { + if strings.HasPrefix(k, prefix) { + delete(m.lastBindings, k) + } + } + m.chains.Store(next) + m.closeChainsAsync(retired) +} + +// InvalidateMiddleware rebuilds only the chains that reference id. +func (m *Manager) InvalidateMiddleware(id string) { + if id == "" { + return + } + m.writeMu.Lock() + defer m.writeMu.Unlock() + + cur := m.chains.Load() + keys, ok := cur.byMiddleware[id] + if !ok || len(keys) == 0 { + return + } + + affected := make([]string, 0, len(keys)) + for k := range keys { + affected = append(affected, k) + } + + next := cur.clone() + var retired []*Chain + check := m.loadLiveServiceCheck() + for _, k := range affected { + ch, _ := next.removeChain(k) + if ch != nil { + retired = append(retired, ch) + } + b, ok := m.lastBindings[k] + if !ok { + delete(m.lastBindings, k) + continue + } + if check != nil && !check(b.ServiceID) { + m.logger.Debugf("middleware %s: skipping rebuild for %s; service no longer live", id, k) + delete(m.lastBindings, k) + continue + } + chain := m.buildChain(b) + if chain == nil || chain.Empty() { + delete(m.lastBindings, k) + continue + } + next.addChain(k, chain) + } + + m.chains.Store(next) + m.closeChainsAsync(retired) +} + +func (m *Manager) loadLiveServiceCheck() LiveServiceCheck { + p := m.liveServiceCheck.Load() + if p == nil { + return nil + } + return *p +} + +// InvalidateAll drops every chain. +func (m *Manager) InvalidateAll() { + m.writeMu.Lock() + defer m.writeMu.Unlock() + cur := m.chains.Load() + retired := make([]*Chain, 0, len(cur.byTarget)) + for _, c := range cur.byTarget { + retired = append(retired, c) + } + m.chains.Store(newChainTable()) + for k := range m.lastBindings { + delete(m.lastBindings, k) + } + m.closeChainsAsync(retired) +} + +func (m *Manager) closeChainsAsync(retired []*Chain) { + if len(retired) == 0 { + return + } + chains := make([]*Chain, len(retired)) + copy(chains, retired) + go func() { + for _, c := range chains { + ctx, cancel := context.WithTimeout(context.Background(), chainCloseTimeout) + start := time.Now() + if err := c.Close(ctx); err != nil { + if m.metrics != nil { + m.metrics.IncError(context.Background(), c.TargetID(), "chain_close_timeout") + } + m.logger.Warnf("middleware chain %s close exceeded %s after %s: %v", + c.TargetID(), chainCloseTimeout, time.Since(start), err) + } + cancel() + } + }() +} + +// ChainFor returns the chain for serviceID/pathID or nil if none is +// registered. Lock-free. +func (m *Manager) ChainFor(serviceID, pathID string) *Chain { + tbl := m.chains.Load() + if tbl == nil { + return nil + } + c, ok := tbl.byTarget[chainKey(serviceID, pathID)] + if !ok { + return nil + } + return c +} + +// buildChain resolves each enabled spec and returns the assembled +// chain. Returns a nil chain when no middlewares are bound; resolver +// errors per middleware are logged and counted but do not abort the +// chain. +func (m *Manager) buildChain(b PathTargetBinding) *Chain { + if len(b.Specs) == 0 || m.resolver == nil { + return nil + } + + bound := make([]boundMiddleware, 0, len(b.Specs)) + for _, spec := range b.Specs { + if !spec.Enabled { + continue + } + mw, merged, err := m.resolver.Resolve(spec) + if err != nil { + m.logger.Warnf("middleware %s resolve on target %s/%s: %v", spec.ID, b.ServiceID, b.PathID, err) + m.metrics.IncError(context.Background(), spec.ID, "resolve_error") + continue + } + if mw == nil { + continue + } + bound = append(bound, boundMiddleware{spec: merged, mw: mw}) + } + if len(bound) == 0 { + return nil + } + return NewChain(chainKey(b.ServiceID, b.PathID), bound, m.dispatcher) +} + +// cloneBinding returns a deep copy of b suitable for caching across +// mapping updates. +func cloneBinding(b PathTargetBinding) PathTargetBinding { + out := PathTargetBinding{ + ServiceID: b.ServiceID, + PathID: b.PathID, + } + if len(b.Specs) == 0 { + return out + } + out.Specs = make([]Spec, len(b.Specs)) + for i, s := range b.Specs { + out.Specs[i] = s.Clone() + } + return out +} + +func chainKey(serviceID, pathID string) string { + return serviceID + "|" + pathID +} diff --git a/proxy/internal/middleware/metadata.go b/proxy/internal/middleware/metadata.go new file mode 100644 index 000000000..576c379ec --- /dev/null +++ b/proxy/internal/middleware/metadata.go @@ -0,0 +1,99 @@ +package middleware + +import "regexp" + +// keyRegex constrains metadata keys to the cross-domain shape +// described in keys.go. At least one dot, lowercase ASCII / digits / +// dot / underscore / hyphen only, length within MaxMetadataKeyBytes. +var keyRegex = regexp.MustCompile(`^[a-z][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)+$`) + +// MetadataRejection describes a single rejected key/value so the +// dispatcher can emit per-reason counter increments. +type MetadataRejection struct { + Key string + Reason string +} + +// Rejection reasons reported by Accumulator.Emit. +const ( + MetadataReasonBadKey = "bad_key" + MetadataReasonNotAllowlisted = "not_allowlisted" + MetadataReasonKeyTooLong = "key_too_long" + MetadataReasonValueTooLong = "value_too_long" + MetadataReasonMiddlewareCap = "middleware_cap" + MetadataReasonRequestCap = "request_cap" +) + +// Accumulator enforces per-middleware and per-request metadata caps. +// Not safe for concurrent use; callers hold one inside a single chain +// execution. +type Accumulator struct { + perMiddlewareUsed map[string]int + totalUsed int + maxPerRequest int +} + +// NewAccumulator returns an accumulator configured for the per-request +// total cap. A maxPerRequest of zero means use MaxRequestMetadataBytes. +func NewAccumulator(maxPerRequest int) *Accumulator { + if maxPerRequest <= 0 { + maxPerRequest = MaxRequestMetadataBytes + } + return &Accumulator{ + perMiddlewareUsed: make(map[string]int), + maxPerRequest: maxPerRequest, + } +} + +// Emit validates the candidate metadata against the middleware's +// allowlist and the global caps, redacts each accepted value, and +// returns the accepted entries plus any rejections for metric emission. +func (a *Accumulator) Emit(middlewareID string, allow []string, out []KV) ([]KV, []MetadataRejection) { + if len(out) == 0 { + return nil, nil + } + allowSet := make(map[string]struct{}, len(allow)) + for _, k := range allow { + allowSet[k] = struct{}{} + } + + accepted := make([]KV, 0, len(out)) + var rejected []MetadataRejection + + for _, kv := range out { + if len(kv.Key) == 0 || len(kv.Key) > MaxMetadataKeyBytes { + rejected = append(rejected, MetadataRejection{Key: kv.Key, Reason: MetadataReasonKeyTooLong}) + continue + } + if !keyRegex.MatchString(kv.Key) { + rejected = append(rejected, MetadataRejection{Key: kv.Key, Reason: MetadataReasonBadKey}) + continue + } + if _, ok := allowSet[kv.Key]; !ok { + rejected = append(rejected, MetadataRejection{Key: kv.Key, Reason: MetadataReasonNotAllowlisted}) + continue + } + if len(kv.Value) > MaxMetadataValueBytes { + rejected = append(rejected, MetadataRejection{Key: kv.Key, Reason: MetadataReasonValueTooLong}) + continue + } + + redacted := Scan(kv.Value) + cost := len(kv.Key) + len(redacted) + + if a.perMiddlewareUsed[middlewareID]+cost > MaxMiddlewareMetadataBytes { + rejected = append(rejected, MetadataRejection{Key: kv.Key, Reason: MetadataReasonMiddlewareCap}) + continue + } + if a.totalUsed+cost > a.maxPerRequest { + rejected = append(rejected, MetadataRejection{Key: kv.Key, Reason: MetadataReasonRequestCap}) + continue + } + + a.perMiddlewareUsed[middlewareID] += cost + a.totalUsed += cost + accepted = append(accepted, KV{Key: kv.Key, Value: redacted}) + } + + return accepted, rejected +} diff --git a/proxy/internal/middleware/metrics.go b/proxy/internal/middleware/metrics.go new file mode 100644 index 000000000..73745a86b --- /dev/null +++ b/proxy/internal/middleware/metrics.go @@ -0,0 +1,171 @@ +package middleware + +import ( + "context" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/metric/noop" +) + +// Metrics is the bundle of OTel instruments emitted by the middleware +// dispatcher. The constructor falls back to a noop meter when given +// nil so tests can skip metrics wiring entirely. +type Metrics struct { + requestsTotal metric.Int64Counter + durationMs metric.Int64Histogram + invocationsTotal metric.Int64Counter + errorsTotal metric.Int64Counter + metadataRejectedTotal metric.Int64Counter + headerMutationBlocked metric.Int64Counter + captureBypassTotal metric.Int64Counter +} + +// NewMetrics registers the proxy.middleware.* instruments on the +// given meter. A nil meter is treated as the global no-op provider. +func NewMetrics(meter metric.Meter) (*Metrics, error) { + if meter == nil { + meter = noop.NewMeterProvider().Meter("proxy.middleware.noop") + } + + m := &Metrics{} + var err error + + m.requestsTotal, err = meter.Int64Counter( + "proxy.middleware.requests_total", + metric.WithUnit("1"), + metric.WithDescription("Middleware invocations grouped by outcome"), + ) + if err != nil { + return nil, err + } + + m.durationMs, err = meter.Int64Histogram( + "proxy.middleware.duration_ms", + metric.WithUnit("milliseconds"), + metric.WithDescription("Middleware Invoke latency"), + ) + if err != nil { + return nil, err + } + + m.invocationsTotal, err = meter.Int64Counter( + "proxy.middleware.invocations_total", + metric.WithUnit("1"), + metric.WithDescription("Middleware Invoke heartbeat counter"), + ) + if err != nil { + return nil, err + } + + m.errorsTotal, err = meter.Int64Counter( + "proxy.middleware.errors_total", + metric.WithUnit("1"), + metric.WithDescription("Middleware errors grouped by kind"), + ) + if err != nil { + return nil, err + } + + m.metadataRejectedTotal, err = meter.Int64Counter( + "proxy.middleware.metadata_rejected_total", + metric.WithUnit("1"), + metric.WithDescription("Middleware metadata entries rejected by the allowlist/caps"), + ) + if err != nil { + return nil, err + } + + m.headerMutationBlocked, err = meter.Int64Counter( + "proxy.middleware.header_mutation_blocked_total", + metric.WithUnit("1"), + metric.WithDescription("Middleware header mutations dropped by the denylist"), + ) + if err != nil { + return nil, err + } + + m.captureBypassTotal, err = meter.Int64Counter( + "proxy.middleware.capture_bypass_total", + metric.WithUnit("1"), + metric.WithDescription("Capture bypasses grouped by reason"), + ) + if err != nil { + return nil, err + } + + return m, nil +} + +// IncRequest increments proxy.middleware.requests_total with the +// middleware, target, and outcome labels. +func (m *Metrics) IncRequest(ctx context.Context, middlewareID, targetID, outcome string) { + if m == nil { + return + } + m.requestsTotal.Add(ctx, 1, metric.WithAttributes( + attribute.String("middleware", middlewareID), + attribute.String("target_id", targetID), + attribute.String("outcome", outcome), + )) +} + +// ObserveDuration records the middleware Invoke latency in milliseconds. +func (m *Metrics) ObserveDuration(ctx context.Context, middlewareID string, ms int64) { + if m == nil { + return + } + m.durationMs.Record(ctx, ms, metric.WithAttributes(attribute.String("middleware", middlewareID))) +} + +// IncInvocation increments the heartbeat counter regardless of outcome. +func (m *Metrics) IncInvocation(ctx context.Context, middlewareID string) { + if m == nil { + return + } + m.invocationsTotal.Add(ctx, 1, metric.WithAttributes(attribute.String("middleware", middlewareID))) +} + +// IncError increments the error counter with the given failure kind label. +func (m *Metrics) IncError(ctx context.Context, middlewareID, kind string) { + if m == nil { + return + } + m.errorsTotal.Add(ctx, 1, metric.WithAttributes( + attribute.String("middleware", middlewareID), + attribute.String("kind", kind), + )) +} + +// IncMetadataRejected increments the rejected-metadata counter for a reason. +func (m *Metrics) IncMetadataRejected(ctx context.Context, middlewareID, reason string) { + if m == nil { + return + } + m.metadataRejectedTotal.Add(ctx, 1, metric.WithAttributes( + attribute.String("middleware", middlewareID), + attribute.String("reason", reason), + )) +} + +// IncHeaderMutationBlocked increments the blocked-header counter. +func (m *Metrics) IncHeaderMutationBlocked(ctx context.Context, middlewareID, header string) { + if m == nil { + return + } + m.headerMutationBlocked.Add(ctx, 1, metric.WithAttributes( + attribute.String("middleware", middlewareID), + attribute.String("header", header), + )) +} + +// IncCaptureBypass increments the capture-bypass counter for a reason. +func (m *Metrics) IncCaptureBypass(ctx context.Context, targetID, reason string) { + if m == nil { + return + } + m.captureBypassTotal.Add(ctx, 1, metric.WithAttributes( + attribute.String("target_id", targetID), + attribute.String("reason", reason), + )) +} diff --git a/proxy/internal/middleware/middleware.go b/proxy/internal/middleware/middleware.go new file mode 100644 index 000000000..16d398d2c --- /dev/null +++ b/proxy/internal/middleware/middleware.go @@ -0,0 +1,47 @@ +package middleware + +import "context" + +// Middleware is the surface exposed by each concrete implementation. +// The Manager invokes it through the Dispatcher, passing a cloned +// Input. Each middleware lives in exactly one Slot. +// +// Close releases any resources owned by the middleware instance +// (background goroutines, file handles). It is invoked when the chain +// holding the middleware is replaced or torn down. Implementations +// must be idempotent and safe to call after construction even when +// Invoke was never called. +type Middleware interface { + ID() string + Version() string + Slot() Slot + + // AcceptedContentTypes lists the request/response content types + // the middleware needs the body for. Empty slice means the + // middleware does not inspect the body. + AcceptedContentTypes() []string + + // MetadataKeys is the closed set of metadata keys this middleware + // may emit. The accumulator drops anything outside this allowlist. + MetadataKeys() []string + + // MutationsSupported reports whether the middleware may emit + // header / body mutations. A spec with CanMutate=true is honoured + // only when the implementation also supports mutations. + MutationsSupported() bool + + Invoke(ctx context.Context, in *Input) (*Output, error) + + Close() error +} + +// Factory builds a configured Middleware instance from raw config +// bytes shipped on the wire. Each registered middleware ID has a +// single factory in the registry. Factory.New returns an error when +// the config is malformed or violates a per-middleware invariant; the +// chain build path logs the error, increments the resolve_error metric, +// and skips the middleware. +type Factory interface { + ID() string + New(rawConfig []byte) (Middleware, error) +} diff --git a/proxy/internal/middleware/redaction.go b/proxy/internal/middleware/redaction.go new file mode 100644 index 000000000..ebbe90c61 --- /dev/null +++ b/proxy/internal/middleware/redaction.go @@ -0,0 +1,79 @@ +package middleware + +import ( + "regexp" + "strings" +) + +// Redaction scope: Scan handles the narrow, high-signal set of +// secrets we are comfortable masking with a regex. The intent is +// "make accidental leaks impossible to miss at a glance", not "be a +// DLP product". Contributors adding more patterns should weigh false +// positives carefully — a metadata value that over-redacts benign +// strings is strictly worse than one that misses a rare format. +var ( + jwtRegex = regexp.MustCompile(`eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}`) + pemRegex = regexp.MustCompile(`-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----`) + awsKeyRegex = regexp.MustCompile(`AKIA[0-9A-Z]{16}`) + bearerRegex = regexp.MustCompile(`(?i)\b(?:bearer|token|api[_-]?key|authorization)[\s:=]+([A-Za-z0-9_\-\.]{40,})`) + ccCandidateRgx = regexp.MustCompile(`\b(?:\d[ -]?){13,19}\b`) +) + +// Scan redacts high-signal secret patterns from value. Matches are +// replaced with `[REDACTED:]`. Non-matching input is returned +// unchanged. +func Scan(value string) string { + if value == "" { + return value + } + result := value + result = pemRegex.ReplaceAllString(result, "[REDACTED:pem]") + result = jwtRegex.ReplaceAllString(result, "[REDACTED:jwt]") + result = awsKeyRegex.ReplaceAllString(result, "[REDACTED:aws_key]") + result = bearerRegex.ReplaceAllStringFunc(result, func(match string) string { + sub := bearerRegex.FindStringSubmatch(match) + if len(sub) < 2 { + return "[REDACTED:bearer]" + } + return strings.Replace(match, sub[1], "[REDACTED:bearer]", 1) + }) + result = ccCandidateRgx.ReplaceAllStringFunc(result, func(match string) string { + digits := stripNonDigits(match) + if len(digits) < 13 || len(digits) > 19 { + return match + } + if !luhn(digits) { + return match + } + return "[REDACTED:cc]" + }) + return result +} + +func stripNonDigits(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if r >= '0' && r <= '9' { + b.WriteRune(r) + } + } + return b.String() +} + +func luhn(digits string) bool { + sum := 0 + alt := false + for i := len(digits) - 1; i >= 0; i-- { + n := int(digits[i] - '0') + if alt { + n *= 2 + if n > 9 { + n -= 9 + } + } + sum += n + alt = !alt + } + return sum%10 == 0 +} diff --git a/proxy/internal/middleware/registry.go b/proxy/internal/middleware/registry.go new file mode 100644 index 000000000..c46552162 --- /dev/null +++ b/proxy/internal/middleware/registry.go @@ -0,0 +1,121 @@ +package middleware + +import ( + "fmt" + "sync" +) + +// Registry maps middleware IDs to their factories. The proxy installs +// a single Registry at boot; concrete middlewares register themselves +// from init() functions inside their own packages so the boot wiring +// only needs an anonymous import. +// +// Registry is safe for concurrent reads after boot. Register / Unregister +// take the write lock; Get and IDs take the read lock. +type Registry struct { + mu sync.RWMutex + factories map[string]Factory +} + +// NewRegistry returns an empty registry. +func NewRegistry() *Registry { + return &Registry{factories: make(map[string]Factory)} +} + +// Register installs the factory under its ID. Returns an error when an +// ID is already registered — collisions are programmer errors and must +// be visible at boot rather than silently last-write-wins. +func (r *Registry) Register(f Factory) error { + if f == nil { + return fmt.Errorf("middleware registry: nil factory") + } + id := f.ID() + if id == "" { + return fmt.Errorf("middleware registry: factory has empty id") + } + r.mu.Lock() + defer r.mu.Unlock() + if _, exists := r.factories[id]; exists { + return fmt.Errorf("middleware registry: %q already registered", id) + } + r.factories[id] = f + return nil +} + +// MustRegister panics on error. Intended for init() registration so +// duplicate IDs surface at startup. +func (r *Registry) MustRegister(f Factory) { + if err := r.Register(f); err != nil { + panic(err) + } +} + +// Get returns the factory for id, or nil when no factory is +// registered. +func (r *Registry) Get(id string) Factory { + r.mu.RLock() + defer r.mu.RUnlock() + return r.factories[id] +} + +// IDs returns the registered IDs in unspecified order. Used by the +// management translator to reject specs that reference unknown IDs at +// apply time. +func (r *Registry) IDs() []string { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]string, 0, len(r.factories)) + for id := range r.factories { + out = append(out, id) + } + return out +} + +// IsKnown reports whether id has a registered factory. +func (r *Registry) IsKnown(id string) bool { + return r.Get(id) != nil +} + +// Resolver wraps a Registry and produces a configured Middleware +// instance from a Spec. The Manager uses this during chain build. +type Resolver struct { + registry *Registry +} + +// NewResolver returns a resolver backed by the registry. +func NewResolver(registry *Registry) *Resolver { + if registry == nil { + registry = NewRegistry() + } + return &Resolver{registry: registry} +} + +// Resolve builds a Middleware instance and merges runtime-only fields +// (version, accepted content types, metadata key allowlist, mutation +// support) onto the spec. +// +// Return semantics: +// - (mw, mergedSpec, nil): instance built, include in chain. +// - (nil, spec, nil): id not registered; silently skip. +// - (nil, spec, err): factory rejected the config (logged + counted +// by Manager, other middlewares still bind). +func (r *Resolver) Resolve(spec Spec) (Middleware, Spec, error) { + f := r.registry.Get(spec.ID) + if f == nil { + return nil, spec, nil + } + mw, err := f.New(spec.RawConfig) + if err != nil { + return nil, spec, fmt.Errorf("middleware %s factory: %w", spec.ID, err) + } + if mw.Slot() != spec.Slot { + _ = mw.Close() + return nil, spec, fmt.Errorf("middleware %s slot mismatch: spec=%d impl=%d", spec.ID, spec.Slot, mw.Slot()) + } + merged := spec + merged.Version = mw.Version() + merged.MetadataKeys = append([]string(nil), mw.MetadataKeys()...) + merged.AcceptedContentTypes = append([]string(nil), mw.AcceptedContentTypes()...) + merged.MutationsSupported = mw.MutationsSupported() + return mw, merged, nil +} diff --git a/proxy/internal/middleware/spec.go b/proxy/internal/middleware/spec.go new file mode 100644 index 000000000..a154ceca2 --- /dev/null +++ b/proxy/internal/middleware/spec.go @@ -0,0 +1,44 @@ +package middleware + +import "time" + +// Spec is the apply-time, validated representation of a per-target +// middleware configuration merged with the runtime-only fields +// compiled into the middleware implementation. +// +// The wire shape is RawConfig (JSON bytes) instead of the older +// params map[string]string. Each middleware unmarshals RawConfig into +// its own typed config struct, surfacing structural validation errors +// at construction rather than per-invocation lookups. +type Spec struct { + ID string + Slot Slot + Version string + Enabled bool + FailMode FailMode + Timeout time.Duration + RawConfig []byte + CanMutate bool + + // Runtime-only fields populated from the registered middleware at + // chain build time; not sourced from proto. + MetadataKeys []string + AcceptedContentTypes []string + MutationsSupported bool +} + +// Clone returns a deep copy of the spec safe to cache across mapping +// updates. +func (s Spec) Clone() Spec { + out := s + if len(s.RawConfig) > 0 { + out.RawConfig = append([]byte(nil), s.RawConfig...) + } + if len(s.MetadataKeys) > 0 { + out.MetadataKeys = append([]string(nil), s.MetadataKeys...) + } + if len(s.AcceptedContentTypes) > 0 { + out.AcceptedContentTypes = append([]string(nil), s.AcceptedContentTypes...) + } + return out +} diff --git a/proxy/internal/middleware/types.go b/proxy/internal/middleware/types.go new file mode 100644 index 000000000..1ed5c9d88 --- /dev/null +++ b/proxy/internal/middleware/types.go @@ -0,0 +1,257 @@ +// Package middleware defines the per-target middleware chain that runs +// inside the reverse proxy hot path. It is the only chain wired into +// the request path. +// +// Concepts: +// - Slot: the position a middleware occupies in the chain. A +// middleware lives in exactly one slot — separate concerns become +// separate middlewares. +// - Decision: the on_request slot can DENY; on_response and terminal +// slots can only PASSTHROUGH. The dispatcher clamps decisions that +// violate this contract. +// - Metadata: the only side-channel between middlewares. Each +// middleware declares an allowlist of keys it may emit; the merger +// enforces caps and namespace rules. +package middleware + +import "time" + +// Slot identifies where in the request lifecycle a middleware runs. +// A middleware declares a single slot. Splitting per-purpose work +// (request parsing vs response parsing vs cost metering) into separate +// slot-keyed middlewares is the explicit architectural choice for the +// agent-network use case; no middleware participates in more than one +// slot. +type Slot int + +const ( + // SlotOnRequest runs before the upstream call. Middlewares in this + // slot may DENY the request, mutate headers/body (when permitted), + // and emit metadata derived from the request envelope. + SlotOnRequest Slot = 1 + // SlotOnResponse runs after the upstream returns. Middlewares in + // this slot observe the response, emit metadata, and may mutate + // response headers when permitted. They cannot DENY. + SlotOnResponse Slot = 2 + // SlotTerminal runs after every SlotOnResponse middleware has + // emitted. Terminal middlewares observe the full metadata bag and + // ship it to external sinks (access log, metrics export). They + // cannot DENY and cannot mutate the response. + SlotTerminal Slot = 3 +) + +// FailMode controls how the dispatcher reacts when a middleware +// returns an error, times out, or panics. Observer middlewares default +// to FailOpen; policy middlewares should default to FailClosed. +type FailMode int + +const ( + // FailOpen allows the request to proceed when a middleware fails. + FailOpen FailMode = 0 + // FailClosed denies the request when a middleware fails. Only + // meaningful for SlotOnRequest middlewares. + FailClosed FailMode = 1 +) + +// Decision captures the outcome of a middleware invocation as observed +// by the dispatcher. Response-phase middlewares always return +// DecisionPassthrough; the dispatcher clamps any other value. +type Decision int + +const ( + // DecisionAllow lets the request proceed. + DecisionAllow Decision = 0 + // DecisionDeny stops the chain and returns a rendered deny + // response. Only honoured in SlotOnRequest. + DecisionDeny Decision = 1 + // DecisionPassthrough is the response-phase neutral outcome. + DecisionPassthrough Decision = 2 +) + +// Resource limits enforced by the proxy at config apply time and by +// the dispatcher at runtime. Per-target values supplied by management +// are clamped to these bounds. +const ( + // MaxBodyCapBytes is the proxy-wide upper bound for per-direction + // body capture. Sized to hold a full LLM streaming response (token + // usage rides the trailing SSE event, so the captured prefix must + // reach the end of the stream); a single response is bounded by the + // model's max output tokens, so this is a real ceiling, not a + // treadmill. Request capture stays well under this — oversized + // requests use the tolerant routing scan instead of buffering. + MaxBodyCapBytes int64 = 8 << 20 + // MinTimeout is the proxy-wide lower bound for per-middleware + // Invoke timeouts. + MinTimeout = 10 * time.Millisecond + // MaxTimeout is the proxy-wide upper bound for per-middleware + // Invoke timeouts. + MaxTimeout = 5 * time.Second + // DefaultTimeout is used when the per-target timeout is zero or + // unset. + DefaultTimeout = 500 * time.Millisecond + + // MaxMiddlewareMetadataBytes is the per-middleware metadata total + // cap. + MaxMiddlewareMetadataBytes = 16 << 10 + // MaxRequestMetadataBytes is the per-request metadata total cap + // across all middlewares in the chain. Earlier middlewares win + // when the budget is exhausted. + MaxRequestMetadataBytes = 32 << 10 + // MaxMetadataKeyBytes is the maximum length of a metadata key. + MaxMetadataKeyBytes = 96 + // MaxMetadataValueBytes is the maximum length of a metadata value. + MaxMetadataValueBytes = 4 << 10 + // MaxMiddlewaresPerChain caps the number of middleware entries + // accepted per chain at the proxy translator and the management + // REST API. Mirrors the chain invocation cap so a misconfigured + // mapping cannot push the chain clone cost beyond a known bound. + MaxMiddlewaresPerChain = 16 +) + +// KV is the canonical header/metadata representation used across the +// middleware boundary. We use a slice of KV instead of http.Header +// because it preserves key order, is cheap to deep-copy per +// invocation, and is directly representable in a future protobuf +// envelope. +type KV struct { + Key string + Value string +} + +// Input is the immutable envelope handed to each middleware. The +// dispatcher deep-copies Headers, Body, Metadata, RespHeaders, and +// RespBody before each invocation so middlewares cannot mutate the +// shared in-flight copies; mutations must flow through Output.Mutations. +type Input struct { + Slot Slot + RequestID string + TargetID string + Method string + URL string + Headers []KV + Body []byte + BodyTruncated bool + OriginalBodySize int64 + + Status int + RespHeaders []KV + RespBody []byte + RespBodyTruncated bool + OriginalRespSize int64 + + ServiceID string + AccountID string + UserID string + // UserEmail is the calling user's email address when the auth path + // resolves a user record. Empty for non-OIDC schemes (PIN/Password/ + // Header) and for legacy session JWTs minted before the email claim + // was introduced. Identity-stamping middlewares (e.g. + // llm_identity_inject) prefer this over UserID for upstream gateways + // that key budgets / attribution on a human-readable identifier. + UserEmail string + AuthMethod string + SourceIP string + // UserGroups captures the calling peer's group memberships at + // request time, surfaced from the proxy's auth flow so policy-aware + // middlewares can authorise without an extra management round-trip. + UserGroups []string + // UserGroupNames carries the human-readable display names paired + // positionally with UserGroups (UserGroupNames[i] is the name of + // UserGroups[i]). Identity-stamping middlewares prefer names for + // upstream tags so attribution dashboards stay readable. Slice may + // be shorter than UserGroups for tokens minted before names were + // resolvable; consumers should fall back to ids for missing + // positions. + UserGroupNames []string + Metadata []KV + + // AgentNetwork is true when the target is a synthesised + // agent-network service. Carried on the input so the access-log + // terminal middleware can stamp the proto field without re-deriving + // from the service ID. + AgentNetwork bool +} + +// DenyReason is the structured payload a middleware returns alongside +// a DecisionDeny. The proxy renders it through a fixed JSON template +// so middlewares cannot emit arbitrary bytes to the wire. +type DenyReason struct { + Code string + Message string + Details map[string]string +} + +// Output is the value each middleware returns to the dispatcher. The +// dispatcher applies the output filter (clamp, mutations gate) before +// any side effect reaches the shared request. +type Output struct { + Decision Decision + DenyStatus int + DenyReason *DenyReason + Metadata []KV + Mutations *Mutations +} + +// Mutations describes the deltas a middleware wants applied to the +// in-flight request. The dispatcher filters HeadersAdd/HeadersRemove +// through the compiled-in denylist and runs BodyReplace through the +// body policy before anything is applied. RewriteUpstream redirects +// the outbound target (scheme + host) for the request; the chain +// returns the latest non-nil rewrite to the reverse proxy. +type Mutations struct { + HeadersAdd []KV + HeadersRemove []string + BodyReplace []byte + RewriteUpstream *UpstreamRewrite +} + +// UpstreamRewrite redirects the request's outbound target. Only +// scheme+host are honoured; path, query, and body are untouched. The +// reverse proxy reads the rewrite (when non-nil) instead of the +// PathTarget URL configured by the synth, so a single shared synth +// service can fan out to many upstreams selected per request. +// +// AuthHeader and StripHeaders carry the upstream auth substitution +// the router needs. They bypass the framework's HeadersAdd / +// HeadersRemove denylist (which blocks Authorization, Cookie, etc. +// from middleware mutation) on the grounds that the proxy itself is +// the entity rewriting auth here, not an arbitrary middleware. The +// reverse proxy applies them directly to the upstream request after +// the chain's regular mutation phase, so a malicious or misconfigured +// middleware can still emit RewriteUpstream but only the proxy's +// trusted upstream-build path actually unpacks AuthHeader. +type UpstreamRewrite struct { + Scheme string + Host string + // Path, when non-empty, replaces the path component of the + // proxy's effective upstream URL. The rewrite path is then joined + // with the agent's request path by httputil.ProxyRequest.SetURL — + // e.g. rewrite Path="/v1/{account}/{gateway}/compat" + agent + // request "/chat/completions" → outbound + // "/v1/{account}/{gateway}/compat/chat/completions". Used by + // llm_router to honor the operator-configured upstream path on + // gateways like Cloudflare AI Gateway whose URL contains + // account / gateway segments that the agent's app doesn't know + // about. Empty Path leaves the original target's path + // untouched (the historical behavior). + Path string + // StripPathPrefix, when non-empty, is removed from the front of the agent's + // request path before it is joined onto the upstream URL. Used for + // gateway-namespace prefixes (e.g. a client addressing Bedrock as + // "/bedrock/model/{id}/invoke") that must not reach the real upstream, whose + // native path is "/model/{id}/invoke". Empty leaves the request path intact. + StripPathPrefix string + AuthHeader *AuthHeader + StripHeaders []string + // SkipTLSVerify, when true, makes the proxy dial the rewritten upstream + // without verifying its TLS certificate. Set by llm_router from the + // provider's skip_tls_verification for self-hosted / internal gateways. + SkipTLSVerify bool +} + +// AuthHeader is a single name/value pair the proxy injects on the +// upstream request after stripping the client's auth headers. +type AuthHeader struct { + Name string + Value string +} diff --git a/proxy/internal/netutil/errors.go b/proxy/internal/netutil/errors.go new file mode 100644 index 000000000..ff24e33d4 --- /dev/null +++ b/proxy/internal/netutil/errors.go @@ -0,0 +1,40 @@ +package netutil + +import ( + "context" + "errors" + "fmt" + "io" + "math" + "net" + "syscall" +) + +// ValidatePort converts an int32 proto port to uint16, returning an error +// if the value is out of the valid 1–65535 range. +func ValidatePort(port int32) (uint16, error) { + if port <= 0 || port > math.MaxUint16 { + return 0, fmt.Errorf("invalid port %d: must be 1–65535", port) + } + return uint16(port), nil +} + +// IsExpectedError returns true for errors that are normal during +// connection teardown and should not be logged as warnings. +func IsExpectedError(err error) bool { + return errors.Is(err, net.ErrClosed) || + errors.Is(err, context.Canceled) || + errors.Is(err, io.EOF) || + errors.Is(err, syscall.ECONNRESET) || + errors.Is(err, syscall.EPIPE) || + errors.Is(err, syscall.ECONNABORTED) +} + +// IsTimeout checks whether the error is a network timeout. +func IsTimeout(err error) bool { + var netErr net.Error + if errors.As(err, &netErr) { + return netErr.Timeout() + } + return false +} diff --git a/proxy/internal/netutil/errors_test.go b/proxy/internal/netutil/errors_test.go new file mode 100644 index 000000000..7d6be10ff --- /dev/null +++ b/proxy/internal/netutil/errors_test.go @@ -0,0 +1,92 @@ +package netutil + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidatePort(t *testing.T) { + tests := []struct { + name string + port int32 + want uint16 + wantErr bool + }{ + {"valid min", 1, 1, false}, + {"valid mid", 8080, 8080, false}, + {"valid max", 65535, 65535, false}, + {"zero", 0, 0, true}, + {"negative", -1, 0, true}, + {"too large", 65536, 0, true}, + {"way too large", 100000, 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ValidatePort(tt.port) + if tt.wantErr { + assert.Error(t, err) + assert.Zero(t, got) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + } + }) + } +} + +func TestIsExpectedError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {"net.ErrClosed", net.ErrClosed, true}, + {"context.Canceled", context.Canceled, true}, + {"io.EOF", io.EOF, true}, + {"ECONNRESET", syscall.ECONNRESET, true}, + {"EPIPE", syscall.EPIPE, true}, + {"ECONNABORTED", syscall.ECONNABORTED, true}, + {"wrapped expected", fmt.Errorf("wrap: %w", net.ErrClosed), true}, + {"unexpected EOF", io.ErrUnexpectedEOF, false}, + {"generic error", errors.New("something"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsExpectedError(tt.err)) + }) + } +} + +type timeoutErr struct{ timeout bool } + +func (e *timeoutErr) Error() string { return "timeout" } +func (e *timeoutErr) Timeout() bool { return e.timeout } +func (e *timeoutErr) Temporary() bool { return false } + +func TestIsTimeout(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {"net timeout", &timeoutErr{timeout: true}, true}, + {"net non-timeout", &timeoutErr{timeout: false}, false}, + {"wrapped timeout", fmt.Errorf("wrap: %w", &timeoutErr{timeout: true}), true}, + {"generic error", errors.New("not a timeout"), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsTimeout(tt.err)) + }) + } +} diff --git a/proxy/internal/proxy/agent_network_chain_realstack_test.go b/proxy/internal/proxy/agent_network_chain_realstack_test.go new file mode 100644 index 000000000..ec7f832bf --- /dev/null +++ b/proxy/internal/proxy/agent_network_chain_realstack_test.go @@ -0,0 +1,321 @@ +package proxy_test + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "net/url" + "runtime" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + + "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/bodytap" + mwbuiltin "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" + // Side-effect imports register every builtin middleware factory. + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/cost_meter" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_identity_inject" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_limit_check" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_limit_record" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_request_parser" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_response_parser" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_router" + "github.com/netbirdio/netbird/proxy/internal/proxy" + nbproxytypes "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" + + log "github.com/sirupsen/logrus" +) + +// TestReverseProxy_AgentNetworkRequest_FullChain is the self-contained Go +// replacement for the bash 50 + 51 legs. It drives a real agent-network +// request through proxy.ReverseProxy.ServeHTTP with the actual middleware +// chain the synthesizer produces, against an in-process management gRPC and a +// httptest fake upstream — no tilt, no docker, no real LLM provider, no +// WireGuard tunnel. The test guarantees: +// +// 1. The reverse proxy's response-leg input construction copies UserGroups +// onto respInput so llm_limit_record sends a non-empty group_ids field +// on RecordLLMUsage. This is the exact bug class that motivated the +// reverseproxy.go fix — its regression would land the request OK but +// leave consumption at zero, defeating any group-targeted budget rule. +// 2. With settings.RedactPii=true the parsers ship redacted text on both +// llm.request_prompt_raw and llm.response_completion — proving the +// end-to-end wiring (synth → proto → spec → parser config) carries the +// toggle through to runtime emission. +// 3. The full chain (request + response + recorder) runs against a real +// management stack and the consumption row for the bound group dim +// increments. +// +// If any of those three guarantees regresses, this single test fails. +func TestReverseProxy_AgentNetworkRequest_FullChain(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("sqlite store not supported on Windows") + } + + const ( + testAccountID = "acct-fullchain-1" + testAdminUser = "user-admin-1" + adminGroupID = "grp-admins" + providerID = "prov-openai-test" + cluster = "test.proxy.local" + subdomain = "fullchain" + ) + testLogger := log.New() + testLogger.SetLevel(log.PanicLevel) // keep test output clean + + ctx := context.Background() + + // ---- 1. Fake upstream that returns OpenAI-shaped JSON with PII in the + // completion. The reverse proxy's chain will redact this when the synth + // stamps redact_pii=true on the response parser config. + completion := "Sample record: Alice Johnson alice.johnson@example.com SSN 123-45-6789 phone (202) 555-0147 also Bob 202/555/0108" + upstreamBody := []byte(`{"id":"x","model":"gpt-5.4","choices":[{"message":{"role":"assistant","content":"` + completion + `"}}],"usage":{"prompt_tokens":12,"completion_tokens":40,"total_tokens":52}}`) + var upstreamHits atomic.Int64 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamHits.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(upstreamBody) + })) + t.Cleanup(upstream.Close) + upstreamHost := strings.TrimPrefix(upstream.URL, "http://") + + // ---- 2. In-process management gRPC server (bufconn) backed by a real + // sqlite store + real agentnetwork.Manager. The proxy's middlewares talk + // to this client. + st, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + t.Cleanup(cleanup) + + anMgr := agentnetwork.NewManager(st, nil, nil, nil) + server := &mgmtgrpc.ProxyServiceServer{} + server.SetAgentNetworkLimitsService(anMgr) + + lis := bufconn.Listen(1024 * 1024) + srv := grpc.NewServer() + proto.RegisterProxyServiceServer(srv, server) + go func() { _ = srv.Serve(lis) }() + t.Cleanup(srv.Stop) + + conn, err := grpc.NewClient("passthrough:///bufnet", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { return lis.Dial() }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + mgmtClient := proto.NewProxyServiceClient(conn) + + // ---- 3. Seed account state: settings (redact + capture on), provider + // whose upstream URL points at our fake server, policy (catch-all-allow + // over the Admins group → window=0 path), and a generous budget rule + // targeting Admins so the curl succeeds and we can prove the counter + // increments on the response leg. + require.NoError(t, st.SaveAgentNetworkSettings(ctx, &agentNetworkTypes.Settings{ + AccountID: testAccountID, + Cluster: cluster, + Subdomain: subdomain, + EnablePromptCollection: true, + EnableLogCollection: true, + RedactPii: true, + })) + require.NoError(t, st.SaveAgentNetworkProvider(ctx, &agentNetworkTypes.Provider{ + ID: providerID, + AccountID: testAccountID, + ProviderID: "openai_api", + Name: "openai-fullchain-test", + UpstreamURL: upstream.URL, // router rewrites to this + APIKey: "sk-test", + Enabled: true, + Models: []agentNetworkTypes.ProviderModel{{ID: "gpt-5.4"}}, + SessionPrivateKey: "priv", + SessionPublicKey: "pub", + })) + require.NoError(t, st.SaveAgentNetworkPolicy(ctx, &agentNetworkTypes.Policy{ + ID: "ainpol-fullchain", + AccountID: testAccountID, + Name: "admins-openai", + Enabled: true, + SourceGroups: []string{adminGroupID}, + DestinationProviderIDs: []string{providerID}, + // No token / budget caps → effectiveWindowSeconds=0 → exercises the + // catch-all-allow path that the GC-2 record-on-window=0 fix targets. + })) + require.NoError(t, st.SaveAgentNetworkBudgetRule(ctx, &agentNetworkTypes.AccountBudgetRule{ + ID: "ainbud-admins-fullchain", + AccountID: testAccountID, + Name: "admins-monthly", + Enabled: true, + TargetGroups: []string{adminGroupID}, + Limits: agentNetworkTypes.PolicyLimits{ + TokenLimit: agentNetworkTypes.PolicyTokenLimit{Enabled: true, GroupCap: 1_000_000, UserCap: 1_000_000, WindowSeconds: 60}, + }, + })) + + // ---- 4. Synth the service. This produces the exact middleware chain + // configuration the production reconcile path ships to the proxy. + services, err := agentnetwork.SynthesizeServices(ctx, st, testAccountID) + require.NoError(t, err) + require.Len(t, services, 1, "exactly one synth service expected") + synthSvc := services[0] + require.NotEmpty(t, synthSvc.Targets, "synth target must exist") + + // ---- 5. Wire the middleware framework — same registry the proxy uses + // in production, configured with our bufconn-backed management client. + mwbuiltin.Configure(ctx, t.TempDir(), nil, testLogger, mgmtClient) + registry := mwbuiltin.DefaultRegistry() + mwMetrics, err := middleware.NewMetrics(nil) + require.NoError(t, err) + mwMgr := middleware.NewManager(0, mwMetrics, testLogger) + mwMgr.SetResolver(middleware.NewResolver(registry)) + + // Convert the synth's rpservice.MiddlewareConfig list into proxy + // middleware.Spec values. Mirrors the proto→Spec translation server.go + // does at runtime; kept inline here so the test isn't coupled to the + // proxy server's private translateMiddlewareConfig helper. + specs := make([]middleware.Spec, 0, len(synthSvc.Targets[0].Options.Middlewares)) + for _, mw := range synthSvc.Targets[0].Options.Middlewares { + var slot middleware.Slot + switch mw.Slot { + case rpservice.MiddlewareSlotOnRequest: + slot = middleware.SlotOnRequest + case rpservice.MiddlewareSlotOnResponse: + slot = middleware.SlotOnResponse + case rpservice.MiddlewareSlotTerminal: + slot = middleware.SlotTerminal + default: + t.Fatalf("unknown middleware slot %q on %s", mw.Slot, mw.ID) + } + specs = append(specs, middleware.Spec{ + ID: mw.ID, + Slot: slot, + Enabled: mw.Enabled, + FailMode: middleware.FailOpen, + Timeout: middleware.DefaultTimeout, + RawConfig: append([]byte(nil), mw.ConfigJSON...), + CanMutate: mw.CanMutate, + }) + } + + serviceIDStr := synthSvc.ID + require.NoError(t, mwMgr.Rebuild(serviceIDStr, []middleware.PathTargetBinding{{ + ServiceID: serviceIDStr, + PathID: "/", + Specs: specs, + }})) + + // ---- 6. Build the reverse proxy, with a mapping whose target URL goes + // straight to the fake upstream (the router middleware rewriting upstream + // from the synth's noop placeholder isn't needed when we own the mapping + // in-process — point the target at the fake URL directly so the body + // arrives at the upstream the synth would have routed to). + upstreamURL, err := url.Parse(upstream.URL) + require.NoError(t, err) + + rp := proxy.NewReverseProxy(http.DefaultTransport, "auto", nil, testLogger, proxy.WithMiddlewareManager(mwMgr)) + rp.AddMapping(proxy.Mapping{ + ID: nbproxytypes.ServiceID(serviceIDStr), + AccountID: nbproxytypes.AccountID(testAccountID), + Host: synthSvc.Domain, + Paths: map[string]*proxy.PathTarget{ + "/": { + URL: upstreamURL, + DirectUpstream: true, + AgentNetwork: true, + Middlewares: specs, + CaptureConfig: &bodytap.Config{ + MaxRequestBytes: 1 << 20, + MaxResponseBytes: 1 << 20, + ContentTypes: []string{"application/json", "text/event-stream"}, + }, + }, + }, + }) + + // ---- 7. Send a request with the auth-stamped CapturedData (mimicking + // what the tunnel-peer auth middleware does at the edge of the proxy). + reqBody := `{"model":"gpt-5.4","client_metadata":{"session_id":"sess-fullchain-1"},"messages":[{"role":"user","content":"contact alice.johnson@example.com SSN 987-65-4321 phone (202)555-0156"}]}` + req := httptest.NewRequest("POST", "https://"+synthSvc.Domain+"/v1/chat/completions", strings.NewReader(reqBody)) + req.Host = synthSvc.Domain + req.Header.Set("Content-Type", "application/json") + + cd := proxy.NewCapturedData("test-request-1") + cd.SetServiceID(nbproxytypes.ServiceID(serviceIDStr)) + cd.SetAccountID(nbproxytypes.AccountID(testAccountID)) + cd.SetUserID(testAdminUser) + cd.SetUserGroups([]string{adminGroupID}) + cd.SetAuthMethod("tunnel_peer") + req = req.WithContext(proxy.WithCapturedData(req.Context(), cd)) + + w := httptest.NewRecorder() + rp.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, "upstream call must succeed end-to-end; body=%s", w.Body.String()) + assert.GreaterOrEqual(t, upstreamHits.Load(), int64(1), "fake upstream must have been hit") + + // ---- 8. Assertions — the three guarantees this test exists for. + + // 8a. The reverseproxy.go respInput construction carried UserGroups + // into the response-leg middleware chain, so llm_limit_record sent a + // non-empty group_ids on RecordLLMUsage. Verifying via the management + // store directly bypasses the manager's permission gate (which is nil + // in this test) — we want to confirm the row landed, not who saw it. + require.Eventually(t, func() bool { + rows, lerr := st.ListAgentNetworkConsumption(ctx, store.LockingStrengthNone, testAccountID) + if lerr != nil { + return false + } + for _, r := range rows { + if r.DimensionKind == agentNetworkTypes.DimensionGroup && + r.DimensionID == adminGroupID && + r.WindowSeconds == 60 && + r.TokensInput+r.TokensOutput > 0 { + return true + } + } + return false + }, 5*time.Second, 50*time.Millisecond, + "Admins group consumption row must increment via the response leg — if this fails the proxy's respInput dropped UserGroups again or the parser/recorder wiring is broken") + + // 8b. Both the captured prompt and the captured completion are + // redacted — proves the synth threads redact_pii=true into BOTH parser + // configs and the parsers honour it at emission time. + md := cd.GetMetadata() + promptRaw := md["llm.request_prompt_raw"] + completionMeta := md["llm.response_completion"] + + // 8a-bis. The session id from client_metadata.session_id flows through + // the request parser into the captured metadata, so the access-log / + // usage rows can group this request with the rest of its conversation. + assert.Equal(t, "sess-fullchain-1", md["llm.session_id"], + "session id must be extracted from client_metadata.session_id and carried through the chain") + + assert.NotEmpty(t, promptRaw, "llm.request_prompt_raw must be present in captured metadata") + assert.Contains(t, promptRaw, "[REDACTED:", "captured raw prompt must carry redaction markers") + assert.NotContains(t, promptRaw, "alice.johnson@example.com", "raw email must NOT survive in prompt_raw") + assert.NotContains(t, promptRaw, "987-65-4321", "raw SSN must NOT survive in prompt_raw") + assert.NotContains(t, promptRaw, "(202)555-0156", "raw paren-no-space phone must NOT survive in prompt_raw") + + assert.NotEmpty(t, completionMeta, "llm.response_completion must be present in captured metadata") + assert.Contains(t, completionMeta, "[REDACTED:", "captured completion must carry redaction markers") + assert.NotContains(t, completionMeta, "alice.johnson@example.com", "raw email must NOT survive in completion") + assert.NotContains(t, completionMeta, "123-45-6789", "raw SSN must NOT survive in completion") + assert.NotContains(t, completionMeta, "(202) 555-0147", "raw paren+space phone must NOT survive in completion") + assert.NotContains(t, completionMeta, "202/555/0108", "raw slash phone must NOT survive in completion") + + _ = upstreamHost // kept for future header-inspection assertions if needed +} diff --git a/proxy/internal/proxy/context.go b/proxy/internal/proxy/context.go new file mode 100644 index 000000000..09bb9a8d1 --- /dev/null +++ b/proxy/internal/proxy/context.go @@ -0,0 +1,304 @@ +package proxy + +import ( + "context" + "maps" + "net/netip" + "sync" + + "github.com/netbirdio/netbird/proxy/internal/types" +) + +type requestContextKey string + +const ( + capturedDataKey requestContextKey = "capturedData" +) + +// ResponseOrigin indicates where a response was generated. +type ResponseOrigin int + +const ( + // OriginBackend means the response came from the backend service. + OriginBackend ResponseOrigin = iota + // OriginNoRoute means the proxy had no matching host or path. + OriginNoRoute + // OriginProxyError means the proxy failed to reach the backend. + OriginProxyError + // OriginAuth means the proxy intercepted the request for authentication. + OriginAuth +) + +func (o ResponseOrigin) String() string { + switch o { + case OriginNoRoute: + return "no_route" + case OriginProxyError: + return "proxy_error" + case OriginAuth: + return "auth" + default: + return "backend" + } +} + +// CapturedData is a mutable struct that allows downstream handlers +// to pass data back up the middleware chain. +type CapturedData struct { + mu sync.RWMutex + requestID string + serviceID types.ServiceID + accountID types.AccountID + origin ResponseOrigin + clientIP netip.Addr + userID string + userEmail string + userGroups []string + // userGroupNames pairs positionally with userGroups; populated from + // the JWT's group_names claim or from ValidateSession/Tunnel + // responses. Slice may be shorter than userGroups for tokens minted + // before names were resolvable. + userGroupNames []string + authMethod string + metadata map[string]string + agentNetwork bool + suppressAccessLog bool +} + +// NewCapturedData creates a CapturedData with the given request ID. +func NewCapturedData(requestID string) *CapturedData { + return &CapturedData{requestID: requestID} +} + +// GetRequestID returns the request ID. +func (c *CapturedData) GetRequestID() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.requestID +} + +// SetServiceID sets the service ID. +func (c *CapturedData) SetServiceID(serviceID types.ServiceID) { + c.mu.Lock() + defer c.mu.Unlock() + c.serviceID = serviceID +} + +// GetServiceID returns the service ID. +func (c *CapturedData) GetServiceID() types.ServiceID { + c.mu.RLock() + defer c.mu.RUnlock() + return c.serviceID +} + +// SetAccountID sets the account ID. +func (c *CapturedData) SetAccountID(accountID types.AccountID) { + c.mu.Lock() + defer c.mu.Unlock() + c.accountID = accountID +} + +// GetAccountID returns the account ID. +func (c *CapturedData) GetAccountID() types.AccountID { + c.mu.RLock() + defer c.mu.RUnlock() + return c.accountID +} + +// SetOrigin sets the response origin. +func (c *CapturedData) SetOrigin(origin ResponseOrigin) { + c.mu.Lock() + defer c.mu.Unlock() + c.origin = origin +} + +// GetOrigin returns the response origin. +func (c *CapturedData) GetOrigin() ResponseOrigin { + c.mu.RLock() + defer c.mu.RUnlock() + return c.origin +} + +// SetClientIP sets the resolved client IP. +func (c *CapturedData) SetClientIP(ip netip.Addr) { + c.mu.Lock() + defer c.mu.Unlock() + c.clientIP = ip +} + +// GetClientIP returns the resolved client IP. +func (c *CapturedData) GetClientIP() netip.Addr { + c.mu.RLock() + defer c.mu.RUnlock() + return c.clientIP +} + +// SetUserID sets the authenticated user ID. +func (c *CapturedData) SetUserID(userID string) { + c.mu.Lock() + defer c.mu.Unlock() + c.userID = userID +} + +// GetUserID returns the authenticated user ID. +func (c *CapturedData) GetUserID() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.userID +} + +// SetUserEmail records the authenticated user's email address. Used by +// policy-aware middlewares to stamp identity onto upstream requests +// (e.g. x-litellm-end-user-id) without a management round-trip. +func (c *CapturedData) SetUserEmail(email string) { + c.mu.Lock() + defer c.mu.Unlock() + c.userEmail = email +} + +// GetUserEmail returns the authenticated user's email address. Returns +// the empty string when the auth path didn't carry an email (e.g. +// non-OIDC schemes or legacy JWTs minted before the email claim). +func (c *CapturedData) GetUserEmail() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.userEmail +} + +// SetUserGroups records the authenticated user's group memberships so +// downstream policy-aware middlewares can authorise the request without +// an additional management round-trip. The auth middleware populates this +// from ValidateSessionResponse / ValidateTunnelPeerResponse and from the +// session JWT's groups claim on cookie-bearing requests. +func (c *CapturedData) SetUserGroups(groups []string) { + c.mu.Lock() + defer c.mu.Unlock() + if len(groups) == 0 { + c.userGroups = nil + return + } + c.userGroups = append(c.userGroups[:0], groups...) +} + +// SetAgentNetwork records whether the request hit a synthesised +// agent-network target. The terminal access-log middleware stamps the +// flag onto the proto so management can distinguish synthetic traffic. +func (c *CapturedData) SetAgentNetwork(b bool) { + c.mu.Lock() + defer c.mu.Unlock() + c.agentNetwork = b +} + +// GetAgentNetwork reports whether the request matched a synthesised +// agent-network target. +func (c *CapturedData) GetAgentNetwork() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.agentNetwork +} + +// SetSuppressAccessLog records whether the per-request access-log emission +// must be skipped for this request. Stamped from the matched target's +// DisableAccessLog flag so the access-log middleware can short-circuit +// log delivery for opted-out agent-network targets. +func (c *CapturedData) SetSuppressAccessLog(b bool) { + c.mu.Lock() + defer c.mu.Unlock() + c.suppressAccessLog = b +} + +// GetSuppressAccessLog reports whether access-log emission has been +// suppressed for this request. +func (c *CapturedData) GetSuppressAccessLog() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.suppressAccessLog +} + +// GetUserGroups returns a copy of the authenticated user's group +// memberships. +func (c *CapturedData) GetUserGroups() []string { + c.mu.RLock() + defer c.mu.RUnlock() + if len(c.userGroups) == 0 { + return nil + } + out := make([]string, len(c.userGroups)) + copy(out, c.userGroups) + return out +} + +// SetUserGroupNames records the human-readable display names for the +// user's groups, ordered identically to UserGroups (positional +// pairing). Stamped onto upstream requests as X-NetBird-Groups so +// downstream services can read names rather than opaque ids. +func (c *CapturedData) SetUserGroupNames(names []string) { + c.mu.Lock() + defer c.mu.Unlock() + if len(names) == 0 { + c.userGroupNames = nil + return + } + c.userGroupNames = append(c.userGroupNames[:0], names...) +} + +// GetUserGroupNames returns a copy of the authenticated user's group +// display names. Position i pairs with UserGroups[i]. May be shorter +// than UserGroups for tokens minted before names were resolvable; the +// consumer should fall back to ids for missing positions. +func (c *CapturedData) GetUserGroupNames() []string { + c.mu.RLock() + defer c.mu.RUnlock() + if len(c.userGroupNames) == 0 { + return nil + } + out := make([]string, len(c.userGroupNames)) + copy(out, c.userGroupNames) + return out +} + +// SetAuthMethod sets the authentication method used. +func (c *CapturedData) SetAuthMethod(method string) { + c.mu.Lock() + defer c.mu.Unlock() + c.authMethod = method +} + +// GetAuthMethod returns the authentication method used. +func (c *CapturedData) GetAuthMethod() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.authMethod +} + +// SetMetadata sets a key-value pair in the metadata map. +func (c *CapturedData) SetMetadata(key, value string) { + c.mu.Lock() + defer c.mu.Unlock() + if c.metadata == nil { + c.metadata = make(map[string]string) + } + c.metadata[key] = value +} + +// GetMetadata returns a copy of the metadata map. +func (c *CapturedData) GetMetadata() map[string]string { + c.mu.RLock() + defer c.mu.RUnlock() + return maps.Clone(c.metadata) +} + +// WithCapturedData adds a CapturedData struct to the context. +func WithCapturedData(ctx context.Context, data *CapturedData) context.Context { + return context.WithValue(ctx, capturedDataKey, data) +} + +// CapturedDataFromContext retrieves the CapturedData from context. +func CapturedDataFromContext(ctx context.Context) *CapturedData { + v := ctx.Value(capturedDataKey) + data, ok := v.(*CapturedData) + if !ok { + return nil + } + return data +} diff --git a/proxy/internal/proxy/proxy_bench_test.go b/proxy/internal/proxy/proxy_bench_test.go new file mode 100644 index 000000000..b59ef75c0 --- /dev/null +++ b/proxy/internal/proxy/proxy_bench_test.go @@ -0,0 +1,136 @@ +package proxy_test + +import ( + "crypto/rand" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/proxy/internal/types" +) + +type nopTransport struct{} + +func (nopTransport) RoundTrip(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: http.NoBody, + }, nil +} + +func BenchmarkServeHTTP(b *testing.B) { + rp := proxy.NewReverseProxy(nopTransport{}, "http", nil, nil) + rp.AddMapping(proxy.Mapping{ + ID: types.ServiceID(rand.Text()), + AccountID: types.AccountID(rand.Text()), + Host: "app.example.com", + Paths: map[string]*proxy.PathTarget{ + "/": { + URL: &url.URL{ + Scheme: "http", + Host: "10.0.0.1:8080", + }, + }, + }, + }) + + req := httptest.NewRequest(http.MethodGet, "http://app.example.com", nil) + req.Host = "app.example.com" + req.RemoteAddr = "203.0.113.50:12345" + + for b.Loop() { + rp.ServeHTTP(httptest.NewRecorder(), req) + } +} + +func BenchmarkServeHTTPHostCount(b *testing.B) { + hostCounts := []int{1, 10, 100, 1_000, 10_000} + + for _, hostCount := range hostCounts { + b.Run(fmt.Sprintf("hosts=%d", hostCount), func(b *testing.B) { + rp := proxy.NewReverseProxy(nopTransport{}, "http", nil, nil) + + var target string + targetIndex, err := rand.Int(rand.Reader, big.NewInt(int64(hostCount))) + if err != nil { + b.Fatal(err) + } + for i := range hostCount { + id := rand.Text() + host := fmt.Sprintf("%s.example.com", id) + if int64(i) == targetIndex.Int64() { + target = id + } + rp.AddMapping(proxy.Mapping{ + ID: types.ServiceID(id), + AccountID: types.AccountID(rand.Text()), + Host: host, + Paths: map[string]*proxy.PathTarget{ + "/": { + URL: &url.URL{ + Scheme: "http", + Host: "10.0.0.1:8080", + }, + }, + }, + }) + } + + req := httptest.NewRequest(http.MethodGet, "http://"+target+"/", nil) + req.Host = target + req.RemoteAddr = "203.0.113.50:12345" + + for b.Loop() { + rp.ServeHTTP(httptest.NewRecorder(), req) + } + }) + } +} + +func BenchmarkServeHTTPPathCount(b *testing.B) { + pathCounts := []int{1, 5, 10, 25, 50} + + for _, pathCount := range pathCounts { + b.Run(fmt.Sprintf("paths=%d", pathCount), func(b *testing.B) { + rp := proxy.NewReverseProxy(nopTransport{}, "http", nil, nil) + + var target string + targetIndex, err := rand.Int(rand.Reader, big.NewInt(int64(pathCount))) + if err != nil { + b.Fatal(err) + } + + paths := make(map[string]*proxy.PathTarget, pathCount) + for i := range pathCount { + path := "/" + rand.Text() + if int64(i) == targetIndex.Int64() { + target = path + } + paths[path] = &proxy.PathTarget{ + URL: &url.URL{ + Scheme: "http", + Host: "10.0.0.1:" + fmt.Sprintf("%d", 8080+i), + }, + } + } + rp.AddMapping(proxy.Mapping{ + ID: types.ServiceID(rand.Text()), + AccountID: types.AccountID(rand.Text()), + Host: "app.example.com", + Paths: paths, + }) + + req := httptest.NewRequest(http.MethodGet, "http://app.example.com"+target, nil) + req.Host = "app.example.com" + req.RemoteAddr = "203.0.113.50:12345" + + for b.Loop() { + rp.ServeHTTP(httptest.NewRecorder(), req) + } + }) + } +} diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go new file mode 100644 index 000000000..9150c0329 --- /dev/null +++ b/proxy/internal/proxy/reverseproxy.go @@ -0,0 +1,940 @@ +package proxy + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/http/httputil" + "net/netip" + "net/url" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/bodytap" + "github.com/netbirdio/netbird/proxy/internal/roundtrip" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/proxy/web" + "github.com/netbirdio/netbird/trustedproxy" +) + +type ReverseProxy struct { + transport http.RoundTripper + // forwardedProto overrides the X-Forwarded-Proto header value. + // Valid values: "auto" (detect from TLS), "http", "https". + forwardedProto string + // trustedProxies is the set of trusted upstream proxies. When the direct + // connection comes from a trusted proxy, forwarding headers are preserved + // and appended to instead of being stripped. + trustedProxies *trustedproxy.List + mappingsMux sync.RWMutex + mappings map[string]Mapping + logger *log.Logger + // middlewareManager, when non-nil, drives per-target middleware + // dispatch. A nil manager (or an empty chain for the resolved + // target) keeps the reverse-proxy hot path on the no-capture fast + // path with no middleware overhead. + middlewareManager *middleware.Manager +} + +// Option configures optional ReverseProxy behavior. Options exist so the core +// constructor signature stays stable across additive features. +type Option func(*ReverseProxy) + +// WithMiddlewareManager attaches a middleware manager to the reverse +// proxy. When the manager is nil or returns an empty chain for the +// target, the request follows the fast path with no middleware +// overhead. +func WithMiddlewareManager(m *middleware.Manager) Option { + return func(p *ReverseProxy) { + p.middlewareManager = m + } +} + +// NewReverseProxy configures a new NetBird ReverseProxy. +// This is a wrapper around an httputil.ReverseProxy set +// to dynamically route requests based on internal mapping +// between requested URLs and targets. +// The internal mappings can be modified using the AddMapping +// and RemoveMapping functions. +func NewReverseProxy(transport http.RoundTripper, forwardedProto string, trustedProxies *trustedproxy.List, logger *log.Logger, opts ...Option) *ReverseProxy { + if logger == nil { + logger = log.StandardLogger() + } + p := &ReverseProxy{ + transport: transport, + forwardedProto: forwardedProto, + trustedProxies: trustedProxies, + mappings: make(map[string]Mapping), + logger: logger, + } + for _, opt := range opts { + opt(p) + } + return p +} + +func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + result, exists := p.findTargetForRequest(r) + if !exists { + p.serveRouteError(w, r, http.StatusNotFound, "Service Not Found", + "The requested service could not be found. Please check the URL, try refreshing, or check if the peer is running. If that doesn't work, see our documentation for help.") + return + } + + // Loop guard for private services: a peer that hosts the target + // dialing its own service URL would round-trip its own traffic + // through the proxy and back over WG to itself. Refuse the request + // with 421 (Misdirected Request) so the caller sees an explicit + // error instead of silently doubling tunnel traffic. + if p.isSelfTargetLoop(r, result.target.URL) { + p.serveRouteError(w, r, http.StatusMisdirectedRequest, "Loop Detected", + "This peer is the target of the requested service. Reach the backend directly instead of dialing the public service URL from the same machine.") + return + } + + pt := result.target + ctx := p.buildTargetContext(r.Context(), result) + + // Populate captured data if it exists (allows middleware to read after handler completes). + // This solves the problem of passing data UP the middleware chain: we put a mutable struct + // pointer in the context, and mutate the struct here so outer middleware can read it. + capturedData := CapturedDataFromContext(ctx) + if capturedData != nil { + capturedData.SetServiceID(result.serviceID) + capturedData.SetAccountID(result.accountID) + capturedData.SetAgentNetwork(result.target != nil && result.target.AgentNetwork) + capturedData.SetSuppressAccessLog(result.target != nil && result.target.DisableAccessLog) + } + + rewriteMatchedPath := result.matchedPath + if pt.PathRewrite == PathRewritePreserve { + rewriteMatchedPath = "" + } + + chain := p.resolveChain(result) + if chain == nil || chain.Empty() { + p.serveDirect(w, r, ctx, result, rewriteMatchedPath) + return + } + p.serveWithChain(w, r, ctx, result, chain, rewriteMatchedPath, capturedData) +} + +// serveRouteError marks the request as un-routed on any captured-data +// context and renders the proxy error page. +func (p *ReverseProxy) serveRouteError(w http.ResponseWriter, r *http.Request, status int, title, message string) { + if cd := CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(OriginNoRoute) + } + web.ServeErrorPage(w, r, status, title, message, getRequestID(r), + web.ErrorStatus{Proxy: true, Destination: false}) +} + +// buildTargetContext layers the per-target roundtrip flags (account id, +// TLS-verify skip, direct upstream, dial timeout) onto the request context. +func (p *ReverseProxy) buildTargetContext(ctx context.Context, result targetResult) context.Context { + pt := result.target + ctx = roundtrip.WithAccountID(ctx, result.accountID) + if pt.SkipTLSVerify { + ctx = roundtrip.WithSkipTLSVerify(ctx) + } + if pt.DirectUpstream { + ctx = roundtrip.WithDirectUpstream(ctx) + } + if pt.RequestTimeout > 0 { + ctx = types.WithDialTimeout(ctx, pt.RequestTimeout) + } + return ctx +} + +// serveDirect forwards the request without a middleware chain — the common +// path for plain reverse-proxy targets. +func (p *ReverseProxy) serveDirect(w http.ResponseWriter, r *http.Request, ctx context.Context, result targetResult, rewriteMatchedPath string) { + pt := result.target + rp := &httputil.ReverseProxy{ + Rewrite: p.rewriteFunc(pt.URL, rewriteMatchedPath, result.passHostHeader, pt.PathRewrite, pt.CustomHeaders, result.stripAuthHeaders), + Transport: p.transport, + FlushInterval: -1, + ErrorHandler: p.proxyErrorHandler, + } + if result.rewriteRedirects { + rp.ModifyResponse = p.rewriteLocationFunc(pt.URL, rewriteMatchedPath, r) //nolint:bodyclose + } + rp.ServeHTTP(w, r.WithContext(ctx)) +} + +// serveWithChain runs the per-target middleware chain around the upstream +// request: request-leg capture and authorisation, then (on allow) the +// upstream forward with response/terminal observation deferred so it reads +// the captured response before the writer is released. +func (p *ReverseProxy) serveWithChain(w http.ResponseWriter, r *http.Request, ctx context.Context, result targetResult, chain *middleware.Chain, rewriteMatchedPath string, capturedData *CapturedData) { + middlewareIDs := chain.IDs() + p.logger.Debugf("middleware chain matched: service=%s path=%s middlewares=%v", result.serviceID, result.matchedPath, middlewareIDs) + + capturedBody, truncated, originalSize, releaseBudget := p.captureRequestForChain(ctx, r, result, capturedData) + defer releaseBudget() + + acc := middleware.NewAccumulator(middleware.MaxRequestMetadataBytes) + reqInput := buildRequestInput(r, result, capturedData, capturedBody, truncated, originalSize) + + denyOutput, requestMeta, upstreamRewrite, _ := chain.RunRequest(ctx, r, reqInput, acc) + if capturedData != nil { + for _, kv := range requestMeta { + capturedData.SetMetadata(kv.Key, kv.Value) + } + } + if denyOutput != nil { + p.serveDeny(w, denyOutput, result, middlewareIDs) + return + } + + respWriter, capturingWriter := p.newResponseWriter(ctx, w, result, capturedData) + if capturingWriter != nil { + defer capturingWriter.Release() + defer p.observeResponse(ctx, chain, acc, reqInput, requestMeta, capturingWriter, w, capturedData, result, middlewareIDs) + } + + p.forwardUpstream(respWriter, r, ctx, result, rewriteMatchedPath, upstreamRewrite) +} + +// captureRequestForChain copies the request body for inspection by the +// chain, records any capture bypass, and applies agent-network routing +// recovery for oversized bodies. The returned release frees the capture +// budget and must be deferred by the caller. +func (p *ReverseProxy) captureRequestForChain(ctx context.Context, r *http.Request, result targetResult, capturedData *CapturedData) ([]byte, bool, int64, func()) { + pt := result.target + capturedBody, truncated, originalSize, bypass, releaseBudget, captureErr := bodytap.CaptureRequest(r, pt.CaptureConfig, p.middlewareManager.Budget()) + if captureErr != nil { + p.logger.Debugf("middleware request body capture error: %v", captureErr) + } + if bypass != "" { + if capturedData != nil { + capturedData.SetMetadata("mw.capture.bypass_reason", bypass) + } + p.middlewareManager.Metrics().IncCaptureBypass(ctx, string(result.serviceID), bypass) + } + + // Routing recovery for oversized agent-network requests: when the body + // exceeded the capture cap (bypassed or truncated), the captured copy + // can't be parsed for the model, so llm_router would deny with + // model_not_routable. Scan the full stream for just the routing fields + // and hand the request parser a minimal stub so routing succeeds; the + // prompt stays uncaptured and the upstream still gets the full body. + if pt.AgentNetwork && (truncated || capturedBody == nil) { + if model, stream, ok := bodytap.ScanRoutingFields(r, bodytap.MaxRoutingScanBytes); ok { + capturedBody = buildRoutingStub(model, stream) + truncated = false + p.logger.Debugf("agent-network routing recovery: extracted model=%s stream=%t from oversized request body (service=%s)", model, stream, result.serviceID) + } + } + return capturedBody, truncated, originalSize, releaseBudget +} + +// serveDeny renders the chain's deny response. Policy/budget/routing/guardrail +// denials are expected runtime outcomes and can be high-volume under +// misconfigured or hostile clients; per-request detail stays at Debug and +// metrics/access logs carry the signal at scale. +func (p *ReverseProxy) serveDeny(w http.ResponseWriter, denyOutput *middleware.Output, result targetResult, middlewareIDs []string) { + middlewareID := "middleware" + if denyOutput.DenyReason != nil && denyOutput.DenyReason.Code != "" { + middlewareID = denyOutput.DenyReason.Code + } + p.logger.Debugf("middleware chain denied request: service=%s path=%s middlewares=%v reason=%s status=%d", + result.serviceID, result.matchedPath, middlewareIDs, middlewareID, denyOutput.DenyStatus) + middleware.RenderDenyResponse(w, middlewareID, denyOutput.DenyReason, denyOutput.DenyStatus) +} + +// newResponseWriter returns the writer the upstream forward should use. When +// response capture is enabled and not bypassed it wraps w in a capturing +// writer (also returned so the caller can release it and feed the response +// leg); otherwise the capturing writer is nil and w is used directly. +func (p *ReverseProxy) newResponseWriter(ctx context.Context, w http.ResponseWriter, result targetResult, capturedData *CapturedData) (http.ResponseWriter, *bodytap.CapturingResponseWriter) { + pt := result.target + if pt.CaptureConfig == nil || pt.CaptureConfig.MaxResponseBytes <= 0 { + return w, nil + } + capturingWriter := bodytap.NewCapturingResponseWriter(w, pt.CaptureConfig.MaxResponseBytes, p.middlewareManager.Budget()) + if capturingWriter.Bypassed() { + if capturedData != nil { + capturedData.SetMetadata("mw.capture.bypass_reason", capturingWriter.BypassReason()) + } + p.middlewareManager.Metrics().IncCaptureBypass(ctx, string(result.serviceID), capturingWriter.BypassReason()) + capturingWriter.Release() + return w, nil + } + return capturingWriter, capturingWriter +} + +// observeResponse runs the response and terminal middleware slots after the +// body has been forwarded. It is deferred by serveWithChain so it reads the +// captured response before the writer is released. +func (p *ReverseProxy) observeResponse(ctx context.Context, chain *middleware.Chain, acc *middleware.Accumulator, reqInput *middleware.Input, requestMeta []middleware.KV, capturingWriter *bodytap.CapturingResponseWriter, w http.ResponseWriter, capturedData *CapturedData, result targetResult, middlewareIDs []string) { + respInput := &middleware.Input{ + Slot: middleware.SlotOnResponse, + RequestID: reqInput.RequestID, + TargetID: reqInput.TargetID, + Method: reqInput.Method, + URL: reqInput.URL, + Headers: reqInput.Headers, + Status: capturingWriter.Status(), + RespHeaders: headerToKV(w.Header()), + RespBody: capturingWriter.Body(), + RespBodyTruncated: capturingWriter.Truncated(), + OriginalRespSize: capturingWriter.BytesWritten(), + ServiceID: reqInput.ServiceID, + AccountID: reqInput.AccountID, + UserID: reqInput.UserID, + // UserEmail / UserGroups / UserGroupNames must flow into the + // response leg too — llm_limit_record needs UserGroups to send + // group_ids on RecordLLMUsage so management's account-budget + // fan-out can match group-targeted rules; identity-stamping and + // any future response-side authorisation also depend on these. + UserEmail: reqInput.UserEmail, + UserGroups: reqInput.UserGroups, + UserGroupNames: reqInput.UserGroupNames, + AuthMethod: reqInput.AuthMethod, + SourceIP: reqInput.SourceIP, + Metadata: requestMeta, + AgentNetwork: reqInput.AgentNetwork, + } + // The response/terminal phase runs after the body is forwarded, so + // a streaming client (e.g. Codex) has usually disconnected by now, + // cancelling r.Context(). These middlewares only observe and record + // (token/cost metering, usage recording) and must still complete — + // otherwise the dispatcher short-circuits each to fail-mode and the + // usage is silently lost. Detach from client cancellation, keep ctx + // values, and bound the work. + obsCtx, obsCancel := context.WithTimeout(context.WithoutCancel(ctx), observabilityPhaseTimeout) + defer obsCancel() + + respMeta := chain.RunResponse(obsCtx, respInput, acc) + if capturedData != nil { + for _, kv := range respMeta { + capturedData.SetMetadata(kv.Key, kv.Value) + } + } + + // Terminal slot sees the merged metadata bag from request and + // response phases. + mergedMeta := append(append([]middleware.KV(nil), requestMeta...), respMeta...) + termInput := *respInput + termInput.Slot = middleware.SlotTerminal + termInput.Metadata = mergedMeta + termMeta := chain.RunTerminal(obsCtx, &termInput, acc) + if capturedData != nil { + for _, kv := range termMeta { + capturedData.SetMetadata(kv.Key, kv.Value) + } + } + + p.logger.Debugf("middleware chain ran: service=%s path=%s middlewares=%v status=%d req_meta=%d resp_meta=%d term_meta=%d", + result.serviceID, result.matchedPath, middlewareIDs, capturingWriter.Status(), len(requestMeta), len(respMeta), len(termMeta)) +} + +// forwardUpstream applies any middleware-emitted upstream rewrite and proxies +// the request to the effective upstream URL. +func (p *ReverseProxy) forwardUpstream(respWriter http.ResponseWriter, r *http.Request, ctx context.Context, result targetResult, rewriteMatchedPath string, upstreamRewrite *middleware.UpstreamRewrite) { + pt := result.target + effectiveURL := applyUpstreamRewrite(pt.URL, upstreamRewrite) + if upstreamRewrite != nil { + r.Host = effectiveURL.Host + applyUpstreamHeaders(r, upstreamRewrite) + stripUpstreamPathPrefix(r, upstreamRewrite.StripPathPrefix) + // A router-selected route (e.g. agent-network provider) can opt into + // skipping upstream TLS verification per its provider config. + if upstreamRewrite.SkipTLSVerify { + ctx = roundtrip.WithSkipTLSVerify(ctx) + } + } + + rp := &httputil.ReverseProxy{ + Rewrite: p.rewriteFunc(effectiveURL, rewriteMatchedPath, result.passHostHeader, pt.PathRewrite, pt.CustomHeaders, result.stripAuthHeaders), + Transport: p.transport, + FlushInterval: -1, + ErrorHandler: p.proxyErrorHandler, + } + if result.rewriteRedirects { + rp.ModifyResponse = p.rewriteLocationFunc(effectiveURL, rewriteMatchedPath, r) //nolint:bodyclose + } + rp.ServeHTTP(respWriter, r.WithContext(ctx)) +} + +// buildRoutingStub returns a minimal JSON request body carrying only the +// model and stream fields. It feeds the LLM request parser when the real +// body was too large to capture: the parser emits llm.model / llm.stream +// so llm_router can route, while ExtractPrompt on the stub yields nothing +// — no prompt is captured for oversized requests. +func buildRoutingStub(model string, stream bool) []byte { + b, err := json.Marshal(map[string]any{"model": model, "stream": stream}) + if err != nil { + return nil + } + return b +} + +// applyUpstreamRewrite returns the effective upstream URL after +// applying a middleware-emitted rewrite. When rewrite is nil or +// incomplete, the original target is returned unchanged. The original +// URL is never mutated; a clone is returned when a rewrite applies. +// +// Rewrite Path semantics: when non-empty, replaces the cloned URL's +// path entirely. httputil.ProxyRequest.SetURL then joins target.Path +// with the agent's request path, so an operator-configured upstream +// path like "/v1/{account}/{gateway}/compat" gets prepended to +// "/chat/completions" yielding the full Cloudflare-shaped path. +// Empty rewrite.Path preserves the original target's path (the +// historical, non-agent-network behavior). +func applyUpstreamRewrite(orig *url.URL, rewrite *middleware.UpstreamRewrite) *url.URL { + if rewrite == nil || orig == nil { + return orig + } + if rewrite.Scheme == "" || rewrite.Host == "" { + return orig + } + cloned := *orig + cloned.Scheme = rewrite.Scheme + cloned.Host = rewrite.Host + if rewrite.Path != "" { + cloned.Path = rewrite.Path + cloned.RawPath = "" + } + return &cloned +} + +// stripUpstreamPathPrefix removes a gateway-namespace prefix (e.g. "/bedrock") +// from the request path before it is forwarded, so the upstream receives its +// native path. The chain has already run by this point, so metering/logging +// keep the original client path; only the outbound path is rewritten. RawPath +// is cleared so the escaped form is recomputed from the trimmed Path. +func stripUpstreamPathPrefix(r *http.Request, prefix string) { + if r == nil || r.URL == nil || prefix == "" { + return + } + if !strings.HasPrefix(r.URL.Path, prefix+"/") && r.URL.Path != prefix { + return + } + r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix) + if r.URL.Path == "" { + r.URL.Path = "/" + } + r.URL.RawPath = "" +} + +// applyUpstreamHeaders strips the headers the rewrite asks for and +// injects the resolved auth header on the in-flight request. It is +// the proxy-trusted counterpart to chain.applyMutations: regular +// middleware HeadersAdd/HeadersRemove pass through the framework +// denylist (which blocks Authorization, Cookie, etc.), but the +// router middleware needs to replace Authorization on the upstream +// request as a first-class operation. AuthHeader/StripHeaders ride +// on UpstreamRewrite so only the proxy's upstream-build path +// unpacks them — middlewares can't smuggle these in via the +// regular mutation surface. +func applyUpstreamHeaders(r *http.Request, rewrite *middleware.UpstreamRewrite) { + if r == nil || rewrite == nil { + return + } + for _, name := range rewrite.StripHeaders { + if name == "" { + continue + } + r.Header.Del(name) + } + if rewrite.AuthHeader != nil && rewrite.AuthHeader.Name != "" { + r.Header.Set(rewrite.AuthHeader.Name, rewrite.AuthHeader.Value) + } +} + +// resolveChain returns the middleware chain registered for the +// resolved target, or nil when middleware is disabled for the proxy +// or the target. +func (p *ReverseProxy) resolveChain(result targetResult) *middleware.Chain { + if p.middlewareManager == nil { + return nil + } + return p.middlewareManager.ChainFor(string(result.serviceID), result.matchedPath) +} + +// buildRequestInput gathers the per-request fields the middleware +// chain needs. Body and captured metadata are passed in; the rest are +// copied from the request and CapturedData. +func buildRequestInput(r *http.Request, result targetResult, cd *CapturedData, body []byte, truncated bool, originalSize int64) *middleware.Input { + in := &middleware.Input{ + Slot: middleware.SlotOnRequest, + TargetID: result.matchedPath, + Method: r.Method, + URL: r.URL.String(), + Headers: headerToKV(r.Header), + Body: body, + BodyTruncated: truncated, + OriginalBodySize: originalSize, + ServiceID: string(result.serviceID), + AccountID: string(result.accountID), + AgentNetwork: result.target != nil && result.target.AgentNetwork, + } + if cd != nil { + in.RequestID = cd.GetRequestID() + in.UserID = cd.GetUserID() + in.UserEmail = cd.GetUserEmail() + in.UserGroups = cd.GetUserGroups() + in.UserGroupNames = cd.GetUserGroupNames() + in.AuthMethod = cd.GetAuthMethod() + if ip := cd.GetClientIP(); ip.IsValid() { + in.SourceIP = ip.String() + } + } + return in +} + +// headerToKV flattens an http.Header into the KV slice shape expected +// by the middleware envelope, preserving value order under the same +// key. +func headerToKV(h http.Header) []middleware.KV { + if len(h) == 0 { + return nil + } + total := 0 + for _, v := range h { + total += len(v) + } + out := make([]middleware.KV, 0, total) + for k, vs := range h { + for _, v := range vs { + out = append(out, middleware.KV{Key: k, Value: v}) + } + } + return out +} + +// isSelfTargetLoop reports whether an overlay-origin request is about to +// be forwarded back to the very peer that initiated it. The detection +// is intentionally narrow: it only fires when the request arrived on +// the per-account inbound (overlay) listener (so we're confident the +// source address is the caller's tunnel IP), and only when the resolved +// target host matches that tunnel IP. Catching this here returns 421 to +// the caller instead of letting the proxy round-trip its own traffic +// over WG twice. +func (p *ReverseProxy) isSelfTargetLoop(r *http.Request, target *url.URL) bool { + if target == nil { + return false + } + if !types.IsOverlayOrigin(r.Context()) { + return false + } + srcIP := trustedproxy.ExtractHostIP(r.RemoteAddr) + if !srcIP.IsValid() { + return false + } + targetIP, err := netip.ParseAddr(target.Hostname()) + if err != nil { + return false + } + return srcIP.Unmap() == targetIP.Unmap() +} + +// rewriteFunc returns a Rewrite function for httputil.ReverseProxy that rewrites +// inbound requests to target the backend service while setting security-relevant +// forwarding headers and stripping proxy authentication credentials. +// When passHostHeader is true, the original client Host header is preserved +// instead of being rewritten to the backend's address. +// The pathRewrite parameter controls how the request path is transformed. +func (p *ReverseProxy) rewriteFunc(target *url.URL, matchedPath string, passHostHeader bool, pathRewrite PathRewriteMode, customHeaders map[string]string, stripAuthHeaders []string) func(r *httputil.ProxyRequest) { + return func(r *httputil.ProxyRequest) { + switch pathRewrite { + case PathRewritePreserve: + // Keep the full original request path as-is. + default: + if matchedPath != "" && matchedPath != "/" { + // Strip the matched path prefix from the incoming request path before + // SetURL joins it with the target's base path, avoiding path duplication. + r.Out.URL.Path = strings.TrimPrefix(r.Out.URL.Path, matchedPath) + if r.Out.URL.Path == "" { + r.Out.URL.Path = "/" + } + r.Out.URL.RawPath = "" + } + } + + r.SetURL(target) + if passHostHeader { + r.Out.Host = r.In.Host + } else { + r.Out.Host = target.Host + } + + for _, h := range stripAuthHeaders { + r.Out.Header.Del(h) + } + + for k, v := range customHeaders { + r.Out.Header.Set(k, v) + } + + stampNetBirdIdentity(r) + + clientIP := trustedproxy.ExtractHostIP(r.In.RemoteAddr) + + if p.trustedProxies.Contains(clientIP) { + p.setTrustedForwardingHeaders(r, clientIP) + } else { + p.setUntrustedForwardingHeaders(r, clientIP) + } + + stripSessionCookie(r) + stripSessionTokenQuery(r) + } +} + +// rewriteLocationFunc returns a ModifyResponse function that rewrites Location +// headers in backend responses when they point to the backend's address, +// replacing them with the public-facing host and scheme. +func (p *ReverseProxy) rewriteLocationFunc(target *url.URL, matchedPath string, inReq *http.Request) func(*http.Response) error { + publicHost := inReq.Host + publicScheme := auth.ResolveProto(p.forwardedProto, inReq.TLS) + + return func(resp *http.Response) error { + location := resp.Header.Get("Location") + if location == "" { + return nil + } + + locURL, err := url.Parse(location) + if err != nil { + return fmt.Errorf("parse Location header %q: %w", location, err) + } + + // Only rewrite absolute URLs that point to the backend. + if locURL.Host == "" || !hostsEqual(locURL, target) { + return nil + } + + locURL.Host = publicHost + locURL.Scheme = publicScheme + + // Re-add the stripped path prefix so the client reaches the correct route. + // TrimRight prevents double slashes when matchedPath has a trailing slash. + if matchedPath != "" && matchedPath != "/" { + locURL.Path = strings.TrimRight(matchedPath, "/") + "/" + strings.TrimLeft(locURL.Path, "/") + } + + resp.Header.Set("Location", locURL.String()) + return nil + } +} + +// hostsEqual compares two URL authorities, normalizing default ports per +// RFC 3986 Section 6.2.3 (https://443 == https, http://80 == http). +func hostsEqual(a, b *url.URL) bool { + return normalizeHost(a) == normalizeHost(b) +} + +// normalizeHost strips the port from a URL's Host field if it matches the +// scheme's default port (443 for https, 80 for http). +func normalizeHost(u *url.URL) string { + host, port, err := net.SplitHostPort(u.Host) + if err != nil { + return u.Host + } + if (u.Scheme == "https" && port == "443") || (u.Scheme == "http" && port == "80") { + return host + } + return u.Host +} + +// setTrustedForwardingHeaders appends to the existing forwarding header chain +// and preserves upstream-provided headers when the direct connection is from +// a trusted proxy. +func (p *ReverseProxy) setTrustedForwardingHeaders(r *httputil.ProxyRequest, clientIP netip.Addr) { + ipStr := clientIP.String() + + // Append the direct connection IP to the existing X-Forwarded-For chain. + if existing := r.In.Header.Get("X-Forwarded-For"); existing != "" { + r.Out.Header.Set("X-Forwarded-For", existing+", "+ipStr) + } else { + r.Out.Header.Set("X-Forwarded-For", ipStr) + } + + // Preserve upstream X-Real-IP if present; otherwise resolve through the chain. + if realIP := r.In.Header.Get("X-Real-IP"); realIP != "" { + r.Out.Header.Set("X-Real-IP", realIP) + } else { + resolved := p.trustedProxies.ResolveClientIP(r.In.RemoteAddr, r.In.Header.Get("X-Forwarded-For")) + r.Out.Header.Set("X-Real-IP", resolved.String()) + } + + // Preserve upstream X-Forwarded-Host if present. + if fwdHost := r.In.Header.Get("X-Forwarded-Host"); fwdHost != "" { + r.Out.Header.Set("X-Forwarded-Host", fwdHost) + } else { + r.Out.Header.Set("X-Forwarded-Host", r.In.Host) + } + + // Trust upstream X-Forwarded-Proto; fall back to local resolution. + if fwdProto := r.In.Header.Get("X-Forwarded-Proto"); fwdProto != "" { + r.Out.Header.Set("X-Forwarded-Proto", fwdProto) + } else { + r.Out.Header.Set("X-Forwarded-Proto", auth.ResolveProto(p.forwardedProto, r.In.TLS)) + } + + // Trust upstream X-Forwarded-Port; fall back to local computation. + if fwdPort := r.In.Header.Get("X-Forwarded-Port"); fwdPort != "" { + r.Out.Header.Set("X-Forwarded-Port", fwdPort) + } else { + resolvedProto := r.Out.Header.Get("X-Forwarded-Proto") + r.Out.Header.Set("X-Forwarded-Port", extractForwardedPort(r.In.Host, resolvedProto)) + } +} + +// setUntrustedForwardingHeaders strips all incoming forwarding headers and +// sets them fresh based on the direct connection. This is the default +// behavior when no trusted proxies are configured or the direct connection +// is from an untrusted source. +func (p *ReverseProxy) setUntrustedForwardingHeaders(r *httputil.ProxyRequest, clientIP netip.Addr) { + ipStr := clientIP.String() + proto := auth.ResolveProto(p.forwardedProto, r.In.TLS) + r.Out.Header.Set("X-Forwarded-For", ipStr) + r.Out.Header.Set("X-Real-IP", ipStr) + r.Out.Header.Set("X-Forwarded-Host", r.In.Host) + r.Out.Header.Set("X-Forwarded-Proto", proto) + r.Out.Header.Set("X-Forwarded-Port", extractForwardedPort(r.In.Host, proto)) +} + +// stripSessionCookie removes the proxy's session cookie from the outgoing +// request while preserving all other cookies. +func stripSessionCookie(r *httputil.ProxyRequest) { + cookies := r.In.Cookies() + r.Out.Header.Del("Cookie") + for _, c := range cookies { + if c.Name != auth.SessionCookieName { + r.Out.AddCookie(c) + } + } +} + +// stripSessionTokenQuery removes the OIDC session_token query parameter from +// the outgoing URL to prevent credential leakage to backends. +func stripSessionTokenQuery(r *httputil.ProxyRequest) { + q := r.Out.URL.Query() + if q.Has("session_token") { + q.Del("session_token") + r.Out.URL.RawQuery = q.Encode() + } +} + +// extractForwardedPort returns the port from the Host header if present, +// otherwise defaults to the standard port for the resolved protocol. +func extractForwardedPort(host, resolvedProto string) string { + _, port, err := net.SplitHostPort(host) + if err == nil && port != "" { + return port + } + if resolvedProto == "https" { + return "443" + } + return "80" +} + +// proxyErrorHandler handles errors from the reverse proxy and serves +// user-friendly error pages instead of raw error responses. +func (p *ReverseProxy) proxyErrorHandler(w http.ResponseWriter, r *http.Request, err error) { + if cd := CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(OriginProxyError) + } + requestID := getRequestID(r) + clientIP := getClientIP(r) + title, message, code, status := classifyProxyError(err) + + p.logger.Warnf("proxy error: request_id=%s client_ip=%s method=%s host=%s path=%s status=%d title=%q err=%v", + requestID, clientIP, r.Method, r.Host, r.URL.Path, code, title, err) + + web.ServeErrorPage(w, r, code, title, message, requestID, status) +} + +// getClientIP retrieves the resolved client IP string from context. +func getClientIP(r *http.Request) string { + if capturedData := CapturedDataFromContext(r.Context()); capturedData != nil { + if ip := capturedData.GetClientIP(); ip.IsValid() { + return ip.String() + } + } + return "" +} + +// getRequestID retrieves the request ID from context or returns empty string. +func getRequestID(r *http.Request) string { + if capturedData := CapturedDataFromContext(r.Context()); capturedData != nil { + return capturedData.GetRequestID() + } + return "" +} + +// classifyProxyError determines the appropriate error title, message, HTTP +// status code, and component status based on the error type. +func classifyProxyError(err error) (title, message string, code int, status web.ErrorStatus) { + switch { + case errors.Is(err, context.DeadlineExceeded), + isNetTimeout(err): + return "Request Timeout", + "The request timed out while trying to reach the service. Please refresh the page and try again.", + http.StatusGatewayTimeout, + web.ErrorStatus{Proxy: true, Destination: false} + + case errors.Is(err, context.Canceled): + return "Request Canceled", + "The request was canceled before it could be completed. Please refresh the page and try again.", + http.StatusBadGateway, + web.ErrorStatus{Proxy: true, Destination: false} + + case errors.Is(err, roundtrip.ErrNoAccountID): + return "Configuration Error", + "The request could not be processed due to a configuration issue. Please refresh the page and try again.", + http.StatusInternalServerError, + web.ErrorStatus{Proxy: false, Destination: false} + + case errors.Is(err, roundtrip.ErrNoPeerConnection), + errors.Is(err, roundtrip.ErrClientStartFailed): + return "Proxy Not Connected", + "The proxy is not connected to the NetBird network. Please try again later or contact your administrator.", + http.StatusBadGateway, + web.ErrorStatus{Proxy: false, Destination: false} + + case errors.Is(err, roundtrip.ErrTooManyInflight): + return "Service Overloaded", + "The service is currently handling too many requests. Please try again shortly.", + http.StatusServiceUnavailable, + web.ErrorStatus{Proxy: true, Destination: false} + + case isConnectionRefused(err): + return "Service Unavailable", + "The connection to the service was refused. Please verify that the service is running and try again.", + http.StatusBadGateway, + web.ErrorStatus{Proxy: true, Destination: false} + + case isHostUnreachable(err): + return "Peer Not Connected", + "The connection to the peer could not be established. Please ensure the peer is running and connected to the NetBird network.", + http.StatusBadGateway, + web.ErrorStatus{Proxy: true, Destination: false} + } + + return "Connection Error", + "An unexpected error occurred while connecting to the service. Please try again later.", + http.StatusBadGateway, + web.ErrorStatus{Proxy: true, Destination: false} +} + +// isConnectionRefused checks for connection refused errors by inspecting +// the inner error of a *net.OpError. This handles both standard net errors +// (where the inner error is a *os.SyscallError with "connection refused") +// and gVisor netstack errors ("connection was refused"). +func isConnectionRefused(err error) bool { + return opErrorContains(err, "refused") +} + +// isHostUnreachable checks for host/network unreachable errors by inspecting +// the inner error of a *net.OpError. Covers standard net ("no route to host", +// "network is unreachable") and gVisor ("host is unreachable", etc.). +func isHostUnreachable(err error) bool { + return opErrorContains(err, "unreachable") || opErrorContains(err, "no route to host") +} + +// isNetTimeout checks whether the error is a network timeout using the +// net.Error interface. +func isNetTimeout(err error) bool { + var netErr net.Error + return errors.As(err, &netErr) && netErr.Timeout() +} + +// opErrorContains extracts the inner error from a *net.OpError and checks +// whether its message contains the given substring. This handles gVisor +// netstack errors which wrap tcpip errors as plain strings rather than +// syscall.Errno values. +func opErrorContains(err error, substr string) bool { + var opErr *net.OpError + if errors.As(err, &opErr) && opErr.Err != nil { + return strings.Contains(opErr.Err.Error(), substr) + } + return false +} + +const ( + // headerNetBirdUser carries the authenticated user's display identity + // (email when the peer is attached to a user, else peer name) onto + // upstream requests. Stripped from inbound requests before stamping + // so a client can't spoof identity by setting the header themselves. + headerNetBirdUser = "X-NetBird-User" + // headerNetBirdGroups carries the user's group display names as a + // comma-separated list. Falls back to group IDs at positions where a + // name wasn't available at session-mint time. Labels containing a + // comma or any non-printable byte are dropped at stamp time so the + // list is unambiguously splittable by consumers. + headerNetBirdGroups = "X-NetBird-Groups" + + // observabilityPhaseTimeout bounds the detached response/terminal + // metering phase. It runs after the client connection (and its context) + // may be gone, so it can't borrow the request deadline; this ceiling + // keeps a slow management round-trip (RecordLLMUsage) from pinning the + // handler goroutine indefinitely while still allowing each middleware + // its own per-invoke timeout. + observabilityPhaseTimeout = 30 * time.Second +) + +// isHeaderValueSafe reports whether v is a valid RFC 7230 field-value: +// VCHAR (0x21-0x7E), SP (0x20), or HTAB (0x09). Empty values are +// rejected; the caller decides whether to omit the header entirely. +func isHeaderValueSafe(v string) bool { + if v == "" { + return false + } + for i := 0; i < len(v); i++ { + c := v[i] + if c == '\t' || (c >= 0x20 && c <= 0x7E) { + continue + } + return false + } + return true +} + +// stampNetBirdIdentity injects authenticated identity onto outbound +// requests as X-NetBird-User and X-NetBird-Groups. Always strips any +// client-sent values first (anti-spoof). Skips when the request didn't +// carry CapturedData (early-path errors, internal endpoints). +func stampNetBirdIdentity(r *httputil.ProxyRequest) { + r.Out.Header.Del(headerNetBirdUser) + r.Out.Header.Del(headerNetBirdGroups) + + cd := CapturedDataFromContext(r.In.Context()) + if cd == nil { + return + } + if email := cd.GetUserEmail(); isHeaderValueSafe(email) { + r.Out.Header.Set(headerNetBirdUser, email) + } + groupIDs := cd.GetUserGroups() + if len(groupIDs) == 0 { + return + } + groupNames := cd.GetUserGroupNames() + labels := make([]string, 0, len(groupIDs)) + for i, id := range groupIDs { + label := id + if i < len(groupNames) && groupNames[i] != "" { + label = groupNames[i] + } + if !isHeaderValueSafe(label) || strings.ContainsRune(label, ',') { + continue + } + labels = append(labels, label) + } + if len(labels) > 0 { + r.Out.Header.Set(headerNetBirdGroups, strings.Join(labels, ",")) + } +} diff --git a/proxy/internal/proxy/reverseproxy_test.go b/proxy/internal/proxy/reverseproxy_test.go new file mode 100644 index 000000000..83afee387 --- /dev/null +++ b/proxy/internal/proxy/reverseproxy_test.go @@ -0,0 +1,1453 @@ +package proxy + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/netip" + "net/url" + "os" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/roundtrip" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/proxy/web" + "github.com/netbirdio/netbird/trustedproxy" +) + +func TestRewriteFunc_HostRewriting(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + p := &ReverseProxy{forwardedProto: "auto"} + + t.Run("rewrites host to backend by default", func(t *testing.T) { + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "https://public.example.com/path", "203.0.113.1:12345") + + rewrite(pr) + + assert.Equal(t, "backend.internal:8080", pr.Out.Host) + }) + + t.Run("preserves original host when passHostHeader is true", func(t *testing.T) { + rewrite := p.rewriteFunc(target, "", true, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "https://public.example.com/path", "203.0.113.1:12345") + + rewrite(pr) + + assert.Equal(t, "public.example.com", pr.Out.Host, + "Host header should be the original client host") + assert.Equal(t, "backend.internal:8080", pr.Out.URL.Host, + "URL host (used for TLS/SNI) must still point to the backend") + }) +} + +func TestRewriteFunc_XForwardedForStripping(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + t.Run("sets X-Forwarded-For from direct connection IP", func(t *testing.T) { + pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999") + + rewrite(pr) + + assert.Equal(t, "203.0.113.50", pr.Out.Header.Get("X-Forwarded-For"), + "should be set to the connecting client IP") + }) + + t.Run("strips spoofed X-Forwarded-For from client", func(t *testing.T) { + pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999") + pr.In.Header.Set("X-Forwarded-For", "10.0.0.1, 172.16.0.1") + + rewrite(pr) + + assert.Equal(t, "203.0.113.50", pr.Out.Header.Get("X-Forwarded-For"), + "spoofed XFF must be replaced, not appended to") + }) + + t.Run("strips spoofed X-Real-IP from client", func(t *testing.T) { + pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999") + pr.In.Header.Set("X-Real-IP", "10.0.0.1") + + rewrite(pr) + + assert.Equal(t, "203.0.113.50", pr.Out.Header.Get("X-Real-IP"), + "spoofed X-Real-IP must be replaced") + }) +} + +func TestRewriteFunc_ForwardedHostAndProto(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + + t.Run("sets X-Forwarded-Host to original host", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://myapp.example.com:8443/path", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "myapp.example.com:8443", pr.Out.Header.Get("X-Forwarded-Host")) + }) + + t.Run("sets X-Forwarded-Port from explicit host port", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://example.com:8443/path", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "8443", pr.Out.Header.Get("X-Forwarded-Port")) + }) + + t.Run("defaults X-Forwarded-Port to 443 for https", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "https://example.com/", "1.2.3.4:5000") + pr.In.TLS = &tls.ConnectionState{} + + rewrite(pr) + + assert.Equal(t, "443", pr.Out.Header.Get("X-Forwarded-Port")) + }) + + t.Run("defaults X-Forwarded-Port to 80 for http", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "80", pr.Out.Header.Get("X-Forwarded-Port")) + }) + + t.Run("auto detects https from TLS", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "https://example.com/", "1.2.3.4:5000") + pr.In.TLS = &tls.ConnectionState{} + + rewrite(pr) + + assert.Equal(t, "https", pr.Out.Header.Get("X-Forwarded-Proto")) + }) + + t.Run("auto detects http without TLS", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "http", pr.Out.Header.Get("X-Forwarded-Proto")) + }) + + t.Run("forced proto overrides TLS detection", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "https"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + // No TLS, but forced to https + + rewrite(pr) + + assert.Equal(t, "https", pr.Out.Header.Get("X-Forwarded-Proto")) + }) + + t.Run("forced http proto", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "http"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "https://example.com/", "1.2.3.4:5000") + pr.In.TLS = &tls.ConnectionState{} + + rewrite(pr) + + assert.Equal(t, "http", pr.Out.Header.Get("X-Forwarded-Proto")) + }) +} + +func TestRewriteFunc_SessionCookieStripping(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + t.Run("strips nb_session cookie", func(t *testing.T) { + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + pr.In.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: "jwt-token-here"}) + + rewrite(pr) + + cookies := pr.Out.Cookies() + for _, c := range cookies { + assert.NotEqual(t, auth.SessionCookieName, c.Name, + "proxy session cookie must not be forwarded to backend") + } + }) + + t.Run("preserves other cookies", func(t *testing.T) { + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + pr.In.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: "jwt-token"}) + pr.In.AddCookie(&http.Cookie{Name: "app_session", Value: "app-value"}) + pr.In.AddCookie(&http.Cookie{Name: "tracking", Value: "track-value"}) + + rewrite(pr) + + cookies := pr.Out.Cookies() + cookieNames := make([]string, 0, len(cookies)) + for _, c := range cookies { + cookieNames = append(cookieNames, c.Name) + } + assert.Contains(t, cookieNames, "app_session", "non-proxy cookies should be preserved") + assert.Contains(t, cookieNames, "tracking", "non-proxy cookies should be preserved") + assert.NotContains(t, cookieNames, auth.SessionCookieName, "proxy cookie must be stripped") + }) + + t.Run("handles request with no cookies", func(t *testing.T) { + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + + rewrite(pr) + + assert.Empty(t, pr.Out.Header.Get("Cookie")) + }) +} + +func TestRewriteFunc_SessionTokenQueryStripping(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + t.Run("strips session_token query parameter", func(t *testing.T) { + pr := newProxyRequest(t, "http://example.com/callback?session_token=secret123&other=keep", "1.2.3.4:5000") + + rewrite(pr) + + assert.Empty(t, pr.Out.URL.Query().Get("session_token"), + "OIDC session token must be stripped from backend request") + assert.Equal(t, "keep", pr.Out.URL.Query().Get("other"), + "other query parameters must be preserved") + }) + + t.Run("preserves query when no session_token present", func(t *testing.T) { + pr := newProxyRequest(t, "http://example.com/api?foo=bar&baz=qux", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "bar", pr.Out.URL.Query().Get("foo")) + assert.Equal(t, "qux", pr.Out.URL.Query().Get("baz")) + }) +} + +func TestRewriteFunc_URLRewriting(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + + t.Run("rewrites URL to target with path prefix", func(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080/app") + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://example.com/somepath", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "http", pr.Out.URL.Scheme) + assert.Equal(t, "backend.internal:8080", pr.Out.URL.Host) + assert.Equal(t, "/app/somepath", pr.Out.URL.Path, + "SetURL should join the target base path with the request path") + }) + + t.Run("strips matched path prefix to avoid duplication", func(t *testing.T) { + target, _ := url.Parse("https://backend.example.org:443/app") + rewrite := p.rewriteFunc(target, "/app", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://example.com/app", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "https", pr.Out.URL.Scheme) + assert.Equal(t, "backend.example.org:443", pr.Out.URL.Host) + assert.Equal(t, "/app/", pr.Out.URL.Path, + "matched path prefix should be stripped before joining with target path") + }) + + t.Run("strips matched prefix and preserves subpath", func(t *testing.T) { + target, _ := url.Parse("https://backend.example.org:443/app") + rewrite := p.rewriteFunc(target, "/app", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://example.com/app/article/123", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "/app/article/123", pr.Out.URL.Path, + "subpath after matched prefix should be preserved") + }) +} + +func TestExtractHostIP(t *testing.T) { + tests := []struct { + name string + remoteAddr string + expected netip.Addr + }{ + {"IPv4 with port", "192.168.1.1:12345", netip.MustParseAddr("192.168.1.1")}, + {"IPv6 with port", "[::1]:12345", netip.MustParseAddr("::1")}, + {"IPv6 full with port", "[2001:db8::1]:443", netip.MustParseAddr("2001:db8::1")}, + {"IPv4 without port fallback", "192.168.1.1", netip.MustParseAddr("192.168.1.1")}, + {"IPv6 without brackets fallback", "::1", netip.MustParseAddr("::1")}, + {"empty string fallback", "", netip.Addr{}}, + {"public IP", "203.0.113.50:9999", netip.MustParseAddr("203.0.113.50")}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, trustedproxy.ExtractHostIP(tt.remoteAddr)) + }) + } +} + +func TestExtractForwardedPort(t *testing.T) { + tests := []struct { + name string + host string + resolvedProto string + expected string + }{ + {"explicit port in host", "example.com:8443", "https", "8443"}, + {"explicit port overrides proto default", "example.com:9090", "http", "9090"}, + {"no port defaults to 443 for https", "example.com", "https", "443"}, + {"no port defaults to 80 for http", "example.com", "http", "80"}, + {"IPv6 host with port", "[::1]:8080", "http", "8080"}, + {"IPv6 host without port", "::1", "https", "443"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, extractForwardedPort(tt.host, tt.resolvedProto)) + }) + } +} + +func TestRewriteFunc_TrustedProxy(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + trusted := trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}) + + t.Run("appends to X-Forwarded-For", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") + pr.In.Header.Set("X-Forwarded-For", "203.0.113.50") + + rewrite(pr) + + assert.Equal(t, "203.0.113.50, 10.0.0.1", pr.Out.Header.Get("X-Forwarded-For")) + }) + + t.Run("preserves upstream X-Real-IP", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") + pr.In.Header.Set("X-Forwarded-For", "203.0.113.50") + pr.In.Header.Set("X-Real-IP", "203.0.113.50") + + rewrite(pr) + + assert.Equal(t, "203.0.113.50", pr.Out.Header.Get("X-Real-IP")) + }) + + t.Run("resolves X-Real-IP from XFF when not set by upstream", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") + pr.In.Header.Set("X-Forwarded-For", "203.0.113.50, 10.0.0.2") + + rewrite(pr) + + assert.Equal(t, "203.0.113.50", pr.Out.Header.Get("X-Real-IP"), + "should resolve real client through trusted chain") + }) + + t.Run("preserves upstream X-Forwarded-Host", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://proxy.internal/", "10.0.0.1:5000") + pr.In.Header.Set("X-Forwarded-Host", "original.example.com") + + rewrite(pr) + + assert.Equal(t, "original.example.com", pr.Out.Header.Get("X-Forwarded-Host")) + }) + + t.Run("preserves upstream X-Forwarded-Proto", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") + pr.In.Header.Set("X-Forwarded-Proto", "https") + + rewrite(pr) + + assert.Equal(t, "https", pr.Out.Header.Get("X-Forwarded-Proto")) + }) + + t.Run("preserves upstream X-Forwarded-Port", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") + pr.In.Header.Set("X-Forwarded-Port", "8443") + + rewrite(pr) + + assert.Equal(t, "8443", pr.Out.Header.Get("X-Forwarded-Port")) + }) + + t.Run("falls back to local proto when upstream does not set it", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "https", trustedProxies: trusted} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") + + rewrite(pr) + + assert.Equal(t, "https", pr.Out.Header.Get("X-Forwarded-Proto"), + "should use configured forwardedProto as fallback") + }) + + t.Run("sets X-Forwarded-Host from request when upstream does not set it", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") + + rewrite(pr) + + assert.Equal(t, "example.com", pr.Out.Header.Get("X-Forwarded-Host")) + }) + + t.Run("untrusted RemoteAddr strips headers even with trusted list", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999") + pr.In.Header.Set("X-Forwarded-For", "10.0.0.1, 172.16.0.1") + pr.In.Header.Set("X-Real-IP", "evil") + pr.In.Header.Set("X-Forwarded-Host", "evil.example.com") + pr.In.Header.Set("X-Forwarded-Proto", "https") + pr.In.Header.Set("X-Forwarded-Port", "9999") + + rewrite(pr) + + assert.Equal(t, "203.0.113.50", pr.Out.Header.Get("X-Forwarded-For"), + "untrusted: XFF must be replaced") + assert.Equal(t, "203.0.113.50", pr.Out.Header.Get("X-Real-IP"), + "untrusted: X-Real-IP must be replaced") + assert.Equal(t, "example.com", pr.Out.Header.Get("X-Forwarded-Host"), + "untrusted: host must be from direct connection") + assert.Equal(t, "http", pr.Out.Header.Get("X-Forwarded-Proto"), + "untrusted: proto must be locally resolved") + assert.Equal(t, "80", pr.Out.Header.Get("X-Forwarded-Port"), + "untrusted: port must be locally computed") + }) + + t.Run("empty trusted list behaves as untrusted", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto", trustedProxies: nil} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") + pr.In.Header.Set("X-Forwarded-For", "203.0.113.50") + + rewrite(pr) + + assert.Equal(t, "10.0.0.1", pr.Out.Header.Get("X-Forwarded-For"), + "nil trusted list: should strip and use RemoteAddr") + }) + + t.Run("XFF starts fresh when trusted proxy has no upstream XFF", func(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto", trustedProxies: trusted} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "10.0.0.1:5000") + + rewrite(pr) + + assert.Equal(t, "10.0.0.1", pr.Out.Header.Get("X-Forwarded-For"), + "no upstream XFF: should set direct connection IP") + }) +} + +// TestRewriteFunc_PathForwarding verifies what path the backend actually +// receives given different configurations. This simulates the full pipeline: +// management builds a target URL (with matching prefix baked into the path), +// then the proxy strips the prefix and SetURL re-joins with the target path. +func TestRewriteFunc_PathForwarding(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + + // Simulate what ToProtoMapping does: target URL includes the matching + // prefix as its path component, so the proxy strips-then-re-adds. + t.Run("path prefix baked into target URL is a no-op", func(t *testing.T) { + // Management builds: path="/heise", target="https://heise.de:443/heise" + target, _ := url.Parse("https://heise.de:443/heise") + rewrite := p.rewriteFunc(target, "/heise", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://external.test/heise", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "/heise/", pr.Out.URL.Path, + "backend sees /heise/ because prefix is stripped then re-added by SetURL") + }) + + t.Run("subpath under prefix also preserved", func(t *testing.T) { + target, _ := url.Parse("https://heise.de:443/heise") + rewrite := p.rewriteFunc(target, "/heise", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://external.test/heise/article/123", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "/heise/article/123", pr.Out.URL.Path, + "subpath is preserved on top of the re-added prefix") + }) + + // What the behavior WOULD be if target URL had no path (true stripping) + t.Run("target without path prefix gives true stripping", func(t *testing.T) { + target, _ := url.Parse("https://heise.de:443") + rewrite := p.rewriteFunc(target, "/heise", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://external.test/heise", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "/", pr.Out.URL.Path, + "without path in target URL, backend sees / (true prefix stripping)") + }) + + t.Run("target without path prefix strips and preserves subpath", func(t *testing.T) { + target, _ := url.Parse("https://heise.de:443") + rewrite := p.rewriteFunc(target, "/heise", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://external.test/heise/article/123", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "/article/123", pr.Out.URL.Path, + "without path in target URL, prefix is truly stripped") + }) + + // Root path "/" — no stripping expected + t.Run("root path forwards full request path unchanged", func(t *testing.T) { + target, _ := url.Parse("https://backend.example.com:443/") + rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://external.test/heise", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "/heise", pr.Out.URL.Path, + "root path match must not strip anything") + }) +} + +func TestRewriteFunc_PreservePath(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + target, _ := url.Parse("http://backend.internal:8080") + + t.Run("preserve keeps full request path", func(t *testing.T) { + rewrite := p.rewriteFunc(target, "/api", false, PathRewritePreserve, nil, nil) + pr := newProxyRequest(t, "http://example.com/api/users/123", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "/api/users/123", pr.Out.URL.Path, + "preserve should keep the full original request path") + }) + + t.Run("preserve with root matchedPath", func(t *testing.T) { + rewrite := p.rewriteFunc(target, "/", false, PathRewritePreserve, nil, nil) + pr := newProxyRequest(t, "http://example.com/anything", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "/anything", pr.Out.URL.Path) + }) +} + +func TestRewriteFunc_CustomHeaders(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + target, _ := url.Parse("http://backend.internal:8080") + + t.Run("injects custom headers", func(t *testing.T) { + headers := map[string]string{ + "X-Custom-Auth": "token-abc", + "X-Env": "production", + } + rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, headers, nil) + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "token-abc", pr.Out.Header.Get("X-Custom-Auth")) + assert.Equal(t, "production", pr.Out.Header.Get("X-Env")) + }) + + t.Run("nil customHeaders is fine", func(t *testing.T) { + rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, nil, nil) + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "backend.internal:8080", pr.Out.Host) + }) + + t.Run("custom headers override existing request headers", func(t *testing.T) { + headers := map[string]string{"X-Override": "new-value"} + rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, headers, nil) + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + pr.In.Header.Set("X-Override", "old-value") + + rewrite(pr) + + assert.Equal(t, "new-value", pr.Out.Header.Get("X-Override")) + }) +} + +func TestRewriteFunc_StripsAuthorizationHeader(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + target, _ := url.Parse("http://backend.internal:8080") + + t.Run("strips incoming Authorization when no custom Authorization set", func(t *testing.T) { + rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, nil, []string{"Authorization"}) + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + pr.In.Header.Set("Authorization", "Bearer proxy-token") + + rewrite(pr) + + assert.Empty(t, pr.Out.Header.Get("Authorization"), "Authorization should be stripped") + }) + + t.Run("custom Authorization replaces incoming", func(t *testing.T) { + headers := map[string]string{"Authorization": "Basic YmFja2VuZDpzZWNyZXQ="} + rewrite := p.rewriteFunc(target, "/", false, PathRewriteDefault, headers, []string{"Authorization"}) + pr := newProxyRequest(t, "http://example.com/", "1.2.3.4:5000") + pr.In.Header.Set("Authorization", "Bearer proxy-token") + + rewrite(pr) + + assert.Equal(t, "Basic YmFja2VuZDpzZWNyZXQ=", pr.Out.Header.Get("Authorization"), + "backend Authorization from custom headers should be set") + }) +} + +func TestRewriteFunc_PreservePathWithCustomHeaders(t *testing.T) { + p := &ReverseProxy{forwardedProto: "auto"} + target, _ := url.Parse("http://backend.internal:8080") + + rewrite := p.rewriteFunc(target, "/api", false, PathRewritePreserve, map[string]string{"X-Via": "proxy"}, nil) + pr := newProxyRequest(t, "http://example.com/api/deep/path", "1.2.3.4:5000") + + rewrite(pr) + + assert.Equal(t, "/api/deep/path", pr.Out.URL.Path, "preserve should keep the full original path") + assert.Equal(t, "proxy", pr.Out.Header.Get("X-Via"), "custom header should be set") +} + +func TestRewriteLocationFunc(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + newProxy := func(proto string) *ReverseProxy { return &ReverseProxy{forwardedProto: proto} } + newReq := func(rawURL string) *http.Request { + t.Helper() + r := httptest.NewRequest(http.MethodGet, rawURL, nil) + parsed, _ := url.Parse(rawURL) + r.Host = parsed.Host + return r + } + run := func(p *ReverseProxy, matchedPath string, inReq *http.Request, location string) (*http.Response, error) { + t.Helper() + modifyResp := p.rewriteLocationFunc(target, matchedPath, inReq) //nolint:bodyclose + resp := &http.Response{Header: http.Header{}} + if location != "" { + resp.Header.Set("Location", location) + } + err := modifyResp(resp) + return resp, err + } + + t.Run("rewrites Location pointing to backend", func(t *testing.T) { + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/page"), //nolint:bodyclose + "http://backend.internal:8080/login") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/login", resp.Header.Get("Location")) + }) + + t.Run("does not rewrite Location pointing to other host", func(t *testing.T) { + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose + "https://other.example.com/path") + + require.NoError(t, err) + assert.Equal(t, "https://other.example.com/path", resp.Header.Get("Location")) + }) + + t.Run("does not rewrite relative Location", func(t *testing.T) { + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose + "/dashboard") + + require.NoError(t, err) + assert.Equal(t, "/dashboard", resp.Header.Get("Location")) + }) + + t.Run("re-adds stripped path prefix", func(t *testing.T) { + resp, err := run(newProxy("https"), "/api", newReq("https://public.example.com/api/users"), //nolint:bodyclose + "http://backend.internal:8080/users") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/api/users", resp.Header.Get("Location")) + }) + + t.Run("uses resolved proto for scheme", func(t *testing.T) { + resp, err := run(newProxy("auto"), "", newReq("http://public.example.com/"), //nolint:bodyclose + "http://backend.internal:8080/path") + + require.NoError(t, err) + assert.Equal(t, "http://public.example.com/path", resp.Header.Get("Location")) + }) + + t.Run("no-op when Location header is empty", func(t *testing.T) { + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), "") //nolint:bodyclose + + require.NoError(t, err) + assert.Empty(t, resp.Header.Get("Location")) + }) + + t.Run("does not prepend root path prefix", func(t *testing.T) { + resp, err := run(newProxy("https"), "/", newReq("https://public.example.com/login"), //nolint:bodyclose + "http://backend.internal:8080/login") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/login", resp.Header.Get("Location")) + }) + + // --- Edge cases: query parameters and fragments --- + + t.Run("preserves query parameters", func(t *testing.T) { + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose + "http://backend.internal:8080/login?redirect=%2Fdashboard&lang=en") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/login?redirect=%2Fdashboard&lang=en", resp.Header.Get("Location")) + }) + + t.Run("preserves fragment", func(t *testing.T) { + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose + "http://backend.internal:8080/docs#section-2") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/docs#section-2", resp.Header.Get("Location")) + }) + + t.Run("preserves query parameters and fragment together", func(t *testing.T) { + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose + "http://backend.internal:8080/search?q=test&page=1#results") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/search?q=test&page=1#results", resp.Header.Get("Location")) + }) + + t.Run("preserves query parameters with path prefix re-added", func(t *testing.T) { + resp, err := run(newProxy("https"), "/api", newReq("https://public.example.com/api/search"), //nolint:bodyclose + "http://backend.internal:8080/search?q=hello") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/api/search?q=hello", resp.Header.Get("Location")) + }) + + // --- Edge cases: slash handling --- + + t.Run("no double slash when matchedPath has trailing slash", func(t *testing.T) { + resp, err := run(newProxy("https"), "/api/", newReq("https://public.example.com/api/users"), //nolint:bodyclose + "http://backend.internal:8080/users") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/api/users", resp.Header.Get("Location")) + }) + + t.Run("backend redirect to root with path prefix", func(t *testing.T) { + resp, err := run(newProxy("https"), "/app", newReq("https://public.example.com/app/"), //nolint:bodyclose + "http://backend.internal:8080/") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/app/", resp.Header.Get("Location")) + }) + + t.Run("backend redirect to root with trailing-slash path prefix", func(t *testing.T) { + resp, err := run(newProxy("https"), "/app/", newReq("https://public.example.com/app/"), //nolint:bodyclose + "http://backend.internal:8080/") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/app/", resp.Header.Get("Location")) + }) + + t.Run("preserves trailing slash on redirect path", func(t *testing.T) { + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose + "http://backend.internal:8080/path/") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/path/", resp.Header.Get("Location")) + }) + + t.Run("backend redirect to bare root", func(t *testing.T) { + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/page"), //nolint:bodyclose + "http://backend.internal:8080/") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/", resp.Header.Get("Location")) + }) + + // --- Edge cases: host/port matching --- + + t.Run("does not rewrite when backend host matches but port differs", func(t *testing.T) { + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose + "http://backend.internal:9090/other") + + require.NoError(t, err) + assert.Equal(t, "http://backend.internal:9090/other", resp.Header.Get("Location"), + "Different port means different host authority, must not rewrite") + }) + + t.Run("rewrites when redirect omits default port matching target", func(t *testing.T) { + // Target is backend.internal:8080, redirect is to backend.internal (no port). + // These are different authorities, so should NOT rewrite. + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose + "http://backend.internal/path") + + require.NoError(t, err) + assert.Equal(t, "http://backend.internal/path", resp.Header.Get("Location"), + "backend.internal != backend.internal:8080, must not rewrite") + }) + + t.Run("rewrites when target has :443 but redirect omits it for https", func(t *testing.T) { + // Target: heise.de:443, redirect: https://heise.de/path (no :443 because it's default) + // Per RFC 3986, these are the same authority. + target443, _ := url.Parse("https://heise.de:443") + p := newProxy("https") + modifyResp := p.rewriteLocationFunc(target443, "", newReq("https://public.example.com/")) //nolint:bodyclose + resp := &http.Response{Header: http.Header{}} + resp.Header.Set("Location", "https://heise.de/path") + + err := modifyResp(resp) + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/path", resp.Header.Get("Location"), + "heise.de:443 and heise.de are the same for https") + }) + + t.Run("rewrites when target has :80 but redirect omits it for http", func(t *testing.T) { + target80, _ := url.Parse("http://backend.local:80") + p := newProxy("http") + modifyResp := p.rewriteLocationFunc(target80, "", newReq("http://public.example.com/")) //nolint:bodyclose + resp := &http.Response{Header: http.Header{}} + resp.Header.Set("Location", "http://backend.local/path") + + err := modifyResp(resp) + + require.NoError(t, err) + assert.Equal(t, "http://public.example.com/path", resp.Header.Get("Location"), + "backend.local:80 and backend.local are the same for http") + }) + + t.Run("rewrites when redirect has :443 but target omits it", func(t *testing.T) { + targetNoPort, _ := url.Parse("https://heise.de") + p := newProxy("https") + modifyResp := p.rewriteLocationFunc(targetNoPort, "", newReq("https://public.example.com/")) //nolint:bodyclose + resp := &http.Response{Header: http.Header{}} + resp.Header.Set("Location", "https://heise.de:443/path") + + err := modifyResp(resp) + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/path", resp.Header.Get("Location"), + "heise.de and heise.de:443 are the same for https") + }) + + t.Run("does not conflate non-default ports", func(t *testing.T) { + target8443, _ := url.Parse("https://backend.internal:8443") + p := newProxy("https") + modifyResp := p.rewriteLocationFunc(target8443, "", newReq("https://public.example.com/")) //nolint:bodyclose + resp := &http.Response{Header: http.Header{}} + resp.Header.Set("Location", "https://backend.internal/path") + + err := modifyResp(resp) + + require.NoError(t, err) + assert.Equal(t, "https://backend.internal/path", resp.Header.Get("Location"), + "backend.internal:8443 != backend.internal (port 443), must not rewrite") + }) + + // --- Edge cases: encoded paths --- + + t.Run("preserves percent-encoded path segments", func(t *testing.T) { + resp, err := run(newProxy("https"), "", newReq("https://public.example.com/"), //nolint:bodyclose + "http://backend.internal:8080/path%20with%20spaces/file%2Fname") + + require.NoError(t, err) + loc := resp.Header.Get("Location") + assert.Contains(t, loc, "public.example.com") + parsed, err := url.Parse(loc) + require.NoError(t, err) + assert.Equal(t, "/path with spaces/file/name", parsed.Path) + }) + + t.Run("preserves encoded query parameters with path prefix", func(t *testing.T) { + resp, err := run(newProxy("https"), "/v1", newReq("https://public.example.com/v1/"), //nolint:bodyclose + "http://backend.internal:8080/redirect?url=http%3A%2F%2Fexample.com") + + require.NoError(t, err) + assert.Equal(t, "https://public.example.com/v1/redirect?url=http%3A%2F%2Fexample.com", resp.Header.Get("Location")) + }) +} + +// newProxyRequest creates an httputil.ProxyRequest suitable for testing +// the Rewrite function. It simulates what httputil.ReverseProxy does internally: +// Out is a shallow clone of In with headers copied. +func newProxyRequest(t *testing.T, rawURL, remoteAddr string) *httputil.ProxyRequest { + t.Helper() + + parsed, err := url.Parse(rawURL) + require.NoError(t, err) + + in := httptest.NewRequest(http.MethodGet, rawURL, nil) + in.RemoteAddr = remoteAddr + in.Host = parsed.Host + + out := in.Clone(in.Context()) + out.Header = in.Header.Clone() + + return &httputil.ProxyRequest{In: in, Out: out} +} + +func TestClassifyProxyError(t *testing.T) { + tests := []struct { + name string + err error + wantTitle string + wantCode int + wantStatus web.ErrorStatus + }{ + { + name: "context deadline exceeded", + err: context.DeadlineExceeded, + wantTitle: "Request Timeout", + wantCode: http.StatusGatewayTimeout, + wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, + }, + { + name: "wrapped deadline exceeded", + err: fmt.Errorf("dial: %w", context.DeadlineExceeded), + wantTitle: "Request Timeout", + wantCode: http.StatusGatewayTimeout, + wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, + }, + { + name: "context canceled", + err: context.Canceled, + wantTitle: "Request Canceled", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, + }, + { + name: "no account ID", + err: roundtrip.ErrNoAccountID, + wantTitle: "Configuration Error", + wantCode: http.StatusInternalServerError, + wantStatus: web.ErrorStatus{Proxy: false, Destination: false}, + }, + { + name: "no peer connection", + err: fmt.Errorf("%w for account: abc", roundtrip.ErrNoPeerConnection), + wantTitle: "Proxy Not Connected", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: false, Destination: false}, + }, + { + name: "client not started", + err: fmt.Errorf("%w: %w", roundtrip.ErrClientStartFailed, errors.New("engine init failed")), + wantTitle: "Proxy Not Connected", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: false, Destination: false}, + }, + { + name: "syscall ECONNREFUSED via os.SyscallError", + err: &net.OpError{ + Op: "dial", + Net: "tcp", + Err: &os.SyscallError{Syscall: "connect", Err: syscall.ECONNREFUSED}, + }, + wantTitle: "Service Unavailable", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, + }, + { + name: "gvisor connection was refused", + err: &net.OpError{ + Op: "connect", + Net: "tcp", + Err: errors.New("connection was refused"), + }, + wantTitle: "Service Unavailable", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, + }, + { + name: "syscall EHOSTUNREACH via os.SyscallError", + err: &net.OpError{ + Op: "dial", + Net: "tcp", + Err: &os.SyscallError{Syscall: "connect", Err: syscall.EHOSTUNREACH}, + }, + wantTitle: "Peer Not Connected", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, + }, + { + name: "syscall ENETUNREACH via os.SyscallError", + err: &net.OpError{ + Op: "dial", + Net: "tcp", + Err: &os.SyscallError{Syscall: "connect", Err: syscall.ENETUNREACH}, + }, + wantTitle: "Peer Not Connected", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, + }, + { + name: "gvisor host is unreachable", + err: &net.OpError{ + Op: "connect", + Net: "tcp", + Err: errors.New("host is unreachable"), + }, + wantTitle: "Peer Not Connected", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, + }, + { + name: "gvisor network is unreachable", + err: &net.OpError{ + Op: "connect", + Net: "tcp", + Err: errors.New("network is unreachable"), + }, + wantTitle: "Peer Not Connected", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, + }, + { + name: "standard no route to host", + err: &net.OpError{ + Op: "dial", + Net: "tcp", + Err: &os.SyscallError{Syscall: "connect", Err: syscall.EHOSTUNREACH}, + }, + wantTitle: "Peer Not Connected", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, + }, + { + name: "unknown error falls to default", + err: errors.New("something unexpected"), + wantTitle: "Connection Error", + wantCode: http.StatusBadGateway, + wantStatus: web.ErrorStatus{Proxy: true, Destination: false}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + title, _, code, status := classifyProxyError(tt.err) + assert.Equal(t, tt.wantTitle, title, "title") + assert.Equal(t, tt.wantCode, code, "status code") + assert.Equal(t, tt.wantStatus, status, "component status") + }) + } +} + +func TestStampNetBirdIdentity_NoCapturedData_StripsOnly(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999") + pr.In.Header.Set(headerNetBirdUser, "spoofed@evil.io") + pr.In.Header.Set(headerNetBirdGroups, "admin") + pr.Out.Header = pr.In.Header.Clone() + + rewrite(pr) + + assert.Empty(t, pr.Out.Header.Get(headerNetBirdUser), + "client-supplied X-NetBird-User must be stripped when no captured identity is present") + assert.Empty(t, pr.Out.Header.Get(headerNetBirdGroups), + "client-supplied X-NetBird-Groups must be stripped when no captured identity is present") +} + +func TestStampNetBirdIdentity_StampsFromCapturedData(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999") + pr.In.Header.Set(headerNetBirdUser, "spoofed@evil.io") + pr.Out.Header = pr.In.Header.Clone() + + cd := NewCapturedData("req-1") + cd.SetUserEmail("alice@netbird.io") + cd.SetUserGroups([]string{"grp-eng", "grp-ops"}) + cd.SetUserGroupNames([]string{"engineering", "operations"}) + + pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd)) + + rewrite(pr) + + assert.Equal(t, "alice@netbird.io", pr.Out.Header.Get(headerNetBirdUser), + "captured email must overwrite any spoofed value") + assert.Equal(t, "engineering,operations", pr.Out.Header.Get(headerNetBirdGroups), + "group display names must be CSV-joined in positional order") +} + +// TestStampNetBirdIdentity_GroupsOnlyWhenEmailEmpty covers the +// tunnel-peer-without-user case (machine agents, unattached proxy peers). +// The proxy must still stamp the peer's groups so downstream services can +// authorise, but X-NetBird-User stays unset — only its inbound stripping +// must happen. +func TestStampNetBirdIdentity_GroupsOnlyWhenEmailEmpty(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999") + pr.In.Header.Set(headerNetBirdUser, "spoofed@evil.io") + pr.Out.Header = pr.In.Header.Clone() + + cd := NewCapturedData("req-1") + cd.SetUserGroups([]string{"grp-machines"}) + cd.SetUserGroupNames([]string{"machines"}) + + pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd)) + + rewrite(pr) + + assert.Empty(t, pr.Out.Header.Get(headerNetBirdUser), + "X-NetBird-User must remain unset when CapturedData carries no email") + assert.Equal(t, "machines", pr.Out.Header.Get(headerNetBirdGroups), + "groups must still be stamped for peers without a user identity") +} + +// TestStampNetBirdIdentity_EmailOnlyWhenGroupsEmpty covers the symmetric +// case: identity-resolved user without resolved group memberships. +func TestStampNetBirdIdentity_EmailOnlyWhenGroupsEmpty(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999") + pr.In.Header.Set(headerNetBirdGroups, "spoofed-admin") + pr.Out.Header = pr.In.Header.Clone() + + cd := NewCapturedData("req-1") + cd.SetUserEmail("carol@netbird.io") + + pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd)) + + rewrite(pr) + + assert.Equal(t, "carol@netbird.io", pr.Out.Header.Get(headerNetBirdUser), + "email must be stamped even when no groups are captured") + assert.Empty(t, pr.Out.Header.Get(headerNetBirdGroups), + "X-NetBird-Groups must remain unset when CapturedData carries no groups") +} + +func TestStampNetBirdIdentity_FallsBackToGroupIDsWhenNameMissing(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999") + + cd := NewCapturedData("req-1") + cd.SetUserEmail("bob@netbird.io") + cd.SetUserGroups([]string{"grp-a", "grp-b", "grp-c"}) + // "grp-b" gets an explicit empty-string display name (not just a + // shorter slice). Both gap shapes must fall back to the id. + cd.SetUserGroupNames([]string{"alpha", "", ""}) + + pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd)) + + rewrite(pr) + + assert.Equal(t, "alpha,grp-b,grp-c", pr.Out.Header.Get(headerNetBirdGroups), + "empty-string and out-of-range name slots must both fall back to the group id") +} + +// TestStampNetBirdIdentity_DropsLabelsWithComma covers the +// comma-separator constraint: a group display name that itself contains +// a comma is dropped from the header (rather than corrupting the list), +// and the remaining labels are stamped. +func TestStampNetBirdIdentity_DropsLabelsWithComma(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999") + + cd := NewCapturedData("req-1") + cd.SetUserEmail("alice@netbird.io") + cd.SetUserGroups([]string{"grp-a", "grp-b", "grp-c"}) + cd.SetUserGroupNames([]string{"engineering", "EU, EMEA", "operations"}) + + pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd)) + + rewrite(pr) + + assert.Equal(t, "engineering,operations", pr.Out.Header.Get(headerNetBirdGroups), + "group label with embedded comma must be dropped, remaining labels stamped") +} + +// TestStampNetBirdIdentity_RejectsControlCharsInEmail covers the +// header-injection defence: an email value containing CR/LF/control +// chars is omitted entirely (not partially stamped) so the upstream +// request stays well-formed and no header injection is possible. +func TestStampNetBirdIdentity_RejectsControlCharsInEmail(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999") + pr.In.Header.Set(headerNetBirdUser, "spoofed@evil.io") + pr.Out.Header = pr.In.Header.Clone() + + cd := NewCapturedData("req-1") + cd.SetUserEmail("alice@netbird.io\r\nX-Admin: yes") + cd.SetUserGroups([]string{"grp-a"}) + cd.SetUserGroupNames([]string{"engineering"}) + + pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd)) + + rewrite(pr) + + assert.Empty(t, pr.Out.Header.Get(headerNetBirdUser), + "email with CR/LF must be dropped, not partially stamped") + assert.Equal(t, "engineering", pr.Out.Header.Get(headerNetBirdGroups), + "groups remain stampable even when email is invalid") +} + +// TestStampNetBirdIdentity_RejectsControlCharsInGroup covers the +// per-label defence: a group name with a control char is silently +// dropped, the rest are stamped. +func TestStampNetBirdIdentity_RejectsControlCharsInGroup(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999") + + cd := NewCapturedData("req-1") + cd.SetUserEmail("alice@netbird.io") + cd.SetUserGroups([]string{"grp-a", "grp-b"}) + cd.SetUserGroupNames([]string{"engineering\r\nsneaky", "operations"}) + + pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd)) + + rewrite(pr) + + assert.Equal(t, "operations", pr.Out.Header.Get(headerNetBirdGroups), + "group label with control char must be dropped, valid ones kept") +} + +// TestStampNetBirdIdentity_OmitsGroupsHeaderWhenAllInvalid covers the +// edge case where every group label is rejected: the header must not be +// set at all (rather than set to an empty string). +func TestStampNetBirdIdentity_OmitsGroupsHeaderWhenAllInvalid(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999") + pr.In.Header.Set(headerNetBirdGroups, "spoofed-admin") + pr.Out.Header = pr.In.Header.Clone() + + cd := NewCapturedData("req-1") + cd.SetUserEmail("alice@netbird.io") + cd.SetUserGroups([]string{"grp-a", "grp-b"}) + cd.SetUserGroupNames([]string{"with,comma", "with\nbreak"}) + + pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd)) + + rewrite(pr) + + _, present := pr.Out.Header[http.CanonicalHeaderKey(headerNetBirdGroups)] + assert.False(t, present, + "X-NetBird-Groups must not be set when every group label is rejected") +} + +// nopOKTransport returns 200 for every request without dialing — used +// by the self-target-loop tests so the non-loop cases don't pay a real +// TCP-dial timeout. +type nopOKTransport struct{} + +func (nopOKTransport) RoundTrip(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Header: http.Header{}}, nil +} + +// TestServeHTTP_SelfTargetLoopReturns421 covers the loop guard for +// private services: when a peer dials a service whose only target is +// the peer itself, the proxy must refuse with 421 (Misdirected +// Request) rather than round-tripping the request back over WG to +// the same peer. +func TestServeHTTP_SelfTargetLoopReturns421(t *testing.T) { + rp := NewReverseProxy(nopOKTransport{}, "auto", nil, nil) + rp.AddMapping(Mapping{ + ID: "svc-1", + AccountID: "acct-1", + Host: "private.svc", + Paths: map[string]*PathTarget{ + "/": { + URL: &url.URL{Scheme: "http", Host: "100.64.0.5:8080"}, + }, + }, + }) + + req := httptest.NewRequest(http.MethodGet, "http://private.svc/", nil) + req.Host = "private.svc" + req.RemoteAddr = "100.64.0.5:55555" + req = req.WithContext(types.WithOverlayOrigin(req.Context())) + rec := httptest.NewRecorder() + + rp.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusMisdirectedRequest, rec.Code, + "a peer dialing a service whose target is itself must get 421") +} + +// TestServeHTTP_SelfTargetLoop_NonOverlayRequestPassesThrough verifies +// the guard is scoped to overlay-origin requests. A public-listener +// request that happens to share a source IP with the target host must +// not be misinterpreted as a loop — the gating relies on the inbound +// marker being attached only by the per-account overlay listener. +func TestServeHTTP_SelfTargetLoop_NonOverlayRequestPassesThrough(t *testing.T) { + rp := NewReverseProxy(nopOKTransport{}, "auto", nil, nil) + rp.AddMapping(Mapping{ + ID: "svc-1", + AccountID: "acct-1", + Host: "public.svc", + Paths: map[string]*PathTarget{ + "/": { + URL: &url.URL{Scheme: "http", Host: "100.64.0.5:8080"}, + }, + }, + }) + + req := httptest.NewRequest(http.MethodGet, "http://public.svc/", nil) + req.Host = "public.svc" + req.RemoteAddr = "100.64.0.5:55555" + // No WithOverlayOrigin → the guard must not fire. + rec := httptest.NewRecorder() + + rp.ServeHTTP(rec, req) + + assert.NotEqual(t, http.StatusMisdirectedRequest, rec.Code, + "a non-overlay request with a colliding source IP must not be flagged as a loop") +} + +// TestServeHTTP_SelfTargetLoop_OverlayDifferentIPPassesThrough confirms +// that overlay-origin requests with a source IP that does *not* match +// the target host are forwarded normally. +func TestServeHTTP_SelfTargetLoop_OverlayDifferentIPPassesThrough(t *testing.T) { + rp := NewReverseProxy(nopOKTransport{}, "auto", nil, nil) + rp.AddMapping(Mapping{ + ID: "svc-1", + AccountID: "acct-1", + Host: "private.svc", + Paths: map[string]*PathTarget{ + "/": { + URL: &url.URL{Scheme: "http", Host: "100.64.0.5:8080"}, + }, + }, + }) + + req := httptest.NewRequest(http.MethodGet, "http://private.svc/", nil) + req.Host = "private.svc" + req.RemoteAddr = "100.64.0.99:55555" // different from the target + req = req.WithContext(types.WithOverlayOrigin(req.Context())) + rec := httptest.NewRecorder() + + rp.ServeHTTP(rec, req) + + assert.NotEqual(t, http.StatusMisdirectedRequest, rec.Code, + "overlay request with a non-matching source IP must not be flagged as a loop") +} + +// TestStampNetBirdIdentity_CapturedDataPresentButEmpty covers requests +// that carry CapturedData with no identity fields populated (e.g. the +// auth middleware ran but the request didn't authenticate). Both +// headers must be cleared and neither stamped. +func TestStampNetBirdIdentity_CapturedDataPresentButEmpty(t *testing.T) { + target, _ := url.Parse("http://backend.internal:8080") + p := &ReverseProxy{forwardedProto: "auto"} + rewrite := p.rewriteFunc(target, "", false, PathRewriteDefault, nil, nil) + + pr := newProxyRequest(t, "http://example.com/", "203.0.113.50:9999") + pr.In.Header.Set(headerNetBirdUser, "spoofed@evil.io") + pr.In.Header.Set(headerNetBirdGroups, "spoofed-admin") + pr.Out.Header = pr.In.Header.Clone() + + cd := NewCapturedData("req-1") + pr.In = pr.In.WithContext(WithCapturedData(pr.In.Context(), cd)) + + rewrite(pr) + + assert.Empty(t, pr.Out.Header.Get(headerNetBirdUser), + "X-NetBird-User must be stripped when CapturedData has no email") + assert.Empty(t, pr.Out.Header.Get(headerNetBirdGroups), + "X-NetBird-Groups must be stripped when CapturedData has no groups") +} + +// TestBuildRequestInput_PropagatesIdentityAndGroups locks the final wiring link +// between auth and the middleware chain: CapturedData identity (user, groups, +// auth method, client IP) and the target's AgentNetwork flag must land on the +// middleware Input the chain runs against. If UserGroups stops flowing here, +// llm_router denies every request with no_authorised_provider. +func TestBuildRequestInput_PropagatesIdentityAndGroups(t *testing.T) { + cd := NewCapturedData("req-123") + cd.SetUserID("user-1") + cd.SetUserEmail("user@example.com") + cd.SetUserGroups([]string{"grp-admins", "grp-users"}) + cd.SetUserGroupNames([]string{"Admins", "Users"}) + cd.SetAuthMethod("oidc") + cd.SetClientIP(netip.MustParseAddr("100.90.1.14")) + + r := httptest.NewRequest(http.MethodPost, "http://agent.example.com/v1/chat/completions", nil) + r.Header.Set("Content-Type", "application/json") + + result := targetResult{ + target: &PathTarget{AgentNetwork: true}, + matchedPath: "/", + serviceID: types.ServiceID("svc-1"), + accountID: types.AccountID("acct-1"), + } + + body := []byte(`{"model":"gpt-5.4"}`) + in := buildRequestInput(r, result, cd, body, false, int64(len(body))) + + require.NotNil(t, in, "buildRequestInput must return an envelope") + assert.Equal(t, middleware.SlotOnRequest, in.Slot, "request input runs in the on-request slot") + assert.Equal(t, "svc-1", in.ServiceID, "service id must propagate") + assert.Equal(t, "acct-1", in.AccountID, "account id must propagate") + assert.Equal(t, "user-1", in.UserID, "user id must propagate") + assert.Equal(t, "user@example.com", in.UserEmail, "user email must propagate") + assert.Equal(t, []string{"grp-admins", "grp-users"}, in.UserGroups, + "CapturedData groups MUST reach the middleware Input — llm_router authorises against this") + assert.Equal(t, []string{"Admins", "Users"}, in.UserGroupNames, "group names must propagate") + assert.Equal(t, "oidc", in.AuthMethod, "auth method must propagate") + assert.Equal(t, "100.90.1.14", in.SourceIP, "client IP must propagate") + assert.True(t, in.AgentNetwork, "agent-network target flag must reach the Input") + assert.Equal(t, body, in.Body, "captured body must reach the Input") +} diff --git a/proxy/internal/proxy/servicemapping.go b/proxy/internal/proxy/servicemapping.go new file mode 100644 index 000000000..64fccc42a --- /dev/null +++ b/proxy/internal/proxy/servicemapping.go @@ -0,0 +1,143 @@ +package proxy + +import ( + "net" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/bodytap" + "github.com/netbirdio/netbird/proxy/internal/types" +) + +// PathRewriteMode controls how the request path is rewritten before forwarding. +type PathRewriteMode int + +const ( + // PathRewriteDefault strips the matched prefix and joins with the target path. + PathRewriteDefault PathRewriteMode = iota + // PathRewritePreserve keeps the full original request path as-is. + PathRewritePreserve +) + +// PathTarget holds a backend URL and per-target behavioral options. +type PathTarget struct { + URL *url.URL + SkipTLSVerify bool + RequestTimeout time.Duration + PathRewrite PathRewriteMode + CustomHeaders map[string]string + // DirectUpstream selects the stdlib HTTP transport (host network stack) + // over the embedded NetBird WireGuard client when forwarding requests + // to this target. Default false → embedded client (existing behaviour). + DirectUpstream bool + // Middlewares is the validated per-target middleware chain. Nil or empty + // for non-agent-network targets, keeping them on the no-middleware fast path. + Middlewares []middleware.Spec + // CaptureConfig holds the per-target body-capture limits used by the + // middleware chain. Nil for targets without body-inspecting middlewares. + CaptureConfig *bodytap.Config + // AgentNetwork marks this target as a synthesised agent-network target so + // the proxy can tag access-log entries and gate agent-network behaviour. + AgentNetwork bool + // DisableAccessLog suppresses the per-request access-log emission for this + // target. Defaults false so non-agent-network targets continue to log + // unchanged. The agent-network synthesizer sets this true only when the + // account's EnableLogCollection toggle is off. + DisableAccessLog bool +} + +// Mapping describes how a domain is routed by the HTTP reverse proxy. +type Mapping struct { + ID types.ServiceID + AccountID types.AccountID + Host string + Paths map[string]*PathTarget + PassHostHeader bool + RewriteRedirects bool + // StripAuthHeaders are header names used for header-based auth. + // These headers are stripped from requests before forwarding. + StripAuthHeaders []string + // sortedPaths caches the paths sorted by length (longest first). + sortedPaths []string +} + +type targetResult struct { + target *PathTarget + matchedPath string + serviceID types.ServiceID + accountID types.AccountID + passHostHeader bool + rewriteRedirects bool + stripAuthHeaders []string +} + +func (p *ReverseProxy) findTargetForRequest(req *http.Request) (targetResult, bool) { + p.mappingsMux.RLock() + defer p.mappingsMux.RUnlock() + + // Strip port from host if present (e.g., "external.test:8443" -> "external.test") + host := req.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + + m, exists := p.mappings[host] + if !exists { + p.logger.Debugf("no mapping found for host: %s", host) + return targetResult{}, false + } + + for _, path := range m.sortedPaths { + if strings.HasPrefix(req.URL.Path, path) { + pt := m.Paths[path] + if pt == nil || pt.URL == nil { + p.logger.Warnf("invalid mapping for host: %s, path: %s (nil target)", host, path) + continue + } + p.logger.Debugf("matched host: %s, path: %s -> %s", host, path, pt.URL) + return targetResult{ + target: pt, + matchedPath: path, + serviceID: m.ID, + accountID: m.AccountID, + passHostHeader: m.PassHostHeader, + rewriteRedirects: m.RewriteRedirects, + stripAuthHeaders: m.StripAuthHeaders, + }, true + } + } + p.logger.Debugf("no path match for host: %s, path: %s", host, req.URL.Path) + return targetResult{}, false +} + +// AddMapping registers a host-to-backend mapping for the reverse proxy. +func (p *ReverseProxy) AddMapping(m Mapping) { + // Sort paths longest-first to match the most specific route first. + paths := make([]string, 0, len(m.Paths)) + for path := range m.Paths { + paths = append(paths, path) + } + sort.Slice(paths, func(i, j int) bool { + return len(paths[i]) > len(paths[j]) + }) + m.sortedPaths = paths + + p.mappingsMux.Lock() + defer p.mappingsMux.Unlock() + p.mappings[m.Host] = m +} + +// RemoveMapping removes the mapping for the given host and reports whether it existed. +func (p *ReverseProxy) RemoveMapping(m Mapping) bool { + p.mappingsMux.Lock() + defer p.mappingsMux.Unlock() + if _, ok := p.mappings[m.Host]; !ok { + return false + } + delete(p.mappings, m.Host) + return true +} diff --git a/proxy/internal/proxy/strip_prefix_test.go b/proxy/internal/proxy/strip_prefix_test.go new file mode 100644 index 000000000..4ff364f6a --- /dev/null +++ b/proxy/internal/proxy/strip_prefix_test.go @@ -0,0 +1,30 @@ +package proxy + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStripUpstreamPathPrefix(t *testing.T) { + cases := []struct { + name string + path string + prefix string + want string + }{ + {"strips matching namespace prefix", "/bedrock/model/x/invoke", "/bedrock", "/model/x/invoke"}, + {"no-op when prefix absent", "/model/x/invoke", "/bedrock", "/model/x/invoke"}, + {"no-op on empty prefix", "/bedrock/model/x/invoke", "", "/bedrock/model/x/invoke"}, + {"no-op on non-segment match", "/bedrockfoo/model/x", "/bedrock", "/bedrockfoo/model/x"}, + {"bare prefix collapses to root", "/bedrock", "/bedrock", "/"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest("POST", tc.path, nil) + stripUpstreamPathPrefix(r, tc.prefix) + assert.Equal(t, tc.want, r.URL.Path, "stripped path for %q", tc.path) + }) + } +} diff --git a/proxy/internal/responsewriter/responsewriter.go b/proxy/internal/responsewriter/responsewriter.go new file mode 100644 index 000000000..b8fc95f2d --- /dev/null +++ b/proxy/internal/responsewriter/responsewriter.go @@ -0,0 +1,53 @@ +package responsewriter + +import ( + "bufio" + "net" + "net/http" +) + +// PassthroughWriter wraps an http.ResponseWriter and preserves optional +// interfaces like Hijacker, Flusher, and Pusher by delegating to the underlying +// ResponseWriter if it supports them. +// +// This is the standard pattern for Go middleware that needs to wrap ResponseWriter +// while maintaining support for protocol upgrades (WebSocket), streaming (Flusher), +// and HTTP/2 server push. +type PassthroughWriter struct { + http.ResponseWriter +} + +// New creates a new wrapper around the given ResponseWriter. +func New(w http.ResponseWriter) *PassthroughWriter { + return &PassthroughWriter{ResponseWriter: w} +} + +// Hijack implements http.Hijacker interface if the underlying ResponseWriter supports it. +// This is required for WebSocket connections and other protocol upgrades. +func (w *PassthroughWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if hijacker, ok := w.ResponseWriter.(http.Hijacker); ok { + return hijacker.Hijack() + } + return nil, nil, http.ErrNotSupported +} + +// Flush implements http.Flusher interface if the underlying ResponseWriter supports it. +func (w *PassthroughWriter) Flush() { + if flusher, ok := w.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } +} + +// Push implements http.Pusher interface if the underlying ResponseWriter supports it. +func (w *PassthroughWriter) Push(target string, opts *http.PushOptions) error { + if pusher, ok := w.ResponseWriter.(http.Pusher); ok { + return pusher.Push(target, opts) + } + return http.ErrNotSupported +} + +// Unwrap returns the underlying ResponseWriter. +// This is required for http.ResponseController (Go 1.20+) to work correctly. +func (w *PassthroughWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} diff --git a/proxy/internal/restrict/restrict.go b/proxy/internal/restrict/restrict.go new file mode 100644 index 000000000..67756b88b --- /dev/null +++ b/proxy/internal/restrict/restrict.go @@ -0,0 +1,327 @@ +// Package restrict provides connection-level access control based on +// IP CIDR ranges and geolocation (country codes). +package restrict + +import ( + "net/netip" + "slices" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/internal/geolocation" +) + +// defaultLogger is used when no logger is provided to ParseFilter. +var defaultLogger = log.NewEntry(log.StandardLogger()) + +// GeoResolver resolves an IP address to geographic information. +type GeoResolver interface { + LookupAddr(addr netip.Addr) geolocation.Result + Available() bool +} + +// DecisionType is the type of CrowdSec remediation action. +type DecisionType string + +const ( + DecisionBan DecisionType = "ban" + DecisionCaptcha DecisionType = "captcha" + DecisionThrottle DecisionType = "throttle" +) + +// CrowdSecDecision holds the type of a CrowdSec decision. +type CrowdSecDecision struct { + Type DecisionType +} + +// CrowdSecChecker queries CrowdSec decisions for an IP address. +type CrowdSecChecker interface { + CheckIP(addr netip.Addr) *CrowdSecDecision + Ready() bool +} + +// CrowdSecMode is the per-service enforcement mode. +type CrowdSecMode string + +const ( + CrowdSecOff CrowdSecMode = "" + CrowdSecEnforce CrowdSecMode = "enforce" + CrowdSecObserve CrowdSecMode = "observe" +) + +// Filter evaluates IP restrictions. CIDR checks are performed first +// (cheap), followed by country lookups (more expensive) only when needed. +type Filter struct { + AllowedCIDRs []netip.Prefix + BlockedCIDRs []netip.Prefix + AllowedCountries []string + BlockedCountries []string + CrowdSec CrowdSecChecker + CrowdSecMode CrowdSecMode +} + +// FilterConfig holds the raw configuration for building a Filter. +type FilterConfig struct { + AllowedCIDRs []string + BlockedCIDRs []string + AllowedCountries []string + BlockedCountries []string + CrowdSec CrowdSecChecker + CrowdSecMode CrowdSecMode + Logger *log.Entry +} + +// ParseFilter builds a Filter from the config. Returns nil if no restrictions +// are configured. +func ParseFilter(cfg FilterConfig) *Filter { + hasCS := cfg.CrowdSecMode == CrowdSecEnforce || cfg.CrowdSecMode == CrowdSecObserve + if len(cfg.AllowedCIDRs) == 0 && len(cfg.BlockedCIDRs) == 0 && + len(cfg.AllowedCountries) == 0 && len(cfg.BlockedCountries) == 0 && !hasCS { + return nil + } + + logger := cfg.Logger + if logger == nil { + logger = defaultLogger + } + + f := &Filter{ + AllowedCountries: normalizeCountryCodes(cfg.AllowedCountries), + BlockedCountries: normalizeCountryCodes(cfg.BlockedCountries), + } + if hasCS { + f.CrowdSec = cfg.CrowdSec + f.CrowdSecMode = cfg.CrowdSecMode + } + for _, cidr := range cfg.AllowedCIDRs { + prefix, err := netip.ParsePrefix(cidr) + if err != nil { + logger.Warnf("skip invalid allowed CIDR %q: %v", cidr, err) + continue + } + f.AllowedCIDRs = append(f.AllowedCIDRs, prefix.Masked()) + } + for _, cidr := range cfg.BlockedCIDRs { + prefix, err := netip.ParsePrefix(cidr) + if err != nil { + logger.Warnf("skip invalid blocked CIDR %q: %v", cidr, err) + continue + } + f.BlockedCIDRs = append(f.BlockedCIDRs, prefix.Masked()) + } + return f +} + +func normalizeCountryCodes(codes []string) []string { + if len(codes) == 0 { + return nil + } + out := make([]string, len(codes)) + for i, c := range codes { + out[i] = strings.ToUpper(c) + } + return out +} + +// Verdict is the result of an access check. +type Verdict int + +const ( + // Allow indicates the address passed all checks. + Allow Verdict = iota + // DenyCIDR indicates the address was blocked by a CIDR rule. + DenyCIDR + // DenyCountry indicates the address was blocked by a country rule. + DenyCountry + // DenyGeoUnavailable indicates that country restrictions are configured + // but the geo lookup is unavailable. + DenyGeoUnavailable + // DenyCrowdSecBan indicates a CrowdSec "ban" decision. + DenyCrowdSecBan + // DenyCrowdSecCaptcha indicates a CrowdSec "captcha" decision. + DenyCrowdSecCaptcha + // DenyCrowdSecThrottle indicates a CrowdSec "throttle" decision. + DenyCrowdSecThrottle + // DenyCrowdSecUnavailable indicates enforce mode but the bouncer has not + // completed its initial sync. + DenyCrowdSecUnavailable +) + +// String returns the deny reason string matching the HTTP auth mechanism names. +func (v Verdict) String() string { + switch v { + case Allow: + return "allow" + case DenyCIDR: + return "ip_restricted" + case DenyCountry: + return "country_restricted" + case DenyGeoUnavailable: + return "geo_unavailable" + case DenyCrowdSecBan: + return "crowdsec_ban" + case DenyCrowdSecCaptcha: + return "crowdsec_captcha" + case DenyCrowdSecThrottle: + return "crowdsec_throttle" + case DenyCrowdSecUnavailable: + return "crowdsec_unavailable" + default: + return "unknown" + } +} + +// IsCrowdSec returns true when the verdict originates from a CrowdSec check. +func (v Verdict) IsCrowdSec() bool { + switch v { + case DenyCrowdSecBan, DenyCrowdSecCaptcha, DenyCrowdSecThrottle, DenyCrowdSecUnavailable: + return true + default: + return false + } +} + +// IsObserveOnly returns true when v is a CrowdSec verdict and the filter is in +// observe mode. Callers should log the verdict but not block the request. +func (f *Filter) IsObserveOnly(v Verdict) bool { + if f == nil { + return false + } + return v.IsCrowdSec() && f.CrowdSecMode == CrowdSecObserve +} + +// CheckCIDR runs only the CIDR allow/block evaluation. Use this when +// country and CrowdSec checks don't apply — e.g. requests arriving +// from the WireGuard overlay, whose source addresses live in the +// CGNAT range and have no meaningful geolocation or IP-reputation +// data. +func (f *Filter) CheckCIDR(addr netip.Addr) Verdict { + if f == nil { + return Allow + } + return f.checkCIDR(addr.Unmap()) +} + +// Check evaluates whether addr is permitted. CIDR rules are evaluated +// first because they are O(n) prefix comparisons. Country rules run +// only when CIDR checks pass and require a geo lookup. CrowdSec checks +// run last. +func (f *Filter) Check(addr netip.Addr, geo GeoResolver) Verdict { + if f == nil { + return Allow + } + + // Normalize v4-mapped-v6 (e.g. ::ffff:10.1.2.3) to plain v4 so that + // IPv4 CIDR rules match regardless of how the address was received. + addr = addr.Unmap() + + if v := f.checkCIDR(addr); v != Allow { + return v + } + if v := f.checkCountry(addr, geo); v != Allow { + return v + } + return f.checkCrowdSec(addr) +} + +func (f *Filter) checkCIDR(addr netip.Addr) Verdict { + if len(f.AllowedCIDRs) > 0 { + allowed := false + for _, prefix := range f.AllowedCIDRs { + if prefix.Contains(addr) { + allowed = true + break + } + } + if !allowed { + return DenyCIDR + } + } + + for _, prefix := range f.BlockedCIDRs { + if prefix.Contains(addr) { + return DenyCIDR + } + } + return Allow +} + +func (f *Filter) checkCountry(addr netip.Addr, geo GeoResolver) Verdict { + if len(f.AllowedCountries) == 0 && len(f.BlockedCountries) == 0 { + return Allow + } + + if geo == nil || !geo.Available() { + return DenyGeoUnavailable + } + + result := geo.LookupAddr(addr) + if result.CountryCode == "" { + // Unknown country: deny if an allowlist is active, allow otherwise. + // Blocklists are best-effort: unknown countries pass through since + // the default policy is allow. + if len(f.AllowedCountries) > 0 { + return DenyCountry + } + return Allow + } + + if len(f.AllowedCountries) > 0 { + if !slices.Contains(f.AllowedCountries, result.CountryCode) { + return DenyCountry + } + } + + if slices.Contains(f.BlockedCountries, result.CountryCode) { + return DenyCountry + } + + return Allow +} + +func (f *Filter) checkCrowdSec(addr netip.Addr) Verdict { + if f.CrowdSecMode == CrowdSecOff { + return Allow + } + + // Checker nil with enforce means CrowdSec was requested but the proxy + // has no LAPI configured. Fail-closed. + if f.CrowdSec == nil { + if f.CrowdSecMode == CrowdSecEnforce { + return DenyCrowdSecUnavailable + } + return Allow + } + + if !f.CrowdSec.Ready() { + if f.CrowdSecMode == CrowdSecEnforce { + return DenyCrowdSecUnavailable + } + return Allow + } + + d := f.CrowdSec.CheckIP(addr) + if d == nil { + return Allow + } + + switch d.Type { + case DecisionCaptcha: + return DenyCrowdSecCaptcha + case DecisionThrottle: + return DenyCrowdSecThrottle + default: + return DenyCrowdSecBan + } +} + +// HasRestrictions returns true if any restriction rules are configured. +func (f *Filter) HasRestrictions() bool { + if f == nil { + return false + } + return len(f.AllowedCIDRs) > 0 || len(f.BlockedCIDRs) > 0 || + len(f.AllowedCountries) > 0 || len(f.BlockedCountries) > 0 || + f.CrowdSecMode == CrowdSecEnforce || f.CrowdSecMode == CrowdSecObserve +} diff --git a/proxy/internal/restrict/restrict_test.go b/proxy/internal/restrict/restrict_test.go new file mode 100644 index 000000000..16aa1e139 --- /dev/null +++ b/proxy/internal/restrict/restrict_test.go @@ -0,0 +1,554 @@ +package restrict + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/proxy/internal/geolocation" +) + +type mockGeo struct { + countries map[string]string +} + +func (m *mockGeo) LookupAddr(addr netip.Addr) geolocation.Result { + return geolocation.Result{CountryCode: m.countries[addr.String()]} +} + +func (m *mockGeo) Available() bool { return true } + +func newMockGeo(entries map[string]string) *mockGeo { + return &mockGeo{countries: entries} +} + +func TestFilter_Check_NilFilter(t *testing.T) { + var f *Filter + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.2.3.4"), nil)) +} + +func TestFilter_Check_AllowedCIDR(t *testing.T) { + f := ParseFilter(FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}) + + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("10.1.2.3"), nil)) + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("192.168.1.1"), nil)) +} + +func TestFilter_Check_BlockedCIDR(t *testing.T) { + f := ParseFilter(FilterConfig{BlockedCIDRs: []string{"10.0.0.0/8"}}) + + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("10.1.2.3"), nil)) + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("192.168.1.1"), nil)) +} + +func TestFilter_Check_AllowedAndBlockedCIDR(t *testing.T) { + f := ParseFilter(FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}, BlockedCIDRs: []string{"10.1.0.0/16"}}) + + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("10.2.3.4"), nil), "allowed by allowlist, not in blocklist") + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("10.1.2.3"), nil), "allowed by allowlist but in blocklist") + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("192.168.1.1"), nil), "not in allowlist") +} + +func TestFilter_Check_AllowedCountry(t *testing.T) { + geo := newMockGeo(map[string]string{ + "1.1.1.1": "US", + "2.2.2.2": "DE", + "3.3.3.3": "CN", + }) + f := ParseFilter(FilterConfig{AllowedCountries: []string{"US", "DE"}}) + + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.1.1.1"), geo), "US in allowlist") + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("2.2.2.2"), geo), "DE in allowlist") + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("3.3.3.3"), geo), "CN not in allowlist") +} + +func TestFilter_Check_BlockedCountry(t *testing.T) { + geo := newMockGeo(map[string]string{ + "1.1.1.1": "CN", + "2.2.2.2": "RU", + "3.3.3.3": "US", + }) + f := ParseFilter(FilterConfig{BlockedCountries: []string{"CN", "RU"}}) + + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("1.1.1.1"), geo), "CN in blocklist") + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("2.2.2.2"), geo), "RU in blocklist") + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("3.3.3.3"), geo), "US not in blocklist") +} + +func TestFilter_Check_AllowedAndBlockedCountry(t *testing.T) { + geo := newMockGeo(map[string]string{ + "1.1.1.1": "US", + "2.2.2.2": "DE", + "3.3.3.3": "CN", + }) + // Allow US and DE, but block DE explicitly. + f := ParseFilter(FilterConfig{AllowedCountries: []string{"US", "DE"}, BlockedCountries: []string{"DE"}}) + + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.1.1.1"), geo), "US allowed and not blocked") + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("2.2.2.2"), geo), "DE allowed but also blocked, block wins") + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("3.3.3.3"), geo), "CN not in allowlist") +} + +func TestFilter_Check_UnknownCountryWithAllowlist(t *testing.T) { + geo := newMockGeo(map[string]string{ + "1.1.1.1": "US", + }) + f := ParseFilter(FilterConfig{AllowedCountries: []string{"US"}}) + + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.1.1.1"), geo), "known US in allowlist") + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("9.9.9.9"), geo), "unknown country denied when allowlist is active") +} + +func TestFilter_Check_UnknownCountryWithBlocklistOnly(t *testing.T) { + geo := newMockGeo(map[string]string{ + "1.1.1.1": "CN", + }) + f := ParseFilter(FilterConfig{BlockedCountries: []string{"CN"}}) + + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("1.1.1.1"), geo), "known CN in blocklist") + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("9.9.9.9"), geo), "unknown country allowed when only blocklist is active") +} + +func TestFilter_Check_CountryWithoutGeo(t *testing.T) { + f := ParseFilter(FilterConfig{AllowedCountries: []string{"US"}}) + assert.Equal(t, DenyGeoUnavailable, f.Check(netip.MustParseAddr("1.2.3.4"), nil), "nil geo with country allowlist") +} + +func TestFilter_Check_CountryBlocklistWithoutGeo(t *testing.T) { + f := ParseFilter(FilterConfig{BlockedCountries: []string{"CN"}}) + assert.Equal(t, DenyGeoUnavailable, f.Check(netip.MustParseAddr("1.2.3.4"), nil), "nil geo with country blocklist") +} + +func TestFilter_Check_GeoUnavailable(t *testing.T) { + geo := &unavailableGeo{} + + f := ParseFilter(FilterConfig{AllowedCountries: []string{"US"}}) + assert.Equal(t, DenyGeoUnavailable, f.Check(netip.MustParseAddr("1.2.3.4"), geo), "unavailable geo with country allowlist") + + f2 := ParseFilter(FilterConfig{BlockedCountries: []string{"CN"}}) + assert.Equal(t, DenyGeoUnavailable, f2.Check(netip.MustParseAddr("1.2.3.4"), geo), "unavailable geo with country blocklist") +} + +func TestFilter_Check_CIDROnlySkipsGeo(t *testing.T) { + f := ParseFilter(FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}) + + // CIDR-only filter should never touch geo, so nil geo is fine. + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("10.1.2.3"), nil)) + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("192.168.1.1"), nil)) +} + +func TestFilter_Check_CIDRAllowThenCountryBlock(t *testing.T) { + geo := newMockGeo(map[string]string{ + "10.1.2.3": "CN", + "10.2.3.4": "US", + }) + f := ParseFilter(FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}, BlockedCountries: []string{"CN"}}) + + assert.Equal(t, DenyCountry, f.Check(netip.MustParseAddr("10.1.2.3"), geo), "CIDR allowed but country blocked") + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("10.2.3.4"), geo), "CIDR allowed and country not blocked") + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("192.168.1.1"), geo), "CIDR denied before country check") +} + +func TestParseFilter_Empty(t *testing.T) { + f := ParseFilter(FilterConfig{}) + assert.Nil(t, f) +} + +func TestParseFilter_InvalidCIDR(t *testing.T) { + f := ParseFilter(FilterConfig{AllowedCIDRs: []string{"invalid", "10.0.0.0/8"}}) + + assert.NotNil(t, f) + assert.Len(t, f.AllowedCIDRs, 1, "invalid CIDR should be skipped") + assert.Equal(t, netip.MustParsePrefix("10.0.0.0/8"), f.AllowedCIDRs[0]) +} + +func TestFilter_HasRestrictions(t *testing.T) { + assert.False(t, (*Filter)(nil).HasRestrictions()) + assert.False(t, (&Filter{}).HasRestrictions()) + assert.True(t, ParseFilter(FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}).HasRestrictions()) + assert.True(t, ParseFilter(FilterConfig{AllowedCountries: []string{"US"}}).HasRestrictions()) +} + +func TestFilter_Check_IPv6CIDR(t *testing.T) { + f := ParseFilter(FilterConfig{AllowedCIDRs: []string{"2001:db8::/32"}}) + + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("2001:db8::1"), nil), "v6 addr in v6 allowlist") + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("2001:db9::1"), nil), "v6 addr not in v6 allowlist") + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("10.1.2.3"), nil), "v4 addr not in v6 allowlist") +} + +func TestFilter_Check_IPv4MappedIPv6(t *testing.T) { + f := ParseFilter(FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}) + + // A v4-mapped-v6 address like ::ffff:10.1.2.3 must match a v4 CIDR. + v4mapped := netip.MustParseAddr("::ffff:10.1.2.3") + assert.True(t, v4mapped.Is4In6(), "precondition: address is v4-in-v6") + assert.Equal(t, Allow, f.Check(v4mapped, nil), "v4-mapped-v6 must match v4 CIDR after Unmap") + + v4mappedOutside := netip.MustParseAddr("::ffff:192.168.1.1") + assert.Equal(t, DenyCIDR, f.Check(v4mappedOutside, nil), "v4-mapped-v6 outside v4 CIDR") +} + +func TestFilter_Check_MixedV4V6CIDRs(t *testing.T) { + f := ParseFilter(FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8", "2001:db8::/32"}}) + + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("10.1.2.3"), nil), "v4 in v4 CIDR") + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("2001:db8::1"), nil), "v6 in v6 CIDR") + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("::ffff:10.1.2.3"), nil), "v4-mapped matches v4 CIDR") + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("192.168.1.1"), nil), "v4 not in either CIDR") + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("fe80::1"), nil), "v6 not in either CIDR") +} + +func TestParseFilter_CanonicalizesNonMaskedCIDR(t *testing.T) { + // 1.1.1.1/24 has host bits set; ParseFilter should canonicalize to 1.1.1.0/24. + f := ParseFilter(FilterConfig{AllowedCIDRs: []string{"1.1.1.1/24"}}) + assert.Equal(t, netip.MustParsePrefix("1.1.1.0/24"), f.AllowedCIDRs[0]) + + // Verify it still matches correctly. + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.1.1.100"), nil)) + assert.Equal(t, DenyCIDR, f.Check(netip.MustParseAddr("1.1.2.1"), nil)) +} + +func TestFilter_Check_CountryCodeCaseInsensitive(t *testing.T) { + geo := newMockGeo(map[string]string{ + "1.1.1.1": "US", + "2.2.2.2": "DE", + "3.3.3.3": "CN", + }) + + tests := []struct { + name string + allowedCountries []string + blockedCountries []string + addr string + want Verdict + }{ + { + name: "lowercase allowlist matches uppercase MaxMind code", + allowedCountries: []string{"us", "de"}, + addr: "1.1.1.1", + want: Allow, + }, + { + name: "mixed-case allowlist matches", + allowedCountries: []string{"Us", "dE"}, + addr: "2.2.2.2", + want: Allow, + }, + { + name: "lowercase allowlist rejects non-matching country", + allowedCountries: []string{"us", "de"}, + addr: "3.3.3.3", + want: DenyCountry, + }, + { + name: "lowercase blocklist blocks matching country", + blockedCountries: []string{"cn"}, + addr: "3.3.3.3", + want: DenyCountry, + }, + { + name: "mixed-case blocklist blocks matching country", + blockedCountries: []string{"Cn"}, + addr: "3.3.3.3", + want: DenyCountry, + }, + { + name: "lowercase blocklist does not block non-matching country", + blockedCountries: []string{"cn"}, + addr: "1.1.1.1", + want: Allow, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + f := ParseFilter(FilterConfig{AllowedCountries: tc.allowedCountries, BlockedCountries: tc.blockedCountries}) + got := f.Check(netip.MustParseAddr(tc.addr), geo) + assert.Equal(t, tc.want, got) + }) + } +} + +// unavailableGeo simulates a GeoResolver whose database is not loaded. +type unavailableGeo struct{} + +func (u *unavailableGeo) LookupAddr(_ netip.Addr) geolocation.Result { return geolocation.Result{} } +func (u *unavailableGeo) Available() bool { return false } + +// mockCrowdSec is a test implementation of CrowdSecChecker. +type mockCrowdSec struct { + decisions map[string]*CrowdSecDecision + ready bool +} + +func (m *mockCrowdSec) CheckIP(addr netip.Addr) *CrowdSecDecision { + return m.decisions[addr.Unmap().String()] +} + +func (m *mockCrowdSec) Ready() bool { return m.ready } + +func TestFilter_CrowdSec_Enforce_Ban(t *testing.T) { + cs := &mockCrowdSec{ + decisions: map[string]*CrowdSecDecision{"1.2.3.4": {Type: DecisionBan}}, + ready: true, + } + f := ParseFilter(FilterConfig{CrowdSec: cs, CrowdSecMode: CrowdSecEnforce}) + + assert.Equal(t, DenyCrowdSecBan, f.Check(netip.MustParseAddr("1.2.3.4"), nil)) + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("5.6.7.8"), nil)) +} + +func TestFilter_CrowdSec_Enforce_Captcha(t *testing.T) { + cs := &mockCrowdSec{ + decisions: map[string]*CrowdSecDecision{"1.2.3.4": {Type: DecisionCaptcha}}, + ready: true, + } + f := ParseFilter(FilterConfig{CrowdSec: cs, CrowdSecMode: CrowdSecEnforce}) + + assert.Equal(t, DenyCrowdSecCaptcha, f.Check(netip.MustParseAddr("1.2.3.4"), nil)) +} + +func TestFilter_CrowdSec_Enforce_Throttle(t *testing.T) { + cs := &mockCrowdSec{ + decisions: map[string]*CrowdSecDecision{"1.2.3.4": {Type: DecisionThrottle}}, + ready: true, + } + f := ParseFilter(FilterConfig{CrowdSec: cs, CrowdSecMode: CrowdSecEnforce}) + + assert.Equal(t, DenyCrowdSecThrottle, f.Check(netip.MustParseAddr("1.2.3.4"), nil)) +} + +func TestFilter_CrowdSec_Observe_DoesNotBlock(t *testing.T) { + cs := &mockCrowdSec{ + decisions: map[string]*CrowdSecDecision{"1.2.3.4": {Type: DecisionBan}}, + ready: true, + } + f := ParseFilter(FilterConfig{CrowdSec: cs, CrowdSecMode: CrowdSecObserve}) + + verdict := f.Check(netip.MustParseAddr("1.2.3.4"), nil) + assert.Equal(t, DenyCrowdSecBan, verdict, "verdict should be ban") + assert.True(t, f.IsObserveOnly(verdict), "should be observe-only") +} + +func TestFilter_CrowdSec_Enforce_NotReady(t *testing.T) { + cs := &mockCrowdSec{ready: false} + f := ParseFilter(FilterConfig{CrowdSec: cs, CrowdSecMode: CrowdSecEnforce}) + + assert.Equal(t, DenyCrowdSecUnavailable, f.Check(netip.MustParseAddr("1.2.3.4"), nil)) +} + +func TestFilter_CrowdSec_Observe_NotReady_Allows(t *testing.T) { + cs := &mockCrowdSec{ready: false} + f := ParseFilter(FilterConfig{CrowdSec: cs, CrowdSecMode: CrowdSecObserve}) + + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.2.3.4"), nil)) +} + +func TestFilter_CrowdSec_Off(t *testing.T) { + cs := &mockCrowdSec{ + decisions: map[string]*CrowdSecDecision{"1.2.3.4": {Type: DecisionBan}}, + ready: true, + } + f := ParseFilter(FilterConfig{CrowdSec: cs, CrowdSecMode: CrowdSecOff}) + + // CrowdSecOff means the filter is nil (no restrictions). + assert.Nil(t, f) +} + +func TestFilter_IsObserveOnly(t *testing.T) { + f := &Filter{CrowdSecMode: CrowdSecObserve} + assert.True(t, f.IsObserveOnly(DenyCrowdSecBan)) + assert.True(t, f.IsObserveOnly(DenyCrowdSecCaptcha)) + assert.True(t, f.IsObserveOnly(DenyCrowdSecThrottle)) + assert.True(t, f.IsObserveOnly(DenyCrowdSecUnavailable)) + assert.False(t, f.IsObserveOnly(DenyCIDR)) + assert.False(t, f.IsObserveOnly(Allow)) + + f2 := &Filter{CrowdSecMode: CrowdSecEnforce} + assert.False(t, f2.IsObserveOnly(DenyCrowdSecBan)) +} + +// TestFilter_LayerInteraction exercises the evaluation order across all three +// restriction layers: CIDR -> Country -> CrowdSec. Each layer can only further +// restrict; no layer can relax a denial from an earlier layer. +// +// Layer order | Behavior +// ---------------|------------------------------------------------------- +// 1. CIDR | Allowlist narrows to specific ranges, blocklist removes +// | specific ranges. Deny here → stop, CrowdSec never runs. +// 2. Country | Allowlist/blocklist by geo. Deny here → stop. +// 3. CrowdSec | IP reputation. Can block IPs that passed layers 1-2. +// | Observe mode: verdict returned but caller doesn't block. +func TestFilter_LayerInteraction(t *testing.T) { + bannedIP := "10.1.2.3" + cleanIP := "10.2.3.4" + outsideIP := "192.168.1.1" + + cs := &mockCrowdSec{ + decisions: map[string]*CrowdSecDecision{bannedIP: {Type: DecisionBan}}, + ready: true, + } + geo := newMockGeo(map[string]string{ + bannedIP: "US", + cleanIP: "US", + outsideIP: "CN", + }) + + tests := []struct { + name string + config FilterConfig + addr string + want Verdict + }{ + // CIDR allowlist + CrowdSec enforce: CrowdSec blocks inside allowed range + { + name: "allowed CIDR + CrowdSec banned", + config: FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}, CrowdSec: cs, CrowdSecMode: CrowdSecEnforce}, + addr: bannedIP, + want: DenyCrowdSecBan, + }, + { + name: "allowed CIDR + CrowdSec clean", + config: FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}, CrowdSec: cs, CrowdSecMode: CrowdSecEnforce}, + addr: cleanIP, + want: Allow, + }, + { + name: "CIDR deny stops before CrowdSec", + config: FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}, CrowdSec: cs, CrowdSecMode: CrowdSecEnforce}, + addr: outsideIP, + want: DenyCIDR, + }, + + // CIDR blocklist + CrowdSec enforce: blocklist blocks first, CrowdSec blocks remaining + { + name: "blocked CIDR stops before CrowdSec", + config: FilterConfig{BlockedCIDRs: []string{"10.1.0.0/16"}, CrowdSec: cs, CrowdSecMode: CrowdSecEnforce}, + addr: bannedIP, + want: DenyCIDR, + }, + { + name: "not in blocklist + CrowdSec clean", + config: FilterConfig{BlockedCIDRs: []string{"10.1.0.0/16"}, CrowdSec: cs, CrowdSecMode: CrowdSecEnforce}, + addr: cleanIP, + want: Allow, + }, + + // Country allowlist + CrowdSec enforce + { + name: "allowed country + CrowdSec banned", + config: FilterConfig{AllowedCountries: []string{"US"}, CrowdSec: cs, CrowdSecMode: CrowdSecEnforce}, + addr: bannedIP, + want: DenyCrowdSecBan, + }, + { + name: "country deny stops before CrowdSec", + config: FilterConfig{AllowedCountries: []string{"US"}, CrowdSec: cs, CrowdSecMode: CrowdSecEnforce}, + addr: outsideIP, + want: DenyCountry, + }, + + // All three layers: CIDR allowlist + country blocklist + CrowdSec + { + name: "all layers: CIDR allow + country allow + CrowdSec ban", + config: FilterConfig{ + AllowedCIDRs: []string{"10.0.0.0/8"}, + BlockedCountries: []string{"CN"}, + CrowdSec: cs, + CrowdSecMode: CrowdSecEnforce, + }, + addr: bannedIP, // 10.x (CIDR ok), US (country ok), banned (CrowdSec deny) + want: DenyCrowdSecBan, + }, + { + name: "all layers: CIDR deny short-circuits everything", + config: FilterConfig{ + AllowedCIDRs: []string{"10.0.0.0/8"}, + BlockedCountries: []string{"CN"}, + CrowdSec: cs, + CrowdSecMode: CrowdSecEnforce, + }, + addr: outsideIP, // 192.x (CIDR deny) + want: DenyCIDR, + }, + + // Observe mode: verdict returned but IsObserveOnly is true + { + name: "observe mode: CrowdSec banned inside allowed CIDR", + config: FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}, CrowdSec: cs, CrowdSecMode: CrowdSecObserve}, + addr: bannedIP, + want: DenyCrowdSecBan, // verdict is ban, caller checks IsObserveOnly + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + f := ParseFilter(tc.config) + got := f.Check(netip.MustParseAddr(tc.addr), geo) + assert.Equal(t, tc.want, got) + + // Verify observe mode flag when applicable. + if tc.config.CrowdSecMode == CrowdSecObserve && got.IsCrowdSec() { + assert.True(t, f.IsObserveOnly(got), "observe mode verdict should be observe-only") + } + if tc.config.CrowdSecMode == CrowdSecEnforce && got.IsCrowdSec() { + assert.False(t, f.IsObserveOnly(got), "enforce mode verdict should not be observe-only") + } + }) + } +} + +func TestFilter_CrowdSec_Enforce_NilChecker(t *testing.T) { + // LAPI not configured: checker is nil but mode is enforce. Must fail closed. + f := ParseFilter(FilterConfig{CrowdSec: nil, CrowdSecMode: CrowdSecEnforce}) + + assert.Equal(t, DenyCrowdSecUnavailable, f.Check(netip.MustParseAddr("1.2.3.4"), nil)) +} + +func TestFilter_CrowdSec_Observe_NilChecker(t *testing.T) { + // LAPI not configured: checker is nil but mode is observe. Must allow. + f := ParseFilter(FilterConfig{CrowdSec: nil, CrowdSecMode: CrowdSecObserve}) + + assert.Equal(t, Allow, f.Check(netip.MustParseAddr("1.2.3.4"), nil)) +} + +func TestFilter_CheckCIDR_AllowsWithoutCountryOrCrowdSec(t *testing.T) { + cs := &mockCrowdSec{ready: true, decisions: map[string]*CrowdSecDecision{ + "100.64.5.6": {Type: DecisionBan}, + }} + f := ParseFilter(FilterConfig{ + AllowedCIDRs: []string{"100.64.0.0/10"}, + AllowedCountries: []string{"US"}, + CrowdSec: cs, + CrowdSecMode: CrowdSecEnforce, + }) + + // CheckCIDR skips country + CrowdSec evaluation: an address inside + // the allowed CIDR passes even when it would be denied by CrowdSec + // or by the country allowlist (CGNAT addresses have no geo data). + assert.Equal(t, Allow, f.CheckCIDR(netip.MustParseAddr("100.64.5.6")), + "CheckCIDR must not run CrowdSec lookups on overlay traffic") + + // CIDR denials still fire. + assert.Equal(t, DenyCIDR, f.CheckCIDR(netip.MustParseAddr("198.51.100.1")), + "CheckCIDR must still reject addresses outside the allow list") +} + +func TestFilter_CheckCIDR_NilFilter(t *testing.T) { + var f *Filter + assert.Equal(t, Allow, f.CheckCIDR(netip.MustParseAddr("100.64.5.6")), + "CheckCIDR on a nil filter must allow") +} + +func TestFilter_HasRestrictions_CrowdSec(t *testing.T) { + cs := &mockCrowdSec{ready: true} + f := ParseFilter(FilterConfig{CrowdSec: cs, CrowdSecMode: CrowdSecEnforce}) + assert.True(t, f.HasRestrictions()) + + // Enforce mode without checker (LAPI not configured): still has restrictions + // because Check() will fail-closed with DenyCrowdSecUnavailable. + f2 := ParseFilter(FilterConfig{CrowdSec: nil, CrowdSecMode: CrowdSecEnforce}) + assert.True(t, f2.HasRestrictions()) +} diff --git a/proxy/internal/roundtrip/context_test.go b/proxy/internal/roundtrip/context_test.go new file mode 100644 index 000000000..c4e8267f8 --- /dev/null +++ b/proxy/internal/roundtrip/context_test.go @@ -0,0 +1,32 @@ +package roundtrip + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/proxy/internal/types" +) + +func TestAccountIDContext(t *testing.T) { + t.Run("returns empty when missing", func(t *testing.T) { + assert.Equal(t, types.AccountID(""), AccountIDFromContext(context.Background())) + }) + + t.Run("round-trips value", func(t *testing.T) { + ctx := WithAccountID(context.Background(), "acc-123") + assert.Equal(t, types.AccountID("acc-123"), AccountIDFromContext(ctx)) + }) +} + +func TestSkipTLSVerifyContext(t *testing.T) { + t.Run("false by default", func(t *testing.T) { + assert.False(t, skipTLSVerifyFromContext(context.Background())) + }) + + t.Run("true when set", func(t *testing.T) { + ctx := WithSkipTLSVerify(context.Background()) + assert.True(t, skipTLSVerifyFromContext(ctx)) + }) +} diff --git a/proxy/internal/roundtrip/multi.go b/proxy/internal/roundtrip/multi.go new file mode 100644 index 000000000..567249437 --- /dev/null +++ b/proxy/internal/roundtrip/multi.go @@ -0,0 +1,112 @@ +package roundtrip + +import ( + "crypto/tls" + "errors" + "net" + "net/http" + "time" + + log "github.com/sirupsen/logrus" +) + +// MultiTransport dispatches each request to either the embedded NetBird +// http.RoundTripper or a stdlib http.Transport based on a per-request +// context flag set by the reverse-proxy rewrite step. When the flag is +// absent (the default for every existing target), requests follow the +// embedded NetBird path — current behaviour, preserved. +// +// The stdlib branch is used when a target was configured with +// direct_upstream=true. It dials via the host's network stack, which is +// what private (`netbird proxy`) deployments and centralised proxies +// fronting host-reachable upstreams (public APIs, LAN services, +// localhost sidecars) want. +// +// An embedded roundtripper is required. To run direct-only (no WG +// branch at all), construct the MultiTransport via NewDirectOnly. +type MultiTransport struct { + embedded http.RoundTripper + direct *http.Transport + insecure *http.Transport +} + +// errNoEmbeddedTransport is returned when a request reaches the +// embedded branch on a MultiTransport that wasn't given one. Surfaces +// the misconfiguration to the caller instead of silently routing to +// the direct branch, which would bypass the WG tunnel. +var errNoEmbeddedTransport = errors.New("multitransport: embedded roundtripper not configured") + +// NewMultiTransport wires both branches. embedded is the existing NetBird +// roundtripper and must not be nil — pass to NewDirectOnly for a +// MultiTransport that only ever uses the direct branch. The direct +// branches honour the same NB_PROXY_* tuning env vars as the embedded +// transport (see loadTransportConfig) plus a dial-timeout wrapper that +// respects types.WithDialTimeout. +func NewMultiTransport(embedded http.RoundTripper, logger *log.Logger) *MultiTransport { + if logger == nil { + logger = log.StandardLogger() + } + cfg := loadTransportConfig(logger) + dialer := &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + } + direct := &http.Transport{ + DialContext: dialWithTimeout(dialer.DialContext), + ForceAttemptHTTP2: true, + MaxIdleConns: cfg.maxIdleConns, + MaxIdleConnsPerHost: cfg.maxIdleConnsPerHost, + MaxConnsPerHost: cfg.maxConnsPerHost, + IdleConnTimeout: cfg.idleConnTimeout, + TLSHandshakeTimeout: cfg.tlsHandshakeTimeout, + ExpectContinueTimeout: cfg.expectContinueTimeout, + ResponseHeaderTimeout: cfg.responseHeaderTimeout, + WriteBufferSize: cfg.writeBufferSize, + ReadBufferSize: cfg.readBufferSize, + DisableCompression: cfg.disableCompression, + } + insecure := direct.Clone() + insecure.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // matches the embedded NetBird transport's per-target opt-in + + return &MultiTransport{ + embedded: embedded, + direct: direct, + insecure: insecure, + } +} + +// NewDirectOnly returns a MultiTransport with no embedded branch. +// Every request goes through the direct branch regardless of the +// per-request flag, so the embedded path can never be reached +// silently — wiring code that needs WG must use NewMultiTransport. +func NewDirectOnly(logger *log.Logger) *MultiTransport { + return NewMultiTransport(noEmbeddedRoundTripper{}, logger) +} + +// noEmbeddedRoundTripper is the sentinel embedded transport for +// direct-only MultiTransports. RoundTrip is never called in practice +// because the direct branch matches every request, but if anything +// ever did reach this path it would fail loudly instead of falling +// back to direct. +type noEmbeddedRoundTripper struct{} + +func (noEmbeddedRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + return nil, errNoEmbeddedTransport +} + +// RoundTrip dispatches by reading the direct-upstream flag from the request +// context. When set, the request is forwarded via the stdlib transport, +// honouring the existing per-request skip-TLS-verify flag. Otherwise it +// goes through the embedded NetBird roundtripper. +func (m *MultiTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if DirectUpstreamFromContext(req.Context()) { + if skipTLSVerifyFromContext(req.Context()) { + return m.insecure.RoundTrip(req) + } + return m.direct.RoundTrip(req) + } + if m.embedded == nil { + return nil, errNoEmbeddedTransport + } + return m.embedded.RoundTrip(req) +} diff --git a/proxy/internal/roundtrip/multi_test.go b/proxy/internal/roundtrip/multi_test.go new file mode 100644 index 000000000..5c6cf1c97 --- /dev/null +++ b/proxy/internal/roundtrip/multi_test.go @@ -0,0 +1,134 @@ +package roundtrip + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stubRoundTripper records whether RoundTrip was called and returns a +// canned response so tests can assert the dispatch decision without +// running a real network. +type stubRoundTripper struct { + called bool + body string +} + +func (s *stubRoundTripper) RoundTrip(_ *http.Request) (*http.Response, error) { + s.called = true + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(s.body)), + Header: http.Header{}, + }, nil +} + +func TestMultiTransport_DispatchesByContextFlag(t *testing.T) { + embedded := &stubRoundTripper{body: "embedded"} + mt := NewMultiTransport(embedded, nil) + + t.Run("default routes to embedded", func(t *testing.T) { + embedded.called = false + req := httptest.NewRequest(http.MethodGet, "http://example.invalid", nil) + resp, err := mt.RoundTrip(req) + require.NoError(t, err, "embedded path must not error on stubbed transport") + require.NotNil(t, resp) + _ = resp.Body.Close() + assert.True(t, embedded.called, "request without WithDirectUpstream must hit the embedded transport") + }) + + t.Run("WithDirectUpstream skips embedded", func(t *testing.T) { + embedded.called = false + // Hit a server we control to verify the stdlib transport is used. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "direct") + })) + defer srv.Close() + + req, err := http.NewRequestWithContext(WithDirectUpstream(context.Background()), http.MethodGet, srv.URL, nil) + require.NoError(t, err) + resp, err := mt.RoundTrip(req) + require.NoError(t, err, "direct path must dial via stdlib transport") + body, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + require.NoError(t, err) + assert.Equal(t, "direct", string(body), "stdlib transport must reach the test server") + assert.False(t, embedded.called, "WithDirectUpstream must bypass the embedded transport") + }) +} + +// TestMultiTransport_AppliesEnvOverridesToDirect verifies that the +// NB_PROXY_* env vars consumed by loadTransportConfig flow into the +// direct branches (previously they only applied to the embedded +// roundtripper, so direct-upstream traffic ignored operator tuning). +func TestMultiTransport_AppliesEnvOverridesToDirect(t *testing.T) { + t.Setenv(EnvMaxIdleConns, "42") + t.Setenv(EnvIdleConnTimeout, "11s") + t.Setenv(EnvTLSHandshakeTimeout, "7s") + + mt := NewMultiTransport(&stubRoundTripper{body: "embedded"}, nil) + + assert.Equal(t, 42, mt.direct.MaxIdleConns, + "NB_PROXY_MAX_IDLE_CONNS must propagate to the direct transport") + assert.Equal(t, 11*time.Second, mt.direct.IdleConnTimeout, + "NB_PROXY_IDLE_CONN_TIMEOUT must propagate to the direct transport") + assert.Equal(t, 7*time.Second, mt.direct.TLSHandshakeTimeout, + "NB_PROXY_TLS_HANDSHAKE_TIMEOUT must propagate to the direct transport") + assert.Equal(t, 42, mt.insecure.MaxIdleConns, + "env tuning must also apply to the insecure-skip-verify direct transport") +} + +// TestMultiTransport_NilEmbeddedErrorsWhenWGPathRequested guards +// against the previous silent fallback: a MultiTransport constructed +// without an embedded transport must reject requests that don't +// explicitly opt into the direct branch, rather than routing them +// over the host stack and bypassing WireGuard. +func TestMultiTransport_NilEmbeddedErrorsWhenWGPathRequested(t *testing.T) { + mt := NewMultiTransport(nil, nil) + + req := httptest.NewRequest(http.MethodGet, "http://example.invalid", nil) + resp, err := mt.RoundTrip(req) + if resp != nil { + _ = resp.Body.Close() + } + require.Error(t, err, "nil embedded must surface as an explicit error, not a silent direct dispatch") + assert.Nil(t, resp) + assert.ErrorIs(t, err, errNoEmbeddedTransport, + "the error must be the sentinel so callers can distinguish misconfiguration from network failures") +} + +// TestMultiTransport_DirectOnlyServesDirectBranch verifies NewDirectOnly +// constructs a MultiTransport whose direct branch handles requests with +// the direct-upstream flag set, and surfaces the explicit sentinel +// when the embedded path is reached. +func TestMultiTransport_DirectOnlyServesDirectBranch(t *testing.T) { + mt := NewDirectOnly(nil) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "ok") + })) + defer srv.Close() + + req, err := http.NewRequestWithContext(WithDirectUpstream(context.Background()), http.MethodGet, srv.URL, nil) + require.NoError(t, err) + resp, err := mt.RoundTrip(req) + require.NoError(t, err, "direct-only must serve requests that opt into the direct branch") + _ = resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + wgReq := httptest.NewRequest(http.MethodGet, "http://example.invalid", nil) + resp, err = mt.RoundTrip(wgReq) + if resp != nil { + _ = resp.Body.Close() + } + require.Error(t, err, "direct-only must refuse requests that didn't opt into the direct branch") + assert.Nil(t, resp) + assert.ErrorIs(t, err, errNoEmbeddedTransport) +} diff --git a/proxy/internal/roundtrip/netbird.go b/proxy/internal/roundtrip/netbird.go new file mode 100644 index 000000000..cb2e7f930 --- /dev/null +++ b/proxy/internal/roundtrip/netbird.go @@ -0,0 +1,915 @@ +package roundtrip + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "net/http" + "net/netip" + "os" + "strings" + "sync" + "time" + + "github.com/hashicorp/go-multierror" + log "github.com/sirupsen/logrus" + "golang.org/x/exp/maps" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + grpcstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/embed" + nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/util" +) + +const deviceNamePrefix = "ingress-proxy-" + +const clientStopTimeout = 30 * time.Second + +const createProxyPeerTimeout = 30 * time.Second + +// backendKey identifies a backend by its host:port from the target URL. +type backendKey string + +// ServiceKey uniquely identifies a service (HTTP reverse proxy or L4 service) +// that holds a reference to an embedded NetBird client. Callers should use the +// DomainServiceKey and L4ServiceKey constructors to avoid namespace collisions. +type ServiceKey string + +// DomainServiceKey returns a ServiceKey for an HTTP/TLS domain-based service. +func DomainServiceKey(domain string) ServiceKey { + return ServiceKey("domain:" + domain) +} + +// L4ServiceKey returns a ServiceKey for an L4 service (TCP/UDP). +func L4ServiceKey(id types.ServiceID) ServiceKey { + return ServiceKey("l4:" + id) +} + +var ( + // ErrNoAccountID is returned when a request context is missing the account ID. + ErrNoAccountID = errors.New("no account ID in request context") + // ErrNoPeerConnection is returned when no embedded client exists for the account. + ErrNoPeerConnection = errors.New("no peer connection found") + // ErrClientStartFailed is returned when the embedded client fails to start. + ErrClientStartFailed = errors.New("client start failed") + // ErrTooManyInflight is returned when the per-backend in-flight limit is reached. + ErrTooManyInflight = errors.New("too many in-flight requests") +) + +// serviceInfo holds metadata about a registered service. +type serviceInfo struct { + serviceID types.ServiceID +} + +type serviceNotification struct { + key ServiceKey + serviceID types.ServiceID +} + +// clientEntry holds an embedded NetBird client and tracks which services use it. +type clientEntry struct { + client *embed.Client + transport *http.Transport + // insecureTransport is a clone of transport with TLS verification disabled, + // used when per-target skip_tls_verify is set. + insecureTransport *http.Transport + services map[ServiceKey]serviceInfo + createdAt time.Time + started bool + // inbound is opaque per-account state owned by the NetBird parent's + // ReadyHandler. The roundtrip package never inspects this value; it + // only stores it so RemovePeer / StopAll can hand it back to the + // matching StopHandler. Nil when no inbound integration is active. + inbound any + // Per-backend in-flight limiting keyed by target host:port. + // TODO: clean up stale entries when backend targets change. + inflightMu sync.Mutex + inflightMap map[backendKey]chan struct{} + maxInflight int +} + +// IdentityForIP resolves a tunnel IP to the peer identity locally known by +// this account's embedded client. Returns (pubKey, fqdn) on success. +// ok=false means the IP is not in the account's roster — callers can use +// that as a fast deny without round-tripping management. The returned +// strings carry only what the embedded peerstore exposes; user identity +// (UserID / Email / Groups) still flows through ValidateTunnelPeer. +func (e *clientEntry) IdentityForIP(ip netip.Addr) (pubKey, fqdn string, ok bool) { + if e == nil || e.client == nil || !ip.IsValid() { + return "", "", false + } + return e.client.IdentityForIP(ip) +} + +// acquireInflight attempts to acquire an in-flight slot for the given backend. +// It returns a release function that must always be called, and true on success. +func (e *clientEntry) acquireInflight(backend backendKey) (release func(), ok bool) { + noop := func() {} + if e.maxInflight <= 0 { + return noop, true + } + + e.inflightMu.Lock() + sem, exists := e.inflightMap[backend] + if !exists { + sem = make(chan struct{}, e.maxInflight) + e.inflightMap[backend] = sem + } + e.inflightMu.Unlock() + + select { + case sem <- struct{}{}: + return func() { <-sem }, true + default: + return noop, false + } +} + +// ClientConfig holds configuration for the embedded NetBird client. +type ClientConfig struct { + MgmtAddr string + WGPort uint16 + PreSharedKey string + Performance embed.Performance + // BlockInbound mirrors embed.Options.BlockInbound. Set to true on the + // standalone proxy where the embedded client never accepts inbound; + // set to false on the private/embedded proxy so the engine creates + // the ACL manager and applies management's per-policy firewall rules + // (which is what gates per-account inbound listeners on the netstack). + BlockInbound bool +} + +type statusNotifier interface { + NotifyStatus(ctx context.Context, accountID types.AccountID, serviceID types.ServiceID, connected bool) error +} + +type managementClient interface { + CreateProxyPeer(ctx context.Context, req *proto.CreateProxyPeerRequest, opts ...grpc.CallOption) (*proto.CreateProxyPeerResponse, error) +} + +// NetBird provides an http.RoundTripper implementation +// backed by underlying NetBird connections. +// Clients are keyed by AccountID, allowing multiple services to share the same connection. +type NetBird struct { + ctx context.Context + proxyID string + proxyAddr string + clientCfg ClientConfig + logger *log.Logger + mgmtClient managementClient + transportCfg transportConfig + + clientsMux sync.RWMutex + clients map[types.AccountID]*clientEntry + lifecycleMu sync.Map + initLogOnce sync.Once + statusNotifier statusNotifier + // readyHandler runs after the embedded client for an account reports + // Ready. The opaque return value is stored on clientEntry and handed + // back to stopHandler when the entry is torn down. Nil disables the + // hook entirely (default for the standalone proxy). + readyHandler func(ctx context.Context, accountID types.AccountID, client *embed.Client) any + // stopHandler runs when an account's last service is removed (or the + // transport is shutting down). Receives whatever readyHandler returned. + stopHandler func(accountID types.AccountID, state any) + + // OnAddPeer, when set, is called after AddPeer completes for a new account + // (i.e. when a new client was actually created, not when an existing one + // was reused). The duration covers keygen + gRPC CreateProxyPeer + embed.New. + OnAddPeer func(d time.Duration, err error) + + // startClient runs the post-create client startup. Nil uses runClientStartup; + // tests override it to avoid a real embed client.Start. + startClient func(accountID types.AccountID, client *embed.Client) +} + +// ClientDebugInfo contains debug information about a client. +type ClientDebugInfo struct { + AccountID types.AccountID + ServiceCount int + ServiceKeys []string + HasClient bool + CreatedAt time.Time +} + +// accountIDContextKey is the context key for storing the account ID. +type accountIDContextKey struct{} + +// skipTLSVerifyContextKey is the context key for requesting insecure TLS. +type skipTLSVerifyContextKey struct{} + +// AddPeer registers a service for an account. If the account doesn't have a client yet, +// one is created by authenticating with the management server using the provided token. +// Multiple services can share the same client. +func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key ServiceKey, authToken string, serviceID types.ServiceID) error { + si := serviceInfo{serviceID: serviceID} + + if n.registerExistingClient(accountID, key, si) { + return nil + } + + lifecycle := n.accountLifecycle(accountID) + lifecycle.Lock() + transferred := false + defer func() { + if !transferred { + lifecycle.Unlock() + } + }() + + if n.registerExistingClient(accountID, key, si) { + return nil + } + + createStart := time.Now() + entry, err := n.createClientEntry(ctx, accountID, key, authToken, si) + if n.OnAddPeer != nil { + n.OnAddPeer(time.Since(createStart), err) + } + if err != nil { + return err + } + + n.clientsMux.Lock() + n.clients[accountID] = entry + n.clientsMux.Unlock() + + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_key": key, + }).Info("created new client for account") + + transferred = true + go func() { + defer lifecycle.Unlock() + n.startClientStartup(accountID, entry.client) + }() + + return nil +} + +func (n *NetBird) startClientStartup(accountID types.AccountID, client *embed.Client) { + if n.startClient != nil { + n.startClient(accountID, client) + return + } + n.runClientStartup(accountID, client) +} + +// registerExistingClient registers the service against an already-present +// client for the account and returns true when it did. It notifies management +// of the new service when the client is already started. +func (n *NetBird) registerExistingClient(accountID types.AccountID, key ServiceKey, si serviceInfo) bool { + n.clientsMux.Lock() + entry, exists := n.clients[accountID] + if !exists { + n.clientsMux.Unlock() + return false + } + entry.services[key] = si + started := entry.started + n.clientsMux.Unlock() + + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_key": key, + }).Debug("registered service with existing client") + + if started && n.statusNotifier != nil { + if err := n.statusNotifier.NotifyStatus(context.Background(), accountID, si.serviceID, true); err != nil { + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_key": key, + }).WithError(err).Warn("failed to notify status for existing client") + } + } + return true +} + +// accountLifecycle returns the per-account lifecycle mutex, serialising client +// creation against teardown so a slow client.Stop cannot race a new +// client.Start for the same account, without blocking clientsMux. +func (n *NetBird) accountLifecycle(accountID types.AccountID) *sync.Mutex { + mu, _ := n.lifecycleMu.LoadOrStore(accountID, &sync.Mutex{}) + return mu.(*sync.Mutex) +} + +// createClientEntry generates a WireGuard keypair, authenticates with management, +// and creates an embedded NetBird client. Must be called with the account's +// lifecycle mutex held. +func (n *NetBird) createClientEntry(ctx context.Context, accountID types.AccountID, key ServiceKey, authToken string, si serviceInfo) (*clientEntry, error) { + serviceID := si.serviceID + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_id": serviceID, + }).Debug("generating WireGuard keypair for new peer") + + privateKey, err := wgtypes.GeneratePrivateKey() + if err != nil { + return nil, fmt.Errorf("generate wireguard private key: %w", err) + } + publicKey := privateKey.PublicKey() + + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_id": serviceID, + "public_key": publicKey.String(), + }).Debug("authenticating new proxy peer with management") + + createCtx, cancel := context.WithTimeout(ctx, createProxyPeerTimeout) + defer cancel() + resp, err := n.mgmtClient.CreateProxyPeer(createCtx, &proto.CreateProxyPeerRequest{ + ServiceId: string(serviceID), + AccountId: string(accountID), + Token: authToken, + WireguardPublicKey: publicKey.String(), + Cluster: n.proxyAddr, + }) + if err != nil { + return nil, fmt.Errorf("authenticate proxy peer with management: %w", err) + } + if resp != nil && !resp.GetSuccess() { + errMsg := "unknown error" + if resp.ErrorMessage != nil { + errMsg = *resp.ErrorMessage + } + return nil, fmt.Errorf("proxy peer authentication failed: %s", errMsg) + } + + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_id": serviceID, + "public_key": publicKey.String(), + }).Info("proxy peer authenticated successfully with management") + + // Embedded client log level: warn by default (quiet in production); set + // NB_PROXY_CLIENT_LOG_LEVEL (e.g. "trace") to surface the embedded NetBird + // client's relay / signal / handshake detail for local debugging. + clientLogLevel := log.WarnLevel.String() + if v := strings.TrimSpace(os.Getenv("NB_PROXY_CLIENT_LOG_LEVEL")); v != "" { + if lvl, err := log.ParseLevel(v); err == nil { + clientLogLevel = lvl.String() + } else { + n.logger.Warnf("invalid NB_PROXY_CLIENT_LOG_LEVEL %q, using %q: %v", v, clientLogLevel, err) + } + } + + n.initLogOnce.Do(func() { + if err := util.InitLog(clientLogLevel, util.LogConsole); err != nil { + n.logger.WithField("account_id", accountID).Warnf("failed to initialize embedded client logging: %v", err) + } + }) + + // Create embedded NetBird client with the generated private key. + // The peer has already been created via CreateProxyPeer RPC with the public key. + wgPort := int(n.clientCfg.WGPort) + embedOpts := embed.Options{ + DeviceName: deviceNamePrefix + n.proxyID, + ManagementURL: n.clientCfg.MgmtAddr, + PrivateKey: privateKey.String(), + LogLevel: clientLogLevel, + BlockInbound: n.clientCfg.BlockInbound, + // The embedded proxy peer must never be a stepping stone into + // the proxy host's LAN: it only exists to reach NetBird mesh + // targets or, when direct_upstream is set, the host network + // stack via the MultiTransport's direct branch (which bypasses + // the engine routing entirely). + BlockLANAccess: true, + WireguardPort: &wgPort, + PreSharedKey: n.clientCfg.PreSharedKey, + Performance: n.clientCfg.Performance, + } + logEmbedOptions(n.logger, accountID, serviceID, publicKey.String(), embedOpts) + client, err := embed.New(embedOpts) + if err != nil { + return nil, fmt.Errorf("create netbird client: %w", err) + } + + // Create a transport using the client dialer. We do this instead of using + // the client's HTTPClient to avoid issues with request validation that do + // not work with reverse proxied requests. + transport := &http.Transport{ + DialContext: dialWithTimeout(client.DialContext), + ForceAttemptHTTP2: true, + MaxIdleConns: n.transportCfg.maxIdleConns, + MaxIdleConnsPerHost: n.transportCfg.maxIdleConnsPerHost, + MaxConnsPerHost: n.transportCfg.maxConnsPerHost, + IdleConnTimeout: n.transportCfg.idleConnTimeout, + TLSHandshakeTimeout: n.transportCfg.tlsHandshakeTimeout, + ExpectContinueTimeout: n.transportCfg.expectContinueTimeout, + ResponseHeaderTimeout: n.transportCfg.responseHeaderTimeout, + WriteBufferSize: n.transportCfg.writeBufferSize, + ReadBufferSize: n.transportCfg.readBufferSize, + DisableCompression: n.transportCfg.disableCompression, + } + + insecureTransport := transport.Clone() + insecureTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec + + return &clientEntry{ + client: client, + services: map[ServiceKey]serviceInfo{key: si}, + transport: transport, + insecureTransport: insecureTransport, + createdAt: time.Now(), + started: false, + inflightMap: make(map[backendKey]chan struct{}), + maxInflight: n.transportCfg.maxInflight, + }, nil +} + +// runClientStartup starts the client and notifies registered services on +// success. This function runs in a goroutine launched from AddPeer, so it +// must never inherit the caller's request-scoped context — a canceled +// request must not abort the inbound listener bring-up or the management +// status notification. The embedded client.Start gets its own bounded +// startCtx; once Start succeeds, notifyClientReady takes over with a +// fresh context.Background() (see that function for the contract). +func (n *NetBird) runClientStartup(accountID types.AccountID, client *embed.Client) { + startCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := client.Start(startCtx); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + n.logger.WithField("account_id", accountID).Warn("netbird client start timed out, will retry on first request") + } else { + n.logger.WithField("account_id", accountID).WithError(err).Error("failed to start netbird client") + } + return + } + + n.notifyClientReady(accountID, client) +} + +// notifyClientReady marks the account's client as started, fires the +// readyHandler hook, and notifies management of the new tunnel +// connection for every registered service. It is split out of +// runClientStartup so a regression test can drive the post-Start tail +// without needing a live embedded client. The contract that the +// hooks/notifier see context.Background() — never the AddPeer caller's +// ctx — lives here. +func (n *NetBird) notifyClientReady(accountID types.AccountID, client *embed.Client) { + n.clientsMux.Lock() + entry, exists := n.clients[accountID] + if exists { + entry.started = true + } + var toNotify []serviceNotification + if exists { + for key, info := range entry.services { + toNotify = append(toNotify, serviceNotification{key: key, serviceID: info.serviceID}) + } + } + readyHandler := n.readyHandler + n.clientsMux.Unlock() + + if readyHandler != nil { + state := readyHandler(n.ctx, accountID, client) + n.clientsMux.Lock() + if e, ok := n.clients[accountID]; ok { + e.inbound = state + } else if state != nil && n.stopHandler != nil { + // Account was removed while readyHandler ran; tear down the + // resources it just brought up. + stop := n.stopHandler + n.clientsMux.Unlock() + stop(accountID, state) + n.clientsMux.Lock() + } + n.clientsMux.Unlock() + } + + if n.statusNotifier == nil { + return + } + for _, sn := range toNotify { + if err := n.statusNotifier.NotifyStatus(n.ctx, accountID, sn.serviceID, true); err != nil { + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_key": sn.key, + }).WithError(err).Warn("failed to notify tunnel connection status") + } else { + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_key": sn.key, + }).Info("notified management about tunnel connection") + } + } +} + +// RemovePeer unregisters a service from an account. The client is only stopped +// when no services are using it anymore. +func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, key ServiceKey) error { + lifecycle := n.accountLifecycle(accountID) + lifecycle.Lock() + transferred := false + defer func() { + if !transferred { + lifecycle.Unlock() + } + }() + + n.clientsMux.Lock() + + entry, exists := n.clients[accountID] + if !exists { + n.clientsMux.Unlock() + n.logger.WithField("account_id", accountID).Debug("remove peer: account not found") + return nil + } + + si, svcExists := entry.services[key] + if !svcExists { + n.clientsMux.Unlock() + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_key": key, + }).Debug("remove peer: service not registered") + return nil + } + + delete(entry.services, key) + + stopClient := len(entry.services) == 0 + if stopClient { + n.logger.WithField("account_id", accountID).Info("stopping client, no more services") + delete(n.clients, accountID) + } else { + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_key": key, + "remaining_services": len(entry.services), + }).Debug("unregistered service, client still in use") + } + n.clientsMux.Unlock() + + n.notifyDisconnect(ctx, accountID, key, si.serviceID) + + if stopClient { + transferred = true + go n.stopClientLocked(accountID, lifecycle, entry) + } + + return nil +} + +// stopClientLocked releases a client's resources off the caller's goroutine so a +// slow client.Stop cannot wedge the mapping receive loop (which calls RemovePeer +// synchronously). It unlocks lifecycle when done so a new client.Start for the +// same account waits for this teardown. +func (n *NetBird) stopClientLocked(accountID types.AccountID, lifecycle *sync.Mutex, entry *clientEntry) { + defer lifecycle.Unlock() + + if entry.inbound != nil && n.stopHandler != nil { + n.stopHandler(accountID, entry.inbound) + } + if entry.transport != nil { + entry.transport.CloseIdleConnections() + } + if entry.insecureTransport != nil { + entry.insecureTransport.CloseIdleConnections() + } + if entry.client == nil { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), clientStopTimeout) + defer cancel() + if err := entry.client.Stop(ctx); err != nil { + n.logger.WithField("account_id", accountID).WithError(err).Warn("failed to stop netbird client") + } +} + +func (n *NetBird) notifyDisconnect(ctx context.Context, accountID types.AccountID, key ServiceKey, serviceID types.ServiceID) { + if n.statusNotifier == nil { + return + } + if err := n.statusNotifier.NotifyStatus(ctx, accountID, serviceID, false); err != nil { + if s, ok := grpcstatus.FromError(err); ok && s.Code() == codes.NotFound { + n.logger.WithField("service_key", key).Debug("service already removed, skipping disconnect notification") + } else { + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_key": key, + }).WithError(err).Warn("failed to notify tunnel disconnection status") + } + } +} + +// RoundTrip implements http.RoundTripper. It looks up the client for the account +// specified in the request context and uses it to dial the backend. +func (n *NetBird) RoundTrip(req *http.Request) (*http.Response, error) { + accountID := AccountIDFromContext(req.Context()) + if accountID == "" { + return nil, ErrNoAccountID + } + + // Copy references while holding lock, then unlock early to avoid blocking + // other requests during the potentially slow RoundTrip. + n.clientsMux.RLock() + entry, exists := n.clients[accountID] + if !exists { + n.clientsMux.RUnlock() + return nil, fmt.Errorf("%w for account: %s", ErrNoPeerConnection, accountID) + } + client := entry.client + transport := entry.transport + if skipTLSVerifyFromContext(req.Context()) { + transport = entry.insecureTransport + } + n.clientsMux.RUnlock() + + release, ok := entry.acquireInflight(backendKey(req.URL.Host)) + defer release() + if !ok { + return nil, ErrTooManyInflight + } + + // Attempt to start the client, if the client is already running then + // it will return an error that we ignore, if this hits a timeout then + // this request is unprocessable. + startCtx, cancel := context.WithTimeout(req.Context(), 30*time.Second) + defer cancel() + if err := client.Start(startCtx); err != nil { + if !errors.Is(err, embed.ErrClientAlreadyStarted) { + return nil, fmt.Errorf("%w: %w", ErrClientStartFailed, err) + } + } + + start := time.Now() + resp, err := transport.RoundTrip(req) + duration := time.Since(start) + + if err != nil { + n.logger.Debugf("roundtrip: method=%s host=%s url=%s account=%s duration=%s err=%v", + req.Method, req.Host, req.URL.String(), accountID, duration.Truncate(time.Millisecond), err) + return nil, err + } + + n.logger.Debugf("roundtrip: method=%s host=%s url=%s account=%s status=%d duration=%s", + req.Method, req.Host, req.URL.String(), accountID, resp.StatusCode, duration.Truncate(time.Millisecond)) + return resp, nil +} + +// StopAll stops all clients. +func (n *NetBird) StopAll(ctx context.Context) error { + n.clientsMux.Lock() + defer n.clientsMux.Unlock() + + stopHandler := n.stopHandler + var merr *multierror.Error + for accountID, entry := range n.clients { + if entry.inbound != nil && stopHandler != nil { + stopHandler(accountID, entry.inbound) + } + entry.transport.CloseIdleConnections() + entry.insecureTransport.CloseIdleConnections() + if err := entry.client.Stop(ctx); err != nil { + n.logger.WithFields(log.Fields{ + "account_id": accountID, + }).WithError(err).Warn("failed to stop netbird client during shutdown") + merr = multierror.Append(merr, err) + } + } + maps.Clear(n.clients) + + return nberrors.FormatErrorOrNil(merr) +} + +// HasClient returns true if there is a client for the given account. +func (n *NetBird) HasClient(accountID types.AccountID) bool { + n.clientsMux.RLock() + defer n.clientsMux.RUnlock() + _, exists := n.clients[accountID] + return exists +} + +// ServiceCount returns the number of services registered for the given account. +// Returns 0 if the account has no client. +func (n *NetBird) ServiceCount(accountID types.AccountID) int { + n.clientsMux.RLock() + defer n.clientsMux.RUnlock() + entry, exists := n.clients[accountID] + if !exists { + return 0 + } + return len(entry.services) +} + +// ClientCount returns the total number of active clients. +func (n *NetBird) ClientCount() int { + n.clientsMux.RLock() + defer n.clientsMux.RUnlock() + return len(n.clients) +} + +// GetClient returns the embed.Client for the given account ID. +func (n *NetBird) GetClient(accountID types.AccountID) (*embed.Client, bool) { + n.clientsMux.RLock() + defer n.clientsMux.RUnlock() + entry, exists := n.clients[accountID] + if !exists { + return nil, false + } + return entry.client, true +} + +// IdentityForIP resolves a tunnel IP to a peer identity local to the given +// account. Delegates to clientEntry.IdentityForIP. Returns ok=false when +// the account has no client or the IP is not in its peerstore. +func (n *NetBird) IdentityForIP(accountID types.AccountID, ip netip.Addr) (pubKey, fqdn string, ok bool) { + n.clientsMux.RLock() + entry, exists := n.clients[accountID] + n.clientsMux.RUnlock() + if !exists { + return "", "", false + } + return entry.IdentityForIP(ip) +} + +// ListClientsForDebug returns information about all clients for debug purposes. +func (n *NetBird) ListClientsForDebug() map[types.AccountID]ClientDebugInfo { + n.clientsMux.RLock() + defer n.clientsMux.RUnlock() + + result := make(map[types.AccountID]ClientDebugInfo) + for accountID, entry := range n.clients { + keys := make([]string, 0, len(entry.services)) + for k := range entry.services { + keys = append(keys, string(k)) + } + result[accountID] = ClientDebugInfo{ + AccountID: accountID, + ServiceCount: len(entry.services), + ServiceKeys: keys, + HasClient: entry.client != nil, + CreatedAt: entry.createdAt, + } + } + return result +} + +// ListClientsForStartup returns all embed.Client instances for health checks. +func (n *NetBird) ListClientsForStartup() map[types.AccountID]*embed.Client { + n.clientsMux.RLock() + defer n.clientsMux.RUnlock() + + result := make(map[types.AccountID]*embed.Client) + for accountID, entry := range n.clients { + if entry.client != nil { + result[accountID] = entry.client + } + } + return result +} + +// NewNetBird creates a new NetBird transport. Set clientCfg.WGPort to 0 for a random +// OS-assigned port. A fixed port only works with single-account deployments; +// multiple accounts will fail to bind the same port. +func NewNetBird(ctx context.Context, proxyID, proxyAddr string, clientCfg ClientConfig, logger *log.Logger, notifier statusNotifier, mgmtClient managementClient) *NetBird { + if logger == nil { + logger = log.StandardLogger() + } + return &NetBird{ + ctx: ctx, + proxyID: proxyID, + proxyAddr: proxyAddr, + clientCfg: clientCfg, + logger: logger, + clients: make(map[types.AccountID]*clientEntry), + statusNotifier: notifier, + mgmtClient: mgmtClient, + transportCfg: loadTransportConfig(logger), + } +} + +// SetClientLifecycle registers callbacks that run when an embedded +// client becomes ready and when its entry is torn down. The opaque value +// returned by ready is stored on the entry and handed back to stop on +// cleanup. Must be called before AddPeer. A nil pair leaves the +// outbound-only behaviour intact. +func (n *NetBird) SetClientLifecycle(ready func(ctx context.Context, accountID types.AccountID, client *embed.Client) any, stop func(accountID types.AccountID, state any)) { + n.clientsMux.Lock() + defer n.clientsMux.Unlock() + n.readyHandler = ready + n.stopHandler = stop +} + +// dialWithTimeout wraps a DialContext function so that any dial timeout +// stored in the context (via types.WithDialTimeout) is applied only to +// the connection establishment phase, not the full request lifetime. +func dialWithTimeout(dial func(ctx context.Context, network, addr string) (net.Conn, error)) func(ctx context.Context, network, addr string) (net.Conn, error) { + return func(ctx context.Context, network, addr string) (net.Conn, error) { + if d, ok := types.DialTimeoutFromContext(ctx); ok { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, d) + defer cancel() + } + return dial(ctx, network, addr) + } +} + +// WithAccountID adds the account ID to the context. +func WithAccountID(ctx context.Context, accountID types.AccountID) context.Context { + return context.WithValue(ctx, accountIDContextKey{}, accountID) +} + +// AccountIDFromContext retrieves the account ID from the context. +func AccountIDFromContext(ctx context.Context) types.AccountID { + v := ctx.Value(accountIDContextKey{}) + if v == nil { + return "" + } + accountID, ok := v.(types.AccountID) + if !ok { + return "" + } + return accountID +} + +// WithSkipTLSVerify marks the context to use an insecure transport that skips +// TLS certificate verification for the backend connection. +func WithSkipTLSVerify(ctx context.Context) context.Context { + return context.WithValue(ctx, skipTLSVerifyContextKey{}, true) +} + +func skipTLSVerifyFromContext(ctx context.Context) bool { + v, _ := ctx.Value(skipTLSVerifyContextKey{}).(bool) + return v +} + +// directUpstreamContextKey signals that the request should bypass the embedded +// NetBird WireGuard client and dial via the host's network stack instead. +// Set by the reverse-proxy rewrite step when the matched target carries +// PathTarget.DirectUpstream; consumed by MultiTransport. +type directUpstreamContextKey struct{} + +// WithDirectUpstream marks the context so MultiTransport routes the request +// through its stdlib transport instead of the embedded NetBird roundtripper. +func WithDirectUpstream(ctx context.Context) context.Context { + return context.WithValue(ctx, directUpstreamContextKey{}, true) +} + +// DirectUpstreamFromContext reports whether the context has been marked to +// bypass the embedded NetBird client. +func DirectUpstreamFromContext(ctx context.Context) bool { + v, _ := ctx.Value(directUpstreamContextKey{}).(bool) + return v +} + +// logEmbedOptions emits a single structured INFO line summarising every +// operationally meaningful flag handed to embed.New for this per-account +// client. Secrets (PrivateKey, PreSharedKey) are reduced to a "present" +// boolean — never logged verbatim. Use this when an embedded peer +// silently misbehaves: most failure modes (inbound drops, wrong +// management URL, v6 unexpectedly on, userspace flipped, port clash) +// are obvious from these flags before any traffic flows. +func logEmbedOptions(logger *log.Logger, accountID types.AccountID, serviceID types.ServiceID, publicKey string, opts embed.Options) { + wgPort := 0 + if opts.WireguardPort != nil { + wgPort = *opts.WireguardPort + } + mtu := uint16(0) + if opts.MTU != nil { + mtu = *opts.MTU + } + perfBuffers := uint32(0) + if opts.Performance.PreallocatedBuffersPerPool != nil { + perfBuffers = *opts.Performance.PreallocatedBuffersPerPool + } + perfBatch := uint32(0) + if opts.Performance.MaxBatchSize != nil { + perfBatch = *opts.Performance.MaxBatchSize + } + logger.WithFields(log.Fields{ + "account_id": accountID, + "service_id": serviceID, + "public_key": publicKey, + "device_name": opts.DeviceName, + "management_url": opts.ManagementURL, + "log_level": opts.LogLevel, + "wg_port": wgPort, + "mtu": mtu, + "block_inbound": opts.BlockInbound, + "block_lan_access": opts.BlockLANAccess, + "disable_ipv6": opts.DisableIPv6, + "disable_client_routes": opts.DisableClientRoutes, + "no_userspace": opts.NoUserspace, + "config_path_set": opts.ConfigPath != "", + "state_path_set": opts.StatePath != "", + "private_key_present": opts.PrivateKey != "", + "presharedkey_present": opts.PreSharedKey != "", + "setup_key_present": opts.SetupKey != "", + "jwt_token_present": opts.JWTToken != "", + "dns_labels": opts.DNSLabels, + "perf_buffers_per_pool": perfBuffers, + "perf_max_batch_size": perfBatch, + }).Info("starting embedded netbird client for account") +} diff --git a/proxy/internal/roundtrip/netbird_bench_test.go b/proxy/internal/roundtrip/netbird_bench_test.go new file mode 100644 index 000000000..330ea0332 --- /dev/null +++ b/proxy/internal/roundtrip/netbird_bench_test.go @@ -0,0 +1,112 @@ +package roundtrip + +import ( + "context" + "crypto/rand" + "math/big" + "sync" + "testing" + "time" + + "github.com/netbirdio/netbird/proxy/internal/types" +) + +// Simple benchmark for comparison with AddPeer contention. +func BenchmarkHasClient(b *testing.B) { + // Knobs for dialling in: + initialClientCount := 100 // Size of initial peer map to generate. + + nb := mockNetBird() + + var target types.AccountID + targetIndex, err := rand.Int(rand.Reader, big.NewInt(int64(initialClientCount))) + if err != nil { + b.Fatal(err) + } + for i := range initialClientCount { + id := types.AccountID(rand.Text()) + if int64(i) == targetIndex.Int64() { + target = id + } + nb.clients[id] = &clientEntry{ + services: map[ServiceKey]serviceInfo{ + ServiceKey(rand.Text()): { + serviceID: types.ServiceID(rand.Text()), + }, + }, + createdAt: time.Now(), + started: true, + } + } + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + nb.HasClient(target) + } + }) + b.StopTimer() +} + +func BenchmarkHasClientDuringAddPeer(b *testing.B) { + // Knobs for dialling in: + initialClientCount := 100 // Size of initial peer map to generate. + addPeerWorkers := 5 // Number of workers to concurrently call AddPeer. + + nb := mockNetBird() + + // Add random client entries to the netbird instance. + // We're trying to test map lock contention, so starting with + // a populated map should help with this. + // Pick a random one to target for retrieval later. + var target types.AccountID + targetIndex, err := rand.Int(rand.Reader, big.NewInt(int64(initialClientCount))) + if err != nil { + b.Fatal(err) + } + for i := range initialClientCount { + id := types.AccountID(rand.Text()) + if int64(i) == targetIndex.Int64() { + target = id + } + nb.clients[id] = &clientEntry{ + services: map[ServiceKey]serviceInfo{ + ServiceKey(rand.Text()): { + serviceID: types.ServiceID(rand.Text()), + }, + }, + createdAt: time.Now(), + started: true, + } + } + + // Launch workers that continuously call AddPeer with new random accountIDs. + ctx, cancel := context.WithCancel(b.Context()) + var wg sync.WaitGroup + for range addPeerWorkers { + wg.Add(1) + go func() { + defer wg.Done() + for ctx.Err() == nil { + if err := nb.AddPeer(ctx, + types.AccountID(rand.Text()), + ServiceKey(rand.Text()), + rand.Text(), + types.ServiceID(rand.Text())); err != nil { + return + } + } + }() + } + + // Benchmark calling HasClient during AddPeer contention. + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + nb.HasClient(target) + } + }) + b.StopTimer() + cancel() + wg.Wait() +} diff --git a/proxy/internal/roundtrip/netbird_test.go b/proxy/internal/roundtrip/netbird_test.go new file mode 100644 index 000000000..700cca83e --- /dev/null +++ b/proxy/internal/roundtrip/netbird_test.go @@ -0,0 +1,552 @@ +package roundtrip + +import ( + "context" + "net/http" + "net/netip" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/client/embed" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +type mockMgmtClient struct{} + +func (m *mockMgmtClient) CreateProxyPeer(_ context.Context, _ *proto.CreateProxyPeerRequest, _ ...grpc.CallOption) (*proto.CreateProxyPeerResponse, error) { + return &proto.CreateProxyPeerResponse{Success: true}, nil +} + +// signalMgmtClient closes entered the first time CreateProxyPeer is called, so +// tests can detect AddPeer reaching client creation. +type signalMgmtClient struct { + entered chan struct{} + once sync.Once +} + +func (m *signalMgmtClient) CreateProxyPeer(_ context.Context, _ *proto.CreateProxyPeerRequest, _ ...grpc.CallOption) (*proto.CreateProxyPeerResponse, error) { + m.once.Do(func() { close(m.entered) }) + return &proto.CreateProxyPeerResponse{Success: true}, nil +} + +type mockStatusNotifier struct { + mu sync.Mutex + statuses []statusCall +} + +type statusCall struct { + accountID types.AccountID + serviceID types.ServiceID + connected bool + // ctx is captured so tests can assert the notifier received a + // fresh background context rather than an inherited request ctx. + ctx context.Context +} + +func (m *mockStatusNotifier) NotifyStatus(ctx context.Context, accountID types.AccountID, serviceID types.ServiceID, connected bool) error { + m.mu.Lock() + defer m.mu.Unlock() + m.statuses = append(m.statuses, statusCall{accountID, serviceID, connected, ctx}) + return nil +} + +func (m *mockStatusNotifier) calls() []statusCall { + m.mu.Lock() + defer m.mu.Unlock() + return append([]statusCall{}, m.statuses...) +} + +// mockNetBird creates a NetBird instance for testing without actually connecting. +// It uses an invalid management URL to prevent real connections. +func mockNetBird() *NetBird { + nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{ + MgmtAddr: "http://invalid.test:9999", + WGPort: 0, + PreSharedKey: "", + }, nil, nil, &mockMgmtClient{}) + // Skip the real embed client.Start, which would hang against the unreachable + // mgmt URL and (now that the lifecycle lock spans startup) serialise removes. + nb.startClient = func(types.AccountID, *embed.Client) {} + return nb +} + +func TestNetBird_AddPeer_CreatesClientForNewAccount(t *testing.T) { + nb := mockNetBird() + accountID := types.AccountID("account-1") + + // Initially no client exists. + assert.False(t, nb.HasClient(accountID), "should not have client before AddPeer") + assert.Equal(t, 0, nb.ServiceCount(accountID), "service count should be 0") + + // Add first service - this should create a new client. + err := nb.AddPeer(context.Background(), accountID, "domain1.test", "setup-key-1", types.ServiceID("proxy-1")) + require.NoError(t, err) + + assert.True(t, nb.HasClient(accountID), "should have client after AddPeer") + assert.Equal(t, 1, nb.ServiceCount(accountID), "service count should be 1") +} + +func TestNetBird_AddPeer_ReuseClientForSameAccount(t *testing.T) { + nb := mockNetBird() + accountID := types.AccountID("account-1") + + // Add first service. + err := nb.AddPeer(context.Background(), accountID, "domain1.test", "setup-key-1", types.ServiceID("proxy-1")) + require.NoError(t, err) + assert.Equal(t, 1, nb.ServiceCount(accountID)) + + // Add second service for the same account - should reuse existing client. + err = nb.AddPeer(context.Background(), accountID, "domain2.test", "setup-key-1", types.ServiceID("proxy-2")) + require.NoError(t, err) + assert.Equal(t, 2, nb.ServiceCount(accountID), "service count should be 2 after adding second service") + + // Add third service. + err = nb.AddPeer(context.Background(), accountID, "domain3.test", "setup-key-1", types.ServiceID("proxy-3")) + require.NoError(t, err) + assert.Equal(t, 3, nb.ServiceCount(accountID), "service count should be 3 after adding third service") + + // Still only one client. + assert.True(t, nb.HasClient(accountID)) +} + +func TestNetBird_AddPeer_SeparateClientsForDifferentAccounts(t *testing.T) { + nb := mockNetBird() + account1 := types.AccountID("account-1") + account2 := types.AccountID("account-2") + + // Add service for account 1. + err := nb.AddPeer(context.Background(), account1, "domain1.test", "setup-key-1", types.ServiceID("proxy-1")) + require.NoError(t, err) + + // Add service for account 2. + err = nb.AddPeer(context.Background(), account2, "domain2.test", "setup-key-2", types.ServiceID("proxy-2")) + require.NoError(t, err) + + // Both accounts should have their own clients. + assert.True(t, nb.HasClient(account1), "account1 should have client") + assert.True(t, nb.HasClient(account2), "account2 should have client") + assert.Equal(t, 1, nb.ServiceCount(account1), "account1 service count should be 1") + assert.Equal(t, 1, nb.ServiceCount(account2), "account2 service count should be 1") +} + +func TestNetBird_RemovePeer_KeepsClientWhenServicesRemain(t *testing.T) { + nb := mockNetBird() + accountID := types.AccountID("account-1") + + // Add multiple services. + err := nb.AddPeer(context.Background(), accountID, "domain1.test", "setup-key-1", types.ServiceID("proxy-1")) + require.NoError(t, err) + err = nb.AddPeer(context.Background(), accountID, "domain2.test", "setup-key-1", types.ServiceID("proxy-2")) + require.NoError(t, err) + err = nb.AddPeer(context.Background(), accountID, "domain3.test", "setup-key-1", types.ServiceID("proxy-3")) + require.NoError(t, err) + assert.Equal(t, 3, nb.ServiceCount(accountID)) + + // Remove one service - client should remain. + err = nb.RemovePeer(context.Background(), accountID, "domain1.test") + require.NoError(t, err) + assert.True(t, nb.HasClient(accountID), "client should remain after removing one service") + assert.Equal(t, 2, nb.ServiceCount(accountID), "service count should be 2") + + // Remove another service - client should still remain. + err = nb.RemovePeer(context.Background(), accountID, "domain2.test") + require.NoError(t, err) + assert.True(t, nb.HasClient(accountID), "client should remain after removing second service") + assert.Equal(t, 1, nb.ServiceCount(accountID), "service count should be 1") +} + +func TestNetBird_RemovePeer_RemovesClientWhenLastServiceRemoved(t *testing.T) { + nb := mockNetBird() + accountID := types.AccountID("account-1") + + // Add single service. + err := nb.AddPeer(context.Background(), accountID, "domain1.test", "setup-key-1", types.ServiceID("proxy-1")) + require.NoError(t, err) + assert.True(t, nb.HasClient(accountID)) + + // Remove the only service - client should be removed. + _ = nb.RemovePeer(context.Background(), accountID, "domain1.test") + + // After removing all services, client should be gone. + assert.False(t, nb.HasClient(accountID), "client should be removed after removing last service") + assert.Equal(t, 0, nb.ServiceCount(accountID), "service count should be 0") +} + +func TestNetBird_RemovePeer_NonExistentAccountIsNoop(t *testing.T) { + nb := mockNetBird() + accountID := types.AccountID("nonexistent-account") + + // Removing from non-existent account should not error. + err := nb.RemovePeer(context.Background(), accountID, "domain1.test") + assert.NoError(t, err, "removing from non-existent account should not error") +} + +func TestNetBird_RemovePeer_NonExistentServiceIsNoop(t *testing.T) { + nb := mockNetBird() + accountID := types.AccountID("account-1") + + // Add one service. + err := nb.AddPeer(context.Background(), accountID, "domain1.test", "setup-key-1", types.ServiceID("proxy-1")) + require.NoError(t, err) + + // Remove non-existent service - should not affect existing service. + err = nb.RemovePeer(context.Background(), accountID, "nonexistent.test") + require.NoError(t, err) + + // Original service should still be registered. + assert.True(t, nb.HasClient(accountID)) + assert.Equal(t, 1, nb.ServiceCount(accountID), "original service should remain") +} + +func TestWithAccountID_AndAccountIDFromContext(t *testing.T) { + ctx := context.Background() + accountID := types.AccountID("test-account") + + // Initially no account ID in context. + retrieved := AccountIDFromContext(ctx) + assert.True(t, retrieved == "", "should be empty when not set") + + // Add account ID to context. + ctx = WithAccountID(ctx, accountID) + retrieved = AccountIDFromContext(ctx) + assert.Equal(t, accountID, retrieved, "should retrieve the same account ID") +} + +func TestAccountIDFromContext_ReturnsEmptyForWrongType(t *testing.T) { + // Create context with wrong type for account ID key. + ctx := context.WithValue(context.Background(), accountIDContextKey{}, "wrong-type-string") + + retrieved := AccountIDFromContext(ctx) + assert.True(t, retrieved == "", "should return empty for wrong type") +} + +func TestNetBird_StopAll_StopsAllClients(t *testing.T) { + nb := mockNetBird() + account1 := types.AccountID("account-1") + account2 := types.AccountID("account-2") + account3 := types.AccountID("account-3") + + // Add services for multiple accounts. + err := nb.AddPeer(context.Background(), account1, "domain1.test", "key-1", types.ServiceID("proxy-1")) + require.NoError(t, err) + err = nb.AddPeer(context.Background(), account2, "domain2.test", "key-2", types.ServiceID("proxy-2")) + require.NoError(t, err) + err = nb.AddPeer(context.Background(), account3, "domain3.test", "key-3", types.ServiceID("proxy-3")) + require.NoError(t, err) + + assert.Equal(t, 3, nb.ClientCount(), "should have 3 clients") + + // Stop all clients. + _ = nb.StopAll(context.Background()) + + assert.Equal(t, 0, nb.ClientCount(), "should have 0 clients after StopAll") + assert.False(t, nb.HasClient(account1), "account1 should not have client") + assert.False(t, nb.HasClient(account2), "account2 should not have client") + assert.False(t, nb.HasClient(account3), "account3 should not have client") +} + +func TestNetBird_ClientCount(t *testing.T) { + nb := mockNetBird() + + assert.Equal(t, 0, nb.ClientCount(), "should start with 0 clients") + + // Add clients for different accounts. + err := nb.AddPeer(context.Background(), types.AccountID("account-1"), "domain1.test", "key-1", types.ServiceID("proxy-1")) + require.NoError(t, err) + assert.Equal(t, 1, nb.ClientCount()) + + err = nb.AddPeer(context.Background(), types.AccountID("account-2"), "domain2.test", "key-2", types.ServiceID("proxy-2")) + require.NoError(t, err) + assert.Equal(t, 2, nb.ClientCount()) + + // Adding service to existing account should not increase count. + err = nb.AddPeer(context.Background(), types.AccountID("account-1"), "domain1b.test", "key-1", types.ServiceID("proxy-1b")) + require.NoError(t, err) + assert.Equal(t, 2, nb.ClientCount(), "adding service to existing account should not increase client count") +} + +func TestNetBird_RoundTrip_RequiresAccountIDInContext(t *testing.T) { + nb := mockNetBird() + + // Create a request without account ID in context. + req, err := http.NewRequest("GET", "http://example.com/", nil) + require.NoError(t, err) + + // RoundTrip should fail because no account ID in context. + _, err = nb.RoundTrip(req) //nolint:bodyclose + require.ErrorIs(t, err, ErrNoAccountID) +} + +func TestNetBird_RoundTrip_RequiresExistingClient(t *testing.T) { + nb := mockNetBird() + accountID := types.AccountID("nonexistent-account") + + // Create a request with account ID but no client exists. + req, err := http.NewRequest("GET", "http://example.com/", nil) + require.NoError(t, err) + req = req.WithContext(WithAccountID(req.Context(), accountID)) + + // RoundTrip should fail because no client for this account. + _, err = nb.RoundTrip(req) //nolint:bodyclose // Error case, no response body + assert.Error(t, err) + assert.Contains(t, err.Error(), "no peer connection found for account") +} + +func TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus(t *testing.T) { + notifier := &mockStatusNotifier{} + nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{ + MgmtAddr: "http://invalid.test:9999", + WGPort: 0, + PreSharedKey: "", + }, nil, notifier, &mockMgmtClient{}) + nb.startClient = func(types.AccountID, *embed.Client) {} + accountID := types.AccountID("account-1") + + // Add first service — creates a new client entry. + err := nb.AddPeer(context.Background(), accountID, "domain1.test", "key-1", types.ServiceID("svc-1")) + require.NoError(t, err) + + // Manually mark client as started to simulate background startup completing. + nb.clientsMux.Lock() + nb.clients[accountID].started = true + nb.clientsMux.Unlock() + + // Add second service with an already-cancelled caller context — + // should notify immediately (client is started) AND the notification + // must not inherit the cancelled ctx. + cancelledCtx, cancel := context.WithCancel(context.Background()) + cancel() + err = nb.AddPeer(cancelledCtx, accountID, "domain2.test", "key-1", types.ServiceID("svc-2")) + require.NoError(t, err) + + calls := notifier.calls() + require.Len(t, calls, 1) + assert.Equal(t, accountID, calls[0].accountID) + assert.Equal(t, types.ServiceID("svc-2"), calls[0].serviceID) + assert.True(t, calls[0].connected) + require.NotNil(t, calls[0].ctx, "NotifyStatus must receive a context") + require.NoError(t, calls[0].ctx.Err(), + "already-started NotifyStatus must use a background ctx, not the cancelled caller ctx") +} + +// TestNetBird_IdentityForIP_UnknownAccountReturnsFalse confirms that the +// public lookup short-circuits when no client has been registered for +// the queried account. The auth middleware uses ok=false as a fast deny. +func TestNetBird_IdentityForIP_UnknownAccountReturnsFalse(t *testing.T) { + nb := mockNetBird() + _, _, ok := nb.IdentityForIP("acct-missing", netip.MustParseAddr("100.64.0.10")) + assert.False(t, ok, "unknown account must yield ok=false") +} + +// TestClientEntry_IdentityForIP_NilClientGuard ensures the receiver +// methods stay safe when called on partially-initialized state, which +// can happen briefly during AddPeer setup or test fixtures. +func TestClientEntry_IdentityForIP_NilClientGuard(t *testing.T) { + var e *clientEntry + _, _, ok := e.IdentityForIP(netip.MustParseAddr("100.64.0.10")) + assert.False(t, ok, "nil clientEntry must yield ok=false") + + e = &clientEntry{} + _, _, ok = e.IdentityForIP(netip.MustParseAddr("100.64.0.10")) + assert.False(t, ok, "clientEntry with nil embed.Client must yield ok=false") +} + +// TestClientEntry_IdentityForIP_InvalidIPReturnsFalse covers the input +// guard so callers don't have to repeat the check. +func TestClientEntry_IdentityForIP_InvalidIPReturnsFalse(t *testing.T) { + e := &clientEntry{} + _, _, ok := e.IdentityForIP(netip.Addr{}) + assert.False(t, ok, "invalid IP must yield ok=false") +} + +func TestNetBird_RemovePeer_NotifiesDisconnection(t *testing.T) { + notifier := &mockStatusNotifier{} + nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{ + MgmtAddr: "http://invalid.test:9999", + WGPort: 0, + PreSharedKey: "", + }, nil, notifier, &mockMgmtClient{}) + accountID := types.AccountID("account-1") + + err := nb.AddPeer(context.Background(), accountID, "domain1.test", "key-1", types.ServiceID("svc-1")) + require.NoError(t, err) + err = nb.AddPeer(context.Background(), accountID, "domain2.test", "key-1", types.ServiceID("svc-2")) + require.NoError(t, err) + + // Remove one service — client stays, but disconnection notification fires. + err = nb.RemovePeer(context.Background(), accountID, "domain1.test") + require.NoError(t, err) + assert.True(t, nb.HasClient(accountID)) + + calls := notifier.calls() + require.Len(t, calls, 1) + assert.Equal(t, types.ServiceID("svc-1"), calls[0].serviceID) + assert.False(t, calls[0].connected) +} + +// TestNetBird_RemovePeer_TeardownIsAsync proves the fix for the receive-loop +// stall: RemovePeer must return promptly even when the client teardown blocks, +// because teardown runs off the caller's goroutine. The receive loop calls +// RemovePeer synchronously, so a blocking teardown inline would wedge it. +func TestNetBird_RemovePeer_TeardownIsAsync(t *testing.T) { + nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{ + MgmtAddr: "http://invalid.test:9999", + }, nil, &mockStatusNotifier{}, &mockMgmtClient{}) + + accountID := types.AccountID("acct-async-teardown") + key := DomainServiceKey("svc.example") + + teardownEntered := make(chan struct{}) + releaseTeardown := make(chan struct{}) + nb.SetClientLifecycle(nil, func(types.AccountID, any) { + close(teardownEntered) + <-releaseTeardown + }) + + nb.clientsMux.Lock() + nb.clients[accountID] = &clientEntry{ + services: map[ServiceKey]serviceInfo{key: {serviceID: types.ServiceID("svc-1")}}, + started: true, + inbound: struct{}{}, + } + nb.clientsMux.Unlock() + + done := make(chan error, 1) + go func() { done <- nb.RemovePeer(context.Background(), accountID, key) }() + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("RemovePeer did not return while teardown was blocked — teardown is not async") + } + + select { + case <-teardownEntered: + case <-time.After(2 * time.Second): + t.Fatal("teardown never ran") + } + + close(releaseTeardown) +} + +// TestNetBird_AddPeer_WaitsForTeardown proves the lifecycle lock serialises a +// new client bringup behind an in-flight teardown for the same account, so a +// slow client.Stop can never race a new client.Start for that account. +// +// It targets the handoff race specifically: AddPeer is launched immediately +// after RemovePeer returns, WITHOUT waiting for the teardown goroutine to start. +// This only passes if RemovePeer acquires the lifecycle lock synchronously +// (before returning) and hands it to the teardown goroutine — if the goroutine +// acquired the lock itself, AddPeer could win the lock in this window and start +// a replacement client while the old teardown is still pending. +func TestNetBird_AddPeer_WaitsForTeardown(t *testing.T) { + nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{ + MgmtAddr: "http://invalid.test:9999", + }, nil, &mockStatusNotifier{}, &mockMgmtClient{}) + nb.startClient = func(types.AccountID, *embed.Client) {} + + accountID := types.AccountID("acct-serialize") + key := DomainServiceKey("svc.example") + + addEntered := make(chan struct{}) + releaseTeardown := make(chan struct{}) + nb.SetClientLifecycle(nil, func(types.AccountID, any) { + // Block teardown until released. If AddPeer ever reaches createClientEntry + // (signalled via the mgmt client below) while we hold the lock, the lock + // failed to serialise and the test fails before we release. + <-releaseTeardown + }) + + nb.clientsMux.Lock() + nb.clients[accountID] = &clientEntry{ + services: map[ServiceKey]serviceInfo{key: {serviceID: types.ServiceID("svc-1")}}, + started: true, + inbound: struct{}{}, + } + nb.clientsMux.Unlock() + + // createClientEntry calls CreateProxyPeer; closing addEntered there tells us + // AddPeer got past the lifecycle lock and into client creation. + nb.mgmtClient = &signalMgmtClient{entered: addEntered} + + require.NoError(t, nb.RemovePeer(context.Background(), accountID, key)) + + // Launch AddPeer with NO synchronisation against the teardown goroutine. + addReturned := make(chan struct{}) + go func() { + _ = nb.AddPeer(context.Background(), accountID, DomainServiceKey("svc2.example"), "key-2", types.ServiceID("svc-2")) + close(addReturned) + }() + + select { + case <-addEntered: + t.Fatal("AddPeer entered client creation while teardown held the lifecycle lock — handoff race not closed") + case <-addReturned: + t.Fatal("AddPeer completed while teardown held the lifecycle lock — not serialised") + case <-time.After(300 * time.Millisecond): + } + + close(releaseTeardown) + select { + case <-addReturned: + case <-time.After(2 * time.Second): + t.Fatal("AddPeer never completed after teardown released the lifecycle lock") + } +} + +// TestNotifyClientReady_UsesBackgroundCtx pins the contract that the +// post-Start hooks (readyHandler + statusNotifier.NotifyStatus) run on +// a fresh context.Background() rather than inheriting the AddPeer +// caller's request- or stream-scoped ctx. Without this, a cancelled +// caller ctx could abort the inbound listener bring-up or cause the +// management status notification to fail spuriously and leave the +// account in a half-connected state. +func TestNotifyClientReady_UsesBackgroundCtx(t *testing.T) { + notifier := &mockStatusNotifier{} + nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{ + MgmtAddr: "http://invalid.test:9999", + }, nil, notifier, &mockMgmtClient{}) + + accountID := types.AccountID("acct-async") + // Pre-populate a client entry so notifyClientReady has something + // to mark started + something to enumerate for NotifyStatus. + nb.clientsMux.Lock() + nb.clients[accountID] = &clientEntry{ + services: map[ServiceKey]serviceInfo{ + DomainServiceKey("svc.example"): {serviceID: types.ServiceID("svc-1")}, + }, + } + nb.clientsMux.Unlock() + + var capturedReadyCtx context.Context + nb.SetClientLifecycle( + func(ctx context.Context, _ types.AccountID, _ *embed.Client) any { + capturedReadyCtx = ctx + return nil + }, + nil, + ) + + // Drive the post-Start path directly; a real client.Start would + // need a working management URL. + nb.notifyClientReady(accountID, nil) + + require.NotNil(t, capturedReadyCtx, "readyHandler must have been invoked") + require.NoError(t, capturedReadyCtx.Err(), + "readyHandler must receive a background context, not an inherited cancelled one") + deadline, ok := capturedReadyCtx.Deadline() + assert.False(t, ok, "readyHandler ctx must have no deadline (background); got %v", deadline) + + calls := notifier.calls() + require.Len(t, calls, 1, "NotifyStatus must be invoked once per registered service") + require.NotNil(t, calls[0].ctx, "NotifyStatus must receive a context") + require.NoError(t, calls[0].ctx.Err(), + "NotifyStatus must receive a background context, not an inherited cancelled one") +} diff --git a/proxy/internal/roundtrip/transport.go b/proxy/internal/roundtrip/transport.go new file mode 100644 index 000000000..7c450bbb7 --- /dev/null +++ b/proxy/internal/roundtrip/transport.go @@ -0,0 +1,152 @@ +package roundtrip + +import ( + "os" + "strconv" + "time" + + log "github.com/sirupsen/logrus" +) + +// Environment variable names for tuning the backend HTTP transport. +const ( + EnvMaxIdleConns = "NB_PROXY_MAX_IDLE_CONNS" + EnvMaxIdleConnsPerHost = "NB_PROXY_MAX_IDLE_CONNS_PER_HOST" + EnvMaxConnsPerHost = "NB_PROXY_MAX_CONNS_PER_HOST" + EnvIdleConnTimeout = "NB_PROXY_IDLE_CONN_TIMEOUT" + EnvTLSHandshakeTimeout = "NB_PROXY_TLS_HANDSHAKE_TIMEOUT" + EnvExpectContinueTimeout = "NB_PROXY_EXPECT_CONTINUE_TIMEOUT" + EnvResponseHeaderTimeout = "NB_PROXY_RESPONSE_HEADER_TIMEOUT" + EnvWriteBufferSize = "NB_PROXY_WRITE_BUFFER_SIZE" + EnvReadBufferSize = "NB_PROXY_READ_BUFFER_SIZE" + EnvDisableCompression = "NB_PROXY_DISABLE_COMPRESSION" + EnvMaxInflight = "NB_PROXY_MAX_INFLIGHT" +) + +// transportConfig holds tunable parameters for the per-account HTTP transport. +type transportConfig struct { + maxIdleConns int + maxIdleConnsPerHost int + maxConnsPerHost int + idleConnTimeout time.Duration + tlsHandshakeTimeout time.Duration + expectContinueTimeout time.Duration + responseHeaderTimeout time.Duration + writeBufferSize int + readBufferSize int + disableCompression bool + // maxInflight limits per-backend concurrent requests. 0 means unlimited. + maxInflight int +} + +func defaultTransportConfig() transportConfig { + return transportConfig{ + maxIdleConns: 100, + maxIdleConnsPerHost: 100, + maxConnsPerHost: 0, // unlimited + idleConnTimeout: 90 * time.Second, + tlsHandshakeTimeout: 10 * time.Second, + expectContinueTimeout: 1 * time.Second, + } +} + +func loadTransportConfig(logger *log.Logger) transportConfig { + cfg := defaultTransportConfig() + + if v, ok := envInt(EnvMaxIdleConns, logger); ok { + cfg.maxIdleConns = v + } + if v, ok := envInt(EnvMaxIdleConnsPerHost, logger); ok { + cfg.maxIdleConnsPerHost = v + } + if v, ok := envInt(EnvMaxConnsPerHost, logger); ok { + cfg.maxConnsPerHost = v + } + if v, ok := envDuration(EnvIdleConnTimeout, logger); ok { + cfg.idleConnTimeout = v + } + if v, ok := envDuration(EnvTLSHandshakeTimeout, logger); ok { + cfg.tlsHandshakeTimeout = v + } + if v, ok := envDuration(EnvExpectContinueTimeout, logger); ok { + cfg.expectContinueTimeout = v + } + if v, ok := envDuration(EnvResponseHeaderTimeout, logger); ok { + cfg.responseHeaderTimeout = v + } + if v, ok := envInt(EnvWriteBufferSize, logger); ok { + cfg.writeBufferSize = v + } + if v, ok := envInt(EnvReadBufferSize, logger); ok { + cfg.readBufferSize = v + } + if v, ok := envBool(EnvDisableCompression, logger); ok { + cfg.disableCompression = v + } + if v, ok := envInt(EnvMaxInflight, logger); ok { + cfg.maxInflight = v + } + + logger.WithFields(log.Fields{ + "max_idle_conns": cfg.maxIdleConns, + "max_idle_conns_per_host": cfg.maxIdleConnsPerHost, + "max_conns_per_host": cfg.maxConnsPerHost, + "idle_conn_timeout": cfg.idleConnTimeout, + "tls_handshake_timeout": cfg.tlsHandshakeTimeout, + "expect_continue_timeout": cfg.expectContinueTimeout, + "response_header_timeout": cfg.responseHeaderTimeout, + "write_buffer_size": cfg.writeBufferSize, + "read_buffer_size": cfg.readBufferSize, + "disable_compression": cfg.disableCompression, + "max_inflight": cfg.maxInflight, + }).Debug("backend transport configuration") + + return cfg +} + +func envInt(key string, logger *log.Logger) (int, bool) { + s := os.Getenv(key) + if s == "" { + return 0, false + } + v, err := strconv.Atoi(s) + if err != nil { + logger.Warnf("failed to parse %s=%q as int: %v", key, s, err) + return 0, false + } + if v < 0 { + logger.Warnf("ignoring negative value for %s=%d", key, v) + return 0, false + } + return v, true +} + +func envDuration(key string, logger *log.Logger) (time.Duration, bool) { + s := os.Getenv(key) + if s == "" { + return 0, false + } + v, err := time.ParseDuration(s) + if err != nil { + logger.Warnf("failed to parse %s=%q as duration: %v", key, s, err) + return 0, false + } + if v < 0 { + logger.Warnf("ignoring negative value for %s=%s", key, v) + return 0, false + } + return v, true +} + +func envBool(key string, logger *log.Logger) (bool, bool) { + s := os.Getenv(key) + if s == "" { + return false, false + } + v, err := strconv.ParseBool(s) + if err != nil { + logger.Warnf("failed to parse %s=%q as bool: %v", key, s, err) + return false, false + } + return v, true +} diff --git a/proxy/internal/tcp/accept.go b/proxy/internal/tcp/accept.go new file mode 100644 index 000000000..a63560a9e --- /dev/null +++ b/proxy/internal/tcp/accept.go @@ -0,0 +1,85 @@ +package tcp + +import ( + "context" + "errors" + "net" + "strings" + "time" +) + +// gvisorInvalidEndpointMsg is the canonical text gVisor netstack returns +// when Accept() is called on a listener whose underlying endpoint has +// been destroyed (peer rekey, embedded-client reset, account churn). +// There is no exported sentinel from gvisor.dev/gvisor/pkg/tcpip that +// survives gonet's *net.OpError wrapping in a way errors.Is can match, +// so we fall back to a string check. Stable across the gVisor versions +// netbird pins. +const gvisorInvalidEndpointMsg = "endpoint is in invalid state" + +// IsClosedListenerErr reports whether err signals that an accept loop +// should exit because the underlying listener can no longer serve +// connections. It recognises: +// +// - net.ErrClosed for stdlib listeners (Listener.Close was called). +// - gVisor's "endpoint is in invalid state" for netstack-backed +// listeners whose endpoint was destroyed out from under them +// (typically when a per-account WireGuard netstack is reset without +// also tearing the listener entry down). +// +// Without the gVisor branch an accept loop on a netstack listener spins +// CPU-hot forever after the endpoint dies, because Accept never blocks +// again and the error neither matches net.ErrClosed nor cancels ctx. +func IsClosedListenerErr(err error) bool { + if err == nil { + return false + } + if errors.Is(err, net.ErrClosed) { + return true + } + return strings.Contains(err.Error(), gvisorInvalidEndpointMsg) +} + +// AcceptBackoff implements the exponential backoff used by +// net/http.Server.Serve for transient Accept errors. Without it a loop +// hitting a sticky unknown error burns a full CPU core. The zero value +// is ready to use; call Reset after a successful Accept. +type AcceptBackoff struct { + delay time.Duration +} + +// minAcceptDelay / maxAcceptDelay mirror the stdlib defaults +// (net/http.Server.Serve) and keep us well below 1 log line per second +// per orphaned listener. +const ( + minAcceptDelay = 5 * time.Millisecond + maxAcceptDelay = time.Second +) + +// Backoff waits the next exponential delay (5ms doubling up to 1s) and +// returns true when the wait completed. Returns false if ctx fired +// during the wait — callers should treat that as "exit the loop". +func (b *AcceptBackoff) Backoff(ctx context.Context) bool { + b.advance() + select { + case <-ctx.Done(): + return false + case <-time.After(b.delay): + return true + } +} + +// Reset clears the accumulated delay so the next failure starts at the +// minimum delay again. Call after a successful Accept. +func (b *AcceptBackoff) Reset() { b.delay = 0 } + +func (b *AcceptBackoff) advance() { + if b.delay == 0 { + b.delay = minAcceptDelay + } else { + b.delay *= 2 + } + if b.delay > maxAcceptDelay { + b.delay = maxAcceptDelay + } +} diff --git a/proxy/internal/tcp/accept_test.go b/proxy/internal/tcp/accept_test.go new file mode 100644 index 000000000..b2824d38a --- /dev/null +++ b/proxy/internal/tcp/accept_test.go @@ -0,0 +1,142 @@ +package tcp + +import ( + "context" + "errors" + "fmt" + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIsClosedListenerErr_NetErrClosed verifies the stdlib path: a +// closed *net.Listener returns net.ErrClosed wrapped in *net.OpError, +// and IsClosedListenerErr must unwrap it. +func TestIsClosedListenerErr_NetErrClosed(t *testing.T) { + wrapped := &net.OpError{Op: "accept", Net: "tcp", Err: net.ErrClosed} + assert.True(t, IsClosedListenerErr(wrapped), + "net.OpError wrapping net.ErrClosed must be recognised as closed") +} + +// TestIsClosedListenerErr_GVisorInvalidEndpoint is the load-bearing +// regression guard. A gVisor netstack listener whose endpoint has been +// destroyed returns this exact text. Without recognising it the accept +// loop spins forever and burns a CPU core. +func TestIsClosedListenerErr_GVisorInvalidEndpoint(t *testing.T) { + err := fmt.Errorf("accept tcp 10.10.1.254:80: endpoint is in invalid state") + assert.True(t, IsClosedListenerErr(err), + "gVisor 'endpoint is in invalid state' must be recognised as closed") +} + +// TestIsClosedListenerErr_OtherError confirms we don't over-match — +// transient errors must keep returning false so the backoff path runs. +func TestIsClosedListenerErr_OtherError(t *testing.T) { + cases := []error{ + errors.New("temporary failure"), + errors.New("accept tcp 10.10.1.254:80: too many open files"), + nil, + } + for _, c := range cases { + assert.False(t, IsClosedListenerErr(c), + "unexpected match on %v — must not be treated as closed", c) + } +} + +// TestAcceptBackoff_ProgressionAndCap asserts the doubling schedule: +// 5ms, 10ms, 20ms, 40ms, ... capped at 1s. The test runs against a +// real timer but uses tight bounds so a slow CI machine still passes. +func TestAcceptBackoff_ProgressionAndCap(t *testing.T) { + var b AcceptBackoff + expected := []time.Duration{ + 5 * time.Millisecond, + 10 * time.Millisecond, + 20 * time.Millisecond, + 40 * time.Millisecond, + } + for i, want := range expected { + start := time.Now() + ok := b.Backoff(context.Background()) + elapsed := time.Since(start) + require.True(t, ok, "Backoff %d must complete; ctx is alive", i) + assert.GreaterOrEqual(t, elapsed, want, + "backoff %d (%v) must wait at least the configured delay", i, want) + assert.Less(t, elapsed, want*4, + "backoff %d (%v) must not overshoot by more than 4x — caps misbehaving", i, want) + } + + // Burn enough rounds to reach the cap, then assert subsequent + // rounds stay at exactly maxAcceptDelay (1s) — the timer should + // never exceed it. + for range 6 { + b.Backoff(context.Background()) + } + assert.Equal(t, maxAcceptDelay, b.delay, + "after enough doublings the delay must clamp to maxAcceptDelay") +} + +// TestAcceptBackoff_Reset confirms that a successful Accept resets the +// schedule — a busy-then-quiet listener mustn't stay on a 1s timer +// after recovery. +func TestAcceptBackoff_Reset(t *testing.T) { + var b AcceptBackoff + for range 5 { + b.Backoff(context.Background()) + } + require.NotEqual(t, time.Duration(0), b.delay, "precondition: delay must have accumulated") + + b.Reset() + assert.Equal(t, time.Duration(0), b.delay, "Reset must zero the delay") + + start := time.Now() + ok := b.Backoff(context.Background()) + elapsed := time.Since(start) + require.True(t, ok, "Backoff after Reset must complete") + assert.GreaterOrEqual(t, elapsed, minAcceptDelay, + "after Reset the next backoff must restart at minAcceptDelay") + assert.Less(t, elapsed, 50*time.Millisecond, + "after Reset the next backoff must NOT carry over the prior delay") +} + +// TestAcceptBackoff_CancelDuringWait proves the loop exits promptly +// when ctx fires mid-wait. Without this, a tear-down would still take +// up to 1 second per orphaned listener. +func TestAcceptBackoff_CancelDuringWait(t *testing.T) { + var b AcceptBackoff + // Drive the backoff up so the next call will wait ~1s — long + // enough that we can detect early cancellation. + for range 10 { + b.Backoff(context.Background()) + } + require.Equal(t, maxAcceptDelay, b.delay) + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(20 * time.Millisecond) + cancel() + }() + + start := time.Now() + ok := b.Backoff(ctx) + elapsed := time.Since(start) + assert.False(t, ok, "Backoff must return false when ctx is cancelled mid-wait") + assert.Less(t, elapsed, 200*time.Millisecond, + "cancellation must short-circuit the timer; took %v", elapsed) +} + +// TestAcceptBackoff_CancelBeforeCall — when ctx is already done the +// loop exits without sleeping at all. +func TestAcceptBackoff_CancelBeforeCall(t *testing.T) { + var b AcceptBackoff + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + start := time.Now() + ok := b.Backoff(ctx) + elapsed := time.Since(start) + assert.False(t, ok, "Backoff must return false when ctx is already cancelled") + assert.Less(t, elapsed, 50*time.Millisecond, + "already-cancelled ctx must return immediately; took %v", elapsed) +} diff --git a/proxy/internal/tcp/bench_test.go b/proxy/internal/tcp/bench_test.go new file mode 100644 index 000000000..6c0b1eea7 --- /dev/null +++ b/proxy/internal/tcp/bench_test.go @@ -0,0 +1,133 @@ +package tcp + +import ( + "bytes" + "crypto/tls" + "io" + "net" + "testing" +) + +// BenchmarkPeekClientHello_TLS measures the overhead of peeking at a real +// TLS ClientHello and extracting the SNI. This is the per-connection cost +// added to every TLS connection on the main listener. +func BenchmarkPeekClientHello_TLS(b *testing.B) { + // Pre-generate a ClientHello by capturing what crypto/tls sends. + clientConn, serverConn := net.Pipe() + go func() { + tlsConn := tls.Client(clientConn, &tls.Config{ + ServerName: "app.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + _ = tlsConn.Handshake() + }() + + var hello []byte + buf := make([]byte, 16384) + n, _ := serverConn.Read(buf) + hello = make([]byte, n) + copy(hello, buf[:n]) + clientConn.Close() + serverConn.Close() + + b.ResetTimer() + b.ReportAllocs() + + for b.Loop() { + r := bytes.NewReader(hello) + conn := &readerConn{Reader: r} + sni, wrapped, _, err := PeekClientHello(conn) + if err != nil { + b.Fatal(err) + } + if sni != "app.example.com" { + b.Fatalf("unexpected SNI: %q", sni) + } + // Simulate draining the peeked bytes (what the HTTP server would do). + _, _ = io.Copy(io.Discard, wrapped) + } +} + +// BenchmarkPeekClientHello_NonTLS measures peek overhead for non-TLS +// connections that hit the fast non-handshake exit path. +func BenchmarkPeekClientHello_NonTLS(b *testing.B) { + httpReq := []byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n") + + b.ResetTimer() + b.ReportAllocs() + + for b.Loop() { + r := bytes.NewReader(httpReq) + conn := &readerConn{Reader: r} + _, wrapped, _, err := PeekClientHello(conn) + if err != nil { + b.Fatal(err) + } + _, _ = io.Copy(io.Discard, wrapped) + } +} + +// BenchmarkPeekedConn_Read measures the read overhead of the peekedConn +// wrapper compared to a plain connection read. The peeked bytes use +// io.MultiReader which adds one indirection per Read call. +func BenchmarkPeekedConn_Read(b *testing.B) { + data := make([]byte, 4096) + peeked := make([]byte, 512) + buf := make([]byte, 1024) + + b.ResetTimer() + b.ReportAllocs() + + for b.Loop() { + r := bytes.NewReader(data) + conn := &readerConn{Reader: r} + pc := newPeekedConn(conn, peeked) + for { + _, err := pc.Read(buf) + if err != nil { + break + } + } + } +} + +// BenchmarkExtractSNI measures just the in-memory SNI parsing cost, +// excluding I/O. +func BenchmarkExtractSNI(b *testing.B) { + clientConn, serverConn := net.Pipe() + go func() { + tlsConn := tls.Client(clientConn, &tls.Config{ + ServerName: "app.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + _ = tlsConn.Handshake() + }() + + buf := make([]byte, 16384) + n, _ := serverConn.Read(buf) + payload := make([]byte, n-tlsRecordHeaderLen) + copy(payload, buf[tlsRecordHeaderLen:n]) + clientConn.Close() + serverConn.Close() + + b.ResetTimer() + b.ReportAllocs() + + for b.Loop() { + sni := extractSNI(payload) + if sni != "app.example.com" { + b.Fatalf("unexpected SNI: %q", sni) + } + } +} + +// readerConn wraps an io.Reader as a net.Conn for benchmarking. +// Only Read is functional; all other methods are no-ops. +type readerConn struct { + io.Reader + net.Conn +} + +func (c *readerConn) Read(b []byte) (int, error) { + return c.Reader.Read(b) +} diff --git a/proxy/internal/tcp/chanlistener.go b/proxy/internal/tcp/chanlistener.go new file mode 100644 index 000000000..ee64bc0a2 --- /dev/null +++ b/proxy/internal/tcp/chanlistener.go @@ -0,0 +1,76 @@ +package tcp + +import ( + "net" + "sync" +) + +// chanListener implements net.Listener by reading connections from a channel. +// It allows the SNI router to feed HTTP connections to http.Server.ServeTLS. +type chanListener struct { + ch chan net.Conn + addr net.Addr + once sync.Once + closed chan struct{} +} + +func newChanListener(ch chan net.Conn, addr net.Addr) *chanListener { + return &chanListener{ + ch: ch, + addr: addr, + closed: make(chan struct{}), + } +} + +// Accept waits for and returns the next connection from the channel. +func (l *chanListener) Accept() (net.Conn, error) { + for { + select { + case conn, ok := <-l.ch: + if !ok { + return nil, net.ErrClosed + } + return conn, nil + case <-l.closed: + // Drain buffered connections before returning. + for { + select { + case conn, ok := <-l.ch: + if !ok { + return nil, net.ErrClosed + } + _ = conn.Close() + default: + return nil, net.ErrClosed + } + } + } + } +} + +// Close signals the listener to stop accepting connections and drains +// any buffered connections that have not yet been accepted. +func (l *chanListener) Close() error { + l.once.Do(func() { + close(l.closed) + for { + select { + case conn, ok := <-l.ch: + if !ok { + return + } + _ = conn.Close() + default: + return + } + } + }) + return nil +} + +// Addr returns the listener's network address. +func (l *chanListener) Addr() net.Addr { + return l.addr +} + +var _ net.Listener = (*chanListener)(nil) diff --git a/proxy/internal/tcp/peekedconn.go b/proxy/internal/tcp/peekedconn.go new file mode 100644 index 000000000..23a348352 --- /dev/null +++ b/proxy/internal/tcp/peekedconn.go @@ -0,0 +1,45 @@ +package tcp + +import ( + "bytes" + "io" + "net" +) + +// peekedConn wraps a net.Conn and prepends previously peeked bytes +// so that readers see the full original stream transparently. +type peekedConn struct { + net.Conn + reader io.Reader +} + +func newPeekedConn(conn net.Conn, peeked []byte) *peekedConn { + return &peekedConn{ + Conn: conn, + reader: io.MultiReader(bytes.NewReader(peeked), conn), + } +} + +// Read replays the peeked bytes first, then reads from the underlying conn. +func (c *peekedConn) Read(b []byte) (int, error) { + return c.reader.Read(b) +} + +// halfCloser matches connections that support shutting down the write +// side while keeping the read side open (e.g. *net.TCPConn). +type halfCloser interface { + CloseWrite() error +} + +// CloseWrite delegates to the underlying connection if it supports +// half-close (e.g. *net.TCPConn). Without this, embedding net.Conn +// as an interface hides the concrete type's CloseWrite method, making +// half-close a silent no-op for all SNI-routed connections. +func (c *peekedConn) CloseWrite() error { + if hc, ok := c.Conn.(halfCloser); ok { + return hc.CloseWrite() + } + return nil +} + +var _ halfCloser = (*peekedConn)(nil) diff --git a/proxy/internal/tcp/proxyprotocol.go b/proxy/internal/tcp/proxyprotocol.go new file mode 100644 index 000000000..699b75a5d --- /dev/null +++ b/proxy/internal/tcp/proxyprotocol.go @@ -0,0 +1,29 @@ +package tcp + +import ( + "fmt" + "net" + + "github.com/pires/go-proxyproto" +) + +// writeProxyProtoV2 sends a PROXY protocol v2 header to the backend connection, +// conveying the real client address. +func writeProxyProtoV2(client, backend net.Conn) error { + tp := proxyproto.TCPv4 + if addr, ok := client.RemoteAddr().(*net.TCPAddr); ok && addr.IP.To4() == nil { + tp = proxyproto.TCPv6 + } + + header := &proxyproto.Header{ + Version: 2, + Command: proxyproto.PROXY, + TransportProtocol: tp, + SourceAddr: client.RemoteAddr(), + DestinationAddr: client.LocalAddr(), + } + if _, err := header.WriteTo(backend); err != nil { + return fmt.Errorf("write PROXY protocol v2 header: %w", err) + } + return nil +} diff --git a/proxy/internal/tcp/proxyprotocol_test.go b/proxy/internal/tcp/proxyprotocol_test.go new file mode 100644 index 000000000..f8c48b2ab --- /dev/null +++ b/proxy/internal/tcp/proxyprotocol_test.go @@ -0,0 +1,128 @@ +package tcp + +import ( + "bufio" + "net" + "testing" + + "github.com/pires/go-proxyproto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWriteProxyProtoV2_IPv4(t *testing.T) { + // Set up a real TCP listener and dial to get connections with real addresses. + ln, err := net.Listen("tcp4", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + var serverConn net.Conn + accepted := make(chan struct{}) + go func() { + var err error + serverConn, err = ln.Accept() + if err != nil { + t.Error("accept failed:", err) + } + close(accepted) + }() + + clientConn, err := net.Dial("tcp4", ln.Addr().String()) + require.NoError(t, err) + defer clientConn.Close() + + <-accepted + defer serverConn.Close() + + // Use a pipe as the backend: write the header to one end, read from the other. + backendRead, backendWrite := net.Pipe() + defer backendRead.Close() + defer backendWrite.Close() + + // serverConn is the "client" arg: RemoteAddr is the source, LocalAddr is the destination. + writeDone := make(chan error, 1) + go func() { + writeDone <- writeProxyProtoV2(serverConn, backendWrite) + }() + + // Read the PROXY protocol header from the backend read side. + header, err := proxyproto.Read(bufio.NewReader(backendRead)) + require.NoError(t, err) + require.NotNil(t, header, "should have received a proxy protocol header") + + writeErr := <-writeDone + require.NoError(t, writeErr) + + assert.Equal(t, byte(2), header.Version, "version should be 2") + assert.Equal(t, proxyproto.PROXY, header.Command, "command should be PROXY") + assert.Equal(t, proxyproto.TCPv4, header.TransportProtocol, "transport should be TCPv4") + + // serverConn.RemoteAddr() is the client's address (source in the header). + expectedSrc := serverConn.RemoteAddr().(*net.TCPAddr) + actualSrc := header.SourceAddr.(*net.TCPAddr) + assert.Equal(t, expectedSrc.IP.String(), actualSrc.IP.String(), "source IP should match client remote addr") + assert.Equal(t, expectedSrc.Port, actualSrc.Port, "source port should match client remote addr") + + // serverConn.LocalAddr() is the server's address (destination in the header). + expectedDst := serverConn.LocalAddr().(*net.TCPAddr) + actualDst := header.DestinationAddr.(*net.TCPAddr) + assert.Equal(t, expectedDst.IP.String(), actualDst.IP.String(), "destination IP should match server local addr") + assert.Equal(t, expectedDst.Port, actualDst.Port, "destination port should match server local addr") +} + +func TestWriteProxyProtoV2_IPv6(t *testing.T) { + // Set up a real TCP6 listener on loopback. + ln, err := net.Listen("tcp6", "[::1]:0") + if err != nil { + t.Skip("IPv6 not available:", err) + } + defer ln.Close() + + var serverConn net.Conn + accepted := make(chan struct{}) + go func() { + var err error + serverConn, err = ln.Accept() + if err != nil { + t.Error("accept failed:", err) + } + close(accepted) + }() + + clientConn, err := net.Dial("tcp6", ln.Addr().String()) + require.NoError(t, err) + defer clientConn.Close() + + <-accepted + defer serverConn.Close() + + backendRead, backendWrite := net.Pipe() + defer backendRead.Close() + defer backendWrite.Close() + + writeDone := make(chan error, 1) + go func() { + writeDone <- writeProxyProtoV2(serverConn, backendWrite) + }() + + header, err := proxyproto.Read(bufio.NewReader(backendRead)) + require.NoError(t, err) + require.NotNil(t, header, "should have received a proxy protocol header") + + writeErr := <-writeDone + require.NoError(t, writeErr) + + assert.Equal(t, byte(2), header.Version, "version should be 2") + assert.Equal(t, proxyproto.PROXY, header.Command, "command should be PROXY") + assert.Equal(t, proxyproto.TCPv6, header.TransportProtocol, "transport should be TCPv6") + + expectedSrc := serverConn.RemoteAddr().(*net.TCPAddr) + actualSrc := header.SourceAddr.(*net.TCPAddr) + assert.Equal(t, expectedSrc.IP.String(), actualSrc.IP.String(), "source IP should match client remote addr") + assert.Equal(t, expectedSrc.Port, actualSrc.Port, "source port should match client remote addr") + + expectedDst := serverConn.LocalAddr().(*net.TCPAddr) + actualDst := header.DestinationAddr.(*net.TCPAddr) + assert.Equal(t, expectedDst.IP.String(), actualDst.IP.String(), "destination IP should match server local addr") + assert.Equal(t, expectedDst.Port, actualDst.Port, "destination port should match server local addr") +} diff --git a/proxy/internal/tcp/relay_test.go b/proxy/internal/tcp/relay_test.go new file mode 100644 index 000000000..f83a0d155 --- /dev/null +++ b/proxy/internal/tcp/relay_test.go @@ -0,0 +1,215 @@ +package tcp + +import ( + "context" + "fmt" + "io" + "net" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/netutil" + "github.com/netbirdio/netbird/util/netrelay" +) + +func testRelay(ctx context.Context, logger *log.Entry, src, dst net.Conn, idleTimeout time.Duration) (int64, int64) { + return netrelay.Relay(ctx, src, dst, netrelay.Options{IdleTimeout: idleTimeout, Logger: logger}) +} + +func TestRelay_BidirectionalCopy(t *testing.T) { + srcClient, srcServer := net.Pipe() + dstClient, dstServer := net.Pipe() + + logger := log.NewEntry(log.StandardLogger()) + ctx := context.Background() + + srcData := []byte("hello from src") + dstData := []byte("hello from dst") + + // dst side: write response first, then read + close. + go func() { + _, _ = dstClient.Write(dstData) + buf := make([]byte, 256) + _, _ = dstClient.Read(buf) + dstClient.Close() + }() + + // src side: read the response, then send data + close. + go func() { + buf := make([]byte, 256) + _, _ = srcClient.Read(buf) + _, _ = srcClient.Write(srcData) + srcClient.Close() + }() + + s2d, d2s := testRelay(ctx, logger, srcServer, dstServer, 0) + + assert.Equal(t, int64(len(srcData)), s2d, "bytes src→dst") + assert.Equal(t, int64(len(dstData)), d2s, "bytes dst→src") +} + +func TestRelay_ContextCancellation(t *testing.T) { + srcClient, srcServer := net.Pipe() + dstClient, dstServer := net.Pipe() + defer srcClient.Close() + defer dstClient.Close() + + logger := log.NewEntry(log.StandardLogger()) + ctx, cancel := context.WithCancel(context.Background()) + + done := make(chan struct{}) + go func() { + testRelay(ctx, logger, srcServer, dstServer, 0) + close(done) + }() + + // Cancel should cause Relay to return. + cancel() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Relay did not return after context cancellation") + } +} + +func TestRelay_OneSideClosed(t *testing.T) { + srcClient, srcServer := net.Pipe() + dstClient, dstServer := net.Pipe() + defer dstClient.Close() + + logger := log.NewEntry(log.StandardLogger()) + ctx := context.Background() + + // Close src immediately. Relay should complete without hanging. + srcClient.Close() + + done := make(chan struct{}) + go func() { + testRelay(ctx, logger, srcServer, dstServer, 0) + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Relay did not return after one side closed") + } +} + +func TestRelay_LargeTransfer(t *testing.T) { + srcClient, srcServer := net.Pipe() + dstClient, dstServer := net.Pipe() + + logger := log.NewEntry(log.StandardLogger()) + ctx := context.Background() + + // 1MB of data. + data := make([]byte, 1<<20) + for i := range data { + data[i] = byte(i % 256) + } + + go func() { + _, _ = srcClient.Write(data) + srcClient.Close() + }() + + errCh := make(chan error, 1) + go func() { + received, err := io.ReadAll(dstClient) + if err != nil { + errCh <- err + return + } + if len(received) != len(data) { + errCh <- fmt.Errorf("expected %d bytes, got %d", len(data), len(received)) + return + } + errCh <- nil + dstClient.Close() + }() + + s2d, _ := testRelay(ctx, logger, srcServer, dstServer, 0) + assert.Equal(t, int64(len(data)), s2d, "should transfer all bytes") + require.NoError(t, <-errCh) +} + +func TestRelay_IdleTimeout(t *testing.T) { + // Use real TCP connections so SetReadDeadline works (net.Pipe + // does not support deadlines). + srcLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer srcLn.Close() + + dstLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer dstLn.Close() + + srcClient, err := net.Dial("tcp", srcLn.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer srcClient.Close() + + srcServer, err := srcLn.Accept() + if err != nil { + t.Fatal(err) + } + + dstClient, err := net.Dial("tcp", dstLn.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer dstClient.Close() + + dstServer, err := dstLn.Accept() + if err != nil { + t.Fatal(err) + } + + logger := log.NewEntry(log.StandardLogger()) + ctx := context.Background() + + // Send initial data to prove the relay works. + go func() { + _, _ = srcClient.Write([]byte("ping")) + }() + + done := make(chan struct{}) + var s2d, d2s int64 + go func() { + s2d, d2s = testRelay(ctx, logger, srcServer, dstServer, 200*time.Millisecond) + close(done) + }() + + // Read the forwarded data on the dst side. + buf := make([]byte, 64) + n, err := dstClient.Read(buf) + assert.NoError(t, err) + assert.Equal(t, "ping", string(buf[:n])) + + // Now stop sending. The relay should close after the idle timeout. + select { + case <-done: + assert.Greater(t, s2d, int64(0), "should have transferred initial data") + _ = d2s + case <-time.After(5 * time.Second): + t.Fatal("Relay did not exit after idle timeout") + } +} + +func TestIsExpectedError(t *testing.T) { + assert.True(t, netutil.IsExpectedError(net.ErrClosed)) + assert.True(t, netutil.IsExpectedError(context.Canceled)) + assert.True(t, netutil.IsExpectedError(io.EOF)) + assert.False(t, netutil.IsExpectedError(io.ErrUnexpectedEOF)) +} diff --git a/proxy/internal/tcp/router.go b/proxy/internal/tcp/router.go new file mode 100644 index 000000000..307f2b4f3 --- /dev/null +++ b/proxy/internal/tcp/router.go @@ -0,0 +1,758 @@ +package tcp + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "slices" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/internal/accesslog" + "github.com/netbirdio/netbird/proxy/internal/restrict" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/util/netrelay" +) + +// defaultDialTimeout is the fallback dial timeout when no per-route +// timeout is configured. +const defaultDialTimeout = 30 * time.Second + +// errAccessRestricted is returned by relayTCP for access restriction +// denials so callers can skip warn-level logging (already logged at debug). +var errAccessRestricted = errors.New("rejected by access restrictions") + +// SNIHost is a typed key for SNI hostname lookups. +type SNIHost string + +// RouteType specifies how a connection should be handled. +type RouteType int + +const ( + // RouteHTTP routes the connection through the HTTP reverse proxy. + RouteHTTP RouteType = iota + // RouteTCP relays the connection directly to the backend (TLS passthrough). + RouteTCP +) + +const ( + // sniPeekTimeout is the deadline for reading the TLS ClientHello. + sniPeekTimeout = 5 * time.Second + // DefaultDrainTimeout is the default grace period for in-flight relay + // connections to finish during shutdown. + DefaultDrainTimeout = 30 * time.Second + // DefaultMaxRelayConns is the default cap on concurrent TCP relay connections per router. + DefaultMaxRelayConns = 4096 + // httpChannelBuffer is the capacity of the channel feeding HTTP connections. + httpChannelBuffer = 4096 +) + +// DialResolver returns a DialContextFunc for the given account. +type DialResolver func(accountID types.AccountID) (types.DialContextFunc, error) + +// Route describes where a connection for a given SNI should be sent. +type Route struct { + Type RouteType + AccountID types.AccountID + ServiceID types.ServiceID + // Domain is the service's configured domain, used for access log entries. + Domain string + // Protocol is the frontend protocol (tcp, tls), used for access log entries. + Protocol accesslog.Protocol + // Target is the backend address for TCP relay (e.g. "10.0.0.5:5432"). + Target string + // ProxyProtocol enables sending a PROXY protocol v2 header to the backend. + ProxyProtocol bool + // DialTimeout overrides the default dial timeout for this route. + // Zero uses defaultDialTimeout. + DialTimeout time.Duration + // SessionIdleTimeout overrides the default idle timeout for relay connections. + // Zero uses DefaultIdleTimeout. + SessionIdleTimeout time.Duration + // Filter holds connection-level IP/geo restrictions. Nil means no restrictions. + Filter *restrict.Filter +} + +// l4Logger sends layer-4 access log entries to the management server. +type l4Logger interface { + LogL4(entry accesslog.L4Entry) +} + +// RelayObserver receives callbacks for TCP relay lifecycle events. +// All methods must be safe for concurrent use. +type RelayObserver interface { + TCPRelayStarted(accountID types.AccountID) + TCPRelayEnded(accountID types.AccountID, duration time.Duration, srcToDst, dstToSrc int64) + TCPRelayDialError(accountID types.AccountID) + TCPRelayRejected(accountID types.AccountID) +} + +// Router accepts raw TCP connections on a shared listener, peeks at +// the TLS ClientHello to extract the SNI, and routes the connection +// to either the HTTP reverse proxy or a direct TCP relay. +type Router struct { + logger *log.Logger + // httpCh is immutable after construction: set only in NewRouter, nil in NewPortRouter. + httpCh chan net.Conn + httpListener *chanListener + // httpPlainCh feeds non-TLS HTTP connections to a parallel http.Server. + // Set only when NewRouter is called with WithPlainHTTP option (used by + // per-account inbound listeners that accept both :80 and :443 traffic). + // Nil for the host SNI router and for port routers. + httpPlainCh chan net.Conn + httpPlainListener *chanListener + mu sync.RWMutex + routes map[SNIHost][]Route + fallback *Route + draining bool + dialResolve DialResolver + activeConns sync.WaitGroup + activeRelays sync.WaitGroup + relaySem chan struct{} + drainDone chan struct{} + observer RelayObserver + accessLog l4Logger + geo restrict.GeoResolver + // svcCtxs tracks a context per service ID. All relay goroutines for a + // service derive from its context; canceling it kills them immediately. + svcCtxs map[types.ServiceID]context.Context + svcCancels map[types.ServiceID]context.CancelFunc +} + +// RouterOption customises Router construction. +type RouterOption func(*Router) + +// WithPlainHTTP enables a parallel plain-HTTP channel on the router. When +// set, connections whose first byte is not a TLS handshake are forwarded +// to the plain channel returned by HTTPListenerPlain instead of the TLS +// channel. Used by per-account inbound listeners that share both :80 and +// :443 traffic on the same router. +func WithPlainHTTP(addr net.Addr) RouterOption { + return func(r *Router) { + ch := make(chan net.Conn, httpChannelBuffer) + r.httpPlainCh = ch + r.httpPlainListener = newChanListener(ch, addr) + } +} + +// NewRouter creates a new SNI-based connection router. +func NewRouter(logger *log.Logger, dialResolve DialResolver, addr net.Addr, opts ...RouterOption) *Router { + httpCh := make(chan net.Conn, httpChannelBuffer) + r := &Router{ + logger: logger, + httpCh: httpCh, + httpListener: newChanListener(httpCh, addr), + routes: make(map[SNIHost][]Route), + dialResolve: dialResolve, + relaySem: make(chan struct{}, DefaultMaxRelayConns), + svcCtxs: make(map[types.ServiceID]context.Context), + svcCancels: make(map[types.ServiceID]context.CancelFunc), + } + for _, opt := range opts { + opt(r) + } + return r +} + +// NewPortRouter creates a Router for a dedicated port without an HTTP +// channel. Connections that don't match any SNI route fall through to +// the fallback relay (if set) or are closed. +func NewPortRouter(logger *log.Logger, dialResolve DialResolver) *Router { + return &Router{ + logger: logger, + routes: make(map[SNIHost][]Route), + dialResolve: dialResolve, + relaySem: make(chan struct{}, DefaultMaxRelayConns), + svcCtxs: make(map[types.ServiceID]context.Context), + svcCancels: make(map[types.ServiceID]context.CancelFunc), + } +} + +// HTTPListener returns a net.Listener that yields connections routed +// to the HTTP handler. Use this with http.Server.ServeTLS. +func (r *Router) HTTPListener() net.Listener { + return r.httpListener +} + +// HTTPListenerPlain returns a net.Listener yielding non-TLS connections +// for use with a parallel plain http.Server. Returns nil when the router +// was not constructed with WithPlainHTTP. +func (r *Router) HTTPListenerPlain() net.Listener { + if r.httpPlainListener == nil { + return nil + } + return r.httpPlainListener +} + +// AddRoute registers an SNI route. Multiple routes for the same host are +// stored and resolved by priority at lookup time (HTTP > TCP). +// Empty host is ignored to prevent conflicts with ECH/ESNI fallback. +func (r *Router) AddRoute(host SNIHost, route Route) { + host = SNIHost(strings.ToLower(string(host))) + if host == "" { + return + } + + r.mu.Lock() + defer r.mu.Unlock() + + routes := r.routes[host] + for i, existing := range routes { + if existing.ServiceID == route.ServiceID { + r.cancelServiceLocked(route.ServiceID) + routes[i] = route + return + } + } + r.routes[host] = append(routes, route) +} + +// RemoveRoute removes the route for the given host and service ID. +// Active relay connections for the service are closed immediately. +// If other routes remain for the host, they are preserved. +func (r *Router) RemoveRoute(host SNIHost, svcID types.ServiceID) { + host = SNIHost(strings.ToLower(string(host))) + + r.mu.Lock() + defer r.mu.Unlock() + + r.routes[host] = slices.DeleteFunc(r.routes[host], func(route Route) bool { + return route.ServiceID == svcID + }) + if len(r.routes[host]) == 0 { + delete(r.routes, host) + } + r.cancelServiceLocked(svcID) +} + +// SetFallback registers a catch-all route for connections that don't +// match any SNI route. On a port router this handles plain TCP relay; +// on the main router it takes priority over the HTTP channel. +func (r *Router) SetFallback(route Route) { + r.mu.Lock() + defer r.mu.Unlock() + r.fallback = &route +} + +// RemoveFallback clears the catch-all fallback route and closes any +// active relay connections for the given service. +func (r *Router) RemoveFallback(svcID types.ServiceID) { + r.mu.Lock() + defer r.mu.Unlock() + r.fallback = nil + r.cancelServiceLocked(svcID) +} + +// SetObserver sets the relay lifecycle observer. Must be called before Serve. +func (r *Router) SetObserver(obs RelayObserver) { + r.mu.Lock() + defer r.mu.Unlock() + r.observer = obs +} + +// SetAccessLogger sets the L4 access logger. Must be called before Serve. +func (r *Router) SetAccessLogger(l l4Logger) { + r.mu.Lock() + defer r.mu.Unlock() + r.accessLog = l +} + +// getObserver returns the current relay observer under the read lock. +func (r *Router) getObserver() RelayObserver { + r.mu.RLock() + defer r.mu.RUnlock() + return r.observer +} + +// IsEmpty returns true when the router has no SNI routes and no fallback. +func (r *Router) IsEmpty() bool { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.routes) == 0 && r.fallback == nil +} + +// Serve accepts connections from ln and routes them based on SNI. +// It blocks until ctx is canceled or ln is closed, then drains +// active relay connections up to DefaultDrainTimeout. +func (r *Router) Serve(ctx context.Context, ln net.Listener) error { + done := make(chan struct{}) + defer close(done) + + go func() { + select { + case <-ctx.Done(): + _ = ln.Close() + if r.httpListener != nil { + r.httpListener.Close() + } + if r.httpPlainListener != nil { + r.httpPlainListener.Close() + } + case <-done: + } + }() + + var backoff AcceptBackoff + for { + conn, err := ln.Accept() + if err != nil { + if ctx.Err() != nil || IsClosedListenerErr(err) { + if ok := r.Drain(DefaultDrainTimeout); !ok { + r.logger.Warn("timed out waiting for connections to drain") + } + return nil + } + r.logger.Debugf("SNI router accept: %v; backing off", err) + if !backoff.Backoff(ctx) { + // Cancelled during backoff: still drain in-flight + // connections/relays before returning, matching the + // shutdown path above. + if ok := r.Drain(DefaultDrainTimeout); !ok { + r.logger.Warn("timed out waiting for connections to drain") + } + return nil + } + continue + } + backoff.Reset() + r.logger.Debugf("SNI router accepted conn from %s on %s", conn.RemoteAddr(), conn.LocalAddr()) + r.activeConns.Add(1) + go func() { + defer r.activeConns.Done() + r.handleConn(ctx, conn) + }() + } +} + +// HandleConn lets external accept loops feed a connection through the +// router's peek-and-dispatch logic. Use this when the same router serves +// a secondary listener (for example, a per-account inbound :80 socket +// alongside its :443 socket). +func (r *Router) HandleConn(ctx context.Context, conn net.Conn) { + r.activeConns.Add(1) + defer r.activeConns.Done() + r.handleConn(ctx, conn) +} + +// handleConn peeks at the TLS ClientHello and routes the connection. +func (r *Router) handleConn(ctx context.Context, conn net.Conn) { + // Fast path: when no SNI routes and no HTTP channel exist (pure TCP + // fallback port), skip the TLS peek entirely to avoid read errors on + // non-TLS connections and reduce latency. + if r.isFallbackOnly() { + r.logger.Debugf("SNI router fallback-only mode for conn from %s; skipping ClientHello peek", conn.RemoteAddr()) + r.handleUnmatched(ctx, conn, false) + return + } + + if err := conn.SetReadDeadline(time.Now().Add(sniPeekTimeout)); err != nil { + r.logger.Debugf("set SNI peek deadline: %v", err) + _ = conn.Close() + return + } + + sni, wrapped, isTLS, err := PeekClientHello(conn) + if err != nil { + r.logger.Debugf("SNI peek failed for conn from %s: %v", conn.RemoteAddr(), err) + if wrapped != nil { + r.handleUnmatched(ctx, wrapped, isTLS) + } else { + _ = conn.Close() + } + return + } + + if err := wrapped.SetReadDeadline(time.Time{}); err != nil { + r.logger.Debugf("clear SNI peek deadline: %v", err) + _ = wrapped.Close() + return + } + + host := SNIHost(strings.ToLower(sni)) + route, ok := r.lookupRoute(host) + r.logger.WithFields(log.Fields{ + "remote": wrapped.RemoteAddr().String(), + "sni": string(host), + "match": ok, + "tls": isTLS, + }).Debug("SNI route lookup") + if !ok { + r.handleUnmatched(ctx, wrapped, isTLS) + return + } + + if route.Type == RouteHTTP { + r.logger.Debugf("SNI %q routed to HTTP handler (service_id=%s)", host, route.ServiceID) + r.sendToHTTP(wrapped, isTLS) + return + } + + if err := r.relayTCP(ctx, wrapped, host, route); err != nil { + if !errors.Is(err, errAccessRestricted) { + r.logger.WithFields(log.Fields{ + "sni": host, + "service_id": route.ServiceID, + "target": route.Target, + }).Warnf("TCP relay: %v", err) + } + _ = wrapped.Close() + } +} + +// isFallbackOnly returns true when the router has no SNI routes and no HTTP +// channel, meaning all connections should go directly to the fallback relay. +func (r *Router) isFallbackOnly() bool { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.routes) == 0 && r.httpCh == nil +} + +// handleUnmatched routes a connection that didn't match any SNI route. +// This includes ECH/ESNI connections where the cleartext SNI is empty, +// and plain (non-TLS) HTTP connections when isTLS is false. +// It tries the fallback relay first, then the HTTP channel, and closes +// the connection if neither is available. +func (r *Router) handleUnmatched(ctx context.Context, conn net.Conn, isTLS bool) { + r.mu.RLock() + fb := r.fallback + r.mu.RUnlock() + + if fb != nil { + r.logger.Debugf("unmatched conn from %s relayed to TCP fallback (service_id=%s, target=%s)", conn.RemoteAddr(), fb.ServiceID, fb.Target) + if err := r.relayTCP(ctx, conn, SNIHost("fallback"), *fb); err != nil { + if !errors.Is(err, errAccessRestricted) { + r.logger.WithFields(log.Fields{ + "service_id": fb.ServiceID, + "target": fb.Target, + }).Warnf("TCP relay (fallback): %v", err) + } + _ = conn.Close() + } + return + } + r.logger.Debugf("unmatched conn from %s sent to HTTP channel (no TCP fallback configured)", conn.RemoteAddr()) + r.sendToHTTP(conn, isTLS) +} + +// lookupRoute returns the highest-priority route for the given SNI host. +// HTTP routes take precedence over TCP routes. +func (r *Router) lookupRoute(host SNIHost) (Route, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + routes, ok := r.routes[host] + if !ok || len(routes) == 0 { + return Route{}, false + } + best := routes[0] + for _, route := range routes[1:] { + if route.Type < best.Type { + best = route + } + } + return best, true +} + +// sendToHTTP feeds the connection to the HTTP handler via the channel. +// When isTLS is false and a plain channel is configured the connection +// is forwarded to the plain channel; otherwise it lands on the TLS +// channel. If no usable channel exists, the router is draining, or the +// channel is full, the connection is closed. +func (r *Router) sendToHTTP(conn net.Conn, isTLS bool) { + ch := r.httpCh + chanName := "HTTP" + if !isTLS && r.httpPlainCh != nil { + ch = r.httpPlainCh + chanName = "HTTP-plain" + } + + if ch == nil { + r.logger.Debugf("%s channel nil; dropping conn from %s", chanName, conn.RemoteAddr()) + _ = conn.Close() + return + } + + r.mu.RLock() + draining := r.draining + r.mu.RUnlock() + + if draining { + r.logger.Debugf("router draining; dropping conn from %s", conn.RemoteAddr()) + _ = conn.Close() + return + } + + select { + case ch <- conn: + default: + r.logger.Warnf("%s channel full, dropping connection from %s", chanName, conn.RemoteAddr()) + _ = conn.Close() + } +} + +// Drain prevents new relay connections from starting and waits for all +// in-flight connection handlers and active relays to finish, up to the +// given timeout. Returns true if all completed, false on timeout. +func (r *Router) Drain(timeout time.Duration) bool { + r.mu.Lock() + r.draining = true + if r.drainDone == nil { + done := make(chan struct{}) + go func() { + r.activeConns.Wait() + r.activeRelays.Wait() + close(done) + }() + r.drainDone = done + } + done := r.drainDone + r.mu.Unlock() + + select { + case <-done: + return true + case <-time.After(timeout): + return false + } +} + +// cancelServiceLocked cancels and removes the context for the given service, +// closing all its active relay connections. Must be called with mu held. +func (r *Router) cancelServiceLocked(svcID types.ServiceID) { + if cancel, ok := r.svcCancels[svcID]; ok { + cancel() + delete(r.svcCtxs, svcID) + delete(r.svcCancels, svcID) + } +} + +// SetGeo sets the geolocation lookup used for country-based restrictions. +func (r *Router) SetGeo(geo restrict.GeoResolver) { + r.mu.Lock() + defer r.mu.Unlock() + r.geo = geo +} + +// checkRestrictions evaluates the route's access filter against the +// connection's remote address. Returns Allow if the connection is +// permitted, or a deny verdict indicating the reason. +func (r *Router) checkRestrictions(conn net.Conn, route Route) restrict.Verdict { + if route.Filter == nil { + return restrict.Allow + } + + addr, err := addrFromConn(conn) + if err != nil { + r.logger.Debugf("cannot parse client address %s for restriction check, denying", conn.RemoteAddr()) + return restrict.DenyCIDR + } + + r.mu.RLock() + geo := r.geo + r.mu.RUnlock() + + return route.Filter.Check(addr, geo) +} + +// relayTCP sets up and runs a bidirectional TCP relay. +// The caller owns conn and must close it if this method returns an error. +// On success (nil error), both conn and backend are closed by the relay. +func (r *Router) relayTCP(ctx context.Context, conn net.Conn, sni SNIHost, route Route) error { + if verdict := r.checkRestrictions(conn, route); verdict != restrict.Allow { + if route.Filter != nil && route.Filter.IsObserveOnly(verdict) { + r.logger.Debugf("CrowdSec observe: would block %s for %s (%s)", conn.RemoteAddr(), sni, verdict) + r.logL4Deny(route, conn, verdict, true) + } else { + r.logger.Debugf("connection from %s rejected by access restrictions: %s", conn.RemoteAddr(), verdict) + r.logL4Deny(route, conn, verdict, false) + return errAccessRestricted + } + } + + svcCtx, err := r.acquireRelay(ctx, route) + if err != nil { + return err + } + defer func() { + <-r.relaySem + r.activeRelays.Done() + }() + + backend, err := r.dialBackend(svcCtx, route) + if err != nil { + obs := r.getObserver() + if obs != nil { + obs.TCPRelayDialError(route.AccountID) + } + return err + } + + if route.ProxyProtocol { + if err := writeProxyProtoV2(conn, backend); err != nil { + _ = backend.Close() + return fmt.Errorf("write PROXY protocol header: %w", err) + } + } + + obs := r.getObserver() + if obs != nil { + obs.TCPRelayStarted(route.AccountID) + } + + entry := r.logger.WithFields(log.Fields{ + "sni": sni, + "service_id": route.ServiceID, + "target": route.Target, + }) + entry.Debug("TCP relay started") + + idleTimeout := route.SessionIdleTimeout + if idleTimeout <= 0 { + idleTimeout = netrelay.DefaultIdleTimeout + } + + start := time.Now() + s2d, d2s := netrelay.Relay(svcCtx, conn, backend, netrelay.Options{ + IdleTimeout: idleTimeout, + Logger: entry, + }) + elapsed := time.Since(start) + + if obs != nil { + obs.TCPRelayEnded(route.AccountID, elapsed, s2d, d2s) + } + entry.Debugf("TCP relay ended (client→backend: %d bytes, backend→client: %d bytes)", s2d, d2s) + + r.logL4Entry(route, conn, elapsed, s2d, d2s) + return nil +} + +// acquireRelay checks draining state, increments activeRelays, and acquires +// a semaphore slot. Returns the per-service context on success. +// The caller must release the semaphore and call activeRelays.Done() when done. +func (r *Router) acquireRelay(ctx context.Context, route Route) (context.Context, error) { + r.mu.Lock() + if r.draining { + r.mu.Unlock() + return nil, errors.New("router is draining") + } + r.activeRelays.Add(1) + svcCtx := r.getOrCreateServiceCtxLocked(ctx, route.ServiceID) + r.mu.Unlock() + + select { + case r.relaySem <- struct{}{}: + return svcCtx, nil + default: + r.activeRelays.Done() + obs := r.getObserver() + if obs != nil { + obs.TCPRelayRejected(route.AccountID) + } + return nil, errors.New("TCP relay connection limit reached") + } +} + +// dialBackend resolves the dialer for the route's account and dials the backend. +func (r *Router) dialBackend(svcCtx context.Context, route Route) (net.Conn, error) { + dialFn, err := r.dialResolve(route.AccountID) + if err != nil { + return nil, fmt.Errorf("resolve dialer: %w", err) + } + + dialTimeout := route.DialTimeout + if dialTimeout <= 0 { + dialTimeout = defaultDialTimeout + } + dialCtx, dialCancel := context.WithTimeout(svcCtx, dialTimeout) + backend, err := dialFn(dialCtx, "tcp", route.Target) + dialCancel() + if err != nil { + return nil, fmt.Errorf("dial backend %s: %w", route.Target, err) + } + return backend, nil +} + +// logL4Entry sends a TCP relay access log entry if an access logger is configured. +func (r *Router) logL4Entry(route Route, conn net.Conn, duration time.Duration, bytesUp, bytesDown int64) { + r.mu.RLock() + al := r.accessLog + r.mu.RUnlock() + + if al == nil { + return + } + + sourceIP, _ := addrFromConn(conn) + + al.LogL4(accesslog.L4Entry{ + AccountID: route.AccountID, + ServiceID: route.ServiceID, + Protocol: route.Protocol, + Host: route.Domain, + SourceIP: sourceIP, + DurationMs: duration.Milliseconds(), + BytesUpload: bytesUp, + BytesDownload: bytesDown, + }) +} + +// logL4Deny sends an access log entry for a denied connection. +func (r *Router) logL4Deny(route Route, conn net.Conn, verdict restrict.Verdict, observeOnly bool) { + r.mu.RLock() + al := r.accessLog + r.mu.RUnlock() + + if al == nil { + return + } + + sourceIP, _ := addrFromConn(conn) + + entry := accesslog.L4Entry{ + AccountID: route.AccountID, + ServiceID: route.ServiceID, + Protocol: route.Protocol, + Host: route.Domain, + SourceIP: sourceIP, + DenyReason: verdict.String(), + } + if verdict.IsCrowdSec() { + entry.Metadata = map[string]string{"crowdsec_verdict": verdict.String()} + if observeOnly { + entry.Metadata["crowdsec_mode"] = "observe" + entry.DenyReason = "" + } + } + al.LogL4(entry) +} + +// getOrCreateServiceCtxLocked returns the context for a service, creating one +// if it doesn't exist yet. The context is a child of the server context. +// Must be called with mu held. +func (r *Router) getOrCreateServiceCtxLocked(parent context.Context, svcID types.ServiceID) context.Context { + if ctx, ok := r.svcCtxs[svcID]; ok { + return ctx + } + ctx, cancel := context.WithCancel(parent) + r.svcCtxs[svcID] = ctx + r.svcCancels[svcID] = cancel + return ctx +} + +// addrFromConn extracts a netip.Addr from a connection's remote address. +func addrFromConn(conn net.Conn) (netip.Addr, error) { + remote := conn.RemoteAddr() + if remote == nil { + return netip.Addr{}, errors.New("no remote address") + } + ap, err := netip.ParseAddrPort(remote.String()) + if err != nil { + return netip.Addr{}, err + } + return ap.Addr().Unmap(), nil +} diff --git a/proxy/internal/tcp/router_test.go b/proxy/internal/tcp/router_test.go new file mode 100644 index 000000000..849030f9b --- /dev/null +++ b/proxy/internal/tcp/router_test.go @@ -0,0 +1,1966 @@ +package tcp + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "math/big" + "net" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/restrict" + "github.com/netbirdio/netbird/proxy/internal/types" +) + +func TestRouter_HTTPRouting(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + + router := NewRouter(logger, nil, addr) + router.AddRoute("example.com", Route{Type: RouteHTTP}) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + _ = router.Serve(ctx, ln) + }() + + // Dial in a goroutine. The TLS handshake will block since nothing + // completes it on the HTTP side, but we only care about routing. + go func() { + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + if err != nil { + return + } + // Send a TLS ClientHello manually. + tlsConn := tls.Client(conn, &tls.Config{ + ServerName: "example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + _ = tlsConn.Handshake() + tlsConn.Close() + }() + + // Verify the connection was routed to the HTTP channel. + select { + case conn := <-router.httpCh: + assert.NotNil(t, conn) + conn.Close() + case <-time.After(5 * time.Second): + t.Fatal("no connection received on HTTP channel") + } +} + +func TestRouter_TCPRouting(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + + // Set up a TLS backend that the relay will connect to. + backendCert := generateSelfSignedCert(t) + backendLn, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ + Certificates: []tls.Certificate{backendCert}, + }) + require.NoError(t, err) + defer backendLn.Close() + + backendAddr := backendLn.Addr().String() + + // Accept one connection on the backend, echo data back. + backendReady := make(chan struct{}) + go func() { + close(backendReady) + conn, err := backendLn.Accept() + if err != nil { + return + } + defer conn.Close() + buf := make([]byte, 1024) + n, _ := conn.Read(buf) + _, _ = conn.Write(buf[:n]) + }() + <-backendReady + + dialResolve := func(accountID types.AccountID) (types.DialContextFunc, error) { + return func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + }, nil + } + + router := NewRouter(logger, dialResolve, addr) + router.AddRoute("tcp.example.com", Route{ + Type: RouteTCP, + AccountID: "test-account", + ServiceID: "test-service", + Target: backendAddr, + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + _ = router.Serve(ctx, ln) + }() + + // Connect as a TLS client; the proxy should passthrough to the backend. + clientConn, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ + ServerName: "tcp.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + require.NoError(t, err) + defer clientConn.Close() + + testData := []byte("hello through TCP passthrough") + _, err = clientConn.Write(testData) + require.NoError(t, err) + + buf := make([]byte, 1024) + n, err := clientConn.Read(buf) + require.NoError(t, err) + assert.Equal(t, testData, buf[:n], "should receive echoed data through TCP passthrough") +} + +func TestRouter_UnknownSNIGoesToHTTP(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + + router := NewRouter(logger, nil, addr) + // No routes registered. + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + _ = router.Serve(ctx, ln) + }() + + go func() { + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + if err != nil { + return + } + tlsConn := tls.Client(conn, &tls.Config{ + ServerName: "unknown.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + _ = tlsConn.Handshake() + tlsConn.Close() + }() + + select { + case conn := <-router.httpCh: + assert.NotNil(t, conn) + conn.Close() + case <-time.After(5 * time.Second): + t.Fatal("unknown SNI should be routed to HTTP") + } +} + +// TestRouter_NonTLSConnectionDropped verifies that a non-TLS connection +// on the shared port is closed by the router (SNI peek fails to find a +// valid ClientHello, so there is no route match). +func TestRouter_NonTLSConnectionDropped(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + + // Register a TLS passthrough route. Non-TLS should NOT match. + dialResolve := func(accountID types.AccountID) (types.DialContextFunc, error) { + return func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + }, nil + } + + router := NewRouter(logger, dialResolve, addr) + router.AddRoute("tcp.example.com", Route{ + Type: RouteTCP, + AccountID: "test-account", + ServiceID: "test-service", + Target: "127.0.0.1:9999", + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + _ = router.Serve(ctx, ln) + }() + + // Send plain HTTP (non-TLS) data. + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + require.NoError(t, err) + defer conn.Close() + + _, _ = conn.Write([]byte("GET / HTTP/1.1\r\nHost: tcp.example.com\r\n\r\n")) + + // Non-TLS traffic on a port with RouteTCP goes to the HTTP channel + // because there's no valid SNI to match. Verify it reaches HTTP. + select { + case httpConn := <-router.httpCh: + assert.NotNil(t, httpConn, "non-TLS connection should fall through to HTTP") + httpConn.Close() + case <-time.After(5 * time.Second): + t.Fatal("non-TLS connection was not routed to HTTP") + } +} + +// TestRouter_TLSAndHTTPCoexist verifies that a shared port with both HTTP +// and TLS passthrough routes correctly demuxes based on the SNI hostname. +func TestRouter_TLSAndHTTPCoexist(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + + backendCert := generateSelfSignedCert(t) + backendLn, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ + Certificates: []tls.Certificate{backendCert}, + }) + require.NoError(t, err) + defer backendLn.Close() + + // Backend echoes data. + go func() { + conn, err := backendLn.Accept() + if err != nil { + return + } + defer conn.Close() + buf := make([]byte, 1024) + n, _ := conn.Read(buf) + _, _ = conn.Write(buf[:n]) + }() + + dialResolve := func(accountID types.AccountID) (types.DialContextFunc, error) { + return func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + }, nil + } + + router := NewRouter(logger, dialResolve, addr) + // HTTP route. + router.AddRoute("app.example.com", Route{Type: RouteHTTP}) + // TLS passthrough route. + router.AddRoute("tcp.example.com", Route{ + Type: RouteTCP, + AccountID: "test-account", + ServiceID: "test-service", + Target: backendLn.Addr().String(), + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + _ = router.Serve(ctx, ln) + }() + + // 1. TLS connection with SNI "tcp.example.com" → TLS passthrough. + tlsConn, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ + ServerName: "tcp.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + require.NoError(t, err) + + testData := []byte("passthrough data") + _, err = tlsConn.Write(testData) + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := tlsConn.Read(buf) + require.NoError(t, err) + assert.Equal(t, testData, buf[:n], "TLS passthrough should relay data") + tlsConn.Close() + + // 2. TLS connection with SNI "app.example.com" → HTTP handler. + go func() { + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + if err != nil { + return + } + c := tls.Client(conn, &tls.Config{ + ServerName: "app.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + _ = c.Handshake() + c.Close() + }() + + select { + case httpConn := <-router.httpCh: + assert.NotNil(t, httpConn, "HTTP SNI should go to HTTP handler") + httpConn.Close() + case <-time.After(5 * time.Second): + t.Fatal("HTTP-route connection was not delivered to HTTP handler") + } +} + +func TestRouter_AddRemoveRoute(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + router := NewRouter(logger, nil, addr) + + router.AddRoute("a.example.com", Route{Type: RouteHTTP, ServiceID: "svc-a"}) + router.AddRoute("b.example.com", Route{Type: RouteTCP, ServiceID: "svc-b", Target: "10.0.0.1:5432"}) + + route, ok := router.lookupRoute("a.example.com") + assert.True(t, ok) + assert.Equal(t, RouteHTTP, route.Type) + + route, ok = router.lookupRoute("b.example.com") + assert.True(t, ok) + assert.Equal(t, RouteTCP, route.Type) + + router.RemoveRoute("a.example.com", "svc-a") + _, ok = router.lookupRoute("a.example.com") + assert.False(t, ok) +} + +func TestChanListener_AcceptAndClose(t *testing.T) { + ch := make(chan net.Conn, 1) + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + ln := newChanListener(ch, addr) + + assert.Equal(t, addr, ln.Addr()) + + // Send a connection. + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + ch <- serverConn + + conn, err := ln.Accept() + require.NoError(t, err) + assert.Equal(t, serverConn, conn) + + // Close should cause Accept to return error. + require.NoError(t, ln.Close()) + // Double close should be safe. + require.NoError(t, ln.Close()) + + _, err = ln.Accept() + assert.ErrorIs(t, err, net.ErrClosed) +} + +func TestRouter_HTTPPrecedenceGuard(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + router := NewRouter(logger, nil, addr) + + host := SNIHost("app.example.com") + + t.Run("http takes precedence over tcp at lookup", func(t *testing.T) { + router.AddRoute(host, Route{Type: RouteHTTP, ServiceID: "svc-http"}) + router.AddRoute(host, Route{Type: RouteTCP, ServiceID: "svc-tcp", Target: "10.0.0.1:443"}) + + route, ok := router.lookupRoute(host) + require.True(t, ok) + assert.Equal(t, RouteHTTP, route.Type, "HTTP route must take precedence over TCP") + assert.Equal(t, types.ServiceID("svc-http"), route.ServiceID) + + router.RemoveRoute(host, "svc-http") + router.RemoveRoute(host, "svc-tcp") + }) + + t.Run("tcp becomes active when http is removed", func(t *testing.T) { + router.AddRoute(host, Route{Type: RouteHTTP, ServiceID: "svc-http"}) + router.AddRoute(host, Route{Type: RouteTCP, ServiceID: "svc-tcp", Target: "10.0.0.1:443"}) + + router.RemoveRoute(host, "svc-http") + + route, ok := router.lookupRoute(host) + require.True(t, ok) + assert.Equal(t, RouteTCP, route.Type, "TCP should take over after HTTP removal") + assert.Equal(t, types.ServiceID("svc-tcp"), route.ServiceID) + + router.RemoveRoute(host, "svc-tcp") + }) + + t.Run("order of add does not matter", func(t *testing.T) { + router.AddRoute(host, Route{Type: RouteTCP, ServiceID: "svc-tcp", Target: "10.0.0.1:443"}) + router.AddRoute(host, Route{Type: RouteHTTP, ServiceID: "svc-http"}) + + route, ok := router.lookupRoute(host) + require.True(t, ok) + assert.Equal(t, RouteHTTP, route.Type, "HTTP takes precedence regardless of add order") + + router.RemoveRoute(host, "svc-http") + router.RemoveRoute(host, "svc-tcp") + }) + + t.Run("same service id updates in place", func(t *testing.T) { + router.AddRoute(host, Route{Type: RouteTCP, ServiceID: "svc-1", Target: "10.0.0.1:443"}) + router.AddRoute(host, Route{Type: RouteTCP, ServiceID: "svc-1", Target: "10.0.0.2:443"}) + + route, ok := router.lookupRoute(host) + require.True(t, ok) + assert.Equal(t, "10.0.0.2:443", route.Target, "route should be updated in place") + + router.RemoveRoute(host, "svc-1") + _, ok = router.lookupRoute(host) + assert.False(t, ok) + }) + + t.Run("double remove is safe", func(t *testing.T) { + router.AddRoute(host, Route{Type: RouteHTTP, ServiceID: "svc-1"}) + router.RemoveRoute(host, "svc-1") + router.RemoveRoute(host, "svc-1") + + _, ok := router.lookupRoute(host) + assert.False(t, ok, "route should be gone after removal") + }) + + t.Run("remove does not affect other hosts", func(t *testing.T) { + router.AddRoute("a.example.com", Route{Type: RouteHTTP, ServiceID: "svc-a"}) + router.AddRoute("b.example.com", Route{Type: RouteTCP, ServiceID: "svc-b", Target: "10.0.0.2:22"}) + + router.RemoveRoute("a.example.com", "svc-a") + + _, ok := router.lookupRoute(SNIHost("a.example.com")) + assert.False(t, ok) + + route, ok := router.lookupRoute(SNIHost("b.example.com")) + require.True(t, ok) + assert.Equal(t, RouteTCP, route.Type, "removing one host must not affect another") + + router.RemoveRoute("b.example.com", "svc-b") + }) +} + +func TestRouter_SetRemoveFallback(t *testing.T) { + logger := log.StandardLogger() + router := NewPortRouter(logger, nil) + + assert.True(t, router.IsEmpty(), "new port router should be empty") + + router.SetFallback(Route{Type: RouteTCP, ServiceID: "svc-fb", Target: "10.0.0.1:5432"}) + assert.False(t, router.IsEmpty(), "router with fallback should not be empty") + + router.AddRoute("a.example.com", Route{Type: RouteTCP, ServiceID: "svc-a", Target: "10.0.0.2:443"}) + assert.False(t, router.IsEmpty()) + + router.RemoveFallback("svc-fb") + assert.False(t, router.IsEmpty(), "router with SNI route should not be empty") + + router.RemoveRoute("a.example.com", "svc-a") + assert.True(t, router.IsEmpty(), "router with no routes and no fallback should be empty") +} + +func TestPortRouter_FallbackRelaysData(t *testing.T) { + // Backend echo server. + backendLn, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer backendLn.Close() + + go func() { + conn, err := backendLn.Accept() + if err != nil { + return + } + defer conn.Close() + buf := make([]byte, 1024) + n, _ := conn.Read(buf) + _, _ = conn.Write(buf[:n]) + }() + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return func(_ context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + }, nil + } + + logger := log.StandardLogger() + router := NewPortRouter(logger, dialResolve) + router.SetFallback(Route{ + Type: RouteTCP, + AccountID: "test-account", + ServiceID: "test-service", + Target: backendLn.Addr().String(), + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // Plain TCP (non-TLS) connection should be relayed via fallback. + // Use exactly 5 bytes. PeekClientHello reads 5 bytes as the TLS + // header, so a single 5-byte write lands as one chunk at the backend. + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + require.NoError(t, err) + defer conn.Close() + + testData := []byte("hello") + _, err = conn.Write(testData) + require.NoError(t, err) + + buf := make([]byte, 1024) + n, err := conn.Read(buf) + require.NoError(t, err) + assert.Equal(t, testData, buf[:n], "should receive echoed data through fallback relay") +} + +func TestPortRouter_FallbackOnUnknownSNI(t *testing.T) { + // Backend TLS echo server. + backendCert := generateSelfSignedCert(t) + backendLn, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ + Certificates: []tls.Certificate{backendCert}, + }) + require.NoError(t, err) + defer backendLn.Close() + + go func() { + conn, err := backendLn.Accept() + if err != nil { + return + } + defer conn.Close() + buf := make([]byte, 1024) + n, _ := conn.Read(buf) + _, _ = conn.Write(buf[:n]) + }() + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return func(_ context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + }, nil + } + + logger := log.StandardLogger() + router := NewPortRouter(logger, dialResolve) + // Only a fallback, no SNI route for "unknown.example.com". + router.SetFallback(Route{ + Type: RouteTCP, + AccountID: "test-account", + ServiceID: "test-service", + Target: backendLn.Addr().String(), + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // TLS with unknown SNI → fallback relay to TLS backend. + tlsConn, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ + ServerName: "tcp.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + require.NoError(t, err) + defer tlsConn.Close() + + testData := []byte("hello through fallback TLS") + _, err = tlsConn.Write(testData) + require.NoError(t, err) + + buf := make([]byte, 1024) + n, err := tlsConn.Read(buf) + require.NoError(t, err) + assert.Equal(t, testData, buf[:n], "unknown SNI should relay through fallback") +} + +func TestPortRouter_SNIWinsOverFallback(t *testing.T) { + // Two backend echo servers: one for SNI match, one for fallback. + sniBacked := startEchoTLS(t) + fbBacked := startEchoTLS(t) + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return func(_ context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + }, nil + } + + logger := log.StandardLogger() + router := NewPortRouter(logger, dialResolve) + router.AddRoute("tcp.example.com", Route{ + Type: RouteTCP, + AccountID: "test-account", + ServiceID: "sni-service", + Target: sniBacked.Addr().String(), + }) + router.SetFallback(Route{ + Type: RouteTCP, + AccountID: "test-account", + ServiceID: "fb-service", + Target: fbBacked.Addr().String(), + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // TLS with matching SNI should go to SNI backend, not fallback. + tlsConn, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ + ServerName: "tcp.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + require.NoError(t, err) + defer tlsConn.Close() + + testData := []byte("SNI route data") + _, err = tlsConn.Write(testData) + require.NoError(t, err) + + buf := make([]byte, 1024) + n, err := tlsConn.Read(buf) + require.NoError(t, err) + assert.Equal(t, testData, buf[:n], "SNI match should use SNI route, not fallback") +} + +func TestPortRouter_NoFallbackNoHTTP_Closes(t *testing.T) { + logger := log.StandardLogger() + router := NewPortRouter(logger, nil) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + require.NoError(t, err) + defer conn.Close() + + _, _ = conn.Write([]byte("hello")) + + // Connection should be closed by the router (no fallback, no HTTP). + buf := make([]byte, 1) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, err = conn.Read(buf) + assert.Error(t, err, "connection should be closed when no fallback and no HTTP channel") +} + +func TestRouter_FallbackAndHTTPCoexist(t *testing.T) { + // Fallback backend echo server (plain TCP). + fbBackend, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer fbBackend.Close() + + go func() { + conn, err := fbBackend.Accept() + if err != nil { + return + } + defer conn.Close() + buf := make([]byte, 1024) + n, _ := conn.Read(buf) + _, _ = conn.Write(buf[:n]) + }() + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return func(_ context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + }, nil + } + + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + router := NewRouter(logger, dialResolve, addr) + + // HTTP route for known SNI. + router.AddRoute("app.example.com", Route{Type: RouteHTTP}) + // Fallback for non-TLS / unknown SNI. + router.SetFallback(Route{ + Type: RouteTCP, + AccountID: "test-account", + ServiceID: "fb-service", + Target: fbBackend.Addr().String(), + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // 1. TLS with known HTTP SNI → should go to HTTP channel. + go func() { + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + if err != nil { + return + } + c := tls.Client(conn, &tls.Config{ + ServerName: "app.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + _ = c.Handshake() + c.Close() + }() + + select { + case httpConn := <-router.httpCh: + assert.NotNil(t, httpConn, "known HTTP SNI should go to HTTP channel") + httpConn.Close() + case <-time.After(5 * time.Second): + t.Fatal("HTTP-route connection was not delivered to HTTP handler") + } + + // 2. Plain TCP (non-TLS) → should go to fallback, not HTTP. + // Use exactly 5 bytes to match PeekClientHello header size. + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + require.NoError(t, err) + defer conn.Close() + + testData := []byte("plain") + _, err = conn.Write(testData) + require.NoError(t, err) + + buf := make([]byte, 1024) + n, err := conn.Read(buf) + require.NoError(t, err) + assert.Equal(t, testData, buf[:n], "non-TLS should be relayed via fallback, not HTTP") +} + +// startEchoTLS starts a TLS echo server and returns the listener. +func startEchoTLS(t *testing.T) net.Listener { + t.Helper() + + cert := generateSelfSignedCert(t) + ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ + Certificates: []tls.Certificate{cert}, + }) + require.NoError(t, err) + t.Cleanup(func() { ln.Close() }) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + buf := make([]byte, 1024) + for { + n, err := conn.Read(buf) + if err != nil { + return + } + if _, err := conn.Write(buf[:n]); err != nil { + return + } + } + }() + + return ln +} + +func generateSelfSignedCert(t *testing.T) tls.Certificate { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + DNSNames: []string{"tcp.example.com"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + return tls.Certificate{ + Certificate: [][]byte{certDER}, + PrivateKey: key, + } +} + +func TestRouter_DrainWaitsForRelays(t *testing.T) { + logger := log.StandardLogger() + backendLn, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer backendLn.Close() + + // Accept connections: echo first message, then hold open until told to close. + closeBackend := make(chan struct{}) + go func() { + for { + conn, err := backendLn.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + buf := make([]byte, 1024) + n, _ := c.Read(buf) + _, _ = c.Write(buf[:n]) + <-closeBackend + }(conn) + } + }() + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return (&net.Dialer{}).DialContext, nil + } + + router := NewPortRouter(logger, dialResolve) + router.SetFallback(Route{ + Type: RouteTCP, + Target: backendLn.Addr().String(), + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + serveDone := make(chan struct{}) + go func() { + _ = router.Serve(ctx, ln) + close(serveDone) + }() + + // Open a relay connection (non-TLS, hits fallback). + conn, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + _, _ = conn.Write([]byte("hello")) + + // Wait for the echo to confirm the relay is fully established. + buf := make([]byte, 16) + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + n, err := conn.Read(buf) + require.NoError(t, err) + assert.Equal(t, "hello", string(buf[:n])) + _ = conn.SetReadDeadline(time.Time{}) + + // Drain with a short timeout should fail because the relay is still active. + assert.False(t, router.Drain(50*time.Millisecond), "drain should timeout with active relay") + + // Close backend connections so relays finish. + close(closeBackend) + _ = conn.Close() + + // Drain should now complete quickly. + assert.True(t, router.Drain(2*time.Second), "drain should succeed after relays end") + + cancel() + <-serveDone +} + +func TestRouter_DrainEmptyReturnsImmediately(t *testing.T) { + logger := log.StandardLogger() + router := NewPortRouter(logger, nil) + + start := time.Now() + ok := router.Drain(5 * time.Second) + elapsed := time.Since(start) + + assert.True(t, ok) + assert.Less(t, elapsed, 100*time.Millisecond, "drain with no relays should return immediately") +} + +// TestRemoveRoute_KillsActiveRelays verifies that removing a route +// immediately kills active relay connections for that service. +func TestRemoveRoute_KillsActiveRelays(t *testing.T) { + backendLn, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer backendLn.Close() + + // Backend echoes first message, then holds connection open. + go func() { + for { + conn, err := backendLn.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + buf := make([]byte, 1024) + n, _ := c.Read(buf) + _, _ = c.Write(buf[:n]) + // Hold the connection open. + for { + if _, err := c.Read(buf); err != nil { + return + } + } + }(conn) + } + }() + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return (&net.Dialer{}).DialContext, nil + } + + logger := log.StandardLogger() + router := NewPortRouter(logger, dialResolve) + router.SetFallback(Route{ + Type: RouteTCP, + ServiceID: "svc-1", + Target: backendLn.Addr().String(), + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // Establish a relay connection. + conn, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + defer conn.Close() + _, err = conn.Write([]byte("hello")) + require.NoError(t, err) + + // Wait for echo to confirm relay is established. + buf := make([]byte, 16) + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + n, err := conn.Read(buf) + require.NoError(t, err) + assert.Equal(t, "hello", string(buf[:n])) + _ = conn.SetReadDeadline(time.Time{}) + + // Remove the fallback: should kill the active relay. + router.RemoveFallback("svc-1") + + // The client connection should see an error (server closed). + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, err = conn.Read(buf) + assert.Error(t, err, "connection should be killed after service removal") +} + +// TestRemoveRoute_KillsSNIRelays verifies that removing an SNI route +// kills its active relays without affecting other services. +func TestRemoveRoute_KillsSNIRelays(t *testing.T) { + backend := startEchoTLS(t) + defer backend.Close() + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return (&net.Dialer{}).DialContext, nil + } + + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + router := NewRouter(logger, dialResolve, addr) + router.AddRoute("tls.example.com", Route{ + Type: RouteTCP, + ServiceID: "svc-tls", + Target: backend.Addr().String(), + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // Establish a TLS relay. + tlsConn, err := tls.DialWithDialer( + &net.Dialer{Timeout: 2 * time.Second}, + "tcp", ln.Addr().String(), + &tls.Config{ServerName: "tls.example.com", InsecureSkipVerify: true}, + ) + require.NoError(t, err) + defer tlsConn.Close() + + _, err = tlsConn.Write([]byte("ping")) + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := tlsConn.Read(buf) + require.NoError(t, err) + assert.Equal(t, "ping", string(buf[:n])) + + // Remove the route: active relay should die. + router.RemoveRoute("tls.example.com", "svc-tls") + + _ = tlsConn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, err = tlsConn.Read(buf) + assert.Error(t, err, "TLS relay should be killed after route removal") +} + +// TestPortRouter_SNIAndTCPFallbackCoexist verifies that a single port can +// serve both SNI-routed TLS passthrough and plain TCP fallback simultaneously. +func TestPortRouter_SNIAndTCPFallbackCoexist(t *testing.T) { + sniBackend := startEchoTLS(t) + fbBackend := startEchoPlain(t) + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return func(_ context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + }, nil + } + + logger := log.StandardLogger() + router := NewPortRouter(logger, dialResolve) + + // SNI route for a specific domain. + router.AddRoute("tcp.example.com", Route{ + Type: RouteTCP, + AccountID: "acct-1", + ServiceID: "svc-sni", + Target: sniBackend.Addr().String(), + }) + // TCP fallback for everything else. + router.SetFallback(Route{ + Type: RouteTCP, + AccountID: "acct-2", + ServiceID: "svc-fb", + Target: fbBackend.Addr().String(), + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // 1. TLS with matching SNI → goes to SNI backend. + tlsConn, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ + ServerName: "tcp.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + require.NoError(t, err) + + _, err = tlsConn.Write([]byte("sni-data")) + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := tlsConn.Read(buf) + require.NoError(t, err) + assert.Equal(t, "sni-data", string(buf[:n]), "SNI match → SNI backend") + tlsConn.Close() + + // 2. Plain TCP (no TLS) → goes to fallback. + tcpConn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + require.NoError(t, err) + + _, err = tcpConn.Write([]byte("plain")) + require.NoError(t, err) + n, err = tcpConn.Read(buf) + require.NoError(t, err) + assert.Equal(t, "plain", string(buf[:n]), "plain TCP → fallback backend") + tcpConn.Close() + + // 3. TLS with unknown SNI → also goes to fallback. + unknownBackend := startEchoTLS(t) + router.SetFallback(Route{ + Type: RouteTCP, + AccountID: "acct-2", + ServiceID: "svc-fb", + Target: unknownBackend.Addr().String(), + }) + + unknownTLS, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ + ServerName: "unknown.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + require.NoError(t, err) + + _, err = unknownTLS.Write([]byte("unknown-sni")) + require.NoError(t, err) + n, err = unknownTLS.Read(buf) + require.NoError(t, err) + assert.Equal(t, "unknown-sni", string(buf[:n]), "unknown SNI → fallback backend") + unknownTLS.Close() +} + +// TestPortRouter_UpdateRouteSwapsSNI verifies that updating a route +// (remove + add with different target) correctly routes to the new backend. +func TestPortRouter_UpdateRouteSwapsSNI(t *testing.T) { + backend1 := startEchoTLS(t) + backend2 := startEchoTLS(t) + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return func(_ context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + }, nil + } + + logger := log.StandardLogger() + router := NewPortRouter(logger, dialResolve) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // Initial route → backend1. + router.AddRoute("db.example.com", Route{ + Type: RouteTCP, + ServiceID: "svc-db", + Target: backend1.Addr().String(), + }) + + conn1, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ + ServerName: "db.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + require.NoError(t, err) + _, err = conn1.Write([]byte("v1")) + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := conn1.Read(buf) + require.NoError(t, err) + assert.Equal(t, "v1", string(buf[:n])) + conn1.Close() + + // Update: remove old route, add new → backend2. + router.RemoveRoute("db.example.com", "svc-db") + router.AddRoute("db.example.com", Route{ + Type: RouteTCP, + ServiceID: "svc-db", + Target: backend2.Addr().String(), + }) + + conn2, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ + ServerName: "db.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + require.NoError(t, err) + _, err = conn2.Write([]byte("v2")) + require.NoError(t, err) + n, err = conn2.Read(buf) + require.NoError(t, err) + assert.Equal(t, "v2", string(buf[:n])) + conn2.Close() +} + +// TestPortRouter_RemoveSNIFallsThrough verifies that after removing an +// SNI route, connections for that domain fall through to the fallback. +func TestPortRouter_RemoveSNIFallsThrough(t *testing.T) { + sniBackend := startEchoTLS(t) + fbBackend := startEchoTLS(t) + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return func(_ context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + }, nil + } + + logger := log.StandardLogger() + router := NewPortRouter(logger, dialResolve) + router.AddRoute("db.example.com", Route{ + Type: RouteTCP, + ServiceID: "svc-db", + Target: sniBackend.Addr().String(), + }) + router.SetFallback(Route{ + Type: RouteTCP, + Target: fbBackend.Addr().String(), + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // Before removal: SNI matches → sniBackend. + conn1, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ + ServerName: "db.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + require.NoError(t, err) + _, err = conn1.Write([]byte("before")) + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := conn1.Read(buf) + require.NoError(t, err) + assert.Equal(t, "before", string(buf[:n])) + conn1.Close() + + // Remove SNI route. Should fall through to fallback. + router.RemoveRoute("db.example.com", "svc-db") + + conn2, err := tls.Dial("tcp", ln.Addr().String(), &tls.Config{ + ServerName: "db.example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + require.NoError(t, err) + _, err = conn2.Write([]byte("after")) + require.NoError(t, err) + n, err = conn2.Read(buf) + require.NoError(t, err) + assert.Equal(t, "after", string(buf[:n]), "after removal, should reach fallback") + conn2.Close() +} + +// TestPortRouter_RemoveFallbackCloses verifies that after removing the +// fallback, non-matching connections are closed. +func TestPortRouter_RemoveFallbackCloses(t *testing.T) { + fbBackend := startEchoPlain(t) + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return func(_ context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + }, nil + } + + logger := log.StandardLogger() + router := NewPortRouter(logger, dialResolve) + router.SetFallback(Route{ + Type: RouteTCP, + ServiceID: "svc-fb", + Target: fbBackend.Addr().String(), + }) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // With fallback: plain TCP works. + conn1, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + require.NoError(t, err) + _, err = conn1.Write([]byte("hello")) + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := conn1.Read(buf) + require.NoError(t, err) + assert.Equal(t, "hello", string(buf[:n])) + conn1.Close() + + // Remove fallback. + router.RemoveFallback("svc-fb") + + // Without fallback on a port router (no HTTP channel): connection should be closed. + conn2, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + require.NoError(t, err) + defer conn2.Close() + _, _ = conn2.Write([]byte("bye")) + _ = conn2.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, err = conn2.Read(buf) + assert.Error(t, err, "without fallback, connection should be closed") +} + +// TestPortRouter_HTTPToTLSTransition verifies that switching a service from +// HTTP-only to TLS-only via remove+add doesn't orphan the old HTTP route. +func TestPortRouter_HTTPToTLSTransition(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + tlsBackend := startEchoTLS(t) + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return (&net.Dialer{}).DialContext, nil + } + + router := NewRouter(logger, dialResolve, addr) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // Phase 1: HTTP-only. SNI connections go to HTTP channel. + router.AddRoute("app.example.com", Route{Type: RouteHTTP, AccountID: "acct-1", ServiceID: "svc-1"}) + + httpConn := router.HTTPListener() + connDone := make(chan struct{}) + go func() { + defer close(connDone) + c, err := httpConn.Accept() + if err == nil { + c.Close() + } + }() + tlsConn, err := tls.DialWithDialer( + &net.Dialer{Timeout: 2 * time.Second}, + "tcp", ln.Addr().String(), + &tls.Config{ServerName: "app.example.com", InsecureSkipVerify: true}, + ) + if err == nil { + tlsConn.Close() + } + select { + case <-connDone: + case <-time.After(2 * time.Second): + t.Fatal("HTTP listener did not receive connection for HTTP-only route") + } + + // Phase 2: Simulate update to TLS-only (removeMapping + addMapping). + router.RemoveRoute("app.example.com", "svc-1") + router.AddRoute("app.example.com", Route{ + Type: RouteTCP, + AccountID: "acct-1", + ServiceID: "svc-1", + Target: tlsBackend.Addr().String(), + }) + + tlsConn2, err := tls.DialWithDialer( + &net.Dialer{Timeout: 2 * time.Second}, + "tcp", ln.Addr().String(), + &tls.Config{ServerName: "app.example.com", InsecureSkipVerify: true}, + ) + require.NoError(t, err, "TLS connection should succeed after HTTP→TLS transition") + defer tlsConn2.Close() + + _, err = tlsConn2.Write([]byte("hello-tls")) + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := tlsConn2.Read(buf) + require.NoError(t, err) + assert.Equal(t, "hello-tls", string(buf[:n]), "data should relay to TLS backend") +} + +// TestPortRouter_TLSToHTTPTransition verifies that switching a service from +// TLS-only to HTTP-only via remove+add doesn't orphan the old TLS route. +func TestPortRouter_TLSToHTTPTransition(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + tlsBackend := startEchoTLS(t) + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return (&net.Dialer{}).DialContext, nil + } + + router := NewRouter(logger, dialResolve, addr) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // Phase 1: TLS-only. Route relays to backend. + router.AddRoute("app.example.com", Route{ + Type: RouteTCP, + AccountID: "acct-1", + ServiceID: "svc-1", + Target: tlsBackend.Addr().String(), + }) + + tlsConn, err := tls.DialWithDialer( + &net.Dialer{Timeout: 2 * time.Second}, + "tcp", ln.Addr().String(), + &tls.Config{ServerName: "app.example.com", InsecureSkipVerify: true}, + ) + require.NoError(t, err, "TLS relay should work before transition") + _, err = tlsConn.Write([]byte("tls-data")) + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := tlsConn.Read(buf) + require.NoError(t, err) + assert.Equal(t, "tls-data", string(buf[:n])) + tlsConn.Close() + + // Phase 2: Simulate update to HTTP-only (removeMapping + addMapping). + router.RemoveRoute("app.example.com", "svc-1") + router.AddRoute("app.example.com", Route{Type: RouteHTTP, AccountID: "acct-1", ServiceID: "svc-1"}) + + // TLS connection should now go to the HTTP listener, NOT to the old TLS backend. + httpConn := router.HTTPListener() + connDone := make(chan struct{}) + go func() { + defer close(connDone) + c, err := httpConn.Accept() + if err == nil { + c.Close() + } + }() + tlsConn2, err := tls.DialWithDialer( + &net.Dialer{Timeout: 2 * time.Second}, + "tcp", ln.Addr().String(), + &tls.Config{ServerName: "app.example.com", InsecureSkipVerify: true}, + ) + if err == nil { + tlsConn2.Close() + } + select { + case <-connDone: + case <-time.After(2 * time.Second): + t.Fatal("HTTP listener should receive connection after TLS→HTTP transition") + } +} + +// TestPortRouter_MultiDomainSamePort verifies that two TLS services sharing +// the same port router are independently routable and removable. +func TestPortRouter_MultiDomainSamePort(t *testing.T) { + logger := log.StandardLogger() + backend1 := startEchoTLSMulti(t) + backend2 := startEchoTLSMulti(t) + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return (&net.Dialer{}).DialContext, nil + } + + router := NewPortRouter(logger, dialResolve) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + router.AddRoute("svc1.example.com", Route{Type: RouteTCP, AccountID: "acct-1", ServiceID: "svc-1", Target: backend1.Addr().String()}) + router.AddRoute("svc2.example.com", Route{Type: RouteTCP, AccountID: "acct-1", ServiceID: "svc-2", Target: backend2.Addr().String()}) + assert.False(t, router.IsEmpty()) + + // Both domains route independently. + for _, tc := range []struct { + sni string + data string + }{ + {"svc1.example.com", "hello-svc1"}, + {"svc2.example.com", "hello-svc2"}, + } { + conn, err := tls.DialWithDialer( + &net.Dialer{Timeout: 2 * time.Second}, + "tcp", ln.Addr().String(), + &tls.Config{ServerName: tc.sni, InsecureSkipVerify: true}, + ) + require.NoError(t, err, "dial %s", tc.sni) + _, err = conn.Write([]byte(tc.data)) + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := conn.Read(buf) + require.NoError(t, err) + assert.Equal(t, tc.data, string(buf[:n])) + conn.Close() + } + + // Remove svc1. Router should NOT be empty (svc2 still present). + router.RemoveRoute("svc1.example.com", "svc-1") + assert.False(t, router.IsEmpty(), "router should not be empty with one route remaining") + + // svc2 still works. + conn2, err := tls.DialWithDialer( + &net.Dialer{Timeout: 2 * time.Second}, + "tcp", ln.Addr().String(), + &tls.Config{ServerName: "svc2.example.com", InsecureSkipVerify: true}, + ) + require.NoError(t, err) + _, err = conn2.Write([]byte("still-alive")) + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := conn2.Read(buf) + require.NoError(t, err) + assert.Equal(t, "still-alive", string(buf[:n])) + conn2.Close() + + // Remove svc2. Router is now empty. + router.RemoveRoute("svc2.example.com", "svc-2") + assert.True(t, router.IsEmpty(), "router should be empty after removing all routes") +} + +// TestPortRouter_SNIAndFallbackLifecycle verifies the full lifecycle of SNI +// routes and TCP fallback coexisting on the same port router, including the +// ordering of add/remove operations. +func TestPortRouter_SNIAndFallbackLifecycle(t *testing.T) { + logger := log.StandardLogger() + sniBackend := startEchoTLS(t) + fallbackBackend := startEchoPlain(t) + + dialResolve := func(_ types.AccountID) (types.DialContextFunc, error) { + return (&net.Dialer{}).DialContext, nil + } + + router := NewPortRouter(logger, dialResolve) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // Step 1: Add fallback first (port mapping), then SNI route (TLS service). + router.SetFallback(Route{Type: RouteTCP, AccountID: "acct-1", ServiceID: "pm-1", Target: fallbackBackend.Addr().String()}) + router.AddRoute("tls.example.com", Route{Type: RouteTCP, AccountID: "acct-1", ServiceID: "svc-1", Target: sniBackend.Addr().String()}) + assert.False(t, router.IsEmpty()) + + // SNI traffic goes to TLS backend. + tlsConn, err := tls.DialWithDialer( + &net.Dialer{Timeout: 2 * time.Second}, + "tcp", ln.Addr().String(), + &tls.Config{ServerName: "tls.example.com", InsecureSkipVerify: true}, + ) + require.NoError(t, err) + _, err = tlsConn.Write([]byte("sni-traffic")) + require.NoError(t, err) + buf := make([]byte, 1024) + n, err := tlsConn.Read(buf) + require.NoError(t, err) + assert.Equal(t, "sni-traffic", string(buf[:n])) + tlsConn.Close() + + // Plain TCP goes to fallback. + plainConn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + require.NoError(t, err) + _, err = plainConn.Write([]byte("plain")) + require.NoError(t, err) + n, err = plainConn.Read(buf) + require.NoError(t, err) + assert.Equal(t, "plain", string(buf[:n])) + plainConn.Close() + + // Step 2: Remove SNI route. Fallback still works, router not empty. + router.RemoveRoute("tls.example.com", "svc-1") + assert.False(t, router.IsEmpty(), "fallback still present") + + plainConn2, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + require.NoError(t, err) + // Must send >= 5 bytes so the SNI peek completes immediately + // without waiting for the 5-second peek timeout. + _, err = plainConn2.Write([]byte("after")) + require.NoError(t, err) + n, err = plainConn2.Read(buf) + require.NoError(t, err) + assert.Equal(t, "after", string(buf[:n])) + plainConn2.Close() + + // Step 3: Remove fallback. Router is now empty. + router.RemoveFallback("pm-1") + assert.True(t, router.IsEmpty()) +} + +// TestPortRouter_IsEmptyTransitions verifies IsEmpty reflects correct state +// through all add/remove operations. +func TestPortRouter_IsEmptyTransitions(t *testing.T) { + logger := log.StandardLogger() + router := NewPortRouter(logger, nil) + + assert.True(t, router.IsEmpty(), "new router") + + router.AddRoute("a.com", Route{Type: RouteTCP, ServiceID: "svc-a"}) + assert.False(t, router.IsEmpty(), "after adding route") + + router.SetFallback(Route{Type: RouteTCP, ServiceID: "svc-fb1"}) + assert.False(t, router.IsEmpty(), "route + fallback") + + router.RemoveRoute("a.com", "svc-a") + assert.False(t, router.IsEmpty(), "fallback only") + + router.RemoveFallback("svc-fb1") + assert.True(t, router.IsEmpty(), "all removed") + + // Reverse order: fallback first, then route. + router.SetFallback(Route{Type: RouteTCP, ServiceID: "svc-fb2"}) + assert.False(t, router.IsEmpty()) + + router.AddRoute("b.com", Route{Type: RouteTCP, ServiceID: "svc-b"}) + assert.False(t, router.IsEmpty()) + + router.RemoveFallback("svc-fb2") + assert.False(t, router.IsEmpty(), "route still present") + + router.RemoveRoute("b.com", "svc-b") + assert.True(t, router.IsEmpty(), "fully empty again") +} + +// startEchoTLSMulti starts a TLS echo server that accepts multiple connections. +func startEchoTLSMulti(t *testing.T) net.Listener { + t.Helper() + + cert := generateSelfSignedCert(t) + ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ + Certificates: []tls.Certificate{cert}, + }) + require.NoError(t, err) + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + buf := make([]byte, 1024) + n, _ := c.Read(buf) + _, _ = c.Write(buf[:n]) + }(conn) + } + }() + + return ln +} + +// startEchoPlain starts a plain TCP echo server that reads until newline +// or connection close, then echoes the received data. +func startEchoPlain(t *testing.T) net.Listener { + t.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + // Set a read deadline so we don't block forever waiting for more data. + _ = c.SetReadDeadline(time.Now().Add(2 * time.Second)) + buf := make([]byte, 1024) + n, _ := c.Read(buf) + _, _ = c.Write(buf[:n]) + }(conn) + } + }() + + return ln +} + +// fakeAddr implements net.Addr with a custom string representation. +type fakeAddr string + +func (f fakeAddr) Network() string { return "tcp" } +func (f fakeAddr) String() string { return string(f) } + +// fakeConn is a minimal net.Conn with a controllable RemoteAddr. +type fakeConn struct { + net.Conn + remote net.Addr +} + +func (f *fakeConn) RemoteAddr() net.Addr { return f.remote } + +func TestCheckRestrictions_UnparseableAddress(t *testing.T) { + router := NewPortRouter(log.StandardLogger(), nil) + filter := restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}) + route := Route{Filter: filter} + + conn := &fakeConn{remote: fakeAddr("not-an-ip")} + assert.NotEqual(t, restrict.Allow, router.checkRestrictions(conn, route), "unparsable address must be denied") +} + +func TestCheckRestrictions_NilRemoteAddr(t *testing.T) { + router := NewPortRouter(log.StandardLogger(), nil) + filter := restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}) + route := Route{Filter: filter} + + conn := &fakeConn{remote: nil} + assert.NotEqual(t, restrict.Allow, router.checkRestrictions(conn, route), "nil remote address must be denied") +} + +func TestCheckRestrictions_AllowedAndDenied(t *testing.T) { + router := NewPortRouter(log.StandardLogger(), nil) + filter := restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}) + route := Route{Filter: filter} + + allowed := &fakeConn{remote: &net.TCPAddr{IP: net.IPv4(10, 1, 2, 3), Port: 1234}} + assert.Equal(t, restrict.Allow, router.checkRestrictions(allowed, route), "10.1.2.3 in allowlist") + + denied := &fakeConn{remote: &net.TCPAddr{IP: net.IPv4(192, 168, 1, 1), Port: 1234}} + assert.NotEqual(t, restrict.Allow, router.checkRestrictions(denied, route), "192.168.1.1 not in allowlist") +} + +func TestCheckRestrictions_NilFilter(t *testing.T) { + router := NewPortRouter(log.StandardLogger(), nil) + route := Route{Filter: nil} + + conn := &fakeConn{remote: fakeAddr("not-an-ip")} + assert.Equal(t, restrict.Allow, router.checkRestrictions(conn, route), "nil filter should allow everything") +} + +func TestCheckRestrictions_IPv4MappedIPv6(t *testing.T) { + router := NewPortRouter(log.StandardLogger(), nil) + filter := restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}) + route := Route{Filter: filter} + + // net.IPv4() returns a 16-byte v4-in-v6 representation internally. + // The restriction check must Unmap it to match the v4 CIDR. + conn := &fakeConn{remote: &net.TCPAddr{IP: net.IPv4(10, 1, 2, 3), Port: 5678}} + assert.Equal(t, restrict.Allow, router.checkRestrictions(conn, route), "v4-in-v6 TCPAddr must match v4 CIDR") + + // Explicitly v4-mapped-v6 address string. + conn6 := &fakeConn{remote: fakeAddr("[::ffff:10.1.2.3]:5678")} + assert.Equal(t, restrict.Allow, router.checkRestrictions(conn6, route), "::ffff:10.1.2.3 must match v4 CIDR") + + connOutside := &fakeConn{remote: fakeAddr("[::ffff:192.168.1.1]:5678")} + assert.NotEqual(t, restrict.Allow, router.checkRestrictions(connOutside, route), "::ffff:192.168.1.1 not in v4 CIDR") +} + +// TestRouter_PlainHTTP_RoutesToPlainChannel verifies that a plain (non-TLS) +// connection lands on the plain HTTP channel when the router was built +// with WithPlainHTTP, leaving the TLS channel untouched. +func TestRouter_PlainHTTP_RoutesToPlainChannel(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + + router := NewRouter(logger, nil, addr, WithPlainHTTP(addr)) + router.AddRoute("example.com", Route{Type: RouteHTTP}) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err, "test listener bind must succeed") + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + _ = router.Serve(ctx, ln) + }() + + // Plain HTTP request (no TLS handshake byte). + go func() { + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + if err != nil { + return + } + _, _ = conn.Write([]byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")) + }() + + plainListener := router.HTTPListenerPlain() + require.NotNil(t, plainListener, "plain listener must be exposed when WithPlainHTTP is set") + + acceptDone := make(chan net.Conn, 1) + go func() { + conn, err := plainListener.Accept() + if err == nil { + acceptDone <- conn + } + }() + + tlsListener, ok := router.HTTPListener().(*chanListener) + require.True(t, ok, "router.HTTPListener() must be the test's chanListener; the test relies on observing its channel directly") + + select { + case conn := <-acceptDone: + require.NotNil(t, conn) + _ = conn.Close() + case <-tlsListener.ch: + t.Fatal("plain HTTP request leaked into TLS channel") + case <-time.After(3 * time.Second): + t.Fatal("plain HTTP connection never reached plain channel") + } +} + +// TestRouter_TLS_StaysOnTLSChannel_WhenPlainEnabled verifies that the +// presence of a plain channel does not divert TLS traffic — TLS still +// goes to the TLS channel as before. +func TestRouter_TLS_StaysOnTLSChannel_WhenPlainEnabled(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + + router := NewRouter(logger, nil, addr, WithPlainHTTP(addr)) + router.AddRoute("example.com", Route{Type: RouteHTTP}) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err, "test listener bind must succeed") + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { _ = router.Serve(ctx, ln) }() + + // Send a TLS ClientHello. + go func() { + conn, err := net.DialTimeout("tcp", ln.Addr().String(), 2*time.Second) + if err != nil { + return + } + tlsConn := tls.Client(conn, &tls.Config{ + ServerName: "example.com", + InsecureSkipVerify: true, //nolint:gosec + }) + _ = tlsConn.Handshake() + _ = tlsConn.Close() + }() + + select { + case conn := <-router.httpCh: + require.NotNil(t, conn, "TLS conn should land on the TLS channel") + _ = conn.Close() + case <-time.After(5 * time.Second): + t.Fatal("TLS conn never reached the TLS channel") + } +} + +// scriptedAcceptListener is a net.Listener whose Accept() returns +// pre-scripted errors. Used by the accept-loop exit tests to simulate +// the failure mode that triggers the tight-loop bug: a netstack +// listener whose endpoint has been destroyed and now returns the gVisor +// "endpoint is in invalid state" error from every Accept call. +type scriptedAcceptListener struct { + errs chan error + closed chan struct{} +} + +func newScriptedAcceptListener(errs ...error) *scriptedAcceptListener { + s := &scriptedAcceptListener{ + errs: make(chan error, len(errs)+1), + closed: make(chan struct{}), + } + for _, e := range errs { + s.errs <- e + } + return s +} + +func (s *scriptedAcceptListener) Accept() (net.Conn, error) { + select { + case <-s.closed: + return nil, net.ErrClosed + case err := <-s.errs: + return nil, err + } +} + +func (s *scriptedAcceptListener) Close() error { + select { + case <-s.closed: + default: + close(s.closed) + } + return nil +} + +func (s *scriptedAcceptListener) Addr() net.Addr { + return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0} +} + +// TestRouter_Serve_ExitsOnGVisorInvalidEndpoint is the regression guard +// for the tight-loop bug: when the underlying netstack endpoint is +// destroyed, Accept returns "endpoint is in invalid state" forever. The +// loop must recognise that signal and return, otherwise it pegs a CPU +// core and floods logs. +func TestRouter_Serve_ExitsOnGVisorInvalidEndpoint(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + router := NewRouter(logger, nil, addr) + + gvisorErr := &net.OpError{ + Op: "accept", + Net: "tcp", + Addr: addr, + Err: errSentinel("endpoint is in invalid state"), + } + ln := newScriptedAcceptListener(gvisorErr) + defer ln.Close() + + done := make(chan error, 1) + go func() { + done <- router.Serve(context.Background(), ln) + }() + + select { + case err := <-done: + assert.NoError(t, err, "Serve must return cleanly on a recognised closed-listener error") + case <-time.After(2 * time.Second): + t.Fatal("Serve did not exit on gVisor 'endpoint is in invalid state' — accept loop is spinning") + } +} + +// TestRouter_Serve_BacksOffOnTransientError verifies the defence-in- +// depth path: when Accept returns an unknown transient error, the loop +// MUST not spin. It backs off, then exits cleanly once ctx is cancelled. +// "Bounded call count" stands in for "no CPU spin" — without backoff +// the goroutine would issue thousands of Accept calls in this window. +func TestRouter_Serve_BacksOffOnTransientError(t *testing.T) { + logger := log.StandardLogger() + addr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443} + router := NewRouter(logger, nil, addr) + + const transientErrCount = 5 + errs := make([]error, transientErrCount) + for i := range errs { + errs[i] = errSentinel("transient: too many open files") + } + ln := newScriptedAcceptListener(errs...) + defer ln.Close() + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + start := time.Now() + go func() { + done <- router.Serve(ctx, ln) + }() + + // Cancel after enough time for the backoff to climb (5ms + 10ms + + // 20ms + 40ms = 75ms minimum), but short enough that a spinning + // loop would have made thousands of calls by now. + time.AfterFunc(150*time.Millisecond, cancel) + + select { + case err := <-done: + assert.NoError(t, err, "Serve must return cleanly on ctx cancellation") + case <-time.After(2 * time.Second): + t.Fatal("Serve did not exit on ctx cancellation — backoff or exit path broken") + } + + // Without backoff the loop would burn through all 5 scripted errors + // in microseconds and then block on the channel. With backoff the + // total wall time should be at least 5ms (the first backoff). + elapsed := time.Since(start) + assert.GreaterOrEqual(t, elapsed, minAcceptDelay, + "loop ran without backing off — would burn CPU in production") +} + +// errSentinel mirrors gVisor's tcpip error message exactly. We can't +// import the gVisor package without dragging in the whole netstack, so +// the test uses the canonical string the production error formatter +// emits — same shape IsClosedListenerErr matches in production. +type errSentinel string + +func (e errSentinel) Error() string { return string(e) } diff --git a/proxy/internal/tcp/snipeek.go b/proxy/internal/tcp/snipeek.go new file mode 100644 index 000000000..dc3c96498 --- /dev/null +++ b/proxy/internal/tcp/snipeek.go @@ -0,0 +1,195 @@ +package tcp + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "net" +) + +const ( + // TLS record header is 5 bytes: ContentType(1) + Version(2) + Length(2). + tlsRecordHeaderLen = 5 + // TLS handshake type for ClientHello. + handshakeTypeClientHello = 1 + // TLS ContentType for handshake messages. + contentTypeHandshake = 22 + // SNI extension type (RFC 6066). + extensionServerName = 0 + // SNI host name type. + sniHostNameType = 0 + // maxClientHelloLen caps the ClientHello size we're willing to buffer. + maxClientHelloLen = 16384 + // maxSNILen is the maximum valid DNS hostname length per RFC 1035. + maxSNILen = 253 +) + +// PeekClientHello reads the TLS ClientHello from conn, extracts the SNI +// server name, and returns a wrapped connection that replays the peeked +// bytes transparently. If the data is not a valid TLS ClientHello or +// contains no SNI extension, sni is empty and err is nil. +// +// isTLS reports whether the first byte indicated a TLS handshake record. +// Callers can use this to distinguish plain (non-TLS) traffic from a TLS +// stream that simply lacked an SNI extension or used ECH. +// +// ECH/ESNI: When the client uses Encrypted Client Hello (TLS 1.3), the +// real server name is encrypted inside the encrypted_client_hello +// extension. This parser only reads the cleartext server_name extension +// (type 0x0000), so ECH connections return sni="" and are routed through +// the fallback path (or HTTP channel), which is the correct behavior +// for a transparent proxy that does not terminate TLS. +func PeekClientHello(conn net.Conn) (sni string, wrapped net.Conn, isTLS bool, err error) { + // Read the 5-byte TLS record header into a small stack-friendly buffer. + var header [tlsRecordHeaderLen]byte + if _, err := io.ReadFull(conn, header[:]); err != nil { + return "", nil, false, fmt.Errorf("read TLS record header: %w", err) + } + + if header[0] != contentTypeHandshake { + return "", newPeekedConn(conn, header[:]), false, nil + } + + recordLen := int(binary.BigEndian.Uint16(header[3:5])) + if recordLen == 0 || recordLen > maxClientHelloLen { + return "", newPeekedConn(conn, header[:]), true, nil + } + + // Single allocation for header + payload. The peekedConn takes + // ownership of this buffer, so no further copies are needed. + buf := make([]byte, tlsRecordHeaderLen+recordLen) + copy(buf, header[:]) + + n, err := io.ReadFull(conn, buf[tlsRecordHeaderLen:]) + if err != nil { + return "", newPeekedConn(conn, buf[:tlsRecordHeaderLen+n]), true, fmt.Errorf("read TLS handshake payload: %w", err) + } + + sni = extractSNI(buf[tlsRecordHeaderLen:]) + return sni, newPeekedConn(conn, buf), true, nil +} + +// extractSNI parses a TLS handshake payload to find the SNI extension. +// Returns empty string if the payload is not a ClientHello or has no SNI. +func extractSNI(payload []byte) string { + if len(payload) < 4 { + return "" + } + + if payload[0] != handshakeTypeClientHello { + return "" + } + + // Handshake length (3 bytes, big-endian). + handshakeLen := int(payload[1])<<16 | int(payload[2])<<8 | int(payload[3]) + if handshakeLen > len(payload)-4 { + return "" + } + + return parseSNIFromClientHello(payload[4 : 4+handshakeLen]) +} + +// parseSNIFromClientHello walks the ClientHello message fields to reach +// the extensions block and extract the server_name extension value. +func parseSNIFromClientHello(msg []byte) string { + // ClientHello layout: + // ProtocolVersion(2) + Random(32) = 34 bytes minimum before session_id + if len(msg) < 34 { + return "" + } + + pos := 34 + + // Session ID (variable, 1 byte length prefix). + if pos >= len(msg) { + return "" + } + sessionIDLen := int(msg[pos]) + pos++ + pos += sessionIDLen + + // Cipher suites (variable, 2 byte length prefix). + if pos+2 > len(msg) { + return "" + } + cipherSuitesLen := int(binary.BigEndian.Uint16(msg[pos : pos+2])) + pos += 2 + cipherSuitesLen + + // Compression methods (variable, 1 byte length prefix). + if pos >= len(msg) { + return "" + } + compMethodsLen := int(msg[pos]) + pos++ + pos += compMethodsLen + + // Extensions (variable, 2 byte length prefix). + if pos+2 > len(msg) { + return "" + } + extensionsLen := int(binary.BigEndian.Uint16(msg[pos : pos+2])) + pos += 2 + + extensionsEnd := pos + extensionsLen + if extensionsEnd > len(msg) { + return "" + } + + return findSNIExtension(msg[pos:extensionsEnd]) +} + +// findSNIExtension iterates over TLS extensions and returns the host +// name from the server_name extension, if present. +func findSNIExtension(extensions []byte) string { + pos := 0 + for pos+4 <= len(extensions) { + extType := binary.BigEndian.Uint16(extensions[pos : pos+2]) + extLen := int(binary.BigEndian.Uint16(extensions[pos+2 : pos+4])) + pos += 4 + + if pos+extLen > len(extensions) { + return "" + } + + if extType == extensionServerName { + return parseSNIExtensionData(extensions[pos : pos+extLen]) + } + pos += extLen + } + return "" +} + +// parseSNIExtensionData parses the ServerNameList structure inside an +// SNI extension to extract the host name. +func parseSNIExtensionData(data []byte) string { + if len(data) < 2 { + return "" + } + listLen := int(binary.BigEndian.Uint16(data[0:2])) + if listLen > len(data)-2 { + return "" + } + + list := data[2 : 2+listLen] + pos := 0 + for pos+3 <= len(list) { + nameType := list[pos] + nameLen := int(binary.BigEndian.Uint16(list[pos+1 : pos+3])) + pos += 3 + + if pos+nameLen > len(list) { + return "" + } + + if nameType == sniHostNameType { + name := list[pos : pos+nameLen] + if nameLen > maxSNILen || bytes.ContainsRune(name, 0) { + return "" + } + return string(name) + } + pos += nameLen + } + return "" +} diff --git a/proxy/internal/tcp/snipeek_test.go b/proxy/internal/tcp/snipeek_test.go new file mode 100644 index 000000000..85dee15c1 --- /dev/null +++ b/proxy/internal/tcp/snipeek_test.go @@ -0,0 +1,255 @@ +package tcp + +import ( + "crypto/tls" + "io" + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPeekClientHello_ValidSNI(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + const expectedSNI = "example.com" + trailingData := []byte("trailing data after handshake") + + go func() { + tlsConn := tls.Client(clientConn, &tls.Config{ + ServerName: expectedSNI, + InsecureSkipVerify: true, //nolint:gosec + }) + // The Handshake will send the ClientHello. It will fail because + // our server side isn't doing a real TLS handshake, but that's + // fine: we only need the ClientHello to be sent. + _ = tlsConn.Handshake() + }() + + sni, wrapped, isTLS, err := PeekClientHello(serverConn) + require.NoError(t, err) + assert.Equal(t, expectedSNI, sni, "should extract SNI from ClientHello") + assert.NotNil(t, wrapped, "wrapped connection should not be nil") + assert.True(t, isTLS, "TLS ClientHello should be flagged as TLS") + + // Verify the wrapped connection replays the peeked bytes. + // Read the first 5 bytes (TLS record header) to confirm replay. + buf := make([]byte, 5) + n, err := wrapped.Read(buf) + require.NoError(t, err) + assert.Equal(t, 5, n) + assert.Equal(t, byte(contentTypeHandshake), buf[0], "first byte should be TLS handshake content type") + + // Write trailing data from the client side and verify it arrives + // through the wrapped connection after the peeked bytes. + go func() { + _, _ = clientConn.Write(trailingData) + }() + + // Drain the rest of the peeked ClientHello first. + peekedRest := make([]byte, 16384) + _, _ = wrapped.Read(peekedRest) + + got := make([]byte, len(trailingData)) + n, err = io.ReadFull(wrapped, got) + require.NoError(t, err) + assert.Equal(t, trailingData, got[:n]) +} + +func TestPeekClientHello_MultipleSNIs(t *testing.T) { + tests := []struct { + name string + serverName string + expectedSNI string + }{ + {"simple domain", "example.com", "example.com"}, + {"subdomain", "sub.example.com", "sub.example.com"}, + {"deep subdomain", "a.b.c.example.com", "a.b.c.example.com"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + go func() { + tlsConn := tls.Client(clientConn, &tls.Config{ + ServerName: tt.serverName, + InsecureSkipVerify: true, //nolint:gosec + }) + _ = tlsConn.Handshake() + }() + + sni, wrapped, isTLS, err := PeekClientHello(serverConn) + require.NoError(t, err) + assert.Equal(t, tt.expectedSNI, sni) + assert.NotNil(t, wrapped) + assert.True(t, isTLS, "TLS handshake should be flagged as TLS") + }) + } +} + +func TestPeekClientHello_NonTLSData(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + // Send plain HTTP data (not TLS). + httpData := []byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n") + go func() { + _, _ = clientConn.Write(httpData) + }() + + sni, wrapped, isTLS, err := PeekClientHello(serverConn) + require.NoError(t, err) + assert.Empty(t, sni, "should return empty SNI for non-TLS data") + assert.NotNil(t, wrapped) + assert.False(t, isTLS, "plain HTTP data should not be flagged as TLS") + + // Verify the wrapped connection still provides the original data. + buf := make([]byte, len(httpData)) + n, err := io.ReadFull(wrapped, buf) + require.NoError(t, err) + assert.Equal(t, httpData, buf[:n], "wrapped connection should replay original data") +} + +func TestPeekClientHello_TruncatedHeader(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer serverConn.Close() + + // Write only 3 bytes then close, fewer than the 5-byte TLS header. + go func() { + _, _ = clientConn.Write([]byte{0x16, 0x03, 0x01}) + clientConn.Close() + }() + + _, _, _, err := PeekClientHello(serverConn) + assert.Error(t, err, "should error on truncated header") +} + +func TestPeekClientHello_TruncatedPayload(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer serverConn.Close() + + // Write a valid TLS header claiming 100 bytes, but only send 10. + go func() { + header := []byte{0x16, 0x03, 0x01, 0x00, 0x64} // 100 bytes claimed + _, _ = clientConn.Write(header) + _, _ = clientConn.Write(make([]byte, 10)) + clientConn.Close() + }() + + _, _, _, err := PeekClientHello(serverConn) + assert.Error(t, err, "should error on truncated payload") +} + +func TestPeekClientHello_ZeroLengthRecord(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + // TLS handshake header with zero-length payload. + go func() { + _, _ = clientConn.Write([]byte{0x16, 0x03, 0x01, 0x00, 0x00}) + }() + + sni, wrapped, isTLS, err := PeekClientHello(serverConn) + require.NoError(t, err) + assert.Empty(t, sni) + assert.NotNil(t, wrapped) + assert.True(t, isTLS, "zero-length record should still be a TLS handshake byte") +} + +func TestExtractSNI_InvalidPayload(t *testing.T) { + tests := []struct { + name string + payload []byte + }{ + {"nil", nil}, + {"empty", []byte{}}, + {"too short", []byte{0x01, 0x00}}, + {"wrong handshake type", []byte{0x02, 0x00, 0x00, 0x05, 0x03, 0x03, 0x00, 0x00, 0x00}}, + {"truncated client hello", []byte{0x01, 0x00, 0x00, 0x20}}, // claims 32 bytes but has none + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Empty(t, extractSNI(tt.payload)) + }) + } +} + +func TestPeekedConn_CloseWrite(t *testing.T) { + t.Run("delegates to underlying TCPConn", func(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + + accepted := make(chan net.Conn, 1) + go func() { + c, err := ln.Accept() + if err == nil { + accepted <- c + } + }() + + client, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + defer client.Close() + + server := <-accepted + defer server.Close() + + wrapped := newPeekedConn(server, []byte("peeked")) + + // CloseWrite should succeed on a real TCP connection. + err = wrapped.CloseWrite() + assert.NoError(t, err) + + // The client should see EOF on reads after CloseWrite. + buf := make([]byte, 1) + _, err = client.Read(buf) + assert.Equal(t, io.EOF, err, "client should see EOF after half-close") + }) + + t.Run("no-op on non-halfcloser", func(t *testing.T) { + // net.Pipe does not implement CloseWrite. + _, server := net.Pipe() + defer server.Close() + + wrapped := newPeekedConn(server, []byte("peeked")) + err := wrapped.CloseWrite() + assert.NoError(t, err, "should be no-op on non-halfcloser") + }) +} + +func TestPeekedConn_ReplayAndPassthrough(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + peeked := []byte("peeked-data") + subsequent := []byte("subsequent-data") + + wrapped := newPeekedConn(serverConn, peeked) + + go func() { + _, _ = clientConn.Write(subsequent) + }() + + // Read should return peeked data first. + buf := make([]byte, len(peeked)) + n, err := io.ReadFull(wrapped, buf) + require.NoError(t, err) + assert.Equal(t, peeked, buf[:n]) + + // Then subsequent data from the real connection. + buf = make([]byte, len(subsequent)) + n, err = io.ReadFull(wrapped, buf) + require.NoError(t, err) + assert.Equal(t, subsequent, buf[:n]) +} diff --git a/proxy/internal/types/types.go b/proxy/internal/types/types.go new file mode 100644 index 000000000..ffdbf2301 --- /dev/null +++ b/proxy/internal/types/types.go @@ -0,0 +1,76 @@ +// Package types defines common types used across the proxy package. +package types + +import ( + "context" + "net" + "time" +) + +// AccountID represents a unique identifier for a NetBird account. +type AccountID string + +// ServiceID represents a unique identifier for a proxy service. +type ServiceID string + +// ServiceMode describes how a reverse proxy service is exposed. +type ServiceMode string + +const ( + ServiceModeHTTP ServiceMode = "http" + ServiceModeTCP ServiceMode = "tcp" + ServiceModeUDP ServiceMode = "udp" + ServiceModeTLS ServiceMode = "tls" +) + +// IsL4 returns true for TCP, UDP, and TLS modes. +func (m ServiceMode) IsL4() bool { + return m == ServiceModeTCP || m == ServiceModeUDP || m == ServiceModeTLS +} + +// RelayDirection indicates the direction of a relayed packet. +type RelayDirection string + +const ( + RelayDirectionClientToBackend RelayDirection = "client_to_backend" + RelayDirectionBackendToClient RelayDirection = "backend_to_client" +) + +// DialContextFunc dials a backend through the WireGuard tunnel. +type DialContextFunc func(ctx context.Context, network, address string) (net.Conn, error) + +// dialTimeoutKey is the context key for a per-request dial timeout. +type dialTimeoutKey struct{} + +// WithDialTimeout returns a context carrying a dial timeout that +// DialContext wrappers can use to scope the timeout to just the +// connection establishment phase. +func WithDialTimeout(ctx context.Context, d time.Duration) context.Context { + return context.WithValue(ctx, dialTimeoutKey{}, d) +} + +// DialTimeoutFromContext returns the dial timeout from the context, if set. +func DialTimeoutFromContext(ctx context.Context) (time.Duration, bool) { + d, ok := ctx.Value(dialTimeoutKey{}).(time.Duration) + return d, ok && d > 0 +} + +// overlayOriginKey is the context key set by per-account inbound +// listeners to mark a request as originating from the WireGuard +// overlay rather than the public-facing host listener. +type overlayOriginKey struct{} + +// WithOverlayOrigin marks the context as originating from the +// embedded NetBird overlay (tunnel-side inbound listener). +func WithOverlayOrigin(ctx context.Context) context.Context { + return context.WithValue(ctx, overlayOriginKey{}, true) +} + +// IsOverlayOrigin reports whether the request reached the proxy via +// the overlay listener. Middlewares that only make sense for WAN +// traffic (geolocation, CrowdSec IP reputation) should short-circuit +// when this is true. +func IsOverlayOrigin(ctx context.Context) bool { + v, _ := ctx.Value(overlayOriginKey{}).(bool) + return v +} diff --git a/proxy/internal/types/types_test.go b/proxy/internal/types/types_test.go new file mode 100644 index 000000000..dd9738442 --- /dev/null +++ b/proxy/internal/types/types_test.go @@ -0,0 +1,54 @@ +package types + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestServiceMode_IsL4(t *testing.T) { + tests := []struct { + mode ServiceMode + want bool + }{ + {ServiceModeHTTP, false}, + {ServiceModeTCP, true}, + {ServiceModeUDP, true}, + {ServiceModeTLS, true}, + {ServiceMode("unknown"), false}, + } + + for _, tt := range tests { + t.Run(string(tt.mode), func(t *testing.T) { + assert.Equal(t, tt.want, tt.mode.IsL4()) + }) + } +} + +func TestDialTimeoutContext(t *testing.T) { + t.Run("round trip", func(t *testing.T) { + ctx := WithDialTimeout(context.Background(), 5*time.Second) + d, ok := DialTimeoutFromContext(ctx) + assert.True(t, ok) + assert.Equal(t, 5*time.Second, d) + }) + + t.Run("missing", func(t *testing.T) { + _, ok := DialTimeoutFromContext(context.Background()) + assert.False(t, ok) + }) + + t.Run("zero returns false", func(t *testing.T) { + ctx := WithDialTimeout(context.Background(), 0) + _, ok := DialTimeoutFromContext(ctx) + assert.False(t, ok, "zero duration should return ok=false") + }) + + t.Run("negative returns false", func(t *testing.T) { + ctx := WithDialTimeout(context.Background(), -1*time.Second) + _, ok := DialTimeoutFromContext(ctx) + assert.False(t, ok, "negative duration should return ok=false") + }) +} diff --git a/proxy/internal/udp/relay.go b/proxy/internal/udp/relay.go new file mode 100644 index 000000000..8293bfe81 --- /dev/null +++ b/proxy/internal/udp/relay.go @@ -0,0 +1,573 @@ +package udp + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "sync" + "sync/atomic" + "time" + + log "github.com/sirupsen/logrus" + "golang.org/x/time/rate" + + "github.com/netbirdio/netbird/proxy/internal/accesslog" + "github.com/netbirdio/netbird/proxy/internal/netutil" + "github.com/netbirdio/netbird/proxy/internal/restrict" + "github.com/netbirdio/netbird/proxy/internal/types" +) + +const ( + // DefaultSessionTTL is the default idle timeout for UDP sessions before cleanup. + DefaultSessionTTL = 30 * time.Second + // cleanupInterval is how often the cleaner goroutine runs. + cleanupInterval = time.Minute + // maxPacketSize is the maximum UDP packet size we'll handle. + maxPacketSize = 65535 + // DefaultMaxSessions is the default cap on concurrent UDP sessions per relay. + DefaultMaxSessions = 1024 + // sessionCreateRate limits new session creation per second. + sessionCreateRate = 50 + // sessionCreateBurst is the burst allowance for session creation. + sessionCreateBurst = 100 + // defaultDialTimeout is the fallback dial timeout for backend connections. + defaultDialTimeout = 30 * time.Second +) + +// l4Logger sends layer-4 access log entries to the management server. +type l4Logger interface { + LogL4(entry accesslog.L4Entry) +} + +// SessionObserver receives callbacks for UDP session lifecycle events. +// All methods must be safe for concurrent use. +type SessionObserver interface { + UDPSessionStarted(accountID types.AccountID) + UDPSessionEnded(accountID types.AccountID) + UDPSessionDialError(accountID types.AccountID) + UDPSessionRejected(accountID types.AccountID) + UDPPacketRelayed(direction types.RelayDirection, bytes int) +} + +// clientAddr is a typed key for UDP session lookups. +type clientAddr string + +// Relay listens for incoming UDP packets on a dedicated port and +// maintains per-client sessions that relay packets to a backend +// through the WireGuard tunnel. +type Relay struct { + logger *log.Entry + listener net.PacketConn + target string + domain string + accountID types.AccountID + serviceID types.ServiceID + dialFunc types.DialContextFunc + dialTimeout time.Duration + sessionTTL time.Duration + maxSessions int + filter *restrict.Filter + geo restrict.GeoResolver + + mu sync.RWMutex + sessions map[clientAddr]*session + + bufPool sync.Pool + sessLimiter *rate.Limiter + sessWg sync.WaitGroup + ctx context.Context + cancel context.CancelFunc + observer SessionObserver + accessLog l4Logger +} + +type session struct { + backend net.Conn + addr net.Addr + createdAt time.Time + // lastSeen stores the last activity timestamp as unix nanoseconds. + lastSeen atomic.Int64 + cancel context.CancelFunc + // bytesIn tracks total bytes received from the client. + bytesIn atomic.Int64 + // bytesOut tracks total bytes sent back to the client. + bytesOut atomic.Int64 +} + +func (s *session) updateLastSeen() { + s.lastSeen.Store(time.Now().UnixNano()) +} + +func (s *session) idleDuration() time.Duration { + return time.Since(time.Unix(0, s.lastSeen.Load())) +} + +// RelayConfig holds the configuration for a UDP relay. +type RelayConfig struct { + Logger *log.Entry + Listener net.PacketConn + Target string + Domain string + AccountID types.AccountID + ServiceID types.ServiceID + DialFunc types.DialContextFunc + DialTimeout time.Duration + SessionTTL time.Duration + MaxSessions int + AccessLog l4Logger + // Filter holds connection-level IP/geo restrictions. Nil means no restrictions. + Filter *restrict.Filter + // Geo is the geolocation lookup used for country-based restrictions. + Geo restrict.GeoResolver +} + +// New creates a UDP relay for the given listener and backend target. +// MaxSessions caps the number of concurrent sessions; use 0 for DefaultMaxSessions. +// DialTimeout controls how long to wait for backend connections; use 0 for default. +// SessionTTL is the idle timeout before a session is reaped; use 0 for DefaultSessionTTL. +func New(parentCtx context.Context, cfg RelayConfig) *Relay { + maxSessions := cfg.MaxSessions + dialTimeout := cfg.DialTimeout + sessionTTL := cfg.SessionTTL + if maxSessions <= 0 { + maxSessions = DefaultMaxSessions + } + if dialTimeout <= 0 { + dialTimeout = defaultDialTimeout + } + if sessionTTL <= 0 { + sessionTTL = DefaultSessionTTL + } + ctx, cancel := context.WithCancel(parentCtx) + return &Relay{ + logger: cfg.Logger, + listener: cfg.Listener, + target: cfg.Target, + domain: cfg.Domain, + accountID: cfg.AccountID, + serviceID: cfg.ServiceID, + accessLog: cfg.AccessLog, + dialFunc: cfg.DialFunc, + dialTimeout: dialTimeout, + sessionTTL: sessionTTL, + maxSessions: maxSessions, + filter: cfg.Filter, + geo: cfg.Geo, + sessions: make(map[clientAddr]*session), + bufPool: sync.Pool{ + New: func() any { + buf := make([]byte, maxPacketSize) + return &buf + }, + }, + sessLimiter: rate.NewLimiter(sessionCreateRate, sessionCreateBurst), + ctx: ctx, + cancel: cancel, + } +} + +// ServiceID returns the service ID associated with this relay. +func (r *Relay) ServiceID() types.ServiceID { + return r.serviceID +} + +// SetObserver sets the session lifecycle observer. Must be called before Serve. +func (r *Relay) SetObserver(obs SessionObserver) { + r.mu.Lock() + defer r.mu.Unlock() + r.observer = obs +} + +// getObserver returns the current session lifecycle observer. +func (r *Relay) getObserver() SessionObserver { + r.mu.RLock() + defer r.mu.RUnlock() + return r.observer +} + +// Serve starts the relay loop. It blocks until the context is canceled +// or the listener is closed. +func (r *Relay) Serve() { + go r.cleanupLoop() + + for { + bufp := r.bufPool.Get().(*[]byte) + buf := *bufp + + n, addr, err := r.listener.ReadFrom(buf) + if err != nil { + r.bufPool.Put(bufp) + if r.ctx.Err() != nil || errors.Is(err, net.ErrClosed) { + return + } + r.logger.Debugf("UDP read: %v", err) + continue + } + + data := buf[:n] + sess, err := r.getOrCreateSession(addr) + if err != nil { + r.bufPool.Put(bufp) + r.logger.Debugf("create UDP session for %s: %v", addr, err) + continue + } + + sess.updateLastSeen() + + nw, err := sess.backend.Write(data) + if err != nil { + r.bufPool.Put(bufp) + if !netutil.IsExpectedError(err) { + r.logger.Debugf("UDP write to backend for %s: %v", addr, err) + } + r.removeSession(sess) + continue + } + sess.bytesIn.Add(int64(nw)) + + if obs := r.getObserver(); obs != nil { + obs.UDPPacketRelayed(types.RelayDirectionClientToBackend, nw) + } + r.bufPool.Put(bufp) + } +} + +// getOrCreateSession returns an existing session or creates a new one. +func (r *Relay) getOrCreateSession(addr net.Addr) (*session, error) { + key := clientAddr(addr.String()) + + r.mu.RLock() + sess, ok := r.sessions[key] + r.mu.RUnlock() + if ok && sess != nil { + return sess, nil + } + + // Check before taking the write lock: if the relay is shutting down, + // don't create new sessions. This prevents orphaned goroutines when + // Serve() processes a packet that was already read before Close(). + if r.ctx.Err() != nil { + return nil, r.ctx.Err() + } + + if err := r.checkAccessRestrictions(addr); err != nil { + return nil, err + } + + r.mu.Lock() + + if sess, ok = r.sessions[key]; ok && sess != nil { + r.mu.Unlock() + return sess, nil + } + if ok { + // Another goroutine is dialing for this key, skip. + r.mu.Unlock() + return nil, fmt.Errorf("session dial in progress for %s", key) + } + + if len(r.sessions) >= r.maxSessions { + r.mu.Unlock() + if obs := r.getObserver(); obs != nil { + obs.UDPSessionRejected(r.accountID) + } + return nil, fmt.Errorf("session limit reached (%d)", r.maxSessions) + } + + if !r.sessLimiter.Allow() { + r.mu.Unlock() + if obs := r.getObserver(); obs != nil { + obs.UDPSessionRejected(r.accountID) + } + return nil, fmt.Errorf("session creation rate limited") + } + + // Reserve the slot with a nil session so concurrent callers for the same + // key see it exists and wait. Release the lock before dialing. + r.sessions[key] = nil + r.mu.Unlock() + + dialCtx, dialCancel := context.WithTimeout(r.ctx, r.dialTimeout) + backend, err := r.dialFunc(dialCtx, "udp", r.target) + dialCancel() + if err != nil { + r.mu.Lock() + delete(r.sessions, key) + r.mu.Unlock() + if obs := r.getObserver(); obs != nil { + obs.UDPSessionDialError(r.accountID) + } + return nil, fmt.Errorf("dial backend %s: %w", r.target, err) + } + + sessCtx, sessCancel := context.WithCancel(r.ctx) + sess = &session{ + backend: backend, + addr: addr, + createdAt: time.Now(), + cancel: sessCancel, + } + sess.updateLastSeen() + + r.mu.Lock() + r.sessions[key] = sess + r.mu.Unlock() + + if obs := r.getObserver(); obs != nil { + obs.UDPSessionStarted(r.accountID) + } + + r.sessWg.Go(func() { + r.relayBackendToClient(sessCtx, sess) + }) + + r.logger.Debugf("UDP session created for %s", addr) + return sess, nil +} + +func (r *Relay) checkAccessRestrictions(addr net.Addr) error { + if r.filter == nil { + return nil + } + clientIP, err := addrFromUDPAddr(addr) + if err != nil { + return fmt.Errorf("parse client address %s for restriction check: %w", addr, err) + } + if v := r.filter.Check(clientIP, r.geo); v != restrict.Allow { + if r.filter.IsObserveOnly(v) { + r.logger.Debugf("CrowdSec observe: would block %s (%s)", clientIP, v) + r.logDeny(clientIP, v, true) + } else { + r.logDeny(clientIP, v, false) + return fmt.Errorf("access restricted for %s", addr) + } + } + return nil +} + +// relayBackendToClient reads packets from the backend and writes them +// back to the client through the public-facing listener. +func (r *Relay) relayBackendToClient(ctx context.Context, sess *session) { + bufp := r.bufPool.Get().(*[]byte) + defer r.bufPool.Put(bufp) + defer r.removeSession(sess) + + for ctx.Err() == nil { + data, ok := r.readBackendPacket(sess, *bufp) + if !ok { + return + } + if data == nil { + continue + } + + sess.updateLastSeen() + + nw, err := r.listener.WriteTo(data, sess.addr) + if err != nil { + if !netutil.IsExpectedError(err) { + r.logger.Debugf("UDP write to client %s: %v", sess.addr, err) + } + return + } + sess.bytesOut.Add(int64(nw)) + + if obs := r.getObserver(); obs != nil { + obs.UDPPacketRelayed(types.RelayDirectionBackendToClient, nw) + } + } +} + +// readBackendPacket reads one packet from the backend with an idle deadline. +// Returns (data, true) on success, (nil, true) on idle timeout that should +// retry, or (nil, false) when the session should be torn down. +func (r *Relay) readBackendPacket(sess *session, buf []byte) ([]byte, bool) { + if err := sess.backend.SetReadDeadline(time.Now().Add(r.sessionTTL)); err != nil { + r.logger.Debugf("set backend read deadline for %s: %v", sess.addr, err) + return nil, false + } + + n, err := sess.backend.Read(buf) + if err != nil { + if netutil.IsTimeout(err) { + if sess.idleDuration() > r.sessionTTL { + return nil, false + } + return nil, true + } + if !netutil.IsExpectedError(err) { + r.logger.Debugf("UDP read from backend for %s: %v", sess.addr, err) + } + return nil, false + } + + return buf[:n], true +} + +// cleanupLoop periodically removes idle sessions. +func (r *Relay) cleanupLoop() { + ticker := time.NewTicker(cleanupInterval) + defer ticker.Stop() + + for { + select { + case <-r.ctx.Done(): + return + case <-ticker.C: + r.cleanupIdleSessions() + } + } +} + +// cleanupIdleSessions closes sessions that have been idle for too long. +func (r *Relay) cleanupIdleSessions() { + var expired []*session + + r.mu.Lock() + for key, sess := range r.sessions { + if sess == nil { + continue + } + idle := sess.idleDuration() + if idle > r.sessionTTL { + r.logger.Debugf("UDP session %s idle for %s, closing (client→backend: %d bytes, backend→client: %d bytes)", + sess.addr, idle, sess.bytesIn.Load(), sess.bytesOut.Load()) + delete(r.sessions, key) + sess.cancel() + if err := sess.backend.Close(); err != nil { + r.logger.Debugf("close idle session %s backend: %v", sess.addr, err) + } + expired = append(expired, sess) + } + } + r.mu.Unlock() + + obs := r.getObserver() + for _, sess := range expired { + if obs != nil { + obs.UDPSessionEnded(r.accountID) + } + r.logSessionEnd(sess) + } +} + +// removeSession removes a session from the map if it still matches the +// given pointer. This is safe to call concurrently with cleanupIdleSessions +// because the identity check prevents double-close when both paths race. +func (r *Relay) removeSession(sess *session) { + r.mu.Lock() + key := clientAddr(sess.addr.String()) + removed := r.sessions[key] == sess + if removed { + delete(r.sessions, key) + sess.cancel() + if err := sess.backend.Close(); err != nil { + r.logger.Debugf("close session %s backend: %v", sess.addr, err) + } + } + r.mu.Unlock() + + if removed { + r.logger.Debugf("UDP session %s ended (client→backend: %d bytes, backend→client: %d bytes)", + sess.addr, sess.bytesIn.Load(), sess.bytesOut.Load()) + if obs := r.getObserver(); obs != nil { + obs.UDPSessionEnded(r.accountID) + } + r.logSessionEnd(sess) + } +} + +// logSessionEnd sends an access log entry for a completed UDP session. +func (r *Relay) logSessionEnd(sess *session) { + if r.accessLog == nil { + return + } + + var sourceIP netip.Addr + if ap, err := netip.ParseAddrPort(sess.addr.String()); err == nil { + sourceIP = ap.Addr().Unmap() + } + + r.accessLog.LogL4(accesslog.L4Entry{ + AccountID: r.accountID, + ServiceID: r.serviceID, + Protocol: accesslog.ProtocolUDP, + Host: r.domain, + SourceIP: sourceIP, + DurationMs: time.Unix(0, sess.lastSeen.Load()).Sub(sess.createdAt).Milliseconds(), + BytesUpload: sess.bytesIn.Load(), + BytesDownload: sess.bytesOut.Load(), + }) +} + +// logDeny sends an access log entry for a denied UDP packet. +func (r *Relay) logDeny(clientIP netip.Addr, verdict restrict.Verdict, observeOnly bool) { + if r.accessLog == nil { + return + } + + entry := accesslog.L4Entry{ + AccountID: r.accountID, + ServiceID: r.serviceID, + Protocol: accesslog.ProtocolUDP, + Host: r.domain, + SourceIP: clientIP, + DenyReason: verdict.String(), + } + if verdict.IsCrowdSec() { + entry.Metadata = map[string]string{"crowdsec_verdict": verdict.String()} + if observeOnly { + entry.Metadata["crowdsec_mode"] = "observe" + entry.DenyReason = "" + } + } + r.accessLog.LogL4(entry) +} + +// Close stops the relay, waits for all session goroutines to exit, +// and cleans up remaining sessions. +func (r *Relay) Close() { + r.cancel() + if err := r.listener.Close(); err != nil { + r.logger.Debugf("close UDP listener: %v", err) + } + + var closedSessions []*session + r.mu.Lock() + for key, sess := range r.sessions { + if sess == nil { + delete(r.sessions, key) + continue + } + r.logger.Debugf("UDP session %s closed (client→backend: %d bytes, backend→client: %d bytes)", + sess.addr, sess.bytesIn.Load(), sess.bytesOut.Load()) + sess.cancel() + if err := sess.backend.Close(); err != nil { + r.logger.Debugf("close session %s backend: %v", sess.addr, err) + } + delete(r.sessions, key) + closedSessions = append(closedSessions, sess) + } + r.mu.Unlock() + + obs := r.getObserver() + for _, sess := range closedSessions { + if obs != nil { + obs.UDPSessionEnded(r.accountID) + } + r.logSessionEnd(sess) + } + + r.sessWg.Wait() +} + +// addrFromUDPAddr extracts a netip.Addr from a net.Addr. +func addrFromUDPAddr(addr net.Addr) (netip.Addr, error) { + ap, err := netip.ParseAddrPort(addr.String()) + if err != nil { + return netip.Addr{}, err + } + return ap.Addr().Unmap(), nil +} diff --git a/proxy/internal/udp/relay_test.go b/proxy/internal/udp/relay_test.go new file mode 100644 index 000000000..a1e91b290 --- /dev/null +++ b/proxy/internal/udp/relay_test.go @@ -0,0 +1,493 @@ +package udp + +import ( + "context" + "fmt" + "net" + "sync" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/types" +) + +func TestRelay_BasicPacketExchange(t *testing.T) { + // Set up a UDP backend that echoes packets. + backend, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer backend.Close() + + go func() { + buf := make([]byte, 65535) + for { + n, addr, err := backend.ReadFrom(buf) + if err != nil { + return + } + _, _ = backend.WriteTo(buf[:n], addr) + } + }() + + // Set up the relay's public-facing listener. + listener, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := log.NewEntry(log.StandardLogger()) + backendAddr := backend.LocalAddr().String() + + dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + } + + relay := New(ctx, RelayConfig{Logger: logger, Listener: listener, Target: backendAddr, DialFunc: dialFunc}) + go relay.Serve() + defer relay.Close() + + // Create a client and send a packet to the relay. + client, err := net.Dial("udp", listener.LocalAddr().String()) + require.NoError(t, err) + defer client.Close() + + testData := []byte("hello UDP relay") + _, err = client.Write(testData) + require.NoError(t, err) + + // Read the echoed response. + if err := client.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatal(err) + } + buf := make([]byte, 1024) + n, err := client.Read(buf) + require.NoError(t, err) + assert.Equal(t, testData, buf[:n], "should receive echoed packet") +} + +func TestRelay_MultipleClients(t *testing.T) { + backend, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer backend.Close() + + go func() { + buf := make([]byte, 65535) + for { + n, addr, err := backend.ReadFrom(buf) + if err != nil { + return + } + _, _ = backend.WriteTo(buf[:n], addr) + } + }() + + listener, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := log.NewEntry(log.StandardLogger()) + dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + } + + relay := New(ctx, RelayConfig{Logger: logger, Listener: listener, Target: backend.LocalAddr().String(), DialFunc: dialFunc}) + go relay.Serve() + defer relay.Close() + + // Two clients, each should get their own session. + for i, msg := range []string{"client-1", "client-2"} { + client, err := net.Dial("udp", listener.LocalAddr().String()) + require.NoError(t, err, "client %d", i) + defer client.Close() + + _, err = client.Write([]byte(msg)) + require.NoError(t, err) + + if err := client.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatal(err) + } + buf := make([]byte, 1024) + n, err := client.Read(buf) + require.NoError(t, err, "client %d read", i) + assert.Equal(t, msg, string(buf[:n]), "client %d should get own echo", i) + } + + // Verify two sessions were created. + relay.mu.RLock() + sessionCount := len(relay.sessions) + relay.mu.RUnlock() + assert.Equal(t, 2, sessionCount, "should have two sessions") +} + +func TestRelay_Close(t *testing.T) { + listener, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := log.NewEntry(log.StandardLogger()) + dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + } + + relay := New(ctx, RelayConfig{Logger: logger, Listener: listener, Target: "127.0.0.1:9999", DialFunc: dialFunc}) + + done := make(chan struct{}) + go func() { + relay.Serve() + close(done) + }() + + relay.Close() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Serve did not return after Close") + } +} + +func TestRelay_SessionCleanup(t *testing.T) { + backend, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer backend.Close() + + go func() { + buf := make([]byte, 65535) + for { + n, addr, err := backend.ReadFrom(buf) + if err != nil { + return + } + _, _ = backend.WriteTo(buf[:n], addr) + } + }() + + listener, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := log.NewEntry(log.StandardLogger()) + dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + } + + relay := New(ctx, RelayConfig{Logger: logger, Listener: listener, Target: backend.LocalAddr().String(), DialFunc: dialFunc}) + go relay.Serve() + defer relay.Close() + + // Create a session. + client, err := net.Dial("udp", listener.LocalAddr().String()) + require.NoError(t, err) + _, err = client.Write([]byte("hello")) + require.NoError(t, err) + + if err := client.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatal(err) + } + buf := make([]byte, 1024) + _, err = client.Read(buf) + require.NoError(t, err) + client.Close() + + // Verify session exists. + relay.mu.RLock() + assert.Equal(t, 1, len(relay.sessions)) + relay.mu.RUnlock() + + // Make session appear idle by setting lastSeen to the past. + relay.mu.Lock() + for _, sess := range relay.sessions { + sess.lastSeen.Store(time.Now().Add(-2 * DefaultSessionTTL).UnixNano()) + } + relay.mu.Unlock() + + // Trigger cleanup manually. + relay.cleanupIdleSessions() + + relay.mu.RLock() + assert.Equal(t, 0, len(relay.sessions), "idle sessions should be cleaned up") + relay.mu.RUnlock() +} + +// TestRelay_CloseAndRecreate verifies that closing a relay and creating a new +// one on the same port works cleanly (simulates port mapping modify cycle). +func TestRelay_CloseAndRecreate(t *testing.T) { + backend, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer backend.Close() + + go func() { + buf := make([]byte, 65535) + for { + n, addr, err := backend.ReadFrom(buf) + if err != nil { + return + } + _, _ = backend.WriteTo(buf[:n], addr) + } + }() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := log.NewEntry(log.StandardLogger()) + dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + } + + // First relay. + ln1, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + + relay1 := New(ctx, RelayConfig{Logger: logger, Listener: ln1, Target: backend.LocalAddr().String(), DialFunc: dialFunc}) + go relay1.Serve() + + client1, err := net.Dial("udp", ln1.LocalAddr().String()) + require.NoError(t, err) + _, err = client1.Write([]byte("relay1")) + require.NoError(t, err) + require.NoError(t, client1.SetReadDeadline(time.Now().Add(2*time.Second))) + buf := make([]byte, 1024) + n, err := client1.Read(buf) + require.NoError(t, err) + assert.Equal(t, "relay1", string(buf[:n])) + client1.Close() + + // Close first relay. + relay1.Close() + + // Second relay on same port. + port := ln1.LocalAddr().(*net.UDPAddr).Port + ln2, err := net.ListenPacket("udp", fmt.Sprintf("127.0.0.1:%d", port)) + require.NoError(t, err) + + relay2 := New(ctx, RelayConfig{Logger: logger, Listener: ln2, Target: backend.LocalAddr().String(), DialFunc: dialFunc}) + go relay2.Serve() + defer relay2.Close() + + client2, err := net.Dial("udp", ln2.LocalAddr().String()) + require.NoError(t, err) + defer client2.Close() + _, err = client2.Write([]byte("relay2")) + require.NoError(t, err) + require.NoError(t, client2.SetReadDeadline(time.Now().Add(2*time.Second))) + n, err = client2.Read(buf) + require.NoError(t, err) + assert.Equal(t, "relay2", string(buf[:n]), "second relay should work on same port") +} + +func TestRelay_SessionLimit(t *testing.T) { + backend, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer backend.Close() + + go func() { + buf := make([]byte, 65535) + for { + n, addr, err := backend.ReadFrom(buf) + if err != nil { + return + } + _, _ = backend.WriteTo(buf[:n], addr) + } + }() + + listener, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := log.NewEntry(log.StandardLogger()) + dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + } + + // Create a relay with a max of 2 sessions. + relay := New(ctx, RelayConfig{Logger: logger, Listener: listener, Target: backend.LocalAddr().String(), DialFunc: dialFunc, MaxSessions: 2}) + go relay.Serve() + defer relay.Close() + + // Create 2 clients to fill up the session limit. + for i := range 2 { + client, err := net.Dial("udp", listener.LocalAddr().String()) + require.NoError(t, err, "client %d", i) + defer client.Close() + + _, err = client.Write([]byte("hello")) + require.NoError(t, err) + + require.NoError(t, client.SetReadDeadline(time.Now().Add(2*time.Second))) + buf := make([]byte, 1024) + _, err = client.Read(buf) + require.NoError(t, err, "client %d should get response", i) + } + + relay.mu.RLock() + assert.Equal(t, 2, len(relay.sessions), "should have exactly 2 sessions") + relay.mu.RUnlock() + + // Third client should get its packet dropped (session creation fails). + client3, err := net.Dial("udp", listener.LocalAddr().String()) + require.NoError(t, err) + defer client3.Close() + + _, err = client3.Write([]byte("should be dropped")) + require.NoError(t, err) + + require.NoError(t, client3.SetReadDeadline(time.Now().Add(500*time.Millisecond))) + buf := make([]byte, 1024) + _, err = client3.Read(buf) + assert.Error(t, err, "third client should time out because session was rejected") + + relay.mu.RLock() + assert.Equal(t, 2, len(relay.sessions), "session count should not exceed limit") + relay.mu.RUnlock() +} + +// testObserver records UDP session lifecycle events for test assertions. +type testObserver struct { + mu sync.Mutex + started int + ended int + rejected int + dialErr int + packets int + bytes int +} + +func (o *testObserver) UDPSessionStarted(types.AccountID) { o.mu.Lock(); o.started++; o.mu.Unlock() } +func (o *testObserver) UDPSessionEnded(types.AccountID) { o.mu.Lock(); o.ended++; o.mu.Unlock() } +func (o *testObserver) UDPSessionDialError(types.AccountID) { o.mu.Lock(); o.dialErr++; o.mu.Unlock() } +func (o *testObserver) UDPSessionRejected(types.AccountID) { o.mu.Lock(); o.rejected++; o.mu.Unlock() } +func (o *testObserver) UDPPacketRelayed(_ types.RelayDirection, b int) { + o.mu.Lock() + o.packets++ + o.bytes += b + o.mu.Unlock() +} + +func TestRelay_CloseFiresObserverEnded(t *testing.T) { + backend, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer backend.Close() + + go func() { + buf := make([]byte, 65535) + for { + n, addr, err := backend.ReadFrom(buf) + if err != nil { + return + } + _, _ = backend.WriteTo(buf[:n], addr) + } + }() + + listener, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := log.NewEntry(log.StandardLogger()) + dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + } + + obs := &testObserver{} + relay := New(ctx, RelayConfig{Logger: logger, Listener: listener, Target: backend.LocalAddr().String(), AccountID: "test-acct", DialFunc: dialFunc}) + relay.SetObserver(obs) + go relay.Serve() + + // Create two sessions. + for i := range 2 { + client, err := net.Dial("udp", listener.LocalAddr().String()) + require.NoError(t, err, "client %d", i) + + _, err = client.Write([]byte("hello")) + require.NoError(t, err) + + require.NoError(t, client.SetReadDeadline(time.Now().Add(2*time.Second))) + buf := make([]byte, 1024) + _, err = client.Read(buf) + require.NoError(t, err) + client.Close() + } + + obs.mu.Lock() + assert.Equal(t, 2, obs.started, "should have 2 started events") + obs.mu.Unlock() + + // Close should fire UDPSessionEnded for all remaining sessions. + relay.Close() + + obs.mu.Lock() + assert.Equal(t, 2, obs.ended, "Close should fire UDPSessionEnded for each session") + obs.mu.Unlock() +} + +func TestRelay_SessionRateLimit(t *testing.T) { + backend, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer backend.Close() + + go func() { + buf := make([]byte, 65535) + for { + n, addr, err := backend.ReadFrom(buf) + if err != nil { + return + } + _, _ = backend.WriteTo(buf[:n], addr) + } + }() + + listener, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := log.NewEntry(log.StandardLogger()) + dialFunc := func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial(network, address) + } + + obs := &testObserver{} + // High max sessions (1000) but the relay uses a rate limiter internally + // (default: 50/s burst 100). We exhaust the burst by creating sessions + // rapidly, then verify that subsequent creates are rejected. + relay := New(ctx, RelayConfig{Logger: logger, Listener: listener, Target: backend.LocalAddr().String(), AccountID: "test-acct", DialFunc: dialFunc, MaxSessions: 1000}) + relay.SetObserver(obs) + go relay.Serve() + defer relay.Close() + + // Exhaust the burst by calling getOrCreateSession directly with + // synthetic addresses. This is faster than real UDP round-trips. + for i := range sessionCreateBurst + 20 { + addr := &net.UDPAddr{IP: net.IPv4(10, 0, byte(i/256), byte(i%256)), Port: 10000 + i} + _, _ = relay.getOrCreateSession(addr) + } + + obs.mu.Lock() + rejected := obs.rejected + obs.mu.Unlock() + + assert.Greater(t, rejected, 0, "some sessions should be rate-limited") +} diff --git a/proxy/lifecycle.go b/proxy/lifecycle.go new file mode 100644 index 000000000..f8c74d8b5 --- /dev/null +++ b/proxy/lifecycle.go @@ -0,0 +1,176 @@ +package proxy + +import ( + "context" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/embed" + "github.com/netbirdio/netbird/proxy/internal/acme" + "github.com/netbirdio/netbird/trustedproxy" +) + +// Config bundles every knob the proxy reads at construction time. It mirrors +// the public fields on Server so library callers don't have to learn the +// internal struct layout. Zero values mean "feature off" or "fall back to the +// internal default" depending on the field — see the per-field doc. +// +// The standalone binary continues to populate Server fields directly, so +// adding fields here must not change the zero-value behaviour of Server. +type Config struct { + // ListenAddr is the TCP address the main listener binds. Required. + ListenAddr string + // ID identifies this proxy instance to management. Empty values are + // replaced with a timestamped default at Server.Start time (see + // initDefaults), not in New. + ID string + // Logger is the logrus logger used everywhere. Empty values fall + // back to log.StandardLogger() at Server.Start time (see + // initDefaults), not in New. + Logger *log.Logger + // Version is the build version string reported to management. Empty + // values are replaced with "dev" at Server.Start time (see + // initDefaults), not in New. + Version string + // ProxyURL is the public address operators use to reach this proxy. + ProxyURL string + // ManagementAddress is the gRPC URL of the management server. + ManagementAddress string + // ProxyToken authenticates this proxy with the management server. + ProxyToken string + + // CertificateDirectory is the directory holding TLS certificate + // material (static or ACME-provisioned). + CertificateDirectory string + // CertificateFile is the certificate filename within + // CertificateDirectory. + CertificateFile string + // CertificateKeyFile is the private key filename within + // CertificateDirectory. + CertificateKeyFile string + // GenerateACMECertificates toggles ACME certificate provisioning. + GenerateACMECertificates bool + // ACMEChallengeAddress is the listen address for HTTP-01 challenges. + ACMEChallengeAddress string + // ACMEDirectory is the ACME directory URL (Let's Encrypt by default). + ACMEDirectory string + // ACMEEABKID is the External Account Binding Key ID for CAs that + // require EAB (e.g. ZeroSSL). + ACMEEABKID string + // ACMEEABHMACKey is the External Account Binding HMAC key for CAs + // that require EAB. + ACMEEABHMACKey string + // ACMEChallengeType is the ACME challenge type ("tls-alpn-01" or + // "http-01"). Empty defaults to "tls-alpn-01". + ACMEChallengeType string + // CertLockMethod controls how ACME certificate locks are coordinated + // across replicas. + CertLockMethod acme.CertLockMethod + // WildcardCertDir is an optional directory containing static wildcard + // certificates that override ACME for matching domains. + WildcardCertDir string + + // DebugEndpointEnabled toggles the debug HTTP endpoint. + DebugEndpointEnabled bool + // DebugEndpointAddress is the bind address for the debug endpoint. + DebugEndpointAddress string + // HealthAddr is the bind address for the health probe and metrics + // surface. Empty disables the health probe entirely (library callers + // can attach their own). + HealthAddr string + + // ForwardedProto overrides the X-Forwarded-Proto value sent to + // backends. Valid values: "auto", "http", "https". + ForwardedProto string + // TrustedProxies is the set of trusted upstream proxies that may set + // forwarding headers. + TrustedProxies *trustedproxy.List + // WireguardPort is the UDP port for the embedded NetBird tunnel. + // Zero asks the OS for a random port. + WireguardPort uint16 + // ProxyProtocol enables PROXY protocol (v1/v2) on TCP listeners. + ProxyProtocol bool + // PreSharedKey is the WireGuard pre-shared key used between the + // proxy's embedded clients and peers. + PreSharedKey string + // Performance configures the tunnel pool/batch sizes for every + // embedded client this proxy creates. Zero values fall back to + // upstream defaults. + Performance embed.Performance + + // SupportsCustomPorts indicates whether the proxy can bind arbitrary + // ports for TCP/UDP/TLS services. + SupportsCustomPorts bool + // RequireSubdomain forces accounts to use a subdomain in front of + // the proxy's cluster domain. + RequireSubdomain bool + // Private flags this proxy as embedded in a netbird client and + // serving exclusively over the WireGuard tunnel. Also enables + // per-account inbound listeners on each embedded client's netstack. + Private bool + + // MaxDialTimeout caps the per-service backend dial timeout. + MaxDialTimeout time.Duration + // MaxSessionIdleTimeout caps the per-service session idle timeout. + MaxSessionIdleTimeout time.Duration + // MappingBatchWatchdog bounds how long a single mapping batch may spend + // being applied before the receive loop reconnects to resync. Zero falls + // back to the internal default. + MappingBatchWatchdog time.Duration + + // GeoDataDir is the directory containing GeoLite2 MMDB files. + GeoDataDir string + // CrowdSecAPIURL is the CrowdSec LAPI URL. Empty disables CrowdSec. + CrowdSecAPIURL string + // CrowdSecAPIKey is the CrowdSec bouncer API key. Empty disables + // CrowdSec. + CrowdSecAPIKey string +} + +// New builds a Server from cfg without performing any I/O. No goroutines +// are spawned, no network connections are dialed, and no listeners are +// bound — call Start to bring the proxy up. Returning a fully-formed +// Server keeps the standalone code path (which still constructs Server +// directly) byte-for-byte equivalent. +func New(ctx context.Context, cfg Config) *Server { + return &Server{ + ctx: ctx, + ListenAddr: cfg.ListenAddr, + ID: cfg.ID, + Logger: cfg.Logger, + Version: cfg.Version, + ProxyURL: cfg.ProxyURL, + ManagementAddress: cfg.ManagementAddress, + ProxyToken: cfg.ProxyToken, + CertificateDirectory: cfg.CertificateDirectory, + CertificateFile: cfg.CertificateFile, + CertificateKeyFile: cfg.CertificateKeyFile, + GenerateACMECertificates: cfg.GenerateACMECertificates, + ACMEChallengeAddress: cfg.ACMEChallengeAddress, + ACMEDirectory: cfg.ACMEDirectory, + ACMEEABKID: cfg.ACMEEABKID, + ACMEEABHMACKey: cfg.ACMEEABHMACKey, + ACMEChallengeType: cfg.ACMEChallengeType, + CertLockMethod: cfg.CertLockMethod, + WildcardCertDir: cfg.WildcardCertDir, + DebugEndpointEnabled: cfg.DebugEndpointEnabled, + DebugEndpointAddress: cfg.DebugEndpointAddress, + HealthAddress: cfg.HealthAddr, + ForwardedProto: cfg.ForwardedProto, + TrustedProxies: cfg.TrustedProxies, + WireguardPort: cfg.WireguardPort, + ProxyProtocol: cfg.ProxyProtocol, + PreSharedKey: cfg.PreSharedKey, + Performance: cfg.Performance, + SupportsCustomPorts: cfg.SupportsCustomPorts, + RequireSubdomain: cfg.RequireSubdomain, + Private: cfg.Private, + MaxDialTimeout: cfg.MaxDialTimeout, + MaxSessionIdleTimeout: cfg.MaxSessionIdleTimeout, + MappingBatchWatchdog: cfg.MappingBatchWatchdog, + GeoDataDir: cfg.GeoDataDir, + CrowdSecAPIURL: cfg.CrowdSecAPIURL, + CrowdSecAPIKey: cfg.CrowdSecAPIKey, + } +} diff --git a/proxy/log.go b/proxy/log.go new file mode 100644 index 000000000..79562989e --- /dev/null +++ b/proxy/log.go @@ -0,0 +1,21 @@ +package proxy + +import ( + stdlog "log" + + log "github.com/sirupsen/logrus" +) + +const ( + // HTTP server type identifiers for logging + logtagFieldHTTPServer = "http-server" + logtagValueHTTPS = "https" + logtagValueACME = "acme" + logtagValueDebug = "debug" +) + +// newHTTPServerLogger creates a standard library logger that writes to logrus +// with the specified server type field. +func newHTTPServerLogger(logger *log.Logger, serverType string) *stdlog.Logger { + return stdlog.New(logger.WithField(logtagFieldHTTPServer, serverType).WriterLevel(log.WarnLevel), "", 0) +} diff --git a/proxy/management_byop_integration_test.go b/proxy/management_byop_integration_test.go new file mode 100644 index 000000000..d075e47ec --- /dev/null +++ b/proxy/management_byop_integration_test.go @@ -0,0 +1,410 @@ +package proxy + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/metric/noop" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + grpcstatus "google.golang.org/grpc/status" + + proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + nbcache "github.com/netbirdio/netbird/management/server/cache" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/management/server/users" + "github.com/netbirdio/netbird/shared/management/proto" +) + +type byopTestSetup struct { + store store.Store + proxyService *nbgrpc.ProxyServiceServer + grpcServer *grpc.Server + grpcAddr string + cleanup func() + + accountA string + accountB string + accountAToken types.PlainProxyToken + accountBToken types.PlainProxyToken + accountACluster string + accountBCluster string +} + +func setupBYOPIntegrationTest(t *testing.T) *byopTestSetup { + t.Helper() + ctx := context.Background() + + testStore, storeCleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + + accountAID := "byop-account-a" + accountBID := "byop-account-b" + + for _, acc := range []*types.Account{ + {Id: accountAID, Domain: "a.test.com", DomainCategory: "private", IsDomainPrimaryAccount: true, CreatedAt: time.Now()}, + {Id: accountBID, Domain: "b.test.com", DomainCategory: "private", IsDomainPrimaryAccount: true, CreatedAt: time.Now()}, + } { + require.NoError(t, testStore.SaveAccount(ctx, acc)) + } + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + pubKey := base64.StdEncoding.EncodeToString(pub) + privKey := base64.StdEncoding.EncodeToString(priv) + + clusterA := "byop-a.proxy.test" + clusterB := "byop-b.proxy.test" + + services := []*service.Service{ + { + ID: "svc-a1", AccountID: accountAID, Name: "App A1", + Domain: "app1." + clusterA, ProxyCluster: clusterA, Enabled: true, + SessionPrivateKey: privKey, SessionPublicKey: pubKey, + Targets: []*service.Target{{Path: strPtr("/"), Host: "10.0.0.1", Port: 8080, Protocol: "http", TargetId: "peer-a1", TargetType: "peer", Enabled: true}}, + }, + { + ID: "svc-a2", AccountID: accountAID, Name: "App A2", + Domain: "app2." + clusterA, ProxyCluster: clusterA, Enabled: true, + SessionPrivateKey: privKey, SessionPublicKey: pubKey, + Targets: []*service.Target{{Path: strPtr("/"), Host: "10.0.0.2", Port: 8080, Protocol: "http", TargetId: "peer-a2", TargetType: "peer", Enabled: true}}, + }, + { + ID: "svc-b1", AccountID: accountBID, Name: "App B1", + Domain: "app1." + clusterB, ProxyCluster: clusterB, Enabled: true, + SessionPrivateKey: privKey, SessionPublicKey: pubKey, + Targets: []*service.Target{{Path: strPtr("/"), Host: "10.0.0.3", Port: 8080, Protocol: "http", TargetId: "peer-b1", TargetType: "peer", Enabled: true}}, + }, + } + for _, svc := range services { + require.NoError(t, testStore.CreateService(ctx, svc)) + } + + tokenA, err := types.CreateNewProxyAccessToken("byop-token-a", 0, &accountAID, "admin-a") + require.NoError(t, err) + require.NoError(t, testStore.SaveProxyAccessToken(ctx, &tokenA.ProxyAccessToken)) + + tokenB, err := types.CreateNewProxyAccessToken("byop-token-b", 0, &accountBID, "admin-b") + require.NoError(t, err) + require.NoError(t, testStore.SaveProxyAccessToken(ctx, &tokenB.ProxyAccessToken)) + + cacheStore, err := nbcache.NewStore(ctx, 30*time.Minute, 10*time.Minute, 100) + require.NoError(t, err) + + tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore) + pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, cacheStore) + + meter := noop.NewMeterProvider().Meter("test") + realProxyManager, err := proxymanager.NewManager(testStore, meter) + require.NoError(t, err) + + oidcConfig := nbgrpc.ProxyOIDCConfig{ + Issuer: "https://fake-issuer.example.com", + ClientID: "test-client", + HMACKey: []byte("test-hmac-key"), + } + + usersManager := users.NewManager(testStore) + + proxyService := nbgrpc.NewProxyServiceServer( + &testAccessLogManager{}, + tokenStore, + pkceStore, + oidcConfig, + nil, + usersManager, + nil, + realProxyManager, + nil, + ) + + svcMgr := &storeBackedServiceManager{store: testStore, tokenStore: tokenStore} + proxyService.SetServiceManager(svcMgr) + + proxyController := &testProxyController{} + proxyService.SetProxyController(proxyController) + + _, streamInterceptor, authClose := nbgrpc.NewProxyAuthInterceptors(testStore) + + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + grpcServer := grpc.NewServer(grpc.StreamInterceptor(streamInterceptor)) + proto.RegisterProxyServiceServer(grpcServer, proxyService) + + go func() { + if err := grpcServer.Serve(lis); err != nil { + t.Logf("gRPC server error: %v", err) + } + }() + + return &byopTestSetup{ + store: testStore, + proxyService: proxyService, + grpcServer: grpcServer, + grpcAddr: lis.Addr().String(), + cleanup: func() { + grpcServer.GracefulStop() + authClose() + storeCleanup() + }, + accountA: accountAID, + accountB: accountBID, + accountAToken: tokenA.PlainToken, + accountBToken: tokenB.PlainToken, + accountACluster: clusterA, + accountBCluster: clusterB, + } +} + +func byopContext(ctx context.Context, token types.PlainProxyToken) context.Context { + md := metadata.Pairs("authorization", "Bearer "+string(token)) + return metadata.NewOutgoingContext(ctx, md) +} + +func receiveBYOPMappings(t *testing.T, stream proto.ProxyService_GetMappingUpdateClient) []*proto.ProxyMapping { + t.Helper() + var mappings []*proto.ProxyMapping + for { + msg, err := stream.Recv() + require.NoError(t, err) + mappings = append(mappings, msg.GetMapping()...) + if msg.GetInitialSyncComplete() { + break + } + } + return mappings +} + +func TestIntegration_BYOPProxy_ReceivesOnlyAccountServices(t *testing.T) { + setup := setupBYOPIntegrationTest(t) + defer setup.cleanup() + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + + ctx, cancel := context.WithTimeout(byopContext(context.Background(), setup.accountAToken), 5*time.Second) + defer cancel() + + stream, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{ + ProxyId: "byop-proxy-a", + Version: "test-v1", + Address: setup.accountACluster, + }) + require.NoError(t, err) + + mappings := receiveBYOPMappings(t, stream) + + assert.Len(t, mappings, 2, "BYOP proxy should receive only account A's 2 services") + for _, m := range mappings { + assert.Equal(t, setup.accountA, m.GetAccountId(), "all mappings should belong to account A") + t.Logf("received mapping: id=%s domain=%s account=%s", m.GetId(), m.GetDomain(), m.GetAccountId()) + } + + ids := map[string]bool{} + for _, m := range mappings { + ids[m.GetId()] = true + } + assert.True(t, ids["svc-a1"], "should contain svc-a1") + assert.True(t, ids["svc-a2"], "should contain svc-a2") + assert.False(t, ids["svc-b1"], "should NOT contain account B's svc-b1") +} + +func TestIntegration_BYOPProxy_AccountBReceivesOnlyItsServices(t *testing.T) { + setup := setupBYOPIntegrationTest(t) + defer setup.cleanup() + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + + ctx, cancel := context.WithTimeout(byopContext(context.Background(), setup.accountBToken), 5*time.Second) + defer cancel() + + stream, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{ + ProxyId: "byop-proxy-b", + Version: "test-v1", + Address: setup.accountBCluster, + }) + require.NoError(t, err) + + mappings := receiveBYOPMappings(t, stream) + + assert.Len(t, mappings, 1, "BYOP proxy B should receive only 1 service") + assert.Equal(t, "svc-b1", mappings[0].GetId()) + assert.Equal(t, setup.accountB, mappings[0].GetAccountId()) +} + +func TestIntegration_BYOPProxy_MultiplePerAccount(t *testing.T) { + setup := setupBYOPIntegrationTest(t) + defer setup.cleanup() + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + + ctx1, cancel1 := context.WithTimeout(byopContext(context.Background(), setup.accountAToken), 5*time.Second) + defer cancel1() + + stream1, err := client.GetMappingUpdate(ctx1, &proto.GetMappingUpdateRequest{ + ProxyId: "byop-proxy-a-first", + Version: "test-v1", + Address: setup.accountACluster, + }) + require.NoError(t, err) + + mappings1 := receiveBYOPMappings(t, stream1) + assert.Len(t, mappings1, 2, "first BYOP proxy should receive account A's 2 services") + + ctx2, cancel2 := context.WithTimeout(byopContext(context.Background(), setup.accountAToken), 5*time.Second) + defer cancel2() + + stream2, err := client.GetMappingUpdate(ctx2, &proto.GetMappingUpdateRequest{ + ProxyId: "byop-proxy-a-second", + Version: "test-v1", + Address: setup.accountACluster, + }) + require.NoError(t, err) + + mappings2 := receiveBYOPMappings(t, stream2) + assert.Len(t, mappings2, 2, "second BYOP proxy from same account should also receive the 2 services") + for _, m := range mappings2 { + assert.Equal(t, setup.accountA, m.GetAccountId()) + } +} + +func TestIntegration_BYOPProxy_ClusterAddressConflict(t *testing.T) { + setup := setupBYOPIntegrationTest(t) + defer setup.cleanup() + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + + ctx1, cancel1 := context.WithTimeout(byopContext(context.Background(), setup.accountAToken), 5*time.Second) + defer cancel1() + + stream1, err := client.GetMappingUpdate(ctx1, &proto.GetMappingUpdateRequest{ + ProxyId: "byop-proxy-a-cluster", + Version: "test-v1", + Address: setup.accountACluster, + }) + require.NoError(t, err) + + _ = receiveBYOPMappings(t, stream1) + + ctx2, cancel2 := context.WithTimeout(byopContext(context.Background(), setup.accountBToken), 5*time.Second) + defer cancel2() + + stream2, err := client.GetMappingUpdate(ctx2, &proto.GetMappingUpdateRequest{ + ProxyId: "byop-proxy-b-conflict", + Version: "test-v1", + Address: setup.accountACluster, + }) + require.NoError(t, err) + + _, err = stream2.Recv() + require.Error(t, err) + + st, ok := grpcstatus.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.AlreadyExists, st.Code(), "cluster address conflict should return AlreadyExists") + t.Logf("expected rejection: %s", st.Message()) +} + +func TestIntegration_BYOPProxy_SameProxyReconnects(t *testing.T) { + setup := setupBYOPIntegrationTest(t) + defer setup.cleanup() + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + + proxyID := "byop-proxy-reconnect" + + ctx1, cancel1 := context.WithTimeout(byopContext(context.Background(), setup.accountAToken), 5*time.Second) + stream1, err := client.GetMappingUpdate(ctx1, &proto.GetMappingUpdateRequest{ + ProxyId: proxyID, + Version: "test-v1", + Address: setup.accountACluster, + }) + require.NoError(t, err) + + firstMappings := receiveBYOPMappings(t, stream1) + cancel1() + + time.Sleep(200 * time.Millisecond) + + ctx2, cancel2 := context.WithTimeout(byopContext(context.Background(), setup.accountAToken), 5*time.Second) + defer cancel2() + + stream2, err := client.GetMappingUpdate(ctx2, &proto.GetMappingUpdateRequest{ + ProxyId: proxyID, + Version: "test-v1", + Address: setup.accountACluster, + }) + require.NoError(t, err) + + secondMappings := receiveBYOPMappings(t, stream2) + + assert.Equal(t, len(firstMappings), len(secondMappings), "reconnect should receive same mappings") + + firstIDs := map[string]bool{} + for _, m := range firstMappings { + firstIDs[m.GetId()] = true + } + for _, m := range secondMappings { + assert.True(t, firstIDs[m.GetId()], "mapping %s should be present on reconnect", m.GetId()) + } +} + +func TestIntegration_BYOPProxy_UnauthenticatedRejected(t *testing.T) { + setup := setupBYOPIntegrationTest(t) + defer setup.cleanup() + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + stream, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{ + ProxyId: "no-auth-proxy", + Version: "test-v1", + Address: "some.cluster.io", + }) + require.NoError(t, err) + + _, err = stream.Recv() + require.Error(t, err) + + st, ok := grpcstatus.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Unauthenticated, st.Code()) +} diff --git a/proxy/management_integration_test.go b/proxy/management_integration_test.go new file mode 100644 index 000000000..cb82813b0 --- /dev/null +++ b/proxy/management_integration_test.go @@ -0,0 +1,779 @@ +package proxy + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "net" + "sync" + "sync/atomic" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + nbproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + nbcache "github.com/netbirdio/netbird/management/server/cache" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/management/server/users" + "github.com/netbirdio/netbird/proxy/internal/auth" + "github.com/netbirdio/netbird/proxy/internal/proxy" + proxytypes "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// integrationTestSetup contains all real components for testing. +type integrationTestSetup struct { + store store.Store + proxyService *nbgrpc.ProxyServiceServer + grpcServer *grpc.Server + grpcAddr string + cleanup func() + services []*service.Service +} + +func setupIntegrationTest(t *testing.T) *integrationTestSetup { + t.Helper() + + ctx := context.Background() + + // Create real SQLite store + testStore, cleanup, err := store.NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err) + + // Create test account + testAccount := &types.Account{ + Id: "test-account-1", + Domain: "test.com", + DomainCategory: "private", + IsDomainPrimaryAccount: true, + CreatedAt: time.Now(), + } + require.NoError(t, testStore.SaveAccount(ctx, testAccount)) + + // Generate session keys for reverse proxies + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + pubKey := base64.StdEncoding.EncodeToString(pub) + privKey := base64.StdEncoding.EncodeToString(priv) + + // Create test services in the store + services := []*service.Service{ + { + ID: "rp-1", + AccountID: "test-account-1", + Name: "Test App 1", + Domain: "app1.test.proxy.io", + Targets: []*service.Target{{ + Path: strPtr("/"), + Host: "10.0.0.1", + Port: 8080, + Protocol: "http", + TargetId: "peer1", + TargetType: "peer", + Enabled: true, + }}, + Enabled: true, + ProxyCluster: "test.proxy.io", + SessionPrivateKey: privKey, + SessionPublicKey: pubKey, + }, + { + ID: "rp-2", + AccountID: "test-account-1", + Name: "Test App 2", + Domain: "app2.test.proxy.io", + Targets: []*service.Target{{ + Path: strPtr("/"), + Host: "10.0.0.2", + Port: 8080, + Protocol: "http", + TargetId: "peer2", + TargetType: "peer", + Enabled: true, + }}, + Enabled: true, + ProxyCluster: "test.proxy.io", + SessionPrivateKey: privKey, + SessionPublicKey: pubKey, + }, + } + + for _, svc := range services { + require.NoError(t, testStore.CreateService(ctx, svc)) + } + + // Create real token store + cacheStore, err := nbcache.NewStore(ctx, 30*time.Minute, 10*time.Minute, 100) + require.NoError(t, err) + + tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore) + pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, cacheStore) + + // Create real users manager + usersManager := users.NewManager(testStore) + + // Create real proxy service server with minimal config + oidcConfig := nbgrpc.ProxyOIDCConfig{ + Issuer: "https://fake-issuer.example.com", + ClientID: "test-client", + HMACKey: []byte("test-hmac-key"), + } + + proxyManager := &testProxyManager{} + + proxyService := nbgrpc.NewProxyServiceServer( + &testAccessLogManager{}, + tokenStore, + pkceStore, + oidcConfig, + nil, + usersManager, + nil, + proxyManager, + nil, + ) + + // Use store-backed service manager + svcMgr := &storeBackedServiceManager{store: testStore, tokenStore: tokenStore} + proxyService.SetServiceManager(svcMgr) + + proxyController := &testProxyController{} + proxyService.SetProxyController(proxyController) + + // Start real gRPC server + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + grpcServer := grpc.NewServer() + proto.RegisterProxyServiceServer(grpcServer, proxyService) + + go func() { + if err := grpcServer.Serve(lis); err != nil { + t.Logf("gRPC server error: %v", err) + } + }() + + return &integrationTestSetup{ + store: testStore, + proxyService: proxyService, + grpcServer: grpcServer, + grpcAddr: lis.Addr().String(), + services: services, + cleanup: func() { + grpcServer.GracefulStop() + cleanup() + }, + } +} + +// testAccessLogManager provides access log storage for testing. +type testAccessLogManager struct{} + +func (m *testAccessLogManager) CleanupOldAccessLogs(ctx context.Context, retentionDays int) (int64, error) { + return 0, nil +} + +func (m *testAccessLogManager) StartPeriodicCleanup(ctx context.Context, retentionDays, cleanupIntervalHours int) { + // noop +} + +func (m *testAccessLogManager) StopPeriodicCleanup() { + // noop +} + +func (m *testAccessLogManager) SaveAccessLog(_ context.Context, _ *accesslogs.AccessLogEntry) error { + return nil +} + +func (m *testAccessLogManager) GetAllAccessLogs(_ context.Context, _, _ string, _ *accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { + return nil, 0, nil +} + +// testProxyManager is a mock implementation of proxy.Manager for testing. +type testProxyManager struct{} + +func (m *testProxyManager) Connect(_ context.Context, proxyID, sessionID, _, _ string, _ *string, _ *nbproxy.Capabilities) (*nbproxy.Proxy, error) { + return &nbproxy.Proxy{ID: proxyID, SessionID: sessionID, Status: nbproxy.StatusConnected}, nil +} + +func (m *testProxyManager) Disconnect(_ context.Context, _, _ string) error { + return nil +} + +func (m *testProxyManager) Heartbeat(_ context.Context, _ *nbproxy.Proxy) error { + return nil +} + +func (m *testProxyManager) GetActiveClusterAddresses(_ context.Context) ([]string, error) { + return nil, nil +} + +func (m *testProxyManager) GetActiveClusterAddressesForAccount(_ context.Context, _ string) ([]string, error) { + return nil, nil +} + +func (m *testProxyManager) GetActiveClusters(_ context.Context) ([]nbproxy.Cluster, error) { + return nil, nil +} + +func (m *testProxyManager) ClusterSupportsCustomPorts(_ context.Context, _ string) *bool { + return nil +} + +func (m *testProxyManager) ClusterRequireSubdomain(_ context.Context, _ string) *bool { + return nil +} + +func (m *testProxyManager) ClusterSupportsCrowdSec(_ context.Context, _ string) *bool { + return nil +} + +func (m *testProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) *bool { + return nil +} + +func (m *testProxyManager) CleanupStale(_ context.Context, _ time.Duration) error { + return nil +} + +func (m *testProxyManager) GetAccountProxy(_ context.Context, accountID string) (*nbproxy.Proxy, error) { + return nil, fmt.Errorf("proxy not found for account %s", accountID) +} + +func (m *testProxyManager) CountAccountProxies(_ context.Context, _ string) (int64, error) { + return 0, nil +} + +func (m *testProxyManager) IsClusterAddressAvailable(_ context.Context, _, _ string) (bool, error) { + return true, nil +} + +func (m *testProxyManager) DeleteAccountCluster(_ context.Context, _, _ string) error { + return nil +} + +// testProxyController is a mock implementation of rpservice.ProxyController for testing. +type testProxyController struct{} + +func (c *testProxyController) SendServiceUpdateToCluster(_ context.Context, _ string, _ *proto.ProxyMapping, _ string) { + // noop +} + +func (c *testProxyController) GetOIDCValidationConfig() nbproxy.OIDCValidationConfig { + return nbproxy.OIDCValidationConfig{} +} + +func (c *testProxyController) RegisterProxyToCluster(_ context.Context, _, _ string) error { + return nil +} + +func (c *testProxyController) UnregisterProxyFromCluster(_ context.Context, _, _ string) error { + return nil +} + +func (c *testProxyController) GetProxiesForCluster(_ string) []string { + return nil +} + +// storeBackedServiceManager reads directly from the real store. +type storeBackedServiceManager struct { + store store.Store + tokenStore *nbgrpc.OneTimeTokenStore +} + +func (m *storeBackedServiceManager) DeleteAllServices(ctx context.Context, accountID, userID string) error { + return nil +} + +func (m *storeBackedServiceManager) GetAllServices(ctx context.Context, accountID, userID string) ([]*service.Service, error) { + return m.store.GetAccountServices(ctx, store.LockingStrengthNone, accountID) +} + +func (m *storeBackedServiceManager) GetService(ctx context.Context, accountID, userID, serviceID string) (*service.Service, error) { + return m.store.GetServiceByID(ctx, store.LockingStrengthNone, accountID, serviceID) +} + +func (m *storeBackedServiceManager) CreateService(_ context.Context, _, _ string, _ *service.Service) (*service.Service, error) { + return nil, errors.New("not implemented") +} + +func (m *storeBackedServiceManager) UpdateService(_ context.Context, _, _ string, _ *service.Service) (*service.Service, error) { + return nil, errors.New("not implemented") +} + +func (m *storeBackedServiceManager) DeleteService(ctx context.Context, accountID, userID, serviceID string) error { + return nil +} + +func (m *storeBackedServiceManager) DeleteAccountCluster(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *storeBackedServiceManager) SetCertificateIssuedAt(ctx context.Context, accountID, serviceID string) error { + return nil +} + +func (m *storeBackedServiceManager) SetStatus(ctx context.Context, accountID, serviceID string, status service.Status) error { + return nil +} + +func (m *storeBackedServiceManager) ReloadAllServicesForAccount(ctx context.Context, accountID string) error { + return nil +} + +func (m *storeBackedServiceManager) ReloadService(ctx context.Context, accountID, serviceID string) error { + return nil +} + +func (m *storeBackedServiceManager) GetGlobalServices(ctx context.Context) ([]*service.Service, error) { + return m.store.GetAccountServices(ctx, store.LockingStrengthNone, "test-account-1") +} + +func (m *storeBackedServiceManager) GetServiceByID(ctx context.Context, accountID, serviceID string) (*service.Service, error) { + return m.store.GetServiceByID(ctx, store.LockingStrengthNone, accountID, serviceID) +} + +func (m *storeBackedServiceManager) GetAccountServices(ctx context.Context, accountID string) ([]*service.Service, error) { + return m.store.GetAccountServices(ctx, store.LockingStrengthNone, accountID) +} + +func (m *storeBackedServiceManager) GetServiceIDByTargetID(ctx context.Context, accountID string, targetID string) (string, error) { + return "", nil +} + +func (m *storeBackedServiceManager) CreateServiceFromPeer(_ context.Context, _, _ string, _ *service.ExposeServiceRequest) (*service.ExposeServiceResponse, error) { + return &service.ExposeServiceResponse{}, nil +} + +func (m *storeBackedServiceManager) RenewServiceFromPeer(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *storeBackedServiceManager) StopServiceFromPeer(_ context.Context, _, _, _ string) error { + return nil +} + +func (m *storeBackedServiceManager) StartExposeReaper(_ context.Context) {} + +func (m *storeBackedServiceManager) GetServiceByDomain(ctx context.Context, domain string) (*service.Service, error) { + return m.store.GetServiceByDomain(ctx, domain) +} + +func (m *storeBackedServiceManager) GetClusters(_ context.Context, _, _ string) ([]nbproxy.Cluster, error) { + return nil, nil +} + +func strPtr(s string) *string { + return &s +} + +func TestIntegration_ProxyConnection_HappyPath(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + stream, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{ + ProxyId: "test-proxy-1", + Version: "test-v1", + Address: "test.proxy.io", + }) + require.NoError(t, err) + + mappingsByID := make(map[string]*proto.ProxyMapping) + for { + msg, err := stream.Recv() + require.NoError(t, err) + for _, m := range msg.GetMapping() { + mappingsByID[m.GetId()] = m + } + if msg.GetInitialSyncComplete() { + break + } + } + + // Should receive 2 mappings total + assert.Len(t, mappingsByID, 2, "Should receive 2 reverse proxy mappings") + + rp1 := mappingsByID["rp-1"] + require.NotNil(t, rp1) + assert.Equal(t, "app1.test.proxy.io", rp1.GetDomain()) + assert.Equal(t, "test-account-1", rp1.GetAccountId()) + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, rp1.GetType()) + assert.NotEmpty(t, rp1.GetAuthToken(), "Should have auth token for peer creation") + + rp2 := mappingsByID["rp-2"] + require.NotNil(t, rp2) + assert.Equal(t, "app2.test.proxy.io", rp2.GetDomain()) +} + +func TestIntegration_ProxyConnection_SendsClusterAddress(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + clusterAddress := "test.proxy.io" + + stream, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{ + ProxyId: "test-proxy-cluster", + Version: "test-v1", + Address: clusterAddress, + }) + require.NoError(t, err) + + mappings := make([]*proto.ProxyMapping, 0) + for { + msg, err := stream.Recv() + require.NoError(t, err) + mappings = append(mappings, msg.GetMapping()...) + if msg.GetInitialSyncComplete() { + break + } + } + + // Should receive the 2 mappings matching the cluster + assert.Len(t, mappings, 2, "Should receive mappings for the cluster") + + for _, mapping := range mappings { + t.Logf("Received mapping: id=%s domain=%s", mapping.GetId(), mapping.GetDomain()) + } +} + +func TestIntegration_ProxyConnection_Reconnect_ReceivesSameConfig(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + + clusterAddress := "test.proxy.io" + proxyID := "test-proxy-reconnect" + + receiveMappings := func(stream proto.ProxyService_GetMappingUpdateClient) []*proto.ProxyMapping { + var mappings []*proto.ProxyMapping + for { + msg, err := stream.Recv() + require.NoError(t, err) + mappings = append(mappings, msg.GetMapping()...) + if msg.GetInitialSyncComplete() { + break + } + } + return mappings + } + + // First connection + ctx1, cancel1 := context.WithTimeout(context.Background(), 5*time.Second) + stream1, err := client.GetMappingUpdate(ctx1, &proto.GetMappingUpdateRequest{ + ProxyId: proxyID, + Version: "test-v1", + Address: clusterAddress, + }) + require.NoError(t, err) + + firstMappings := receiveMappings(stream1) + cancel1() + + time.Sleep(100 * time.Millisecond) + + // Second connection (simulating reconnect) + ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel2() + + stream2, err := client.GetMappingUpdate(ctx2, &proto.GetMappingUpdateRequest{ + ProxyId: proxyID, + Version: "test-v1", + Address: clusterAddress, + }) + require.NoError(t, err) + + secondMappings := receiveMappings(stream2) + + // Should receive the same mappings + assert.Equal(t, len(firstMappings), len(secondMappings), + "Should receive same number of mappings on reconnect") + + firstIDs := make(map[string]bool) + for _, m := range firstMappings { + firstIDs[m.GetId()] = true + } + + for _, m := range secondMappings { + assert.True(t, firstIDs[m.GetId()], + "Mapping %s should be present in both connections", m.GetId()) + } +} + +func TestIntegration_ProxyConnection_ReconnectDoesNotDuplicateState(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + + // Use real auth middleware and proxy to verify idempotency + logger := log.New() + logger.SetLevel(log.WarnLevel) + + authMw := auth.NewMiddleware(logger, nil, nil) + proxyHandler := proxy.NewReverseProxy(nil, "auto", nil, logger) + + clusterAddress := "test.proxy.io" + proxyID := "test-proxy-idempotent" + + var addMappingCalls atomic.Int32 + + applyMappings := func(mappings []*proto.ProxyMapping) { + for _, mapping := range mappings { + if mapping.GetType() == proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED { + addMappingCalls.Add(1) + + // Apply to real auth middleware (idempotent) + err := authMw.AddDomain( + mapping.GetDomain(), + nil, + "", + 0, + proxytypes.AccountID(mapping.GetAccountId()), + proxytypes.ServiceID(mapping.GetId()), + nil, + mapping.GetPrivate(), + ) + require.NoError(t, err) + + // Apply to real proxy (idempotent) + proxyHandler.AddMapping(proxy.Mapping{ + Host: mapping.GetDomain(), + ID: proxytypes.ServiceID(mapping.GetId()), + AccountID: proxytypes.AccountID(mapping.GetAccountId()), + }) + } + } + } + + receiveAndApply := func(stream proto.ProxyService_GetMappingUpdateClient) { + for { + msg, err := stream.Recv() + require.NoError(t, err) + applyMappings(msg.GetMapping()) + if msg.GetInitialSyncComplete() { + break + } + } + } + + // First connection + ctx1, cancel1 := context.WithTimeout(context.Background(), 5*time.Second) + stream1, err := client.GetMappingUpdate(ctx1, &proto.GetMappingUpdateRequest{ + ProxyId: proxyID, + Version: "test-v1", + Address: clusterAddress, + }) + require.NoError(t, err) + + receiveAndApply(stream1) + cancel1() + + firstCallCount := addMappingCalls.Load() + t.Logf("First connection: applied %d mappings", firstCallCount) + + time.Sleep(100 * time.Millisecond) + + // Second connection + ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second) + stream2, err := client.GetMappingUpdate(ctx2, &proto.GetMappingUpdateRequest{ + ProxyId: proxyID, + Version: "test-v1", + Address: clusterAddress, + }) + require.NoError(t, err) + + receiveAndApply(stream2) + cancel2() + + time.Sleep(100 * time.Millisecond) + + // Third connection + ctx3, cancel3 := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel3() + + stream3, err := client.GetMappingUpdate(ctx3, &proto.GetMappingUpdateRequest{ + ProxyId: proxyID, + Version: "test-v1", + Address: clusterAddress, + }) + require.NoError(t, err) + + receiveAndApply(stream3) + + totalCalls := addMappingCalls.Load() + t.Logf("After three connections: total applied %d mappings", totalCalls) + + // Should have called addMapping 6 times (2 mappings x 3 connections) + // But internal state is NOT duplicated because auth and proxy use maps keyed by domain/host + assert.Equal(t, int32(6), totalCalls, "Should have 6 total calls (2 mappings x 3 connections)") +} + +func TestIntegration_ProxyConnection_MultipleProxiesReceiveUpdates(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + clusterAddress := "test.proxy.io" + + var wg sync.WaitGroup + var mu sync.Mutex + receivedByProxy := make(map[string]int) + + for i := 1; i <= 3; i++ { + wg.Add(1) + go func(proxyNum int) { + defer wg.Done() + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + proxyID := "test-proxy-" + string(rune('A'+proxyNum-1)) + + stream, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{ + ProxyId: proxyID, + Version: "test-v1", + Address: clusterAddress, + }) + require.NoError(t, err) + + count := 0 + for { + msg, err := stream.Recv() + require.NoError(t, err) + count += len(msg.GetMapping()) + if msg.GetInitialSyncComplete() { + break + } + } + + mu.Lock() + receivedByProxy[proxyID] = count + mu.Unlock() + }(i) + } + + wg.Wait() + + for proxyID, count := range receivedByProxy { + assert.Equal(t, 2, count, "Proxy %s should receive 2 mappings", proxyID) + } +} + +// TestIntegration_ProxyConnection_FastReconnectDoesNotLoseState verifies that +// when a proxy reconnects before the old stream's cleanup runs, the new +// connection is NOT removed by the stale defer. +func TestIntegration_ProxyConnection_FastReconnectDoesNotLoseState(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + clusterAddress := "test.proxy.io" + proxyID := "test-proxy-race" + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + + ctx1, cancel1 := context.WithCancel(context.Background()) + stream1, err := client.GetMappingUpdate(ctx1, &proto.GetMappingUpdateRequest{ + ProxyId: proxyID, + Version: "test-v1", + Address: clusterAddress, + }) + require.NoError(t, err) + + for { + msg, err := stream1.Recv() + require.NoError(t, err) + if msg.GetInitialSyncComplete() { + break + } + } + + require.Contains(t, setup.proxyService.GetConnectedProxies(), proxyID, + "proxy should be registered after first connection") + + ctx2, cancel2 := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel2() + + stream2, err := client.GetMappingUpdate(ctx2, &proto.GetMappingUpdateRequest{ + ProxyId: proxyID, + Version: "test-v1", + Address: clusterAddress, + }) + require.NoError(t, err) + + for { + msg, err := stream2.Recv() + require.NoError(t, err) + if msg.GetInitialSyncComplete() { + break + } + } + + cancel1() + + time.Sleep(200 * time.Millisecond) + + assert.Contains(t, setup.proxyService.GetConnectedProxies(), proxyID, + "proxy should still be registered after old connection cleanup — old defer must not remove new connection") + + setup.proxyService.SendServiceUpdate(&proto.GetMappingUpdateResponse{ + Mapping: []*proto.ProxyMapping{{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, + Id: "rp-1", + AccountId: "test-account-1", + Domain: "app1.test.proxy.io", + }}, + }) + + msg, err := stream2.Recv() + require.NoError(t, err, "new stream should still receive updates") + require.NotEmpty(t, msg.GetMapping(), "update should contain the mapping") + assert.Equal(t, "rp-1", msg.GetMapping()[0].GetId()) +} diff --git a/proxy/mapping_stall_test.go b/proxy/mapping_stall_test.go new file mode 100644 index 000000000..acf313d19 --- /dev/null +++ b/proxy/mapping_stall_test.go @@ -0,0 +1,282 @@ +package proxy + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + + "github.com/netbirdio/netbird/proxy/internal/roundtrip" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// blockingMgmtClient implements roundtrip's managementClient interface. +// CreateProxyPeer parks until release is closed, signalling entry on entered. +// This reproduces the confirmed real-world stall: createClientEntry calls +// CreateProxyPeer synchronously while holding clientsMux, and the proxy's +// receive loop calls that path synchronously inside processMappings. +type blockingMgmtClient struct { + entered chan struct{} + once sync.Once +} + +func (b *blockingMgmtClient) CreateProxyPeer(ctx context.Context, _ *proto.CreateProxyPeerRequest, _ ...grpc.CallOption) (*proto.CreateProxyPeerResponse, error) { + b.once.Do(func() { close(b.entered) }) + // Park until the caller's context is cancelled. In production this ctx is + // the gRPC mapping-stream context with no per-call timeout, so a slow or + // unresponsive CreateProxyPeer parks the receive loop here indefinitely. + <-ctx.Done() + return nil, ctx.Err() +} + +// gatedMappingStream is a mock GetMappingUpdate client stream that hands out a +// pre-seeded list of messages, then records how many times Recv advanced. It +// lets the test observe whether the single-threaded receive loop ever gets +// past the first (blocking) batch to pull the second message. +type gatedMappingStream struct { + grpc.ClientStream + messages []*proto.GetMappingUpdateResponse + idx int32 +} + +func (g *gatedMappingStream) Recv() (*proto.GetMappingUpdateResponse, error) { + i := int(atomic.LoadInt32(&g.idx)) + if i >= len(g.messages) { + // Block instead of returning EOF so the loop doesn't exit; we only + // care whether the loop ever reaches this second Recv at all. + select {} + } + msg := g.messages[i] + atomic.AddInt32(&g.idx, 1) + return msg, nil +} + +func (g *gatedMappingStream) deliveredCount() int32 { return atomic.LoadInt32(&g.idx) } + +func (g *gatedMappingStream) Header() (metadata.MD, error) { return nil, nil } //nolint:nilnil +func (g *gatedMappingStream) Trailer() metadata.MD { return nil } +func (g *gatedMappingStream) CloseSend() error { return nil } +func (g *gatedMappingStream) Context() context.Context { return context.Background() } +func (g *gatedMappingStream) SendMsg(any) error { return nil } +func (g *gatedMappingStream) RecvMsg(any) error { return nil } + +// noopNotifier satisfies roundtrip's statusNotifier interface. +type noopNotifier struct{} + +func (noopNotifier) NotifyStatus(context.Context, types.AccountID, types.ServiceID, bool) error { + return nil +} + +// noopProxyClient is a proto.ProxyServiceClient that no-ops the one method the +// teardown unwind reaches (SendStatusUpdate, via notifyError when the parked +// AddPeer is cancelled). The embedded nil interface satisfies the rest at +// compile time; none of those methods are called by this test. +type noopProxyClient struct { + proto.ProxyServiceClient +} + +func (noopProxyClient) SendStatusUpdate(context.Context, *proto.SendStatusUpdateRequest, ...grpc.CallOption) (*proto.SendStatusUpdateResponse, error) { + return &proto.SendStatusUpdateResponse{}, nil +} + +// TestMappingStream_StallsWhenApplyBlocks proves the deadlock: the proxy's +// mapping receive loop processes batches strictly serially, so when applying +// one batch blocks (here: createClientEntry parked on a synchronous +// CreateProxyPeer call, exactly as observed in production), the loop never +// advances to Recv the next batch. Management can keep sending updates onto +// the stream with no error and no channel overflow, yet the proxy applies +// nothing further — it is stuck. +func TestMappingStream_StallsWhenApplyBlocks(t *testing.T) { + logger := log.New() + logger.SetLevel(log.PanicLevel) + + mgmt := &blockingMgmtClient{ + entered: make(chan struct{}), + } + + nb := roundtrip.NewNetBird( + context.Background(), + "proxy-test", + "proxy.example.com", + roundtrip.ClientConfig{}, + logger, + noopNotifier{}, + mgmt, + ) + + s := &Server{ + Logger: logger, + netbird: nb, + mgmtClient: noopProxyClient{}, + routerReady: closedChan(), + lastMappings: make(map[types.ServiceID]*proto.ProxyMapping), + } + + // First batch: a CREATED mapping for a brand-new account. addMapping -> + // netbird.AddPeer -> createClientEntry -> CreateProxyPeer, which blocks. + // Empty Path keeps setupHTTPMapping a no-op (it returns early), so the + // ONLY blocking point is the synchronous CreateProxyPeer in AddPeer — + // no routers/auth need wiring. The second batch exists only to detect + // whether the loop ever advances past the blocked first batch. + stream := &gatedMappingStream{ + messages: []*proto.GetMappingUpdateResponse{ + { + Mapping: []*proto.ProxyMapping{ + { + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, + Id: "svc-1", + AccountId: "acct-1", + AuthToken: "token-1", + }, + }, + }, + { + Mapping: []*proto.ProxyMapping{ + { + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, + Id: "svc-2", + AccountId: "acct-2", + AuthToken: "token-2", + }, + }, + }, + }, + } + + ctx, cancel := context.WithCancel(context.Background()) + // Unblock the parked apply on teardown via ctx (CreateProxyPeer returns + // ctx.Err()), so the wedged loop goroutine unwinds before embed.New — + // avoiding any dependency on collaborators this test deliberately leaves + // nil. The deadlock is fully proven before this fires. + t.Cleanup(cancel) + + loopDone := make(chan struct{}) + syncDone := false + go func() { + defer close(loopDone) + _ = s.handleMappingStream(ctx, stream, &syncDone, time.Time{}) + }() + + // The loop must reach the blocking apply for the first batch. + select { + case <-mgmt.entered: + case <-time.After(2 * time.Second): + t.Fatal("receive loop never reached CreateProxyPeer for the first batch") + } + + // THE DEADLOCK: while the first batch is parked in CreateProxyPeer, the + // single-threaded loop cannot advance. The second batch is never pulled, + // even though it is already available on the stream. Give it ample time. + // deliveredCount is atomic; syncDone is intentionally not read here because + // the loop goroutine owns it (reading it from the test would race). + time.Sleep(500 * time.Millisecond) + assert.Equal(t, int32(1), stream.deliveredCount(), + "loop must NOT consume the second batch while the first is blocked in apply — proxy is stuck") + + select { + case <-loopDone: + t.Fatal("receive loop returned while it should be wedged in apply") + default: + // Still wedged, as expected. + } +} + +// TestMappingStream_StallsWhenRemoveBlocks proves the deadlock for the REMOVE +// path observed in production: a mapping remove tears down the account's last +// embedded client via netbird.RemovePeer -> client.Stop -> Engine.Stop, whose +// jobExecutorWG.Wait() is unbounded. Because the receive loop is single- +// threaded, a blocked remove wedges the loop: no further mapping updates of any +// kind (create/modify/remove) are applied, while management keeps sending them +// successfully (no send error, no channel-full). Matches the reported symptom: +// the last log line is a remove that stops a client, then silence. +func TestMappingStream_StallsWhenRemoveBlocks(t *testing.T) { + logger := log.New() + logger.SetLevel(log.PanicLevel) + + enteredRemove := make(chan struct{}) + blockRemove := make(chan struct{}) + var once sync.Once + + s := &Server{ + Logger: logger, + mgmtClient: noopProxyClient{}, + routerReady: closedChan(), + lastMappings: make(map[types.ServiceID]*proto.ProxyMapping), + // Stand in for netbird.RemovePeer -> client.Stop hanging on + // Engine.Stop's unbounded jobExecutorWG.Wait(). Only the first remove + // blocks; later removes return immediately so the recovery assertion + // can observe the loop advancing. + removePeer: func(ctx context.Context, _ types.AccountID, _ roundtrip.ServiceKey) error { + first := false + once.Do(func() { + first = true + close(enteredRemove) + }) + if !first { + return nil + } + select { + case <-blockRemove: + case <-ctx.Done(): + } + return nil + }, + } + + // Batch 1 removes a service (blocks in teardown). Batch 2 is a later update + // that must never be applied while the remove is wedged. + stream := &gatedMappingStream{ + messages: []*proto.GetMappingUpdateResponse{ + { + Mapping: []*proto.ProxyMapping{ + {Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, Id: "svc-1", AccountId: "acct-1"}, + }, + }, + { + Mapping: []*proto.ProxyMapping{ + {Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, Id: "svc-2", AccountId: "acct-1"}, + }, + }, + }, + } + + loopDone := make(chan struct{}) + syncDone := false + go func() { + defer close(loopDone) + _ = s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{}) + }() + + select { + case <-enteredRemove: + case <-time.After(2 * time.Second): + t.Fatal("receive loop never reached the blocking remove for the first batch") + } + + // THE DEADLOCK: the loop is parked in the blocked remove and cannot advance. + // syncDone is owned by the loop goroutine, so it is not read here. + time.Sleep(500 * time.Millisecond) + assert.Equal(t, int32(1), stream.deliveredCount(), + "loop must NOT consume the second batch while the first remove is blocked — proxy is stuck") + + select { + case <-loopDone: + t.Fatal("receive loop returned while it should be wedged on the remove") + default: + } + + // Unblock and confirm the wedge was solely the blocked remove: the loop + // then advances and consumes the next batch. + close(blockRemove) + assert.Eventually(t, func() bool { + return stream.deliveredCount() >= 2 + }, 2*time.Second, 5*time.Millisecond, + "once the remove unblocks, the loop must advance and consume the next batch") +} diff --git a/proxy/middleware_register.go b/proxy/middleware_register.go new file mode 100644 index 000000000..736ee04c1 --- /dev/null +++ b/proxy/middleware_register.go @@ -0,0 +1,16 @@ +package proxy + +// Anonymous imports trigger init() in each built-in middleware +// sub-package so they self-register into mwbuiltin.DefaultRegistry() +// before initMiddlewareManager builds the resolver. Add a new line +// here when introducing another built-in middleware. +import ( + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/cost_meter" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_identity_inject" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_limit_check" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_limit_record" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_request_parser" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_response_parser" + _ "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_router" +) diff --git a/proxy/middleware_translate.go b/proxy/middleware_translate.go new file mode 100644 index 000000000..c5d9fe016 --- /dev/null +++ b/proxy/middleware_translate.go @@ -0,0 +1,165 @@ +package proxy + +import ( + "context" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/bodytap" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// translateMiddlewareCaptureConfig builds the per-target capture +// limits used by the middleware chain. Returns nil when the options +// are nil or no capture field is set. Negative caps are normalised to +// zero; oversized caps are clamped to middleware.MaxBodyCapBytes. +func translateMiddlewareCaptureConfig(targetID string, opts *proto.PathTargetOptions) *bodytap.Config { + if opts == nil { + return nil + } + reqCap := clampMiddlewareCaptureBytes(targetID, "request", opts.GetCaptureMaxRequestBytes()) + respCap := clampMiddlewareCaptureBytes(targetID, "response", opts.GetCaptureMaxResponseBytes()) + types := opts.GetCaptureContentTypes() + if reqCap == 0 && respCap == 0 && len(types) == 0 { + return nil + } + return &bodytap.Config{ + MaxRequestBytes: reqCap, + MaxResponseBytes: respCap, + ContentTypes: types, + } +} + +func clampMiddlewareCaptureBytes(targetID, direction string, v int64) int64 { + if v < 0 { + log.Debugf("target %s %s capture cap %d clamped to 0", targetID, direction, v) + return 0 + } + if v > middleware.MaxBodyCapBytes { + log.Debugf("target %s %s capture cap %d clamped to %d", targetID, direction, v, middleware.MaxBodyCapBytes) + return middleware.MaxBodyCapBytes + } + return v +} + +// translateMiddlewareConfigs converts the proto MiddlewareConfig list +// into validated middleware.Spec values. The list is truncated to +// middleware.MaxMiddlewaresPerChain when the caller exceeds the cap. +// Entries with empty IDs, unknown IDs (when registry is non-nil), or +// unspecified slots are skipped with a warn log. Timeouts are clamped +// to [MinTimeout, MaxTimeout] and zero substitutes for DefaultTimeout. +// Returns nil when the resulting slice is empty so callers can leave +// PathTarget.Middlewares unset. +func translateMiddlewareConfigs( + ctx context.Context, + targetID string, + in []*proto.MiddlewareConfig, + registry *middleware.Registry, +) []middleware.Spec { + _ = ctx + if len(in) == 0 { + return nil + } + if len(in) > middleware.MaxMiddlewaresPerChain { + log.Warnf("middleware list for target %q truncated: %d entries exceeds cap of %d", + targetID, len(in), middleware.MaxMiddlewaresPerChain) + in = in[:middleware.MaxMiddlewaresPerChain] + } + + out := make([]middleware.Spec, 0, len(in)) + for _, cfg := range in { + spec, ok := translateMiddlewareConfig(targetID, cfg, registry) + if !ok { + continue + } + out = append(out, spec) + } + if len(out) == 0 { + return nil + } + return out +} + +// translateMiddlewareConfig validates and converts a single +// MiddlewareConfig. The second return value is false when the entry +// must be dropped from the chain. +func translateMiddlewareConfig(targetID string, cfg *proto.MiddlewareConfig, registry *middleware.Registry) (middleware.Spec, bool) { + if cfg == nil { + return middleware.Spec{}, false + } + id := cfg.GetId() + if id == "" { + log.Warnf("middleware config for target %q dropped: empty middleware id", targetID) + return middleware.Spec{}, false + } + if registry != nil && !registry.IsKnown(id) { + log.Warnf("unknown middleware %q configured for target %s; dropping", id, targetID) + return middleware.Spec{}, false + } + slot, ok := protoToMiddlewareSlot(cfg.GetSlot()) + if !ok { + log.Warnf("middleware %q on target %q dropped: slot is unspecified", id, targetID) + return middleware.Spec{}, false + } + + var rawConfig []byte + if src := cfg.GetConfigJson(); len(src) > 0 { + rawConfig = append([]byte(nil), src...) + } + + return middleware.Spec{ + ID: id, + Slot: slot, + Enabled: cfg.GetEnabled(), + FailMode: protoToMiddlewareFailMode(cfg.GetFailMode()), + Timeout: clampMiddlewareTimeout(id, cfg.GetTimeout().AsDuration()), + RawConfig: rawConfig, + CanMutate: cfg.GetCanMutate(), + }, true +} + +// protoToMiddlewareSlot maps the proto slot enum onto the internal +// middleware.Slot. Returns ok=false for the UNSPECIFIED value so the +// translator can drop the entry. +func protoToMiddlewareSlot(s proto.MiddlewareSlot) (middleware.Slot, bool) { + switch s { + case proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST: + return middleware.SlotOnRequest, true + case proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE: + return middleware.SlotOnResponse, true + case proto.MiddlewareSlot_MIDDLEWARE_SLOT_TERMINAL: + return middleware.SlotTerminal, true + default: + return 0, false + } +} + +// protoToMiddlewareFailMode maps the proto FailMode enum onto the +// internal middleware.FailMode, defaulting to FailOpen for any value +// other than FAIL_CLOSED. +func protoToMiddlewareFailMode(m proto.MiddlewareConfig_FailMode) middleware.FailMode { + if m == proto.MiddlewareConfig_FAIL_CLOSED { + return middleware.FailClosed + } + return middleware.FailOpen +} + +// clampMiddlewareTimeout enforces the proxy-wide [MinTimeout, MaxTimeout] +// bounds and substitutes DefaultTimeout for zero inputs. A warn is logged +// only on an actual clamp, not when filling the default. +func clampMiddlewareTimeout(id string, d time.Duration) time.Duration { + if d <= 0 { + return middleware.DefaultTimeout + } + if d < middleware.MinTimeout { + log.Debugf("middleware %s timeout %s clamped to %s", id, d, middleware.MinTimeout) + return middleware.MinTimeout + } + if d > middleware.MaxTimeout { + log.Debugf("middleware %s timeout %s clamped to %s", id, d, middleware.MaxTimeout) + return middleware.MaxTimeout + } + return d +} diff --git a/proxy/middleware_translate_test.go b/proxy/middleware_translate_test.go new file mode 100644 index 000000000..1a956090c --- /dev/null +++ b/proxy/middleware_translate_test.go @@ -0,0 +1,246 @@ +package proxy + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// stubFactory builds a stub Middleware so the registry's IsKnown check +// passes for the configured id. The translator never invokes the +// middleware, so the methods only need to satisfy the interface. +type stubFactory struct { + id string + slot middleware.Slot +} + +func (f stubFactory) ID() string { return f.id } +func (f stubFactory) New(_ []byte) (middleware.Middleware, error) { + return stubMiddleware(f), nil +} + +type stubMiddleware struct { + id string + slot middleware.Slot +} + +func (m stubMiddleware) ID() string { return m.id } +func (m stubMiddleware) Version() string { return "test" } +func (m stubMiddleware) Slot() middleware.Slot { return m.slot } +func (m stubMiddleware) AcceptedContentTypes() []string { return nil } +func (m stubMiddleware) MetadataKeys() []string { return nil } +func (m stubMiddleware) MutationsSupported() bool { return false } +func (m stubMiddleware) Close() error { return nil } +func (m stubMiddleware) Invoke(context.Context, *middleware.Input) (*middleware.Output, error) { + panic("stubMiddleware.Invoke must not be called in translator tests") +} + +// newTestRegistry returns a fresh registry pre-populated with the given +// middleware ids in the matching slot. +func newTestRegistry(t *testing.T, entries map[string]middleware.Slot) *middleware.Registry { + t.Helper() + r := middleware.NewRegistry() + for id, slot := range entries { + require.NoError(t, r.Register(stubFactory{id: id, slot: slot}), "stub registration must succeed") + } + return r +} + +func TestTranslateMiddlewareConfigs_EmptyInput(t *testing.T) { + assert.Nil(t, translateMiddlewareConfigs(context.Background(), "target-a", nil, nil), + "nil input should translate to nil") + assert.Nil(t, translateMiddlewareConfigs(context.Background(), "target-a", []*proto.MiddlewareConfig{}, nil), + "empty input should translate to nil") +} + +func TestTranslateMiddlewareConfigs_KnownIDs(t *testing.T) { + registry := newTestRegistry(t, map[string]middleware.Slot{ + "llm_request_parser": middleware.SlotOnRequest, + "llm_response_parser": middleware.SlotOnResponse, + }) + in := []*proto.MiddlewareConfig{ + { + Id: "llm_request_parser", + Enabled: true, + Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, + ConfigJson: []byte(`{"foo":"bar"}`), + FailMode: proto.MiddlewareConfig_FAIL_OPEN, + Timeout: durationpb.New(250 * time.Millisecond), + CanMutate: true, + }, + { + Id: "llm_response_parser", + Enabled: false, + Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE, + ConfigJson: nil, + FailMode: proto.MiddlewareConfig_FAIL_CLOSED, + Timeout: durationpb.New(50 * time.Millisecond), + }, + } + + out := translateMiddlewareConfigs(context.Background(), "target-a", in, registry) + require.Len(t, out, 2, "two known middlewares should produce two specs") + + assert.Equal(t, "llm_request_parser", out[0].ID, "first id should match") + assert.Equal(t, middleware.SlotOnRequest, out[0].Slot, "first slot should be on_request") + assert.True(t, out[0].Enabled, "first spec should be enabled") + assert.Equal(t, middleware.FailOpen, out[0].FailMode, "first spec should be fail-open") + assert.Equal(t, 250*time.Millisecond, out[0].Timeout, "first spec timeout should pass through") + assert.True(t, out[0].CanMutate, "first spec should permit mutations") + assert.Equal(t, []byte(`{"foo":"bar"}`), out[0].RawConfig, "first spec raw config should match") + + assert.Equal(t, "llm_response_parser", out[1].ID, "second id should match") + assert.Equal(t, middleware.SlotOnResponse, out[1].Slot, "second slot should be on_response") + assert.False(t, out[1].Enabled, "second spec should be disabled") + assert.Equal(t, middleware.FailClosed, out[1].FailMode, "second spec should be fail-closed") + assert.Equal(t, 50*time.Millisecond, out[1].Timeout, "second spec timeout should pass through") + assert.Nil(t, out[1].RawConfig, "second spec raw config should be nil") +} + +func TestTranslateMiddlewareConfigs_UnknownIDSkipped(t *testing.T) { + registry := newTestRegistry(t, map[string]middleware.Slot{ + "llm_request_parser": middleware.SlotOnRequest, + }) + in := []*proto.MiddlewareConfig{ + {Id: "llm_request_parser", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST}, + {Id: "not_registered", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST}, + } + out := translateMiddlewareConfigs(context.Background(), "target-unknown", in, registry) + require.Len(t, out, 1, "unknown id must be skipped") + assert.Equal(t, "llm_request_parser", out[0].ID, "remaining entry should be the known one") +} + +func TestTranslateMiddlewareConfigs_NilRegistrySkipsValidation(t *testing.T) { + in := []*proto.MiddlewareConfig{ + {Id: "anything_goes", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST}, + } + out := translateMiddlewareConfigs(context.Background(), "target-nilreg", in, nil) + require.Len(t, out, 1, "nil registry must accept any non-empty id") + assert.Equal(t, "anything_goes", out[0].ID, "id should pass through unchecked") +} + +func TestTranslateMiddlewareConfigs_TimeoutClamps(t *testing.T) { + registry := newTestRegistry(t, map[string]middleware.Slot{ + "llm_request_parser": middleware.SlotOnRequest, + }) + in := []*proto.MiddlewareConfig{ + {Id: "llm_request_parser", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, Timeout: nil}, + {Id: "llm_request_parser", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, Timeout: durationpb.New(time.Microsecond)}, + {Id: "llm_request_parser", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, Timeout: durationpb.New(time.Hour)}, + } + out := translateMiddlewareConfigs(context.Background(), "target-clamp", in, registry) + require.Len(t, out, 3, "clamping must keep all three entries") + assert.Equal(t, middleware.DefaultTimeout, out[0].Timeout, "zero timeout should default") + assert.Equal(t, middleware.MinTimeout, out[1].Timeout, "below-min timeout should clamp up") + assert.Equal(t, middleware.MaxTimeout, out[2].Timeout, "above-max timeout should clamp down") +} + +func TestTranslateMiddlewareConfigs_FailModeMapping(t *testing.T) { + registry := newTestRegistry(t, map[string]middleware.Slot{ + "llm_request_parser": middleware.SlotOnRequest, + }) + in := []*proto.MiddlewareConfig{ + {Id: "llm_request_parser", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST}, + {Id: "llm_request_parser", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, FailMode: proto.MiddlewareConfig_FAIL_CLOSED}, + } + out := translateMiddlewareConfigs(context.Background(), "target-failmode", in, registry) + require.Len(t, out, 2, "both entries should translate") + assert.Equal(t, middleware.FailOpen, out[0].FailMode, "default fail mode should be open") + assert.Equal(t, middleware.FailClosed, out[1].FailMode, "explicit fail closed should map") +} + +func TestTranslateMiddlewareConfigs_SlotMapping(t *testing.T) { + registry := newTestRegistry(t, map[string]middleware.Slot{ + "req": middleware.SlotOnRequest, + "resp": middleware.SlotOnResponse, + "term": middleware.SlotTerminal, + }) + in := []*proto.MiddlewareConfig{ + {Id: "req", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST}, + {Id: "resp", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE}, + {Id: "term", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_TERMINAL}, + {Id: "req", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_UNSPECIFIED}, + } + out := translateMiddlewareConfigs(context.Background(), "target-slot", in, registry) + require.Len(t, out, 3, "unspecified slot entry must be skipped") + assert.Equal(t, middleware.SlotOnRequest, out[0].Slot, "on_request slot mapping") + assert.Equal(t, middleware.SlotOnResponse, out[1].Slot, "on_response slot mapping") + assert.Equal(t, middleware.SlotTerminal, out[2].Slot, "terminal slot mapping") +} + +func TestTranslateMiddlewareConfigs_EmptyIDSkipped(t *testing.T) { + registry := newTestRegistry(t, map[string]middleware.Slot{ + "llm_request_parser": middleware.SlotOnRequest, + }) + in := []*proto.MiddlewareConfig{ + {Id: "", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST}, + {Id: "llm_request_parser", Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST}, + } + out := translateMiddlewareConfigs(context.Background(), "target-empty-id", in, registry) + require.Len(t, out, 1, "empty id must be dropped") + assert.Equal(t, "llm_request_parser", out[0].ID, "remaining entry should be valid") +} + +// TestTranslateMiddlewareConfigs_TruncatesAboveCap proves the translator +// truncates lists that exceed MaxMiddlewaresPerChain rather than dropping +// the whole slice, matching the documented G3 behaviour. +func TestTranslateMiddlewareConfigs_TruncatesAboveCap(t *testing.T) { + registry := newTestRegistry(t, map[string]middleware.Slot{ + "llm_request_parser": middleware.SlotOnRequest, + }) + overCap := middleware.MaxMiddlewaresPerChain + 1 + in := make([]*proto.MiddlewareConfig, 0, overCap) + for i := 0; i < overCap; i++ { + in = append(in, &proto.MiddlewareConfig{ + Id: "llm_request_parser", + Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, + }) + } + out := translateMiddlewareConfigs(context.Background(), "target-truncate", in, registry) + assert.Len(t, out, middleware.MaxMiddlewaresPerChain, "over-cap input must be truncated to MaxMiddlewaresPerChain") +} + +func TestTranslateMiddlewareConfigs_AllowsListAtCap(t *testing.T) { + registry := newTestRegistry(t, map[string]middleware.Slot{ + "llm_request_parser": middleware.SlotOnRequest, + }) + in := make([]*proto.MiddlewareConfig, 0, middleware.MaxMiddlewaresPerChain) + for i := 0; i < middleware.MaxMiddlewaresPerChain; i++ { + in = append(in, &proto.MiddlewareConfig{ + Id: "llm_request_parser", + Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, + }) + } + out := translateMiddlewareConfigs(context.Background(), "target-cap", in, registry) + assert.Len(t, out, middleware.MaxMiddlewaresPerChain, "list at the cap boundary must translate fully") +} + +func TestProtoToMiddlewareSlot(t *testing.T) { + cases := []struct { + name string + in proto.MiddlewareSlot + want middleware.Slot + wantOk bool + }{ + {"unspecified", proto.MiddlewareSlot_MIDDLEWARE_SLOT_UNSPECIFIED, 0, false}, + {"on_request", proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, middleware.SlotOnRequest, true}, + {"on_response", proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE, middleware.SlotOnResponse, true}, + {"terminal", proto.MiddlewareSlot_MIDDLEWARE_SLOT_TERMINAL, middleware.SlotTerminal, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := protoToMiddlewareSlot(tc.in) + assert.Equal(t, tc.wantOk, ok, "ok flag for %s", tc.name) + if tc.wantOk { + assert.Equal(t, tc.want, got, "slot mapping for %s", tc.name) + } + }) + } +} diff --git a/proxy/process_mappings_bench_test.go b/proxy/process_mappings_bench_test.go new file mode 100644 index 000000000..919cab95c --- /dev/null +++ b/proxy/process_mappings_bench_test.go @@ -0,0 +1,300 @@ +package proxy + +import ( + "context" + "fmt" + "net" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/proxy/internal/auth" + "github.com/netbirdio/netbird/proxy/internal/conntrack" + "github.com/netbirdio/netbird/proxy/internal/crowdsec" + proxymetrics "github.com/netbirdio/netbird/proxy/internal/metrics" + "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/proxy/internal/roundtrip" + nbtcp "github.com/netbirdio/netbird/proxy/internal/tcp" + "github.com/netbirdio/netbird/proxy/internal/types" + udprelay "github.com/netbirdio/netbird/proxy/internal/udp" + "github.com/netbirdio/netbird/shared/management/proto" + + "go.opentelemetry.io/otel/metric/noop" +) + +// latencyMockClient simulates realistic gRPC latency for management calls. +type latencyMockClient struct { + proto.ProxyServiceClient + createPeerDelay time.Duration + statusUpdateDelay time.Duration +} + +func (m *latencyMockClient) SendStatusUpdate(ctx context.Context, _ *proto.SendStatusUpdateRequest, _ ...grpc.CallOption) (*proto.SendStatusUpdateResponse, error) { + if m.statusUpdateDelay > 0 { + select { + case <-time.After(m.statusUpdateDelay): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + return &proto.SendStatusUpdateResponse{}, nil +} + +func (m *latencyMockClient) CreateProxyPeer(ctx context.Context, _ *proto.CreateProxyPeerRequest, _ ...grpc.CallOption) (*proto.CreateProxyPeerResponse, error) { + if m.createPeerDelay > 0 { + select { + case <-time.After(m.createPeerDelay): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + return &proto.CreateProxyPeerResponse{Success: true}, nil +} + +type discardWriter struct{} + +func (discardWriter) Write(p []byte) (int, error) { return len(p), nil } + +func benchServerWithLatency(b *testing.B, createPeerDelay, statusDelay time.Duration) *Server { + b.Helper() + logger := log.New() + logger.SetLevel(log.FatalLevel) + logger.SetOutput(&discardWriter{}) + + meter, err := proxymetrics.New(context.Background(), noop.Meter{}) + if err != nil { + b.Fatal(err) + } + + mgmtClient := &latencyMockClient{ + createPeerDelay: createPeerDelay, + statusUpdateDelay: statusDelay, + } + + nb := roundtrip.NewNetBird(b.Context(), "bench-proxy", "bench.test", + roundtrip.ClientConfig{MgmtAddr: "http://bench.test:9999"}, + logger, nil, mgmtClient) + + mainRouter := nbtcp.NewRouter(logger, func(accountID types.AccountID) (types.DialContextFunc, error) { + return (&net.Dialer{}).DialContext, nil + }, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 443}) + + return &Server{ + Logger: logger, + mgmtClient: mgmtClient, + netbird: nb, + proxy: proxy.NewReverseProxy(nil, "auto", nil, logger), + auth: auth.NewMiddleware(logger, nil, nil), + mainRouter: mainRouter, + mainPort: 443, + meter: meter, + hijackTracker: conntrack.HijackTracker{}, + crowdsecRegistry: crowdsec.NewRegistry("", "", log.NewEntry(logger)), + crowdsecServices: make(map[types.ServiceID]bool), + lastMappings: make(map[types.ServiceID]*proto.ProxyMapping), + portRouters: make(map[uint16]*portRouter), + svcPorts: make(map[types.ServiceID][]uint16), + udpRelays: make(map[types.ServiceID]*udprelay.Relay), + } +} + +// generateHTTPMappings creates N HTTP-mode mappings with the given update type. +// All belong to a single account to share the embedded client. +func generateHTTPMappings(n int, updateType proto.ProxyMappingUpdateType) []*proto.ProxyMapping { + mappings := make([]*proto.ProxyMapping, n) + for i := range n { + mappings[i] = &proto.ProxyMapping{ + Type: updateType, + Id: fmt.Sprintf("svc-%d", i), + AccountId: "account-1", + Domain: fmt.Sprintf("svc-%d.bench.example.com", i), + Mode: "http", + Path: []*proto.PathMapping{ + { + Path: "/", + Target: fmt.Sprintf("http://10.0.%d.%d:8080", (i/256)%256, i%256), + }, + }, + Auth: &proto.Authentication{}, + } + } + return mappings +} + +// generateMultiAccountHTTPMappings creates N HTTP-mode CREATED mappings spread +// across the given number of accounts. This stresses the AddPeer new-account +// path which calls CreateProxyPeer + embed.New per unique account. +func generateMultiAccountHTTPMappings(n, accounts int) []*proto.ProxyMapping { + mappings := make([]*proto.ProxyMapping, n) + for i := range n { + mappings[i] = &proto.ProxyMapping{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, + Id: fmt.Sprintf("svc-%d", i), + AccountId: fmt.Sprintf("account-%d", i%accounts), + Domain: fmt.Sprintf("svc-%d.bench.example.com", i), + Mode: "http", + Path: []*proto.PathMapping{ + { + Path: "/", + Target: fmt.Sprintf("http://10.0.%d.%d:8080", (i/256)%256, i%256), + }, + }, + Auth: &proto.Authentication{}, + } + } + return mappings +} + +// generateMixedMappings creates mappings with a realistic distribution: +// 70% HTTP create, 15% modify existing, 10% TLS on main port, 5% remove. +// All use a single account to avoid embed.New dialing. +func generateMixedMappings(n int) []*proto.ProxyMapping { + mappings := make([]*proto.ProxyMapping, n) + for i := range n { + var m *proto.ProxyMapping + switch { + case i%20 < 14: // 70% HTTP create + m = &proto.ProxyMapping{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, + Id: fmt.Sprintf("svc-http-%d", i), + AccountId: "account-1", + Domain: fmt.Sprintf("svc-%d.bench.example.com", i), + Mode: "http", + Path: []*proto.PathMapping{ + {Path: "/", Target: fmt.Sprintf("http://10.0.%d.%d:8080", (i/256)%256, i%256)}, + {Path: "/api", Target: fmt.Sprintf("http://10.0.%d.%d:8081", (i/256)%256, i%256)}, + }, + Auth: &proto.Authentication{}, + } + case i%20 < 17: // 15% modify + m = &proto.ProxyMapping{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED, + Id: fmt.Sprintf("svc-http-%d", i%100), + AccountId: "account-1", + Domain: fmt.Sprintf("svc-%d.bench.example.com", i%100), + Mode: "http", + Path: []*proto.PathMapping{ + {Path: "/", Target: fmt.Sprintf("http://10.1.%d.%d:8080", (i/256)%256, i%256)}, + }, + Auth: &proto.Authentication{}, + } + case i%20 < 19: // 10% TLS passthrough on main port + m = &proto.ProxyMapping{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, + Id: fmt.Sprintf("svc-tls-%d", i), + AccountId: "account-1", + Domain: fmt.Sprintf("tls-%d.bench.example.com", i), + Mode: "tls", + ListenPort: 443, + Path: []*proto.PathMapping{ + {Path: "/", Target: fmt.Sprintf("10.2.%d.%d:443", (i/256)%256, i%256)}, + }, + } + default: // 5% remove + m = &proto.ProxyMapping{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, + Id: fmt.Sprintf("svc-http-%d", i%50), + AccountId: "account-1", + Domain: fmt.Sprintf("svc-%d.bench.example.com", i%50), + Mode: "http", + } + } + mappings[i] = m + } + return mappings +} + +const ( + createPeerLatency = 100 * time.Millisecond + statusUpdateLatency = 50 * time.Millisecond +) + +// BenchmarkProcessMappings_HTTPCreate_SingleAccount benchmarks the initial sync +// scenario: N HTTP mappings all on a single account. Only the first mapping +// triggers CreateProxyPeer (100ms gRPC). The rest just register with the +// existing client. This is the "best case" production path. +func BenchmarkProcessMappings_HTTPCreate_SingleAccount(b *testing.B) { + for _, n := range []int{100, 1000, 5000} { + b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) { + mappings := generateHTTPMappings(n, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED) + for range b.N { + s := benchServerWithLatency(b, createPeerLatency, statusUpdateLatency) + s.processMappings(b.Context(), mappings) + } + }) + } +} + +// BenchmarkProcessMappings_HTTPCreate_MultiAccount benchmarks the worst-case +// initial sync: every mapping belongs to a different account, so each one +// triggers a full CreateProxyPeer gRPC round-trip (100ms) + embed.New. +// With 500 accounts this serializes to ~50s of blocking I/O. +func BenchmarkProcessMappings_HTTPCreate_MultiAccount(b *testing.B) { + for _, tc := range []struct { + mappings int + accounts int + }{ + {100, 10}, + {100, 50}, + {1000, 50}, + {1000, 200}, + {3000, 500}, + } { + b.Run(fmt.Sprintf("mappings=%d/accounts=%d", tc.mappings, tc.accounts), func(b *testing.B) { + mappings := generateMultiAccountHTTPMappings(tc.mappings, tc.accounts) + for range b.N { + s := benchServerWithLatency(b, createPeerLatency, statusUpdateLatency) + s.processMappings(b.Context(), mappings) + } + }) + } +} + +// BenchmarkProcessMappings_Mixed benchmarks a realistic mixed workload +// of creates, modifies, TLS, and removes with production-like latency. +// TLS mappings call SendStatusUpdate (50ms each), serialized. +func BenchmarkProcessMappings_Mixed(b *testing.B) { + for _, n := range []int{100, 1000, 5000} { + b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) { + mappings := generateMixedMappings(n) + for range b.N { + s := benchServerWithLatency(b, createPeerLatency, statusUpdateLatency) + creates := generateHTTPMappings(100, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED) + s.processMappings(b.Context(), creates) + s.processMappings(b.Context(), mappings) + } + }) + } +} + +// BenchmarkProcessMappings_ModifyOnly benchmarks bulk modification of +// already-registered mappings (no new peers needed, no gRPC). +func BenchmarkProcessMappings_ModifyOnly(b *testing.B) { + for _, n := range []int{100, 1000, 5000} { + b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) { + creates := generateHTTPMappings(n, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED) + modifies := generateHTTPMappings(n, proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED) + for range b.N { + s := benchServerWithLatency(b, createPeerLatency, statusUpdateLatency) + s.processMappings(b.Context(), creates) + s.processMappings(b.Context(), modifies) + } + }) + } +} + +// BenchmarkProcessMappings_NoLatency measures pure CPU/allocation overhead +// with zero I/O latency for profiling purposes. +func BenchmarkProcessMappings_NoLatency(b *testing.B) { + for _, n := range []int{1000, 5000} { + b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) { + mappings := generateHTTPMappings(n, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED) + for range b.N { + s := benchServerWithLatency(b, 0, 0) + s.processMappings(b.Context(), mappings) + } + }) + } +} diff --git a/proxy/proxyprotocol_test.go b/proxy/proxyprotocol_test.go new file mode 100644 index 000000000..9e19314ed --- /dev/null +++ b/proxy/proxyprotocol_test.go @@ -0,0 +1,108 @@ +package proxy + +import ( + "net" + "net/netip" + "testing" + "time" + + proxyproto "github.com/pires/go-proxyproto" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/trustedproxy" +) + +func TestWrapProxyProtocol_OverridesRemoteAddr(t *testing.T) { + srv := &Server{ + Logger: log.StandardLogger(), + TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}), + ProxyProtocol: true, + } + + raw, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer raw.Close() + + ln := srv.wrapProxyProtocol(raw) + + realClientIP := "203.0.113.50" + realClientPort := uint16(54321) + + accepted := make(chan net.Conn, 1) + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + accepted <- conn + }() + + // Connect and send a PROXY v2 header. + conn, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + defer conn.Close() + + header := &proxyproto.Header{ + Version: 2, + Command: proxyproto.PROXY, + TransportProtocol: proxyproto.TCPv4, + SourceAddr: &net.TCPAddr{IP: net.ParseIP(realClientIP), Port: int(realClientPort)}, + DestinationAddr: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 443}, + } + _, err = header.WriteTo(conn) + require.NoError(t, err) + + select { + case accepted := <-accepted: + defer accepted.Close() + host, _, err := net.SplitHostPort(accepted.RemoteAddr().String()) + require.NoError(t, err) + assert.Equal(t, realClientIP, host, "RemoteAddr should reflect the PROXY header source IP") + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for connection") + } +} + +func TestProxyProtocolPolicy_TrustedRequires(t *testing.T) { + srv := &Server{ + Logger: log.StandardLogger(), + TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}), + } + + opts := proxyproto.ConnPolicyOptions{ + Upstream: &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 1234}, + } + policy, err := srv.proxyProtocolPolicy(opts) + require.NoError(t, err) + assert.Equal(t, proxyproto.REQUIRE, policy, "trusted source should require PROXY header") +} + +func TestProxyProtocolPolicy_UntrustedIgnores(t *testing.T) { + srv := &Server{ + Logger: log.StandardLogger(), + TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}), + } + + opts := proxyproto.ConnPolicyOptions{ + Upstream: &net.TCPAddr{IP: net.ParseIP("203.0.113.50"), Port: 1234}, + } + policy, err := srv.proxyProtocolPolicy(opts) + require.NoError(t, err) + assert.Equal(t, proxyproto.IGNORE, policy, "untrusted source should have PROXY header ignored") +} + +func TestProxyProtocolPolicy_InvalidIPRejects(t *testing.T) { + srv := &Server{ + Logger: log.StandardLogger(), + TrustedProxies: trustedproxy.FromPrefixes([]netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}), + } + + opts := proxyproto.ConnPolicyOptions{ + Upstream: &net.UnixAddr{Name: "/tmp/test.sock", Net: "unix"}, + } + policy, err := srv.proxyProtocolPolicy(opts) + require.NoError(t, err) + assert.Equal(t, proxyproto.REJECT, policy, "unparsable address should be rejected") +} diff --git a/proxy/server.go b/proxy/server.go new file mode 100644 index 000000000..4f448e4b8 --- /dev/null +++ b/proxy/server.go @@ -0,0 +1,2355 @@ +// Package proxy runs a NetBird proxy server. +// It attempts to do everything it needs to do within the context +// of a single request to the server to try to reduce the amount +// of concurrency coordination that is required. However, it does +// run two additional routines in an error group for handling +// updates from the management server and running a separate +// HTTP server to handle ACME HTTP-01 challenges (if configured). +package proxy + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "path/filepath" + "reflect" + "sync" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/google/uuid" + "github.com/pires/go-proxyproto" + prometheus2 "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + log "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel/exporters/prometheus" + "go.opentelemetry.io/otel/sdk/metric" + "golang.org/x/exp/maps" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/keepalive" + grpcstatus "google.golang.org/grpc/status" + "google.golang.org/protobuf/encoding/protojson" + goproto "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/netbirdio/netbird/client/embed" + "github.com/netbirdio/netbird/proxy/internal/accesslog" + "github.com/netbirdio/netbird/proxy/internal/acme" + "github.com/netbirdio/netbird/proxy/internal/auth" + "github.com/netbirdio/netbird/proxy/internal/certwatch" + "github.com/netbirdio/netbird/proxy/internal/conntrack" + "github.com/netbirdio/netbird/proxy/internal/crowdsec" + "github.com/netbirdio/netbird/proxy/internal/debug" + "github.com/netbirdio/netbird/proxy/internal/geolocation" + proxygrpc "github.com/netbirdio/netbird/proxy/internal/grpc" + "github.com/netbirdio/netbird/proxy/internal/health" + "github.com/netbirdio/netbird/proxy/internal/k8s" + proxymetrics "github.com/netbirdio/netbird/proxy/internal/metrics" + "github.com/netbirdio/netbird/proxy/internal/middleware" + mwbuiltin "github.com/netbirdio/netbird/proxy/internal/middleware/builtin" + "github.com/netbirdio/netbird/proxy/internal/netutil" + "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/proxy/internal/restrict" + "github.com/netbirdio/netbird/proxy/internal/roundtrip" + nbtcp "github.com/netbirdio/netbird/proxy/internal/tcp" + "github.com/netbirdio/netbird/proxy/internal/types" + udprelay "github.com/netbirdio/netbird/proxy/internal/udp" + "github.com/netbirdio/netbird/proxy/web" + "github.com/netbirdio/netbird/shared/management/domain" + "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/trustedproxy" + "github.com/netbirdio/netbird/util/embeddedroots" +) + +// portRouter bundles a per-port Router with its listener and cancel func. +type portRouter struct { + router *nbtcp.Router + listener net.Listener + cancel context.CancelFunc +} + +type Server struct { + ctx context.Context + mgmtClient proto.ProxyServiceClient + proxy *proxy.ReverseProxy + netbird *roundtrip.NetBird + acme *acme.Manager + staticCertWatcher *certwatch.Watcher + auth *auth.Middleware + http *http.Server + https *http.Server + debug *http.Server + healthServer *health.Server + healthChecker *health.Checker + meter *proxymetrics.Metrics + accessLog *accesslog.Logger + // middlewareManager drives per-target middleware dispatch. Always + // constructed during boot; an empty registry produces empty chains and + // the reverse-proxy stays on the no-capture fast path. + middlewareManager *middleware.Manager + // middlewareRegistry is the source of registered middleware factories. + // Concrete middlewares register themselves through init(). + middlewareRegistry *middleware.Registry + mainRouter *nbtcp.Router + mainPort uint16 + udpMu sync.Mutex + udpRelays map[types.ServiceID]*udprelay.Relay + udpRelayWg sync.WaitGroup + portMu sync.RWMutex + portRouters map[uint16]*portRouter + svcPorts map[types.ServiceID][]uint16 + lastMappings map[types.ServiceID]*proto.ProxyMapping + portRouterWg sync.WaitGroup + + // hijackTracker tracks hijacked connections (e.g. WebSocket upgrades) + // so they can be closed during graceful shutdown, since http.Server.Shutdown + // does not handle them. + hijackTracker conntrack.HijackTracker + // geo resolves IP addresses to country/city for access restrictions and access logs. + geo restrict.GeoResolver + geoRaw *geolocation.Lookup + + // crowdsecRegistry manages the shared CrowdSec bouncer lifecycle. + crowdsecRegistry *crowdsec.Registry + // crowdsecServices tracks which services have CrowdSec enabled for + // proper acquire/release lifecycle management. + crowdsecMu sync.Mutex + crowdsecServices map[types.ServiceID]bool + + // routerReady is closed once mainRouter is fully initialized. + // The mapping worker waits on this before processing updates. + routerReady chan struct{} + + // removePeer defaults to netbird.RemovePeer; overridable in tests. + removePeer func(ctx context.Context, accountID types.AccountID, key roundtrip.ServiceKey) error + + // inbound, when non-nil, manages per-account inbound listeners. Set by + // initPrivateInbound only when Private is true so the standalone + // proxy keeps its zero-overhead default path. + inbound *inboundManager + + // Lifecycle state — populated by Start, consumed by Stop. The fields + // stay zero on a fresh Server until Start runs so direct struct + // construction (`&Server{...}`) used by tests still works. + runCancel context.CancelFunc + mgmtConn *grpc.ClientConn + runErr error + runErrCh chan struct{} + startMu sync.Mutex + started bool + stopOnce sync.Once + + // Mostly used for debugging on management. + startTime time.Time + + // ListenAddr is the address the main TCP listener binds. Populated by + // New from Config or by ListenAndServe from its addr argument. + ListenAddr string + ID string + Logger *log.Logger + Version string + ProxyURL string + ManagementAddress string + CertificateDirectory string + CertificateFile string + CertificateKeyFile string + GenerateACMECertificates bool + ACMEChallengeAddress string + ACMEDirectory string + // ACMEEABKID is the External Account Binding Key ID for CAs that require EAB (e.g., ZeroSSL). + ACMEEABKID string + // ACMEEABHMACKey is the External Account Binding HMAC key (base64 URL-encoded) for CAs that require EAB. + ACMEEABHMACKey string + // ACMEChallengeType specifies the ACME challenge type: "http-01" or "tls-alpn-01". + // Defaults to "tls-alpn-01" if not specified. + ACMEChallengeType string + // CertLockMethod controls how ACME certificate locks are coordinated + // across replicas. Default: CertLockAuto (detect environment). + CertLockMethod acme.CertLockMethod + // WildcardCertDir is an optional directory containing wildcard certificate + // pairs (.crt / .key). Wildcard patterns are extracted from + // the certificates' SAN lists. Matching domains use these static certs + // instead of ACME. + WildcardCertDir string + + // DebugEndpointEnabled enables the debug HTTP endpoint. + DebugEndpointEnabled bool + // DebugEndpointAddress is the address for the debug HTTP endpoint (default: ":8444"). + DebugEndpointAddress string + // HealthAddress is the address for the health probe endpoint. + HealthAddress string + // ProxyToken is the access token for authenticating with the management server. + ProxyToken string + // ForwardedProto overrides the X-Forwarded-Proto value sent to backends. + // Valid values: "auto" (detect from TLS), "http", "https". + ForwardedProto string + // TrustedProxies is the set of trusted upstream proxies. When set, + // forwarding headers from these sources are preserved and appended to + // instead of being stripped. + TrustedProxies *trustedproxy.List + // WireguardPort is the port for the NetBird tunnel interface. Use 0 + // for a random OS-assigned port. A fixed port only works with + // single-account deployments; multiple accounts will fail to bind + // the same port. + WireguardPort uint16 + // Performance configures the tunnel pool/batch sizes for every + // embedded client this proxy spawns. + Performance embed.Performance + // ProxyProtocol enables PROXY protocol (v1/v2) on TCP listeners. + // When enabled, the real client IP is extracted from the PROXY header + // sent by upstream L4 proxies that support PROXY protocol. + ProxyProtocol bool + // PreSharedKey used for tunnel between proxy and peers (set globally not per account) + PreSharedKey string + // SupportsCustomPorts indicates whether the proxy can bind arbitrary + // ports for TCP/UDP/TLS services. + SupportsCustomPorts bool + // RequireSubdomain indicates whether a subdomain label is required + // in front of this proxy's cluster domain. When true, accounts cannot + // create services on the bare cluster domain. + RequireSubdomain bool + // Private flags this proxy as embedded in a netbird client and serving + // exclusively over the WireGuard tunnel (i.e. `netbird proxy`). Reported + // upstream as a capability so dashboards can distinguish per-peer + // clusters from centralised ones, and turns on per-account inbound + // listeners on each embedded client's netstack: every account that + // registers a service exposes :80 + :443 inside its own WG tunnel, + // scoped to that account's services only. + Private bool + // MaxDialTimeout caps the per-service backend dial timeout. + // When the API sends a timeout, it is clamped to this value. + // When the API sends no timeout, this value is used as the default. + // Zero means no cap (the proxy honors whatever management sends). + MaxDialTimeout time.Duration + // GeoDataDir is the directory containing GeoLite2 MMDB files for + // country-based access restrictions. Empty disables geo lookups. + GeoDataDir string + // CrowdSecAPIURL is the CrowdSec LAPI URL. Empty disables CrowdSec. + CrowdSecAPIURL string + // CrowdSecAPIKey is the CrowdSec bouncer API key. Empty disables CrowdSec. + CrowdSecAPIKey string + // MaxSessionIdleTimeout caps the per-service session idle timeout. + // Zero means no cap (the proxy honors whatever management sends). + // Set via NB_PROXY_MAX_SESSION_IDLE_TIMEOUT for shared deployments. + MaxSessionIdleTimeout time.Duration + // MappingBatchWatchdog bounds how long a single mapping batch may spend + // in processMappings before the receive loop reconnects to resync. + // Zero uses defaultMappingBatchWatchdog. + MappingBatchWatchdog time.Duration + // MiddlewareDataDir is the base directory the middleware system uses to + // resolve file-backed configuration (e.g. the cost_meter pricing table). + // Empty means any middleware that requires a file fails at configure time. + MiddlewareDataDir string + // MiddlewareCaptureBudgetBytes overrides the proxy-wide in-flight capture + // budget passed to middleware.NewManager. Zero or negative values fall + // back to defaultMiddlewareCaptureBudgetBytes (256 MiB). + MiddlewareCaptureBudgetBytes int64 +} + +// defaultMiddlewareCaptureBudgetBytes is the proxy-wide in-flight capture cap +// passed to middleware.NewManager when MiddlewareCaptureBudgetBytes is unset. +const defaultMiddlewareCaptureBudgetBytes = 256 << 20 + +// clampIdleTimeout returns d capped to MaxSessionIdleTimeout when configured. +func (s *Server) clampIdleTimeout(d time.Duration) time.Duration { + if s.MaxSessionIdleTimeout > 0 && d > s.MaxSessionIdleTimeout { + return s.MaxSessionIdleTimeout + } + return d +} + +// clampDialTimeout returns d capped to MaxDialTimeout when configured. +// If d is zero, MaxDialTimeout is used as the default. +func (s *Server) clampDialTimeout(d time.Duration) time.Duration { + if s.MaxDialTimeout <= 0 { + return d + } + if d <= 0 || d > s.MaxDialTimeout { + return s.MaxDialTimeout + } + return d +} + +// NotifyStatus sends a status update to management about tunnel connectivity. +func (s *Server) NotifyStatus(ctx context.Context, accountID types.AccountID, serviceID types.ServiceID, connected bool) error { + status := proto.ProxyStatus_PROXY_STATUS_TUNNEL_NOT_CREATED + if connected { + status = proto.ProxyStatus_PROXY_STATUS_ACTIVE + } + + req := &proto.SendStatusUpdateRequest{ + ServiceId: string(serviceID), + AccountId: string(accountID), + Status: status, + CertificateIssued: false, + } + if connected { + req.InboundListener = s.inboundListenerProto(accountID) + } + _, err := s.mgmtClient.SendStatusUpdate(ctx, req) + return err +} + +// NotifyCertificateIssued sends a notification to management that a certificate was issued +func (s *Server) NotifyCertificateIssued(ctx context.Context, accountID types.AccountID, serviceID types.ServiceID, domain string) error { + _, err := s.mgmtClient.SendStatusUpdate(ctx, &proto.SendStatusUpdateRequest{ + ServiceId: string(serviceID), + AccountId: string(accountID), + Status: proto.ProxyStatus_PROXY_STATUS_ACTIVE, + CertificateIssued: true, + InboundListener: s.inboundListenerProto(accountID), + }) + return err +} + +// inboundListenerProto resolves the per-account inbound listener state for +// the SendStatusUpdate payload. Returns nil when --private is off +// or the account has no live listener so management treats the field as +// absent. +func (s *Server) inboundListenerProto(accountID types.AccountID) *proto.ProxyInboundListener { + if s.inbound == nil { + return nil + } + info, ok := s.inbound.ListenerInfo(accountID) + if !ok || info.TunnelIP == "" { + return nil + } + return &proto.ProxyInboundListener{ + TunnelIp: info.TunnelIP, + HttpsPort: uint32(info.HTTPSPort), + HttpPort: uint32(info.HTTPPort), + } +} + +// ListenAndServe is the standalone entrypoint. It binds the listener, runs +// the proxy until ctx is cancelled or a background goroutine fails, then +// drains and stops. Library callers should prefer New + Start + Stop and +// own their own shutdown signalling. +func (s *Server) ListenAndServe(ctx context.Context, addr string) error { + s.ListenAddr = addr + if err := s.Start(ctx); err != nil { + return err + } + return s.waitAndStop(ctx) +} + +// Start brings the proxy up: dials management, configures TLS, binds the +// main listener, and spawns the SNI router and HTTPS server goroutines. It +// returns once the listener is bound; background errors are surfaced +// through Stop's return value. Start is not safe to call twice. +func (s *Server) Start(ctx context.Context) error { + s.startMu.Lock() + if s.started { + s.startMu.Unlock() + return errors.New("proxy already started") + } + s.started = true + s.startMu.Unlock() + + s.initLifecycleState() + if err := s.initMetrics(ctx); err != nil { + return err + } + + if err := s.initManagementClient(); err != nil { + return err + } + + // Management client must be initialised BEFORE the middleware manager — + // initMiddlewareManager passes s.mgmtClient into the builtin FactoryContext + // that the limit-check / limit-record middlewares pull from. Reversed + // order would silently disable enforcement (mgmt=nil → allow-without- + // attribution + no-record). + if err := s.initMiddlewareManager(ctx); err != nil { + return fmt.Errorf("init middleware manager: %w", err) + } + + runCtx, runCancel := context.WithCancel(ctx) + s.runCancel = runCancel + + s.initNetBirdClient() + // Create health checker before the mapping worker so it can track + // management connectivity from the first stream connection. + s.healthChecker = health.NewChecker(s.Logger, s.netbird) + + s.crowdsecRegistry = crowdsec.NewRegistry(s.CrowdSecAPIURL, s.CrowdSecAPIKey, log.NewEntry(s.Logger)) + s.crowdsecServices = make(map[types.ServiceID]bool) + + go s.newManagementMappingWorker(runCtx, s.mgmtClient) + + tlsConfig, err := s.configureTLS(ctx) + if err != nil { + return err + } + + s.initReverseProxy() + + if err := s.initGeoLookup(); err != nil { + return err + } + + startupOK := false + defer func() { + if startupOK { + return + } + if s.geoRaw != nil { + if closeErr := s.geoRaw.Close(); closeErr != nil { + s.Logger.Debugf("close geolocation on startup failure: %v", closeErr) + } + } + }() + + s.auth = auth.NewMiddleware(s.Logger, s.mgmtClient, s.geo) + s.accessLog = accesslog.NewLogger(s.mgmtClient, s.Logger, s.TrustedProxies) + + s.startDebugEndpoint() + + if err := s.startHealthServer(); err != nil { + return err + } + + handler := s.buildHandlerChain() + s.initPrivateInbound(handler, tlsConfig) + + ln, err := s.bindMainListener(ctx) + if err != nil { + return err + } + + s.mainRouter = nbtcp.NewRouter(s.Logger, s.resolveDialFunc, ln.Addr()) + s.mainRouter.SetObserver(s.meter) + s.mainRouter.SetAccessLogger(s.accessLog) + close(s.routerReady) + + s.https = &http.Server{ + Addr: s.ListenAddr, + Handler: handler, + TLSConfig: tlsConfig, + ReadHeaderTimeout: httpReadHeaderTimeout, + IdleTimeout: httpIdleTimeout, + ErrorLog: newHTTPServerLogger(s.Logger, logtagValueHTTPS), + } + + startupOK = true + + go func() { + s.Logger.Debug("starting HTTPS server on SNI router HTTP channel") + if serveErr := s.https.ServeTLS(s.mainRouter.HTTPListener(), "", ""); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + s.recordRunErr(fmt.Errorf("https server: %w", serveErr)) + } + }() + + go func() { + s.Logger.Debugf("starting SNI router on %s", s.ListenAddr) + if serveErr := s.mainRouter.Serve(runCtx, ln); serveErr != nil { + s.recordRunErr(fmt.Errorf("SNI router: %w", serveErr)) + } + }() + + return nil +} + +// Stop drains in-flight connections, shuts down all background services, +// and releases resources. Idempotent; calling it before Start is a no-op. +// Returns the first fatal error reported by a background goroutine, if +// any. The provided ctx bounds the total wait time; once it is cancelled +// Stop returns even if drain is still in flight. +func (s *Server) Stop(ctx context.Context) error { + s.stopOnce.Do(func() { + s.startMu.Lock() + started := s.started + s.startMu.Unlock() + if !started { + return + } + + done := make(chan struct{}) + go func() { + defer close(done) + s.gracefulShutdown() + if s.runCancel != nil { + s.runCancel() + } + if s.mgmtConn != nil { + if err := s.mgmtConn.Close(); err != nil { + s.Logger.Debugf("management connection close: %v", err) + } + } + }() + + select { + case <-done: + case <-ctx.Done(): + s.Logger.Warnf("proxy stop deadline exceeded: %v", ctx.Err()) + } + }) + + s.startMu.Lock() + defer s.startMu.Unlock() + return s.runErr +} + +// waitAndStop blocks until ctx is cancelled or a background goroutine +// reports a fatal error, then drains and stops. Used by ListenAndServe. +func (s *Server) waitAndStop(ctx context.Context) error { + select { + case <-ctx.Done(): + case <-s.runErrCh: + } + stopCtx, cancel := context.WithTimeout(context.Background(), shutdownDrainTimeout+shutdownServiceTimeout) + defer cancel() + return s.Stop(stopCtx) +} + +// recordRunErr stores the first fatal background error and signals +// waitAndStop. Subsequent errors are logged at debug level so the first +// cause is preserved. +func (s *Server) recordRunErr(err error) { + s.startMu.Lock() + defer s.startMu.Unlock() + if s.runErr != nil { + s.Logger.Debugf("background error after first failure: %v", err) + return + } + s.runErr = err + if s.runErrCh != nil { + close(s.runErrCh) + } +} + +// initLifecycleState seeds the maps and channels Start needs to wire up +// background goroutines. Called once at the top of Start. +func (s *Server) initLifecycleState() { + s.initDefaults() + s.routerReady = make(chan struct{}) + s.runErrCh = make(chan struct{}) + s.udpRelays = make(map[types.ServiceID]*udprelay.Relay) + s.portRouters = make(map[uint16]*portRouter) + s.svcPorts = make(map[types.ServiceID][]uint16) + s.lastMappings = make(map[types.ServiceID]*proto.ProxyMapping) +} + +// initMetrics builds the prometheus exporter and meter bundle. +func (s *Server) initMetrics(ctx context.Context) error { + exporter, err := prometheus.New() + if err != nil { + return fmt.Errorf("create prometheus exporter: %w", err) + } + provider := metric.NewMeterProvider(metric.WithReader(exporter)) + pkg := reflect.TypeOf(Server{}).PkgPath() + meter := provider.Meter(pkg) + s.meter, err = proxymetrics.New(ctx, meter) + if err != nil { + return fmt.Errorf("create metrics: %w", err) + } + return nil +} + +// initManagementClient dials management and stashes the connection so +// Stop can close it deterministically. +func (s *Server) initManagementClient() error { + conn, err := s.dialManagement() + if err != nil { + return err + } + s.mgmtConn = conn + s.mgmtClient = proto.NewProxyServiceClient(conn) + return nil +} + +// initNetBirdClient builds the multi-tenant embedded NetBird client used +// for outbound RoundTripping and (when --private is on) per-account +// inbound listeners. +func (s *Server) initNetBirdClient() { + s.netbird = roundtrip.NewNetBird(s.ctx, s.ID, s.ProxyURL, roundtrip.ClientConfig{ + MgmtAddr: s.ManagementAddress, + WGPort: s.WireguardPort, + PreSharedKey: s.PreSharedKey, + Performance: s.Performance, + // On --private the embedded client serves per-account inbound + // listeners and must apply management's ACL: keep BlockInbound off + // so the engine creates the ACL manager. On the standalone proxy + // the embedded client never accepts inbound, so block. + BlockInbound: !s.Private, + }, s.Logger, s, s.mgmtClient) + s.netbird.OnAddPeer = s.meter.RecordAddPeerDuration +} + +// initReverseProxy builds the meter-instrumented reverse proxy. MultiTransport +// routes targets opted into direct_upstream through the host's network stack +// (stdlib transport); everything else falls through to the embedded NetBird +// client. The split is needed so direct_upstream targets resolve DNS via the +// proxy host's resolver instead of the tunnel's DNS. +func (s *Server) initReverseProxy() { + upstreamRT := roundtrip.NewMultiTransport(s.netbird, s.Logger) + var rpOpts []proxy.Option + if s.middlewareManager != nil { + rpOpts = append(rpOpts, proxy.WithMiddlewareManager(s.middlewareManager)) + } + s.proxy = proxy.NewReverseProxy(s.meter.RoundTripper(upstreamRT), s.ForwardedProto, s.TrustedProxies, s.Logger, rpOpts...) +} + +// initGeoLookup configures the GeoLite2 lookup used for country-based +// access restrictions and access-log enrichment. +func (s *Server) initGeoLookup() error { + geoLookup, err := geolocation.NewLookup(s.Logger, s.GeoDataDir) + if err != nil { + return fmt.Errorf("initialize geolocation: %w", err) + } + s.geoRaw = geoLookup + if geoLookup != nil { + s.geo = geoLookup + } + return nil +} + +// buildHandlerChain wires the request middlewares from inside out. +func (s *Server) buildHandlerChain() http.Handler { + handler := http.Handler(s.proxy) + handler = s.auth.Protect(handler) + handler = web.AssetHandler(handler) + handler = s.accessLog.Middleware(handler) + handler = s.meter.Middleware(handler) + return s.hijackTracker.Middleware(handler) +} + +// bindMainListener binds the main TCP listener and wraps it with PROXY +// protocol when configured. +func (s *Server) bindMainListener(ctx context.Context) (net.Listener, error) { + lc := net.ListenConfig{} + ln, err := lc.Listen(ctx, "tcp", s.ListenAddr) + if err != nil { + return nil, fmt.Errorf("listen on %s: %w", s.ListenAddr, err) + } + if s.ProxyProtocol { + ln = s.wrapProxyProtocol(ln) + } + s.mainPort = uint16(ln.Addr().(*net.TCPAddr).Port) //nolint:gosec // port from OS is always valid + s.Logger.WithFields(log.Fields{ + "requested_addr": s.ListenAddr, + "bound_addr": ln.Addr().String(), + "private": s.Private, + "proxy_protocol": s.ProxyProtocol, + }).Info("proxy main listener bound") + return ln, nil +} + +// initDefaults sets fallback values for optional Server fields. +func (s *Server) initDefaults() { + s.startTime = time.Now() + + // If no ID is set then one can be generated. + if s.ID == "" { + s.ID = fmt.Sprintf("netbird-proxy-%s", uuid.NewString()) + } + // Fallback version option in case it is not set. + if s.Version == "" { + s.Version = "dev" + } + + // If no logger is specified fallback to the standard logger. + if s.Logger == nil { + s.Logger = log.StandardLogger() + } +} + +// startDebugEndpoint launches the debug HTTP server if enabled. +func (s *Server) startDebugEndpoint() { + if !s.DebugEndpointEnabled { + return + } + debugAddr := debugEndpointAddr(s.DebugEndpointAddress) + debugHandler := debug.NewHandler(s.netbird, s.healthChecker, s.Logger) + if s.acme != nil { + debugHandler.SetCertStatus(s.acme) + } + if s.inbound != nil { + debugHandler.SetInboundProvider(inboundDebugAdapter{mgr: s.inbound}) + } + s.debug = &http.Server{ + Addr: debugAddr, + Handler: debugHandler, + ErrorLog: newHTTPServerLogger(s.Logger, logtagValueDebug), + } + go func() { + s.Logger.Infof("starting debug endpoint on %s", debugAddr) + if err := s.debug.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + s.Logger.Errorf("debug endpoint error: %v", err) + } + }() +} + +// startHealthServer launches the health probe and metrics server. Empty +// HealthAddress disables the probe entirely (intended for library callers +// that want to manage their own health surface). +func (s *Server) startHealthServer() error { + if s.HealthAddress == "" { + s.Logger.Debug("health probe disabled (empty HealthAddress)") + return nil + } + s.healthServer = health.NewServer(s.HealthAddress, s.healthChecker, s.Logger, promhttp.HandlerFor(prometheus2.DefaultGatherer, promhttp.HandlerOpts{EnableOpenMetrics: true})) + healthListener, err := net.Listen("tcp", s.HealthAddress) + if err != nil { + return fmt.Errorf("health probe server listen on %s: %w", s.HealthAddress, err) + } + go func() { + if err := s.healthServer.Serve(healthListener); err != nil && !errors.Is(err, http.ErrServerClosed) { + s.Logger.Errorf("health probe server: %v", err) + } + }() + return nil +} + +// wrapProxyProtocol wraps a listener with PROXY protocol support. +// When TrustedProxies is configured, only those sources may send PROXY headers; +// connections from untrusted sources have any PROXY header ignored. +func (s *Server) wrapProxyProtocol(ln net.Listener) net.Listener { + ppListener := &proxyproto.Listener{ + Listener: ln, + ReadHeaderTimeout: proxyProtoHeaderTimeout, + } + if !s.TrustedProxies.Empty() { + ppListener.ConnPolicy = s.proxyProtocolPolicy + } else { + s.Logger.Warn("PROXY protocol enabled without trusted proxies; any source may send PROXY headers") + } + s.Logger.Info("PROXY protocol enabled on listener") + return ppListener +} + +// proxyProtocolPolicy returns whether to require, skip, or reject the PROXY +// header based on whether the connection source is in TrustedProxies. +func (s *Server) proxyProtocolPolicy(opts proxyproto.ConnPolicyOptions) (proxyproto.Policy, error) { + // No logging on reject to prevent abuse + tcpAddr, ok := opts.Upstream.(*net.TCPAddr) + if !ok { + return proxyproto.REJECT, nil + } + addr, ok := netip.AddrFromSlice(tcpAddr.IP) + if !ok { + return proxyproto.REJECT, nil + } + addr = addr.Unmap() + + // called per accept + if s.TrustedProxies.Contains(addr) { + return proxyproto.REQUIRE, nil + } + return proxyproto.IGNORE, nil +} + +const ( + // defaultDebugAddr is the localhost-bound fallback for the debug endpoint + // when DebugEndpointAddress is empty. + defaultDebugAddr = "localhost:8444" + + // proxyProtoHeaderTimeout is the deadline for reading the PROXY protocol + // header after accepting a connection. + proxyProtoHeaderTimeout = 5 * time.Second + + // shutdownPreStopDelay is the time to wait after receiving a shutdown signal + // before draining connections. This allows the load balancer to propagate + // the endpoint removal. + shutdownPreStopDelay = 5 * time.Second + + // shutdownDrainTimeout is the maximum time to wait for in-flight HTTP + // requests to complete during graceful shutdown. + shutdownDrainTimeout = 30 * time.Second + + // shutdownServiceTimeout is the maximum time to wait for auxiliary + // services (health probe, debug endpoint, ACME) to shut down. + shutdownServiceTimeout = 5 * time.Second + + // httpReadHeaderTimeout limits how long the server waits to read + // request headers after accepting a connection. Prevents slowloris. + httpReadHeaderTimeout = 10 * time.Second + // httpIdleTimeout limits how long an idle keep-alive connection + // stays open before the server closes it. + httpIdleTimeout = 120 * time.Second +) + +func (s *Server) dialManagement() (*grpc.ClientConn, error) { + mgmtURL, err := url.Parse(s.ManagementAddress) + if err != nil { + return nil, fmt.Errorf("parse management address: %w", err) + } + creds := insecure.NewCredentials() + // Assume management TLS is enabled for gRPC as well if using HTTPS for the API. + if mgmtURL.Scheme == "https" { + certPool, err := x509.SystemCertPool() + if err != nil || certPool == nil { + // Fall back to embedded CAs if no OS-provided ones are available. + certPool = embeddedroots.Get() + } + creds = credentials.NewTLS(&tls.Config{ + RootCAs: certPool, + }) + } + s.Logger.WithFields(log.Fields{ + "gRPC_address": mgmtURL.Host, + "TLS_enabled": mgmtURL.Scheme == "https", + }).Debug("starting management gRPC client") + conn, err := grpc.NewClient(mgmtURL.Host, + grpc.WithTransportCredentials(creds), + grpc.WithKeepaliveParams(keepalive.ClientParameters{ + Time: 20 * time.Second, + Timeout: 10 * time.Second, + PermitWithoutStream: true, + }), + proxygrpc.WithProxyToken(s.ProxyToken), + ) + if err != nil { + return nil, fmt.Errorf("create management connection: %w", err) + } + return conn, nil +} + +func (s *Server) configureTLS(ctx context.Context) (*tls.Config, error) { + tlsConfig := &tls.Config{} + if !s.GenerateACMECertificates { + s.Logger.Debug("ACME certificates disabled, using static certificates with file watching") + certPath := filepath.Join(s.CertificateDirectory, s.CertificateFile) + keyPath := filepath.Join(s.CertificateDirectory, s.CertificateKeyFile) + + certWatcher, err := certwatch.NewWatcher(certPath, keyPath, s.Logger) + if err != nil { + return nil, fmt.Errorf("initialize certificate watcher: %w", err) + } + go certWatcher.Watch(ctx) + s.staticCertWatcher = certWatcher + tlsConfig.GetCertificate = certWatcher.GetCertificate + return tlsConfig, nil + } + + if s.ACMEChallengeType == "" { + s.ACMEChallengeType = "tls-alpn-01" + } + s.Logger.WithFields(log.Fields{ + "acme_server": s.ACMEDirectory, + "challenge_type": s.ACMEChallengeType, + }).Debug("ACME certificates enabled, configuring certificate manager") + var err error + s.acme, err = acme.NewManager(acme.ManagerConfig{ + CertDir: s.CertificateDirectory, + ACMEURL: s.ACMEDirectory, + EABKID: s.ACMEEABKID, + EABHMACKey: s.ACMEEABHMACKey, + LockMethod: s.CertLockMethod, + WildcardDir: s.WildcardCertDir, + }, s, s.Logger, s.meter) + if err != nil { + return nil, fmt.Errorf("create ACME manager: %w", err) + } + + go s.acme.WatchWildcards(ctx) + + if s.ACMEChallengeType == "http-01" { + s.http = &http.Server{ + Addr: s.ACMEChallengeAddress, + Handler: s.acme.HTTPHandler(nil), + ErrorLog: newHTTPServerLogger(s.Logger, logtagValueACME), + } + go func() { + if err := s.http.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + s.Logger.WithError(err).Error("ACME HTTP-01 challenge server failed") + } + }() + } + tlsConfig = s.acme.TLSConfig() + + // autocert.Manager.TLSConfig() wires its own GetCertificate, which + // bypasses our override that checks wildcards first. + tlsConfig.GetCertificate = s.acme.GetCertificate + + // ServerName needs to be set to allow for ACME to work correctly + // when using CNAME URLs to access the proxy. + tlsConfig.ServerName = s.ProxyURL + + s.Logger.WithFields(log.Fields{ + "ServerName": s.ProxyURL, + "challenge_type": s.ACMEChallengeType, + }).Debug("ACME certificate manager configured") + return tlsConfig, nil +} + +// gracefulShutdown performs a zero-downtime shutdown sequence. It marks the +// readiness probe as failing, waits for load balancer propagation, drains +// in-flight connections, and then stops all background services. +func (s *Server) gracefulShutdown() { + s.Logger.Info("shutdown signal received, starting graceful shutdown") + + // Step 1: Fail readiness probe so load balancers stop routing new traffic. + if s.healthChecker != nil { + s.healthChecker.SetShuttingDown() + } + + // Step 2: When running behind a load balancer, wait for endpoint removal + // to propagate before draining connections. + if k8s.InCluster() { + s.Logger.Infof("waiting %s for load balancer propagation", shutdownPreStopDelay) + time.Sleep(shutdownPreStopDelay) + } + + // Step 3: Stop accepting new connections and drain in-flight requests. + drainCtx, drainCancel := context.WithTimeout(context.Background(), shutdownDrainTimeout) + defer drainCancel() + + s.Logger.Info("draining in-flight connections") + if s.https != nil { + if err := s.https.Shutdown(drainCtx); err != nil { + s.Logger.Warnf("https server drain: %v", err) + } + } + + // Step 4: Close hijacked connections (WebSocket) that Shutdown does not handle. + if n := s.hijackTracker.CloseAll(); n > 0 { + s.Logger.Infof("closed %d hijacked connection(s)", n) + } + + // Drain all router relay connections (main + per-port) in parallel. + s.drainAllRouters(shutdownDrainTimeout) + + // Step 5: Stop all remaining background services. + s.shutdownServices() + s.Logger.Info("graceful shutdown complete") +} + +// shutdownServices stops all background services concurrently and waits for +// them to finish. +// drainAllRouters drains active relay connections on the main router and +// all per-port routers in parallel, up to the given timeout. +func (s *Server) drainAllRouters(timeout time.Duration) { + var wg sync.WaitGroup + + drain := func(name string, router *nbtcp.Router) { + wg.Add(1) + go func() { + defer wg.Done() + if ok := router.Drain(timeout); !ok { + s.Logger.Warnf("timed out draining %s relay connections", name) + } + }() + } + + if s.mainRouter != nil { + drain("main router", s.mainRouter) + } + + s.portMu.RLock() + for port, pr := range s.portRouters { + drain(fmt.Sprintf("port %d", port), pr.router) + } + s.portMu.RUnlock() + + wg.Wait() +} + +func (s *Server) shutdownServices() { + var wg sync.WaitGroup + + shutdownHTTP := func(name string, shutdown func(context.Context) error) { + wg.Add(1) + go func() { + defer wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), shutdownServiceTimeout) + defer cancel() + if err := shutdown(ctx); err != nil { + s.Logger.Debugf("%s shutdown: %v", name, err) + } + }() + } + + if s.healthServer != nil { + shutdownHTTP("health probe", s.healthServer.Shutdown) + } + if s.debug != nil { + shutdownHTTP("debug endpoint", s.debug.Shutdown) + } + if s.http != nil { + shutdownHTTP("acme http", s.http.Shutdown) + } + + if s.netbird != nil { + wg.Add(1) + go func() { + defer wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), shutdownDrainTimeout) + defer cancel() + if err := s.netbird.StopAll(ctx); err != nil { + s.Logger.Warnf("stop netbird clients: %v", err) + } + }() + } + + // Close all UDP relays and wait for their goroutines to exit. + s.udpMu.Lock() + for id, relay := range s.udpRelays { + relay.Close() + delete(s.udpRelays, id) + } + s.udpMu.Unlock() + s.udpRelayWg.Wait() + + // Close all per-port routers. + s.portMu.Lock() + for port, pr := range s.portRouters { + pr.cancel() + if err := pr.listener.Close(); err != nil { + s.Logger.Debugf("close listener on port %d: %v", port, err) + } + delete(s.portRouters, port) + } + maps.Clear(s.svcPorts) + maps.Clear(s.lastMappings) + s.portMu.Unlock() + + // Wait for per-port router serve goroutines to exit. + s.portRouterWg.Wait() + + wg.Wait() + + if s.accessLog != nil { + s.accessLog.Close() + } + + if s.geoRaw != nil { + if err := s.geoRaw.Close(); err != nil { + s.Logger.Debugf("close geolocation: %v", err) + } + } + + s.shutdownCrowdSec() +} + +func (s *Server) shutdownCrowdSec() { + if s.crowdsecRegistry == nil { + return + } + s.crowdsecMu.Lock() + services := maps.Clone(s.crowdsecServices) + maps.Clear(s.crowdsecServices) + s.crowdsecMu.Unlock() + + for svcID := range services { + s.crowdsecRegistry.Release(svcID) + } +} + +// resolveDialFunc returns a DialContextFunc that dials through the +// NetBird tunnel for the given account. +func (s *Server) resolveDialFunc(accountID types.AccountID) (types.DialContextFunc, error) { + client, ok := s.netbird.GetClient(accountID) + if !ok { + return nil, fmt.Errorf("no client for account %s", accountID) + } + return client.DialContext, nil +} + +// initPrivateInbound wires per-account inbound listeners when --private +// is set. When the flag is off this is a no-op and the standalone proxy keeps +// its byte-for-byte previous behaviour. +func (s *Server) initPrivateInbound(handler http.Handler, tlsConfig *tls.Config) { + if !s.Private { + return + } + s.inbound = newInboundManager(s.Logger, handler, tlsConfig) + s.netbird.SetClientLifecycle(s.inbound.onClientReady, s.inbound.onClientStop) + s.Logger.Info("private inbound listeners enabled (per-account :80 + :443)") +} + +// notifyError reports a resource error back to management so it can be +// surfaced to the user (e.g. port bind failure, dialer resolution error). +func (s *Server) notifyError(ctx context.Context, mapping *proto.ProxyMapping, err error) { + s.sendStatusUpdate(ctx, types.AccountID(mapping.GetAccountId()), types.ServiceID(mapping.GetId()), proto.ProxyStatus_PROXY_STATUS_ERROR, err) +} + +// sendStatusUpdate sends a status update for a service to management. +func (s *Server) sendStatusUpdate(ctx context.Context, accountID types.AccountID, serviceID types.ServiceID, st proto.ProxyStatus, err error) { + req := &proto.SendStatusUpdateRequest{ + ServiceId: string(serviceID), + AccountId: string(accountID), + Status: st, + } + if err != nil { + msg := err.Error() + req.ErrorMessage = &msg + } + if _, sendErr := s.mgmtClient.SendStatusUpdate(ctx, req); sendErr != nil { + s.Logger.Debugf("failed to send status update for %s: %v", serviceID, sendErr) + } +} + +// routerForPort returns the router that handles the given listen port. If port +// is 0 or matches the main listener port, the main router is returned. +// Otherwise a new per-port router is created and started. +func (s *Server) routerForPort(ctx context.Context, port uint16) (*nbtcp.Router, error) { + if port == 0 || port == s.mainPort { + return s.mainRouter, nil + } + return s.getOrCreatePortRouter(ctx, port) +} + +// routerForPortExisting returns the router for the given port without creating +// one. Returns the main router for port 0 / mainPort, or nil if no per-port +// router exists. +func (s *Server) routerForPortExisting(port uint16) *nbtcp.Router { + if port == 0 || port == s.mainPort { + return s.mainRouter + } + s.portMu.RLock() + pr := s.portRouters[port] + s.portMu.RUnlock() + if pr != nil { + return pr.router + } + return nil +} + +// getOrCreatePortRouter returns an existing per-port router or creates one +// with a new TCP listener and starts serving. +func (s *Server) getOrCreatePortRouter(ctx context.Context, port uint16) (*nbtcp.Router, error) { + s.portMu.Lock() + defer s.portMu.Unlock() + + if pr, ok := s.portRouters[port]; ok { + return pr.router, nil + } + + listenAddr := fmt.Sprintf(":%d", port) + ln, err := net.Listen("tcp", listenAddr) + if err != nil { + return nil, fmt.Errorf("listen TCP on %s: %w", listenAddr, err) + } + if s.ProxyProtocol { + ln = s.wrapProxyProtocol(ln) + } + + router := nbtcp.NewPortRouter(s.Logger, s.resolveDialFunc) + router.SetObserver(s.meter) + router.SetAccessLogger(s.accessLog) + portCtx, cancel := context.WithCancel(s.portRouterContext(ctx)) + + s.portRouters[port] = &portRouter{ + router: router, + listener: ln, + cancel: cancel, + } + + s.portRouterWg.Add(1) + go func() { + defer s.portRouterWg.Done() + if err := router.Serve(portCtx, ln); err != nil { + s.Logger.Debugf("port %d router stopped: %v", port, err) + } + }() + + s.Logger.WithFields(log.Fields{ + "port": port, + "listen_addr": listenAddr, + "bound_addr": ln.Addr().String(), + "proxy_protocol": s.ProxyProtocol, + }).Info("custom TCP listener started") + return router, nil +} + +// portRouterContext returns the server-lifetime context for custom TCP +// listeners. Mapping-batch contexts are cancelled after a batch is applied; a +// per-port listener must outlive that batch and only stop on service removal or +// server shutdown. +func (s *Server) portRouterContext(ctx context.Context) context.Context { + if s.ctx != nil { + return s.ctx + } + return ctx +} + +// cleanupPortIfEmpty tears down a per-port router if it has no remaining +// routes or fallback. The main port is never cleaned up. Active relay +// connections are drained before the listener is closed. +func (s *Server) cleanupPortIfEmpty(port uint16) { + if port == 0 || port == s.mainPort { + return + } + + s.portMu.Lock() + pr, ok := s.portRouters[port] + if !ok || !pr.router.IsEmpty() { + s.portMu.Unlock() + return + } + + // Cancel and close the listener while holding the lock so that + // getOrCreatePortRouter sees the entry is gone before we drain. + pr.cancel() + if err := pr.listener.Close(); err != nil { + s.Logger.Debugf("close listener on port %d: %v", port, err) + } + delete(s.portRouters, port) + s.portMu.Unlock() + + // Drain active relay connections outside the lock. + if ok := pr.router.Drain(nbtcp.DefaultDrainTimeout); !ok { + s.Logger.Warnf("timed out draining relay connections on port %d", port) + } + s.Logger.Debugf("cleaned up empty per-port router on port %d", port) +} + +func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.ProxyServiceClient) { + bo := &backoff.ExponentialBackOff{ + InitialInterval: 800 * time.Millisecond, + RandomizationFactor: 1, + Multiplier: 1.7, + MaxInterval: 10 * time.Second, + MaxElapsedTime: 0, // retry indefinitely until context is canceled + Stop: backoff.Stop, + Clock: backoff.SystemClock, + } + + // syncSupported tracks whether management supports SyncMappings. + // Starts true; set to false on the first Unimplemented error so + // subsequent retries skip straight to GetMappingUpdate. + syncSupported := true + initialSyncDone := false + + operation := func() error { + s.Logger.Debug("connecting to management mapping stream") + + initialSyncDone = false + + if s.healthChecker != nil { + s.healthChecker.SetManagementConnected(false) + } + + connected := false + onConnected := func() { connected = true } + + var streamErr error + if syncSupported { + streamErr = s.trySyncMappings(ctx, client, &initialSyncDone, onConnected) + if isSyncUnimplemented(streamErr) { + syncSupported = false + s.Logger.Info("management does not support SyncMappings, falling back to GetMappingUpdate") + streamErr = s.tryGetMappingUpdate(ctx, client, &initialSyncDone, onConnected) + } + } else { + streamErr = s.tryGetMappingUpdate(ctx, client, &initialSyncDone, onConnected) + } + + if s.healthChecker != nil { + s.healthChecker.SetManagementConnected(false) + } + + // Reset backoff only when a stream actually connected, so immediate + // connect failures still back off instead of spinning. + if connected { + bo.Reset() + } + + if streamErr == nil { + return fmt.Errorf("stream closed by server") + } + + return fmt.Errorf("mapping stream: %w", streamErr) + } + + notify := func(err error, next time.Duration) { + s.Logger.Warnf("management connection failed, retrying in %s: %v", next.Truncate(time.Millisecond), err) + } + + if err := backoff.RetryNotify(operation, backoff.WithContext(bo, ctx), notify); err != nil { + s.Logger.WithError(err).Debug("management mapping worker exiting") + } +} + +func (s *Server) proxyCapabilities() *proto.ProxyCapabilities { + supportsCrowdSec := s.crowdsecRegistry.Available() + privateCapability := s.Private + // Always true: this build enforces ProxyMapping.private via the auth middleware. + supportsPrivateService := true + return &proto.ProxyCapabilities{ + SupportsCustomPorts: &s.SupportsCustomPorts, + RequireSubdomain: &s.RequireSubdomain, + SupportsCrowdsec: &supportsCrowdSec, + Private: &privateCapability, + SupportsPrivateService: &supportsPrivateService, + } +} + +func (s *Server) tryGetMappingUpdate(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool, onConnected func()) error { + connectTime := time.Now() + mappingClient, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{ + ProxyId: s.ID, + Version: s.Version, + StartedAt: timestamppb.New(s.startTime), + Address: s.ProxyURL, + Capabilities: s.proxyCapabilities(), + }) + if err != nil { + return fmt.Errorf("create mapping stream: %w", err) + } + + onConnected() + if s.healthChecker != nil { + s.healthChecker.SetManagementConnected(true) + } + s.Logger.Debug("management mapping stream established (GetMappingUpdate)") + + return s.handleMappingStream(ctx, mappingClient, initialSyncDone, connectTime) +} + +func (s *Server) trySyncMappings(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool, onConnected func()) error { + connectTime := time.Now() + stream, err := client.SyncMappings(ctx) + if err != nil { + return fmt.Errorf("create sync stream: %w", err) + } + + if err := stream.Send(&proto.SyncMappingsRequest{ + Msg: &proto.SyncMappingsRequest_Init{ + Init: &proto.SyncMappingsInit{ + ProxyId: s.ID, + Version: s.Version, + StartedAt: timestamppb.New(s.startTime), + Address: s.ProxyURL, + Capabilities: s.proxyCapabilities(), + }, + }, + }); err != nil { + return fmt.Errorf("send sync init: %w", err) + } + + onConnected() + if s.healthChecker != nil { + s.healthChecker.SetManagementConnected(true) + } + s.Logger.Debug("management mapping stream established (SyncMappings)") + + return s.handleSyncMappingsStream(ctx, stream, initialSyncDone, connectTime) +} + +func isSyncUnimplemented(err error) bool { + if err == nil { + return false + } + st, ok := grpcstatus.FromError(err) + return ok && st.Code() == codes.Unimplemented +} + +// handleSyncMappingsStream consumes batches from a bidirectional SyncMappings +// stream, sending an ack after each batch is fully processed. Management waits +// for the ack before sending the next batch, providing application-level +// back-pressure. +func (s *Server) handleSyncMappingsStream(ctx context.Context, stream proto.ProxyService_SyncMappingsClient, initialSyncDone *bool, connectTime time.Time) error { + select { + case <-s.routerReady: + case <-ctx.Done(): + return ctx.Err() + } + + tracker := s.newSnapshotTracker(initialSyncDone, connectTime) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + msg, err := stream.Recv() + switch { + case errors.Is(err, io.EOF): + return nil + case err != nil: + return fmt.Errorf("receive msg: %w", err) + } + + batchStart := time.Now() + s.Logger.Debug("Received mapping update, starting processing") + if err := s.processMappingsGuarded(ctx, msg.GetMapping()); err != nil { + return err + } + s.Logger.Debug("Processing mapping update completed") + tracker.recordBatch(ctx, s, msg.GetMapping(), msg.GetInitialSyncComplete(), batchStart) + + if err := stream.Send(&proto.SyncMappingsRequest{ + Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}}, + }); err != nil { + return fmt.Errorf("send ack: %w", err) + } + } + } +} + +// snapshotTracker accumulates service IDs during the initial snapshot and +// finalises sync state when the complete flag arrives. Used by both +// handleMappingStream and handleSyncMappingsStream so metric emission and +// reconciliation behave identically on either RPC. +type snapshotTracker struct { + done *bool + connectTime time.Time + snapshotIDs map[types.ServiceID]struct{} +} + +func (s *Server) newSnapshotTracker(done *bool, connectTime time.Time) *snapshotTracker { + var ids map[types.ServiceID]struct{} + if !*done { + ids = make(map[types.ServiceID]struct{}) + } + return &snapshotTracker{done: done, connectTime: connectTime, snapshotIDs: ids} +} + +func (t *snapshotTracker) recordBatch(ctx context.Context, s *Server, mappings []*proto.ProxyMapping, syncComplete bool, batchStart time.Time) { + if *t.done { + return + } + + if s.meter != nil { + s.meter.RecordSnapshotBatchDuration(time.Since(batchStart)) + } + + for _, m := range mappings { + t.snapshotIDs[types.ServiceID(m.GetId())] = struct{}{} + } + + if !syncComplete { + return + } + + s.reconcileSnapshot(ctx, t.snapshotIDs) + t.snapshotIDs = nil + if s.healthChecker != nil { + s.healthChecker.SetInitialSyncComplete() + } + *t.done = true + if s.meter != nil { + s.meter.RecordSnapshotSyncDuration(time.Since(t.connectTime)) + } + s.Logger.Info("Initial mapping sync complete") +} + +func (s *Server) handleMappingStream(ctx context.Context, mappingClient proto.ProxyService_GetMappingUpdateClient, initialSyncDone *bool, connectTime time.Time) error { + select { + case <-s.routerReady: + case <-ctx.Done(): + return ctx.Err() + } + + tracker := s.newSnapshotTracker(initialSyncDone, connectTime) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + msg, err := mappingClient.Recv() + switch { + case errors.Is(err, io.EOF): + return nil + case err != nil: + return fmt.Errorf("receive msg: %w", err) + } + + batchStart := time.Now() + s.Logger.Debug("Received mapping update, starting processing") + if err := s.processMappingsGuarded(ctx, msg.GetMapping()); err != nil { + return err + } + s.Logger.Debug("Processing mapping update completed") + tracker.recordBatch(ctx, s, msg.GetMapping(), msg.GetInitialSyncComplete(), batchStart) + } + } +} + +// reconcileSnapshot removes local mappings that are absent from the snapshot. +// This ensures services deleted while the proxy was disconnected get cleaned up. +func (s *Server) reconcileSnapshot(ctx context.Context, snapshotIDs map[types.ServiceID]struct{}) { + s.portMu.RLock() + var stale []*proto.ProxyMapping + for svcID, mapping := range s.lastMappings { + if _, ok := snapshotIDs[svcID]; !ok { + stale = append(stale, mapping) + } + } + s.portMu.RUnlock() + + for _, mapping := range stale { + s.Logger.WithFields(log.Fields{ + "service_id": mapping.GetId(), + "domain": mapping.GetDomain(), + }).Info("Removing stale mapping absent from snapshot") + s.removeMapping(ctx, mapping) + } +} + +// mappingJSONMarshal dumps mappings on one line with zero-value fields visible for debug logs. +var mappingJSONMarshal = protojson.MarshalOptions{ + Multiline: false, + EmitUnpopulated: true, + UseProtoNames: true, +} + +// redactMappingForLog returns a deep copy of the mapping with sensitive fields +// (auth_token, header-auth hashed values, custom upstream headers) replaced so +// debug logs never carry credentials. +func redactMappingForLog(m *proto.ProxyMapping) *proto.ProxyMapping { + const placeholder = "[REDACTED]" + c := goproto.Clone(m).(*proto.ProxyMapping) + if c.GetAuthToken() != "" { + c.AuthToken = placeholder + } + if c.Auth != nil { + for _, h := range c.Auth.GetHeaderAuths() { + if h.GetHashedValue() != "" { + h.HashedValue = placeholder + } + } + } + for _, p := range c.GetPath() { + opts := p.GetOptions() + if opts == nil || len(opts.CustomHeaders) == 0 { + continue + } + redacted := make(map[string]string, len(opts.CustomHeaders)) + for k := range opts.CustomHeaders { + redacted[k] = placeholder + } + opts.CustomHeaders = redacted + } + return c +} + +const defaultMappingBatchWatchdog = 2 * time.Minute + +// mappingBatchWatchdog returns the configured batch watchdog or the default. +func (s *Server) mappingBatchWatchdog() time.Duration { + if s.MappingBatchWatchdog > 0 { + return s.MappingBatchWatchdog + } + return defaultMappingBatchWatchdog +} + +// processMappingsGuarded applies a batch under a watchdog, returning an error +// if processing exceeds the watchdog so the caller reconnects and resyncs +// instead of wedging silently. +func (s *Server) processMappingsGuarded(ctx context.Context, mappings []*proto.ProxyMapping) error { + batchCtx, cancel := context.WithCancel(ctx) + defer cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + s.processMappings(batchCtx, mappings) + }() + + watchdog := s.mappingBatchWatchdog() + timer := time.NewTimer(watchdog) + defer timer.Stop() + + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + s.Logger.Errorf("processing mapping batch exceeded %s, cancelling and reconnecting to resync", watchdog) + return fmt.Errorf("mapping batch processing stalled after %s", watchdog) + } +} + +func (s *Server) processMappings(ctx context.Context, mappings []*proto.ProxyMapping) { + debug := s.Logger != nil && s.Logger.IsLevelEnabled(log.DebugLevel) + for _, mapping := range mappings { + if debug { + raw, err := mappingJSONMarshal.Marshal(redactMappingForLog(mapping)) + if err != nil { + raw = []byte(fmt.Sprintf("", err)) + } + s.Logger.WithFields(log.Fields{ + "type": mapping.GetType(), + "domain": mapping.GetDomain(), + "id": mapping.GetId(), + "mapping": string(raw), + }).Debug("Processing mapping update") + } + switch mapping.GetType() { + case proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED: + if err := s.addMapping(ctx, mapping); err != nil { + s.Logger.WithFields(log.Fields{ + "service_id": mapping.GetId(), + "domain": mapping.GetDomain(), + "error": err, + }).Error("Error adding new mapping, ignoring this mapping and continuing processing") + s.notifyError(ctx, mapping, err) + } + case proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED: + if err := s.modifyMapping(ctx, mapping); err != nil { + s.Logger.WithFields(log.Fields{ + "service_id": mapping.GetId(), + "domain": mapping.GetDomain(), + "error": err, + }).Error("failed to modify mapping") + s.notifyError(ctx, mapping, err) + } + case proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED: + s.removeMapping(ctx, mapping) + } + } +} + +// addMapping registers a service mapping and starts the appropriate relay or routes. +func (s *Server) addMapping(ctx context.Context, mapping *proto.ProxyMapping) error { + accountID := types.AccountID(mapping.GetAccountId()) + svcID := types.ServiceID(mapping.GetId()) + authToken := mapping.GetAuthToken() + + svcKey := s.serviceKeyForMapping(mapping) + if err := s.netbird.AddPeer(ctx, accountID, svcKey, authToken, svcID); err != nil { + return fmt.Errorf("create peer for service %s: %w", svcID, err) + } + + if err := s.setupMappingRoutes(ctx, mapping); err != nil { + s.cleanupMappingRoutes(mapping) + if peerErr := s.netbird.RemovePeer(ctx, accountID, svcKey); peerErr != nil { + s.Logger.WithError(peerErr).WithField("service_id", svcID).Warn("failed to remove peer after setup failure") + } + return err + } + s.storeMapping(mapping) + return nil +} + +// modifyMapping updates a service mapping in place without tearing down the +// NetBird peer. It cleans up old routes using the previously stored mapping +// state and re-applies them from the new mapping. +func (s *Server) modifyMapping(ctx context.Context, mapping *proto.ProxyMapping) error { + if old := s.loadMapping(types.ServiceID(mapping.GetId())); old != nil { + s.cleanupMappingRoutes(old) + if mode := types.ServiceMode(old.GetMode()); mode.IsL4() { + s.meter.L4ServiceRemoved(mode) + } + } else { + s.cleanupMappingRoutes(mapping) + } + if err := s.setupMappingRoutes(ctx, mapping); err != nil { + s.cleanupMappingRoutes(mapping) + return err + } + s.storeMapping(mapping) + return nil +} + +// setupMappingRoutes configures the appropriate routes or relays for the given +// service mapping based on its mode. The NetBird peer must already exist. +func (s *Server) setupMappingRoutes(ctx context.Context, mapping *proto.ProxyMapping) error { + switch types.ServiceMode(mapping.GetMode()) { + case types.ServiceModeTCP: + return s.setupTCPMapping(ctx, mapping) + case types.ServiceModeUDP: + return s.setupUDPMapping(ctx, mapping) + case types.ServiceModeTLS: + return s.setupTLSMapping(ctx, mapping) + default: + return s.setupHTTPMapping(ctx, mapping) + } +} + +// setupHTTPMapping configures HTTP reverse proxy, auth, and ACME routes. +func (s *Server) setupHTTPMapping(ctx context.Context, mapping *proto.ProxyMapping) error { + d := domain.Domain(mapping.GetDomain()) + accountID := types.AccountID(mapping.GetAccountId()) + svcID := types.ServiceID(mapping.GetId()) + + if len(mapping.GetPath()) == 0 { + return nil + } + + var wildcardHit bool + if s.acme != nil { + wildcardHit = s.acme.AddDomain(d, accountID, svcID) + } else { + wildcardHit = s.staticCertCovers(d) + } + httpRoute := nbtcp.Route{ + Type: nbtcp.RouteHTTP, + AccountID: accountID, + ServiceID: svcID, + Domain: mapping.GetDomain(), + } + s.mainRouter.AddRoute(nbtcp.SNIHost(mapping.GetDomain()), httpRoute) + if s.inbound != nil { + s.inbound.AddRoute(accountID, nbtcp.SNIHost(mapping.GetDomain()), httpRoute) + } + if err := s.updateMapping(ctx, mapping); err != nil { + return fmt.Errorf("update mapping for domain %q: %w", d, err) + } + + if wildcardHit { + if err := s.NotifyCertificateIssued(ctx, accountID, svcID, string(d)); err != nil { + s.Logger.Warnf("notify certificate ready for domain %q: %v", d, err) + } + } + + return nil +} + +// staticCertCovers reports whether the static certificate loaded when ACME is +// disabled covers the given domain, making it certificate-ready immediately — +// the equivalent of a wildcard hit in the ACME path. Domains the certificate +// does not cover are logged: clients connecting to them will get TLS errors. +func (s *Server) staticCertCovers(d domain.Domain) bool { + if s.staticCertWatcher == nil { + return false + } + leaf := s.staticCertWatcher.Leaf() + if leaf == nil { + return false + } + name := d.PunycodeString() + if err := leaf.VerifyHostname(name); err != nil { + s.Logger.Warnf("static certificate (SANs %v) does not cover domain %q: %v", leaf.DNSNames, name, err) + return false + } + return true +} + +// setupTCPMapping sets up a TCP port-forwarding fallback route on the listen port. +func (s *Server) setupTCPMapping(ctx context.Context, mapping *proto.ProxyMapping) error { + svcID := types.ServiceID(mapping.GetId()) + accountID := types.AccountID(mapping.GetAccountId()) + + port, err := netutil.ValidatePort(mapping.GetListenPort()) + if err != nil { + return fmt.Errorf("TCP service %s: %w", svcID, err) + } + + targetAddr := s.l4TargetAddress(mapping) + if targetAddr == "" { + return fmt.Errorf("empty target address for TCP service %s", svcID) + } + + if s.WireguardPort != 0 && port == s.WireguardPort { + return fmt.Errorf("port %d conflicts with tunnel port", port) + } + + router, err := s.routerForPort(ctx, port) + if err != nil { + return fmt.Errorf("router for TCP port %d: %w", port, err) + } + + s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions()) + + router.SetGeo(s.geo) + router.SetFallback(nbtcp.Route{ + Type: nbtcp.RouteTCP, + AccountID: accountID, + ServiceID: svcID, + Domain: mapping.GetDomain(), + Protocol: accesslog.ProtocolTCP, + Target: targetAddr, + ProxyProtocol: s.l4ProxyProtocol(mapping), + DialTimeout: s.l4DialTimeout(mapping), + SessionIdleTimeout: s.clampIdleTimeout(l4SessionIdleTimeout(mapping)), + Filter: s.parseRestrictions(mapping), + }) + + s.portMu.Lock() + s.svcPorts[svcID] = []uint16{port} + s.portMu.Unlock() + + s.meter.L4ServiceAdded(types.ServiceModeTCP) + s.sendStatusUpdate(ctx, accountID, svcID, proto.ProxyStatus_PROXY_STATUS_ACTIVE, nil) + + s.Logger.WithFields(log.Fields{ + "domain": mapping.GetDomain(), + "target": targetAddr, + "port": port, + "service": svcID, + }).Info("TCP mapping added") + return nil +} + +// setupUDPMapping starts a UDP relay on the listen port. +func (s *Server) setupUDPMapping(ctx context.Context, mapping *proto.ProxyMapping) error { + svcID := types.ServiceID(mapping.GetId()) + accountID := types.AccountID(mapping.GetAccountId()) + + port, err := netutil.ValidatePort(mapping.GetListenPort()) + if err != nil { + return fmt.Errorf("UDP service %s: %w", svcID, err) + } + + targetAddr := s.l4TargetAddress(mapping) + if targetAddr == "" { + return fmt.Errorf("empty target address for UDP service %s", svcID) + } + + s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions()) + + if err := s.addUDPRelay(ctx, mapping, targetAddr, port); err != nil { + return fmt.Errorf("UDP relay for service %s: %w", svcID, err) + } + + s.meter.L4ServiceAdded(types.ServiceModeUDP) + s.sendStatusUpdate(ctx, accountID, svcID, proto.ProxyStatus_PROXY_STATUS_ACTIVE, nil) + return nil +} + +// setupTLSMapping configures a TLS SNI-routed passthrough on the listen port. +func (s *Server) setupTLSMapping(ctx context.Context, mapping *proto.ProxyMapping) error { + svcID := types.ServiceID(mapping.GetId()) + accountID := types.AccountID(mapping.GetAccountId()) + + tlsPort, err := netutil.ValidatePort(mapping.GetListenPort()) + if err != nil { + return fmt.Errorf("TLS service %s: %w", svcID, err) + } + + targetAddr := s.l4TargetAddress(mapping) + if targetAddr == "" { + return fmt.Errorf("empty target address for TLS service %s", svcID) + } + + if s.WireguardPort != 0 && tlsPort == s.WireguardPort { + return fmt.Errorf("port %d conflicts with tunnel port", tlsPort) + } + + router, err := s.routerForPort(ctx, tlsPort) + if err != nil { + return fmt.Errorf("router for TLS port %d: %w", tlsPort, err) + } + + s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions()) + + router.SetGeo(s.geo) + router.AddRoute(nbtcp.SNIHost(mapping.GetDomain()), nbtcp.Route{ + Type: nbtcp.RouteTCP, + AccountID: accountID, + ServiceID: svcID, + Domain: mapping.GetDomain(), + Protocol: accesslog.ProtocolTLS, + Target: targetAddr, + ProxyProtocol: s.l4ProxyProtocol(mapping), + DialTimeout: s.l4DialTimeout(mapping), + SessionIdleTimeout: s.clampIdleTimeout(l4SessionIdleTimeout(mapping)), + Filter: s.parseRestrictions(mapping), + }) + + if tlsPort != s.mainPort { + s.portMu.Lock() + s.svcPorts[svcID] = []uint16{tlsPort} + s.portMu.Unlock() + } + + s.Logger.WithFields(log.Fields{ + "domain": mapping.GetDomain(), + "target": targetAddr, + "port": tlsPort, + "service": svcID, + }).Info("TLS passthrough mapping added") + + s.meter.L4ServiceAdded(types.ServiceModeTLS) + s.sendStatusUpdate(ctx, accountID, svcID, proto.ProxyStatus_PROXY_STATUS_ACTIVE, nil) + return nil +} + +// serviceKeyForMapping returns the appropriate ServiceKey for a mapping. +// TCP/UDP use an ID-based key; HTTP/TLS use a domain-based key. +func (s *Server) serviceKeyForMapping(mapping *proto.ProxyMapping) roundtrip.ServiceKey { + switch types.ServiceMode(mapping.GetMode()) { + case types.ServiceModeTCP, types.ServiceModeUDP: + return roundtrip.L4ServiceKey(types.ServiceID(mapping.GetId())) + default: + return roundtrip.DomainServiceKey(mapping.GetDomain()) + } +} + +// parseRestrictions converts a proto mapping's access restrictions into +// a restrict.Filter. Returns nil if the mapping has no restrictions. +func (s *Server) parseRestrictions(mapping *proto.ProxyMapping) *restrict.Filter { + r := mapping.GetAccessRestrictions() + if r == nil { + return nil + } + + svcID := types.ServiceID(mapping.GetId()) + csMode := restrict.CrowdSecMode(r.GetCrowdsecMode()) + + var checker restrict.CrowdSecChecker + if csMode == restrict.CrowdSecEnforce || csMode == restrict.CrowdSecObserve { + if b := s.crowdsecRegistry.Acquire(svcID); b != nil { + checker = b + s.crowdsecMu.Lock() + s.crowdsecServices[svcID] = true + s.crowdsecMu.Unlock() + } else { + s.Logger.Warnf("service %s requests CrowdSec mode %q but proxy has no CrowdSec configured", svcID, csMode) + // Keep the mode: restrict.Filter will fail-closed for enforce (DenyCrowdSecUnavailable) + // and allow for observe. + } + } + + return restrict.ParseFilter(restrict.FilterConfig{ + AllowedCIDRs: r.GetAllowedCidrs(), + BlockedCIDRs: r.GetBlockedCidrs(), + AllowedCountries: r.GetAllowedCountries(), + BlockedCountries: r.GetBlockedCountries(), + CrowdSec: checker, + CrowdSecMode: csMode, + Logger: log.NewEntry(s.Logger), + }) +} + +// releaseCrowdSec releases the CrowdSec bouncer reference for the given +// service if it had one. +func (s *Server) releaseCrowdSec(svcID types.ServiceID) { + s.crowdsecMu.Lock() + had := s.crowdsecServices[svcID] + delete(s.crowdsecServices, svcID) + s.crowdsecMu.Unlock() + + if had { + s.crowdsecRegistry.Release(svcID) + } +} + +// warnIfGeoUnavailable logs a warning if the mapping has country restrictions +// but the proxy has no geolocation database loaded. All requests to this +// service will be denied at runtime (fail-close). +func (s *Server) warnIfGeoUnavailable(domain string, r *proto.AccessRestrictions) { + if r == nil { + return + } + if len(r.GetAllowedCountries()) == 0 && len(r.GetBlockedCountries()) == 0 { + return + } + if s.geo != nil && s.geo.Available() { + return + } + s.Logger.Warnf("service %s has country restrictions but no geolocation database is loaded: all requests will be denied", domain) +} + +// l4TargetAddress extracts and validates the target address from a mapping's +// first path entry. Returns empty string if no paths exist or the address is +// not a valid host:port. +func (s *Server) l4TargetAddress(mapping *proto.ProxyMapping) string { + paths := mapping.GetPath() + if len(paths) == 0 { + return "" + } + target := paths[0].GetTarget() + if _, _, err := net.SplitHostPort(target); err != nil { + s.Logger.WithFields(log.Fields{ + "service_id": mapping.GetId(), + "target": target, + }).Warnf("invalid L4 target address: %v", err) + return "" + } + return target +} + +// l4ProxyProtocol returns whether the first target has PROXY protocol enabled. +func (s *Server) l4ProxyProtocol(mapping *proto.ProxyMapping) bool { + paths := mapping.GetPath() + if len(paths) == 0 { + return false + } + return paths[0].GetOptions().GetProxyProtocol() +} + +// l4DialTimeout returns the dial timeout from the first target's options, +// clamped to MaxDialTimeout. +func (s *Server) l4DialTimeout(mapping *proto.ProxyMapping) time.Duration { + paths := mapping.GetPath() + if len(paths) > 0 { + if d := paths[0].GetOptions().GetRequestTimeout(); d != nil { + return s.clampDialTimeout(d.AsDuration()) + } + } + return s.clampDialTimeout(0) +} + +// l4SessionIdleTimeout returns the configured session idle timeout from the +// mapping options, or 0 to use the relay's default. +func l4SessionIdleTimeout(mapping *proto.ProxyMapping) time.Duration { + paths := mapping.GetPath() + if len(paths) > 0 { + if d := paths[0].GetOptions().GetSessionIdleTimeout(); d != nil { + return d.AsDuration() + } + } + return 0 +} + +// addUDPRelay starts a UDP relay on the specified listen port. +func (s *Server) addUDPRelay(ctx context.Context, mapping *proto.ProxyMapping, targetAddress string, listenPort uint16) error { + svcID := types.ServiceID(mapping.GetId()) + accountID := types.AccountID(mapping.GetAccountId()) + + if s.WireguardPort != 0 && listenPort == s.WireguardPort { + return fmt.Errorf("UDP port %d conflicts with tunnel port", listenPort) + } + + // Close existing relay if present (idempotent re-add). + s.removeUDPRelay(svcID) + + listenAddr := fmt.Sprintf(":%d", listenPort) + + listener, err := net.ListenPacket("udp", listenAddr) + if err != nil { + return fmt.Errorf("listen UDP on %s: %w", listenAddr, err) + } + + dialFn, err := s.resolveDialFunc(accountID) + if err != nil { + if err := listener.Close(); err != nil { + s.Logger.Debugf("close UDP listener on %s: %v", listenAddr, err) + } + return fmt.Errorf("resolve dialer for UDP: %w", err) + } + + entry := s.Logger.WithFields(log.Fields{ + "target": targetAddress, + "listen_port": listenPort, + "service_id": svcID, + }) + + relay := udprelay.New(s.portRouterContext(ctx), udprelay.RelayConfig{ + Logger: entry, + Listener: listener, + Target: targetAddress, + Domain: mapping.GetDomain(), + AccountID: accountID, + ServiceID: svcID, + DialFunc: dialFn, + DialTimeout: s.l4DialTimeout(mapping), + SessionTTL: s.clampIdleTimeout(l4SessionIdleTimeout(mapping)), + AccessLog: s.accessLog, + Filter: s.parseRestrictions(mapping), + Geo: s.geo, + }) + relay.SetObserver(s.meter) + + s.udpMu.Lock() + s.udpRelays[svcID] = relay + s.udpMu.Unlock() + + s.udpRelayWg.Go(relay.Serve) + entry.Info("UDP relay added") + return nil +} + +func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping) error { + // Very simple implementation here, we don't touch the existing peer + // connection or any existing TLS configuration, we simply overwrite + // the auth and proxy mappings. + // Note: this does require the management server to always send a + // full mapping rather than deltas during a modification. + accountID := types.AccountID(mapping.GetAccountId()) + svcID := types.ServiceID(mapping.GetId()) + + var schemes []auth.Scheme + if mapping.GetAuth().GetPassword() { + schemes = append(schemes, auth.NewPassword(s.mgmtClient, svcID, accountID)) + } + if mapping.GetAuth().GetPin() { + schemes = append(schemes, auth.NewPin(s.mgmtClient, svcID, accountID)) + } + if mapping.GetAuth().GetOidc() { + schemes = append(schemes, auth.NewOIDC(s.mgmtClient, svcID, accountID, s.ForwardedProto)) + } + for _, ha := range mapping.GetAuth().GetHeaderAuths() { + schemes = append(schemes, auth.NewHeader(s.mgmtClient, svcID, accountID, ha.GetHeader())) + } + + ipRestrictions := s.parseRestrictions(mapping) + s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions()) + + maxSessionAge := time.Duration(mapping.GetAuth().GetMaxSessionAgeSeconds()) * time.Second + if err := s.auth.AddDomain(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge, accountID, svcID, ipRestrictions, mapping.GetPrivate()); err != nil { + return fmt.Errorf("auth setup for domain %s: %w", mapping.GetDomain(), err) + } + m := s.protoToMapping(ctx, mapping) + s.proxy.AddMapping(m) + s.meter.AddMapping(m) + s.rebuildMiddlewareChains(svcID, m) + return nil +} + +// initMiddlewareManager wires the middleware subsystem at boot. It configures +// the per-process FactoryContext concrete middlewares consult, installs the +// live-service check, and binds the resolver to the registry concrete +// middlewares register themselves into via init(). +func (s *Server) initMiddlewareManager(ctx context.Context) error { + if s.meter == nil { + return fmt.Errorf("middleware manager requires metrics bundle") + } + otelMeter := s.meter.Meter() + mwbuiltin.Configure(ctx, s.MiddlewareDataDir, otelMeter, s.Logger, s.mgmtClient) + + mwMetrics, err := middleware.NewMetrics(otelMeter) + if err != nil { + return fmt.Errorf("init middleware metrics: %w", err) + } + budgetBytes := s.MiddlewareCaptureBudgetBytes + if budgetBytes <= 0 { + budgetBytes = defaultMiddlewareCaptureBudgetBytes + } + + registry := mwbuiltin.DefaultRegistry() + mgr := middleware.NewManager(budgetBytes, mwMetrics, s.Logger) + mgr.SetResolver(middleware.NewResolver(registry)) + mgr.SetLiveServiceCheck(s.isLiveService) + + s.middlewareRegistry = registry + s.middlewareManager = mgr + ids := registry.IDs() + s.Logger.Infof("middleware system enabled: %d built-in middlewares registered %v, capture budget %d bytes", + len(ids), ids, budgetBytes) + return nil +} + +// rebuildMiddlewareChains converts m into per-path bindings and calls +// Manager.Rebuild. Short-circuits when the middleware manager is unset. +func (s *Server) rebuildMiddlewareChains(svcID types.ServiceID, m proxy.Mapping) { + if s.middlewareManager == nil { + return + } + bindings := buildMiddlewareBindings(svcID, m) + if err := s.middlewareManager.Rebuild(string(svcID), bindings); err != nil { + s.Logger.WithError(err).WithField("service_id", svcID).Error("failed to rebuild middleware chains") + } +} + +// isLiveService reports whether svcID is currently present in the live +// mapping cache. Used by the middleware manager to confirm a chain is still +// referenced before rebuilding it from cached bindings. +func (s *Server) isLiveService(svcID string) bool { + s.portMu.RLock() + defer s.portMu.RUnlock() + _, ok := s.lastMappings[types.ServiceID(svcID)] + return ok +} + +// invalidateMiddlewareChains drops every middleware chain registered for svcID. +func (s *Server) invalidateMiddlewareChains(svcID types.ServiceID) { + if s.middlewareManager == nil { + return + } + s.middlewareManager.Invalidate(string(svcID)) +} + +// buildMiddlewareBindings converts the path targets of m into the per-path +// binding list the middleware manager's Rebuild expects. Targets without any +// middleware specs are skipped. +func buildMiddlewareBindings(svcID types.ServiceID, m proxy.Mapping) []middleware.PathTargetBinding { + if len(m.Paths) == 0 { + return nil + } + bindings := make([]middleware.PathTargetBinding, 0, len(m.Paths)) + for pathID, pt := range m.Paths { + if pt == nil || len(pt.Middlewares) == 0 { + continue + } + bindings = append(bindings, middleware.PathTargetBinding{ + ServiceID: string(svcID), + PathID: pathID, + Specs: pt.Middlewares, + }) + } + return bindings +} + +// removeMapping tears down routes/relays and the NetBird peer for a service. +// Uses the stored mapping state when available to ensure all previously +// configured routes are cleaned up. +func (s *Server) removeMapping(ctx context.Context, mapping *proto.ProxyMapping) { + accountID := types.AccountID(mapping.GetAccountId()) + svcKey := s.serviceKeyForMapping(mapping) + removePeer := s.removePeer + if removePeer == nil { + removePeer = s.netbird.RemovePeer + } + if err := removePeer(ctx, accountID, svcKey); err != nil { + s.Logger.WithFields(log.Fields{ + "account_id": accountID, + "service_id": mapping.GetId(), + "error": err, + }).Error("failed to remove NetBird peer, continuing cleanup") + } + + if old := s.deleteMapping(types.ServiceID(mapping.GetId())); old != nil { + s.cleanupMappingRoutes(old) + if mode := types.ServiceMode(old.GetMode()); mode.IsL4() { + s.meter.L4ServiceRemoved(mode) + } + } else { + s.cleanupMappingRoutes(mapping) + } +} + +// cleanupMappingRoutes removes HTTP/TLS/L4 routes and custom port state for a +// service without touching the NetBird peer. This is used for both full +// removal and in-place modification of mappings. +func (s *Server) cleanupMappingRoutes(mapping *proto.ProxyMapping) { + svcID := types.ServiceID(mapping.GetId()) + host := mapping.GetDomain() + + s.invalidateMiddlewareChains(svcID) + + // HTTP/TLS cleanup (only relevant when a domain is set). + if host != "" { + d := domain.Domain(host) + if s.acme != nil { + s.acme.RemoveDomain(d) + } + s.auth.RemoveDomain(host) + if s.proxy.RemoveMapping(proxy.Mapping{Host: host}) { + s.meter.RemoveMapping(proxy.Mapping{Host: host}) + } + // Close hijacked connections (WebSocket) for this domain. + if n := s.hijackTracker.CloseByHost(host); n > 0 { + s.Logger.Debugf("closed %d hijacked connection(s) for %s", n, host) + } + // Remove SNI route from the main router (covers both HTTP and main-port TLS). + s.mainRouter.RemoveRoute(nbtcp.SNIHost(host), svcID) + if s.inbound != nil { + s.inbound.RemoveRoute(types.AccountID(mapping.GetAccountId()), nbtcp.SNIHost(host), svcID) + } + } + + // Extract and delete tracked custom-port entries atomically. + s.portMu.Lock() + entries := s.svcPorts[svcID] + delete(s.svcPorts, svcID) + s.portMu.Unlock() + + for _, entry := range entries { + if router := s.routerForPortExisting(entry); router != nil { + if host != "" { + router.RemoveRoute(nbtcp.SNIHost(host), svcID) + } else { + router.RemoveFallback(svcID) + } + } + s.cleanupPortIfEmpty(entry) + } + + // UDP relay cleanup (idempotent). + s.removeUDPRelay(svcID) + + // Release CrowdSec after all routes are removed so the shared bouncer + // isn't stopped while stale filters can still be reached by in-flight requests. + s.releaseCrowdSec(svcID) +} + +// removeUDPRelay stops and removes a UDP relay by service ID. +func (s *Server) removeUDPRelay(svcID types.ServiceID) { + s.udpMu.Lock() + relay, ok := s.udpRelays[svcID] + if ok { + delete(s.udpRelays, svcID) + } + s.udpMu.Unlock() + + if ok { + relay.Close() + s.Logger.WithField("service_id", svcID).Info("UDP relay removed") + } +} + +func (s *Server) storeMapping(mapping *proto.ProxyMapping) { + s.portMu.Lock() + s.lastMappings[types.ServiceID(mapping.GetId())] = mapping + s.portMu.Unlock() +} + +func (s *Server) loadMapping(svcID types.ServiceID) *proto.ProxyMapping { + s.portMu.RLock() + m := s.lastMappings[svcID] + s.portMu.RUnlock() + return m +} + +func (s *Server) deleteMapping(svcID types.ServiceID) *proto.ProxyMapping { + s.portMu.Lock() + m := s.lastMappings[svcID] + delete(s.lastMappings, svcID) + s.portMu.Unlock() + return m +} + +func (s *Server) protoToMapping(ctx context.Context, mapping *proto.ProxyMapping) proxy.Mapping { + paths := make(map[string]*proxy.PathTarget) + for _, pathMapping := range mapping.GetPath() { + targetURL, err := url.Parse(pathMapping.GetTarget()) + if err != nil { + s.Logger.WithFields(log.Fields{ + "service_id": mapping.GetId(), + "account_id": mapping.GetAccountId(), + "domain": mapping.GetDomain(), + "path": pathMapping.GetPath(), + "target": pathMapping.GetTarget(), + }).WithError(err).Error("failed to parse target URL for path, skipping") + s.notifyError(ctx, mapping, fmt.Errorf("invalid target URL %q for path %q: %w", pathMapping.GetTarget(), pathMapping.GetPath(), err)) + continue + } + + pt := &proxy.PathTarget{URL: targetURL} + if opts := pathMapping.GetOptions(); opts != nil { + pt.SkipTLSVerify = opts.GetSkipTlsVerify() + pt.PathRewrite = protoToPathRewrite(opts.GetPathRewrite()) + pt.CustomHeaders = opts.GetCustomHeaders() + if d := opts.GetRequestTimeout(); d != nil { + pt.RequestTimeout = d.AsDuration() + } + pt.DirectUpstream = opts.GetDirectUpstream() + // Agent-network middleware specs + capture config + flag ride on + // the same per-target options. + pt.CaptureConfig = translateMiddlewareCaptureConfig(mapping.GetId(), opts) + pt.Middlewares = translateMiddlewareConfigs(ctx, mapping.GetId(), opts.GetMiddlewares(), s.middlewareRegistry) + pt.AgentNetwork = opts.GetAgentNetwork() + pt.DisableAccessLog = opts.GetDisableAccessLog() + } + pt.RequestTimeout = s.clampDialTimeout(pt.RequestTimeout) + paths[pathMapping.GetPath()] = pt + } + m := proxy.Mapping{ + ID: types.ServiceID(mapping.GetId()), + AccountID: types.AccountID(mapping.GetAccountId()), + Host: mapping.GetDomain(), + Paths: paths, + PassHostHeader: mapping.GetPassHostHeader(), + RewriteRedirects: mapping.GetRewriteRedirects(), + } + for _, ha := range mapping.GetAuth().GetHeaderAuths() { + m.StripAuthHeaders = append(m.StripAuthHeaders, ha.GetHeader()) + } + return m +} + +func protoToPathRewrite(mode proto.PathRewriteMode) proxy.PathRewriteMode { + switch mode { + case proto.PathRewriteMode_PATH_REWRITE_PRESERVE: + return proxy.PathRewritePreserve + default: + return proxy.PathRewriteDefault + } +} + +// debugEndpointAddr returns the address for the debug endpoint. +// If addr is empty, it defaults to localhost:8444 for security. +func debugEndpointAddr(addr string) string { + if addr == "" { + return defaultDebugAddr + } + return addr +} diff --git a/proxy/server_test.go b/proxy/server_test.go new file mode 100644 index 000000000..f0c4765db --- /dev/null +++ b/proxy/server_test.go @@ -0,0 +1,324 @@ +package proxy + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/metric/noop" + "google.golang.org/grpc" + + proxymetrics "github.com/netbirdio/netbird/proxy/internal/metrics" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +func TestDebugEndpointDisabledByDefault(t *testing.T) { + s := &Server{} + assert.False(t, s.DebugEndpointEnabled, "debug endpoint should be disabled by default") +} + +func TestDebugEndpointAddr(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "empty defaults to localhost", + input: "", + expected: "localhost:8444", + }, + { + name: "explicit localhost preserved", + input: "localhost:9999", + expected: "localhost:9999", + }, + { + name: "explicit address preserved", + input: "0.0.0.0:8444", + expected: "0.0.0.0:8444", + }, + { + name: "127.0.0.1 preserved", + input: "127.0.0.1:8444", + expected: "127.0.0.1:8444", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := debugEndpointAddr(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +// quietLifecycleLogger keeps lifecycle tests from spamming the test output. +func quietLifecycleLogger() *log.Logger { + l := log.New() + l.SetOutput(io.Discard) + l.SetLevel(log.PanicLevel) + return l +} + +func TestStopBeforeStartIsNoOp(t *testing.T) { + srv := New(t.Context(), Config{Logger: quietLifecycleLogger()}) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + err := srv.Stop(ctx) + assert.NoError(t, err, "Stop on an unstarted server must succeed without error") + + err = srv.Stop(ctx) + assert.NoError(t, err, "Stop must remain idempotent across repeated calls") +} + +func TestStartFailsWithoutManagement(t *testing.T) { + srv := New(t.Context(), Config{ + Logger: quietLifecycleLogger(), + ListenAddr: "127.0.0.1:0", + ManagementAddress: "://broken-url", + }) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + err := srv.Start(ctx) + require.Error(t, err, "Start must surface management dial failures") + + assert.True(t, srv.started, "started flag is set before any dial attempt so a second Start fails fast") + + err = srv.Start(ctx) + require.Error(t, err, "second Start must reject") + assert.Contains(t, err.Error(), "already started", "error must explain why the call was rejected") +} + +func TestStopIsIdempotent(t *testing.T) { + srv := &Server{ + Logger: quietLifecycleLogger(), + started: true, + runErrCh: make(chan struct{}), + runCancel: func() {}, + } + srv.recordRunErr(errors.New("synthetic")) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + err := srv.Stop(ctx) + require.Error(t, err, "Stop must surface the recorded background error") + assert.Contains(t, err.Error(), "synthetic", "error must round-trip recordRunErr's value") + + err = srv.Stop(ctx) + require.Error(t, err, "second Stop must still report the same error") + assert.Contains(t, err.Error(), "synthetic", "idempotent Stop must return the cached error") +} + +func TestRecordRunErrPreservesFirstFailure(t *testing.T) { + srv := &Server{ + Logger: quietLifecycleLogger(), + runErrCh: make(chan struct{}), + } + + srv.recordRunErr(errors.New("first")) + srv.recordRunErr(errors.New("second")) + + require.Error(t, srv.runErr, "first failure must be retained") + assert.Contains(t, srv.runErr.Error(), "first", "second call must not overwrite the cached error") + + select { + case <-srv.runErrCh: + default: + t.Fatal("recordRunErr must close runErrCh so waitAndStop unblocks") + } +} + +func TestStopSkipsShutdownWhenNeverStarted(t *testing.T) { + srv := New(t.Context(), Config{Logger: quietLifecycleLogger()}) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := srv.Stop(ctx) + assert.NoError(t, err, "Stop on an unstarted server should not block on the cancelled ctx") +} + +func TestRedactMappingForLog_ScrubsSensitiveFields(t *testing.T) { + original := &proto.ProxyMapping{ + Id: "svc-1", + Domain: "example.com", + AuthToken: "super-secret-token", + Auth: &proto.Authentication{ + SessionKey: "pubkey-not-secret", + HeaderAuths: []*proto.HeaderAuth{ + {Header: "Authorization", HashedValue: "argon2-hash-1"}, + {Header: "X-Api-Key", HashedValue: "argon2-hash-2"}, + }, + }, + Path: []*proto.PathMapping{ + { + Path: "/api", + Target: "10.0.0.1:8080", + Options: &proto.PathTargetOptions{ + CustomHeaders: map[string]string{ + "Authorization": "Bearer upstream-token", + "X-Tenant": "acme", + }, + }, + }, + }, + } + + redacted := redactMappingForLog(original) + + assert.Equal(t, "super-secret-token", original.AuthToken, "original must not be mutated") + assert.Equal(t, "argon2-hash-1", original.Auth.HeaderAuths[0].HashedValue, "original header hash must not be mutated") + assert.Equal(t, "Bearer upstream-token", original.Path[0].Options.CustomHeaders["Authorization"], "original custom header must not be mutated") + + assert.Equal(t, "[REDACTED]", redacted.AuthToken, "auth_token must be redacted") + require.Len(t, redacted.Auth.HeaderAuths, 2, "header auths must be preserved in count") + assert.Equal(t, "Authorization", redacted.Auth.HeaderAuths[0].Header, "header name must be preserved") + assert.Equal(t, "[REDACTED]", redacted.Auth.HeaderAuths[0].HashedValue, "hashed_value must be redacted") + assert.Equal(t, "[REDACTED]", redacted.Auth.HeaderAuths[1].HashedValue, "hashed_value must be redacted for every header auth") + assert.Equal(t, "pubkey-not-secret", redacted.Auth.SessionKey, "session_key (public) must be preserved") + + headers := redacted.Path[0].Options.CustomHeaders + require.Len(t, headers, 2, "custom header keys must be preserved") + assert.Equal(t, "[REDACTED]", headers["Authorization"], "custom header values must be redacted") + assert.Equal(t, "[REDACTED]", headers["X-Tenant"], "every custom header value must be redacted") + + assert.Equal(t, "svc-1", redacted.Id, "non-sensitive fields must round-trip") + assert.Equal(t, "example.com", redacted.Domain, "non-sensitive fields must round-trip") +} + +func TestRedactMappingForLog_HandlesEmptyOrNilFields(t *testing.T) { + empty := &proto.ProxyMapping{Id: "svc-empty"} + redacted := redactMappingForLog(empty) + + assert.Equal(t, "", redacted.AuthToken, "empty auth_token must remain empty (no placeholder)") + assert.Nil(t, redacted.Auth, "nil Auth must remain nil") + assert.Empty(t, redacted.Path, "empty Path must remain empty") +} + +type statusUpdateOnlyClient struct { + proto.ProxyServiceClient +} + +func (statusUpdateOnlyClient) SendStatusUpdate(context.Context, *proto.SendStatusUpdateRequest, ...grpc.CallOption) (*proto.SendStatusUpdateResponse, error) { + return &proto.SendStatusUpdateResponse{}, nil +} + +func TestSetupTCPMappingBindsCustomListenPort(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + port := uint16(ln.Addr().(*net.TCPAddr).Port) //nolint:gosec // test port allocated by the OS + require.NoError(t, ln.Close()) + + meter, err := proxymetrics.New(context.Background(), noop.Meter{}) + require.NoError(t, err) + + srv := &Server{ + Logger: quietLifecycleLogger(), + mgmtClient: statusUpdateOnlyClient{}, + meter: meter, + mainPort: 8443, + portRouters: make(map[uint16]*portRouter), + svcPorts: make(map[types.ServiceID][]uint16), + } + t.Cleanup(func() { + srv.portMu.Lock() + for p, pr := range srv.portRouters { + pr.cancel() + require.NoError(t, pr.listener.Close()) + delete(srv.portRouters, p) + } + srv.portMu.Unlock() + srv.portRouterWg.Wait() + }) + + mapping := &proto.ProxyMapping{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, + Id: "svc-tcp", + AccountId: "acct-1", + Domain: "ssh.example.com", + Mode: "tcp", + ListenPort: int32(port), + Path: []*proto.PathMapping{ + {Target: "10.0.0.5:22"}, + }, + } + + require.NoError(t, srv.setupTCPMapping(context.Background(), mapping)) + + srv.portMu.RLock() + pr := srv.portRouters[port] + ports := append([]uint16(nil), srv.svcPorts[types.ServiceID("svc-tcp")]...) + srv.portMu.RUnlock() + + require.NotNil(t, pr, "custom TCP mapping must create a per-port router") + assert.Equal(t, []uint16{port}, ports, "service must track the custom listen port for cleanup") + + second, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err == nil { + _ = second.Close() + } + require.Error(t, err, "custom TCP listen port must be bound after setup") +} + +func TestCustomTCPPortRouterOutlivesMappingBatchContext(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + port := uint16(ln.Addr().(*net.TCPAddr).Port) //nolint:gosec // test port allocated by the OS + require.NoError(t, ln.Close()) + + meter, err := proxymetrics.New(context.Background(), noop.Meter{}) + require.NoError(t, err) + + srvCtx, srvCancel := context.WithCancel(context.Background()) + t.Cleanup(srvCancel) + + srv := &Server{ + ctx: srvCtx, + Logger: quietLifecycleLogger(), + meter: meter, + mainPort: 8443, + portRouters: make(map[uint16]*portRouter), + svcPorts: make(map[types.ServiceID][]uint16), + } + t.Cleanup(func() { + srv.portMu.Lock() + for p, pr := range srv.portRouters { + pr.cancel() + if err := pr.listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + require.NoError(t, err) + } + delete(srv.portRouters, p) + } + srv.portMu.Unlock() + srv.portRouterWg.Wait() + }) + + batchCtx, cancelBatch := context.WithCancel(context.Background()) + _, err = srv.getOrCreatePortRouter(batchCtx, port) + require.NoError(t, err) + + cancelBatch() + + assert.Never(t, func() bool { + second, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err == nil { + _ = second.Close() + return true + } + return false + }, 200*time.Millisecond, 10*time.Millisecond, "custom TCP listener must outlive mapping-batch context cancellation") +} diff --git a/proxy/snapshot_reconcile_test.go b/proxy/snapshot_reconcile_test.go new file mode 100644 index 000000000..2e6c80cfc --- /dev/null +++ b/proxy/snapshot_reconcile_test.go @@ -0,0 +1,228 @@ +package proxy + +import ( + "context" + "io" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/health" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// collectStaleIDs mirrors the stale-detection logic in reconcileSnapshot +// so we can verify it without triggering removeMapping (which requires full +// server wiring). This keeps the test focused on the detection algorithm. +func collectStaleIDs(lastMappings map[types.ServiceID]*proto.ProxyMapping, snapshotIDs map[types.ServiceID]struct{}) []types.ServiceID { + var stale []types.ServiceID + for svcID := range lastMappings { + if _, ok := snapshotIDs[svcID]; !ok { + stale = append(stale, svcID) + } + } + return stale +} + +// TestStaleDetection_PartialOverlap verifies that only services absent from +// the snapshot are flagged as stale. +func TestStaleDetection_PartialOverlap(t *testing.T) { + local := map[types.ServiceID]*proto.ProxyMapping{ + "svc-1": {Id: "svc-1"}, + "svc-2": {Id: "svc-2"}, + "svc-stale-a": {Id: "svc-stale-a"}, + "svc-stale-b": {Id: "svc-stale-b"}, + } + snapshot := map[types.ServiceID]struct{}{ + "svc-1": {}, + "svc-2": {}, + "svc-3": {}, // new service, not in local + } + + stale := collectStaleIDs(local, snapshot) + assert.Len(t, stale, 2) + staleSet := make(map[types.ServiceID]struct{}) + for _, id := range stale { + staleSet[id] = struct{}{} + } + assert.Contains(t, staleSet, types.ServiceID("svc-stale-a")) + assert.Contains(t, staleSet, types.ServiceID("svc-stale-b")) +} + +// TestStaleDetection_AllStale verifies an empty snapshot flags everything. +func TestStaleDetection_AllStale(t *testing.T) { + local := map[types.ServiceID]*proto.ProxyMapping{ + "svc-1": {Id: "svc-1"}, + "svc-2": {Id: "svc-2"}, + } + stale := collectStaleIDs(local, map[types.ServiceID]struct{}{}) + assert.Len(t, stale, 2) +} + +// TestStaleDetection_NoneStale verifies full overlap produces no stale entries. +func TestStaleDetection_NoneStale(t *testing.T) { + local := map[types.ServiceID]*proto.ProxyMapping{ + "svc-1": {Id: "svc-1"}, + "svc-2": {Id: "svc-2"}, + } + snapshot := map[types.ServiceID]struct{}{ + "svc-1": {}, + "svc-2": {}, + } + stale := collectStaleIDs(local, snapshot) + assert.Empty(t, stale) +} + +// TestStaleDetection_EmptyLocal verifies no stale entries when local is empty. +func TestStaleDetection_EmptyLocal(t *testing.T) { + stale := collectStaleIDs( + map[types.ServiceID]*proto.ProxyMapping{}, + map[types.ServiceID]struct{}{"svc-1": {}}, + ) + assert.Empty(t, stale) +} + +// TestReconcileSnapshot_NoStale verifies reconciliation is a no-op when all +// local mappings are present in the snapshot (removeMapping is never called). +func TestReconcileSnapshot_NoStale(t *testing.T) { + s := &Server{ + Logger: log.StandardLogger(), + lastMappings: make(map[types.ServiceID]*proto.ProxyMapping), + } + s.lastMappings["svc-1"] = &proto.ProxyMapping{Id: "svc-1"} + s.lastMappings["svc-2"] = &proto.ProxyMapping{Id: "svc-2"} + + snapshotIDs := map[types.ServiceID]struct{}{ + "svc-1": {}, + "svc-2": {}, + } + // This should not panic — no stale entries means removeMapping is never called. + s.reconcileSnapshot(context.Background(), snapshotIDs) + + assert.Len(t, s.lastMappings, 2, "no mappings should be removed when all are in snapshot") +} + +// TestReconcileSnapshot_EmptyLocal verifies reconciliation is a no-op with +// no local mappings. +func TestReconcileSnapshot_EmptyLocal(t *testing.T) { + s := &Server{ + Logger: log.StandardLogger(), + lastMappings: make(map[types.ServiceID]*proto.ProxyMapping), + } + s.reconcileSnapshot(context.Background(), map[types.ServiceID]struct{}{"svc-1": {}}) + assert.Empty(t, s.lastMappings) +} + +// --- handleMappingStream tests for batched snapshot ID accumulation --- + +// TestHandleMappingStream_BatchedSnapshotSyncComplete verifies that sync is +// marked done only after the final InitialSyncComplete message, even when +// the snapshot arrives in multiple batches. +func TestHandleMappingStream_BatchedSnapshotSyncComplete(t *testing.T) { + checker := health.NewChecker(nil, nil) + s := &Server{ + Logger: log.StandardLogger(), + healthChecker: checker, + routerReady: closedChan(), + lastMappings: make(map[types.ServiceID]*proto.ProxyMapping), + } + + stream := &mockMappingStream{ + messages: []*proto.GetMappingUpdateResponse{ + {}, // batch 1: no sync-complete + {}, // batch 2: no sync-complete + {InitialSyncComplete: true}, // batch 3: sync done + }, + } + + syncDone := false + err := s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{}) + assert.NoError(t, err) + assert.True(t, syncDone, "sync should be marked done after final batch") +} + +// TestHandleMappingStream_PostSyncDoesNotReconcile verifies that messages +// arriving after InitialSyncComplete do not trigger a second reconciliation. +func TestHandleMappingStream_PostSyncDoesNotReconcile(t *testing.T) { + s := &Server{ + Logger: log.StandardLogger(), + routerReady: closedChan(), + lastMappings: make(map[types.ServiceID]*proto.ProxyMapping), + } + + // Simulate state left over from a previous sync. + s.lastMappings["svc-1"] = &proto.ProxyMapping{Id: "svc-1", AccountId: "acct-1"} + s.lastMappings["svc-2"] = &proto.ProxyMapping{Id: "svc-2", AccountId: "acct-1"} + + stream := &mockMappingStream{ + messages: []*proto.GetMappingUpdateResponse{ + {}, // post-sync empty message — must not reconcile + }, + } + + syncDone := true // sync already completed in a previous stream + err := s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{}) + require.NoError(t, err) + + assert.Len(t, s.lastMappings, 2, + "post-sync messages must not trigger reconciliation — all entries should survive") +} + +// TestHandleMappingStream_ImmediateEOF_NoReconciliation verifies that if the +// stream closes before sync completes, no reconciliation occurs. +func TestHandleMappingStream_ImmediateEOF_NoReconciliation(t *testing.T) { + s := &Server{ + Logger: log.StandardLogger(), + routerReady: closedChan(), + lastMappings: make(map[types.ServiceID]*proto.ProxyMapping), + } + + s.lastMappings["svc-stale"] = &proto.ProxyMapping{Id: "svc-stale", AccountId: "acct-1"} + + stream := &mockMappingStream{} // no messages → immediate EOF + + syncDone := false + err := s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{}) + assert.NoError(t, err) + assert.False(t, syncDone, "sync should not be marked done on immediate EOF") + + _, hasStale := s.lastMappings["svc-stale"] + assert.True(t, hasStale, "stale mapping should remain when sync never completed") +} + +// mockErrRecvStream returns an error on the second Recv to verify +// handleMappingStream returns without completing sync. +type mockErrRecvStream struct { + mockMappingStream + calls int +} + +func (m *mockErrRecvStream) Recv() (*proto.GetMappingUpdateResponse, error) { + m.calls++ + if m.calls == 1 { + return &proto.GetMappingUpdateResponse{}, nil + } + return nil, io.ErrUnexpectedEOF +} + +func TestHandleMappingStream_ErrorMidSync_NoReconciliation(t *testing.T) { + s := &Server{ + Logger: log.StandardLogger(), + routerReady: closedChan(), + lastMappings: make(map[types.ServiceID]*proto.ProxyMapping), + } + + s.lastMappings["svc-stale"] = &proto.ProxyMapping{Id: "svc-stale", AccountId: "acct-1"} + + syncDone := false + err := s.handleMappingStream(context.Background(), &mockErrRecvStream{}, &syncDone, time.Time{}) + assert.Error(t, err) + assert.False(t, syncDone) + + _, hasStale := s.lastMappings["svc-stale"] + assert.True(t, hasStale, "stale mapping should remain when sync was interrupted by error") +} diff --git a/proxy/static_cert_test.go b/proxy/static_cert_test.go new file mode 100644 index 000000000..54d2b6485 --- /dev/null +++ b/proxy/static_cert_test.go @@ -0,0 +1,89 @@ +package proxy + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/certwatch" + "github.com/netbirdio/netbird/shared/management/domain" +) + +func generateCertWithSANs(t *testing.T, dnsNames []string) (certPEM, keyPEM []byte) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: dnsNames[0]}, + DNSNames: dnsNames, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + keyDER, err := x509.MarshalECPrivateKey(key) + require.NoError(t, err) + keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + + return certPEM, keyPEM +} + +func newStaticWatcher(t *testing.T, dnsNames []string) *certwatch.Watcher { + t.Helper() + + dir := t.TempDir() + certPEM, keyPEM := generateCertWithSANs(t, dnsNames) + certPath := filepath.Join(dir, "tls.crt") + keyPath := filepath.Join(dir, "tls.key") + require.NoError(t, os.WriteFile(certPath, certPEM, 0o600)) + require.NoError(t, os.WriteFile(keyPath, keyPEM, 0o600)) + + w, err := certwatch.NewWatcher(certPath, keyPath, quietLifecycleLogger()) + require.NoError(t, err) + return w +} + +func TestStaticCertCovers(t *testing.T) { + s := &Server{ + Logger: quietLifecycleLogger(), + staticCertWatcher: newStaticWatcher(t, []string{"*.p.example.com", "exact.example.com"}), + } + + cases := []struct { + domain string + covered bool + }{ + {"svc.p.example.com", true}, + {"exact.example.com", true}, + {"a.b.p.example.com", false}, // wildcard does not span labels + {"p.example.com", false}, + {"other.example.com", false}, + } + for _, tc := range cases { + t.Run(tc.domain, func(t *testing.T) { + assert.Equal(t, tc.covered, s.staticCertCovers(domain.Domain(tc.domain))) + }) + } +} + +func TestStaticCertCoversNoWatcher(t *testing.T) { + s := &Server{Logger: quietLifecycleLogger()} + assert.False(t, s.staticCertCovers(domain.Domain("svc.p.example.com"))) +} diff --git a/proxy/sync_mappings_test.go b/proxy/sync_mappings_test.go new file mode 100644 index 000000000..c9c0dad03 --- /dev/null +++ b/proxy/sync_mappings_test.go @@ -0,0 +1,614 @@ +package proxy + +import ( + "context" + "errors" + "fmt" + "net" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + grpcstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/shared/management/proto" +) + +func TestIntegration_SyncMappings_HappyPath(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + stream, err := client.SyncMappings(ctx) + require.NoError(t, err) + + // Send init. + err = stream.Send(&proto.SyncMappingsRequest{ + Msg: &proto.SyncMappingsRequest_Init{ + Init: &proto.SyncMappingsInit{ + ProxyId: "sync-proxy-1", + Version: "test-v1", + Address: "test.proxy.io", + }, + }, + }) + require.NoError(t, err) + + mappingsByID := make(map[string]*proto.ProxyMapping) + for { + msg, err := stream.Recv() + require.NoError(t, err) + for _, m := range msg.GetMapping() { + mappingsByID[m.GetId()] = m + } + + // Ack every batch. + err = stream.Send(&proto.SyncMappingsRequest{ + Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}}, + }) + require.NoError(t, err) + + if msg.GetInitialSyncComplete() { + break + } + } + + assert.Len(t, mappingsByID, 2, "Should receive 2 mappings") + + rp1 := mappingsByID["rp-1"] + require.NotNil(t, rp1) + assert.Equal(t, "app1.test.proxy.io", rp1.GetDomain()) + assert.Equal(t, "test-account-1", rp1.GetAccountId()) + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, rp1.GetType()) + assert.NotEmpty(t, rp1.GetAuthToken(), "Should have auth token") + + rp2 := mappingsByID["rp-2"] + require.NotNil(t, rp2) + assert.Equal(t, "app2.test.proxy.io", rp2.GetDomain()) +} + +func TestIntegration_SyncMappings_CustomTCPMappingDeliveredWithCapabilities(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + ctx := context.Background() + tcpSvc := &service.Service{ + ID: "tcp-custom", + AccountID: "test-account-1", + Name: "Custom TCP", + Domain: "ssh.test.proxy.io", + ProxyCluster: "test.proxy.io", + Mode: "tcp", + ListenPort: 10001, + Enabled: true, + Targets: []*service.Target{{ + Host: "10.0.0.5", + Port: 22, + Protocol: "tcp", + TargetId: "peer-ssh", + TargetType: "peer", + Enabled: true, + }}, + } + require.NoError(t, setup.store.CreateService(ctx, tcpSvc)) + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + receiveSnapshot := func(proxyID string, caps *proto.ProxyCapabilities) map[string]*proto.ProxyMapping { + t.Helper() + + streamCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + stream, err := client.SyncMappings(streamCtx) + require.NoError(t, err) + + err = stream.Send(&proto.SyncMappingsRequest{ + Msg: &proto.SyncMappingsRequest_Init{ + Init: &proto.SyncMappingsInit{ + ProxyId: proxyID, + Version: "test-v1", + Address: "test.proxy.io", + Capabilities: caps, + }, + }, + }) + require.NoError(t, err) + + mappingsByID := make(map[string]*proto.ProxyMapping) + for { + msg, err := stream.Recv() + require.NoError(t, err) + for _, m := range msg.GetMapping() { + mappingsByID[m.GetId()] = m + } + + err = stream.Send(&proto.SyncMappingsRequest{ + Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}}, + }) + require.NoError(t, err) + + if msg.GetInitialSyncComplete() { + break + } + } + return mappingsByID + } + + legacyMappings := receiveSnapshot("sync-proxy-no-capabilities", nil) + assert.NotContains(t, legacyMappings, "tcp-custom", + "legacy proxies that do not report capabilities must not receive TCP custom-port mappings") + + supportsCustomPorts := true + modernMappings := receiveSnapshot("sync-proxy-custom-ports", &proto.ProxyCapabilities{ + SupportsCustomPorts: &supportsCustomPorts, + }) + + tcpMapping := modernMappings["tcp-custom"] + require.NotNil(t, tcpMapping, "capability-aware proxy must receive TCP custom-port mapping") + assert.Equal(t, "tcp", tcpMapping.GetMode()) + assert.Equal(t, int32(10001), tcpMapping.GetListenPort()) + require.Len(t, tcpMapping.GetPath(), 1) + assert.Equal(t, "10.0.0.5:22", tcpMapping.GetPath()[0].GetTarget()) + assert.NotEmpty(t, tcpMapping.GetAuthToken(), "snapshot mapping must include per-proxy auth token") +} + +func TestIntegration_SyncMappings_BackPressure(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + // Add enough services to guarantee multiple batches (default batch size 500). + addServicesToStore(t, setup, 600, "test.proxy.io") + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + stream, err := client.SyncMappings(ctx) + require.NoError(t, err) + + err = stream.Send(&proto.SyncMappingsRequest{ + Msg: &proto.SyncMappingsRequest_Init{ + Init: &proto.SyncMappingsInit{ + ProxyId: "sync-proxy-backpressure", + Version: "test-v1", + Address: "test.proxy.io", + }, + }, + }) + require.NoError(t, err) + + // Strategy: receive batch 1, then hold for a significant delay before + // acking. If back-pressure works, batch 2 cannot arrive until after + // the ack is sent — so its receive timestamp must be >= the ack + // timestamp. If management were fire-and-forget, all batches would + // already be buffered in the gRPC transport and batch 2 would arrive + // well before the ack time. + const ackDelay = 300 * time.Millisecond + + type batchEvent struct { + recvAt time.Time + ackAt time.Time + count int + } + var batches []batchEvent + var totalMappings int + + for { + msg, err := stream.Recv() + require.NoError(t, err) + + recvAt := time.Now() + totalMappings += len(msg.GetMapping()) + + // Delay the ack on non-final batches to create a measurable gap. + if !msg.GetInitialSyncComplete() { + time.Sleep(ackDelay) + } + + ackAt := time.Now() + batches = append(batches, batchEvent{ + recvAt: recvAt, + ackAt: ackAt, + count: len(msg.GetMapping()), + }) + + err = stream.Send(&proto.SyncMappingsRequest{ + Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}}, + }) + require.NoError(t, err) + + if msg.GetInitialSyncComplete() { + break + } + } + + // 2 original + 600 added = 602 services total. + assert.Equal(t, 602, totalMappings, "should receive all 602 mappings") + require.GreaterOrEqual(t, len(batches), 2, "need at least 2 batches to verify back-pressure") + + // For every batch after the first, its receive time must be after the + // previous batch's ack time. This proves management waited for the ack + // before sending the next batch. + for i := 1; i < len(batches); i++ { + prevAckAt := batches[i-1].ackAt + thisRecvAt := batches[i].recvAt + assert.True(t, !thisRecvAt.Before(prevAckAt), + "batch %d received at %v, but batch %d was acked at %v — "+ + "management sent the next batch before receiving the ack", + i, thisRecvAt, i-1, prevAckAt) + } +} + +func TestIntegration_SyncMappings_IncrementalUpdate(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + stream, err := client.SyncMappings(ctx) + require.NoError(t, err) + + err = stream.Send(&proto.SyncMappingsRequest{ + Msg: &proto.SyncMappingsRequest_Init{ + Init: &proto.SyncMappingsInit{ + ProxyId: "sync-proxy-incremental", + Version: "test-v1", + Address: "test.proxy.io", + }, + }, + }) + require.NoError(t, err) + + // Drain initial snapshot. + for { + msg, err := stream.Recv() + require.NoError(t, err) + + err = stream.Send(&proto.SyncMappingsRequest{ + Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}}, + }) + require.NoError(t, err) + + if msg.GetInitialSyncComplete() { + break + } + } + + // Now send an incremental update via the management server. + setup.proxyService.SendServiceUpdate(&proto.GetMappingUpdateResponse{ + Mapping: []*proto.ProxyMapping{{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, + Id: "rp-1", + AccountId: "test-account-1", + Domain: "app1.test.proxy.io", + }}, + }) + + // Receive the incremental update on the sync stream. + msg, err := stream.Recv() + require.NoError(t, err) + require.NotEmpty(t, msg.GetMapping()) + assert.Equal(t, "rp-1", msg.GetMapping()[0].GetId()) + assert.Equal(t, proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, msg.GetMapping()[0].GetType()) +} + +func TestIntegration_SyncMappings_MixedProxyVersions(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Old proxy uses GetMappingUpdate. + legacyStream, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{ + ProxyId: "legacy-proxy", + Version: "old-v1", + Address: "test.proxy.io", + }) + require.NoError(t, err) + + var legacyMappings []*proto.ProxyMapping + for { + msg, err := legacyStream.Recv() + require.NoError(t, err) + legacyMappings = append(legacyMappings, msg.GetMapping()...) + if msg.GetInitialSyncComplete() { + break + } + } + + // New proxy uses SyncMappings. + syncStream, err := client.SyncMappings(ctx) + require.NoError(t, err) + + err = syncStream.Send(&proto.SyncMappingsRequest{ + Msg: &proto.SyncMappingsRequest_Init{ + Init: &proto.SyncMappingsInit{ + ProxyId: "new-proxy", + Version: "new-v2", + Address: "test.proxy.io", + }, + }, + }) + require.NoError(t, err) + + var syncMappings []*proto.ProxyMapping + for { + msg, err := syncStream.Recv() + require.NoError(t, err) + syncMappings = append(syncMappings, msg.GetMapping()...) + + err = syncStream.Send(&proto.SyncMappingsRequest{ + Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}}, + }) + require.NoError(t, err) + + if msg.GetInitialSyncComplete() { + break + } + } + + // Both should receive the same set of mappings. + assert.Equal(t, len(legacyMappings), len(syncMappings), + "legacy and sync proxies should receive the same number of mappings") + + legacyIDs := make(map[string]bool) + for _, m := range legacyMappings { + legacyIDs[m.GetId()] = true + } + for _, m := range syncMappings { + assert.True(t, legacyIDs[m.GetId()], + "mapping %s should be present in both streams", m.GetId()) + } + + // Both proxies should be connected. + proxies := setup.proxyService.GetConnectedProxies() + assert.Contains(t, proxies, "legacy-proxy") + assert.Contains(t, proxies, "new-proxy") + + // Both should receive incremental updates. + setup.proxyService.SendServiceUpdate(&proto.GetMappingUpdateResponse{ + Mapping: []*proto.ProxyMapping{{ + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, + Id: "rp-1", + AccountId: "test-account-1", + Domain: "app1.test.proxy.io", + }}, + }) + + // Legacy proxy receives via GetMappingUpdateResponse. + legacyMsg, err := legacyStream.Recv() + require.NoError(t, err) + assert.Equal(t, "rp-1", legacyMsg.GetMapping()[0].GetId()) + + // Sync proxy receives via SyncMappingsResponse. + syncMsg, err := syncStream.Recv() + require.NoError(t, err) + assert.Equal(t, "rp-1", syncMsg.GetMapping()[0].GetId()) +} + +func TestIntegration_SyncMappings_Reconnect(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + proxyID := "sync-proxy-reconnect" + + receiveMappings := func() []*proto.ProxyMapping { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + stream, err := client.SyncMappings(ctx) + require.NoError(t, err) + + err = stream.Send(&proto.SyncMappingsRequest{ + Msg: &proto.SyncMappingsRequest_Init{ + Init: &proto.SyncMappingsInit{ + ProxyId: proxyID, + Version: "test-v1", + Address: "test.proxy.io", + }, + }, + }) + require.NoError(t, err) + + var mappings []*proto.ProxyMapping + for { + msg, err := stream.Recv() + require.NoError(t, err) + mappings = append(mappings, msg.GetMapping()...) + + err = stream.Send(&proto.SyncMappingsRequest{ + Msg: &proto.SyncMappingsRequest_Ack{Ack: &proto.SyncMappingsAck{}}, + }) + require.NoError(t, err) + + if msg.GetInitialSyncComplete() { + break + } + } + return mappings + } + + first := receiveMappings() + time.Sleep(100 * time.Millisecond) + second := receiveMappings() + + assert.Equal(t, len(first), len(second), + "should receive same mappings on reconnect") + + firstIDs := make(map[string]bool) + for _, m := range first { + firstIDs[m.GetId()] = true + } + for _, m := range second { + assert.True(t, firstIDs[m.GetId()], + "mapping %s should be present in both connections", m.GetId()) + } +} + +// --- Fallback tests: old management returns Unimplemented --- + +// unimplementedProxyServer embeds UnimplementedProxyServiceServer so +// SyncMappings returns codes.Unimplemented while GetMappingUpdate works. +type unimplementedSyncServer struct { + proto.UnimplementedProxyServiceServer + getMappingCalls atomic.Int32 +} + +func (s *unimplementedSyncServer) GetMappingUpdate(_ *proto.GetMappingUpdateRequest, stream proto.ProxyService_GetMappingUpdateServer) error { + s.getMappingCalls.Add(1) + return stream.Send(&proto.GetMappingUpdateResponse{ + Mapping: []*proto.ProxyMapping{{Id: "svc-1", AccountId: "acct-1", Domain: "example.com"}}, + InitialSyncComplete: true, + }) +} + +func TestIntegration_FallbackToGetMappingUpdate(t *testing.T) { + // Start a gRPC server that does NOT implement SyncMappings. + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + srv := &unimplementedSyncServer{} + grpcServer := grpc.NewServer() + proto.RegisterProxyServiceServer(grpcServer, srv) + go func() { _ = grpcServer.Serve(lis) }() + defer grpcServer.GracefulStop() + + conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + + client := proto.NewProxyServiceClient(conn) + + // Try SyncMappings — should get Unimplemented. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + stream, err := client.SyncMappings(ctx) + require.NoError(t, err) + + err = stream.Send(&proto.SyncMappingsRequest{ + Msg: &proto.SyncMappingsRequest_Init{ + Init: &proto.SyncMappingsInit{ + ProxyId: "test-proxy", + Address: "test.example.com", + }, + }, + }) + require.NoError(t, err) + + _, err = stream.Recv() + require.Error(t, err) + st, ok := grpcstatus.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Unimplemented, st.Code(), + "unimplemented SyncMappings should return Unimplemented code") + + // isSyncUnimplemented should detect this. + assert.True(t, isSyncUnimplemented(err)) + + // The actual fallback: GetMappingUpdate should work. + legacyStream, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{ + ProxyId: "test-proxy", + Address: "test.example.com", + }) + require.NoError(t, err) + + msg, err := legacyStream.Recv() + require.NoError(t, err) + assert.True(t, msg.GetInitialSyncComplete()) + assert.Len(t, msg.GetMapping(), 1) + assert.Equal(t, int32(1), srv.getMappingCalls.Load()) +} + +func TestIsSyncUnimplemented(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {"nil error", nil, false}, + {"non-grpc error", errors.New("random"), false}, + {"grpc internal", grpcstatus.Error(codes.Internal, "fail"), false}, + {"grpc unavailable", grpcstatus.Error(codes.Unavailable, "fail"), false}, + {"grpc unimplemented", grpcstatus.Error(codes.Unimplemented, "method not found"), true}, + { + "wrapped unimplemented", + fmt.Errorf("create sync stream: %w", grpcstatus.Error(codes.Unimplemented, "nope")), + // grpc/status.FromError unwraps in recent versions of grpc-go. + true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isSyncUnimplemented(tt.err)) + }) + } +} + +// addServicesToStore adds n extra services to the test store for the given cluster. +func addServicesToStore(t *testing.T, setup *integrationTestSetup, n int, cluster string) { + t.Helper() + ctx := context.Background() + for i := 0; i < n; i++ { + svc := &service.Service{ + ID: fmt.Sprintf("extra-svc-%d", i), + AccountID: "test-account-1", + Name: fmt.Sprintf("Extra Service %d", i), + Domain: fmt.Sprintf("extra-%d.test.proxy.io", i), + ProxyCluster: cluster, + Enabled: true, + Targets: []*service.Target{{ + Path: strPtr("/"), + Host: fmt.Sprintf("10.0.1.%d", i%256), + Port: 8080, + Protocol: "http", + TargetId: fmt.Sprintf("peer-extra-%d", i), + TargetType: "peer", + Enabled: true, + }}, + } + require.NoError(t, setup.store.CreateService(ctx, svc)) + } +} diff --git a/proxy/web/.gitignore b/proxy/web/.gitignore new file mode 100644 index 000000000..251ce6d2b --- /dev/null +++ b/proxy/web/.gitignore @@ -0,0 +1,23 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/proxy/web/dist/assets/Inter-Italic-VariableFont_opsz_wght.ttf b/proxy/web/dist/assets/Inter-Italic-VariableFont_opsz_wght.ttf new file mode 100644 index 000000000..43ed4f5ee Binary files /dev/null and b/proxy/web/dist/assets/Inter-Italic-VariableFont_opsz_wght.ttf differ diff --git a/proxy/web/dist/assets/Inter-VariableFont_opsz_wght.ttf b/proxy/web/dist/assets/Inter-VariableFont_opsz_wght.ttf new file mode 100644 index 000000000..e31b51e3e Binary files /dev/null and b/proxy/web/dist/assets/Inter-VariableFont_opsz_wght.ttf differ diff --git a/proxy/web/dist/assets/favicon.ico b/proxy/web/dist/assets/favicon.ico new file mode 100644 index 000000000..50bb80966 Binary files /dev/null and b/proxy/web/dist/assets/favicon.ico differ diff --git a/proxy/web/dist/assets/index.js b/proxy/web/dist/assets/index.js new file mode 100644 index 000000000..9ce3e4394 --- /dev/null +++ b/proxy/web/dist/assets/index.js @@ -0,0 +1,9 @@ +(function(){const v=document.createElement("link").relList;if(v&&v.supports&&v.supports("modulepreload"))return;for(const _ of document.querySelectorAll('link[rel="modulepreload"]'))f(_);new MutationObserver(_=>{for(const O of _)if(O.type==="childList")for(const D of O.addedNodes)D.tagName==="LINK"&&D.rel==="modulepreload"&&f(D)}).observe(document,{childList:!0,subtree:!0});function S(_){const O={};return _.integrity&&(O.integrity=_.integrity),_.referrerPolicy&&(O.referrerPolicy=_.referrerPolicy),_.crossOrigin==="use-credentials"?O.credentials="include":_.crossOrigin==="anonymous"?O.credentials="omit":O.credentials="same-origin",O}function f(_){if(_.ep)return;_.ep=!0;const O=S(_);fetch(_.href,O)}})();var Sf={exports:{}},Du={};var Yd;function jm(){if(Yd)return Du;Yd=1;var r=Symbol.for("react.transitional.element"),v=Symbol.for("react.fragment");function S(f,_,O){var D=null;if(O!==void 0&&(D=""+O),_.key!==void 0&&(D=""+_.key),"key"in _){O={};for(var U in _)U!=="key"&&(O[U]=_[U])}else O=_;return _=O.ref,{$$typeof:r,type:f,key:D,ref:_!==void 0?_:null,props:O}}return Du.Fragment=v,Du.jsx=S,Du.jsxs=S,Du}var Gd;function Rm(){return Gd||(Gd=1,Sf.exports=jm()),Sf.exports}var A=Rm(),xf={exports:{}},K={};var Xd;function Hm(){if(Xd)return K;Xd=1;var r=Symbol.for("react.transitional.element"),v=Symbol.for("react.portal"),S=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),_=Symbol.for("react.profiler"),O=Symbol.for("react.consumer"),D=Symbol.for("react.context"),U=Symbol.for("react.forward_ref"),N=Symbol.for("react.suspense"),p=Symbol.for("react.memo"),R=Symbol.for("react.lazy"),H=Symbol.for("react.activity"),V=Symbol.iterator;function st(s){return s===null||typeof s!="object"?null:(s=V&&s[V]||s["@@iterator"],typeof s=="function"?s:null)}var ct={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},G=Object.assign,Q={};function L(s,M,j){this.props=s,this.context=M,this.refs=Q,this.updater=j||ct}L.prototype.isReactComponent={},L.prototype.setState=function(s,M){if(typeof s!="object"&&typeof s!="function"&&s!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,s,M,"setState")},L.prototype.forceUpdate=function(s){this.updater.enqueueForceUpdate(this,s,"forceUpdate")};function gt(){}gt.prototype=L.prototype;function zt(s,M,j){this.props=s,this.context=M,this.refs=Q,this.updater=j||ct}var _t=zt.prototype=new gt;_t.constructor=zt,G(_t,L.prototype),_t.isPureReactComponent=!0;var it=Array.isArray;function Ot(){}var J={H:null,A:null,T:null,S:null},Rt=Object.prototype.hasOwnProperty;function It(s,M,j){var q=j.ref;return{$$typeof:r,type:s,key:M,ref:q!==void 0?q:null,props:j}}function jl(s,M){return It(s.type,M,s.props)}function Pt(s){return typeof s=="object"&&s!==null&&s.$$typeof===r}function I(s){var M={"=":"=0",":":"=2"};return"$"+s.replace(/[=:]/g,function(j){return M[j]})}var Rl=/\/+/g;function tl(s,M){return typeof s=="object"&&s!==null&&s.key!=null?I(""+s.key):M.toString(36)}function ll(s){switch(s.status){case"fulfilled":return s.value;case"rejected":throw s.reason;default:switch(typeof s.status=="string"?s.then(Ot,Ot):(s.status="pending",s.then(function(M){s.status==="pending"&&(s.status="fulfilled",s.value=M)},function(M){s.status==="pending"&&(s.status="rejected",s.reason=M)})),s.status){case"fulfilled":return s.value;case"rejected":throw s.reason}}throw s}function x(s,M,j,q,k){var P=typeof s;(P==="undefined"||P==="boolean")&&(s=null);var yt=!1;if(s===null)yt=!0;else switch(P){case"bigint":case"string":case"number":yt=!0;break;case"object":switch(s.$$typeof){case r:case v:yt=!0;break;case R:return yt=s._init,x(yt(s._payload),M,j,q,k)}}if(yt)return k=k(s),yt=q===""?"."+tl(s,0):q,it(k)?(j="",yt!=null&&(j=yt.replace(Rl,"$&/")+"/"),x(k,M,j,"",function(qa){return qa})):k!=null&&(Pt(k)&&(k=jl(k,j+(k.key==null||s&&s.key===k.key?"":(""+k.key).replace(Rl,"$&/")+"/")+yt)),M.push(k)),1;yt=0;var Wt=q===""?".":q+":";if(it(s))for(var Ut=0;Ut>>1,dt=x[nt];if(0<_(dt,C))x[nt]=C,x[Z]=dt,Z=nt;else break t}}function S(x){return x.length===0?null:x[0]}function f(x){if(x.length===0)return null;var C=x[0],Z=x.pop();if(Z!==C){x[0]=Z;t:for(var nt=0,dt=x.length,s=dt>>>1;nt_(j,Z))q_(k,j)?(x[nt]=k,x[q]=Z,nt=q):(x[nt]=j,x[M]=Z,nt=M);else if(q_(k,Z))x[nt]=k,x[q]=Z,nt=q;else break t}}return C}function _(x,C){var Z=x.sortIndex-C.sortIndex;return Z!==0?Z:x.id-C.id}if(r.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var O=performance;r.unstable_now=function(){return O.now()}}else{var D=Date,U=D.now();r.unstable_now=function(){return D.now()-U}}var N=[],p=[],R=1,H=null,V=3,st=!1,ct=!1,G=!1,Q=!1,L=typeof setTimeout=="function"?setTimeout:null,gt=typeof clearTimeout=="function"?clearTimeout:null,zt=typeof setImmediate<"u"?setImmediate:null;function _t(x){for(var C=S(p);C!==null;){if(C.callback===null)f(p);else if(C.startTime<=x)f(p),C.sortIndex=C.expirationTime,v(N,C);else break;C=S(p)}}function it(x){if(G=!1,_t(x),!ct)if(S(N)!==null)ct=!0,Ot||(Ot=!0,I());else{var C=S(p);C!==null&&ll(it,C.startTime-x)}}var Ot=!1,J=-1,Rt=5,It=-1;function jl(){return Q?!0:!(r.unstable_now()-Itx&&jl());){var nt=H.callback;if(typeof nt=="function"){H.callback=null,V=H.priorityLevel;var dt=nt(H.expirationTime<=x);if(x=r.unstable_now(),typeof dt=="function"){H.callback=dt,_t(x),C=!0;break l}H===S(N)&&f(N),_t(x)}else f(N);H=S(N)}if(H!==null)C=!0;else{var s=S(p);s!==null&&ll(it,s.startTime-x),C=!1}}break t}finally{H=null,V=Z,st=!1}C=void 0}}finally{C?I():Ot=!1}}}var I;if(typeof zt=="function")I=function(){zt(Pt)};else if(typeof MessageChannel<"u"){var Rl=new MessageChannel,tl=Rl.port2;Rl.port1.onmessage=Pt,I=function(){tl.postMessage(null)}}else I=function(){L(Pt,0)};function ll(x,C){J=L(function(){x(r.unstable_now())},C)}r.unstable_IdlePriority=5,r.unstable_ImmediatePriority=1,r.unstable_LowPriority=4,r.unstable_NormalPriority=3,r.unstable_Profiling=null,r.unstable_UserBlockingPriority=2,r.unstable_cancelCallback=function(x){x.callback=null},r.unstable_forceFrameRate=function(x){0>x||125nt?(x.sortIndex=Z,v(p,x),S(N)===null&&x===S(p)&&(G?(gt(J),J=-1):G=!0,ll(it,Z-nt))):(x.sortIndex=dt,v(N,x),ct||st||(ct=!0,Ot||(Ot=!0,I()))),x},r.unstable_shouldYield=jl,r.unstable_wrapCallback=function(x){var C=V;return function(){var Z=V;V=C;try{return x.apply(this,arguments)}finally{V=Z}}}})(Ef)),Ef}var wd;function qm(){return wd||(wd=1,Tf.exports=Bm()),Tf.exports}var Af={exports:{}},kt={};var Ld;function Ym(){if(Ld)return kt;Ld=1;var r=Rf();function v(N){var p="https://react.dev/errors/"+N;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(r)}catch(v){console.error(v)}}return r(),Af.exports=Ym(),Af.exports}var Kd;function Xm(){if(Kd)return Uu;Kd=1;var r=qm(),v=Rf(),S=Gm();function f(t){var l="https://react.dev/errors/"+t;if(1dt||(t.current=nt[dt],nt[dt]=null,dt--)}function j(t,l){dt++,nt[dt]=t.current,t.current=l}var q=s(null),k=s(null),P=s(null),yt=s(null);function Wt(t,l){switch(j(P,l),j(k,t),j(q,null),l.nodeType){case 9:case 11:t=(t=l.documentElement)&&(t=t.namespaceURI)?cd(t):0;break;default:if(t=l.tagName,l=l.namespaceURI)l=cd(l),t=fd(l,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}M(q),j(q,t)}function Ut(){M(q),M(k),M(P)}function qa(t){t.memoizedState!==null&&j(yt,t);var l=q.current,e=fd(l,t.type);l!==e&&(j(k,t),j(q,e))}function Hu(t){k.current===t&&(M(q),M(k)),yt.current===t&&(M(yt),Mu._currentValue=Z)}var li,Bf;function Ue(t){if(li===void 0)try{throw Error()}catch(e){var l=e.stack.trim().match(/\n( *(at )?)/);li=l&&l[1]||"",Bf=-1)":-1u||o[a]!==h[u]){var z=` +`+o[a].replace(" at new "," at ");return t.displayName&&z.includes("")&&(z=z.replace("",t.displayName)),z}while(1<=a&&0<=u);break}}}finally{ei=!1,Error.prepareStackTrace=e}return(e=t?t.displayName||t.name:"")?Ue(e):""}function o0(t,l){switch(t.tag){case 26:case 27:case 5:return Ue(t.type);case 16:return Ue("Lazy");case 13:return t.child!==l&&l!==null?Ue("Suspense Fallback"):Ue("Suspense");case 19:return Ue("SuspenseList");case 0:case 15:return ai(t.type,!1);case 11:return ai(t.type.render,!1);case 1:return ai(t.type,!0);case 31:return Ue("Activity");default:return""}}function qf(t){try{var l="",e=null;do l+=o0(t,e),e=t,t=t.return;while(t);return l}catch(a){return` +Error generating stack: `+a.message+` +`+a.stack}}var ui=Object.prototype.hasOwnProperty,ni=r.unstable_scheduleCallback,ii=r.unstable_cancelCallback,s0=r.unstable_shouldYield,d0=r.unstable_requestPaint,rl=r.unstable_now,y0=r.unstable_getCurrentPriorityLevel,Yf=r.unstable_ImmediatePriority,Gf=r.unstable_UserBlockingPriority,Bu=r.unstable_NormalPriority,m0=r.unstable_LowPriority,Xf=r.unstable_IdlePriority,h0=r.log,g0=r.unstable_setDisableYieldValue,Ya=null,ol=null;function ue(t){if(typeof h0=="function"&&g0(t),ol&&typeof ol.setStrictMode=="function")try{ol.setStrictMode(Ya,t)}catch{}}var sl=Math.clz32?Math.clz32:p0,v0=Math.log,b0=Math.LN2;function p0(t){return t>>>=0,t===0?32:31-(v0(t)/b0|0)|0}var qu=256,Yu=262144,Gu=4194304;function Ce(t){var l=t&42;if(l!==0)return l;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&261888;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Xu(t,l,e){var a=t.pendingLanes;if(a===0)return 0;var u=0,n=t.suspendedLanes,i=t.pingedLanes;t=t.warmLanes;var c=a&134217727;return c!==0?(a=c&~n,a!==0?u=Ce(a):(i&=c,i!==0?u=Ce(i):e||(e=c&~t,e!==0&&(u=Ce(e))))):(c=a&~n,c!==0?u=Ce(c):i!==0?u=Ce(i):e||(e=a&~t,e!==0&&(u=Ce(e)))),u===0?0:l!==0&&l!==u&&(l&n)===0&&(n=u&-u,e=l&-l,n>=e||n===32&&(e&4194048)!==0)?l:u}function Ga(t,l){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&l)===0}function S0(t,l){switch(t){case 1:case 2:case 4:case 8:case 64:return l+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return l+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Qf(){var t=Gu;return Gu<<=1,(Gu&62914560)===0&&(Gu=4194304),t}function ci(t){for(var l=[],e=0;31>e;e++)l.push(t);return l}function Xa(t,l){t.pendingLanes|=l,l!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function x0(t,l,e,a,u,n){var i=t.pendingLanes;t.pendingLanes=e,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=e,t.entangledLanes&=e,t.errorRecoveryDisabledLanes&=e,t.shellSuspendCounter=0;var c=t.entanglements,o=t.expirationTimes,h=t.hiddenUpdates;for(e=i&~e;0"u")return null;try{return t.activeElement||t.body}catch{return t.body}}var _0=/[\n"\\]/g;function Sl(t){return t.replace(_0,function(l){return"\\"+l.charCodeAt(0).toString(16)+" "})}function yi(t,l,e,a,u,n,i,c){t.name="",i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"?t.type=i:t.removeAttribute("type"),l!=null?i==="number"?(l===0&&t.value===""||t.value!=l)&&(t.value=""+pl(l)):t.value!==""+pl(l)&&(t.value=""+pl(l)):i!=="submit"&&i!=="reset"||t.removeAttribute("value"),l!=null?mi(t,i,pl(l)):e!=null?mi(t,i,pl(e)):a!=null&&t.removeAttribute("value"),u==null&&n!=null&&(t.defaultChecked=!!n),u!=null&&(t.checked=u&&typeof u!="function"&&typeof u!="symbol"),c!=null&&typeof c!="function"&&typeof c!="symbol"&&typeof c!="boolean"?t.name=""+pl(c):t.removeAttribute("name")}function tr(t,l,e,a,u,n,i,c){if(n!=null&&typeof n!="function"&&typeof n!="symbol"&&typeof n!="boolean"&&(t.type=n),l!=null||e!=null){if(!(n!=="submit"&&n!=="reset"||l!=null)){di(t);return}e=e!=null?""+pl(e):"",l=l!=null?""+pl(l):e,c||l===t.value||(t.value=l),t.defaultValue=l}a=a??u,a=typeof a!="function"&&typeof a!="symbol"&&!!a,t.checked=c?t.checked:!!a,t.defaultChecked=!!a,i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"&&(t.name=i),di(t)}function mi(t,l,e){l==="number"&&wu(t.ownerDocument)===t||t.defaultValue===""+e||(t.defaultValue=""+e)}function ea(t,l,e,a){if(t=t.options,l){l={};for(var u=0;u"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),pi=!1;if(Ql)try{var La={};Object.defineProperty(La,"passive",{get:function(){pi=!0}}),window.addEventListener("test",La,La),window.removeEventListener("test",La,La)}catch{pi=!1}var ie=null,Si=null,Vu=null;function cr(){if(Vu)return Vu;var t,l=Si,e=l.length,a,u="value"in ie?ie.value:ie.textContent,n=u.length;for(t=0;t=Ja),yr=" ",mr=!1;function hr(t,l){switch(t){case"keyup":return ly.indexOf(l.keyCode)!==-1;case"keydown":return l.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function gr(t){return t=t.detail,typeof t=="object"&&"data"in t?t.data:null}var ia=!1;function ay(t,l){switch(t){case"compositionend":return gr(l);case"keypress":return l.which!==32?null:(mr=!0,yr);case"textInput":return t=l.data,t===yr&&mr?null:t;default:return null}}function uy(t,l){if(ia)return t==="compositionend"||!Ai&&hr(t,l)?(t=cr(),Vu=Si=ie=null,ia=!1,t):null;switch(t){case"paste":return null;case"keypress":if(!(l.ctrlKey||l.altKey||l.metaKey)||l.ctrlKey&&l.altKey){if(l.char&&1=l)return{node:e,offset:l-t};t=a}t:{for(;e;){if(e.nextSibling){e=e.nextSibling;break t}e=e.parentNode}e=void 0}e=Er(e)}}function Mr(t,l){return t&&l?t===l?!0:t&&t.nodeType===3?!1:l&&l.nodeType===3?Mr(t,l.parentNode):"contains"in t?t.contains(l):t.compareDocumentPosition?!!(t.compareDocumentPosition(l)&16):!1:!1}function _r(t){t=t!=null&&t.ownerDocument!=null&&t.ownerDocument.defaultView!=null?t.ownerDocument.defaultView:window;for(var l=wu(t.document);l instanceof t.HTMLIFrameElement;){try{var e=typeof l.contentWindow.location.href=="string"}catch{e=!1}if(e)t=l.contentWindow;else break;l=wu(t.document)}return l}function Oi(t){var l=t&&t.nodeName&&t.nodeName.toLowerCase();return l&&(l==="input"&&(t.type==="text"||t.type==="search"||t.type==="tel"||t.type==="url"||t.type==="password")||l==="textarea"||t.contentEditable==="true")}var dy=Ql&&"documentMode"in document&&11>=document.documentMode,ca=null,Ni=null,Fa=null,Di=!1;function Or(t,l,e){var a=e.window===e?e.document:e.nodeType===9?e:e.ownerDocument;Di||ca==null||ca!==wu(a)||(a=ca,"selectionStart"in a&&Oi(a)?a={start:a.selectionStart,end:a.selectionEnd}:(a=(a.ownerDocument&&a.ownerDocument.defaultView||window).getSelection(),a={anchorNode:a.anchorNode,anchorOffset:a.anchorOffset,focusNode:a.focusNode,focusOffset:a.focusOffset}),Fa&&$a(Fa,a)||(Fa=a,a=Gn(Ni,"onSelect"),0>=i,u-=i,Hl=1<<32-sl(l)+u|e<$?(at=Y,Y=null):at=Y.sibling;var rt=g(y,Y,m[$],T);if(rt===null){Y===null&&(Y=at);break}t&&Y&&rt.alternate===null&&l(y,Y),d=n(rt,d,$),ft===null?X=rt:ft.sibling=rt,ft=rt,Y=at}if($===m.length)return e(y,Y),ut&&wl(y,$),X;if(Y===null){for(;$$?(at=Y,Y=null):at=Y.sibling;var Oe=g(y,Y,rt.value,T);if(Oe===null){Y===null&&(Y=at);break}t&&Y&&Oe.alternate===null&&l(y,Y),d=n(Oe,d,$),ft===null?X=Oe:ft.sibling=Oe,ft=Oe,Y=at}if(rt.done)return e(y,Y),ut&&wl(y,$),X;if(Y===null){for(;!rt.done;$++,rt=m.next())rt=E(y,rt.value,T),rt!==null&&(d=n(rt,d,$),ft===null?X=rt:ft.sibling=rt,ft=rt);return ut&&wl(y,$),X}for(Y=a(Y);!rt.done;$++,rt=m.next())rt=b(Y,y,$,rt.value,T),rt!==null&&(t&&rt.alternate!==null&&Y.delete(rt.key===null?$:rt.key),d=n(rt,d,$),ft===null?X=rt:ft.sibling=rt,ft=rt);return t&&Y.forEach(function(Cm){return l(y,Cm)}),ut&&wl(y,$),X}function pt(y,d,m,T){if(typeof m=="object"&&m!==null&&m.type===G&&m.key===null&&(m=m.props.children),typeof m=="object"&&m!==null){switch(m.$$typeof){case st:t:{for(var X=m.key;d!==null;){if(d.key===X){if(X=m.type,X===G){if(d.tag===7){e(y,d.sibling),T=u(d,m.props.children),T.return=y,y=T;break t}}else if(d.elementType===X||typeof X=="object"&&X!==null&&X.$$typeof===Rt&&we(X)===d.type){e(y,d.sibling),T=u(d,m.props),au(T,m),T.return=y,y=T;break t}e(y,d);break}else l(y,d);d=d.sibling}m.type===G?(T=Ye(m.props.children,y.mode,T,m.key),T.return=y,y=T):(T=ln(m.type,m.key,m.props,null,y.mode,T),au(T,m),T.return=y,y=T)}return i(y);case ct:t:{for(X=m.key;d!==null;){if(d.key===X)if(d.tag===4&&d.stateNode.containerInfo===m.containerInfo&&d.stateNode.implementation===m.implementation){e(y,d.sibling),T=u(d,m.children||[]),T.return=y,y=T;break t}else{e(y,d);break}else l(y,d);d=d.sibling}T=qi(m,y.mode,T),T.return=y,y=T}return i(y);case Rt:return m=we(m),pt(y,d,m,T)}if(ll(m))return B(y,d,m,T);if(I(m)){if(X=I(m),typeof X!="function")throw Error(f(150));return m=X.call(m),w(y,d,m,T)}if(typeof m.then=="function")return pt(y,d,rn(m),T);if(m.$$typeof===zt)return pt(y,d,un(y,m),T);on(y,m)}return typeof m=="string"&&m!==""||typeof m=="number"||typeof m=="bigint"?(m=""+m,d!==null&&d.tag===6?(e(y,d.sibling),T=u(d,m),T.return=y,y=T):(e(y,d),T=Bi(m,y.mode,T),T.return=y,y=T),i(y)):e(y,d)}return function(y,d,m,T){try{eu=0;var X=pt(y,d,m,T);return ba=null,X}catch(Y){if(Y===va||Y===cn)throw Y;var ft=yl(29,Y,null,y.mode);return ft.lanes=T,ft.return=y,ft}}}var Ve=Fr(!0),Ir=Fr(!1),se=!1;function Wi(t){t.updateQueue={baseState:t.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function $i(t,l){t=t.updateQueue,l.updateQueue===t&&(l.updateQueue={baseState:t.baseState,firstBaseUpdate:t.firstBaseUpdate,lastBaseUpdate:t.lastBaseUpdate,shared:t.shared,callbacks:null})}function de(t){return{lane:t,tag:0,payload:null,callback:null,next:null}}function ye(t,l,e){var a=t.updateQueue;if(a===null)return null;if(a=a.shared,(ot&2)!==0){var u=a.pending;return u===null?l.next=l:(l.next=u.next,u.next=l),a.pending=l,l=tn(t),Hr(t,null,e),l}return Pu(t,a,l,e),tn(t)}function uu(t,l,e){if(l=l.updateQueue,l!==null&&(l=l.shared,(e&4194048)!==0)){var a=l.lanes;a&=t.pendingLanes,e|=a,l.lanes=e,wf(t,e)}}function Fi(t,l){var e=t.updateQueue,a=t.alternate;if(a!==null&&(a=a.updateQueue,e===a)){var u=null,n=null;if(e=e.firstBaseUpdate,e!==null){do{var i={lane:e.lane,tag:e.tag,payload:e.payload,callback:null,next:null};n===null?u=n=i:n=n.next=i,e=e.next}while(e!==null);n===null?u=n=l:n=n.next=l}else u=n=l;e={baseState:a.baseState,firstBaseUpdate:u,lastBaseUpdate:n,shared:a.shared,callbacks:a.callbacks},t.updateQueue=e;return}t=e.lastBaseUpdate,t===null?e.firstBaseUpdate=l:t.next=l,e.lastBaseUpdate=l}var Ii=!1;function nu(){if(Ii){var t=ga;if(t!==null)throw t}}function iu(t,l,e,a){Ii=!1;var u=t.updateQueue;se=!1;var n=u.firstBaseUpdate,i=u.lastBaseUpdate,c=u.shared.pending;if(c!==null){u.shared.pending=null;var o=c,h=o.next;o.next=null,i===null?n=h:i.next=h,i=o;var z=t.alternate;z!==null&&(z=z.updateQueue,c=z.lastBaseUpdate,c!==i&&(c===null?z.firstBaseUpdate=h:c.next=h,z.lastBaseUpdate=o))}if(n!==null){var E=u.baseState;i=0,z=h=o=null,c=n;do{var g=c.lane&-536870913,b=g!==c.lane;if(b?(et&g)===g:(a&g)===g){g!==0&&g===ha&&(Ii=!0),z!==null&&(z=z.next={lane:0,tag:c.tag,payload:c.payload,callback:null,next:null});t:{var B=t,w=c;g=l;var pt=e;switch(w.tag){case 1:if(B=w.payload,typeof B=="function"){E=B.call(pt,E,g);break t}E=B;break t;case 3:B.flags=B.flags&-65537|128;case 0:if(B=w.payload,g=typeof B=="function"?B.call(pt,E,g):B,g==null)break t;E=H({},E,g);break t;case 2:se=!0}}g=c.callback,g!==null&&(t.flags|=64,b&&(t.flags|=8192),b=u.callbacks,b===null?u.callbacks=[g]:b.push(g))}else b={lane:g,tag:c.tag,payload:c.payload,callback:c.callback,next:null},z===null?(h=z=b,o=E):z=z.next=b,i|=g;if(c=c.next,c===null){if(c=u.shared.pending,c===null)break;b=c,c=b.next,b.next=null,u.lastBaseUpdate=b,u.shared.pending=null}}while(!0);z===null&&(o=E),u.baseState=o,u.firstBaseUpdate=h,u.lastBaseUpdate=z,n===null&&(u.shared.lanes=0),be|=i,t.lanes=i,t.memoizedState=E}}function Pr(t,l){if(typeof t!="function")throw Error(f(191,t));t.call(l)}function to(t,l){var e=t.callbacks;if(e!==null)for(t.callbacks=null,t=0;tn?n:8;var i=x.T,c={};x.T=c,vc(t,!1,l,e);try{var o=u(),h=x.S;if(h!==null&&h(c,o),o!==null&&typeof o=="object"&&typeof o.then=="function"){var z=xy(o,a);ru(t,l,z,bl(t))}else ru(t,l,a,bl(t))}catch(E){ru(t,l,{then:function(){},status:"rejected",reason:E},bl())}finally{C.p=n,i!==null&&c.types!==null&&(i.types=c.types),x.T=i}}function _y(){}function hc(t,l,e,a){if(t.tag!==5)throw Error(f(476));var u=jo(t).queue;Co(t,u,l,Z,e===null?_y:function(){return Ro(t),e(a)})}function jo(t){var l=t.memoizedState;if(l!==null)return l;l={memoizedState:Z,baseState:Z,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Jl,lastRenderedState:Z},next:null};var e={};return l.next={memoizedState:e,baseState:e,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Jl,lastRenderedState:e},next:null},t.memoizedState=l,t=t.alternate,t!==null&&(t.memoizedState=l),l}function Ro(t){var l=jo(t);l.next===null&&(l=t.alternate.memoizedState),ru(t,l.next.queue,{},bl())}function gc(){return Vt(Mu)}function Ho(){return jt().memoizedState}function Bo(){return jt().memoizedState}function Oy(t){for(var l=t.return;l!==null;){switch(l.tag){case 24:case 3:var e=bl();t=de(e);var a=ye(l,t,e);a!==null&&(fl(a,l,e),uu(a,l,e)),l={cache:Vi()},t.payload=l;return}l=l.return}}function Ny(t,l,e){var a=bl();e={lane:a,revertLane:0,gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null},Sn(t)?Yo(l,e):(e=Ri(t,l,e,a),e!==null&&(fl(e,t,a),Go(e,l,a)))}function qo(t,l,e){var a=bl();ru(t,l,e,a)}function ru(t,l,e,a){var u={lane:a,revertLane:0,gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null};if(Sn(t))Yo(l,u);else{var n=t.alternate;if(t.lanes===0&&(n===null||n.lanes===0)&&(n=l.lastRenderedReducer,n!==null))try{var i=l.lastRenderedState,c=n(i,e);if(u.hasEagerState=!0,u.eagerState=c,dl(c,i))return Pu(t,l,u,0),St===null&&Iu(),!1}catch{}if(e=Ri(t,l,u,a),e!==null)return fl(e,t,a),Go(e,l,a),!0}return!1}function vc(t,l,e,a){if(a={lane:2,revertLane:Wc(),gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null},Sn(t)){if(l)throw Error(f(479))}else l=Ri(t,e,a,2),l!==null&&fl(l,t,2)}function Sn(t){var l=t.alternate;return t===W||l!==null&&l===W}function Yo(t,l){Sa=yn=!0;var e=t.pending;e===null?l.next=l:(l.next=e.next,e.next=l),t.pending=l}function Go(t,l,e){if((e&4194048)!==0){var a=l.lanes;a&=t.pendingLanes,e|=a,l.lanes=e,wf(t,e)}}var ou={readContext:Vt,use:gn,useCallback:Nt,useContext:Nt,useEffect:Nt,useImperativeHandle:Nt,useLayoutEffect:Nt,useInsertionEffect:Nt,useMemo:Nt,useReducer:Nt,useRef:Nt,useState:Nt,useDebugValue:Nt,useDeferredValue:Nt,useTransition:Nt,useSyncExternalStore:Nt,useId:Nt,useHostTransitionStatus:Nt,useFormState:Nt,useActionState:Nt,useOptimistic:Nt,useMemoCache:Nt,useCacheRefresh:Nt};ou.useEffectEvent=Nt;var Xo={readContext:Vt,use:gn,useCallback:function(t,l){return $t().memoizedState=[t,l===void 0?null:l],t},useContext:Vt,useEffect:To,useImperativeHandle:function(t,l,e){e=e!=null?e.concat([t]):null,bn(4194308,4,_o.bind(null,l,t),e)},useLayoutEffect:function(t,l){return bn(4194308,4,t,l)},useInsertionEffect:function(t,l){bn(4,2,t,l)},useMemo:function(t,l){var e=$t();l=l===void 0?null:l;var a=t();if(Ke){ue(!0);try{t()}finally{ue(!1)}}return e.memoizedState=[a,l],a},useReducer:function(t,l,e){var a=$t();if(e!==void 0){var u=e(l);if(Ke){ue(!0);try{e(l)}finally{ue(!1)}}}else u=l;return a.memoizedState=a.baseState=u,t={pending:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:u},a.queue=t,t=t.dispatch=Ny.bind(null,W,t),[a.memoizedState,t]},useRef:function(t){var l=$t();return t={current:t},l.memoizedState=t},useState:function(t){t=oc(t);var l=t.queue,e=qo.bind(null,W,l);return l.dispatch=e,[t.memoizedState,e]},useDebugValue:yc,useDeferredValue:function(t,l){var e=$t();return mc(e,t,l)},useTransition:function(){var t=oc(!1);return t=Co.bind(null,W,t.queue,!0,!1),$t().memoizedState=t,[!1,t]},useSyncExternalStore:function(t,l,e){var a=W,u=$t();if(ut){if(e===void 0)throw Error(f(407));e=e()}else{if(e=l(),St===null)throw Error(f(349));(et&127)!==0||io(a,l,e)}u.memoizedState=e;var n={value:e,getSnapshot:l};return u.queue=n,To(fo.bind(null,a,n,t),[t]),a.flags|=2048,za(9,{destroy:void 0},co.bind(null,a,n,e,l),null),e},useId:function(){var t=$t(),l=St.identifierPrefix;if(ut){var e=Bl,a=Hl;e=(a&~(1<<32-sl(a)-1)).toString(32)+e,l="_"+l+"R_"+e,e=mn++,0<\/script>",n=n.removeChild(n.firstChild);break;case"select":n=typeof a.is=="string"?i.createElement("select",{is:a.is}):i.createElement("select"),a.multiple?n.multiple=!0:a.size&&(n.size=a.size);break;default:n=typeof a.is=="string"?i.createElement(u,{is:a.is}):i.createElement(u)}}n[wt]=l,n[el]=a;t:for(i=l.child;i!==null;){if(i.tag===5||i.tag===6)n.appendChild(i.stateNode);else if(i.tag!==4&&i.tag!==27&&i.child!==null){i.child.return=i,i=i.child;continue}if(i===l)break t;for(;i.sibling===null;){if(i.return===null||i.return===l)break t;i=i.return}i.sibling.return=i.return,i=i.sibling}l.stateNode=n;t:switch(Jt(n,u,a),u){case"button":case"input":case"select":case"textarea":a=!!a.autoFocus;break t;case"img":a=!0;break t;default:a=!1}a&&Wl(l)}}return Et(l),Uc(l,l.type,t===null?null:t.memoizedProps,l.pendingProps,e),null;case 6:if(t&&l.stateNode!=null)t.memoizedProps!==a&&Wl(l);else{if(typeof a!="string"&&l.stateNode===null)throw Error(f(166));if(t=P.current,ya(l)){if(t=l.stateNode,e=l.memoizedProps,a=null,u=Lt,u!==null)switch(u.tag){case 27:case 5:a=u.memoizedProps}t[wt]=l,t=!!(t.nodeValue===e||a!==null&&a.suppressHydrationWarning===!0||nd(t.nodeValue,e)),t||re(l,!0)}else t=Xn(t).createTextNode(a),t[wt]=l,l.stateNode=t}return Et(l),null;case 31:if(e=l.memoizedState,t===null||t.memoizedState!==null){if(a=ya(l),e!==null){if(t===null){if(!a)throw Error(f(318));if(t=l.memoizedState,t=t!==null?t.dehydrated:null,!t)throw Error(f(557));t[wt]=l}else Ge(),(l.flags&128)===0&&(l.memoizedState=null),l.flags|=4;Et(l),t=!1}else e=Qi(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=e),t=!0;if(!t)return l.flags&256?(hl(l),l):(hl(l),null);if((l.flags&128)!==0)throw Error(f(558))}return Et(l),null;case 13:if(a=l.memoizedState,t===null||t.memoizedState!==null&&t.memoizedState.dehydrated!==null){if(u=ya(l),a!==null&&a.dehydrated!==null){if(t===null){if(!u)throw Error(f(318));if(u=l.memoizedState,u=u!==null?u.dehydrated:null,!u)throw Error(f(317));u[wt]=l}else Ge(),(l.flags&128)===0&&(l.memoizedState=null),l.flags|=4;Et(l),u=!1}else u=Qi(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=u),u=!0;if(!u)return l.flags&256?(hl(l),l):(hl(l),null)}return hl(l),(l.flags&128)!==0?(l.lanes=e,l):(e=a!==null,t=t!==null&&t.memoizedState!==null,e&&(a=l.child,u=null,a.alternate!==null&&a.alternate.memoizedState!==null&&a.alternate.memoizedState.cachePool!==null&&(u=a.alternate.memoizedState.cachePool.pool),n=null,a.memoizedState!==null&&a.memoizedState.cachePool!==null&&(n=a.memoizedState.cachePool.pool),n!==u&&(a.flags|=2048)),e!==t&&e&&(l.child.flags|=8192),An(l,l.updateQueue),Et(l),null);case 4:return Ut(),t===null&&Pc(l.stateNode.containerInfo),Et(l),null;case 10:return Vl(l.type),Et(l),null;case 19:if(M(Ct),a=l.memoizedState,a===null)return Et(l),null;if(u=(l.flags&128)!==0,n=a.rendering,n===null)if(u)du(a,!1);else{if(Dt!==0||t!==null&&(t.flags&128)!==0)for(t=l.child;t!==null;){if(n=dn(t),n!==null){for(l.flags|=128,du(a,!1),t=n.updateQueue,l.updateQueue=t,An(l,t),l.subtreeFlags=0,t=e,e=l.child;e!==null;)Br(e,t),e=e.sibling;return j(Ct,Ct.current&1|2),ut&&wl(l,a.treeForkCount),l.child}t=t.sibling}a.tail!==null&&rl()>Dn&&(l.flags|=128,u=!0,du(a,!1),l.lanes=4194304)}else{if(!u)if(t=dn(n),t!==null){if(l.flags|=128,u=!0,t=t.updateQueue,l.updateQueue=t,An(l,t),du(a,!0),a.tail===null&&a.tailMode==="hidden"&&!n.alternate&&!ut)return Et(l),null}else 2*rl()-a.renderingStartTime>Dn&&e!==536870912&&(l.flags|=128,u=!0,du(a,!1),l.lanes=4194304);a.isBackwards?(n.sibling=l.child,l.child=n):(t=a.last,t!==null?t.sibling=n:l.child=n,a.last=n)}return a.tail!==null?(t=a.tail,a.rendering=t,a.tail=t.sibling,a.renderingStartTime=rl(),t.sibling=null,e=Ct.current,j(Ct,u?e&1|2:e&1),ut&&wl(l,a.treeForkCount),t):(Et(l),null);case 22:case 23:return hl(l),tc(),a=l.memoizedState!==null,t!==null?t.memoizedState!==null!==a&&(l.flags|=8192):a&&(l.flags|=8192),a?(e&536870912)!==0&&(l.flags&128)===0&&(Et(l),l.subtreeFlags&6&&(l.flags|=8192)):Et(l),e=l.updateQueue,e!==null&&An(l,e.retryQueue),e=null,t!==null&&t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(e=t.memoizedState.cachePool.pool),a=null,l.memoizedState!==null&&l.memoizedState.cachePool!==null&&(a=l.memoizedState.cachePool.pool),a!==e&&(l.flags|=2048),t!==null&&M(Ze),null;case 24:return e=null,t!==null&&(e=t.memoizedState.cache),l.memoizedState.cache!==e&&(l.flags|=2048),Vl(Ht),Et(l),null;case 25:return null;case 30:return null}throw Error(f(156,l.tag))}function Ry(t,l){switch(Gi(l),l.tag){case 1:return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 3:return Vl(Ht),Ut(),t=l.flags,(t&65536)!==0&&(t&128)===0?(l.flags=t&-65537|128,l):null;case 26:case 27:case 5:return Hu(l),null;case 31:if(l.memoizedState!==null){if(hl(l),l.alternate===null)throw Error(f(340));Ge()}return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 13:if(hl(l),t=l.memoizedState,t!==null&&t.dehydrated!==null){if(l.alternate===null)throw Error(f(340));Ge()}return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 19:return M(Ct),null;case 4:return Ut(),null;case 10:return Vl(l.type),null;case 22:case 23:return hl(l),tc(),t!==null&&M(Ze),t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 24:return Vl(Ht),null;case 25:return null;default:return null}}function os(t,l){switch(Gi(l),l.tag){case 3:Vl(Ht),Ut();break;case 26:case 27:case 5:Hu(l);break;case 4:Ut();break;case 31:l.memoizedState!==null&&hl(l);break;case 13:hl(l);break;case 19:M(Ct);break;case 10:Vl(l.type);break;case 22:case 23:hl(l),tc(),t!==null&&M(Ze);break;case 24:Vl(Ht)}}function yu(t,l){try{var e=l.updateQueue,a=e!==null?e.lastEffect:null;if(a!==null){var u=a.next;e=u;do{if((e.tag&t)===t){a=void 0;var n=e.create,i=e.inst;a=n(),i.destroy=a}e=e.next}while(e!==u)}}catch(c){ht(l,l.return,c)}}function ge(t,l,e){try{var a=l.updateQueue,u=a!==null?a.lastEffect:null;if(u!==null){var n=u.next;a=n;do{if((a.tag&t)===t){var i=a.inst,c=i.destroy;if(c!==void 0){i.destroy=void 0,u=l;var o=e,h=c;try{h()}catch(z){ht(u,o,z)}}}a=a.next}while(a!==n)}}catch(z){ht(l,l.return,z)}}function ss(t){var l=t.updateQueue;if(l!==null){var e=t.stateNode;try{to(l,e)}catch(a){ht(t,t.return,a)}}}function ds(t,l,e){e.props=Je(t.type,t.memoizedProps),e.state=t.memoizedState;try{e.componentWillUnmount()}catch(a){ht(t,l,a)}}function mu(t,l){try{var e=t.ref;if(e!==null){switch(t.tag){case 26:case 27:case 5:var a=t.stateNode;break;case 30:a=t.stateNode;break;default:a=t.stateNode}typeof e=="function"?t.refCleanup=e(a):e.current=a}}catch(u){ht(t,l,u)}}function ql(t,l){var e=t.ref,a=t.refCleanup;if(e!==null)if(typeof a=="function")try{a()}catch(u){ht(t,l,u)}finally{t.refCleanup=null,t=t.alternate,t!=null&&(t.refCleanup=null)}else if(typeof e=="function")try{e(null)}catch(u){ht(t,l,u)}else e.current=null}function ys(t){var l=t.type,e=t.memoizedProps,a=t.stateNode;try{t:switch(l){case"button":case"input":case"select":case"textarea":e.autoFocus&&a.focus();break t;case"img":e.src?a.src=e.src:e.srcSet&&(a.srcset=e.srcSet)}}catch(u){ht(t,t.return,u)}}function Cc(t,l,e){try{var a=t.stateNode;em(a,t.type,e,l),a[el]=l}catch(u){ht(t,t.return,u)}}function ms(t){return t.tag===5||t.tag===3||t.tag===26||t.tag===27&&Te(t.type)||t.tag===4}function jc(t){t:for(;;){for(;t.sibling===null;){if(t.return===null||ms(t.return))return null;t=t.return}for(t.sibling.return=t.return,t=t.sibling;t.tag!==5&&t.tag!==6&&t.tag!==18;){if(t.tag===27&&Te(t.type)||t.flags&2||t.child===null||t.tag===4)continue t;t.child.return=t,t=t.child}if(!(t.flags&2))return t.stateNode}}function Rc(t,l,e){var a=t.tag;if(a===5||a===6)t=t.stateNode,l?(e.nodeType===9?e.body:e.nodeName==="HTML"?e.ownerDocument.body:e).insertBefore(t,l):(l=e.nodeType===9?e.body:e.nodeName==="HTML"?e.ownerDocument.body:e,l.appendChild(t),e=e._reactRootContainer,e!=null||l.onclick!==null||(l.onclick=Xl));else if(a!==4&&(a===27&&Te(t.type)&&(e=t.stateNode,l=null),t=t.child,t!==null))for(Rc(t,l,e),t=t.sibling;t!==null;)Rc(t,l,e),t=t.sibling}function Mn(t,l,e){var a=t.tag;if(a===5||a===6)t=t.stateNode,l?e.insertBefore(t,l):e.appendChild(t);else if(a!==4&&(a===27&&Te(t.type)&&(e=t.stateNode),t=t.child,t!==null))for(Mn(t,l,e),t=t.sibling;t!==null;)Mn(t,l,e),t=t.sibling}function hs(t){var l=t.stateNode,e=t.memoizedProps;try{for(var a=t.type,u=l.attributes;u.length;)l.removeAttributeNode(u[0]);Jt(l,a,e),l[wt]=t,l[el]=e}catch(n){ht(t,t.return,n)}}var $l=!1,Yt=!1,Hc=!1,gs=typeof WeakSet=="function"?WeakSet:Set,Qt=null;function Hy(t,l){if(t=t.containerInfo,ef=Jn,t=_r(t),Oi(t)){if("selectionStart"in t)var e={start:t.selectionStart,end:t.selectionEnd};else t:{e=(e=t.ownerDocument)&&e.defaultView||window;var a=e.getSelection&&e.getSelection();if(a&&a.rangeCount!==0){e=a.anchorNode;var u=a.anchorOffset,n=a.focusNode;a=a.focusOffset;try{e.nodeType,n.nodeType}catch{e=null;break t}var i=0,c=-1,o=-1,h=0,z=0,E=t,g=null;l:for(;;){for(var b;E!==e||u!==0&&E.nodeType!==3||(c=i+u),E!==n||a!==0&&E.nodeType!==3||(o=i+a),E.nodeType===3&&(i+=E.nodeValue.length),(b=E.firstChild)!==null;)g=E,E=b;for(;;){if(E===t)break l;if(g===e&&++h===u&&(c=i),g===n&&++z===a&&(o=i),(b=E.nextSibling)!==null)break;E=g,g=E.parentNode}E=b}e=c===-1||o===-1?null:{start:c,end:o}}else e=null}e=e||{start:0,end:0}}else e=null;for(af={focusedElem:t,selectionRange:e},Jn=!1,Qt=l;Qt!==null;)if(l=Qt,t=l.child,(l.subtreeFlags&1028)!==0&&t!==null)t.return=l,Qt=t;else for(;Qt!==null;){switch(l=Qt,n=l.alternate,t=l.flags,l.tag){case 0:if((t&4)!==0&&(t=l.updateQueue,t=t!==null?t.events:null,t!==null))for(e=0;e title"))),Jt(n,a,e),n[wt]=t,Xt(n),a=n;break t;case"link":var i=zd("link","href",u).get(a+(e.href||""));if(i){for(var c=0;cpt&&(i=pt,pt=w,w=i);var y=Ar(c,w),d=Ar(c,pt);if(y&&d&&(b.rangeCount!==1||b.anchorNode!==y.node||b.anchorOffset!==y.offset||b.focusNode!==d.node||b.focusOffset!==d.offset)){var m=E.createRange();m.setStart(y.node,y.offset),b.removeAllRanges(),w>pt?(b.addRange(m),b.extend(d.node,d.offset)):(m.setEnd(d.node,d.offset),b.addRange(m))}}}}for(E=[],b=c;b=b.parentNode;)b.nodeType===1&&E.push({element:b,left:b.scrollLeft,top:b.scrollTop});for(typeof c.focus=="function"&&c.focus(),c=0;ce?32:e,x.T=null,e=Zc,Zc=null;var n=Se,i=le;if(Gt=0,_a=Se=null,le=0,(ot&6)!==0)throw Error(f(331));var c=ot;if(ot|=4,_s(n.current),Es(n,n.current,i,e),ot=c,Su(0,!1),ol&&typeof ol.onPostCommitFiberRoot=="function")try{ol.onPostCommitFiberRoot(Ya,n)}catch{}return!0}finally{C.p=u,x.T=a,Vs(t,l)}}function Js(t,l,e){l=zl(e,l),l=xc(t.stateNode,l,2),t=ye(t,l,2),t!==null&&(Xa(t,2),Yl(t))}function ht(t,l,e){if(t.tag===3)Js(t,t,e);else for(;l!==null;){if(l.tag===3){Js(l,t,e);break}else if(l.tag===1){var a=l.stateNode;if(typeof l.type.getDerivedStateFromError=="function"||typeof a.componentDidCatch=="function"&&(pe===null||!pe.has(a))){t=zl(e,t),e=ko(2),a=ye(l,e,2),a!==null&&(Wo(e,a,l,t),Xa(a,2),Yl(a));break}}l=l.return}}function Kc(t,l,e){var a=t.pingCache;if(a===null){a=t.pingCache=new Yy;var u=new Set;a.set(l,u)}else u=a.get(l),u===void 0&&(u=new Set,a.set(l,u));u.has(e)||(Yc=!0,u.add(e),t=wy.bind(null,t,l,e),l.then(t,t))}function wy(t,l,e){var a=t.pingCache;a!==null&&a.delete(l),t.pingedLanes|=t.suspendedLanes&e,t.warmLanes&=~e,St===t&&(et&e)===e&&(Dt===4||Dt===3&&(et&62914560)===et&&300>rl()-Nn?(ot&2)===0&&Oa(t,0):Gc|=e,Ma===et&&(Ma=0)),Yl(t)}function ks(t,l){l===0&&(l=Qf()),t=qe(t,l),t!==null&&(Xa(t,l),Yl(t))}function Ly(t){var l=t.memoizedState,e=0;l!==null&&(e=l.retryLane),ks(t,e)}function Vy(t,l){var e=0;switch(t.tag){case 31:case 13:var a=t.stateNode,u=t.memoizedState;u!==null&&(e=u.retryLane);break;case 19:a=t.stateNode;break;case 22:a=t.stateNode._retryCache;break;default:throw Error(f(314))}a!==null&&a.delete(l),ks(t,e)}function Ky(t,l){return ni(t,l)}var Bn=null,Da=null,Jc=!1,qn=!1,kc=!1,ze=0;function Yl(t){t!==Da&&t.next===null&&(Da===null?Bn=Da=t:Da=Da.next=t),qn=!0,Jc||(Jc=!0,ky())}function Su(t,l){if(!kc&&qn){kc=!0;do for(var e=!1,a=Bn;a!==null;){if(t!==0){var u=a.pendingLanes;if(u===0)var n=0;else{var i=a.suspendedLanes,c=a.pingedLanes;n=(1<<31-sl(42|t)+1)-1,n&=u&~(i&~c),n=n&201326741?n&201326741|1:n?n|2:0}n!==0&&(e=!0,Is(a,n))}else n=et,n=Xu(a,a===St?n:0,a.cancelPendingCommit!==null||a.timeoutHandle!==-1),(n&3)===0||Ga(a,n)||(e=!0,Is(a,n));a=a.next}while(e);kc=!1}}function Jy(){Ws()}function Ws(){qn=Jc=!1;var t=0;ze!==0&&um()&&(t=ze);for(var l=rl(),e=null,a=Bn;a!==null;){var u=a.next,n=$s(a,l);n===0?(a.next=null,e===null?Bn=u:e.next=u,u===null&&(Da=e)):(e=a,(t!==0||(n&3)!==0)&&(qn=!0)),a=u}Gt!==0&&Gt!==5||Su(t),ze!==0&&(ze=0)}function $s(t,l){for(var e=t.suspendedLanes,a=t.pingedLanes,u=t.expirationTimes,n=t.pendingLanes&-62914561;0c)break;var z=o.transferSize,E=o.initiatorType;z&&id(E)&&(o=o.responseEnd,i+=z*(o"u"?null:document;function bd(t,l,e){var a=Ua;if(a&&typeof l=="string"&&l){var u=Sl(l);u='link[rel="'+t+'"][href="'+u+'"]',typeof e=="string"&&(u+='[crossorigin="'+e+'"]'),vd.has(u)||(vd.add(u),t={rel:t,crossOrigin:e,href:l},a.querySelector(u)===null&&(l=a.createElement("link"),Jt(l,"link",t),Xt(l),a.head.appendChild(l)))}}function ym(t){ee.D(t),bd("dns-prefetch",t,null)}function mm(t,l){ee.C(t,l),bd("preconnect",t,l)}function hm(t,l,e){ee.L(t,l,e);var a=Ua;if(a&&t&&l){var u='link[rel="preload"][as="'+Sl(l)+'"]';l==="image"&&e&&e.imageSrcSet?(u+='[imagesrcset="'+Sl(e.imageSrcSet)+'"]',typeof e.imageSizes=="string"&&(u+='[imagesizes="'+Sl(e.imageSizes)+'"]')):u+='[href="'+Sl(t)+'"]';var n=u;switch(l){case"style":n=Ca(t);break;case"script":n=ja(t)}Ol.has(n)||(t=H({rel:"preload",href:l==="image"&&e&&e.imageSrcSet?void 0:t,as:l},e),Ol.set(n,t),a.querySelector(u)!==null||l==="style"&&a.querySelector(Eu(n))||l==="script"&&a.querySelector(Au(n))||(l=a.createElement("link"),Jt(l,"link",t),Xt(l),a.head.appendChild(l)))}}function gm(t,l){ee.m(t,l);var e=Ua;if(e&&t){var a=l&&typeof l.as=="string"?l.as:"script",u='link[rel="modulepreload"][as="'+Sl(a)+'"][href="'+Sl(t)+'"]',n=u;switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":n=ja(t)}if(!Ol.has(n)&&(t=H({rel:"modulepreload",href:t},l),Ol.set(n,t),e.querySelector(u)===null)){switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(e.querySelector(Au(n)))return}a=e.createElement("link"),Jt(a,"link",t),Xt(a),e.head.appendChild(a)}}}function vm(t,l,e){ee.S(t,l,e);var a=Ua;if(a&&t){var u=ta(a).hoistableStyles,n=Ca(t);l=l||"default";var i=u.get(n);if(!i){var c={loading:0,preload:null};if(i=a.querySelector(Eu(n)))c.loading=5;else{t=H({rel:"stylesheet",href:t,"data-precedence":l},e),(e=Ol.get(n))&&sf(t,e);var o=i=a.createElement("link");Xt(o),Jt(o,"link",t),o._p=new Promise(function(h,z){o.onload=h,o.onerror=z}),o.addEventListener("load",function(){c.loading|=1}),o.addEventListener("error",function(){c.loading|=2}),c.loading|=4,Zn(i,l,a)}i={type:"stylesheet",instance:i,count:1,state:c},u.set(n,i)}}}function bm(t,l){ee.X(t,l);var e=Ua;if(e&&t){var a=ta(e).hoistableScripts,u=ja(t),n=a.get(u);n||(n=e.querySelector(Au(u)),n||(t=H({src:t,async:!0},l),(l=Ol.get(u))&&df(t,l),n=e.createElement("script"),Xt(n),Jt(n,"link",t),e.head.appendChild(n)),n={type:"script",instance:n,count:1,state:null},a.set(u,n))}}function pm(t,l){ee.M(t,l);var e=Ua;if(e&&t){var a=ta(e).hoistableScripts,u=ja(t),n=a.get(u);n||(n=e.querySelector(Au(u)),n||(t=H({src:t,async:!0,type:"module"},l),(l=Ol.get(u))&&df(t,l),n=e.createElement("script"),Xt(n),Jt(n,"link",t),e.head.appendChild(n)),n={type:"script",instance:n,count:1,state:null},a.set(u,n))}}function pd(t,l,e,a){var u=(u=P.current)?Qn(u):null;if(!u)throw Error(f(446));switch(t){case"meta":case"title":return null;case"style":return typeof e.precedence=="string"&&typeof e.href=="string"?(l=Ca(e.href),e=ta(u).hoistableStyles,a=e.get(l),a||(a={type:"style",instance:null,count:0,state:null},e.set(l,a)),a):{type:"void",instance:null,count:0,state:null};case"link":if(e.rel==="stylesheet"&&typeof e.href=="string"&&typeof e.precedence=="string"){t=Ca(e.href);var n=ta(u).hoistableStyles,i=n.get(t);if(i||(u=u.ownerDocument||u,i={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},n.set(t,i),(n=u.querySelector(Eu(t)))&&!n._p&&(i.instance=n,i.state.loading=5),Ol.has(t)||(e={rel:"preload",as:"style",href:e.href,crossOrigin:e.crossOrigin,integrity:e.integrity,media:e.media,hrefLang:e.hrefLang,referrerPolicy:e.referrerPolicy},Ol.set(t,e),n||Sm(u,t,e,i.state))),l&&a===null)throw Error(f(528,""));return i}if(l&&a!==null)throw Error(f(529,""));return null;case"script":return l=e.async,e=e.src,typeof e=="string"&&l&&typeof l!="function"&&typeof l!="symbol"?(l=ja(e),e=ta(u).hoistableScripts,a=e.get(l),a||(a={type:"script",instance:null,count:0,state:null},e.set(l,a)),a):{type:"void",instance:null,count:0,state:null};default:throw Error(f(444,t))}}function Ca(t){return'href="'+Sl(t)+'"'}function Eu(t){return'link[rel="stylesheet"]['+t+"]"}function Sd(t){return H({},t,{"data-precedence":t.precedence,precedence:null})}function Sm(t,l,e,a){t.querySelector('link[rel="preload"][as="style"]['+l+"]")?a.loading=1:(l=t.createElement("link"),a.preload=l,l.addEventListener("load",function(){return a.loading|=1}),l.addEventListener("error",function(){return a.loading|=2}),Jt(l,"link",e),Xt(l),t.head.appendChild(l))}function ja(t){return'[src="'+Sl(t)+'"]'}function Au(t){return"script[async]"+t}function xd(t,l,e){if(l.count++,l.instance===null)switch(l.type){case"style":var a=t.querySelector('style[data-href~="'+Sl(e.href)+'"]');if(a)return l.instance=a,Xt(a),a;var u=H({},e,{"data-href":e.href,"data-precedence":e.precedence,href:null,precedence:null});return a=(t.ownerDocument||t).createElement("style"),Xt(a),Jt(a,"style",u),Zn(a,e.precedence,t),l.instance=a;case"stylesheet":u=Ca(e.href);var n=t.querySelector(Eu(u));if(n)return l.state.loading|=4,l.instance=n,Xt(n),n;a=Sd(e),(u=Ol.get(u))&&sf(a,u),n=(t.ownerDocument||t).createElement("link"),Xt(n);var i=n;return i._p=new Promise(function(c,o){i.onload=c,i.onerror=o}),Jt(n,"link",a),l.state.loading|=4,Zn(n,e.precedence,t),l.instance=n;case"script":return n=ja(e.src),(u=t.querySelector(Au(n)))?(l.instance=u,Xt(u),u):(a=e,(u=Ol.get(n))&&(a=H({},e),df(a,u)),t=t.ownerDocument||t,u=t.createElement("script"),Xt(u),Jt(u,"link",a),t.head.appendChild(u),l.instance=u);case"void":return null;default:throw Error(f(443,l.type))}else l.type==="stylesheet"&&(l.state.loading&4)===0&&(a=l.instance,l.state.loading|=4,Zn(a,e.precedence,t));return l.instance}function Zn(t,l,e){for(var a=e.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),u=a.length?a[a.length-1]:null,n=u,i=0;i title"):null)}function xm(t,l,e){if(e===1||l.itemProp!=null)return!1;switch(t){case"meta":case"title":return!0;case"style":if(typeof l.precedence!="string"||typeof l.href!="string"||l.href==="")break;return!0;case"link":if(typeof l.rel!="string"||typeof l.href!="string"||l.href===""||l.onLoad||l.onError)break;return l.rel==="stylesheet"?(t=l.disabled,typeof l.precedence=="string"&&t==null):!0;case"script":if(l.async&&typeof l.async!="function"&&typeof l.async!="symbol"&&!l.onLoad&&!l.onError&&l.src&&typeof l.src=="string")return!0}return!1}function Ed(t){return!(t.type==="stylesheet"&&(t.state.loading&3)===0)}function zm(t,l,e,a){if(e.type==="stylesheet"&&(typeof a.media!="string"||matchMedia(a.media).matches!==!1)&&(e.state.loading&4)===0){if(e.instance===null){var u=Ca(a.href),n=l.querySelector(Eu(u));if(n){l=n._p,l!==null&&typeof l=="object"&&typeof l.then=="function"&&(t.count++,t=Ln.bind(t),l.then(t,t)),e.state.loading|=4,e.instance=n,Xt(n);return}n=l.ownerDocument||l,a=Sd(a),(u=Ol.get(u))&&sf(a,u),n=n.createElement("link"),Xt(n);var i=n;i._p=new Promise(function(c,o){i.onload=c,i.onerror=o}),Jt(n,"link",a),e.instance=n}t.stylesheets===null&&(t.stylesheets=new Map),t.stylesheets.set(e,l),(l=e.state.preload)&&(e.state.loading&3)===0&&(t.count++,e=Ln.bind(t),l.addEventListener("load",e),l.addEventListener("error",e))}}var yf=0;function Tm(t,l){return t.stylesheets&&t.count===0&&Kn(t,t.stylesheets),0yf?50:800)+l);return t.unsuspend=e,function(){t.unsuspend=null,clearTimeout(a),clearTimeout(u)}}:null}function Ln(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Kn(this,this.stylesheets);else if(this.unsuspend){var t=this.unsuspend;this.unsuspend=null,t()}}}var Vn=null;function Kn(t,l){t.stylesheets=null,t.unsuspend!==null&&(t.count++,Vn=new Map,l.forEach(Em,t),Vn=null,Ln.call(t))}function Em(t,l){if(!(l.state.loading&4)){var e=Vn.get(t);if(e)var a=e.get(null);else{e=new Map,Vn.set(t,e);for(var u=t.querySelectorAll("link[data-precedence],style[data-precedence]"),n=0;n"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(r)}catch(v){console.error(v)}}return r(),zf.exports=Xm(),zf.exports}var Zm=Qm();const wm=r=>r.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),Pd=(...r)=>r.filter((v,S,f)=>!!v&&v.trim()!==""&&f.indexOf(v)===S).join(" ").trim();var Lm={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};const Vm=xt.forwardRef(({color:r="currentColor",size:v=24,strokeWidth:S=2,absoluteStrokeWidth:f,className:_="",children:O,iconNode:D,...U},N)=>xt.createElement("svg",{ref:N,...Lm,width:v,height:v,stroke:r,strokeWidth:f?Number(S)*24/Number(v):S,className:Pd("lucide",_),...U},[...D.map(([p,R])=>xt.createElement(p,R)),...Array.isArray(O)?O:[O]]));const Nl=(r,v)=>{const S=xt.forwardRef(({className:f,..._},O)=>xt.createElement(Vm,{ref:O,iconNode:v,className:Pd(`lucide-${wm(r)}`,f),..._}));return S.displayName=`${r}`,S};const Km=Nl("Binary",[["rect",{x:"14",y:"14",width:"4",height:"6",rx:"2",key:"p02svl"}],["rect",{x:"6",y:"4",width:"4",height:"6",rx:"2",key:"xm4xkj"}],["path",{d:"M6 20h4",key:"1i6q5t"}],["path",{d:"M14 10h4",key:"ru81e7"}],["path",{d:"M6 14h2v6",key:"16z9wg"}],["path",{d:"M14 4h2v6",key:"1idq9u"}]]);const Jm=Nl("BookText",[["path",{d:"M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20",key:"k3hazp"}],["path",{d:"M8 11h8",key:"vwpz6n"}],["path",{d:"M8 7h6",key:"1f0q6e"}]]);const km=Nl("EyeOff",[["path",{d:"M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49",key:"ct8e1f"}],["path",{d:"M14.084 14.158a3 3 0 0 1-4.242-4.242",key:"151rxh"}],["path",{d:"M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143",key:"13bj9a"}],["path",{d:"m2 2 20 20",key:"1ooewy"}]]);const Wm=Nl("Eye",[["path",{d:"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0",key:"1nclc0"}],["circle",{cx:"12",cy:"12",r:"3",key:"1v7zrd"}]]);const $m=Nl("Globe",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20",key:"13o1zl"}],["path",{d:"M2 12h20",key:"9i4pu4"}]]);const kd=Nl("LoaderCircle",[["path",{d:"M21 12a9 9 0 1 1-6.219-8.56",key:"13zald"}]]);const Fm=Nl("Lock",[["rect",{width:"18",height:"11",x:"3",y:"11",rx:"2",ry:"2",key:"1w4ew1"}],["path",{d:"M7 11V7a5 5 0 0 1 10 0v4",key:"fwvmzm"}]]);const Im=Nl("LogIn",[["path",{d:"M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4",key:"u53s6r"}],["polyline",{points:"10 17 15 12 10 7",key:"1ail0h"}],["line",{x1:"15",x2:"3",y1:"12",y2:"12",key:"v6grx8"}]]);const Pm=Nl("RotateCw",[["path",{d:"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8",key:"1p45f6"}],["path",{d:"M21 3v5h-5",key:"1q7to0"}]]);const th=Nl("User",[["path",{d:"M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2",key:"975kel"}],["circle",{cx:"12",cy:"7",r:"4",key:"17ys0d"}]]);const lh=Nl("Waypoints",[["circle",{cx:"12",cy:"4.5",r:"2.5",key:"r5ysbb"}],["path",{d:"m10.2 6.3-3.9 3.9",key:"1nzqf6"}],["circle",{cx:"4.5",cy:"12",r:"2.5",key:"jydg6v"}],["path",{d:"M7 12h10",key:"b7w52i"}],["circle",{cx:"19.5",cy:"12",r:"2.5",key:"1piiel"}],["path",{d:"m13.8 17.7 3.9-3.9",key:"1wyg1y"}],["circle",{cx:"12",cy:"19.5",r:"2.5",key:"13o1pw"}]]);const eh=Nl("X",[["path",{d:"M18 6 6 18",key:"1bl5f8"}],["path",{d:"m6 6 12 12",key:"d8bk6v"}]]);function t0(){return globalThis.__DATA__??{}}function l0(r){var v,S,f="";if(typeof r=="string"||typeof r=="number")f+=r;else if(typeof r=="object")if(Array.isArray(r)){var _=r.length;for(v=0;v<_;v++)r[v]&&(S=l0(r[v]))&&(f&&(f+=" "),f+=S)}else for(S in r)r[S]&&(f&&(f+=" "),f+=S);return f}function ah(){for(var r,v,S=0,f="",_=arguments.length;S<_;S++)(r=arguments[S])&&(v=l0(r))&&(f&&(f+=" "),f+=v);return f}const Hf="-",uh=r=>{const v=ih(r),{conflictingClassGroups:S,conflictingClassGroupModifiers:f}=r;return{getClassGroupId:D=>{const U=D.split(Hf);return U[0]===""&&U.length!==1&&U.shift(),e0(U,v)||nh(D)},getConflictingClassGroupIds:(D,U)=>{const N=S[D]||[];return U&&f[D]?[...N,...f[D]]:N}}},e0=(r,v)=>{if(r.length===0)return v.classGroupId;const S=r[0],f=v.nextPart.get(S),_=f?e0(r.slice(1),f):void 0;if(_)return _;if(v.validators.length===0)return;const O=r.join(Hf);return v.validators.find(({validator:D})=>D(O))?.classGroupId},Wd=/^\[(.+)\]$/,nh=r=>{if(Wd.test(r)){const v=Wd.exec(r)[1],S=v?.substring(0,v.indexOf(":"));if(S)return"arbitrary.."+S}},ih=r=>{const{theme:v,prefix:S}=r,f={nextPart:new Map,validators:[]};return fh(Object.entries(r.classGroups),S).forEach(([O,D])=>{Df(D,f,O,v)}),f},Df=(r,v,S,f)=>{r.forEach(_=>{if(typeof _=="string"){const O=_===""?v:$d(v,_);O.classGroupId=S;return}if(typeof _=="function"){if(ch(_)){Df(_(f),v,S,f);return}v.validators.push({validator:_,classGroupId:S});return}Object.entries(_).forEach(([O,D])=>{Df(D,$d(v,O),S,f)})})},$d=(r,v)=>{let S=r;return v.split(Hf).forEach(f=>{S.nextPart.has(f)||S.nextPart.set(f,{nextPart:new Map,validators:[]}),S=S.nextPart.get(f)}),S},ch=r=>r.isThemeGetter,fh=(r,v)=>v?r.map(([S,f])=>{const _=f.map(O=>typeof O=="string"?v+O:typeof O=="object"?Object.fromEntries(Object.entries(O).map(([D,U])=>[v+D,U])):O);return[S,_]}):r,rh=r=>{if(r<1)return{get:()=>{},set:()=>{}};let v=0,S=new Map,f=new Map;const _=(O,D)=>{S.set(O,D),v++,v>r&&(v=0,f=S,S=new Map)};return{get(O){let D=S.get(O);if(D!==void 0)return D;if((D=f.get(O))!==void 0)return _(O,D),D},set(O,D){S.has(O)?S.set(O,D):_(O,D)}}},a0="!",oh=r=>{const{separator:v,experimentalParseClassName:S}=r,f=v.length===1,_=v[0],O=v.length,D=U=>{const N=[];let p=0,R=0,H;for(let Q=0;QR?H-R:void 0;return{modifiers:N,hasImportantModifier:st,baseClassName:ct,maybePostfixModifierPosition:G}};return S?U=>S({className:U,parseClassName:D}):D},sh=r=>{if(r.length<=1)return r;const v=[];let S=[];return r.forEach(f=>{f[0]==="["?(v.push(...S.sort(),f),S=[]):S.push(f)}),v.push(...S.sort()),v},dh=r=>({cache:rh(r.cacheSize),parseClassName:oh(r),...uh(r)}),yh=/\s+/,mh=(r,v)=>{const{parseClassName:S,getClassGroupId:f,getConflictingClassGroupIds:_}=v,O=[],D=r.trim().split(yh);let U="";for(let N=D.length-1;N>=0;N-=1){const p=D[N],{modifiers:R,hasImportantModifier:H,baseClassName:V,maybePostfixModifierPosition:st}=S(p);let ct=!!st,G=f(ct?V.substring(0,st):V);if(!G){if(!ct){U=p+(U.length>0?" "+U:U);continue}if(G=f(V),!G){U=p+(U.length>0?" "+U:U);continue}ct=!1}const Q=sh(R).join(":"),L=H?Q+a0:Q,gt=L+G;if(O.includes(gt))continue;O.push(gt);const zt=_(G,ct);for(let _t=0;_t0?" "+U:U)}return U};function hh(){let r=0,v,S,f="";for(;r{if(typeof r=="string")return r;let v,S="";for(let f=0;fH(R),r());return S=dh(p),f=S.cache.get,_=S.cache.set,O=U,U(N)}function U(N){const p=f(N);if(p)return p;const R=mh(N,S);return _(N,R),R}return function(){return O(hh.apply(null,arguments))}}const At=r=>{const v=S=>S[r]||[];return v.isThemeGetter=!0,v},n0=/^\[(?:([a-z-]+):)?(.+)\]$/i,vh=/^\d+\/\d+$/,bh=new Set(["px","full","screen"]),ph=/^(\d+(\.\d+)?)?(xs|sm|md|lg|xl)$/,Sh=/\d+(%|px|r?em|[sdl]?v([hwib]|min|max)|pt|pc|in|cm|mm|cap|ch|ex|r?lh|cq(w|h|i|b|min|max))|\b(calc|min|max|clamp)\(.+\)|^0$/,xh=/^(rgba?|hsla?|hwb|(ok)?(lab|lch)|color-mix)\(.+\)$/,zh=/^(inset_)?-?((\d+)?\.?(\d+)[a-z]+|0)_-?((\d+)?\.?(\d+)[a-z]+|0)/,Th=/^(url|image|image-set|cross-fade|element|(repeating-)?(linear|radial|conic)-gradient)\(.+\)$/,ae=r=>Ha(r)||bh.has(r)||vh.test(r),Ne=r=>Ba(r,"length",Uh),Ha=r=>!!r&&!Number.isNaN(Number(r)),Mf=r=>Ba(r,"number",Ha),Cu=r=>!!r&&Number.isInteger(Number(r)),Eh=r=>r.endsWith("%")&&Ha(r.slice(0,-1)),F=r=>n0.test(r),De=r=>ph.test(r),Ah=new Set(["length","size","percentage"]),Mh=r=>Ba(r,Ah,i0),_h=r=>Ba(r,"position",i0),Oh=new Set(["image","url"]),Nh=r=>Ba(r,Oh,jh),Dh=r=>Ba(r,"",Ch),ju=()=>!0,Ba=(r,v,S)=>{const f=n0.exec(r);return f?f[1]?typeof v=="string"?f[1]===v:v.has(f[1]):S(f[2]):!1},Uh=r=>Sh.test(r)&&!xh.test(r),i0=()=>!1,Ch=r=>zh.test(r),jh=r=>Th.test(r),Rh=()=>{const r=At("colors"),v=At("spacing"),S=At("blur"),f=At("brightness"),_=At("borderColor"),O=At("borderRadius"),D=At("borderSpacing"),U=At("borderWidth"),N=At("contrast"),p=At("grayscale"),R=At("hueRotate"),H=At("invert"),V=At("gap"),st=At("gradientColorStops"),ct=At("gradientColorStopPositions"),G=At("inset"),Q=At("margin"),L=At("opacity"),gt=At("padding"),zt=At("saturate"),_t=At("scale"),it=At("sepia"),Ot=At("skew"),J=At("space"),Rt=At("translate"),It=()=>["auto","contain","none"],jl=()=>["auto","hidden","clip","visible","scroll"],Pt=()=>["auto",F,v],I=()=>[F,v],Rl=()=>["",ae,Ne],tl=()=>["auto",Ha,F],ll=()=>["bottom","center","left","left-bottom","left-top","right","right-bottom","right-top","top"],x=()=>["solid","dashed","dotted","double","none"],C=()=>["normal","multiply","screen","overlay","darken","lighten","color-dodge","color-burn","hard-light","soft-light","difference","exclusion","hue","saturation","color","luminosity"],Z=()=>["start","end","center","between","around","evenly","stretch"],nt=()=>["","0",F],dt=()=>["auto","avoid","all","avoid-page","page","left","right","column"],s=()=>[Ha,F];return{cacheSize:500,separator:":",theme:{colors:[ju],spacing:[ae,Ne],blur:["none","",De,F],brightness:s(),borderColor:[r],borderRadius:["none","","full",De,F],borderSpacing:I(),borderWidth:Rl(),contrast:s(),grayscale:nt(),hueRotate:s(),invert:nt(),gap:I(),gradientColorStops:[r],gradientColorStopPositions:[Eh,Ne],inset:Pt(),margin:Pt(),opacity:s(),padding:I(),saturate:s(),scale:s(),sepia:nt(),skew:s(),space:I(),translate:I()},classGroups:{aspect:[{aspect:["auto","square","video",F]}],container:["container"],columns:[{columns:[De]}],"break-after":[{"break-after":dt()}],"break-before":[{"break-before":dt()}],"break-inside":[{"break-inside":["auto","avoid","avoid-page","avoid-column"]}],"box-decoration":[{"box-decoration":["slice","clone"]}],box:[{box:["border","content"]}],display:["block","inline-block","inline","flex","inline-flex","table","inline-table","table-caption","table-cell","table-column","table-column-group","table-footer-group","table-header-group","table-row-group","table-row","flow-root","grid","inline-grid","contents","list-item","hidden"],float:[{float:["right","left","none","start","end"]}],clear:[{clear:["left","right","both","none","start","end"]}],isolation:["isolate","isolation-auto"],"object-fit":[{object:["contain","cover","fill","none","scale-down"]}],"object-position":[{object:[...ll(),F]}],overflow:[{overflow:jl()}],"overflow-x":[{"overflow-x":jl()}],"overflow-y":[{"overflow-y":jl()}],overscroll:[{overscroll:It()}],"overscroll-x":[{"overscroll-x":It()}],"overscroll-y":[{"overscroll-y":It()}],position:["static","fixed","absolute","relative","sticky"],inset:[{inset:[G]}],"inset-x":[{"inset-x":[G]}],"inset-y":[{"inset-y":[G]}],start:[{start:[G]}],end:[{end:[G]}],top:[{top:[G]}],right:[{right:[G]}],bottom:[{bottom:[G]}],left:[{left:[G]}],visibility:["visible","invisible","collapse"],z:[{z:["auto",Cu,F]}],basis:[{basis:Pt()}],"flex-direction":[{flex:["row","row-reverse","col","col-reverse"]}],"flex-wrap":[{flex:["wrap","wrap-reverse","nowrap"]}],flex:[{flex:["1","auto","initial","none",F]}],grow:[{grow:nt()}],shrink:[{shrink:nt()}],order:[{order:["first","last","none",Cu,F]}],"grid-cols":[{"grid-cols":[ju]}],"col-start-end":[{col:["auto",{span:["full",Cu,F]},F]}],"col-start":[{"col-start":tl()}],"col-end":[{"col-end":tl()}],"grid-rows":[{"grid-rows":[ju]}],"row-start-end":[{row:["auto",{span:[Cu,F]},F]}],"row-start":[{"row-start":tl()}],"row-end":[{"row-end":tl()}],"grid-flow":[{"grid-flow":["row","col","dense","row-dense","col-dense"]}],"auto-cols":[{"auto-cols":["auto","min","max","fr",F]}],"auto-rows":[{"auto-rows":["auto","min","max","fr",F]}],gap:[{gap:[V]}],"gap-x":[{"gap-x":[V]}],"gap-y":[{"gap-y":[V]}],"justify-content":[{justify:["normal",...Z()]}],"justify-items":[{"justify-items":["start","end","center","stretch"]}],"justify-self":[{"justify-self":["auto","start","end","center","stretch"]}],"align-content":[{content:["normal",...Z(),"baseline"]}],"align-items":[{items:["start","end","center","baseline","stretch"]}],"align-self":[{self:["auto","start","end","center","stretch","baseline"]}],"place-content":[{"place-content":[...Z(),"baseline"]}],"place-items":[{"place-items":["start","end","center","baseline","stretch"]}],"place-self":[{"place-self":["auto","start","end","center","stretch"]}],p:[{p:[gt]}],px:[{px:[gt]}],py:[{py:[gt]}],ps:[{ps:[gt]}],pe:[{pe:[gt]}],pt:[{pt:[gt]}],pr:[{pr:[gt]}],pb:[{pb:[gt]}],pl:[{pl:[gt]}],m:[{m:[Q]}],mx:[{mx:[Q]}],my:[{my:[Q]}],ms:[{ms:[Q]}],me:[{me:[Q]}],mt:[{mt:[Q]}],mr:[{mr:[Q]}],mb:[{mb:[Q]}],ml:[{ml:[Q]}],"space-x":[{"space-x":[J]}],"space-x-reverse":["space-x-reverse"],"space-y":[{"space-y":[J]}],"space-y-reverse":["space-y-reverse"],w:[{w:["auto","min","max","fit","svw","lvw","dvw",F,v]}],"min-w":[{"min-w":[F,v,"min","max","fit"]}],"max-w":[{"max-w":[F,v,"none","full","min","max","fit","prose",{screen:[De]},De]}],h:[{h:[F,v,"auto","min","max","fit","svh","lvh","dvh"]}],"min-h":[{"min-h":[F,v,"min","max","fit","svh","lvh","dvh"]}],"max-h":[{"max-h":[F,v,"min","max","fit","svh","lvh","dvh"]}],size:[{size:[F,v,"auto","min","max","fit"]}],"font-size":[{text:["base",De,Ne]}],"font-smoothing":["antialiased","subpixel-antialiased"],"font-style":["italic","not-italic"],"font-weight":[{font:["thin","extralight","light","normal","medium","semibold","bold","extrabold","black",Mf]}],"font-family":[{font:[ju]}],"fvn-normal":["normal-nums"],"fvn-ordinal":["ordinal"],"fvn-slashed-zero":["slashed-zero"],"fvn-figure":["lining-nums","oldstyle-nums"],"fvn-spacing":["proportional-nums","tabular-nums"],"fvn-fraction":["diagonal-fractions","stacked-fractions"],tracking:[{tracking:["tighter","tight","normal","wide","wider","widest",F]}],"line-clamp":[{"line-clamp":["none",Ha,Mf]}],leading:[{leading:["none","tight","snug","normal","relaxed","loose",ae,F]}],"list-image":[{"list-image":["none",F]}],"list-style-type":[{list:["none","disc","decimal",F]}],"list-style-position":[{list:["inside","outside"]}],"placeholder-color":[{placeholder:[r]}],"placeholder-opacity":[{"placeholder-opacity":[L]}],"text-alignment":[{text:["left","center","right","justify","start","end"]}],"text-color":[{text:[r]}],"text-opacity":[{"text-opacity":[L]}],"text-decoration":["underline","overline","line-through","no-underline"],"text-decoration-style":[{decoration:[...x(),"wavy"]}],"text-decoration-thickness":[{decoration:["auto","from-font",ae,Ne]}],"underline-offset":[{"underline-offset":["auto",ae,F]}],"text-decoration-color":[{decoration:[r]}],"text-transform":["uppercase","lowercase","capitalize","normal-case"],"text-overflow":["truncate","text-ellipsis","text-clip"],"text-wrap":[{text:["wrap","nowrap","balance","pretty"]}],indent:[{indent:I()}],"vertical-align":[{align:["baseline","top","middle","bottom","text-top","text-bottom","sub","super",F]}],whitespace:[{whitespace:["normal","nowrap","pre","pre-line","pre-wrap","break-spaces"]}],break:[{break:["normal","words","all","keep"]}],hyphens:[{hyphens:["none","manual","auto"]}],content:[{content:["none",F]}],"bg-attachment":[{bg:["fixed","local","scroll"]}],"bg-clip":[{"bg-clip":["border","padding","content","text"]}],"bg-opacity":[{"bg-opacity":[L]}],"bg-origin":[{"bg-origin":["border","padding","content"]}],"bg-position":[{bg:[...ll(),_h]}],"bg-repeat":[{bg:["no-repeat",{repeat:["","x","y","round","space"]}]}],"bg-size":[{bg:["auto","cover","contain",Mh]}],"bg-image":[{bg:["none",{"gradient-to":["t","tr","r","br","b","bl","l","tl"]},Nh]}],"bg-color":[{bg:[r]}],"gradient-from-pos":[{from:[ct]}],"gradient-via-pos":[{via:[ct]}],"gradient-to-pos":[{to:[ct]}],"gradient-from":[{from:[st]}],"gradient-via":[{via:[st]}],"gradient-to":[{to:[st]}],rounded:[{rounded:[O]}],"rounded-s":[{"rounded-s":[O]}],"rounded-e":[{"rounded-e":[O]}],"rounded-t":[{"rounded-t":[O]}],"rounded-r":[{"rounded-r":[O]}],"rounded-b":[{"rounded-b":[O]}],"rounded-l":[{"rounded-l":[O]}],"rounded-ss":[{"rounded-ss":[O]}],"rounded-se":[{"rounded-se":[O]}],"rounded-ee":[{"rounded-ee":[O]}],"rounded-es":[{"rounded-es":[O]}],"rounded-tl":[{"rounded-tl":[O]}],"rounded-tr":[{"rounded-tr":[O]}],"rounded-br":[{"rounded-br":[O]}],"rounded-bl":[{"rounded-bl":[O]}],"border-w":[{border:[U]}],"border-w-x":[{"border-x":[U]}],"border-w-y":[{"border-y":[U]}],"border-w-s":[{"border-s":[U]}],"border-w-e":[{"border-e":[U]}],"border-w-t":[{"border-t":[U]}],"border-w-r":[{"border-r":[U]}],"border-w-b":[{"border-b":[U]}],"border-w-l":[{"border-l":[U]}],"border-opacity":[{"border-opacity":[L]}],"border-style":[{border:[...x(),"hidden"]}],"divide-x":[{"divide-x":[U]}],"divide-x-reverse":["divide-x-reverse"],"divide-y":[{"divide-y":[U]}],"divide-y-reverse":["divide-y-reverse"],"divide-opacity":[{"divide-opacity":[L]}],"divide-style":[{divide:x()}],"border-color":[{border:[_]}],"border-color-x":[{"border-x":[_]}],"border-color-y":[{"border-y":[_]}],"border-color-s":[{"border-s":[_]}],"border-color-e":[{"border-e":[_]}],"border-color-t":[{"border-t":[_]}],"border-color-r":[{"border-r":[_]}],"border-color-b":[{"border-b":[_]}],"border-color-l":[{"border-l":[_]}],"divide-color":[{divide:[_]}],"outline-style":[{outline:["",...x()]}],"outline-offset":[{"outline-offset":[ae,F]}],"outline-w":[{outline:[ae,Ne]}],"outline-color":[{outline:[r]}],"ring-w":[{ring:Rl()}],"ring-w-inset":["ring-inset"],"ring-color":[{ring:[r]}],"ring-opacity":[{"ring-opacity":[L]}],"ring-offset-w":[{"ring-offset":[ae,Ne]}],"ring-offset-color":[{"ring-offset":[r]}],shadow:[{shadow:["","inner","none",De,Dh]}],"shadow-color":[{shadow:[ju]}],opacity:[{opacity:[L]}],"mix-blend":[{"mix-blend":[...C(),"plus-lighter","plus-darker"]}],"bg-blend":[{"bg-blend":C()}],filter:[{filter:["","none"]}],blur:[{blur:[S]}],brightness:[{brightness:[f]}],contrast:[{contrast:[N]}],"drop-shadow":[{"drop-shadow":["","none",De,F]}],grayscale:[{grayscale:[p]}],"hue-rotate":[{"hue-rotate":[R]}],invert:[{invert:[H]}],saturate:[{saturate:[zt]}],sepia:[{sepia:[it]}],"backdrop-filter":[{"backdrop-filter":["","none"]}],"backdrop-blur":[{"backdrop-blur":[S]}],"backdrop-brightness":[{"backdrop-brightness":[f]}],"backdrop-contrast":[{"backdrop-contrast":[N]}],"backdrop-grayscale":[{"backdrop-grayscale":[p]}],"backdrop-hue-rotate":[{"backdrop-hue-rotate":[R]}],"backdrop-invert":[{"backdrop-invert":[H]}],"backdrop-opacity":[{"backdrop-opacity":[L]}],"backdrop-saturate":[{"backdrop-saturate":[zt]}],"backdrop-sepia":[{"backdrop-sepia":[it]}],"border-collapse":[{border:["collapse","separate"]}],"border-spacing":[{"border-spacing":[D]}],"border-spacing-x":[{"border-spacing-x":[D]}],"border-spacing-y":[{"border-spacing-y":[D]}],"table-layout":[{table:["auto","fixed"]}],caption:[{caption:["top","bottom"]}],transition:[{transition:["none","all","","colors","opacity","shadow","transform",F]}],duration:[{duration:s()}],ease:[{ease:["linear","in","out","in-out",F]}],delay:[{delay:s()}],animate:[{animate:["none","spin","ping","pulse","bounce",F]}],transform:[{transform:["","gpu","none"]}],scale:[{scale:[_t]}],"scale-x":[{"scale-x":[_t]}],"scale-y":[{"scale-y":[_t]}],rotate:[{rotate:[Cu,F]}],"translate-x":[{"translate-x":[Rt]}],"translate-y":[{"translate-y":[Rt]}],"skew-x":[{"skew-x":[Ot]}],"skew-y":[{"skew-y":[Ot]}],"transform-origin":[{origin:["center","top","top-right","right","bottom-right","bottom","bottom-left","left","top-left",F]}],accent:[{accent:["auto",r]}],appearance:[{appearance:["none","auto"]}],cursor:[{cursor:["auto","default","pointer","wait","text","move","help","not-allowed","none","context-menu","progress","cell","crosshair","vertical-text","alias","copy","no-drop","grab","grabbing","all-scroll","col-resize","row-resize","n-resize","e-resize","s-resize","w-resize","ne-resize","nw-resize","se-resize","sw-resize","ew-resize","ns-resize","nesw-resize","nwse-resize","zoom-in","zoom-out",F]}],"caret-color":[{caret:[r]}],"pointer-events":[{"pointer-events":["none","auto"]}],resize:[{resize:["none","y","x",""]}],"scroll-behavior":[{scroll:["auto","smooth"]}],"scroll-m":[{"scroll-m":I()}],"scroll-mx":[{"scroll-mx":I()}],"scroll-my":[{"scroll-my":I()}],"scroll-ms":[{"scroll-ms":I()}],"scroll-me":[{"scroll-me":I()}],"scroll-mt":[{"scroll-mt":I()}],"scroll-mr":[{"scroll-mr":I()}],"scroll-mb":[{"scroll-mb":I()}],"scroll-ml":[{"scroll-ml":I()}],"scroll-p":[{"scroll-p":I()}],"scroll-px":[{"scroll-px":I()}],"scroll-py":[{"scroll-py":I()}],"scroll-ps":[{"scroll-ps":I()}],"scroll-pe":[{"scroll-pe":I()}],"scroll-pt":[{"scroll-pt":I()}],"scroll-pr":[{"scroll-pr":I()}],"scroll-pb":[{"scroll-pb":I()}],"scroll-pl":[{"scroll-pl":I()}],"snap-align":[{snap:["start","end","center","align-none"]}],"snap-stop":[{snap:["normal","always"]}],"snap-type":[{snap:["none","x","y","both"]}],"snap-strictness":[{snap:["mandatory","proximity"]}],touch:[{touch:["auto","none","manipulation"]}],"touch-x":[{"touch-pan":["x","left","right"]}],"touch-y":[{"touch-pan":["y","up","down"]}],"touch-pz":["touch-pinch-zoom"],select:[{select:["none","text","all","auto"]}],"will-change":[{"will-change":["auto","scroll","contents","transform",F]}],fill:[{fill:[r,"none"]}],"stroke-w":[{stroke:[ae,Ne,Mf]}],stroke:[{stroke:[r,"none"]}],sr:["sr-only","not-sr-only"],"forced-color-adjust":[{"forced-color-adjust":["auto","none"]}]},conflictingClassGroups:{overflow:["overflow-x","overflow-y"],overscroll:["overscroll-x","overscroll-y"],inset:["inset-x","inset-y","start","end","top","right","bottom","left"],"inset-x":["right","left"],"inset-y":["top","bottom"],flex:["basis","grow","shrink"],gap:["gap-x","gap-y"],p:["px","py","ps","pe","pt","pr","pb","pl"],px:["pr","pl"],py:["pt","pb"],m:["mx","my","ms","me","mt","mr","mb","ml"],mx:["mr","ml"],my:["mt","mb"],size:["w","h"],"font-size":["leading"],"fvn-normal":["fvn-ordinal","fvn-slashed-zero","fvn-figure","fvn-spacing","fvn-fraction"],"fvn-ordinal":["fvn-normal"],"fvn-slashed-zero":["fvn-normal"],"fvn-figure":["fvn-normal"],"fvn-spacing":["fvn-normal"],"fvn-fraction":["fvn-normal"],"line-clamp":["display","overflow"],rounded:["rounded-s","rounded-e","rounded-t","rounded-r","rounded-b","rounded-l","rounded-ss","rounded-se","rounded-ee","rounded-es","rounded-tl","rounded-tr","rounded-br","rounded-bl"],"rounded-s":["rounded-ss","rounded-es"],"rounded-e":["rounded-se","rounded-ee"],"rounded-t":["rounded-tl","rounded-tr"],"rounded-r":["rounded-tr","rounded-br"],"rounded-b":["rounded-br","rounded-bl"],"rounded-l":["rounded-tl","rounded-bl"],"border-spacing":["border-spacing-x","border-spacing-y"],"border-w":["border-w-s","border-w-e","border-w-t","border-w-r","border-w-b","border-w-l"],"border-w-x":["border-w-r","border-w-l"],"border-w-y":["border-w-t","border-w-b"],"border-color":["border-color-s","border-color-e","border-color-t","border-color-r","border-color-b","border-color-l"],"border-color-x":["border-color-r","border-color-l"],"border-color-y":["border-color-t","border-color-b"],"scroll-m":["scroll-mx","scroll-my","scroll-ms","scroll-me","scroll-mt","scroll-mr","scroll-mb","scroll-ml"],"scroll-mx":["scroll-mr","scroll-ml"],"scroll-my":["scroll-mt","scroll-mb"],"scroll-p":["scroll-px","scroll-py","scroll-ps","scroll-pe","scroll-pt","scroll-pr","scroll-pb","scroll-pl"],"scroll-px":["scroll-pr","scroll-pl"],"scroll-py":["scroll-pt","scroll-pb"],touch:["touch-x","touch-y","touch-pz"],"touch-x":["touch"],"touch-y":["touch"],"touch-pz":["touch"]},conflictingClassGroupModifiers:{"font-size":["leading"]}}},Hh=gh(Rh);function Zt(...r){return Hh(ah(r))}const Bh=["relative cursor-pointer","text-sm focus:z-10 focus:ring-2 font-medium focus:outline-none whitespace-nowrap shadow-sm","inline-flex gap-2 items-center justify-center transition-colors focus:ring-offset-1","disabled:opacity-40 disabled:cursor-not-allowed disabled:text-nb-gray-300 ring-offset-neutral-950/50"],qh={default:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900","dark:focus:ring-zinc-800/50 dark:bg-nb-gray dark:text-gray-400 dark:border-gray-700/30 dark:hover:text-white dark:hover:bg-zinc-800/50"],primary:["dark:focus:ring-netbird-600/50 dark:ring-offset-neutral-950/50 enabled:dark:bg-netbird disabled:dark:bg-nb-gray-910 dark:text-gray-100 enabled:dark:hover:text-white enabled:dark:hover:bg-netbird-500/80","enabled:bg-netbird enabled:text-white enabled:focus:ring-netbird-400/50 enabled:hover:bg-netbird-500"],secondary:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900","dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20","dark:bg-nb-gray-920 dark:text-gray-400 dark:border-gray-700/40 dark:hover:text-white dark:hover:bg-nb-gray-910"],secondaryLighter:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900","dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20","dark:bg-nb-gray-900/70 dark:text-gray-400 dark:border-gray-700/70 dark:hover:text-white dark:hover:bg-nb-gray-800/60"],input:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900","dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20","dark:bg-nb-gray-900 dark:text-gray-400 dark:border-nb-gray-700 dark:hover:bg-nb-gray-900/80"],dropdown:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900","dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20","dark:bg-nb-gray-900/40 dark:text-gray-400 dark:border-nb-gray-900 dark:hover:bg-nb-gray-900/50"],dotted:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900 border-dashed","dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20","dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-500/40 dark:hover:text-white dark:hover:bg-zinc-800/50"],tertiary:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900","dark:focus:ring-zinc-800/50 dark:bg-white dark:text-gray-800 dark:border-gray-700/40 dark:hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300"],white:["focus:ring-white/50 bg-white text-gray-800 border-white outline-none hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300","disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300 disabled:dark:border-nb-gray-900"],outline:["bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900","dark:focus:ring-zinc-800/50 dark:bg-transparent dark:text-netbird dark:border-netbird dark:hover:bg-nb-gray-900/30"],"danger-outline":["enabled:dark:focus:ring-red-800/20 enabled:dark:focus:bg-red-950/40 enabled:hover:dark:bg-red-950/50 enabled:dark:hover:border-red-800/50 dark:bg-transparent dark:text-red-500"],"danger-text":["dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50"],"default-outline":["dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20","dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-nb-gray-900/30 dark:hover:border-nb-gray-800/50","data-[state=open]:dark:text-white data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:border-nb-gray-800/50"],danger:["dark:focus:ring-red-700/20 dark:focus:bg-red-700 hover:dark:bg-red-700 dark:hover:border-red-800/50 dark:bg-red-600 dark:text-red-100"]},Yh={xs:"text-xs py-2 px-4",xs2:"text-[0.78rem] py-2 px-4",sm:"text-sm py-2.5 px-4",md:"text-sm py-2.5 px-4",lg:"text-base py-2.5 px-4"},Gh={0:"border",1:"border border-transparent",2:"border border-t-0 border-b-0"},Ru=xt.forwardRef(({variant:r="default",rounded:v=!0,border:S=1,size:f="md",stopPropagation:_=!0,className:O,onClick:D,children:U,...N},p)=>A.jsx("button",{type:"button",...N,ref:p,className:Zt(Bh,qh[r],Yh[f],Gh[S?1:0],v&&"rounded-md",O),onClick:R=>{_&&R.stopPropagation(),D?.(R)},children:U}));Ru.displayName="Button";const Xh={default:["bg-nb-gray-900 placeholder:text-neutral-400/70 border-nb-gray-700","ring-offset-neutral-950/50 focus-visible:ring-neutral-500/20"],darker:["bg-nb-gray-920 placeholder:text-neutral-400/70 border-nb-gray-800","ring-offset-neutral-950/50 focus-visible:ring-neutral-500/20"],error:["bg-nb-gray-900 placeholder:text-neutral-400/70 border-red-500 text-red-500","ring-offset-red-500/10 focus-visible:ring-red-500/10"]},Qh={default:"bg-nb-gray-900 border-nb-gray-700 text-nb-gray-300",error:"bg-nb-gray-900 border-red-500 text-nb-gray-300 text-red-500"},c0=xt.forwardRef(({className:r,type:v,customSuffix:S,customPrefix:f,icon:_,maxWidthClass:O="",error:D,variant:U="default",prefixClassName:N,showPasswordToggle:p=!1,...R},H)=>{const[V,st]=xt.useState(!1),ct=v==="password",G=ct&&V?"text":v,L=(ct&&p?A.jsx("button",{type:"button",onClick:()=>st(!V),className:"hover:text-white transition-all","aria-label":"Toggle password visibility",children:V?A.jsx(km,{size:18}):A.jsx(Wm,{size:18})}):null)||S,gt=D?"error":U;return A.jsxs(A.Fragment,{children:[A.jsxs("div",{className:Zt("flex relative h-[42px]",O),children:[f&&A.jsx("div",{className:Zt(Qh[D?"error":"default"],"flex h-[42px] w-auto rounded-l-md px-3 py-2 text-sm","border items-center whitespace-nowrap",R.disabled&&"opacity-40",N),children:f}),A.jsx("div",{className:Zt("absolute left-0 top-0 h-full flex items-center text-xs text-nb-gray-300 pl-3 leading-[0]",R.disabled&&"opacity-40"),children:_}),A.jsx("input",{type:G,ref:H,...R,className:Zt(Xh[gt],"flex h-[42px] w-full rounded-md px-3 py-2 text-sm","file:bg-transparent file:text-sm file:font-medium file:border-0","focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2","disabled:cursor-not-allowed disabled:opacity-40","border",f&&"!border-l-0 !rounded-l-none",L&&"!pr-16",_&&"!pl-10",r)}),A.jsx("div",{className:Zt("absolute right-0 top-0 h-full flex items-center text-xs text-nb-gray-300 pr-4 leading-[0] select-none",R.disabled&&"opacity-30"),children:L})]}),D&&A.jsx("p",{className:"text-xs text-red-500 mt-2",children:D})]})});c0.displayName="Input";const Zh=xt.forwardRef(function({value:v,onChange:S,length:f=6,disabled:_=!1,className:O,autoFocus:D=!1},U){const N=xt.useRef([]);xt.useImperativeHandle(U,()=>({focus:()=>{N.current[0]?.focus()}}));const p=v.split("").concat(new Array(f).fill("")).slice(0,f),R=Array.from({length:f},(G,Q)=>`pin-${Q}`),H=(G,Q)=>{if(!/^\d*$/.test(Q))return;const L=[...p];L[G]=Q.slice(-1);const gt=L.join("").replaceAll(/\s/g,"");S(gt),Q&&G{Q.key==="Backspace"&&!p[G]&&G>0&&N.current[G-1]?.focus(),Q.key==="ArrowLeft"&&G>0&&N.current[G-1]?.focus(),Q.key==="ArrowRight"&&G{G.preventDefault();const Q=G.clipboardData.getData("text").replaceAll(/\D/g,"").slice(0,f);S(Q);const L=Math.min(Q.length,f-1);N.current[L]?.focus()},ct=G=>{G.target.select()};return A.jsx("div",{className:Zt("flex gap-2 w-full min-w-0",O),children:p.map((G,Q)=>A.jsx("input",{id:R[Q],ref:L=>{N.current[Q]=L},type:"text",inputMode:"numeric",maxLength:1,value:G,onChange:L=>H(Q,L.target.value),onKeyDown:L=>V(Q,L),onPaste:st,onFocus:ct,disabled:_,autoFocus:D&&Q===0,className:Zt("flex-1 min-w-0 h-[42px] text-center text-sm rounded-md","dark:bg-nb-gray-900 border dark:border-nb-gray-700","dark:placeholder:text-neutral-400/70","focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2","ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20","disabled:cursor-not-allowed disabled:opacity-40")},R[Q]))})}),f0=xt.createContext({value:"",onChange:()=>{}}),r0=()=>xt.useContext(f0);function $e({value:r,defaultValue:v,onChange:S,children:f}){const[_,O]=xt.useState(v??""),D=r??_,U=xt.useCallback(p=>{r===void 0&&O(p),S?.(p)},[r,S]),N=xt.useMemo(()=>({value:D,onChange:U}),[D,U]);return A.jsx(f0.Provider,{value:N,children:A.jsx("div",{children:typeof f=="function"?f({value:D,onChange:U}):f})})}function wh({children:r,className:v}){return A.jsx("div",{role:"tablist",className:Zt("bg-nb-gray-930/70 p-1.5 flex justify-center gap-1 border-nb-gray-900",v),children:r})}function Lh({children:r,value:v,disabled:S=!1,className:f,selected:_,onClick:O}){const D=r0(),U=_??v===D.value;let N="";U?N="bg-nb-gray-900 text-white":S||(N="text-nb-gray-400 hover:bg-nb-gray-900/50");const p=()=>{D.onChange(v),O?.()};return A.jsx("button",{role:"tab",type:"button",disabled:S,"aria-selected":U,onClick:p,className:Zt("px-4 py-2 text-sm rounded-md w-full transition-all cursor-pointer",S&&"opacity-30 cursor-not-allowed",N,f),children:A.jsx("div",{className:"flex items-center w-full justify-center gap-2",children:r})})}function Vh({children:r,value:v,className:S,visible:f}){const _=r0();return f??v===_.value?A.jsx("div",{role:"tabpanel",className:Zt("bg-nb-gray-930/70 px-4 pt-4 pb-5 rounded-b-md border border-t-0 border-nb-gray-900",S),children:r}):null}$e.List=wh;$e.Trigger=Lh;$e.Content=Vh;const Kh="/__netbird__/assets/netbird-full.svg",Jh="data:image/svg+xml,%3csvg%20width='31'%20height='23'%20viewBox='0%200%2031%2023'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20d='M21.4631%200.523438C17.8173%200.857913%2016.0028%202.95675%2015.3171%204.01871L4.66406%2022.4734H17.5163L30.1929%200.523438H21.4631Z'%20fill='%23F68330'/%3e%3cpath%20d='M17.5265%2022.4737L0%203.88525C0%203.88525%2019.8177%20-1.44128%2021.7493%2015.1738L17.5265%2022.4737Z'%20fill='%23F68330'/%3e%3cpath%20d='M14.9236%204.70563L9.54688%2014.0208L17.5158%2022.4747L21.7385%2015.158C21.0696%209.44682%2018.2851%206.32784%2014.9236%204.69727'%20fill='%23F05252'/%3e%3c/svg%3e",ti={small:{desktop:14,mobile:20},default:{desktop:22,mobile:30},large:{desktop:24,mobile:40}},kh=({size:r="default",mobile:v=!0})=>A.jsxs(A.Fragment,{children:[A.jsx("img",{src:Kh,height:ti[r].desktop,style:{height:ti[r].desktop},alt:"NetBird Logo",className:Zt(v&&"hidden md:block","group-hover:opacity-80 transition-all")}),v&&A.jsx("img",{src:Jh,width:ti[r].mobile,style:{width:ti[r].mobile},alt:"NetBird Logo",className:Zt(v&&"md:hidden ml-4")})]});function Uf(){return A.jsxs("a",{href:"https://netbird.io?utm_source=netbird-proxy&utm_medium=web&utm_campaign=powered_by",target:"_blank",rel:"noopener noreferrer",className:"flex items-center justify-center mt-8 gap-2 group cursor-pointer",children:[A.jsx("span",{className:"text-sm text-nb-gray-400 font-light text-center group-hover:opacity-80 transition-all",children:"Powered by"}),A.jsx(kh,{size:"small",mobile:!1})]})}const Wh=({className:r})=>A.jsx("div",{className:Zt("h-full w-full absolute left-0 top-0 rounded-md overflow-hidden z-0 pointer-events-none",r),children:A.jsx("div",{className:"bg-linear-to-b from-nb-gray-900/10 via-transparent to-transparent w-full h-full rounded-md"})}),Fd=({children:r,className:v})=>A.jsxs("div",{className:Zt("px-6 sm:px-10 py-10 pt-8","bg-nb-gray-940 border border-nb-gray-910 rounded-lg relative",v),children:[A.jsx(Wh,{}),r]});function Cf({children:r,className:v}){return A.jsx("h1",{className:Zt("text-xl! text-center z-10 relative",v),children:r})}function jf({children:r,className:v}){return A.jsx("div",{className:Zt("text-sm text-nb-gray-300 font-light mt-2 block text-center z-10 relative",v),children:r})}const $h=()=>A.jsxs("div",{className:"flex items-center justify-center relative my-4",children:[A.jsx("span",{className:"bg-nb-gray-940 relative z-10 px-4 text-xs text-nb-gray-400 font-medium",children:"OR"}),A.jsx("span",{className:"h-px bg-nb-gray-900 w-full absolute z-0"})]}),Fh=({error:r})=>A.jsx("div",{className:"text-red-400 bg-red-800/20 border border-red-800/50 rounded-lg px-4 py-3 whitespace-break-spaces text-sm",children:r});function Id({className:r,htmlFor:v,...S}){return A.jsx("label",{htmlFor:v,className:Zt("text-sm font-medium tracking-wider leading-none","peer-disabled:cursor-not-allowed peer-disabled:opacity-70","mb-2.5 inline-block text-nb-gray-200","flex items-center gap-2 select-none",r),...S})}const _f=t0(),Ft=_f.methods&&Object.keys(_f.methods).length>0?_f.methods:{password:"password",pin:"pin",oidc:"/auth/oidc"};function Ih(){xt.useEffect(()=>{document.title="Authentication Required - NetBird Service"},[]);const[r,v]=xt.useState(null),[S,f]=xt.useState(null),[_,O]=xt.useState(""),[D,U]=xt.useState(""),N=xt.useRef(null),p=xt.useRef(null),[R,H]=xt.useState(Ft.password?"password":"pin"),V=(it,Ot)=>{v(Ot),f(null),it==="password"?(U(""),setTimeout(()=>N.current?.focus(),200)):(O(""),setTimeout(()=>p.current?.focus(),200))},st=(it,Ot)=>{v(null),f(it);const J=new FormData;it==="password"?J.append(Ft.password,Ot):J.append(Ft.pin,Ot),fetch(globalThis.location.href,{method:"POST",body:J,redirect:"manual"}).then(Rt=>{Rt.type==="opaqueredirect"||Rt.status===0?(f("redirect"),globalThis.location.reload()):V(it,"Authentication failed. Please try again.")}).catch(()=>{V(it,"An error occurred. Please try again.")})},ct=it=>{O(it),it.length===6&&st("pin",it)},G=_.length===6,Q=D.length>0,L=S!==null||R==="password"&&!Q||R==="pin"&&!G,gt=Ft.password||Ft.pin,zt=Ft.password&&Ft.pin,_t=R==="password"?"Sign in":"Submit";return S==="redirect"?A.jsxs("main",{className:"mt-20",children:[A.jsxs(Fd,{className:"max-w-105 mx-auto",children:[A.jsx(Cf,{children:"Authenticated"}),A.jsx(jf,{children:"Loading service..."}),A.jsx("div",{className:"flex justify-center mt-7",children:A.jsx(kd,{className:"animate-spin",size:24})})]}),A.jsx(Uf,{})]}):A.jsxs("main",{className:"mt-20",children:[A.jsxs(Fd,{className:"max-w-105 mx-auto",children:[A.jsx(Cf,{children:"Authentication Required"}),A.jsx(jf,{children:"The service you are trying to access is protected. Please authenticate to continue."}),A.jsxs("div",{className:"flex flex-col gap-4 mt-7 z-10 relative",children:[r&&A.jsx(Fh,{error:r}),Ft.oidc&&A.jsxs(Ru,{variant:"primary",className:"w-full",onClick:()=>{globalThis.location.href=Ft.oidc},children:[A.jsx(Im,{size:16}),"Sign in with SSO"]}),Ft.oidc&>&&A.jsx($h,{}),gt&&A.jsxs("form",{onSubmit:it=>{it.preventDefault(),st(R,R==="password"?D:_)},children:[zt&&A.jsx($e,{value:R,onChange:it=>{H(it),setTimeout(()=>{it==="password"?N.current?.focus():p.current?.focus()},0)},children:A.jsxs($e.List,{className:"rounded-lg border mb-4",children:[A.jsxs($e.Trigger,{value:"password",children:[A.jsx(Fm,{size:14}),"Password"]}),A.jsxs($e.Trigger,{value:"pin",children:[A.jsx(Km,{size:14}),"PIN"]})]})}),A.jsxs("div",{className:"mb-4",children:[Ft.password&&(R==="password"||!Ft.pin)&&A.jsxs(A.Fragment,{children:[!zt&&A.jsx(Id,{htmlFor:"password",children:"Password"}),A.jsx(c0,{ref:N,type:"password",id:"password",placeholder:"Enter password",disabled:S!==null,showPasswordToggle:!0,autoFocus:!0,value:D,onChange:it=>U(it.target.value)})]}),Ft.pin&&(R==="pin"||!Ft.password)&&A.jsxs(A.Fragment,{children:[!zt&&A.jsx(Id,{htmlFor:"pin-0",children:"Enter PIN Code"}),A.jsx(Zh,{ref:p,value:_,onChange:ct,disabled:S!==null,autoFocus:!Ft.password})]})]}),A.jsx(Ru,{type:"submit",disabled:L,variant:"secondary",className:"w-full",children:S===null?_t:A.jsxs(A.Fragment,{children:[A.jsx(kd,{className:"animate-spin",size:16}),"Verifying..."]})})]})]})]}),A.jsx(Uf,{})]})}function Ph({success:r=!0}){return r?A.jsx("div",{className:"flex-1 flex items-center justify-center h-12 w-full px-5",children:A.jsx("div",{className:"w-full border-t-2 border-dashed border-green-500"})}):A.jsxs("div",{className:"flex-1 flex items-center justify-center h-12 min-w-10 px-5 relative",children:[A.jsx("div",{className:"w-full border-t-2 border-dashed border-nb-gray-900"}),A.jsx("div",{className:"absolute inset-0 flex items-center justify-center",children:A.jsx("div",{className:"w-8 h-8 rounded-full flex items-center justify-center",children:A.jsx(eh,{size:18,className:"text-netbird"})})})]})}function Of({icon:r,label:v,detail:S,success:f=!0,line:_=!0}){return A.jsxs(A.Fragment,{children:[_&&A.jsx(Ph,{success:f}),A.jsxs("div",{className:"flex flex-col items-center gap-2",children:[A.jsx("div",{className:"w-14 h-14 rounded-md flex items-center justify-center from-nb-gray-940 to-nb-gray-930/70 bg-gradient-to-br border border-nb-gray-910",children:A.jsx(r,{size:20,className:"text-nb-gray-200"})}),A.jsx("span",{className:"text-sm text-nb-gray-200 font-normal mt-1",children:v}),A.jsx("span",{className:`text-xs font-medium uppercase ${f?"text-green-500":"text-netbird"}`,children:f?"Connected":"Unreachable"}),S&&A.jsx("span",{className:"text-xs text-nb-gray-400 truncate text-center",children:S})]})]})}function tg({code:r,title:v,message:S,proxy:f=!0,destination:_=!0,requestId:O,simple:D=!1,retryUrl:U}){xt.useEffect(()=>{document.title=`${v} - NetBird Service`},[v]);const[N]=xt.useState(()=>new Date().toISOString());return A.jsxs("main",{className:"flex flex-col items-center mt-24 px-4 max-w-3xl mx-auto",children:[A.jsxs("div",{className:"text-sm text-netbird font-normal font-mono mb-3 z-10 relative",children:["Error ",r]}),A.jsx(Cf,{className:"text-3xl!",children:v}),A.jsx(jf,{className:"mt-2 mb-8 max-w-md",children:S}),!D&&A.jsxs("div",{className:"hidden sm:flex items-start justify-center w-full mt-6 mb-16 z-10 relative",children:[A.jsx(Of,{icon:th,label:"You",line:!1}),A.jsx(Of,{icon:lh,label:"Proxy",success:f}),A.jsx(Of,{icon:$m,label:"Destination",success:_})]}),A.jsxs("div",{className:"flex gap-3 justify-center items-center mb-6 z-10 relative",children:[A.jsxs(Ru,{variant:"primary",onClick:()=>{U?globalThis.location.href=U:globalThis.location.reload()},children:[A.jsx(Pm,{size:16}),"Refresh Page"]}),A.jsxs(Ru,{variant:"secondary",onClick:()=>globalThis.open("https://docs.netbird.io","_blank","noopener,noreferrer"),children:[A.jsx(Jm,{size:16}),"Documentation"]})]}),A.jsxs("div",{className:"text-center text-xs text-nb-gray-300 uppercase z-10 relative font-mono flex flex-col sm:flex-row gap-2 sm:gap-10 mt-4 mb-3",children:[A.jsxs("div",{children:[A.jsx("span",{className:"text-nb-gray-400",children:"REQUEST-ID:"})," ",O]}),A.jsxs("div",{children:[A.jsx("span",{className:"text-nb-gray-400",children:"TIMESTAMP:"})," ",N]})]}),A.jsx(Uf,{})]})}const Nf=t0();Zm.createRoot(document.getElementById("root")).render(A.jsx(xt.StrictMode,{children:Nf.page==="error"&&Nf.error?A.jsx(tg,{...Nf.error}):A.jsx(Ih,{})})); diff --git a/proxy/web/dist/assets/netbird-full.svg b/proxy/web/dist/assets/netbird-full.svg new file mode 100644 index 000000000..f925d5761 --- /dev/null +++ b/proxy/web/dist/assets/netbird-full.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/proxy/web/dist/assets/style.css b/proxy/web/dist/assets/style.css new file mode 100644 index 000000000..95a00c303 --- /dev/null +++ b/proxy/web/dist/assets/style.css @@ -0,0 +1 @@ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-100:#fde8e8;--color-red-400:#f98080;--color-red-500:#f05252;--color-red-600:#e02424;--color-red-700:#c81e1e;--color-red-800:#9b1c1c;--color-red-950:oklch(25.8% .092 26.042);--color-green-500:#0e9f6e;--color-gray-100:#f3f4f6;--color-gray-200:#e5e7eb;--color-gray-400:#9ca3af;--color-gray-500:#6b7280;--color-gray-700:#374151;--color-gray-800:#1f2937;--color-gray-900:#111827;--color-zinc-50:oklch(98.5% 0 0);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-800:oklch(27.4% .006 286.033);--color-neutral-200:oklch(92.2% 0 0);--color-neutral-400:oklch(70.8% 0 0);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-950:oklch(14.5% 0 0);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height: 1.2 ;--font-weight-light:300;--font-weight-normal:400;--font-weight-medium:500;--tracking-wide:.025em;--tracking-wider:.05em;--radius-md:.375rem;--radius-lg:.5rem;--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-nb-gray:#181a1d;--color-nb-gray-100:#e4e7e9;--color-nb-gray-200:#cbd2d6;--color-nb-gray-300:#aab4bd;--color-nb-gray-400:#7c8994;--color-nb-gray-500:#616e79;--color-nb-gray-700:#474e57;--color-nb-gray-800:#3f444b;--color-nb-gray-900:#32363d;--color-nb-gray-910:#2b2f33;--color-nb-gray-920:#25282d;--color-nb-gray-930:#25282c;--color-nb-gray-940:#1c1e21;--color-nb-gray-950:#181a1d;--color-netbird:#f68330;--color-netbird-400:#f68330;--color-netbird-500:#f46d1b;--color-netbird-600:#e55311}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.not-sr-only{clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:static;overflow:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing)*0)}.top-0{top:calc(var(--spacing)*0)}.right-0{right:calc(var(--spacing)*0)}.left-0{left:calc(var(--spacing)*0)}.isolate{isolation:isolate}.isolation-auto{isolation:auto}.z-0{z-index:0}.z-10{z-index:10}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.my-4{margin-block:calc(var(--spacing)*4)}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-6{margin-top:calc(var(--spacing)*6)}.mt-7{margin-top:calc(var(--spacing)*7)}.mt-8{margin-top:calc(var(--spacing)*8)}.mt-20{margin-top:calc(var(--spacing)*20)}.mt-24{margin-top:calc(var(--spacing)*24)}.mb-2\.5{margin-bottom:calc(var(--spacing)*2.5)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.mb-16{margin-bottom:calc(var(--spacing)*16)}.ml-4{margin-left:calc(var(--spacing)*4)}.block{display:block}.contents{display:contents}.flex{display:flex}.flow-root{display:flow-root}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.inline-grid{display:inline-grid}.inline-table{display:inline-table}.list-item{display:list-item}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-column{display:table-column}.table-column-group{display:table-column-group}.table-footer-group{display:table-footer-group}.table-header-group{display:table-header-group}.table-row{display:table-row}.table-row-group{display:table-row-group}.h-8{height:calc(var(--spacing)*8)}.h-12{height:calc(var(--spacing)*12)}.h-14{height:calc(var(--spacing)*14)}.h-\[42px\]{height:42px}.h-full{height:100%}.h-px{height:1px}.w-8{width:calc(var(--spacing)*8)}.w-14{width:calc(var(--spacing)*14)}.w-auto{width:auto}.w-full{width:100%}.max-w-3xl{max-width:var(--container-3xl)}.max-w-105{max-width:calc(var(--spacing)*105)}.max-w-md{max-width:var(--container-md)}.min-w-0{min-width:calc(var(--spacing)*0)}.min-w-10{min-width:calc(var(--spacing)*10)}.flex-1{flex:1}.shrink{flex-shrink:1}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.animate-spin{animation:var(--animate-spin)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.touch-pinch-zoom{--tw-pinch-zoom:pinch-zoom;touch-action:var(--tw-pan-x,)var(--tw-pan-y,)var(--tw-pinch-zoom,)}.resize{resize:both}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-center{justify-content:center}.gap-1{gap:calc(var(--spacing)*1)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}:where(.space-y-reverse>:not(:last-child)){--tw-space-y-reverse:1}:where(.space-x-reverse>:not(:last-child)){--tw-space-x-reverse:1}:where(.divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px*var(--tw-divide-x-reverse));border-inline-end-width:calc(1px*calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px*var(--tw-divide-y-reverse));border-bottom-width:calc(1px*calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-y-reverse>:not(:last-child)){--tw-divide-y-reverse:1}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-s{border-start-start-radius:.25rem;border-end-start-radius:.25rem}.rounded-ss{border-start-start-radius:.25rem}.rounded-e{border-start-end-radius:.25rem;border-end-end-radius:.25rem}.rounded-se{border-start-end-radius:.25rem}.rounded-ee{border-end-end-radius:.25rem}.rounded-es{border-end-start-radius:.25rem}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.\!rounded-l-none{border-top-left-radius:0!important;border-bottom-left-radius:0!important}.rounded-l{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-l-md{border-top-left-radius:var(--radius-md);border-bottom-left-radius:var(--radius-md)}.rounded-tl{border-top-left-radius:.25rem}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-tr{border-top-right-radius:.25rem}.rounded-b{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-b-md{border-bottom-right-radius:var(--radius-md);border-bottom-left-radius:var(--radius-md)}.rounded-br{border-bottom-right-radius:.25rem}.rounded-bl{border-bottom-left-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border-x{border-inline-style:var(--tw-border-style);border-inline-width:1px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-s{border-inline-start-style:var(--tw-border-style);border-inline-start-width:1px}.border-e{border-inline-end-style:var(--tw-border-style);border-inline-end-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.border-t-2{border-top-style:var(--tw-border-style);border-top-width:2px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.\!border-l-0{border-left-style:var(--tw-border-style)!important;border-left-width:0!important}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-gray-200{border-color:var(--color-gray-200)}.border-green-500{border-color:var(--color-green-500)}.border-nb-gray-700{border-color:var(--color-nb-gray-700)}.border-nb-gray-800{border-color:var(--color-nb-gray-800)}.border-nb-gray-900{border-color:var(--color-nb-gray-900)}.border-nb-gray-910{border-color:var(--color-nb-gray-910)}.border-neutral-200{border-color:var(--color-neutral-200)}.border-red-500{border-color:var(--color-red-500)}.border-red-800\/50{border-color:#9b1c1c80}@supports (color:color-mix(in lab,red,red)){.border-red-800\/50{border-color:color-mix(in oklab,var(--color-red-800)50%,transparent)}}.border-transparent{border-color:#0000}.border-white{border-color:var(--color-white)}.bg-nb-gray-900{background-color:var(--color-nb-gray-900)}.bg-nb-gray-920{background-color:var(--color-nb-gray-920)}.bg-nb-gray-930\/70{background-color:#25282cb3}@supports (color:color-mix(in lab,red,red)){.bg-nb-gray-930\/70{background-color:color-mix(in oklab,var(--color-nb-gray-930)70%,transparent)}}.bg-nb-gray-940{background-color:var(--color-nb-gray-940)}.bg-red-800\/20{background-color:#9b1c1c33}@supports (color:color-mix(in lab,red,red)){.bg-red-800\/20{background-color:color-mix(in oklab,var(--color-red-800)20%,transparent)}}.bg-white{background-color:var(--color-white)}.bg-linear-to-b{--tw-gradient-position:to bottom}@supports (background-image:linear-gradient(in lab,red,red)){.bg-linear-to-b{--tw-gradient-position:to bottom in oklab}}.bg-linear-to-b{background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-br{--tw-gradient-position:to bottom right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-nb-gray-900\/10{--tw-gradient-from:#32363d1a}@supports (color:color-mix(in lab,red,red)){.from-nb-gray-900\/10{--tw-gradient-from:color-mix(in oklab,var(--color-nb-gray-900)10%,transparent)}}.from-nb-gray-900\/10{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.from-nb-gray-940{--tw-gradient-from:var(--color-nb-gray-940);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.via-transparent{--tw-gradient-via:transparent;--tw-gradient-via-stops:var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-via)var(--tw-gradient-via-position),var(--tw-gradient-to)var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.to-nb-gray-930\/70{--tw-gradient-to:#25282cb3}@supports (color:color-mix(in lab,red,red)){.to-nb-gray-930\/70{--tw-gradient-to:color-mix(in oklab,var(--color-nb-gray-930)70%,transparent)}}.to-nb-gray-930\/70{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.to-transparent{--tw-gradient-to:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position),var(--tw-gradient-from)var(--tw-gradient-from-position),var(--tw-gradient-to)var(--tw-gradient-to-position))}.bg-repeat{background-repeat:repeat}.p-1\.5{padding:calc(var(--spacing)*1.5)}.\!px-0{padding-inline:calc(var(--spacing)*0)!important}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-5{padding-inline:calc(var(--spacing)*5)}.px-6{padding-inline:calc(var(--spacing)*6)}.\!py-0{padding-block:calc(var(--spacing)*0)!important}.py-2{padding-block:calc(var(--spacing)*2)}.py-2\.5{padding-block:calc(var(--spacing)*2.5)}.py-3{padding-block:calc(var(--spacing)*3)}.py-10{padding-block:calc(var(--spacing)*10)}.pt-4{padding-top:calc(var(--spacing)*4)}.pt-8{padding-top:calc(var(--spacing)*8)}.\!pr-16{padding-right:calc(var(--spacing)*16)!important}.pr-4{padding-right:calc(var(--spacing)*4)}.pb-5{padding-bottom:calc(var(--spacing)*5)}.\!pl-10{padding-left:calc(var(--spacing)*10)!important}.pl-3{padding-left:calc(var(--spacing)*3)}.text-center{text-align:center}.font-mono{font-family:var(--font-mono)}.text-3xl\!{font-size:var(--text-3xl)!important;line-height:var(--tw-leading,var(--text-3xl--line-height))!important}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl\!{font-size:var(--text-xl)!important;line-height:var(--tw-leading,var(--text-xl--line-height))!important}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[\.8rem\]{font-size:.8rem}.text-\[0\.78rem\]{font-size:.78rem}.leading-\[0\]{--tw-leading:0;line-height:0}.leading-none{--tw-leading:1;line-height:1}.font-light{--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.text-wrap{text-wrap:wrap}.text-clip{text-overflow:clip}.text-ellipsis{text-overflow:ellipsis}.whitespace-break-spaces{white-space:break-spaces}.whitespace-nowrap{white-space:nowrap}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-500{color:var(--color-green-500)}.text-nb-gray-200{color:var(--color-nb-gray-200)}.text-nb-gray-300{color:var(--color-nb-gray-300)}.text-nb-gray-400{color:var(--color-nb-gray-400)}.text-netbird{color:var(--color-netbird)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.not-italic{font-style:normal}.diagonal-fractions{--tw-numeric-fraction:diagonal-fractions;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.lining-nums{--tw-numeric-figure:lining-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.oldstyle-nums{--tw-numeric-figure:oldstyle-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.proportional-nums{--tw-numeric-spacing:proportional-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.slashed-zero{--tw-slashed-zero:slashed-zero;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.stacked-fractions{--tw-numeric-fraction:stacked-fractions;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.normal-nums{font-variant-numeric:normal}.line-through{text-decoration-line:line-through}.no-underline{text-decoration-line:none}.overline{text-decoration-line:overline}.underline{text-decoration-line:underline}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.subpixel-antialiased{-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.\!shadow-none{--tw-shadow:0 0 #0000!important;box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)!important}.shadow,.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-offset-neutral-200\/20{--tw-ring-offset-color:#e5e5e533}@supports (color:color-mix(in lab,red,red)){.ring-offset-neutral-200\/20{--tw-ring-offset-color:color-mix(in oklab,var(--color-neutral-200)20%,transparent)}}.ring-offset-neutral-950\/50{--tw-ring-offset-color:#0a0a0a80}@supports (color:color-mix(in lab,red,red)){.ring-offset-neutral-950\/50{--tw-ring-offset-color:color-mix(in oklab,var(--color-neutral-950)50%,transparent)}}.ring-offset-red-500\/10{--tw-ring-offset-color:#f052521a}@supports (color:color-mix(in lab,red,red)){.ring-offset-red-500\/10{--tw-ring-offset-color:color-mix(in oklab,var(--color-red-500)10%,transparent)}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.drop-shadow{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#0000001a))drop-shadow(0 1px 1px var(--tw-drop-shadow-color,#0000000f));--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a)drop-shadow(0 1px 1px #0000000f);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.grayscale{--tw-grayscale:grayscale(100%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.sepia{--tw-sepia:sepia(100%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-grayscale{--tw-backdrop-grayscale:grayscale(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-invert{--tw-backdrop-invert:invert(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-sepia{--tw-backdrop-sepia:sepia(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}:where(.divide-x-reverse>:not(:last-child)){--tw-divide-x-reverse:1}.ring-inset{--tw-ring-inset:inset}@media(hover:hover){.group-hover\:opacity-80:is(:where(.group):hover *){opacity:.8}}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-70:is(:where(.peer):disabled~*){opacity:.7}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.placeholder\:text-neutral-400\/70::placeholder{color:#a1a1a1b3}@supports (color:color-mix(in lab,red,red)){.placeholder\:text-neutral-400\/70::placeholder{color:color-mix(in oklab,var(--color-neutral-400)70%,transparent)}}@media(hover:hover){.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:bg-nb-gray-900\/50:hover{background-color:#32363d80}@supports (color:color-mix(in lab,red,red)){.hover\:bg-nb-gray-900\/50:hover{background-color:color-mix(in oklab,var(--color-nb-gray-900)50%,transparent)}}.hover\:bg-neutral-200:hover{background-color:var(--color-neutral-200)}.hover\:text-black:hover{color:var(--color-black)}.hover\:text-white:hover{color:var(--color-white)}}.focus\:z-10:focus{z-index:10}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-red-500\/30:focus{--tw-ring-color:#f052524d}@supports (color:color-mix(in lab,red,red)){.focus\:ring-red-500\/30:focus{--tw-ring-color:color-mix(in oklab,var(--color-red-500)30%,transparent)}}.focus\:ring-white\/50:focus{--tw-ring-color:#ffffff80}@supports (color:color-mix(in lab,red,red)){.focus\:ring-white\/50:focus{--tw-ring-color:color-mix(in oklab,var(--color-white)50%,transparent)}}.focus\:ring-zinc-200\/50:focus{--tw-ring-color:#e4e4e780}@supports (color:color-mix(in lab,red,red)){.focus\:ring-zinc-200\/50:focus{--tw-ring-color:color-mix(in oklab,var(--color-zinc-200)50%,transparent)}}.focus\:ring-offset-1:focus{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-neutral-500\/20:focus-visible{--tw-ring-color:#73737333}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-neutral-500\/20:focus-visible{--tw-ring-color:color-mix(in oklab,var(--color-neutral-500)20%,transparent)}}.focus-visible\:ring-red-500\/10:focus-visible{--tw-ring-color:#f052521a}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-red-500\/10:focus-visible{--tw-ring-color:color-mix(in oklab,var(--color-red-500)10%,transparent)}}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.enabled\:bg-netbird:enabled{background-color:var(--color-netbird)}.enabled\:text-white:enabled{color:var(--color-white)}@media(hover:hover){.enabled\:hover\:bg-netbird-500:enabled:hover{background-color:var(--color-netbird-500)}}.enabled\:focus\:ring-netbird-400\/50:enabled:focus{--tw-ring-color:#f6833080}@supports (color:color-mix(in lab,red,red)){.enabled\:focus\:ring-netbird-400\/50:enabled:focus{--tw-ring-color:color-mix(in oklab,var(--color-netbird-400)50%,transparent)}}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:text-nb-gray-300:disabled{color:var(--color-nb-gray-300)}.disabled\:opacity-40:disabled{opacity:.4}@media(min-width:40rem){.sm\:flex{display:flex}.sm\:flex-row{flex-direction:row}.sm\:gap-10{gap:calc(var(--spacing)*10)}.sm\:px-10{padding-inline:calc(var(--spacing)*10)}}@media(min-width:48rem){.md\:block{display:block}.md\:hidden{display:none}}.dark\:border-gray-500\/40:where(.dark,.dark *){border-color:#6b728066}@supports (color:color-mix(in lab,red,red)){.dark\:border-gray-500\/40:where(.dark,.dark *){border-color:color-mix(in oklab,var(--color-gray-500)40%,transparent)}}.dark\:border-gray-700\/30:where(.dark,.dark *){border-color:#3741514d}@supports (color:color-mix(in lab,red,red)){.dark\:border-gray-700\/30:where(.dark,.dark *){border-color:color-mix(in oklab,var(--color-gray-700)30%,transparent)}}.dark\:border-gray-700\/40:where(.dark,.dark *){border-color:#37415166}@supports (color:color-mix(in lab,red,red)){.dark\:border-gray-700\/40:where(.dark,.dark *){border-color:color-mix(in oklab,var(--color-gray-700)40%,transparent)}}.dark\:border-gray-700\/70:where(.dark,.dark *){border-color:#374151b3}@supports (color:color-mix(in lab,red,red)){.dark\:border-gray-700\/70:where(.dark,.dark *){border-color:color-mix(in oklab,var(--color-gray-700)70%,transparent)}}.dark\:border-nb-gray-700:where(.dark,.dark *){border-color:var(--color-nb-gray-700)}.dark\:border-nb-gray-900:where(.dark,.dark *){border-color:var(--color-nb-gray-900)}.dark\:border-netbird:where(.dark,.dark *){border-color:var(--color-netbird)}.dark\:border-transparent:where(.dark,.dark *){border-color:#0000}.dark\:bg-nb-gray:where(.dark,.dark *){background-color:var(--color-nb-gray)}.dark\:bg-nb-gray-900:where(.dark,.dark *){background-color:var(--color-nb-gray-900)}.dark\:bg-nb-gray-900\/30:where(.dark,.dark *){background-color:#32363d4d}@supports (color:color-mix(in lab,red,red)){.dark\:bg-nb-gray-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-nb-gray-900)30%,transparent)}}.dark\:bg-nb-gray-900\/40:where(.dark,.dark *){background-color:#32363d66}@supports (color:color-mix(in lab,red,red)){.dark\:bg-nb-gray-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-nb-gray-900)40%,transparent)}}.dark\:bg-nb-gray-900\/70:where(.dark,.dark *){background-color:#32363db3}@supports (color:color-mix(in lab,red,red)){.dark\:bg-nb-gray-900\/70:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-nb-gray-900)70%,transparent)}}.dark\:bg-nb-gray-920:where(.dark,.dark *){background-color:var(--color-nb-gray-920)}.dark\:bg-red-600:where(.dark,.dark *){background-color:var(--color-red-600)}.dark\:bg-transparent:where(.dark,.dark *){background-color:#0000}.dark\:bg-white:where(.dark,.dark *){background-color:var(--color-white)}.dark\:text-gray-100:where(.dark,.dark *){color:var(--color-gray-100)}.dark\:text-gray-400:where(.dark,.dark *){color:var(--color-gray-400)}.dark\:text-gray-800:where(.dark,.dark *){color:var(--color-gray-800)}.dark\:text-nb-gray-400:where(.dark,.dark *){color:var(--color-nb-gray-400)}.dark\:text-netbird:where(.dark,.dark *){color:var(--color-netbird)}.dark\:text-red-100:where(.dark,.dark *){color:var(--color-red-100)}.dark\:text-red-500:where(.dark,.dark *){color:var(--color-red-500)}.dark\:ring-offset-nb-gray-950\/50:where(.dark,.dark *){--tw-ring-offset-color:#181a1d80}@supports (color:color-mix(in lab,red,red)){.dark\:ring-offset-nb-gray-950\/50:where(.dark,.dark *){--tw-ring-offset-color:color-mix(in oklab,var(--color-nb-gray-950)50%,transparent)}}.dark\:ring-offset-neutral-950\/50:where(.dark,.dark *){--tw-ring-offset-color:#0a0a0a80}@supports (color:color-mix(in lab,red,red)){.dark\:ring-offset-neutral-950\/50:where(.dark,.dark *){--tw-ring-offset-color:color-mix(in oklab,var(--color-neutral-950)50%,transparent)}}.dark\:placeholder\:text-neutral-400\/70:where(.dark,.dark *)::placeholder{color:#a1a1a1b3}@supports (color:color-mix(in lab,red,red)){.dark\:placeholder\:text-neutral-400\/70:where(.dark,.dark *)::placeholder{color:color-mix(in oklab,var(--color-neutral-400)70%,transparent)}}@media(hover:hover){.dark\:hover\:border-nb-gray-800\/50:where(.dark,.dark *):hover{border-color:#3f444b80}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:border-nb-gray-800\/50:where(.dark,.dark *):hover{border-color:color-mix(in oklab,var(--color-nb-gray-800)50%,transparent)}}.dark\:hover\:border-red-800\/50:where(.dark,.dark *):hover{border-color:#9b1c1c80}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:border-red-800\/50:where(.dark,.dark *):hover{border-color:color-mix(in oklab,var(--color-red-800)50%,transparent)}}.dark\:hover\:bg-nb-gray-800\/60:where(.dark,.dark *):hover{background-color:#3f444b99}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-nb-gray-800\/60:where(.dark,.dark *):hover{background-color:color-mix(in oklab,var(--color-nb-gray-800)60%,transparent)}}.dark\:hover\:bg-nb-gray-900\/30:where(.dark,.dark *):hover{background-color:#32363d4d}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-nb-gray-900\/30:where(.dark,.dark *):hover{background-color:color-mix(in oklab,var(--color-nb-gray-900)30%,transparent)}}.dark\:hover\:bg-nb-gray-900\/50:where(.dark,.dark *):hover{background-color:#32363d80}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-nb-gray-900\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab,var(--color-nb-gray-900)50%,transparent)}}.dark\:hover\:bg-nb-gray-900\/80:where(.dark,.dark *):hover{background-color:#32363dcc}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-nb-gray-900\/80:where(.dark,.dark *):hover{background-color:color-mix(in oklab,var(--color-nb-gray-900)80%,transparent)}}.dark\:hover\:bg-nb-gray-910:where(.dark,.dark *):hover{background-color:var(--color-nb-gray-910)}.dark\:hover\:bg-neutral-200:where(.dark,.dark *):hover{background-color:var(--color-neutral-200)}.dark\:hover\:bg-zinc-800\/50:where(.dark,.dark *):hover{background-color:#27272a80}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-zinc-800\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab,var(--color-zinc-800)50%,transparent)}}.hover\:dark\:bg-red-700:hover:where(.dark,.dark *){background-color:var(--color-red-700)}.dark\:hover\:text-red-600:where(.dark,.dark *):hover{color:var(--color-red-600)}.dark\:hover\:text-white:where(.dark,.dark *):hover{color:var(--color-white)}}.dark\:focus\:bg-red-700:where(.dark,.dark *):focus{background-color:var(--color-red-700)}.dark\:focus\:ring-nb-gray-500\/20:where(.dark,.dark *):focus{--tw-ring-color:#616e7933}@supports (color:color-mix(in lab,red,red)){.dark\:focus\:ring-nb-gray-500\/20:where(.dark,.dark *):focus{--tw-ring-color:color-mix(in oklab,var(--color-nb-gray-500)20%,transparent)}}.dark\:focus\:ring-netbird-600\/50:where(.dark,.dark *):focus{--tw-ring-color:#e5531180}@supports (color:color-mix(in lab,red,red)){.dark\:focus\:ring-netbird-600\/50:where(.dark,.dark *):focus{--tw-ring-color:color-mix(in oklab,var(--color-netbird-600)50%,transparent)}}.dark\:focus\:ring-neutral-500\/20:where(.dark,.dark *):focus{--tw-ring-color:#73737333}@supports (color:color-mix(in lab,red,red)){.dark\:focus\:ring-neutral-500\/20:where(.dark,.dark *):focus{--tw-ring-color:color-mix(in oklab,var(--color-neutral-500)20%,transparent)}}.dark\:focus\:ring-red-700\/20:where(.dark,.dark *):focus{--tw-ring-color:#c81e1e33}@supports (color:color-mix(in lab,red,red)){.dark\:focus\:ring-red-700\/20:where(.dark,.dark *):focus{--tw-ring-color:color-mix(in oklab,var(--color-red-700)20%,transparent)}}.dark\:focus\:ring-zinc-800\/50:where(.dark,.dark *):focus{--tw-ring-color:#27272a80}@supports (color:color-mix(in lab,red,red)){.dark\:focus\:ring-zinc-800\/50:where(.dark,.dark *):focus{--tw-ring-color:color-mix(in oklab,var(--color-zinc-800)50%,transparent)}}.dark\:focus-visible\:ring-neutral-500\/20:where(.dark,.dark *):focus-visible{--tw-ring-color:#73737333}@supports (color:color-mix(in lab,red,red)){.dark\:focus-visible\:ring-neutral-500\/20:where(.dark,.dark *):focus-visible{--tw-ring-color:color-mix(in oklab,var(--color-neutral-500)20%,transparent)}}.enabled\:dark\:bg-netbird:enabled:where(.dark,.dark *){background-color:var(--color-netbird)}@media(hover:hover){.enabled\:dark\:hover\:border-red-800\/50:enabled:where(.dark,.dark *):hover{border-color:#9b1c1c80}@supports (color:color-mix(in lab,red,red)){.enabled\:dark\:hover\:border-red-800\/50:enabled:where(.dark,.dark *):hover{border-color:color-mix(in oklab,var(--color-red-800)50%,transparent)}}.enabled\:dark\:hover\:bg-netbird-500\/80:enabled:where(.dark,.dark *):hover{background-color:#f46d1bcc}@supports (color:color-mix(in lab,red,red)){.enabled\:dark\:hover\:bg-netbird-500\/80:enabled:where(.dark,.dark *):hover{background-color:color-mix(in oklab,var(--color-netbird-500)80%,transparent)}}.enabled\:hover\:dark\:bg-red-950\/50:enabled:hover:where(.dark,.dark *){background-color:#46080980}@supports (color:color-mix(in lab,red,red)){.enabled\:hover\:dark\:bg-red-950\/50:enabled:hover:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-red-950)50%,transparent)}}.enabled\:dark\:hover\:text-white:enabled:where(.dark,.dark *):hover{color:var(--color-white)}}.enabled\:dark\:focus\:bg-red-950\/40:enabled:where(.dark,.dark *):focus{background-color:#46080966}@supports (color:color-mix(in lab,red,red)){.enabled\:dark\:focus\:bg-red-950\/40:enabled:where(.dark,.dark *):focus{background-color:color-mix(in oklab,var(--color-red-950)40%,transparent)}}.enabled\:dark\:focus\:ring-red-800\/20:enabled:where(.dark,.dark *):focus{--tw-ring-color:#9b1c1c33}@supports (color:color-mix(in lab,red,red)){.enabled\:dark\:focus\:ring-red-800\/20:enabled:where(.dark,.dark *):focus{--tw-ring-color:color-mix(in oklab,var(--color-red-800)20%,transparent)}}.disabled\:dark\:border-nb-gray-900:disabled:where(.dark,.dark *){border-color:var(--color-nb-gray-900)}.disabled\:dark\:bg-nb-gray-900:disabled:where(.dark,.dark *){background-color:var(--color-nb-gray-900)}.disabled\:dark\:bg-nb-gray-910:disabled:where(.dark,.dark *){background-color:var(--color-nb-gray-910)}.disabled\:dark\:bg-nb-gray-920:disabled:where(.dark,.dark *){background-color:var(--color-nb-gray-920)}.disabled\:dark\:text-nb-gray-300:disabled:where(.dark,.dark *){color:var(--color-nb-gray-300)}.data-\[state\=open\]\:dark\:border-nb-gray-800\/50[data-state=open]:where(.dark,.dark *){border-color:#3f444b80}@supports (color:color-mix(in lab,red,red)){.data-\[state\=open\]\:dark\:border-nb-gray-800\/50[data-state=open]:where(.dark,.dark *){border-color:color-mix(in oklab,var(--color-nb-gray-800)50%,transparent)}}.data-\[state\=open\]\:dark\:bg-nb-gray-900\/30[data-state=open]:where(.dark,.dark *){background-color:#32363d4d}@supports (color:color-mix(in lab,red,red)){.data-\[state\=open\]\:dark\:bg-nb-gray-900\/30[data-state=open]:where(.dark,.dark *){background-color:color-mix(in oklab,var(--color-nb-gray-900)30%,transparent)}}.data-\[state\=open\]\:dark\:text-white[data-state=open]:where(.dark,.dark *){color:var(--color-white)}}@font-face{font-family:Inter;font-style:normal;font-weight:100 900;font-display:swap;src:url(/__netbird__/assets/Inter-VariableFont_opsz_wght.ttf)format("truetype")}@font-face{font-family:Inter;font-style:italic;font-weight:100 900;font-display:swap;src:url(/__netbird__/assets/Inter-Italic-VariableFont_opsz_wght.ttf)format("truetype")}:root{--nb-bg:#18191d;--nb-card-bg:#1b1f22;--nb-border:#32363d80;--nb-text:#e4e7e9;--nb-text-muted:#a7b1b9cc;--nb-primary:#f68330;--nb-primary-hover:#e5722a;--nb-input-bg:#3f444b80;--nb-input-border:#3f444bcc;--nb-error-bg:#991b1b33;--nb-error-border:#991b1b80;--nb-error-text:#f87171}html{color-scheme:dark;background-color:var(--color-nb-gray)}html.dark,:root{color-scheme:dark}body{font-family:Inter,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji}h1{margin-block:calc(var(--spacing)*1);font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700)}h1:where(.dark,.dark *){color:var(--color-nb-gray-100)}h2{margin-block:calc(var(--spacing)*1);font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-gray-700)}h2:where(.dark,.dark *){color:var(--color-nb-gray-100)}p{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-light);font-weight:var(--font-weight-light);--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide);color:var(--color-gray-700)}p:where(.dark,.dark *){color:var(--color-zinc-50)}[placeholder]{text-overflow:ellipsis}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-pan-x{syntax:"*";inherits:false}@property --tw-pan-y{syntax:"*";inherits:false}@property --tw-pinch-zoom{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}} diff --git a/proxy/web/dist/index.html b/proxy/web/dist/index.html new file mode 100644 index 000000000..ea253a77d --- /dev/null +++ b/proxy/web/dist/index.html @@ -0,0 +1,19 @@ + + + + + + + NetBird Service + + + + + + + +
                                                + + diff --git a/proxy/web/dist/robots.txt b/proxy/web/dist/robots.txt new file mode 100644 index 000000000..1f53798bb --- /dev/null +++ b/proxy/web/dist/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/proxy/web/eslint.config.js b/proxy/web/eslint.config.js new file mode 100644 index 000000000..5e6b472f5 --- /dev/null +++ b/proxy/web/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/proxy/web/index.html b/proxy/web/index.html new file mode 100644 index 000000000..e41f24f38 --- /dev/null +++ b/proxy/web/index.html @@ -0,0 +1,18 @@ + + + + + + + NetBird Service + + + + + +
                                                + + + diff --git a/proxy/web/package-lock.json b/proxy/web/package-lock.json new file mode 100644 index 000000000..1611323a7 --- /dev/null +++ b/proxy/web/package-lock.json @@ -0,0 +1,3952 @@ +{ + "name": "web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.0", + "dependencies": { + "clsx": "^2.1.1", + "lucide-react": "^0.468.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "tailwind-merge": "^2.6.0" + }, + "devDependencies": { + "@eslint/js": "9.39.2", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "tailwindcss": "^4.1.18", + "tsx": "^4.21.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "7.3.2" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz", + "integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.10.tgz", + "integrity": "sha512-+0/4J266CBGPUq/ELg7QUHhN25WYjE0wYTPSQJn1xeu8DOlIOPxXxrNGiLmfAWl7HMMgWFWXpt9IDjMWrF5Iow==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", + "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", + "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/type-utils": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.54.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", + "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", + "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.54.0", + "@typescript-eslint/types": "^8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", + "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", + "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", + "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", + "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", + "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.54.0", + "@typescript-eslint/tsconfig-utils": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", + "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", + "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz", + "integrity": "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.2", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001767", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz", + "integrity": "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.1.tgz", + "integrity": "sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz", + "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.54.0", + "@typescript-eslint/parser": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/proxy/web/package.json b/proxy/web/package.json new file mode 100644 index 000000000..9a7c84ed4 --- /dev/null +++ b/proxy/web/package.json @@ -0,0 +1,36 @@ +{ + "name": "web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "clsx": "^2.1.1", + "lucide-react": "^0.468.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "tailwind-merge": "^2.6.0" + }, + "devDependencies": { + "@eslint/js": "9.39.2", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "tailwindcss": "^4.1.18", + "tsx": "^4.21.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "7.3.2" + } +} diff --git a/proxy/web/public/robots.txt b/proxy/web/public/robots.txt new file mode 100644 index 000000000..1f53798bb --- /dev/null +++ b/proxy/web/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/proxy/web/src/App.tsx b/proxy/web/src/App.tsx new file mode 100644 index 000000000..ab453aa3e --- /dev/null +++ b/proxy/web/src/App.tsx @@ -0,0 +1,227 @@ +import { useState, useRef, useEffect } from "react"; +import {Loader2, Lock, Binary, LogIn} from "lucide-react"; +import { getData, type Data } from "@/data"; +import Button from "@/components/Button"; +import { Input } from "@/components/Input"; +import PinCodeInput, { type PinCodeInputRef } from "@/components/PinCodeInput"; +import { SegmentedTabs } from "@/components/SegmentedTabs"; +import { PoweredByNetBird } from "@/components/PoweredByNetBird"; +import { Card } from "@/components/Card"; +import { Title } from "@/components/Title"; +import { Description } from "@/components/Description"; +import { Separator } from "@/components/Separator"; +import { ErrorMessage } from "@/components/ErrorMessage"; +import { Label } from "@/components/Label"; + +const data = getData(); + +// For testing, show all methods if none are configured +const methods: NonNullable = + data.methods && Object.keys(data.methods).length > 0 + ? data.methods + : { password:"password", pin: "pin", oidc: "/auth/oidc" }; + +function App() { + useEffect(() => { + document.title = "Authentication Required - NetBird Service"; + }, []); + + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(null); + const [pin, setPin] = useState(""); + const [password, setPassword] = useState(""); + const passwordRef = useRef(null); + const pinRef = useRef(null); + const [activeTab, setActiveTab] = useState<"password" | "pin">( + methods.password ? "password" : "pin" + ); + + const handleAuthError = (method: "password" | "pin", message: string) => { + setError(message); + setSubmitting(null); + if (method === "password") { + setPassword(""); + setTimeout(() => passwordRef.current?.focus(), 200); + } else { + setPin(""); + setTimeout(() => pinRef.current?.focus(), 200); + } + }; + + const submitCredentials = (method: "password" | "pin", value: string) => { + setError(null); + setSubmitting(method); + + const formData = new FormData(); + if (method === "password") { + formData.append(methods.password!, value); + } else { + formData.append(methods.pin!, value); + } + + fetch(globalThis.location.href, { + method: "POST", + body: formData, + redirect: "manual", + }) + .then((res) => { + if (res.type === "opaqueredirect" || res.status === 0) { + setSubmitting("redirect"); + globalThis.location.reload(); + } else { + handleAuthError(method, "Authentication failed. Please try again."); + } + }) + .catch(() => { + handleAuthError(method, "An error occurred. Please try again."); + }); + }; + + const handlePinChange = (value: string) => { + setPin(value); + if (value.length === 6) { + submitCredentials("pin", value); + } + }; + + const isPinComplete = pin.length === 6; + const isPasswordEntered = password.length > 0; + const isButtonDisabled = submitting !== null || + (activeTab === "password" && !isPasswordEntered) || + (activeTab === "pin" && !isPinComplete); + + const hasCredentialAuth = methods.password || methods.pin; + const hasBothCredentials = methods.password && methods.pin; + const buttonLabel = activeTab === "password" ? "Sign in" : "Submit"; + + if (submitting === "redirect") { + return ( +
                                                + + Authenticated + Loading service... +
                                                + +
                                                +
                                                + +
                                                + ); + } + + return ( +
                                                + + Authentication Required + + The service you are trying to access is protected. Please authenticate to continue. + + +
                                                + {error && } + + {/* SSO Button */} + {methods.oidc && ( + + )} + + {/* Separator */} + {methods.oidc && hasCredentialAuth && } + + {/* Credential Authentication */} + {hasCredentialAuth && ( +
                                                { + e.preventDefault(); + submitCredentials(activeTab, activeTab === "password" ? password : pin); + }}> + {hasBothCredentials && ( + { + setActiveTab(v as "password" | "pin"); + setTimeout(() => { + if (v === "password") { + passwordRef.current?.focus(); + } else { + pinRef.current?.focus(); + } + }, 0); + }} + > + + + + Password + + + + PIN + + + + )} + +
                                                + {methods.password && (activeTab === "password" || !methods.pin) && ( + <> + {!hasBothCredentials && } + setPassword(e.target.value)} + /> + + )} + {methods.pin && (activeTab === "pin" || !methods.password) && ( + <> + {!hasBothCredentials && } + + + )} +
                                                + + +
                                                + )} +
                                                +
                                                + + +
                                                + ); +} + +export default App; diff --git a/proxy/web/src/ErrorPage.tsx b/proxy/web/src/ErrorPage.tsx new file mode 100644 index 000000000..c3120d9a1 --- /dev/null +++ b/proxy/web/src/ErrorPage.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from "react"; +import {BookText, RotateCw, Globe, UserIcon, WaypointsIcon} from "lucide-react"; +import { Title } from "@/components/Title"; +import { Description } from "@/components/Description"; +import Button from "@/components/Button"; +import { PoweredByNetBird } from "@/components/PoweredByNetBird"; +import { StatusCard } from "@/components/StatusCard"; +import type { ErrorData } from "@/data"; + +export function ErrorPage({ code, title, message, proxy = true, destination = true, requestId, simple = false, retryUrl }: Readonly) { + useEffect(() => { + document.title = `${title} - NetBird Service`; + }, [title]); + + const [timestamp] = useState(() => new Date().toISOString()); + + return ( +
                                                + {/* Error Code */} +
                                                + Error {code} +
                                                + + {/* Title */} + {title} + + {/* Description */} + {message} + + {/* Status Cards - hidden in simple mode */} + {!simple && ( +
                                                + + + +
                                                + )} + + {/* Buttons */} +
                                                + + +
                                                + + {/* Request Info */} +
                                                +
                                                + REQUEST-ID: {requestId} +
                                                +
                                                + TIMESTAMP: {timestamp} +
                                                +
                                                + + +
                                                + ); +} diff --git a/proxy/web/src/assets/favicon.ico b/proxy/web/src/assets/favicon.ico new file mode 100644 index 000000000..50bb80966 Binary files /dev/null and b/proxy/web/src/assets/favicon.ico differ diff --git a/proxy/web/src/assets/fonts/Inter-Italic-VariableFont_opsz,wght.ttf b/proxy/web/src/assets/fonts/Inter-Italic-VariableFont_opsz,wght.ttf new file mode 100644 index 000000000..43ed4f5ee Binary files /dev/null and b/proxy/web/src/assets/fonts/Inter-Italic-VariableFont_opsz,wght.ttf differ diff --git a/proxy/web/src/assets/fonts/Inter-VariableFont_opsz,wght.ttf b/proxy/web/src/assets/fonts/Inter-VariableFont_opsz,wght.ttf new file mode 100644 index 000000000..e31b51e3e Binary files /dev/null and b/proxy/web/src/assets/fonts/Inter-VariableFont_opsz,wght.ttf differ diff --git a/proxy/web/src/assets/netbird-full.svg b/proxy/web/src/assets/netbird-full.svg new file mode 100644 index 000000000..f925d5761 --- /dev/null +++ b/proxy/web/src/assets/netbird-full.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/proxy/web/src/assets/netbird.svg b/proxy/web/src/assets/netbird.svg new file mode 100644 index 000000000..6254931c6 --- /dev/null +++ b/proxy/web/src/assets/netbird.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/proxy/web/src/components/Button.tsx b/proxy/web/src/components/Button.tsx new file mode 100644 index 000000000..aef8496b9 --- /dev/null +++ b/proxy/web/src/components/Button.tsx @@ -0,0 +1,156 @@ +import { cn } from "@/utils/helpers"; +import { forwardRef } from "react"; + +type Variant = + | "default" + | "primary" + | "secondary" + | "secondaryLighter" + | "input" + | "dropdown" + | "dotted" + | "tertiary" + | "white" + | "outline" + | "danger-outline" + | "danger-text" + | "default-outline" + | "danger"; + +type Size = "xs" | "xs2" | "sm" | "md" | "lg"; + +export interface ButtonProps + extends React.ButtonHTMLAttributes { + variant?: Variant; + size?: Size; + rounded?: boolean; + border?: 0 | 1 | 2; + disabled?: boolean; + stopPropagation?: boolean; +} + +const baseStyles = [ + "relative cursor-pointer", + "text-sm focus:z-10 focus:ring-2 font-medium focus:outline-none whitespace-nowrap shadow-sm", + "inline-flex gap-2 items-center justify-center transition-colors focus:ring-offset-1", + "disabled:opacity-40 disabled:cursor-not-allowed disabled:text-nb-gray-300 ring-offset-neutral-950/50", +]; + +const variantStyles: Record = { + default: [ + "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900", + "dark:focus:ring-zinc-800/50 dark:bg-nb-gray dark:text-gray-400 dark:border-gray-700/30 dark:hover:text-white dark:hover:bg-zinc-800/50", + ], + primary: [ + "dark:focus:ring-netbird-600/50 dark:ring-offset-neutral-950/50 enabled:dark:bg-netbird disabled:dark:bg-nb-gray-910 dark:text-gray-100 enabled:dark:hover:text-white enabled:dark:hover:bg-netbird-500/80", + "enabled:bg-netbird enabled:text-white enabled:focus:ring-netbird-400/50 enabled:hover:bg-netbird-500", + ], + secondary: [ + "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:bg-nb-gray-920 dark:text-gray-400 dark:border-gray-700/40 dark:hover:text-white dark:hover:bg-nb-gray-910", + ], + secondaryLighter: [ + "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:bg-nb-gray-900/70 dark:text-gray-400 dark:border-gray-700/70 dark:hover:text-white dark:hover:bg-nb-gray-800/60", + ], + input: [ + "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:bg-nb-gray-900 dark:text-gray-400 dark:border-nb-gray-700 dark:hover:bg-nb-gray-900/80", + ], + dropdown: [ + "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:bg-nb-gray-900/40 dark:text-gray-400 dark:border-nb-gray-900 dark:hover:bg-nb-gray-900/50", + ], + dotted: [ + "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900 border-dashed", + "dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20", + "dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-500/40 dark:hover:text-white dark:hover:bg-zinc-800/50", + ], + tertiary: [ + "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900", + "dark:focus:ring-zinc-800/50 dark:bg-white dark:text-gray-800 dark:border-gray-700/40 dark:hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", + ], + white: [ + "focus:ring-white/50 bg-white text-gray-800 border-white outline-none hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300", + "disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300 disabled:dark:border-nb-gray-900", + ], + outline: [ + "bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900", + "dark:focus:ring-zinc-800/50 dark:bg-transparent dark:text-netbird dark:border-netbird dark:hover:bg-nb-gray-900/30", + ], + "danger-outline": [ + "enabled:dark:focus:ring-red-800/20 enabled:dark:focus:bg-red-950/40 enabled:hover:dark:bg-red-950/50 enabled:dark:hover:border-red-800/50 dark:bg-transparent dark:text-red-500", + ], + "danger-text": [ + "dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50", + ], + "default-outline": [ + "dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20", + "dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-nb-gray-900/30 dark:hover:border-nb-gray-800/50", + "data-[state=open]:dark:text-white data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:border-nb-gray-800/50", + ], + danger: [ + "dark:focus:ring-red-700/20 dark:focus:bg-red-700 hover:dark:bg-red-700 dark:hover:border-red-800/50 dark:bg-red-600 dark:text-red-100", + ], +}; + +const sizeStyles: Record = { + xs: "text-xs py-2 px-4", + xs2: "text-[0.78rem] py-2 px-4", + sm: "text-sm py-2.5 px-4", + md: "text-sm py-2.5 px-4", + lg: "text-base py-2.5 px-4", +}; + +const borderStyles: Record<0 | 1 | 2, string> = { + 0: "border", + 1: "border border-transparent", + 2: "border border-t-0 border-b-0", +}; + +const Button = forwardRef( + ( + { + variant = "default", + rounded = true, + border = 1, + size = "md", + stopPropagation = true, + className, + onClick, + children, + ...props + }, + ref + ) => { + return ( + + ); + } +); + +Button.displayName = "Button"; + +export default Button; diff --git a/proxy/web/src/components/Card.tsx b/proxy/web/src/components/Card.tsx new file mode 100644 index 000000000..ba92274ac --- /dev/null +++ b/proxy/web/src/components/Card.tsx @@ -0,0 +1,23 @@ +import { cn } from "@/utils/helpers"; +import { GradientFadedBackground } from "@/components/GradientFadedBackground"; + +export const Card = ({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) => { + return ( +
                                                + + {children} +
                                                + ); +}; diff --git a/proxy/web/src/components/ConnectionLine.tsx b/proxy/web/src/components/ConnectionLine.tsx new file mode 100644 index 000000000..39080ff6f --- /dev/null +++ b/proxy/web/src/components/ConnectionLine.tsx @@ -0,0 +1,26 @@ +import { X } from "lucide-react"; + +interface ConnectionLineProps { + success?: boolean; +} + +export function ConnectionLine({ success = true }: Readonly) { + if (success) { + return ( +
                                                +
                                                +
                                                + ); + } + + return ( +
                                                +
                                                +
                                                +
                                                + +
                                                +
                                                +
                                                + ); +} diff --git a/proxy/web/src/components/Description.tsx b/proxy/web/src/components/Description.tsx new file mode 100644 index 000000000..60e7ce1cc --- /dev/null +++ b/proxy/web/src/components/Description.tsx @@ -0,0 +1,14 @@ +import { cn } from "@/utils/helpers"; + +type Props = { + children: React.ReactNode; + className?: string; +}; + +export function Description({ children, className }: Readonly) { + return ( +
                                                + {children} +
                                                + ); +} \ No newline at end of file diff --git a/proxy/web/src/components/ErrorMessage.tsx b/proxy/web/src/components/ErrorMessage.tsx new file mode 100644 index 000000000..67a66c20f --- /dev/null +++ b/proxy/web/src/components/ErrorMessage.tsx @@ -0,0 +1,7 @@ +export const ErrorMessage = ({ error }: { error?: string }) => { + return ( +
                                                + {error} +
                                                + ); +}; diff --git a/proxy/web/src/components/GradientFadedBackground.tsx b/proxy/web/src/components/GradientFadedBackground.tsx new file mode 100644 index 000000000..fc0bdc831 --- /dev/null +++ b/proxy/web/src/components/GradientFadedBackground.tsx @@ -0,0 +1,22 @@ +import { cn } from "@/utils/helpers"; + +type Props = { + className?: string; +}; + +export const GradientFadedBackground = ({ className }: Props) => { + return ( +
                                                +
                                                +
                                                + ); +}; diff --git a/proxy/web/src/components/HelpText.tsx b/proxy/web/src/components/HelpText.tsx new file mode 100644 index 000000000..ce71bfa6d --- /dev/null +++ b/proxy/web/src/components/HelpText.tsx @@ -0,0 +1,19 @@ +import { cn } from "@/utils/helpers"; + +interface HelpTextProps { + children?: React.ReactNode; + className?: string; +} + +export default function HelpText({ children, className }: Readonly) { + return ( + + {children} + + ); +} diff --git a/proxy/web/src/components/Input.tsx b/proxy/web/src/components/Input.tsx new file mode 100644 index 000000000..7b880ed00 --- /dev/null +++ b/proxy/web/src/components/Input.tsx @@ -0,0 +1,137 @@ +import { cn } from "@/utils/helpers"; +import { Eye, EyeOff } from "lucide-react"; +import * as React from "react"; +import { useState } from "react"; + +export interface InputProps + extends React.InputHTMLAttributes { + customPrefix?: React.ReactNode; + customSuffix?: React.ReactNode; + maxWidthClass?: string; + icon?: React.ReactNode; + error?: string; + prefixClassName?: string; + showPasswordToggle?: boolean; + variant?: "default" | "darker"; +} + +const variantStyles = { + default: [ + "bg-nb-gray-900 placeholder:text-neutral-400/70 border-nb-gray-700", + "ring-offset-neutral-950/50 focus-visible:ring-neutral-500/20", + ], + darker: [ + "bg-nb-gray-920 placeholder:text-neutral-400/70 border-nb-gray-800", + "ring-offset-neutral-950/50 focus-visible:ring-neutral-500/20", + ], + error: [ + "bg-nb-gray-900 placeholder:text-neutral-400/70 border-red-500 text-red-500", + "ring-offset-red-500/10 focus-visible:ring-red-500/10", + ], +}; + +const prefixSuffixStyles = { + default: "bg-nb-gray-900 border-nb-gray-700 text-nb-gray-300", + error: "bg-nb-gray-900 border-red-500 text-nb-gray-300 text-red-500", +}; + +const Input = React.forwardRef( + ( + { + className, + type, + customSuffix, + customPrefix, + icon, + maxWidthClass = "", + error, + variant = "default", + prefixClassName, + showPasswordToggle = false, + ...props + }, + ref + ) => { + const [showPassword, setShowPassword] = useState(false); + const isPasswordType = type === "password"; + const inputType = isPasswordType && showPassword ? "text" : type; + + const passwordToggle = + isPasswordType && showPasswordToggle ? ( + + ) : null; + + const suffix = passwordToggle || customSuffix; + const activeVariant = error ? "error" : variant; + + return ( + <> +
                                                + {customPrefix && ( +
                                                + {customPrefix} +
                                                + )} + +
                                                + {icon} +
                                                + + + +
                                                + {suffix} +
                                                +
                                                + {error && ( +

                                                {error}

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